PartnerUI evidence, notification, indicators and actuals fixes
This commit is contained in:
@@ -12,7 +12,14 @@ public sealed class CreateOutcomeEndpoint : IEndpoint
|
||||
{
|
||||
public void Map(IEndpointRouteBuilder builder)
|
||||
{
|
||||
builder.MapPost("api/projects/beneficiaries/outcomes", HandleCreateAsync)
|
||||
builder.MapPost("api/projects/beneficiaries/outcomes", async (CreateOutcomeRequest request, HttpContext context,
|
||||
IProjectService service, CancellationToken cancellationToken = default) =>
|
||||
await service.CreateOutcomeAsync(request, context.GetUserId(), cancellationToken) switch
|
||||
{
|
||||
{ IsSuccess: true } => Results.Ok(),
|
||||
var r when r.HasError<CreateError>() => Results.BadRequest(r.Errors.ToProblems()),
|
||||
var r => Results.InternalServerError(r.Errors.ToProblems())
|
||||
})
|
||||
.WithDescription("Add a new beneficiary outcome")
|
||||
.RequireAuthorization(Constants.MainScope)
|
||||
.WithName(typeof(CreateOutcomeEndpoint).ToEndpointName())
|
||||
@@ -22,25 +29,5 @@ public sealed class CreateOutcomeEndpoint : IEndpoint
|
||||
.Produces<Problems>(StatusCodes.Status500InternalServerError)
|
||||
.Produces(StatusCodes.Status401Unauthorized)
|
||||
.WithTags(EndpointTags.Projects);
|
||||
|
||||
builder.MapPost("api/projects/beneficiaris/outcomes", HandleCreateAsync)
|
||||
.WithDescription("Add a new beneficiary outcome (legacy route)")
|
||||
.RequireAuthorization(Constants.MainScope)
|
||||
.WithName(typeof(CreateOutcomeEndpoint).ToEndpointName("TypoAlias"))
|
||||
.MapToApiVersion(new ApiVersion(1))
|
||||
.Produces(StatusCodes.Status200OK)
|
||||
.Produces<Problems>(StatusCodes.Status400BadRequest)
|
||||
.Produces<Problems>(StatusCodes.Status500InternalServerError)
|
||||
.Produces(StatusCodes.Status401Unauthorized)
|
||||
.WithTags(EndpointTags.Projects);
|
||||
}
|
||||
|
||||
private static async Task<IResult> HandleCreateAsync(CreateOutcomeRequest request, HttpContext context,
|
||||
IProjectService service, CancellationToken cancellationToken = default) =>
|
||||
await service.CreateOutcomeAsync(request, context.GetUserId(), cancellationToken) switch
|
||||
{
|
||||
{ IsSuccess: true } => Results.Ok(),
|
||||
var r when r.HasError<CreateError>() => Results.BadRequest(r.Errors.ToProblems()),
|
||||
var r => Results.InternalServerError(r.Errors.ToProblems())
|
||||
};
|
||||
}
|
||||
|
||||
@@ -25,7 +25,6 @@ public interface ICommunicationsService
|
||||
Task<Result> CreateNotificationAsync(CreateNotificationRequest request, Guid userId, CancellationToken cancellationToken = default);
|
||||
|
||||
[Log]
|
||||
[Audit]
|
||||
Task<Result> UpdateNotificationAsync(UpdateNotificationRequest request, CancellationToken cancellationToken = default);
|
||||
|
||||
[Log]
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
Search,
|
||||
@@ -26,6 +27,9 @@ import {
|
||||
Info,
|
||||
Sparkles,
|
||||
Infinity as InfinityIcon,
|
||||
Menu,
|
||||
Reply,
|
||||
Mail,
|
||||
} from 'lucide-react';
|
||||
import { useAuth } from '../../auth/AuthProvider';
|
||||
import { useRbac } from '../../auth/rbac';
|
||||
@@ -60,12 +64,13 @@ import {
|
||||
Notification,
|
||||
NotificationStatus,
|
||||
} from '../../types';
|
||||
import { formatZAR, formatCompactZAR } from '../../lib/formatters';
|
||||
import { formatZAR, formatCompactZAR, getUserEmail } from '../../lib/formatters';
|
||||
import { MessageViewer } from '../notifications/MessageViewer';
|
||||
import { stripMarkdown } from '../../lib/markdownUtils';
|
||||
import { PfmPartnerLogo } from '../common/PfmPartnerLogo';
|
||||
import { ProjectCard, EnrichedPartnerProject } from './ProjectCard';
|
||||
import { EvidenceUploadModal } from './EvidenceUploadModal';
|
||||
import { PartnerMessageComposerModal } from '../notifications/PartnerMessageComposerModal';
|
||||
|
||||
type NavTab = 'dashboard' | 'messages' | 'contracts' | 'analytics' | 'documents';
|
||||
type SortOption = 'date_added' | 'grant_amount' | 'days_left' | 'name';
|
||||
@@ -130,6 +135,7 @@ export function DashboardPage() {
|
||||
const [sortBy, setSortBy] = useState<SortOption>('date_added');
|
||||
const [showBellDropdown, setShowBellDropdown] = useState(false);
|
||||
const [showUserDropdown, setShowUserDropdown] = useState(false);
|
||||
const [showMobileMenu, setShowMobileMenu] = useState(false);
|
||||
|
||||
// In-place expanded project state (Requirement #4: NO modal used for project details)
|
||||
const [expandedProjectIds, setExpandedProjectIds] = useState<Record<string, boolean>>({});
|
||||
@@ -420,6 +426,100 @@ export function DashboardPage() {
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
// Email resolution helpers for messages
|
||||
const getMessageSenderEmail = (notification: Notification | null): string => {
|
||||
if (!notification) {
|
||||
return (
|
||||
partnerSummary.primaryFundingOrg?.email ||
|
||||
(partnerSummary.primaryFundingOrg as any)?.Email ||
|
||||
'support@ifri.khongisa.co.za'
|
||||
);
|
||||
}
|
||||
const dest = notification.destination || '';
|
||||
let rawFrom = '';
|
||||
if (dest.includes('From:')) {
|
||||
rawFrom = dest.split('From:')[1]?.split('|')[0]?.trim().replace(/[<>]/g, '') || '';
|
||||
} else if (!dest.includes('To:') && dest.includes('@')) {
|
||||
rawFrom = dest.trim();
|
||||
}
|
||||
|
||||
if (rawFrom) {
|
||||
const resolved = getUserEmail(rawFrom);
|
||||
if (resolved && resolved.includes('@')) {
|
||||
return resolved;
|
||||
}
|
||||
if (rawFrom.includes('@')) {
|
||||
return rawFrom;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
partnerSummary.primaryFundingOrg?.email ||
|
||||
(partnerSummary.primaryFundingOrg as any)?.Email ||
|
||||
'support@ifri.khongisa.co.za'
|
||||
);
|
||||
};
|
||||
|
||||
const getMessageRecipientEmail = (notification: Notification | null): string => {
|
||||
if (!notification) return user?.profile?.email || (user as any)?.email || 'Partner User';
|
||||
const dest = notification.destination || '';
|
||||
let rawTo = '';
|
||||
if (dest.includes('To:')) {
|
||||
rawTo = dest.split('To:')[1]?.split('|')[0]?.trim().replace(/[<>]/g, '') || '';
|
||||
}
|
||||
|
||||
if (rawTo) {
|
||||
const resolved = getUserEmail(rawTo);
|
||||
if (resolved && resolved.includes('@')) {
|
||||
return resolved;
|
||||
}
|
||||
if (rawTo.includes('@')) {
|
||||
return rawTo;
|
||||
}
|
||||
}
|
||||
|
||||
if (notification.recipient) {
|
||||
const resolvedRecip = getUserEmail(notification.recipient);
|
||||
if (resolvedRecip && resolvedRecip.includes('@')) {
|
||||
return resolvedRecip;
|
||||
}
|
||||
}
|
||||
|
||||
return user?.profile?.email || (user as any)?.email || 'Partner User';
|
||||
};
|
||||
|
||||
const handleReplyToMessage = (notification: Notification) => {
|
||||
const senderEmail = getMessageSenderEmail(notification).trim().toLowerCase();
|
||||
const currentUserEmail = (
|
||||
user?.profile?.email ||
|
||||
(user as any)?.email ||
|
||||
''
|
||||
).trim().toLowerCase();
|
||||
|
||||
if (currentUserEmail && senderEmail && senderEmail === currentUserEmail) {
|
||||
showErrorToast(
|
||||
'Action Not Allowed',
|
||||
'You cannot reply to a message sent from your own email address.'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const replySubject = notification.subject?.startsWith('Re:')
|
||||
? notification.subject
|
||||
: `Re: ${notification.subject || 'Message'}`;
|
||||
|
||||
setContactTarget({
|
||||
organisation: partnerSummary.primaryFundingOrg,
|
||||
projectTitle: replySubject,
|
||||
recipientEmail: senderEmail,
|
||||
recipientId: notification.recipient,
|
||||
});
|
||||
setMessageSubject(replySubject);
|
||||
setMessageBody('');
|
||||
setSelectedNotification(null);
|
||||
};
|
||||
|
||||
// Secure download link handler
|
||||
const handleOpenSecureFile = async (type: 'contract' | 'evidence', id: string | number) => {
|
||||
try {
|
||||
@@ -469,19 +569,22 @@ export function DashboardPage() {
|
||||
return;
|
||||
}
|
||||
|
||||
const recipient =
|
||||
contactTarget?.recipientId ||
|
||||
contactTarget?.recipientEmail ||
|
||||
contactTarget?.organisation?.name ||
|
||||
applicantUserId;
|
||||
const fundingEmail =
|
||||
partnerSummary.primaryFundingOrg?.email ||
|
||||
(partnerSummary.primaryFundingOrg as any)?.Email ||
|
||||
'support@ifri.khongisa.co.za';
|
||||
|
||||
const targetEmail = contactTarget?.recipientEmail || fundingEmail;
|
||||
|
||||
try {
|
||||
await sendMessage({
|
||||
recipient,
|
||||
recipientEmail: targetEmail,
|
||||
recipient: targetEmail,
|
||||
subject: messageSubject.trim(),
|
||||
message: messageBody.trim(),
|
||||
organisationId: partnerSummary.primaryFundingOrg?.id || undefined,
|
||||
});
|
||||
showSuccessToast('Message Sent', 'Your communication was delivered to the programme team.');
|
||||
showSuccessToast('Message Sent', `Your communication was delivered to ${targetEmail}.`);
|
||||
setContactTarget(null);
|
||||
setMessageSubject('');
|
||||
setMessageBody('');
|
||||
@@ -548,6 +651,20 @@ export function DashboardPage() {
|
||||
|
||||
{/* Right: Search, Notifications Bell, User Menu */}
|
||||
<div className="flex items-center gap-3 sm:gap-4">
|
||||
{/* Mobile Navigation Toggle Button (< md) */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setShowMobileMenu(!showMobileMenu);
|
||||
setShowBellDropdown(false);
|
||||
setShowUserDropdown(false);
|
||||
}}
|
||||
className="md:hidden p-2 rounded-xl bg-slate-900/80 border border-white/10 text-slate-300 hover:text-emerald-400 hover:border-emerald-500/30 transition-colors cursor-pointer"
|
||||
title="Toggle Navigation Menu"
|
||||
aria-label="Toggle navigation menu"
|
||||
>
|
||||
{showMobileMenu ? <X className="w-4 h-4 text-emerald-400" /> : <Menu className="w-4 h-4" />}
|
||||
</button>
|
||||
{/* Search Input */}
|
||||
<div className="relative hidden sm:block w-48 md:w-64">
|
||||
<Search className="w-4 h-4 text-slate-400 absolute left-3 top-1/2 -translate-y-1/2 pointer-events-none" />
|
||||
@@ -695,6 +812,37 @@ export function DashboardPage() {
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* =====================================================================
|
||||
MOBILE NAVIGATION DRAWER (< md) (Requirement #6)
|
||||
===================================================================== */}
|
||||
{showMobileMenu && (
|
||||
<div className="md:hidden sticky top-[72px] z-25 w-full bg-slate-950/95 border-b border-white/10 shadow-2xl backdrop-blur-xl px-4 py-3 space-y-1.5 transition-all">
|
||||
{navTabs.map(tab => {
|
||||
const isActive = activeTab === tab.id;
|
||||
return (
|
||||
<button
|
||||
key={tab.id}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setActiveTab(tab.id);
|
||||
setShowMobileMenu(false);
|
||||
}}
|
||||
className={`flex items-center justify-between w-full px-4 py-2.5 rounded-xl text-xs font-semibold transition-all cursor-pointer ${
|
||||
isActive
|
||||
? 'bg-emerald-500/15 text-emerald-400 border border-emerald-500/30 shadow-[0_0_15px_rgba(16,185,129,0.15)]'
|
||||
: 'text-slate-300 hover:text-white hover:bg-white/[0.04]'
|
||||
}`}
|
||||
>
|
||||
<span>{tab.label}</span>
|
||||
{isActive && (
|
||||
<span className="w-2 h-2 rounded-full bg-emerald-400 shadow-[0_0_8px_rgba(16,185,129,0.8)]" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* =====================================================================
|
||||
MAIN DASHBOARD BODY (Max-width 1440px, full-width horizontal layout)
|
||||
(Overview & Inbox sidebar deleted per Requirements #1, #2, #3)
|
||||
@@ -811,9 +959,15 @@ export function DashboardPage() {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const fundingEmail =
|
||||
partnerSummary.primaryFundingOrg?.email ||
|
||||
(partnerSummary.primaryFundingOrg as any)?.Email ||
|
||||
'support@ifri.khongisa.co.za';
|
||||
|
||||
setContactTarget({
|
||||
organisation: partnerSummary.primaryFundingOrg,
|
||||
projectTitle: 'Partner Support',
|
||||
projectTitle: 'General Support Enquiry',
|
||||
recipientEmail: fundingEmail,
|
||||
});
|
||||
setMessageSubject('');
|
||||
setMessageBody('');
|
||||
@@ -858,6 +1012,9 @@ export function DashboardPage() {
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-slate-400 mt-1 line-clamp-2">{stripMarkdown(msg.message || '')}</p>
|
||||
<div className="flex items-center gap-2 mt-1.5 text-[11px] text-slate-500 font-mono">
|
||||
<span>From: <strong className="text-slate-300 font-medium">{getMessageSenderEmail(msg)}</strong></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-xs text-slate-400/70 shrink-0">
|
||||
@@ -1090,105 +1247,99 @@ export function DashboardPage() {
|
||||
)}
|
||||
|
||||
{/* =====================================================================
|
||||
SEND MESSAGE MODAL
|
||||
SEND MESSAGE MODAL (WYSIWYG Composer, strictly In-App, centered to window)
|
||||
(Requirements #1, #2, #8)
|
||||
===================================================================== */}
|
||||
{contactTarget && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-slate-950/80 backdrop-blur-md p-4">
|
||||
<div className="w-full max-w-lg rounded-2xl bg-[#0d1322] border border-white/10 shadow-2xl p-6 space-y-4">
|
||||
<div className="flex items-center justify-between border-b border-white/[0.08] pb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<MessageSquare className="w-5 h-5 text-indigo-400" />
|
||||
<h3 className="text-base font-bold text-white">Send Message</h3>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setContactTarget(null)}
|
||||
className="p-1 rounded-lg text-slate-400 hover:text-white cursor-pointer"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
<PartnerMessageComposerModal
|
||||
isOpen={true}
|
||||
onClose={() => setContactTarget(null)}
|
||||
recipientEmail={
|
||||
contactTarget.recipientEmail ||
|
||||
partnerSummary.primaryFundingOrg?.email ||
|
||||
'support@ifri.khongisa.co.za'
|
||||
}
|
||||
organisationName={
|
||||
contactTarget.organisation?.name ||
|
||||
partnerSummary.primaryFundingOrg?.name ||
|
||||
'Programme Management Office'
|
||||
}
|
||||
initialSubject={
|
||||
messageSubject ||
|
||||
(contactTarget.projectTitle?.startsWith('Re:') ? contactTarget.projectTitle : '')
|
||||
}
|
||||
isSending={isSending}
|
||||
onSend={async (subj, markdown) => {
|
||||
const fundingEmail =
|
||||
partnerSummary.primaryFundingOrg?.email ||
|
||||
(partnerSummary.primaryFundingOrg as any)?.Email ||
|
||||
'support@ifri.khongisa.co.za';
|
||||
|
||||
<form onSubmit={handleSendContactMessage} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-300 mb-1">Recipient</label>
|
||||
<input
|
||||
type="text"
|
||||
disabled
|
||||
value={`${contactTarget.organisation?.name || 'Programme Office'} (${contactTarget.projectTitle || 'Enquiry'})`}
|
||||
className="w-full px-3.5 py-2 rounded-xl bg-slate-900 border border-white/10 text-xs text-slate-300"
|
||||
const targetEmail = contactTarget.recipientEmail || fundingEmail;
|
||||
|
||||
await sendMessage({
|
||||
recipientEmail: targetEmail,
|
||||
recipient: targetEmail,
|
||||
subject: subj,
|
||||
message: markdown,
|
||||
platform: NotificationPlatform.InApp,
|
||||
organisationId: partnerSummary.primaryFundingOrg?.id || undefined,
|
||||
});
|
||||
|
||||
showSuccessToast('Message Sent', `Your communication was delivered to ${targetEmail}.`);
|
||||
setContactTarget(null);
|
||||
setMessageSubject('');
|
||||
setMessageBody('');
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-300 mb-1">Subject</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={messageSubject}
|
||||
onChange={e => setMessageSubject(e.target.value)}
|
||||
placeholder="How can the funding team help you?"
|
||||
className="w-full px-3.5 py-2.5 rounded-xl bg-slate-950 border border-white/10 text-xs text-white focus:outline-none focus:border-indigo-400"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-300 mb-1">Message</label>
|
||||
<textarea
|
||||
rows={4}
|
||||
required
|
||||
value={messageBody}
|
||||
onChange={e => setMessageBody(e.target.value)}
|
||||
placeholder="Write your message here..."
|
||||
className="w-full px-3.5 py-2.5 rounded-xl bg-slate-950 border border-white/10 text-xs text-white focus:outline-none focus:border-indigo-400"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-end gap-3 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setContactTarget(null)}
|
||||
className="px-4 py-2 rounded-xl bg-slate-800 text-slate-300 text-xs font-semibold cursor-pointer"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSending}
|
||||
className="px-5 py-2 rounded-xl bg-indigo-500 hover:bg-indigo-400 text-white font-semibold text-xs flex items-center gap-2 cursor-pointer disabled:opacity-50"
|
||||
>
|
||||
<Send className="w-3.5 h-3.5" />
|
||||
<span>{isSending ? 'Sending...' : 'Send Message'}</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* =====================================================================
|
||||
MESSAGE READER MODAL
|
||||
MESSAGE READER MODAL (Centered to Window via Portal)
|
||||
(Requirements #1, #2, #3, #4, #8)
|
||||
===================================================================== */}
|
||||
{selectedNotification && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-slate-950/80 backdrop-blur-md p-4 animate-in fade-in duration-200">
|
||||
<div className="w-full max-w-lg rounded-2xl bg-[#0d1322] border border-white/10 shadow-2xl p-6 space-y-4">
|
||||
<div className="flex items-center justify-between border-b border-white/[0.08] pb-3">
|
||||
<h3 className="text-base font-bold text-white truncate max-w-[360px]">
|
||||
{selectedNotification &&
|
||||
typeof document !== 'undefined' &&
|
||||
createPortal(
|
||||
<div className="fixed inset-0 z-[9999] flex items-center justify-center bg-slate-950/85 backdrop-blur-md p-4 sm:p-6 overflow-y-auto animate-in fade-in duration-200">
|
||||
<div className="w-full max-w-3xl sm:max-w-4xl rounded-3xl bg-slate-900 border border-white/10 shadow-2xl p-6 sm:p-8 space-y-5 my-auto text-slate-100">
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between border-b border-white/[0.08] pb-4 gap-4">
|
||||
<div className="space-y-1.5 min-w-0">
|
||||
<h3 className="text-lg sm:text-xl font-bold text-slate-100 truncate">
|
||||
{selectedNotification.subject || 'Message Details'}
|
||||
</h3>
|
||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-slate-400">
|
||||
<div>
|
||||
From:{' '}
|
||||
<span className="font-mono text-emerald-400 font-semibold">
|
||||
{getMessageSenderEmail(selectedNotification)}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
To:{' '}
|
||||
<span className="font-mono text-slate-300">
|
||||
{getMessageRecipientEmail(selectedNotification)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-slate-500">
|
||||
{formatFullDate(selectedNotification.createdAt)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSelectedNotification(null)}
|
||||
className="p-1 rounded-lg text-slate-400 hover:text-white cursor-pointer"
|
||||
className="p-1.5 rounded-xl text-slate-400 hover:text-white hover:bg-white/[0.06] transition-colors cursor-pointer shrink-0"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 text-xs">
|
||||
<div className="flex items-center justify-between text-slate-400">
|
||||
<span>From: {selectedNotification.destination?.split('|')[0] || 'Programme Management Office'}</span>
|
||||
<span>{formatFullDate(selectedNotification.createdAt)}</span>
|
||||
</div>
|
||||
<div className="p-4 rounded-xl bg-slate-950/60 border border-white/5 text-slate-200 whitespace-pre-wrap leading-relaxed max-h-80 overflow-y-auto">
|
||||
{/* Message Content Viewer */}
|
||||
<div className="p-5 sm:p-6 rounded-2xl bg-slate-950/80 border border-white/5 text-slate-200 leading-relaxed min-h-[180px] max-h-[60vh] overflow-y-auto">
|
||||
<MessageViewer
|
||||
content={selectedNotification.message || ''}
|
||||
message={selectedNotification.message || ''}
|
||||
subject={selectedNotification.subject || undefined}
|
||||
createdDate={selectedNotification.createdAt}
|
||||
@@ -1196,19 +1347,28 @@ export function DashboardPage() {
|
||||
variant="card"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end pt-2">
|
||||
{/* Action Buttons: Reply and Close */}
|
||||
<div className="flex items-center justify-between pt-2 border-t border-white/[0.08]">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleReplyToMessage(selectedNotification)}
|
||||
className="px-5 py-2.5 rounded-xl bg-emerald-600 hover:bg-emerald-500 text-white text-xs font-semibold flex items-center gap-2 shadow-lg shadow-emerald-500/20 transition-all cursor-pointer"
|
||||
>
|
||||
<Reply className="w-3.5 h-3.5" />
|
||||
<span>Reply</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSelectedNotification(null)}
|
||||
className="px-4 py-2 rounded-xl bg-slate-800 hover:bg-slate-700 text-slate-200 text-xs font-semibold cursor-pointer"
|
||||
className="px-5 py-2.5 rounded-xl bg-slate-800 hover:bg-slate-700 text-slate-200 text-xs font-semibold transition-colors cursor-pointer"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
import React, { useState } from 'react';
|
||||
import { uploadEvidenceFile, extractApiErrors, toIntegerId } from '../../services/api';
|
||||
import { Activity, Milestone, Project, EvidenceType } from '../../services/api/models';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import {
|
||||
uploadEvidenceFile,
|
||||
extractApiErrors,
|
||||
toIntegerId,
|
||||
getProjectIndicators,
|
||||
recordIndicatorActual,
|
||||
} from '../../services/api';
|
||||
import { Activity, Milestone, Project, Indicator } from '../../services/api/models';
|
||||
import { EVIDENCE_TYPE_OPTIONS } from '../../lib/formatters';
|
||||
import {
|
||||
X,
|
||||
@@ -11,7 +19,8 @@ import {
|
||||
Loader2,
|
||||
Plus,
|
||||
Sparkles,
|
||||
Link as LinkIcon
|
||||
Target,
|
||||
BarChart3,
|
||||
} from 'lucide-react';
|
||||
|
||||
interface EvidenceUploadModalProps {
|
||||
@@ -35,11 +44,43 @@ export const EvidenceUploadModal: React.FC<EvidenceUploadModalProps> = ({
|
||||
const [title, setTitle] = useState('');
|
||||
const [evidenceType, setEvidenceType] = useState<number>(4); // Default: FinancialInvoiceOrReceipt
|
||||
const [description, setDescription] = useState('');
|
||||
const [selectedIndicatorId, setSelectedIndicatorId] = useState<string>('');
|
||||
const [actualAmount, setActualAmount] = useState<string>('');
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [errorMessages, setErrorMessages] = useState<string[]>([]);
|
||||
const [successMessage, setSuccessMessage] = useState<string | null>(null);
|
||||
|
||||
if (!isOpen) return null;
|
||||
const projectId = project?.id || 0;
|
||||
|
||||
// Requirement #4: Fetch linked indicators for project & activity
|
||||
const { data: projectIndicators = [], isLoading: isLoadingIndicators } = useQuery<Indicator[]>({
|
||||
queryKey: ['evidence-project-indicators', String(projectId)],
|
||||
queryFn: () => getProjectIndicators(projectId),
|
||||
enabled: isOpen && Boolean(projectId),
|
||||
});
|
||||
|
||||
// Prioritize indicators directly linked to this activity, then project indicators
|
||||
const availableIndicators = React.useMemo(() => {
|
||||
if (!projectIndicators || projectIndicators.length === 0) return [];
|
||||
const directMatches = projectIndicators.filter(
|
||||
ind => ind.activityId && String(ind.activityId) === String(activity.id)
|
||||
);
|
||||
if (directMatches.length > 0) return directMatches;
|
||||
return projectIndicators;
|
||||
}, [projectIndicators, activity.id]);
|
||||
|
||||
// Auto-select first indicator if available
|
||||
useEffect(() => {
|
||||
if (availableIndicators.length > 0 && !selectedIndicatorId) {
|
||||
const firstId = String(availableIndicators[0].id);
|
||||
setSelectedIndicatorId(firstId);
|
||||
if (availableIndicators[0].actualAmount !== undefined) {
|
||||
setActualAmount(String(availableIndicators[0].actualAmount || ''));
|
||||
}
|
||||
}
|
||||
}, [availableIndicators, selectedIndicatorId]);
|
||||
|
||||
if (!isOpen || typeof document === 'undefined') return null;
|
||||
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (e.target.files && e.target.files[0]) {
|
||||
@@ -51,6 +92,10 @@ export const EvidenceUploadModal: React.FC<EvidenceUploadModalProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
const selectedIndicatorObj = availableIndicators.find(
|
||||
i => String(i.id) === String(selectedIndicatorId)
|
||||
);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setErrorMessages([]);
|
||||
@@ -64,6 +109,22 @@ export const EvidenceUploadModal: React.FC<EvidenceUploadModalProps> = ({
|
||||
const numProjectId = toIntegerId(project.id);
|
||||
const numActivityId = toIntegerId(activity.id);
|
||||
const numMilestoneId = toIntegerId(milestone?.id || activity.milestoneId || 0);
|
||||
const numIndicatorId = toIntegerId(selectedIndicatorId);
|
||||
|
||||
// Requirement #4: IndicatorId must not be 0 to maintain referential integrity in database
|
||||
if (!numIndicatorId || numIndicatorId <= 0) {
|
||||
setErrorMessages([
|
||||
'A valid linked Indicator is mandatory for evidence verification. Please select an indicator from the list.',
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!actualAmount.trim() || isNaN(Number(actualAmount))) {
|
||||
setErrorMessages([
|
||||
'Recording an Actual amount/metric against the selected indicator is mandatory. Please provide a numeric value.',
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!numProjectId || !numActivityId) {
|
||||
setErrorMessages(['Cannot upload evidence: Missing projectId or activityId reference.']);
|
||||
@@ -73,22 +134,34 @@ export const EvidenceUploadModal: React.FC<EvidenceUploadModalProps> = ({
|
||||
setIsSubmitting(true);
|
||||
|
||||
try {
|
||||
// Build multipart/form-data adhering strictly to EvidenceService.CreateEvidenceAsync
|
||||
// 1. Build multipart/form-data adhering strictly to EvidenceService.CreateEvidenceAsync
|
||||
// Requirement #7: When uploading, IForm does not need the userId field in it; API detects userId from JWT sub
|
||||
const formData = new FormData();
|
||||
formData.append('file', selectedFile);
|
||||
formData.append('projectId', String(numProjectId));
|
||||
formData.append('activityId', String(numActivityId));
|
||||
formData.append('milestoneId', String(numMilestoneId));
|
||||
formData.append('indicatorId', '0');
|
||||
formData.append('indicatorId', String(numIndicatorId));
|
||||
formData.append('type', String(Number(evidenceType) || 4));
|
||||
formData.append('title', title.trim() || selectedFile.name);
|
||||
if (description.trim()) {
|
||||
formData.append('description', description.trim());
|
||||
}
|
||||
|
||||
// 2. Upload evidence file
|
||||
await uploadEvidenceFile(formData);
|
||||
|
||||
setSuccessMessage('Evidence successfully uploaded and linked to activity!');
|
||||
// 3. Requirement #4: Record Actual against linked indicator
|
||||
const parsedActual = Number(actualAmount);
|
||||
if (!isNaN(parsedActual)) {
|
||||
try {
|
||||
await recordIndicatorActual(numIndicatorId, parsedActual);
|
||||
} catch (indErr) {
|
||||
console.warn('[Record Indicator Actual Warning]:', indErr);
|
||||
}
|
||||
}
|
||||
|
||||
setSuccessMessage('Evidence successfully uploaded and linked to activity with recorded indicator actual!');
|
||||
setTimeout(() => {
|
||||
onEvidenceUploaded();
|
||||
onClose();
|
||||
@@ -96,15 +169,17 @@ export const EvidenceUploadModal: React.FC<EvidenceUploadModalProps> = ({
|
||||
} catch (err: any) {
|
||||
console.error('[Evidence Upload Error]:', err);
|
||||
const errors = extractApiErrors(err);
|
||||
setErrorMessages(errors.length > 0 ? errors : ['Failed to upload evidence file. Please try again.']);
|
||||
setErrorMessages(
|
||||
errors.length > 0 ? errors : ['Failed to upload evidence file. Please verify parameters and try again.']
|
||||
);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-slate-950/80 backdrop-blur-md animate-in fade-in duration-200">
|
||||
<div className="relative w-full max-w-lg rounded-3xl border border-white/15 bg-slate-900/95 p-6 shadow-2xl backdrop-blur-2xl text-slate-100 overflow-hidden">
|
||||
return createPortal(
|
||||
<div className="fixed inset-0 z-[9999] flex items-center justify-center p-4 bg-slate-950/80 backdrop-blur-md animate-in fade-in duration-200 overflow-y-auto">
|
||||
<div className="relative w-full max-w-lg rounded-3xl border border-white/15 bg-slate-900/95 p-6 shadow-2xl backdrop-blur-2xl text-slate-100 overflow-hidden my-auto">
|
||||
{/* Ambient background glow */}
|
||||
<div className="absolute top-0 right-0 -mr-16 -mt-16 w-60 h-60 bg-emerald-500/10 rounded-full blur-3xl pointer-events-none" />
|
||||
|
||||
@@ -192,13 +267,78 @@ export const EvidenceUploadModal: React.FC<EvidenceUploadModalProps> = ({
|
||||
) : (
|
||||
<div>
|
||||
<span className="font-medium text-slate-300">Click to choose document</span>
|
||||
<p className="text-[11px] text-slate-500 mt-0.5">PDF, DOCX, XLSX, PNG, JPG (Multiple uploads supported)</p>
|
||||
<p className="text-[11px] text-slate-500 mt-0.5">
|
||||
PDF, DOCX, XLSX, PNG, JPG (Multiple uploads supported)
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Requirement #4: Mandatory Indicator Selection & Actual Amount Recording */}
|
||||
<div className="p-3.5 rounded-2xl bg-slate-950/80 border border-emerald-500/30 space-y-3">
|
||||
<div className="flex items-center gap-2 text-emerald-400 font-semibold text-xs">
|
||||
<Target className="w-4 h-4" />
|
||||
<span>Linked Indicator & Performance Actual (Mandatory)</span>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-slate-300 font-medium mb-1">
|
||||
Select Linked Indicator <span className="text-emerald-400">*</span>
|
||||
</label>
|
||||
{isLoadingIndicators ? (
|
||||
<div className="text-[11px] text-slate-400 py-1">Loading indicators...</div>
|
||||
) : availableIndicators.length === 0 ? (
|
||||
<div className="text-[11px] text-amber-300 bg-amber-950/40 border border-amber-500/30 p-2.5 rounded-xl">
|
||||
No indicators currently recorded for this project. Please contact programme management or select standard output.
|
||||
</div>
|
||||
) : (
|
||||
<select
|
||||
required
|
||||
value={selectedIndicatorId}
|
||||
onChange={e => setSelectedIndicatorId(e.target.value)}
|
||||
className="w-full px-3 py-2 rounded-xl bg-slate-900 border border-white/10 text-slate-100 focus:outline-none focus:border-emerald-500/50 cursor-pointer"
|
||||
>
|
||||
<option value="">-- Choose Indicator for this Deliverable --</option>
|
||||
{availableIndicators.map(ind => (
|
||||
<option key={ind.id} value={ind.id} className="bg-slate-900 text-slate-100">
|
||||
{ind.name || `Indicator #${ind.id}`} (Target: {ind.targetAmount} • Baseline: {ind.baselineAmount})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-slate-300 font-medium mb-1">
|
||||
Actual Amount / Metric Recorded <span className="text-emerald-400">*</span>
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type="number"
|
||||
step="any"
|
||||
min="0"
|
||||
required
|
||||
placeholder={
|
||||
selectedIndicatorObj
|
||||
? `Target is ${selectedIndicatorObj.targetAmount}`
|
||||
: 'e.g. 10 or 1500'
|
||||
}
|
||||
value={actualAmount}
|
||||
onChange={e => setActualAmount(e.target.value)}
|
||||
className="w-full px-3.5 py-2 rounded-xl bg-slate-900 border border-white/10 text-slate-100 placeholder:text-slate-500 focus:outline-none focus:border-emerald-500/50"
|
||||
/>
|
||||
<BarChart3 className="w-4 h-4 text-emerald-400 absolute right-3 top-1/2 -translate-y-1/2 pointer-events-none" />
|
||||
</div>
|
||||
{selectedIndicatorObj && (
|
||||
<p className="text-[11px] text-slate-400 mt-1">
|
||||
Baseline: {selectedIndicatorObj.baselineAmount} • Target: {selectedIndicatorObj.targetAmount} • Current Actual: {selectedIndicatorObj.actualAmount}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Evidence Title & Type */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<div>
|
||||
@@ -207,7 +347,7 @@ export const EvidenceUploadModal: React.FC<EvidenceUploadModalProps> = ({
|
||||
type="text"
|
||||
placeholder="e.g. Activity Verification Proof"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
onChange={e => setTitle(e.target.value)}
|
||||
className="w-full px-3 py-2 rounded-xl bg-slate-950/70 border border-white/10 text-slate-200 placeholder:text-slate-500 focus:outline-none focus:border-emerald-500/50"
|
||||
/>
|
||||
</div>
|
||||
@@ -215,10 +355,10 @@ export const EvidenceUploadModal: React.FC<EvidenceUploadModalProps> = ({
|
||||
<label className="block text-slate-300 font-medium mb-1">Evidence Type</label>
|
||||
<select
|
||||
value={evidenceType}
|
||||
onChange={(e) => setEvidenceType(Number(e.target.value))}
|
||||
onChange={e => setEvidenceType(Number(e.target.value))}
|
||||
className="w-full px-3 py-2 rounded-xl bg-slate-950/70 border border-white/10 text-slate-200 focus:outline-none focus:border-emerald-500/50 cursor-pointer"
|
||||
>
|
||||
{EVIDENCE_TYPE_OPTIONS.map((opt) => (
|
||||
{EVIDENCE_TYPE_OPTIONS.map(opt => (
|
||||
<option key={opt.value} value={opt.value} className="bg-slate-900 text-slate-100">
|
||||
{opt.label}
|
||||
</option>
|
||||
@@ -234,7 +374,7 @@ export const EvidenceUploadModal: React.FC<EvidenceUploadModalProps> = ({
|
||||
rows={2}
|
||||
placeholder="Provide verification notes or audit references..."
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
onChange={e => setDescription(e.target.value)}
|
||||
className="w-full px-3 py-2 rounded-xl bg-slate-950/70 border border-white/10 text-slate-200 placeholder:text-slate-500 focus:outline-none focus:border-emerald-500/50 resize-none"
|
||||
/>
|
||||
</div>
|
||||
@@ -262,13 +402,14 @@ export const EvidenceUploadModal: React.FC<EvidenceUploadModalProps> = ({
|
||||
) : (
|
||||
<>
|
||||
<Plus className="w-4 h-4" />
|
||||
<span>Upload & Attach Evidence</span>
|
||||
<span>Upload & Record Actual</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,333 @@
|
||||
import React, { useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import {
|
||||
X,
|
||||
ShieldAlert,
|
||||
ShieldCheck,
|
||||
AlertTriangle,
|
||||
Scale,
|
||||
FileCheck,
|
||||
Info,
|
||||
Calendar,
|
||||
User,
|
||||
CheckCircle2,
|
||||
Clock,
|
||||
Layers,
|
||||
Sparkles,
|
||||
} from 'lucide-react';
|
||||
import { Project, Milestone, Activity, ComplianceItem, Risk, Rule, ComplianceStatus } from '../../services/api/models';
|
||||
import { getProjectComplianceItems, getProjectRisks, getRules } from '../../services/api';
|
||||
import { formatDate } from '../../lib/formatters';
|
||||
|
||||
interface GovernanceInfoModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
project: Project;
|
||||
milestone?: Milestone | null;
|
||||
activity?: Activity | null;
|
||||
initialTab?: 'compliance' | 'risks' | 'rules';
|
||||
}
|
||||
|
||||
export const GovernanceInfoModal: React.FC<GovernanceInfoModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
project,
|
||||
milestone,
|
||||
activity,
|
||||
initialTab = 'compliance',
|
||||
}) => {
|
||||
const [activeTab, setActiveTab] = useState<'compliance' | 'risks' | 'rules'>(initialTab);
|
||||
|
||||
const projectId = project?.id || 0;
|
||||
|
||||
// 1. Fetch compliance items for project
|
||||
const { data: complianceItems = [], isLoading: isLoadingCompliance } = useQuery<ComplianceItem[]>({
|
||||
queryKey: ['governance-compliance', String(projectId)],
|
||||
queryFn: () => getProjectComplianceItems(projectId),
|
||||
enabled: isOpen && Boolean(projectId),
|
||||
});
|
||||
|
||||
// 2. Fetch risks for project
|
||||
const { data: projectRisks = [], isLoading: isLoadingRisks } = useQuery<Risk[]>({
|
||||
queryKey: ['governance-risks', String(projectId)],
|
||||
queryFn: () => getProjectRisks(projectId),
|
||||
enabled: isOpen && Boolean(projectId),
|
||||
});
|
||||
|
||||
// 3. Fetch rules
|
||||
const { data: governanceRules = [], isLoading: isLoadingRules } = useQuery<Rule[]>({
|
||||
queryKey: ['governance-rules'],
|
||||
queryFn: () => getRules(1, 100),
|
||||
enabled: isOpen,
|
||||
});
|
||||
|
||||
if (!isOpen || typeof document === 'undefined') return null;
|
||||
|
||||
const renderComplianceStatus = (status?: number) => {
|
||||
switch (status) {
|
||||
case ComplianceStatus.Compliant:
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-[10px] font-bold bg-emerald-500/15 text-emerald-300 border border-emerald-500/30">
|
||||
<CheckCircle2 className="w-3 h-3 text-emerald-400" />
|
||||
<span>Compliant</span>
|
||||
</span>
|
||||
);
|
||||
case ComplianceStatus.UnderReview:
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-[10px] font-bold bg-amber-500/15 text-amber-300 border border-amber-500/30">
|
||||
<Clock className="w-3 h-3 text-amber-400" />
|
||||
<span>Under Review</span>
|
||||
</span>
|
||||
);
|
||||
case ComplianceStatus.NonCompliant:
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-[10px] font-bold bg-rose-500/15 text-rose-300 border border-rose-500/30">
|
||||
<AlertTriangle className="w-3 h-3 text-rose-400" />
|
||||
<span>Non-Compliant</span>
|
||||
</span>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-[10px] font-bold bg-slate-800 text-slate-300 border border-white/10">
|
||||
<span>Pending Submission</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return createPortal(
|
||||
<div className="fixed inset-0 z-[9999] flex items-center justify-center p-4 sm:p-6 bg-slate-950/85 backdrop-blur-md animate-in fade-in duration-200 overflow-y-auto">
|
||||
<div className="relative w-full max-w-4xl rounded-3xl border border-white/10 bg-slate-900 shadow-2xl p-6 sm:p-8 space-y-6 my-auto text-slate-100 max-h-[90vh] flex flex-col">
|
||||
{/* Modal Header */}
|
||||
<div className="flex items-start justify-between border-b border-white/[0.08] pb-4 shrink-0">
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-md text-[10px] font-mono font-bold bg-emerald-500/15 text-emerald-300 border border-emerald-500/30">
|
||||
<ShieldAlert className="w-3 h-3 text-emerald-400" />
|
||||
<span>Governance & Compliance Guidance</span>
|
||||
</span>
|
||||
{activity ? (
|
||||
<span className="text-[11px] text-slate-400 font-mono">Activity: {activity.title}</span>
|
||||
) : milestone ? (
|
||||
<span className="text-[11px] text-slate-400 font-mono">Milestone: {milestone.name}</span>
|
||||
) : (
|
||||
<span className="text-[11px] text-slate-400 font-mono">Project: {project.name}</span>
|
||||
)}
|
||||
</div>
|
||||
<h3 className="text-lg sm:text-xl font-bold text-white tracking-tight">
|
||||
Compliance Items, Risks & Institutional Rules
|
||||
</h3>
|
||||
<p className="text-xs text-slate-400">
|
||||
Contractual obligations, risk mitigations, and execution requirements for grant fulfillment.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="p-1.5 rounded-xl text-slate-400 hover:text-white hover:bg-white/[0.06] transition-colors cursor-pointer shrink-0"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Tab Navigation */}
|
||||
<div className="flex items-center gap-2 border-b border-white/[0.08] pb-3 shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveTab('compliance')}
|
||||
className={`flex items-center gap-2 px-4 py-2 rounded-xl text-xs font-semibold transition-all cursor-pointer ${
|
||||
activeTab === 'compliance'
|
||||
? 'bg-emerald-500/20 text-emerald-300 border border-emerald-500/40 shadow-sm'
|
||||
: 'text-slate-400 hover:text-white hover:bg-white/[0.04]'
|
||||
}`}
|
||||
>
|
||||
<FileCheck className="w-4 h-4" />
|
||||
<span>Compliance Items ({complianceItems.length})</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveTab('risks')}
|
||||
className={`flex items-center gap-2 px-4 py-2 rounded-xl text-xs font-semibold transition-all cursor-pointer ${
|
||||
activeTab === 'risks'
|
||||
? 'bg-amber-500/20 text-amber-300 border border-amber-500/40 shadow-sm'
|
||||
: 'text-slate-400 hover:text-white hover:bg-white/[0.04]'
|
||||
}`}
|
||||
>
|
||||
<AlertTriangle className="w-4 h-4" />
|
||||
<span>Risk Register ({projectRisks.length})</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveTab('rules')}
|
||||
className={`flex items-center gap-2 px-4 py-2 rounded-xl text-xs font-semibold transition-all cursor-pointer ${
|
||||
activeTab === 'rules'
|
||||
? 'bg-teal-500/20 text-teal-300 border border-teal-500/40 shadow-sm'
|
||||
: 'text-slate-400 hover:text-white hover:bg-white/[0.04]'
|
||||
}`}
|
||||
>
|
||||
<Scale className="w-4 h-4" />
|
||||
<span>Governance Rules ({governanceRules.length})</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Tab Content Panels */}
|
||||
<div className="flex-1 overflow-y-auto space-y-4 pr-1 [scrollbar-width:thin] [scrollbar-color:#334155_transparent]">
|
||||
{/* TAB 1: COMPLIANCE ITEMS */}
|
||||
{activeTab === 'compliance' && (
|
||||
<div className="space-y-3">
|
||||
{isLoadingCompliance ? (
|
||||
<div className="p-8 text-center text-xs text-slate-400">Loading compliance requirements...</div>
|
||||
) : complianceItems.length === 0 ? (
|
||||
<div className="p-8 rounded-2xl bg-slate-950/60 border border-white/5 text-center text-xs text-slate-400 space-y-2">
|
||||
<FileCheck className="w-8 h-8 text-slate-500 mx-auto" />
|
||||
<p>No specific compliance items registered for this project.</p>
|
||||
<p className="text-[11px] text-slate-500">
|
||||
Standard grant statutory terms apply as per your signed funding agreement.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
complianceItems.map((item, idx) => (
|
||||
<div
|
||||
key={item.id || idx}
|
||||
className="p-4 rounded-2xl bg-slate-950/70 border border-white/[0.08] space-y-2.5"
|
||||
>
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="font-semibold text-sm text-slate-100">
|
||||
{item.title || `Compliance Item #${idx + 1}`}
|
||||
</span>
|
||||
{renderComplianceStatus(item.status)}
|
||||
</div>
|
||||
{item.dueDate && (
|
||||
<div className="text-[11px] text-slate-400 flex items-center gap-1 font-mono">
|
||||
<Calendar className="w-3.5 h-3.5 text-slate-500" />
|
||||
<span>Due: {formatDate(item.dueDate)}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{item.description && (
|
||||
<p className="text-xs text-slate-300 leading-relaxed">{item.description}</p>
|
||||
)}
|
||||
{item.notes && (
|
||||
<div className="text-[11px] text-emerald-400/90 bg-emerald-950/40 border border-emerald-500/20 p-2.5 rounded-xl font-mono">
|
||||
Note: {item.notes}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* TAB 2: RISK REGISTER */}
|
||||
{activeTab === 'risks' && (
|
||||
<div className="space-y-3">
|
||||
{isLoadingRisks ? (
|
||||
<div className="p-8 text-center text-xs text-slate-400">Loading risk register...</div>
|
||||
) : projectRisks.length === 0 ? (
|
||||
<div className="p-8 rounded-2xl bg-slate-950/60 border border-white/5 text-center text-xs text-slate-400 space-y-2">
|
||||
<ShieldCheck className="w-8 h-8 text-emerald-500 mx-auto" />
|
||||
<p>No high-severity risks recorded for this project.</p>
|
||||
<p className="text-[11px] text-slate-500">
|
||||
Maintain milestone delivery cadence to preserve low-risk standing.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
projectRisks.map((risk, idx) => (
|
||||
<div
|
||||
key={risk.id || idx}
|
||||
className="p-4 rounded-2xl bg-slate-950/70 border border-white/[0.08] space-y-2.5"
|
||||
>
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-2">
|
||||
<h4 className="font-semibold text-sm text-slate-100">
|
||||
{risk.title || `Risk Item #${idx + 1}`}
|
||||
</h4>
|
||||
<div className="flex items-center gap-2 text-[10px] font-mono">
|
||||
<span className="px-2 py-0.5 rounded-md bg-amber-500/15 text-amber-300 border border-amber-500/30">
|
||||
Likelihood: {risk.likelihood ?? 'Medium'}
|
||||
</span>
|
||||
<span className="px-2 py-0.5 rounded-md bg-rose-500/15 text-rose-300 border border-rose-500/30">
|
||||
Impact: {risk.impact ?? 'Moderate'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{risk.description && (
|
||||
<p className="text-xs text-slate-300 leading-relaxed">{risk.description}</p>
|
||||
)}
|
||||
{risk.mitigation && (
|
||||
<div className="text-[11px] text-teal-300 bg-teal-950/40 border border-teal-500/20 p-2.5 rounded-xl">
|
||||
<strong>Mitigation Strategy:</strong> {risk.mitigation}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* TAB 3: GOVERNANCE & RULES */}
|
||||
{activeTab === 'rules' && (
|
||||
<div className="space-y-3">
|
||||
{isLoadingRules ? (
|
||||
<div className="p-8 text-center text-xs text-slate-400">Loading institutional rules...</div>
|
||||
) : governanceRules.length === 0 ? (
|
||||
<div className="p-8 rounded-2xl bg-slate-950/60 border border-white/5 text-center text-xs text-slate-400 space-y-2">
|
||||
<Scale className="w-8 h-8 text-slate-500 mx-auto" />
|
||||
<p>Standard PFM Funding Portfolio Governance Rules apply.</p>
|
||||
<p className="text-[11px] text-slate-500">
|
||||
All disbursements gate against verified milestone deliverables and evidence.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
governanceRules.map((rule, idx) => (
|
||||
<div
|
||||
key={rule.id || idx}
|
||||
className="p-4 rounded-2xl bg-slate-950/70 border border-white/[0.08] space-y-2"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<h4 className="font-semibold text-sm text-slate-100">
|
||||
{rule.name || `Rule #${idx + 1}`}
|
||||
</h4>
|
||||
{rule.category && (
|
||||
<span className="text-[10px] font-mono px-2 py-0.5 rounded-md bg-slate-800 text-slate-300 border border-white/10">
|
||||
{rule.category}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{rule.description && (
|
||||
<p className="text-xs text-slate-300 leading-relaxed">{rule.description}</p>
|
||||
)}
|
||||
{rule.condition && (
|
||||
<div className="text-[11px] text-slate-400 bg-slate-900 border border-white/5 p-2 rounded-xl font-mono">
|
||||
Condition: {rule.condition}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Modal Footer */}
|
||||
<div className="flex items-center justify-between pt-3 border-t border-white/[0.08] shrink-0">
|
||||
<span className="text-[11px] text-slate-500 font-mono">
|
||||
PFM Governance Architecture • Clean Audit Policy
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="px-5 py-2 rounded-xl bg-slate-800 hover:bg-slate-700 text-slate-200 text-xs font-semibold transition-colors cursor-pointer"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
};
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
Organisation,
|
||||
ApprovalStatus
|
||||
} from '../../services/api/models';
|
||||
import { encodeHashIdSync } from '../../services/toolsService';
|
||||
import { encodeHashIdSync, decodeHashIdSync } from '../../services/toolsService';
|
||||
import {
|
||||
toIntegerId,
|
||||
getMilestones,
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
getProjectTypeLabel
|
||||
} from '../../lib/formatters';
|
||||
import { EvidenceUploadModal } from './EvidenceUploadModal';
|
||||
import { GovernanceInfoModal } from './GovernanceInfoModal';
|
||||
import {
|
||||
FolderKanban,
|
||||
ChevronDown,
|
||||
@@ -87,6 +88,13 @@ export const ProjectCard: React.FC<ProjectCardProps> = ({
|
||||
activity: Activity;
|
||||
} | null>(null);
|
||||
|
||||
const [governanceTarget, setGovernanceTarget] = useState<{
|
||||
project: Project;
|
||||
milestone?: Milestone | null;
|
||||
activity?: Activity | null;
|
||||
initialTab?: 'compliance' | 'risks' | 'rules';
|
||||
} | null>(null);
|
||||
|
||||
const { project, award, contract, fundingOrganisation, invitedOrganisation, disbursements } = item;
|
||||
const hashedProjectId = encodeHashIdSync(project.id);
|
||||
|
||||
@@ -136,7 +144,7 @@ export const ProjectCard: React.FC<ProjectCardProps> = ({
|
||||
};
|
||||
|
||||
// Status Badge Helper using Landing Page emerald/teal/amber color palette
|
||||
const renderStatusBadge = (status?: any, fallback = 'Active') => {
|
||||
const renderStatusBadge = (status?: any, fallback = 'In Progress') => {
|
||||
const num = Number(status);
|
||||
if (num === ApprovalStatus.Approved || String(status).toLowerCase().includes('approved')) {
|
||||
return (
|
||||
@@ -154,10 +162,19 @@ export const ProjectCard: React.FC<ProjectCardProps> = ({
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (num === ApprovalStatus.Pending || String(status).toLowerCase().includes('pending')) {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-[11px] font-semibold bg-teal-950/80 border border-teal-500/40 text-teal-300 shadow-sm shadow-teal-950/50">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-teal-400 animate-pulse" />
|
||||
<span>{String(status || fallback)}</span>
|
||||
<span>In Progress</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
const label = (status !== undefined && status !== null && !isNaN(num)) ? fallback : String(status || fallback);
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-[11px] font-semibold bg-teal-950/80 border border-teal-500/40 text-teal-300 shadow-sm shadow-teal-950/50">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-teal-400 animate-pulse" />
|
||||
<span>{label}</span>
|
||||
</span>
|
||||
);
|
||||
};
|
||||
@@ -279,7 +296,30 @@ export const ProjectCard: React.FC<ProjectCardProps> = ({
|
||||
#{hashedProjectId}
|
||||
</span>
|
||||
)}
|
||||
{renderStatusBadge(project.status, 'Active Implementation')}
|
||||
{hasOverdueMilestone ? (
|
||||
<span className="inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-[11px] font-bold bg-rose-950/90 border border-rose-500/50 text-rose-300 shadow-sm shadow-rose-950/50">
|
||||
<AlertTriangle className="w-3.5 h-3.5 text-rose-400" />
|
||||
<span>Late (Overdue Milestone)</span>
|
||||
</span>
|
||||
) : hasAtRiskMilestone ? (
|
||||
<span className="inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-[11px] font-bold bg-amber-950/90 border border-amber-500/50 text-amber-300 shadow-sm shadow-amber-950/50">
|
||||
<Clock className="w-3.5 h-3.5 text-amber-400" />
|
||||
<span>At Risk (Milestone Due Soon)</span>
|
||||
</span>
|
||||
) : (
|
||||
renderStatusBadge(project.status, 'Active Implementation')
|
||||
)}
|
||||
|
||||
{/* Requirement #6: Informational Compliance & Risk Modal Trigger */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setGovernanceTarget({ project, initialTab: 'compliance' })}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1 rounded-lg bg-white/[0.04] hover:bg-white/[0.08] border border-white/10 text-[11px] font-medium text-slate-300 hover:text-white transition-colors cursor-pointer"
|
||||
title="View Project Compliance, Risks & Institutional Rules"
|
||||
>
|
||||
<ShieldAlert className="w-3.5 h-3.5 text-emerald-400" />
|
||||
<span>Compliance & Risks</span>
|
||||
</button>
|
||||
|
||||
{/* Requirement #3: Display enum string description instead of integer value */}
|
||||
{sectorDescription && (
|
||||
@@ -504,7 +544,11 @@ export const ProjectCard: React.FC<ProjectCardProps> = ({
|
||||
const evs = getEvidenceForActivity(a.id);
|
||||
return evs.length > 0 || Number(a.status) === ApprovalStatus.Approved || Number(a.status) === ApprovalStatus.Review;
|
||||
});
|
||||
const isMilestoneFullyFulfilled = milestoneActs.length > 0 && milestoneFulfilledActs.length === milestoneActs.length;
|
||||
const isMilestoneFullyFulfilled = (Number(milestone.status) === ApprovalStatus.Approved) || (milestoneActs.length > 0 && milestoneFulfilledActs.length === milestoneActs.length);
|
||||
const actualMilestoneId = decodeHashIdSync(milestone.id);
|
||||
const dueTime = milestone.dueAt ? new Date(milestone.dueAt).getTime() : null;
|
||||
const isMilestoneOverdue = dueTime !== null && dueTime < nowTime && !isMilestoneFullyFulfilled;
|
||||
const isMilestoneAtRisk = dueTime !== null && !isMilestoneOverdue && !isMilestoneFullyFulfilled && (dueTime - nowTime <= 3 * 24 * 60 * 60 * 1000);
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -521,13 +565,43 @@ export const ProjectCard: React.FC<ProjectCardProps> = ({
|
||||
<span className="font-mono text-[10px] text-slate-400">
|
||||
(ID: #{encodeHashIdSync(milestone.id)})
|
||||
</span>
|
||||
{renderStatusBadge(milestone.status, 'In Progress')}
|
||||
{/* Requirement #3: Decode hashId to show actual milestone ID */}
|
||||
<span className="font-mono text-[10px] font-semibold text-slate-300 bg-slate-900 border border-white/10 px-2 py-0.5 rounded-md">
|
||||
Actual ID: #{actualMilestoneId}
|
||||
</span>
|
||||
|
||||
{/* Requirement #5: Milestone late and at risk badges */}
|
||||
{isMilestoneOverdue ? (
|
||||
<span className="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-[10px] font-bold bg-rose-950/90 border border-rose-500/50 text-rose-300 shadow-sm">
|
||||
<AlertTriangle className="w-3 h-3 text-rose-400" />
|
||||
<span>Late</span>
|
||||
</span>
|
||||
) : isMilestoneAtRisk ? (
|
||||
<span className="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-[10px] font-bold bg-amber-950/90 border border-amber-500/50 text-amber-300 shadow-sm">
|
||||
<Clock className="w-3 h-3 text-amber-400" />
|
||||
<span>At Risk (Due Soon)</span>
|
||||
</span>
|
||||
) : (
|
||||
renderStatusBadge(milestone.status, 'In Progress')
|
||||
)}
|
||||
|
||||
{isMilestoneFullyFulfilled && (
|
||||
<span className="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-[10px] font-bold bg-emerald-500/15 text-emerald-300 border border-emerald-500/30">
|
||||
<ShieldCheck className="w-3 h-3 text-emerald-400" />
|
||||
<span>Milestone Fulfilled</span>
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Requirement #6: Milestone level Rules & Risks trigger */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setGovernanceTarget({ project, milestone, initialTab: 'rules' })}
|
||||
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-md bg-white/[0.04] hover:bg-white/[0.08] text-[10px] font-medium text-slate-400 hover:text-white transition-colors cursor-pointer border border-white/5"
|
||||
title="View Milestone Rules and Risk Guidance"
|
||||
>
|
||||
<Scale className="w-3 h-3 text-teal-400" />
|
||||
<span>Rules & Risks</span>
|
||||
</button>
|
||||
</div>
|
||||
<h5 className="text-base font-bold text-slate-100 mt-1">
|
||||
{milestone.name || `Milestone Target #${mIdx + 1}`}
|
||||
|
||||
@@ -1,37 +1,32 @@
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import {
|
||||
Copy,
|
||||
Check,
|
||||
Mail,
|
||||
MessageSquare,
|
||||
Code2,
|
||||
Eye,
|
||||
ShieldCheck,
|
||||
} from 'lucide-react';
|
||||
import { Copy, Check } from 'lucide-react';
|
||||
import { NotificationPlatform } from '../../types';
|
||||
import { parseMarkdownToHtml, isHtmlContent, stripMarkdown } from '../../lib/markdownUtils';
|
||||
|
||||
export interface MessageViewerProps {
|
||||
content: string;
|
||||
content?: string;
|
||||
message?: string;
|
||||
platform?: NotificationPlatform | number | string;
|
||||
subject?: string;
|
||||
className?: string;
|
||||
showFormatHeader?: boolean;
|
||||
maxHeightClass?: string;
|
||||
variant?: string;
|
||||
createdDate?: string;
|
||||
}
|
||||
|
||||
export const MessageViewer: React.FC<MessageViewerProps> = ({
|
||||
content,
|
||||
message,
|
||||
platform,
|
||||
subject,
|
||||
className = '',
|
||||
showFormatHeader = true,
|
||||
maxHeightClass = 'max-h-[60vh]',
|
||||
maxHeightClass = 'max-h-[65vh]',
|
||||
}) => {
|
||||
const rawContent = (content !== undefined && content !== null ? content : message) ?? '';
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [showRaw, setShowRaw] = useState(false);
|
||||
|
||||
// Determine whether this message represents an Email (HTML) or In-App (Markup)
|
||||
// Determine whether this message represents an Email (HTML) or In-App (Markdown)
|
||||
const isEmail = useMemo(() => {
|
||||
if (
|
||||
platform === NotificationPlatform.Email ||
|
||||
@@ -41,148 +36,81 @@ export const MessageViewer: React.FC<MessageViewerProps> = ({
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
// If platform is not explicitly InApp, detect if content is structured HTML
|
||||
if (
|
||||
platform !== NotificationPlatform.InApp &&
|
||||
platform !== 5 &&
|
||||
platform !== '5' &&
|
||||
String(platform).toLowerCase() !== 'inapp' &&
|
||||
isHtmlContent(content)
|
||||
isHtmlContent(rawContent)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}, [platform, content]);
|
||||
}, [platform, rawContent]);
|
||||
|
||||
const isInApp = useMemo(() => {
|
||||
if (
|
||||
platform === NotificationPlatform.InApp ||
|
||||
platform === 5 ||
|
||||
platform === '5' ||
|
||||
String(platform).toLowerCase() === 'inapp'
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
return !isEmail;
|
||||
}, [platform, isEmail]);
|
||||
|
||||
// Convert the message to its respective format:
|
||||
// - Email: HTML rendered
|
||||
// - In-App: Markup (Markdown) parsed to HTML
|
||||
// Requirement #1: In-App message contents encoded as markdown, rendered for user; Email in HTML
|
||||
// Requirement #8: User is none the wiser of raw form or format in any Messages UI
|
||||
const renderedHtml = useMemo(() => {
|
||||
if (!content) return '<p class="text-slate-400 italic">No message content</p>';
|
||||
if (!rawContent || !rawContent.trim()) {
|
||||
return '<p class="text-slate-400 italic">No message content</p>';
|
||||
}
|
||||
|
||||
// Format 1: Email => HTML
|
||||
if (isEmail) {
|
||||
if (isHtmlContent(content)) {
|
||||
return content.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, '');
|
||||
if (isHtmlContent(rawContent)) {
|
||||
return rawContent.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, '');
|
||||
}
|
||||
return parseMarkdownToHtml(content);
|
||||
return parseMarkdownToHtml(rawContent);
|
||||
}
|
||||
|
||||
// Format 2: In-App => Markup (Markdown)
|
||||
return parseMarkdownToHtml(content);
|
||||
}, [content, isEmail]);
|
||||
// In-App: parsed from Markdown to styled HTML
|
||||
return parseMarkdownToHtml(rawContent);
|
||||
}, [rawContent, isEmail]);
|
||||
|
||||
const handleCopy = () => {
|
||||
const textToCopy = showRaw ? content : stripMarkdown(content) || content;
|
||||
const textToCopy = stripMarkdown(rawContent) || rawContent;
|
||||
navigator.clipboard.writeText(textToCopy);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`flex flex-col rounded-2xl bg-slate-900/50 border border-white/10 overflow-hidden w-full max-w-full ${className}`}>
|
||||
{/* Format Header & Controls Bar */}
|
||||
{showFormatHeader && (
|
||||
<div className="px-4 py-2.5 border-b border-white/[0.08] bg-[#0b101d]/90 flex items-center justify-between text-xs text-slate-400 gap-3 shrink-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{isEmail ? (
|
||||
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg bg-cyan-500/10 border border-cyan-500/20 text-cyan-300 font-semibold text-[11px]">
|
||||
<Mail className="w-3.5 h-3.5 text-cyan-400" />
|
||||
<span>Email (HTML)</span>
|
||||
<div className={`flex flex-col rounded-2xl bg-slate-900/60 border border-white/10 overflow-hidden w-full max-w-full ${className}`}>
|
||||
{/* Top action toolbar - clean without technical format metadata */}
|
||||
<div className="px-5 py-3 border-b border-white/[0.08] bg-[#0b101d]/90 flex items-center justify-between text-xs text-slate-400 gap-3 shrink-0">
|
||||
<span className="text-xs font-semibold text-slate-200 truncate">
|
||||
{subject || 'Message Content'}
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg bg-emerald-500/10 border border-emerald-500/20 text-emerald-300 font-semibold text-[11px]">
|
||||
<MessageSquare className="w-3.5 h-3.5 text-emerald-400" />
|
||||
<span>In-App (Markup)</span>
|
||||
</span>
|
||||
)}
|
||||
|
||||
<span className="text-[11px] text-slate-400 hidden sm:inline">
|
||||
{isEmail ? 'Rendered as HTML Email' : 'Converted from Markup to Formatted View'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1.5 shrink-0">
|
||||
{/* View Mode Toggle: Formatted vs Raw */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowRaw(!showRaw)}
|
||||
className="flex items-center gap-1 px-2 py-1 rounded-lg bg-slate-800/80 hover:bg-slate-700 text-slate-300 hover:text-white text-[11px] font-medium transition cursor-pointer border border-white/10"
|
||||
title={showRaw ? 'Switch to Formatted view' : 'View Raw message content'}
|
||||
>
|
||||
{showRaw ? (
|
||||
<>
|
||||
<Eye className="w-3 h-3 text-emerald-400" />
|
||||
<span>Formatted</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Code2 className="w-3 h-3" />
|
||||
<span>Raw</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Copy Button */}
|
||||
{/* Copy Clean Text */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopy}
|
||||
className="flex items-center gap-1 px-2 py-1 rounded-lg bg-slate-800/80 hover:bg-slate-700 text-slate-300 hover:text-white text-[11px] font-medium transition cursor-pointer border border-white/10"
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-xl bg-slate-800/90 hover:bg-slate-700 text-slate-300 hover:text-white text-xs font-medium transition cursor-pointer border border-white/10"
|
||||
title="Copy message content"
|
||||
>
|
||||
{copied ? (
|
||||
<>
|
||||
<Check className="w-3 h-3 text-emerald-400" />
|
||||
<Check className="w-3.5 h-3.5 text-emerald-400" />
|
||||
<span className="text-emerald-300">Copied</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Copy className="w-3 h-3" />
|
||||
<Copy className="w-3.5 h-3.5 text-slate-400" />
|
||||
<span>Copy</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Message Body Content - with explicit max height, max width, word wrapping, and automatic scrollbars */}
|
||||
<div className={`w-full max-w-full p-4 sm:p-6 overflow-x-auto overflow-y-auto ${maxHeightClass} text-sm leading-relaxed text-slate-200 [scrollbar-width:thin] [scrollbar-color:#334155_transparent]`}>
|
||||
{showRaw ? (
|
||||
<pre className="font-mono text-xs text-slate-300 whitespace-pre-wrap break-words [word-break:break-word] bg-[#080d18] p-4 rounded-xl border border-white/5 max-w-full overflow-x-auto [scrollbar-width:thin] [scrollbar-color:#334155_transparent]">
|
||||
{content}
|
||||
</pre>
|
||||
) : (
|
||||
{/* Rendered Body - Prose & Markdown styling */}
|
||||
<div
|
||||
className={`w-full max-w-full p-5 sm:p-7 overflow-x-auto overflow-y-auto ${maxHeightClass} text-sm leading-relaxed text-slate-200 [scrollbar-width:thin] [scrollbar-color:#334155_transparent]`}
|
||||
>
|
||||
<div
|
||||
className="prose prose-invert max-w-full w-full break-words [word-break:break-word] text-xs sm:text-sm leading-relaxed text-slate-200 [&_h1]:text-white [&_h1]:text-xl [&_h1]:font-bold [&_h1]:mb-3 [&_h2]:text-white [&_h2]:text-lg [&_h2]:font-bold [&_h2]:mb-2 [&_h3]:text-white [&_h3]:text-base [&_h3]:font-bold [&_h3]:mb-2 [&_h4]:text-white [&_h4]:font-semibold [&_a]:text-emerald-400 [&_a:hover]:underline [&_ul]:list-disc [&_ul]:pl-5 [&_ul]:my-2 [&_ol]:list-decimal [&_ol]:pl-5 [&_ol]:my-2 [&_li]:text-slate-300 [&_table]:w-full [&_table]:max-w-full [&_table]:border-collapse [&_table]:my-3 [&_th]:border [&_th]:border-slate-700 [&_th]:bg-slate-800/80 [&_th]:p-2.5 [&_th]:break-words [&_td]:border [&_td]:border-slate-800 [&_td]:p-2.5 [&_td]:break-words [&_blockquote]:border-l-4 [&_blockquote]:border-emerald-500 [&_blockquote]:pl-4 [&_blockquote]:italic [&_blockquote]:my-2 [&_code]:bg-slate-800 [&_code]:px-1.5 [&_code]:py-0.5 [&_code]:rounded [&_code]:text-emerald-300 [&_code]:break-all [&_pre]:max-w-full [&_pre]:overflow-x-auto [&_img]:max-w-full [&_img]:h-auto [&_iframe]:max-w-full"
|
||||
dangerouslySetInnerHTML={{ __html: renderedHtml }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer Info Stamp */}
|
||||
<div className="px-5 py-2.5 border-t border-white/[0.08] bg-[#090e1a] flex items-center justify-between text-[11px] text-slate-400 shrink-0">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<ShieldCheck className="w-3.5 h-3.5 text-emerald-400" />
|
||||
<span>PFM Communications • {isEmail ? 'Email HTML Format' : 'In-App Markup Format'}</span>
|
||||
</div>
|
||||
<span className="font-mono text-[10px] text-slate-400">
|
||||
{stripMarkdown(content).length} characters
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
+287
@@ -0,0 +1,287 @@
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import {
|
||||
X,
|
||||
Send,
|
||||
Bold,
|
||||
Italic,
|
||||
Underline,
|
||||
Heading2,
|
||||
List,
|
||||
ListOrdered,
|
||||
Quote,
|
||||
Minus,
|
||||
MessageSquare,
|
||||
Sparkles,
|
||||
Eraser,
|
||||
} from 'lucide-react';
|
||||
import { htmlToMarkdown } from '../../lib/markdownUtils';
|
||||
|
||||
interface PartnerMessageComposerModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
recipientEmail: string;
|
||||
recipientName?: string;
|
||||
organisationName?: string;
|
||||
initialSubject?: string;
|
||||
onSend: (subject: string, markdownMessage: string) => Promise<void>;
|
||||
isSending?: boolean;
|
||||
}
|
||||
|
||||
export const PartnerMessageComposerModal: React.FC<PartnerMessageComposerModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
recipientEmail,
|
||||
recipientName,
|
||||
organisationName,
|
||||
initialSubject = '',
|
||||
onSend,
|
||||
isSending = false,
|
||||
}) => {
|
||||
const [subject, setSubject] = useState(initialSubject);
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
const editorRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setSubject(initialSubject);
|
||||
setErrorMessage(null);
|
||||
}, [initialSubject, isOpen]);
|
||||
|
||||
if (!isOpen || typeof document === 'undefined') return null;
|
||||
|
||||
const applyFormat = (command: string, value: string | undefined = undefined) => {
|
||||
const editor = editorRef.current;
|
||||
if (!editor) return;
|
||||
editor.focus();
|
||||
document.execCommand(command, false, value);
|
||||
};
|
||||
|
||||
const handleClearFormat = () => {
|
||||
applyFormat('removeFormat');
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setErrorMessage(null);
|
||||
|
||||
if (!subject.trim()) {
|
||||
setErrorMessage('Please enter a message subject.');
|
||||
return;
|
||||
}
|
||||
|
||||
const htmlContent = editorRef.current ? editorRef.current.innerHTML : '';
|
||||
const textContent = editorRef.current ? editorRef.current.innerText.trim() : '';
|
||||
|
||||
if (!textContent && !htmlContent.trim()) {
|
||||
setErrorMessage('Please write your message in the composer space.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Requirement #1: In-App messages are encoded as Markdown
|
||||
// Requirement #8: User is none the wiser of raw format
|
||||
const markdown = htmlToMarkdown(htmlContent);
|
||||
|
||||
try {
|
||||
await onSend(subject.trim(), markdown);
|
||||
onClose();
|
||||
} catch (err: any) {
|
||||
setErrorMessage(err?.message || 'Failed to dispatch message. Please try again.');
|
||||
}
|
||||
};
|
||||
|
||||
return createPortal(
|
||||
<div className="fixed inset-0 z-[9999] flex items-center justify-center p-4 sm:p-6 bg-slate-950/85 backdrop-blur-md animate-in fade-in duration-200 overflow-y-auto">
|
||||
<div className="relative w-full max-w-2xl rounded-3xl border border-white/10 bg-slate-900 shadow-2xl p-6 sm:p-7 space-y-5 my-auto text-slate-100">
|
||||
{/* Modal Header */}
|
||||
<div className="flex items-center justify-between border-b border-white/[0.08] pb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2.5 rounded-2xl bg-emerald-500/15 border border-emerald-500/30 text-emerald-400">
|
||||
<MessageSquare className="w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-base sm:text-lg font-bold text-white tracking-tight">
|
||||
{initialSubject.startsWith('Re:') ? 'Reply Message' : 'Compose Message'}
|
||||
</h3>
|
||||
<p className="text-xs text-slate-400">
|
||||
Direct transmission to your programme funding administration
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="p-1.5 rounded-xl text-slate-400 hover:text-white hover:bg-white/[0.06] transition-colors cursor-pointer"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{errorMessage && (
|
||||
<div className="p-3 rounded-xl bg-rose-500/15 border border-rose-500/30 text-rose-300 text-xs">
|
||||
{errorMessage}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{/* Recipient Field */}
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-slate-300 mb-1">
|
||||
To (Recipient Support Email)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
disabled
|
||||
value={recipientEmail || 'support@ifri.khongisa.co.za'}
|
||||
className="w-full px-3.5 py-2.5 rounded-xl bg-slate-950 border border-white/10 text-xs font-mono text-emerald-400 select-none"
|
||||
/>
|
||||
{(organisationName || recipientName) && (
|
||||
<p className="text-[11px] text-slate-400 mt-1">
|
||||
Organisation: <span className="text-slate-200">{organisationName || recipientName}</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Subject Field */}
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-slate-300 mb-1">Subject</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={subject}
|
||||
onChange={e => setSubject(e.target.value)}
|
||||
placeholder="e.g. Milestone 2 Deliverable Verification Enquiry"
|
||||
className="w-full px-3.5 py-2.5 rounded-xl bg-slate-950 border border-white/10 text-xs text-white placeholder-slate-500 focus:outline-none focus:border-emerald-500/50"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* WYSIWYG Message Composition Space (Requirement #2 & #8) */}
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-slate-300 mb-1">
|
||||
Message Composition Space
|
||||
</label>
|
||||
|
||||
<div className="rounded-2xl border border-white/10 bg-slate-950 overflow-hidden shadow-inner focus-within:border-emerald-500/50 transition">
|
||||
{/* WYSIWYG Toolbar */}
|
||||
<div className="px-3 py-2 border-b border-white/[0.08] bg-slate-900/80 flex flex-wrap items-center gap-1 text-slate-300">
|
||||
<button
|
||||
type="button"
|
||||
onMouseDown={e => e.preventDefault()}
|
||||
onClick={() => applyFormat('bold')}
|
||||
className="p-1.5 rounded-lg hover:text-white hover:bg-white/[0.08] transition cursor-pointer"
|
||||
title="Bold"
|
||||
>
|
||||
<Bold className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onMouseDown={e => e.preventDefault()}
|
||||
onClick={() => applyFormat('italic')}
|
||||
className="p-1.5 rounded-lg hover:text-white hover:bg-white/[0.08] transition cursor-pointer"
|
||||
title="Italic"
|
||||
>
|
||||
<Italic className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onMouseDown={e => e.preventDefault()}
|
||||
onClick={() => applyFormat('underline')}
|
||||
className="p-1.5 rounded-lg hover:text-white hover:bg-white/[0.08] transition cursor-pointer"
|
||||
title="Underline"
|
||||
>
|
||||
<Underline className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onMouseDown={e => e.preventDefault()}
|
||||
onClick={() => applyFormat('formatBlock', 'H3')}
|
||||
className="p-1.5 rounded-lg hover:text-white hover:bg-white/[0.08] transition cursor-pointer"
|
||||
title="Heading"
|
||||
>
|
||||
<Heading2 className="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
<span className="w-px h-4 bg-white/10 mx-1" />
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onMouseDown={e => e.preventDefault()}
|
||||
onClick={() => applyFormat('insertUnorderedList')}
|
||||
className="p-1.5 rounded-lg hover:text-white hover:bg-white/[0.08] transition cursor-pointer"
|
||||
title="Bullet List"
|
||||
>
|
||||
<List className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onMouseDown={e => e.preventDefault()}
|
||||
onClick={() => applyFormat('insertOrderedList')}
|
||||
className="p-1.5 rounded-lg hover:text-white hover:bg-white/[0.08] transition cursor-pointer"
|
||||
title="Numbered List"
|
||||
>
|
||||
<ListOrdered className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onMouseDown={e => e.preventDefault()}
|
||||
onClick={() => applyFormat('formatBlock', 'BLOCKQUOTE')}
|
||||
className="p-1.5 rounded-lg hover:text-white hover:bg-white/[0.08] transition cursor-pointer"
|
||||
title="Quote Block"
|
||||
>
|
||||
<Quote className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onMouseDown={e => e.preventDefault()}
|
||||
onClick={() => applyFormat('insertHorizontalRule')}
|
||||
className="p-1.5 rounded-lg hover:text-white hover:bg-white/[0.08] transition cursor-pointer"
|
||||
title="Horizontal Divider"
|
||||
>
|
||||
<Minus className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onMouseDown={e => e.preventDefault()}
|
||||
onClick={handleClearFormat}
|
||||
className="p-1.5 rounded-lg hover:text-white hover:bg-white/[0.08] transition cursor-pointer ml-auto"
|
||||
title="Clear Formatting"
|
||||
>
|
||||
<Eraser className="w-4 h-4 text-slate-400" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Rich ContentEditable Area */}
|
||||
<div
|
||||
ref={editorRef}
|
||||
contentEditable
|
||||
className="w-full min-h-[160px] max-h-[300px] p-4 text-xs sm:text-sm text-slate-100 leading-relaxed overflow-y-auto focus:outline-none [scrollbar-width:thin] [scrollbar-color:#334155_transparent] prose prose-invert max-w-none [&_blockquote]:border-l-4 [&_blockquote]:border-emerald-500 [&_blockquote]:pl-3 [&_blockquote]:italic [&_ul]:list-disc [&_ul]:pl-5 [&_ol]:list-decimal [&_ol]:pl-5"
|
||||
placeholder="Write your message here..."
|
||||
tabIndex={0}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Modal Actions */}
|
||||
<div className="flex items-center justify-end gap-3 pt-3 border-t border-white/[0.08]">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="px-4 py-2.5 rounded-xl bg-slate-800 hover:bg-slate-700 text-slate-300 text-xs font-semibold transition-colors cursor-pointer"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSending}
|
||||
className="px-5 py-2.5 rounded-xl bg-emerald-600 hover:bg-emerald-500 text-white font-semibold text-xs flex items-center gap-2 shadow-lg shadow-emerald-500/20 transition-all cursor-pointer disabled:opacity-50"
|
||||
>
|
||||
<Send className="w-3.5 h-3.5" />
|
||||
<span>{isSending ? 'Sending...' : 'Send Message'}</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
};
|
||||
|
||||
@@ -269,8 +269,9 @@ export function useInAppNotifications() {
|
||||
|
||||
const sendMessageMutation = useMutation({
|
||||
mutationFn: async (payload: {
|
||||
recipientId: string;
|
||||
recipientEmail: string;
|
||||
recipientId?: string;
|
||||
recipientEmail?: string;
|
||||
recipient?: string;
|
||||
recipientName?: string;
|
||||
subject: string;
|
||||
message: string;
|
||||
@@ -280,11 +281,13 @@ export function useInAppNotifications() {
|
||||
}) => {
|
||||
const orgId = payload.organisationId || getCurrentUserTenantId() || 1;
|
||||
const targetPlatform = payload.platform || NotificationPlatform.InApp;
|
||||
const formattedDestination = `To: ${payload.recipientEmail} | From: ${userEmail || 'Partner User'}`;
|
||||
const email = payload.recipientEmail || (payload.recipient?.includes('@') ? payload.recipient : '') || 'support@ifri.khongisa.co.za';
|
||||
const id = payload.recipientId || (!payload.recipient?.includes('@') ? payload.recipient : '') || '00000000-0000-0000-0000-000000000000';
|
||||
const formattedDestination = `To: ${email} | From: ${userEmail || 'Partner User'}`;
|
||||
|
||||
const req: CreateNotificationRequest = {
|
||||
organisationId: orgId,
|
||||
recipient: payload.recipientId,
|
||||
recipient: id,
|
||||
platform: targetPlatform,
|
||||
subject: payload.subject,
|
||||
destination: formattedDestination,
|
||||
|
||||
@@ -149,3 +149,79 @@ export function parseMarkdownToHtml(markdown: string): string {
|
||||
return html;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* Converts formatted HTML from WYSIWYG composition into clean, semantic Markdown.
|
||||
* Preserves headings, bold, italic, blockquotes, lists, dividers, and links.
|
||||
*/
|
||||
export function htmlToMarkdown(html: string): string {
|
||||
if (!html) return '';
|
||||
let md = html
|
||||
.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, '')
|
||||
.replace(/<style\b[^<]*(?:(?!<\/style>)<[^<]*)*<\/style>/gi, '');
|
||||
|
||||
// Line breaks & paragraph breaks
|
||||
md = md.replace(/<br\s*\/?>/gi, '\n');
|
||||
md = md.replace(/<\/p>/gi, '\n\n');
|
||||
md = md.replace(/<p[^>]*>/gi, '');
|
||||
|
||||
// Headers
|
||||
md = md.replace(/<h1[^>]*>(.*?)<\/h1>/gis, '# $1\n\n');
|
||||
md = md.replace(/<h2[^>]*>(.*?)<\/h2>/gis, '## $1\n\n');
|
||||
md = md.replace(/<h3[^>]*>(.*?)<\/h3>/gis, '### $1\n\n');
|
||||
md = md.replace(/<h4[^>]*>(.*?)<\/h4>/gis, '#### $1\n\n');
|
||||
|
||||
// Bold / Italic / Underline
|
||||
md = md.replace(/<(strong|b)[^>]*>(.*?)<\/\1>/gis, '**$2**');
|
||||
md = md.replace(/<(em|i)[^>]*>(.*?)<\/\1>/gis, '*$2*');
|
||||
md = md.replace(/<u[^>]*>(.*?)<\/u>/gis, '$1');
|
||||
|
||||
// Blockquote
|
||||
md = md.replace(/<blockquote[^>]*>(.*?)<\/blockquote>/gis, (_match, p1) => {
|
||||
return (
|
||||
p1
|
||||
.trim()
|
||||
.split('\n')
|
||||
.map((l: string) => `> ${l.trim()}`)
|
||||
.join('\n') + '\n\n'
|
||||
);
|
||||
});
|
||||
|
||||
// Unordered Lists
|
||||
md = md.replace(/<ul[^>]*>(.*?)<\/ul>/gis, (_match, p1) => {
|
||||
return p1.replace(/<li[^>]*>(.*?)<\/li>/gis, '- $1\n') + '\n';
|
||||
});
|
||||
|
||||
// Ordered Lists
|
||||
md = md.replace(/<ol[^>]*>(.*?)<\/ol>/gis, (_match, p1) => {
|
||||
let index = 1;
|
||||
return p1.replace(/<li[^>]*>(.*?)<\/li>/gis, () => `${index++}. $1\n`) + '\n';
|
||||
});
|
||||
|
||||
// Horizontal Rules
|
||||
md = md.replace(/<hr\s*\/?>/gi, '\n---\n\n');
|
||||
|
||||
// Links
|
||||
md = md.replace(/<a\b[^>]*href=["']([^"']*)["'][^>]*>(.*?)<\/a>/gis, '[$2]($1)');
|
||||
|
||||
// Divs
|
||||
md = md.replace(/<\/div>/gi, '\n');
|
||||
md = md.replace(/<div[^>]*>/gi, '');
|
||||
|
||||
// Strip any remaining HTML tags
|
||||
md = md.replace(/<[^>]+>/g, '');
|
||||
|
||||
// Unescape standard HTML entities
|
||||
md = md
|
||||
.replace(/ /g, ' ')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'");
|
||||
|
||||
// Collapse 3+ newlines into 2
|
||||
return md.replace(/\n{3,}/g, '\n\n').trim();
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user