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)
|
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")
|
.WithDescription("Add a new beneficiary outcome")
|
||||||
.RequireAuthorization(Constants.MainScope)
|
.RequireAuthorization(Constants.MainScope)
|
||||||
.WithName(typeof(CreateOutcomeEndpoint).ToEndpointName())
|
.WithName(typeof(CreateOutcomeEndpoint).ToEndpointName())
|
||||||
@@ -22,25 +29,5 @@ public sealed class CreateOutcomeEndpoint : IEndpoint
|
|||||||
.Produces<Problems>(StatusCodes.Status500InternalServerError)
|
.Produces<Problems>(StatusCodes.Status500InternalServerError)
|
||||||
.Produces(StatusCodes.Status401Unauthorized)
|
.Produces(StatusCodes.Status401Unauthorized)
|
||||||
.WithTags(EndpointTags.Projects);
|
.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);
|
Task<Result> CreateNotificationAsync(CreateNotificationRequest request, Guid userId, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
[Log]
|
[Log]
|
||||||
[Audit]
|
|
||||||
Task<Result> UpdateNotificationAsync(UpdateNotificationRequest request, CancellationToken cancellationToken = default);
|
Task<Result> UpdateNotificationAsync(UpdateNotificationRequest request, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
[Log]
|
[Log]
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import React, { useState, useMemo } from 'react';
|
import React, { useState, useMemo } from 'react';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import {
|
import {
|
||||||
Search,
|
Search,
|
||||||
@@ -26,6 +27,9 @@ import {
|
|||||||
Info,
|
Info,
|
||||||
Sparkles,
|
Sparkles,
|
||||||
Infinity as InfinityIcon,
|
Infinity as InfinityIcon,
|
||||||
|
Menu,
|
||||||
|
Reply,
|
||||||
|
Mail,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useAuth } from '../../auth/AuthProvider';
|
import { useAuth } from '../../auth/AuthProvider';
|
||||||
import { useRbac } from '../../auth/rbac';
|
import { useRbac } from '../../auth/rbac';
|
||||||
@@ -60,12 +64,13 @@ import {
|
|||||||
Notification,
|
Notification,
|
||||||
NotificationStatus,
|
NotificationStatus,
|
||||||
} from '../../types';
|
} from '../../types';
|
||||||
import { formatZAR, formatCompactZAR } from '../../lib/formatters';
|
import { formatZAR, formatCompactZAR, getUserEmail } from '../../lib/formatters';
|
||||||
import { MessageViewer } from '../notifications/MessageViewer';
|
import { MessageViewer } from '../notifications/MessageViewer';
|
||||||
import { stripMarkdown } from '../../lib/markdownUtils';
|
import { stripMarkdown } from '../../lib/markdownUtils';
|
||||||
import { PfmPartnerLogo } from '../common/PfmPartnerLogo';
|
import { PfmPartnerLogo } from '../common/PfmPartnerLogo';
|
||||||
import { ProjectCard, EnrichedPartnerProject } from './ProjectCard';
|
import { ProjectCard, EnrichedPartnerProject } from './ProjectCard';
|
||||||
import { EvidenceUploadModal } from './EvidenceUploadModal';
|
import { EvidenceUploadModal } from './EvidenceUploadModal';
|
||||||
|
import { PartnerMessageComposerModal } from '../notifications/PartnerMessageComposerModal';
|
||||||
|
|
||||||
type NavTab = 'dashboard' | 'messages' | 'contracts' | 'analytics' | 'documents';
|
type NavTab = 'dashboard' | 'messages' | 'contracts' | 'analytics' | 'documents';
|
||||||
type SortOption = 'date_added' | 'grant_amount' | 'days_left' | 'name';
|
type SortOption = 'date_added' | 'grant_amount' | 'days_left' | 'name';
|
||||||
@@ -130,6 +135,7 @@ export function DashboardPage() {
|
|||||||
const [sortBy, setSortBy] = useState<SortOption>('date_added');
|
const [sortBy, setSortBy] = useState<SortOption>('date_added');
|
||||||
const [showBellDropdown, setShowBellDropdown] = useState(false);
|
const [showBellDropdown, setShowBellDropdown] = useState(false);
|
||||||
const [showUserDropdown, setShowUserDropdown] = 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)
|
// In-place expanded project state (Requirement #4: NO modal used for project details)
|
||||||
const [expandedProjectIds, setExpandedProjectIds] = useState<Record<string, boolean>>({});
|
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
|
// Secure download link handler
|
||||||
const handleOpenSecureFile = async (type: 'contract' | 'evidence', id: string | number) => {
|
const handleOpenSecureFile = async (type: 'contract' | 'evidence', id: string | number) => {
|
||||||
try {
|
try {
|
||||||
@@ -469,19 +569,22 @@ export function DashboardPage() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const recipient =
|
const fundingEmail =
|
||||||
contactTarget?.recipientId ||
|
partnerSummary.primaryFundingOrg?.email ||
|
||||||
contactTarget?.recipientEmail ||
|
(partnerSummary.primaryFundingOrg as any)?.Email ||
|
||||||
contactTarget?.organisation?.name ||
|
'support@ifri.khongisa.co.za';
|
||||||
applicantUserId;
|
|
||||||
|
const targetEmail = contactTarget?.recipientEmail || fundingEmail;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await sendMessage({
|
await sendMessage({
|
||||||
recipient,
|
recipientEmail: targetEmail,
|
||||||
|
recipient: targetEmail,
|
||||||
subject: messageSubject.trim(),
|
subject: messageSubject.trim(),
|
||||||
message: messageBody.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);
|
setContactTarget(null);
|
||||||
setMessageSubject('');
|
setMessageSubject('');
|
||||||
setMessageBody('');
|
setMessageBody('');
|
||||||
@@ -548,6 +651,20 @@ export function DashboardPage() {
|
|||||||
|
|
||||||
{/* Right: Search, Notifications Bell, User Menu */}
|
{/* Right: Search, Notifications Bell, User Menu */}
|
||||||
<div className="flex items-center gap-3 sm:gap-4">
|
<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 */}
|
{/* Search Input */}
|
||||||
<div className="relative hidden sm:block w-48 md:w-64">
|
<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" />
|
<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>
|
</div>
|
||||||
</header>
|
</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)
|
MAIN DASHBOARD BODY (Max-width 1440px, full-width horizontal layout)
|
||||||
(Overview & Inbox sidebar deleted per Requirements #1, #2, #3)
|
(Overview & Inbox sidebar deleted per Requirements #1, #2, #3)
|
||||||
@@ -811,9 +959,15 @@ export function DashboardPage() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
const fundingEmail =
|
||||||
|
partnerSummary.primaryFundingOrg?.email ||
|
||||||
|
(partnerSummary.primaryFundingOrg as any)?.Email ||
|
||||||
|
'support@ifri.khongisa.co.za';
|
||||||
|
|
||||||
setContactTarget({
|
setContactTarget({
|
||||||
organisation: partnerSummary.primaryFundingOrg,
|
organisation: partnerSummary.primaryFundingOrg,
|
||||||
projectTitle: 'Partner Support',
|
projectTitle: 'General Support Enquiry',
|
||||||
|
recipientEmail: fundingEmail,
|
||||||
});
|
});
|
||||||
setMessageSubject('');
|
setMessageSubject('');
|
||||||
setMessageBody('');
|
setMessageBody('');
|
||||||
@@ -858,6 +1012,9 @@ export function DashboardPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-slate-400 mt-1 line-clamp-2">{stripMarkdown(msg.message || '')}</p>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-xs text-slate-400/70 shrink-0">
|
<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 && (
|
{contactTarget && (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-slate-950/80 backdrop-blur-md p-4">
|
<PartnerMessageComposerModal
|
||||||
<div className="w-full max-w-lg rounded-2xl bg-[#0d1322] border border-white/10 shadow-2xl p-6 space-y-4">
|
isOpen={true}
|
||||||
<div className="flex items-center justify-between border-b border-white/[0.08] pb-3">
|
onClose={() => setContactTarget(null)}
|
||||||
<div className="flex items-center gap-2">
|
recipientEmail={
|
||||||
<MessageSquare className="w-5 h-5 text-indigo-400" />
|
contactTarget.recipientEmail ||
|
||||||
<h3 className="text-base font-bold text-white">Send Message</h3>
|
partnerSummary.primaryFundingOrg?.email ||
|
||||||
</div>
|
'support@ifri.khongisa.co.za'
|
||||||
<button
|
}
|
||||||
type="button"
|
organisationName={
|
||||||
onClick={() => setContactTarget(null)}
|
contactTarget.organisation?.name ||
|
||||||
className="p-1 rounded-lg text-slate-400 hover:text-white cursor-pointer"
|
partnerSummary.primaryFundingOrg?.name ||
|
||||||
>
|
'Programme Management Office'
|
||||||
<X className="w-4 h-4" />
|
}
|
||||||
</button>
|
initialSubject={
|
||||||
</div>
|
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">
|
const targetEmail = contactTarget.recipientEmail || fundingEmail;
|
||||||
<div>
|
|
||||||
<label className="block text-xs font-medium text-slate-300 mb-1">Recipient</label>
|
await sendMessage({
|
||||||
<input
|
recipientEmail: targetEmail,
|
||||||
type="text"
|
recipient: targetEmail,
|
||||||
disabled
|
subject: subj,
|
||||||
value={`${contactTarget.organisation?.name || 'Programme Office'} (${contactTarget.projectTitle || 'Enquiry'})`}
|
message: markdown,
|
||||||
className="w-full px-3.5 py-2 rounded-xl bg-slate-900 border border-white/10 text-xs text-slate-300"
|
platform: NotificationPlatform.InApp,
|
||||||
/>
|
organisationId: partnerSummary.primaryFundingOrg?.id || undefined,
|
||||||
</div>
|
});
|
||||||
<div>
|
|
||||||
<label className="block text-xs font-medium text-slate-300 mb-1">Subject</label>
|
showSuccessToast('Message Sent', `Your communication was delivered to ${targetEmail}.`);
|
||||||
<input
|
setContactTarget(null);
|
||||||
type="text"
|
setMessageSubject('');
|
||||||
required
|
setMessageBody('');
|
||||||
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 && (
|
{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">
|
typeof document !== 'undefined' &&
|
||||||
<div className="w-full max-w-lg rounded-2xl bg-[#0d1322] border border-white/10 shadow-2xl p-6 space-y-4">
|
createPortal(
|
||||||
<div className="flex items-center justify-between border-b border-white/[0.08] pb-3">
|
<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">
|
||||||
<h3 className="text-base font-bold text-white truncate max-w-[360px]">
|
<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">
|
||||||
{selectedNotification.subject || 'Message Details'}
|
{/* Header */}
|
||||||
</h3>
|
<div className="flex items-start justify-between border-b border-white/[0.08] pb-4 gap-4">
|
||||||
<button
|
<div className="space-y-1.5 min-w-0">
|
||||||
type="button"
|
<h3 className="text-lg sm:text-xl font-bold text-slate-100 truncate">
|
||||||
onClick={() => setSelectedNotification(null)}
|
{selectedNotification.subject || 'Message Details'}
|
||||||
className="p-1 rounded-lg text-slate-400 hover:text-white cursor-pointer"
|
</h3>
|
||||||
>
|
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-slate-400">
|
||||||
<X className="w-4 h-4" />
|
<div>
|
||||||
</button>
|
From:{' '}
|
||||||
</div>
|
<span className="font-mono text-emerald-400 font-semibold">
|
||||||
|
{getMessageSenderEmail(selectedNotification)}
|
||||||
<div className="space-y-3 text-xs">
|
</span>
|
||||||
<div className="flex items-center justify-between text-slate-400">
|
</div>
|
||||||
<span>From: {selectedNotification.destination?.split('|')[0] || 'Programme Management Office'}</span>
|
<div>
|
||||||
<span>{formatFullDate(selectedNotification.createdAt)}</span>
|
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.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>
|
</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
|
<MessageViewer
|
||||||
|
content={selectedNotification.message || ''}
|
||||||
message={selectedNotification.message || ''}
|
message={selectedNotification.message || ''}
|
||||||
subject={selectedNotification.subject || undefined}
|
subject={selectedNotification.subject || undefined}
|
||||||
createdDate={selectedNotification.createdAt}
|
createdDate={selectedNotification.createdAt}
|
||||||
@@ -1196,20 +1347,29 @@ export function DashboardPage() {
|
|||||||
variant="card"
|
variant="card"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex justify-end pt-2">
|
{/* Action Buttons: Reply and Close */}
|
||||||
<button
|
<div className="flex items-center justify-between pt-2 border-t border-white/[0.08]">
|
||||||
type="button"
|
<button
|
||||||
onClick={() => setSelectedNotification(null)}
|
type="button"
|
||||||
className="px-4 py-2 rounded-xl bg-slate-800 hover:bg-slate-700 text-slate-200 text-xs font-semibold cursor-pointer"
|
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"
|
||||||
Close
|
>
|
||||||
</button>
|
<Reply className="w-3.5 h-3.5" />
|
||||||
|
<span>Reply</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSelectedNotification(null)}
|
||||||
|
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>
|
</div>,
|
||||||
</div>
|
document.body
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,17 +1,26 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { uploadEvidenceFile, extractApiErrors, toIntegerId } from '../../services/api';
|
import { createPortal } from 'react-dom';
|
||||||
import { Activity, Milestone, Project, EvidenceType } from '../../services/api/models';
|
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 { EVIDENCE_TYPE_OPTIONS } from '../../lib/formatters';
|
||||||
import {
|
import {
|
||||||
X,
|
X,
|
||||||
UploadCloud,
|
UploadCloud,
|
||||||
FileText,
|
FileText,
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
Loader2,
|
Loader2,
|
||||||
Plus,
|
Plus,
|
||||||
Sparkles,
|
Sparkles,
|
||||||
Link as LinkIcon
|
Target,
|
||||||
|
BarChart3,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
interface EvidenceUploadModalProps {
|
interface EvidenceUploadModalProps {
|
||||||
@@ -35,11 +44,43 @@ export const EvidenceUploadModal: React.FC<EvidenceUploadModalProps> = ({
|
|||||||
const [title, setTitle] = useState('');
|
const [title, setTitle] = useState('');
|
||||||
const [evidenceType, setEvidenceType] = useState<number>(4); // Default: FinancialInvoiceOrReceipt
|
const [evidenceType, setEvidenceType] = useState<number>(4); // Default: FinancialInvoiceOrReceipt
|
||||||
const [description, setDescription] = useState('');
|
const [description, setDescription] = useState('');
|
||||||
|
const [selectedIndicatorId, setSelectedIndicatorId] = useState<string>('');
|
||||||
|
const [actualAmount, setActualAmount] = useState<string>('');
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
const [errorMessages, setErrorMessages] = useState<string[]>([]);
|
const [errorMessages, setErrorMessages] = useState<string[]>([]);
|
||||||
const [successMessage, setSuccessMessage] = useState<string | null>(null);
|
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>) => {
|
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
if (e.target.files && e.target.files[0]) {
|
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) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setErrorMessages([]);
|
setErrorMessages([]);
|
||||||
@@ -64,6 +109,22 @@ export const EvidenceUploadModal: React.FC<EvidenceUploadModalProps> = ({
|
|||||||
const numProjectId = toIntegerId(project.id);
|
const numProjectId = toIntegerId(project.id);
|
||||||
const numActivityId = toIntegerId(activity.id);
|
const numActivityId = toIntegerId(activity.id);
|
||||||
const numMilestoneId = toIntegerId(milestone?.id || activity.milestoneId || 0);
|
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) {
|
if (!numProjectId || !numActivityId) {
|
||||||
setErrorMessages(['Cannot upload evidence: Missing projectId or activityId reference.']);
|
setErrorMessages(['Cannot upload evidence: Missing projectId or activityId reference.']);
|
||||||
@@ -73,22 +134,34 @@ export const EvidenceUploadModal: React.FC<EvidenceUploadModalProps> = ({
|
|||||||
setIsSubmitting(true);
|
setIsSubmitting(true);
|
||||||
|
|
||||||
try {
|
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();
|
const formData = new FormData();
|
||||||
formData.append('file', selectedFile);
|
formData.append('file', selectedFile);
|
||||||
formData.append('projectId', String(numProjectId));
|
formData.append('projectId', String(numProjectId));
|
||||||
formData.append('activityId', String(numActivityId));
|
formData.append('activityId', String(numActivityId));
|
||||||
formData.append('milestoneId', String(numMilestoneId));
|
formData.append('milestoneId', String(numMilestoneId));
|
||||||
formData.append('indicatorId', '0');
|
formData.append('indicatorId', String(numIndicatorId));
|
||||||
formData.append('type', String(Number(evidenceType) || 4));
|
formData.append('type', String(Number(evidenceType) || 4));
|
||||||
formData.append('title', title.trim() || selectedFile.name);
|
formData.append('title', title.trim() || selectedFile.name);
|
||||||
if (description.trim()) {
|
if (description.trim()) {
|
||||||
formData.append('description', description.trim());
|
formData.append('description', description.trim());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 2. Upload evidence file
|
||||||
await uploadEvidenceFile(formData);
|
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(() => {
|
setTimeout(() => {
|
||||||
onEvidenceUploaded();
|
onEvidenceUploaded();
|
||||||
onClose();
|
onClose();
|
||||||
@@ -96,15 +169,17 @@ export const EvidenceUploadModal: React.FC<EvidenceUploadModalProps> = ({
|
|||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error('[Evidence Upload Error]:', err);
|
console.error('[Evidence Upload Error]:', err);
|
||||||
const errors = extractApiErrors(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 {
|
} finally {
|
||||||
setIsSubmitting(false);
|
setIsSubmitting(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return createPortal(
|
||||||
<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="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">
|
<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 */}
|
{/* 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" />
|
<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>
|
<div>
|
||||||
<span className="font-medium text-slate-300">Click to choose document</span>
|
<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>
|
||||||
</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 */}
|
{/* Evidence Title & Type */}
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||||
<div>
|
<div>
|
||||||
@@ -207,7 +347,7 @@ export const EvidenceUploadModal: React.FC<EvidenceUploadModalProps> = ({
|
|||||||
type="text"
|
type="text"
|
||||||
placeholder="e.g. Activity Verification Proof"
|
placeholder="e.g. Activity Verification Proof"
|
||||||
value={title}
|
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"
|
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>
|
</div>
|
||||||
@@ -215,10 +355,10 @@ export const EvidenceUploadModal: React.FC<EvidenceUploadModalProps> = ({
|
|||||||
<label className="block text-slate-300 font-medium mb-1">Evidence Type</label>
|
<label className="block text-slate-300 font-medium mb-1">Evidence Type</label>
|
||||||
<select
|
<select
|
||||||
value={evidenceType}
|
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"
|
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">
|
<option key={opt.value} value={opt.value} className="bg-slate-900 text-slate-100">
|
||||||
{opt.label}
|
{opt.label}
|
||||||
</option>
|
</option>
|
||||||
@@ -234,7 +374,7 @@ export const EvidenceUploadModal: React.FC<EvidenceUploadModalProps> = ({
|
|||||||
rows={2}
|
rows={2}
|
||||||
placeholder="Provide verification notes or audit references..."
|
placeholder="Provide verification notes or audit references..."
|
||||||
value={description}
|
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"
|
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>
|
</div>
|
||||||
@@ -262,13 +402,14 @@ export const EvidenceUploadModal: React.FC<EvidenceUploadModalProps> = ({
|
|||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Plus className="w-4 h-4" />
|
<Plus className="w-4 h-4" />
|
||||||
<span>Upload & Attach Evidence</span>
|
<span>Upload & Record Actual</span>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</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,
|
Organisation,
|
||||||
ApprovalStatus
|
ApprovalStatus
|
||||||
} from '../../services/api/models';
|
} from '../../services/api/models';
|
||||||
import { encodeHashIdSync } from '../../services/toolsService';
|
import { encodeHashIdSync, decodeHashIdSync } from '../../services/toolsService';
|
||||||
import {
|
import {
|
||||||
toIntegerId,
|
toIntegerId,
|
||||||
getMilestones,
|
getMilestones,
|
||||||
@@ -24,6 +24,7 @@ import {
|
|||||||
getProjectTypeLabel
|
getProjectTypeLabel
|
||||||
} from '../../lib/formatters';
|
} from '../../lib/formatters';
|
||||||
import { EvidenceUploadModal } from './EvidenceUploadModal';
|
import { EvidenceUploadModal } from './EvidenceUploadModal';
|
||||||
|
import { GovernanceInfoModal } from './GovernanceInfoModal';
|
||||||
import {
|
import {
|
||||||
FolderKanban,
|
FolderKanban,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
@@ -87,6 +88,13 @@ export const ProjectCard: React.FC<ProjectCardProps> = ({
|
|||||||
activity: Activity;
|
activity: Activity;
|
||||||
} | null>(null);
|
} | 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 { project, award, contract, fundingOrganisation, invitedOrganisation, disbursements } = item;
|
||||||
const hashedProjectId = encodeHashIdSync(project.id);
|
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
|
// 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);
|
const num = Number(status);
|
||||||
if (num === ApprovalStatus.Approved || String(status).toLowerCase().includes('approved')) {
|
if (num === ApprovalStatus.Approved || String(status).toLowerCase().includes('approved')) {
|
||||||
return (
|
return (
|
||||||
@@ -154,10 +162,19 @@ export const ProjectCard: React.FC<ProjectCardProps> = ({
|
|||||||
</span>
|
</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>In Progress</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const label = (status !== undefined && status !== null && !isNaN(num)) ? fallback : String(status || fallback);
|
||||||
return (
|
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="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 className="w-1.5 h-1.5 rounded-full bg-teal-400 animate-pulse" />
|
||||||
<span>{String(status || fallback)}</span>
|
<span>{label}</span>
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -279,7 +296,30 @@ export const ProjectCard: React.FC<ProjectCardProps> = ({
|
|||||||
#{hashedProjectId}
|
#{hashedProjectId}
|
||||||
</span>
|
</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 */}
|
{/* Requirement #3: Display enum string description instead of integer value */}
|
||||||
{sectorDescription && (
|
{sectorDescription && (
|
||||||
@@ -504,7 +544,11 @@ export const ProjectCard: React.FC<ProjectCardProps> = ({
|
|||||||
const evs = getEvidenceForActivity(a.id);
|
const evs = getEvidenceForActivity(a.id);
|
||||||
return evs.length > 0 || Number(a.status) === ApprovalStatus.Approved || Number(a.status) === ApprovalStatus.Review;
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -521,13 +565,43 @@ export const ProjectCard: React.FC<ProjectCardProps> = ({
|
|||||||
<span className="font-mono text-[10px] text-slate-400">
|
<span className="font-mono text-[10px] text-slate-400">
|
||||||
(ID: #{encodeHashIdSync(milestone.id)})
|
(ID: #{encodeHashIdSync(milestone.id)})
|
||||||
</span>
|
</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 && (
|
{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">
|
<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" />
|
<ShieldCheck className="w-3 h-3 text-emerald-400" />
|
||||||
<span>Milestone Fulfilled</span>
|
<span>Milestone Fulfilled</span>
|
||||||
</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>
|
</div>
|
||||||
<h5 className="text-base font-bold text-slate-100 mt-1">
|
<h5 className="text-base font-bold text-slate-100 mt-1">
|
||||||
{milestone.name || `Milestone Target #${mIdx + 1}`}
|
{milestone.name || `Milestone Target #${mIdx + 1}`}
|
||||||
|
|||||||
@@ -1,37 +1,32 @@
|
|||||||
import React, { useState, useMemo } from 'react';
|
import React, { useState, useMemo } from 'react';
|
||||||
import {
|
import { Copy, Check } from 'lucide-react';
|
||||||
Copy,
|
|
||||||
Check,
|
|
||||||
Mail,
|
|
||||||
MessageSquare,
|
|
||||||
Code2,
|
|
||||||
Eye,
|
|
||||||
ShieldCheck,
|
|
||||||
} from 'lucide-react';
|
|
||||||
import { NotificationPlatform } from '../../types';
|
import { NotificationPlatform } from '../../types';
|
||||||
import { parseMarkdownToHtml, isHtmlContent, stripMarkdown } from '../../lib/markdownUtils';
|
import { parseMarkdownToHtml, isHtmlContent, stripMarkdown } from '../../lib/markdownUtils';
|
||||||
|
|
||||||
export interface MessageViewerProps {
|
export interface MessageViewerProps {
|
||||||
content: string;
|
content?: string;
|
||||||
|
message?: string;
|
||||||
platform?: NotificationPlatform | number | string;
|
platform?: NotificationPlatform | number | string;
|
||||||
subject?: string;
|
subject?: string;
|
||||||
className?: string;
|
className?: string;
|
||||||
showFormatHeader?: boolean;
|
showFormatHeader?: boolean;
|
||||||
maxHeightClass?: string;
|
maxHeightClass?: string;
|
||||||
|
variant?: string;
|
||||||
|
createdDate?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const MessageViewer: React.FC<MessageViewerProps> = ({
|
export const MessageViewer: React.FC<MessageViewerProps> = ({
|
||||||
content,
|
content,
|
||||||
|
message,
|
||||||
platform,
|
platform,
|
||||||
subject,
|
subject,
|
||||||
className = '',
|
className = '',
|
||||||
showFormatHeader = true,
|
maxHeightClass = 'max-h-[65vh]',
|
||||||
maxHeightClass = 'max-h-[60vh]',
|
|
||||||
}) => {
|
}) => {
|
||||||
|
const rawContent = (content !== undefined && content !== null ? content : message) ?? '';
|
||||||
const [copied, setCopied] = useState(false);
|
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(() => {
|
const isEmail = useMemo(() => {
|
||||||
if (
|
if (
|
||||||
platform === NotificationPlatform.Email ||
|
platform === NotificationPlatform.Email ||
|
||||||
@@ -41,148 +36,81 @@ export const MessageViewer: React.FC<MessageViewerProps> = ({
|
|||||||
) {
|
) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
// If platform is not explicitly InApp, detect if content is structured HTML
|
|
||||||
if (
|
if (
|
||||||
platform !== NotificationPlatform.InApp &&
|
platform !== NotificationPlatform.InApp &&
|
||||||
platform !== 5 &&
|
platform !== 5 &&
|
||||||
platform !== '5' &&
|
platform !== '5' &&
|
||||||
String(platform).toLowerCase() !== 'inapp' &&
|
String(platform).toLowerCase() !== 'inapp' &&
|
||||||
isHtmlContent(content)
|
isHtmlContent(rawContent)
|
||||||
) {
|
) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
}, [platform, content]);
|
}, [platform, rawContent]);
|
||||||
|
|
||||||
const isInApp = useMemo(() => {
|
// Requirement #1: In-App message contents encoded as markdown, rendered for user; Email in HTML
|
||||||
if (
|
// Requirement #8: User is none the wiser of raw form or format in any Messages UI
|
||||||
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
|
|
||||||
const renderedHtml = useMemo(() => {
|
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, '');
|
|
||||||
}
|
|
||||||
return parseMarkdownToHtml(content);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Format 2: In-App => Markup (Markdown)
|
if (isEmail) {
|
||||||
return parseMarkdownToHtml(content);
|
if (isHtmlContent(rawContent)) {
|
||||||
}, [content, isEmail]);
|
return rawContent.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, '');
|
||||||
|
}
|
||||||
|
return parseMarkdownToHtml(rawContent);
|
||||||
|
}
|
||||||
|
|
||||||
|
// In-App: parsed from Markdown to styled HTML
|
||||||
|
return parseMarkdownToHtml(rawContent);
|
||||||
|
}, [rawContent, isEmail]);
|
||||||
|
|
||||||
const handleCopy = () => {
|
const handleCopy = () => {
|
||||||
const textToCopy = showRaw ? content : stripMarkdown(content) || content;
|
const textToCopy = stripMarkdown(rawContent) || rawContent;
|
||||||
navigator.clipboard.writeText(textToCopy);
|
navigator.clipboard.writeText(textToCopy);
|
||||||
setCopied(true);
|
setCopied(true);
|
||||||
setTimeout(() => setCopied(false), 2000);
|
setTimeout(() => setCopied(false), 2000);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`flex flex-col rounded-2xl bg-slate-900/50 border border-white/10 overflow-hidden w-full max-w-full ${className}`}>
|
<div className={`flex flex-col rounded-2xl bg-slate-900/60 border border-white/10 overflow-hidden w-full max-w-full ${className}`}>
|
||||||
{/* Format Header & Controls Bar */}
|
{/* Top action toolbar - clean without technical format metadata */}
|
||||||
{showFormatHeader && (
|
<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">
|
||||||
<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">
|
<span className="text-xs font-semibold text-slate-200 truncate">
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
{subject || 'Message Content'}
|
||||||
{isEmail ? (
|
</span>
|
||||||
<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>
|
|
||||||
</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">
|
{/* Copy Clean Text */}
|
||||||
{isEmail ? 'Rendered as HTML Email' : 'Converted from Markup to Formatted View'}
|
<button
|
||||||
</span>
|
type="button"
|
||||||
</div>
|
onClick={handleCopy}
|
||||||
|
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"
|
||||||
<div className="flex items-center gap-1.5 shrink-0">
|
title="Copy message content"
|
||||||
{/* View Mode Toggle: Formatted vs Raw */}
|
>
|
||||||
<button
|
{copied ? (
|
||||||
type="button"
|
<>
|
||||||
onClick={() => setShowRaw(!showRaw)}
|
<Check className="w-3.5 h-3.5 text-emerald-400" />
|
||||||
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"
|
<span className="text-emerald-300">Copied</span>
|
||||||
title={showRaw ? 'Switch to Formatted view' : 'View Raw message content'}
|
</>
|
||||||
>
|
) : (
|
||||||
{showRaw ? (
|
<>
|
||||||
<>
|
<Copy className="w-3.5 h-3.5 text-slate-400" />
|
||||||
<Eye className="w-3 h-3 text-emerald-400" />
|
<span>Copy</span>
|
||||||
<span>Formatted</span>
|
</>
|
||||||
</>
|
)}
|
||||||
) : (
|
</button>
|
||||||
<>
|
|
||||||
<Code2 className="w-3 h-3" />
|
|
||||||
<span>Raw</span>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* Copy Button */}
|
|
||||||
<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"
|
|
||||||
title="Copy message content"
|
|
||||||
>
|
|
||||||
{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>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 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>
|
|
||||||
) : (
|
|
||||||
<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>
|
</div>
|
||||||
|
|
||||||
{/* Footer Info Stamp */}
|
{/* Rendered Body - Prose & Markdown styling */}
|
||||||
<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
|
||||||
<div className="flex items-center gap-1.5">
|
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]`}
|
||||||
<ShieldCheck className="w-3.5 h-3.5 text-emerald-400" />
|
>
|
||||||
<span>PFM Communications • {isEmail ? 'Email HTML Format' : 'In-App Markup Format'}</span>
|
<div
|
||||||
</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"
|
||||||
<span className="font-mono text-[10px] text-slate-400">
|
dangerouslySetInnerHTML={{ __html: renderedHtml }}
|
||||||
{stripMarkdown(content).length} characters
|
/>
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</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({
|
const sendMessageMutation = useMutation({
|
||||||
mutationFn: async (payload: {
|
mutationFn: async (payload: {
|
||||||
recipientId: string;
|
recipientId?: string;
|
||||||
recipientEmail: string;
|
recipientEmail?: string;
|
||||||
|
recipient?: string;
|
||||||
recipientName?: string;
|
recipientName?: string;
|
||||||
subject: string;
|
subject: string;
|
||||||
message: string;
|
message: string;
|
||||||
@@ -280,11 +281,13 @@ export function useInAppNotifications() {
|
|||||||
}) => {
|
}) => {
|
||||||
const orgId = payload.organisationId || getCurrentUserTenantId() || 1;
|
const orgId = payload.organisationId || getCurrentUserTenantId() || 1;
|
||||||
const targetPlatform = payload.platform || NotificationPlatform.InApp;
|
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 = {
|
const req: CreateNotificationRequest = {
|
||||||
organisationId: orgId,
|
organisationId: orgId,
|
||||||
recipient: payload.recipientId,
|
recipient: id,
|
||||||
platform: targetPlatform,
|
platform: targetPlatform,
|
||||||
subject: payload.subject,
|
subject: payload.subject,
|
||||||
destination: formattedDestination,
|
destination: formattedDestination,
|
||||||
|
|||||||
@@ -149,3 +149,79 @@ export function parseMarkdownToHtml(markdown: string): string {
|
|||||||
return html;
|
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