Added PFM Console UI project

This commit is contained in:
2026-09-11 12:13:25 +02:00
parent a15b25248d
commit 99ab5b2ce6
35 changed files with 12231 additions and 0 deletions
@@ -0,0 +1,501 @@
import React, { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import {
Shield,
History,
CheckCircle,
Bell,
Send,
Radio,
Filter,
UserCheck,
} from 'lucide-react';
import {
getAuditLogs,
} from '../services/api';
import { Modal } from '../components/ui/Modal';
import { useToast } from '../components/ui/Toast';
import { useAuth } from '../auth/AuthProvider';
import {
EmailStatuses,
NotificationStatus,
NotificationPlatform,
} from '../types';
import {
formatDate,
getEmailStatusLabel,
getEmailStatusBadge,
getNotificationStatusLabel,
getNotificationStatusBadge,
getNotificationPlatformLabel,
getUserEmail,
EMAIL_STATUSES_OPTIONS,
NOTIFICATION_STATUS_OPTIONS,
NOTIFICATION_PLATFORM_OPTIONS,
} from '../lib/formatters';
interface NotificationItem {
id: number;
recipient: string;
platform: NotificationPlatform;
status: NotificationStatus;
subject: string;
sentAt: string;
errorMessage?: string;
}
export const SettingsPage: React.FC = () => {
const { user } = useAuth();
const { success, error: toastError } = useToast();
const [activeTab, setActiveTab] = useState<'notifications' | 'audit' | 'profile'>('notifications');
const [isSendNotificationModalOpen, setIsSendNotificationModalOpen] = useState(false);
// Email Gateway & Notification Engine states
const [emailGatewayStatus, setEmailGatewayStatus] = useState<EmailStatuses>(EmailStatuses.Connected);
const [selectedPlatformFilter, setSelectedPlatformFilter] = useState<number | 'all'>('all');
const [selectedStatusFilter, setSelectedStatusFilter] = useState<number | 'all'>('all');
// Dispatch Form State
const [dispatchPlatform, setDispatchPlatform] = useState<NotificationPlatform>(NotificationPlatform.Email);
const [dispatchStatus, setDispatchStatus] = useState<NotificationStatus>(NotificationStatus.Sent);
const [dispatchRecipient, setDispatchRecipient] = useState('');
const [dispatchSubject, setDispatchSubject] = useState('');
const [notifications, setNotifications] = useState<NotificationItem[]>([]);
// Queries
const { data: auditLogs = [], isLoading: loadingAudit } = useQuery({
queryKey: ['auditLogs'],
queryFn: () => getAuditLogs(),
});
return (
<div className="space-y-6 max-w-7xl mx-auto">
{/* Header */}
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 bg-[#0f1523]/80 backdrop-blur-md p-6 rounded-2xl border border-slate-800/80 shadow-xl">
<div>
<h2 className="text-2xl font-bold text-white tracking-tight">
System Settings & Communications
</h2>
<p className="text-xs text-slate-400 mt-1">
Communication dispatch gateways, statutory audit trail, and active session claims.
</p>
</div>
{activeTab === 'notifications' && (
<button
onClick={() => setIsSendNotificationModalOpen(true)}
className="inline-flex items-center gap-1.5 px-4 py-2 text-xs font-semibold text-white bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-500 hover:to-indigo-500 rounded-xl shadow-lg shadow-blue-500/20 transition"
>
<Send className="w-4 h-4" />
Dispatch Notification
</button>
)}
</div>
{/* Tabs */}
<div className="flex gap-2 border-b border-slate-800/80 pb-3 overflow-x-auto">
<button
onClick={() => setActiveTab('notifications')}
className={`px-4 py-2 text-xs font-medium rounded-xl transition flex items-center gap-2 whitespace-nowrap ${activeTab === 'notifications'
? 'bg-blue-600 text-white shadow-lg shadow-blue-600/30'
: 'text-slate-400 hover:text-slate-200 hover:bg-slate-800/50'
}`}
>
<Bell className="w-4 h-4" />
Notifications & Email Gateways ({notifications.length})
</button>
<button
onClick={() => setActiveTab('audit')}
className={`px-4 py-2 text-xs font-medium rounded-xl transition flex items-center gap-2 whitespace-nowrap ${activeTab === 'audit'
? 'bg-blue-600 text-white shadow-lg shadow-blue-600/30'
: 'text-slate-400 hover:text-slate-200 hover:bg-slate-800/50'
}`}
>
<History className="w-4 h-4" />
Statutory Audit Trail ({auditLogs.length})
</button>
<button
onClick={() => setActiveTab('profile')}
className={`px-4 py-2 text-xs font-medium rounded-xl transition flex items-center gap-2 whitespace-nowrap ${activeTab === 'profile'
? 'bg-blue-600 text-white shadow-lg shadow-blue-600/30'
: 'text-slate-400 hover:text-slate-200 hover:bg-slate-800/50'
}`}
>
<UserCheck className="w-4 h-4" />
Session & Token Claims
</button>
</div>
{/* Tab 1: Notifications & Email Gateways */}
{activeTab === 'notifications' && (
<div className="space-y-6">
{/* Status KPI Cards */}
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<div className="bg-[#0f1523]/80 backdrop-blur-md p-5 rounded-2xl border border-slate-800/80 shadow-xl space-y-2">
<div className="flex items-center justify-between">
<span className="text-xs font-semibold text-slate-400">Email Gateway Status</span>
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-[10px] font-semibold border ${getEmailStatusBadge(emailGatewayStatus).className}`}>
{getEmailStatusBadge(emailGatewayStatus).label}
</span>
</div>
<div className="pt-1">
<label className="block text-[11px] font-medium text-slate-400 mb-1.5">Update Gateway Status:</label>
<select
value={emailGatewayStatus}
onChange={(e) => {
const newStatus = Number(e.target.value) as EmailStatuses;
setEmailGatewayStatus(newStatus);
success('Gateway State Updated', `Email service transitioned to ${getEmailStatusLabel(newStatus)}`);
}}
className="w-full text-xs bg-slate-900/80 border border-slate-700/80 rounded-xl px-2.5 py-1.5 text-white focus:outline-none focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500 font-medium transition [&>option]:bg-slate-900 [&>option]:text-white"
>
{EMAIL_STATUSES_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
</div>
<div className="bg-[#0f1523]/80 backdrop-blur-md p-5 rounded-2xl border border-slate-800/80 shadow-xl space-y-1">
<span className="text-xs font-semibold text-slate-400">Queued / Active Pipeline</span>
<div className="text-2xl font-bold font-mono text-white">
{notifications.filter((n) => n.status === NotificationStatus.Pending || n.status === NotificationStatus.Queued || n.status === NotificationStatus.Processing).length}
</div>
<span className="text-[11px] text-slate-400">Automated retry scheduler active</span>
</div>
<div className="bg-[#0f1523]/80 backdrop-blur-md p-5 rounded-2xl border border-slate-800/80 shadow-xl space-y-1">
<span className="text-xs font-semibold text-slate-400">Delivery Success Ratio</span>
<div className="text-2xl font-bold font-mono text-emerald-400">97.4%</div>
<span className="text-[11px] text-slate-400">Cross-platform SLA target: ≥95.0%</span>
</div>
</div>
{/* Filter Bar */}
<div className="bg-[#0f1523]/80 backdrop-blur-md p-4 rounded-2xl border border-slate-800/80 shadow-xl flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-2 text-xs font-semibold text-slate-300">
<Filter className="w-4 h-4 text-slate-400" />
Filter Notification Logs:
</div>
<div className="flex flex-wrap items-center gap-3">
<div className="flex items-center gap-1.5">
<label className="text-xs text-slate-400 font-medium">Platform:</label>
<select
value={selectedPlatformFilter}
onChange={(e) => setSelectedPlatformFilter(e.target.value === 'all' ? 'all' : Number(e.target.value))}
className="text-xs bg-slate-900/80 border border-slate-700/80 rounded-xl px-2.5 py-1.5 text-white focus:outline-none focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500 font-medium transition [&>option]:bg-slate-900 [&>option]:text-white"
>
<option value="all">All Channels ({NOTIFICATION_PLATFORM_OPTIONS.length})</option>
{NOTIFICATION_PLATFORM_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
<div className="flex items-center gap-1.5">
<label className="text-xs text-slate-400 font-medium">Status:</label>
<select
value={selectedStatusFilter}
onChange={(e) => setSelectedStatusFilter(e.target.value === 'all' ? 'all' : Number(e.target.value))}
className="text-xs bg-slate-900/80 border border-slate-700/80 rounded-xl px-2.5 py-1.5 text-white focus:outline-none focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500 font-medium transition [&>option]:bg-slate-900 [&>option]:text-white"
>
<option value="all">All Statuses ({NOTIFICATION_STATUS_OPTIONS.length})</option>
{NOTIFICATION_STATUS_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
</div>
</div>
{/* Notifications Table */}
<div className="bg-[#0f1523]/80 backdrop-blur-md rounded-2xl border border-slate-800/80 shadow-xl overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-left text-xs border-collapse">
<thead>
<tr className="bg-slate-900/60 border-b border-slate-800/80 text-slate-400 uppercase tracking-wider font-semibold">
<th className="py-3.5 px-5">Platform Channel</th>
<th className="py-3.5 px-4">Recipient Destination</th>
<th className="py-3.5 px-4">Subject / Notification Message</th>
<th className="py-3.5 px-4">Dispatch Timestamp</th>
<th className="py-3.5 px-4">Status</th>
<th className="py-3.5 px-5 text-right">Action</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-800/50">
{notifications
.filter((n) => selectedPlatformFilter === 'all' || n.platform === selectedPlatformFilter)
.filter((n) => selectedStatusFilter === 'all' || n.status === selectedStatusFilter)
.map((item) => {
const statusBadge = getNotificationStatusBadge(item.status);
return (
<tr key={item.id} className="hover:bg-slate-800/40 transition-colors">
<td className="py-3.5 px-5">
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg text-[11px] font-semibold bg-slate-800/80 text-slate-200 border border-slate-700/80">
<Radio className="w-3 h-3 text-blue-400" />
{getNotificationPlatformLabel(item.platform)}
</span>
</td>
<td className="py-3.5 px-4 font-mono font-semibold text-white">
{item.recipient}
</td>
<td className="py-3.5 px-4 max-w-xs truncate text-slate-300">
<div className="font-semibold text-white truncate">{item.subject}</div>
{item.errorMessage && (
<div className="text-[10px] text-rose-400 font-mono mt-0.5">{item.errorMessage}</div>
)}
</td>
<td className="py-3.5 px-4 text-slate-400 font-mono text-[11px]">
{formatDate(item.sentAt)}
</td>
<td className="py-3.5 px-4">
<select
value={item.status}
onChange={(e) => {
const newStatus = Number(e.target.value) as NotificationStatus;
setNotifications((prev) =>
prev.map((n) => (n.id === item.id ? { ...n, status: newStatus } : n))
);
success('Status Updated', `Notification #${item.id} status updated to ${getNotificationStatusLabel(newStatus)}`);
}}
className={`text-[10px] font-semibold border rounded-full px-2 py-0.5 bg-slate-900 cursor-pointer ${statusBadge.className} [&>option]:bg-slate-900 [&>option]:text-white`}
>
{NOTIFICATION_STATUS_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</td>
<td className="py-3.5 px-5 text-right">
<button
onClick={() => {
setNotifications((prev) =>
prev.map((n) =>
n.id === item.id
? { ...n, status: NotificationStatus.Delivered, errorMessage: undefined, sentAt: new Date().toISOString() }
: n
)
);
success('Re-dispatched', `Notification #${item.id} re-sent successfully via ${getNotificationPlatformLabel(item.platform)}`);
}}
className="px-2.5 py-1 text-[11px] font-semibold text-slate-300 hover:text-white bg-slate-800/80 hover:bg-slate-700 rounded-lg border border-slate-700/60 transition"
>
Retry / Resend
</button>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
</div>
)}
{/* Tab 2: Statutory Audit Trail */}
{activeTab === 'audit' && (
<div className="bg-[#0f1523]/80 backdrop-blur-md rounded-2xl border border-slate-800/80 shadow-xl overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-left text-xs border-collapse">
<thead>
<tr className="bg-slate-900/60 border-b border-slate-800/80 text-slate-400 uppercase tracking-wider font-semibold">
<th className="py-3.5 px-5">Timestamp</th>
<th className="py-3.5 px-4">Action Type</th>
<th className="py-3.5 px-4">Target Entity</th>
<th className="py-3.5 px-4">Actor Email</th>
<th className="py-3.5 px-5 text-right">Origin Details</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-800/50">
{loadingAudit ? (
<tr>
<td colSpan={5} className="py-8 text-center text-slate-500">
Loading audit log entries...
</td>
</tr>
) : (
auditLogs.map((log) => (
<tr key={log.id} className="hover:bg-slate-800/40 transition-colors">
<td className="py-3 px-5 text-slate-400 font-mono">{formatDate(log.createdAt)}</td>
<td className="py-3 px-4 font-bold text-white">{log.action}</td>
<td className="py-3 px-4 text-blue-400 font-medium font-mono">
{log.entityType} #{log.entityId}
</td>
<td className="py-3 px-4 font-mono font-medium text-slate-200">
{getUserEmail(log.userEmail)}
</td>
<td className="py-3 px-5 text-right text-slate-400 font-mono text-[11px]">
{log.details || 'Web Session'}
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
)}
{/* Tab 3: Session & Token Claims */}
{activeTab === 'profile' && (
<div className="max-w-2xl bg-[#0f1523]/80 backdrop-blur-md p-6 rounded-2xl border border-slate-800/80 shadow-xl space-y-5">
<div className="flex items-center gap-3 pb-4 border-b border-slate-800/80">
<div className="h-12 w-12 rounded-full bg-gradient-to-r from-blue-600 to-indigo-600 flex items-center justify-center font-bold text-white text-base shadow-lg shadow-blue-500/25">
{user?.name?.slice(0, 2).toUpperCase() || 'PF'}
</div>
<div>
<h3 className="text-base font-bold text-white">{user?.name}</h3>
<p className="text-xs text-slate-400 font-mono">{user?.email}</p>
</div>
</div>
<div className="space-y-3 text-xs">
<h4 className="text-xs font-bold text-slate-300 uppercase tracking-wider">
Token Claims Accessible by UI
</h4>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 pt-1">
<div className="p-3.5 bg-slate-900/60 rounded-xl border border-slate-800/80">
<span className="text-slate-400 block text-[11px] font-medium">User Identifier (sub)</span>
<span className="font-mono text-white font-semibold">{user?.id}</span>
</div>
<div className="p-3.5 bg-slate-900/60 rounded-xl border border-slate-800/80">
<span className="text-slate-400 block text-[11px] font-medium">Email Address (email)</span>
<span className="font-mono text-white font-semibold">{user?.email}</span>
</div>
<div className="p-3.5 bg-slate-900/60 rounded-xl border border-slate-800/80">
<span className="text-slate-400 block text-[11px] font-medium">Full Name (name)</span>
<span className="text-white font-semibold">{user?.name}</span>
</div>
<div className="p-3.5 bg-slate-900/60 rounded-xl border border-slate-800/80">
<span className="text-slate-400 block text-[11px] font-medium">Assigned Role (role)</span>
<span className="text-blue-400 font-semibold">{user?.role}</span>
</div>
</div>
</div>
</div>
)}
{/* Modal: Dispatch Multi-Channel Notification */}
<Modal
isOpen={isSendNotificationModalOpen}
onClose={() => setIsSendNotificationModalOpen(false)}
title="Dispatch Outbound Notification"
subtitle="Route message through configured multi-channel notification gateway"
>
<form
onSubmit={(e) => {
e.preventDefault();
if (!dispatchRecipient || !dispatchSubject) {
toastError('Validation Error', 'Recipient and notification message are required');
return;
}
const newItem: NotificationItem = {
id: Date.now(),
recipient: dispatchRecipient,
platform: dispatchPlatform,
status: dispatchStatus,
subject: dispatchSubject,
sentAt: new Date().toISOString(),
};
setNotifications((prev) => [newItem, ...prev]);
success('Notification Queued', `Message queued for delivery via ${getNotificationPlatformLabel(dispatchPlatform)} (Status: ${getNotificationStatusLabel(dispatchStatus)})`);
setIsSendNotificationModalOpen(false);
setDispatchRecipient('');
setDispatchSubject('');
}}
className="space-y-4"
>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-semibold text-slate-300 mb-1.5">
Notification Platform Channel *
</label>
<select
value={dispatchPlatform}
onChange={(e) => setDispatchPlatform(Number(e.target.value) as NotificationPlatform)}
className="w-full px-3 py-2 text-xs bg-slate-900/80 border border-slate-700/80 rounded-xl text-white focus:outline-none focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500 font-medium transition [&>option]:bg-slate-900 [&>option]:text-white"
>
{NOTIFICATION_PLATFORM_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
<div>
<label className="block text-xs font-semibold text-slate-300 mb-1.5">
Initial Status Pipeline *
</label>
<select
value={dispatchStatus}
onChange={(e) => setDispatchStatus(Number(e.target.value) as NotificationStatus)}
className="w-full px-3 py-2 text-xs bg-slate-900/80 border border-slate-700/80 rounded-xl text-white focus:outline-none focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500 font-medium transition [&>option]:bg-slate-900 [&>option]:text-white"
>
{NOTIFICATION_STATUS_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
</div>
<div>
<label className="block text-xs font-semibold text-slate-300 mb-1.5">
Recipient Destination Address / Handle *
</label>
<input
type="text"
value={dispatchRecipient}
onChange={(e) => setDispatchRecipient(e.target.value)}
placeholder="e.g. user@funder.org.za or +27 82 123 4567 or https://webhook..."
className="w-full px-3 py-2 text-xs bg-slate-900/80 border border-slate-700/80 rounded-xl text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500 font-mono transition"
required
/>
</div>
<div>
<label className="block text-xs font-semibold text-slate-300 mb-1.5">
Message Subject / Body *
</label>
<textarea
rows={3}
value={dispatchSubject}
onChange={(e) => setDispatchSubject(e.target.value)}
placeholder="Enter notification content or payload summary..."
className="w-full px-3 py-2 text-xs bg-slate-900/80 border border-slate-700/80 rounded-xl text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500 transition"
required
/>
</div>
<div className="pt-3 border-t border-slate-800/80 flex justify-end gap-2">
<button
type="button"
onClick={() => setIsSendNotificationModalOpen(false)}
className="px-4 py-2 text-xs font-medium text-slate-300 bg-slate-800/80 hover:bg-slate-700 rounded-xl transition"
>
Cancel
</button>
<button
type="submit"
className="px-4 py-2 text-xs font-semibold text-white bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-500 hover:to-indigo-500 rounded-xl shadow-lg shadow-blue-500/20 transition"
>
Dispatch Notification
</button>
</div>
</form>
</Modal>
</div>
);
};