Restyled concoleui notification tab
This commit is contained in:
@@ -73,6 +73,8 @@ import { useToast } from '../components/ui/Toast';
|
||||
import { ExportButton } from '../components/ui/ExportButton';
|
||||
import { ExportColumn } from '../utils/exportUtils';
|
||||
import { useTableSort, SortableTh } from '../utils/tableSort';
|
||||
import { MarkdownViewer } from '../components/notifications/MarkdownViewer';
|
||||
import { isHtmlContent, stripMarkdown } from '../lib/markdownUtils';
|
||||
import { UserRoleBadge } from '../components/ui/StatusBadge';
|
||||
import { useAuth } from '../auth/AuthProvider';
|
||||
import {
|
||||
@@ -439,6 +441,9 @@ export const SettingsPage: React.FC = () => {
|
||||
const [selectedNotificationForDetail, setSelectedNotificationForDetail] = useState<Notification | null>(null);
|
||||
const [isNotificationDetailModalOpen, setIsNotificationDetailModalOpen] = useState(false);
|
||||
const [copiedNotificationPayload, setCopiedNotificationPayload] = useState(false);
|
||||
const [isNotificationRenderModalOpen, setIsNotificationRenderModalOpen] = useState(false);
|
||||
const [renderedViewTab, setRenderedViewTab] = useState<'rendered' | 'source'>('rendered');
|
||||
const [copiedRenderedContent, setCopiedRenderedContent] = useState(false);
|
||||
|
||||
const effectiveNotificationOrgId =
|
||||
notificationOrgFilter || (organisations[0]?.id ? String(organisations[0].id) : '1');
|
||||
@@ -455,7 +460,7 @@ export const SettingsPage: React.FC = () => {
|
||||
});
|
||||
|
||||
const notificationExportColumns: ExportColumn<Notification>[] = [
|
||||
{ header: 'Notification ID', key: 'id' },
|
||||
{ header: 'ID', key: 'id' },
|
||||
{ header: 'Created Date', key: 'createdAt', format: (val) => formatDate(val) },
|
||||
{ header: 'Sent Date', key: 'sentAt', format: (val) => (val ? formatDate(val) : 'Pending/Queued') },
|
||||
{ header: 'Platform / Channel', key: 'platform', format: (val) => getNotificationPlatformLabel(val) },
|
||||
@@ -463,7 +468,14 @@ export const SettingsPage: React.FC = () => {
|
||||
{ header: 'Subject', key: 'subject', format: (val) => val || '—' },
|
||||
{ header: 'Recipient UUID', key: 'recipient' },
|
||||
{ header: 'Destination', key: 'destination', format: (val) => val || '—' },
|
||||
{ header: 'Target Organisation', key: 'organisationId', format: (val) => `Org #${val}` },
|
||||
{
|
||||
header: 'Target Organisation',
|
||||
key: 'organisationId',
|
||||
format: (val) => {
|
||||
const org = organisations.find((o) => String(o.id) === String(val));
|
||||
return org?.name || `Org #${val}`;
|
||||
},
|
||||
},
|
||||
{ header: 'Message Content', key: 'message', format: (val) => val || '—' },
|
||||
{ header: 'Error Diagnostics', key: 'error', format: (val) => val || 'None' },
|
||||
];
|
||||
@@ -483,13 +495,16 @@ export const SettingsPage: React.FC = () => {
|
||||
const recip = (item.recipient || '').toLowerCase();
|
||||
const dest = (item.destination || '').toLowerCase();
|
||||
const err = (item.error || '').toLowerCase();
|
||||
const org = organisations.find((o) => String(o.id) === String(item.organisationId));
|
||||
const orgName = (org?.name || '').toLowerCase();
|
||||
return (
|
||||
id.includes(q) ||
|
||||
subj.includes(q) ||
|
||||
msg.includes(q) ||
|
||||
recip.includes(q) ||
|
||||
dest.includes(q) ||
|
||||
err.includes(q)
|
||||
err.includes(q) ||
|
||||
orgName.includes(q)
|
||||
);
|
||||
});
|
||||
|
||||
@@ -498,7 +513,13 @@ export const SettingsPage: React.FC = () => {
|
||||
sortColumn: notifSortCol,
|
||||
sortDirection: notifSortDir,
|
||||
handleSort: handleNotifSort,
|
||||
} = useTableSort(filteredNotifications, 'createdAt', 'desc');
|
||||
} = useTableSort(filteredNotifications, 'createdAt', 'desc', {
|
||||
organisationId: (item: Notification) => {
|
||||
const org = organisations.find((o) => String(o.id) === String(item.organisationId));
|
||||
return org?.name || `Organisation #${item.organisationId}`;
|
||||
},
|
||||
destination: (item: Notification) => item.destination || '',
|
||||
});
|
||||
|
||||
const resolveInvitePlatform = (inv: Invite): NotificationPlatform => {
|
||||
if (inv.platform !== undefined && inv.platform !== null) {
|
||||
@@ -1664,11 +1685,11 @@ export const SettingsPage: React.FC = () => {
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto min-w-0 w-full">
|
||||
<table className="w-full text-left border-collapse text-xs min-w-[900px]">
|
||||
<table className="w-full text-left border-collapse text-xs min-w-[760px]">
|
||||
<thead>
|
||||
<tr className="border-b border-slate-800 bg-slate-900/40 text-slate-400 font-medium">
|
||||
<SortableTh column="id" currentColumn={notifSortCol} direction={notifSortDir} onSort={handleNotifSort} className="py-3.5 px-4">
|
||||
Notification ID
|
||||
<SortableTh column="id" currentColumn={notifSortCol} direction={notifSortDir} onSort={handleNotifSort} className="py-3.5 px-3 w-20">
|
||||
ID
|
||||
</SortableTh>
|
||||
<SortableTh column="platform" currentColumn={notifSortCol} direction={notifSortDir} onSort={handleNotifSort} className="py-3.5 px-4">
|
||||
Channel / Platform
|
||||
@@ -1676,11 +1697,8 @@ export const SettingsPage: React.FC = () => {
|
||||
<SortableTh column="status" currentColumn={notifSortCol} direction={notifSortDir} onSort={handleNotifSort} className="py-3.5 px-4">
|
||||
Status
|
||||
</SortableTh>
|
||||
<SortableTh column="subject" currentColumn={notifSortCol} direction={notifSortDir} onSort={handleNotifSort} className="py-3.5 px-4">
|
||||
Subject & Message Payload
|
||||
</SortableTh>
|
||||
<SortableTh column="recipient" currentColumn={notifSortCol} direction={notifSortDir} onSort={handleNotifSort} className="py-3.5 px-4">
|
||||
Recipient & Destination
|
||||
<SortableTh column="destination" currentColumn={notifSortCol} direction={notifSortDir} onSort={handleNotifSort} className="py-3.5 px-4">
|
||||
Destination
|
||||
</SortableTh>
|
||||
<SortableTh column="organisationId" currentColumn={notifSortCol} direction={notifSortDir} onSort={handleNotifSort} className="py-3.5 px-4">
|
||||
Target Org
|
||||
@@ -1694,14 +1712,14 @@ export const SettingsPage: React.FC = () => {
|
||||
<tbody className="divide-y divide-slate-800/50">
|
||||
{loadingNotifications ? (
|
||||
<tr>
|
||||
<td colSpan={8} className="py-12 text-center text-slate-500">
|
||||
<td colSpan={7} className="py-12 text-center text-slate-500">
|
||||
<RefreshCw className="w-6 h-6 animate-spin mx-auto mb-2 text-blue-400" />
|
||||
Loading communication records for Organisation #{effectiveNotificationOrgId}...
|
||||
</td>
|
||||
</tr>
|
||||
) : sortedNotifications.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={8} className="py-12 text-center text-slate-500">
|
||||
<td colSpan={7} className="py-12 text-center text-slate-500">
|
||||
<Bell className="w-8 h-8 text-slate-600 mx-auto mb-2" />
|
||||
No notification records returned for Organisation #{effectiveNotificationOrgId}.
|
||||
</td>
|
||||
@@ -1709,9 +1727,11 @@ export const SettingsPage: React.FC = () => {
|
||||
) : (
|
||||
sortedNotifications.map((item) => {
|
||||
const statusBadge = getNotificationStatusBadge(item.status);
|
||||
const org = organisations.find((o) => String(o.id) === String(item.organisationId));
|
||||
const orgName = org?.name || (item.organisationId ? `Organisation #${item.organisationId}` : '—');
|
||||
return (
|
||||
<tr key={item.id} className="hover:bg-slate-800/40 transition-colors">
|
||||
<td className="py-3 px-4 font-mono text-slate-300 font-semibold text-[11px]">
|
||||
<td className="py-3 px-3 font-mono text-slate-300 font-semibold text-[11px] w-20 whitespace-nowrap">
|
||||
#{item.id}
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
@@ -1726,22 +1746,13 @@ export const SettingsPage: React.FC = () => {
|
||||
{statusBadge.label}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-3 px-4 max-w-xs">
|
||||
<div className="text-white font-medium truncate">{item.subject || 'Statutory Notice'}</div>
|
||||
<div className="text-slate-400 text-[11px] truncate">{item.message || '—'}</div>
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
<div className="font-mono text-slate-300 text-[11px] truncate max-w-[180px]" title={item.recipient}>
|
||||
{item.recipient}
|
||||
</div>
|
||||
{item.destination && (
|
||||
<div className="font-mono text-slate-400 text-[10px] truncate max-w-[180px]" title={item.destination}>
|
||||
{item.destination}
|
||||
</div>
|
||||
)}
|
||||
<span className="font-mono text-slate-300 text-[11px] truncate max-w-[200px] block" title={item.destination || '—'}>
|
||||
{item.destination || '—'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-3 px-4 text-slate-400 text-[11px]">
|
||||
Org #{item.organisationId}
|
||||
<td className="py-3 px-4 text-slate-300 text-xs font-medium">
|
||||
<span title={`Organisation #${item.organisationId}`}>{orgName}</span>
|
||||
</td>
|
||||
<td className="py-3 px-4 font-mono text-slate-400 text-[11px]">
|
||||
{formatDate(item.createdAt)}
|
||||
@@ -3001,8 +3012,19 @@ export const SettingsPage: React.FC = () => {
|
||||
|
||||
{/* Message */}
|
||||
<div>
|
||||
<span className="text-slate-400 font-semibold block text-[11px] uppercase mb-1">Message Content:</span>
|
||||
<div className="p-3 rounded-lg bg-slate-900 border border-slate-800 text-slate-200 leading-relaxed font-sans text-xs whitespace-pre-wrap">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className="text-slate-400 font-semibold block text-[11px] uppercase">Message Content:</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsNotificationRenderModalOpen(true)}
|
||||
className="inline-flex items-center gap-1.5 px-2.5 py-1 text-[11px] font-semibold text-blue-400 hover:text-white bg-blue-500/10 hover:bg-blue-600 border border-blue-500/30 hover:border-blue-500 rounded-lg transition cursor-pointer shadow-xs"
|
||||
title="Pop up rendered message content"
|
||||
>
|
||||
<Eye className="w-3.5 h-3.5" />
|
||||
<span>Read Rendered Contents</span>
|
||||
</button>
|
||||
</div>
|
||||
<div className="p-3 rounded-lg bg-slate-900 border border-slate-800 text-slate-200 leading-relaxed font-sans text-xs whitespace-pre-wrap max-h-48 overflow-y-auto">
|
||||
{selectedNotificationForDetail.message || '—'}
|
||||
</div>
|
||||
</div>
|
||||
@@ -3026,8 +3048,10 @@ export const SettingsPage: React.FC = () => {
|
||||
{/* Organisation & Tenant Grid */}
|
||||
<div className="grid grid-cols-2 gap-3 border-t border-slate-800/80 pt-2.5">
|
||||
<div>
|
||||
<span className="text-slate-400 font-semibold block text-[11px] uppercase">Organisation ID:</span>
|
||||
<span className="text-white font-medium">Org #{selectedNotificationForDetail.organisationId}</span>
|
||||
<span className="text-slate-400 font-semibold block text-[11px] uppercase">Target Organisation:</span>
|
||||
<span className="text-white font-medium">
|
||||
{organisations.find((o) => String(o.id) === String(selectedNotificationForDetail.organisationId))?.name || `Org #${selectedNotificationForDetail.organisationId}`}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-slate-400 font-semibold block text-[11px] uppercase">Tenant Scope:</span>
|
||||
@@ -3109,13 +3133,22 @@ export const SettingsPage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-2 flex justify-end gap-2">
|
||||
<div className="pt-2 flex justify-between items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsNotificationRenderModalOpen(true)}
|
||||
className="inline-flex items-center gap-1.5 px-3.5 py-2 text-xs font-semibold text-white bg-blue-600 hover:bg-blue-500 rounded-xl transition cursor-pointer shadow-lg shadow-blue-600/25"
|
||||
>
|
||||
<Eye className="w-3.5 h-3.5" />
|
||||
<span>Read Rendered Contents</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setIsNotificationDetailModalOpen(false);
|
||||
setSelectedNotificationForDetail(null);
|
||||
setCopiedNotificationPayload(false);
|
||||
setIsNotificationRenderModalOpen(false);
|
||||
}}
|
||||
className="px-4 py-2 text-xs font-semibold text-white bg-slate-800 hover:bg-slate-700 rounded-xl transition cursor-pointer"
|
||||
>
|
||||
@@ -3125,6 +3158,209 @@ export const SettingsPage: React.FC = () => {
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
{/* ============================================================== */}
|
||||
{/* Modal: Rendered Notification Contents Preview */}
|
||||
{/* ============================================================== */}
|
||||
<Modal
|
||||
isOpen={isNotificationRenderModalOpen}
|
||||
onClose={() => setIsNotificationRenderModalOpen(false)}
|
||||
title={`Rendered Notification: #${selectedNotificationForDetail?.id || ''}`}
|
||||
subtitle={
|
||||
selectedNotificationForDetail
|
||||
? `${getNotificationPlatformLabel(selectedNotificationForDetail.platform)} Delivery View • ${
|
||||
selectedNotificationForDetail.platform === NotificationPlatform.InApp ? 'Markdown Format' : 'HTML Format'
|
||||
}`
|
||||
: 'Rendered message output'
|
||||
}
|
||||
maxWidth="4xl"
|
||||
zIndex="z-[10000]"
|
||||
>
|
||||
{selectedNotificationForDetail && (() => {
|
||||
const isEmail = selectedNotificationForDetail.platform === NotificationPlatform.Email;
|
||||
const isHtml = isEmail || isHtmlContent(selectedNotificationForDetail.message || '');
|
||||
const targetOrg = organisations.find(
|
||||
(o) => String(o.id) === String(selectedNotificationForDetail.organisationId)
|
||||
);
|
||||
const orgName = targetOrg?.name || `Organisation #${selectedNotificationForDetail.organisationId}`;
|
||||
|
||||
return (
|
||||
<div className="space-y-4 text-xs">
|
||||
{/* Header Info Bar */}
|
||||
<div className="p-3.5 bg-[#0d121f] rounded-xl border border-slate-800 flex flex-wrap items-center justify-between gap-2.5">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="inline-flex items-center px-2 py-0.5 rounded text-[10px] font-semibold bg-blue-500/10 text-blue-400 border border-blue-500/20">
|
||||
{getNotificationPlatformLabel(selectedNotificationForDetail.platform)}
|
||||
</span>
|
||||
<span className="inline-flex items-center px-2 py-0.5 rounded text-[10px] font-semibold bg-purple-500/10 text-purple-400 border border-purple-500/20">
|
||||
{isHtml ? 'HTML Format' : 'Markdown Format'}
|
||||
</span>
|
||||
<span className="text-slate-400 text-[11px]">
|
||||
Target Org: <strong className="text-white font-medium">{orgName}</strong>
|
||||
</span>
|
||||
{selectedNotificationForDetail.destination && (
|
||||
<span className="text-slate-400 text-[11px] font-mono">
|
||||
Destination: <strong className="text-slate-200">{selectedNotificationForDetail.destination}</strong>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center bg-slate-900 border border-slate-700/80 rounded-lg p-0.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setRenderedViewTab('rendered')}
|
||||
className={`px-2.5 py-1 rounded text-[11px] font-semibold transition cursor-pointer ${
|
||||
renderedViewTab === 'rendered'
|
||||
? 'bg-blue-600 text-white shadow-xs'
|
||||
: 'text-slate-400 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
Rendered
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setRenderedViewTab('source')}
|
||||
className={`px-2.5 py-1 rounded text-[11px] font-semibold transition cursor-pointer ${
|
||||
renderedViewTab === 'source'
|
||||
? 'bg-blue-600 text-white shadow-xs'
|
||||
: 'text-slate-400 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
Raw Source
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const textToCopy =
|
||||
renderedViewTab === 'source'
|
||||
? selectedNotificationForDetail.message || ''
|
||||
: isHtml
|
||||
? selectedNotificationForDetail.message || ''
|
||||
: stripMarkdown(selectedNotificationForDetail.message || '');
|
||||
navigator.clipboard.writeText(textToCopy);
|
||||
setCopiedRenderedContent(true);
|
||||
setTimeout(() => setCopiedRenderedContent(false), 2000);
|
||||
}}
|
||||
className="inline-flex items-center gap-1 px-2.5 py-1 rounded-lg bg-slate-800 hover:bg-slate-700 text-slate-300 hover:text-white text-[11px] font-medium border border-slate-700 transition cursor-pointer"
|
||||
title="Copy message contents"
|
||||
>
|
||||
{copiedRenderedContent ? (
|
||||
<>
|
||||
<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>
|
||||
|
||||
{/* Subject */}
|
||||
{selectedNotificationForDetail.subject && (
|
||||
<div className="px-4 py-2.5 bg-slate-900/60 rounded-xl border border-slate-800">
|
||||
<span className="text-[10px] uppercase font-semibold text-slate-500 block">Subject</span>
|
||||
<div className="text-sm font-semibold text-white">
|
||||
{selectedNotificationForDetail.subject}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Body Content */}
|
||||
{renderedViewTab === 'source' ? (
|
||||
<div className="space-y-1">
|
||||
<pre className="p-4 rounded-xl bg-[#070a12] border border-slate-800 text-slate-200 font-mono text-xs overflow-x-auto max-h-[480px] whitespace-pre-wrap leading-relaxed">
|
||||
{selectedNotificationForDetail.message || '—'}
|
||||
</pre>
|
||||
</div>
|
||||
) : isHtml ? (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between text-[11px] text-slate-400 px-1">
|
||||
<span className="flex items-center gap-1.5 text-blue-400">
|
||||
<FileText className="w-3.5 h-3.5" />
|
||||
Rendered Email HTML
|
||||
</span>
|
||||
<span className="text-[10px] text-slate-500">Sandboxed HTML Canvas</span>
|
||||
</div>
|
||||
<div className="rounded-xl overflow-hidden border border-slate-700/80 bg-white shadow-xl">
|
||||
<iframe
|
||||
title={`Rendered HTML for Notification #${selectedNotificationForDetail.id}`}
|
||||
srcDoc={
|
||||
selectedNotificationForDetail.message?.trim().startsWith('<!DOCTYPE') ||
|
||||
selectedNotificationForDetail.message?.trim().startsWith('<html')
|
||||
? selectedNotificationForDetail.message
|
||||
: `<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<style>
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
|
||||
margin: 20px;
|
||||
padding: 0;
|
||||
color: #1e293b;
|
||||
line-height: 1.6;
|
||||
background-color: #ffffff;
|
||||
font-size: 14px;
|
||||
}
|
||||
a { color: #2563eb; text-decoration: underline; }
|
||||
h1, h2, h3, h4 { color: #0f172a; margin-top: 1.2em; margin-bottom: 0.5em; }
|
||||
table { width: 100%; border-collapse: collapse; margin: 16px 0; }
|
||||
th { background: #f1f5f9; padding: 8px 12px; text-align: left; border: 1px solid #cbd5e1; font-weight: 600; font-size: 12px; }
|
||||
td { padding: 8px 12px; border: 1px solid #cbd5e1; font-size: 13px; }
|
||||
blockquote { border-left: 4px solid #3b82f6; padding-left: 12px; margin: 12px 0; color: #475569; font-style: italic; }
|
||||
code { background: #f1f5f9; padding: 2px 4px; border-radius: 4px; font-family: monospace; font-size: 12px; }
|
||||
pre { background: #0f172a; color: #f8fafc; padding: 12px; border-radius: 6px; overflow-x: auto; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
${selectedNotificationForDetail.message || '<p style="color: #64748b; font-style: italic;">No message body provided</p>'}
|
||||
</body>
|
||||
</html>`
|
||||
}
|
||||
className="w-full h-[460px] border-0"
|
||||
sandbox="allow-same-origin allow-popups"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between text-[11px] text-slate-400 px-1">
|
||||
<span className="flex items-center gap-1.5 text-blue-400">
|
||||
<FileText className="w-3.5 h-3.5" />
|
||||
Rendered In-App Markdown
|
||||
</span>
|
||||
<span className="text-[10px] text-slate-500">Semantic Markdown</span>
|
||||
</div>
|
||||
<MarkdownViewer
|
||||
content={selectedNotificationForDetail.message || ''}
|
||||
subject={selectedNotificationForDetail.subject || undefined}
|
||||
showBrandingHeader={true}
|
||||
className="max-h-[480px] overflow-y-auto"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="pt-2 flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsNotificationRenderModalOpen(false)}
|
||||
className="px-4 py-2 text-xs font-semibold text-white bg-slate-800 hover:bg-slate-700 rounded-xl transition cursor-pointer"
|
||||
>
|
||||
Back to Inspection
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user