PartnerUI evidence, notification, indicators and actuals fixes

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