Files
pfm/PostFundManagement.ConsoleUI/src/pages/SettingsPage.tsx
T
2026-09-27 12:04:22 +02:00

2187 lines
108 KiB
TypeScript

import React, { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import {
Shield,
History,
CheckCircle,
Bell,
Send,
Radio,
Filter,
UserCheck,
Users,
RefreshCw,
Search,
Mail,
Eye,
ExternalLink,
Plus,
Building2,
Award as AwardIcon,
FileText,
AlertCircle,
Layers,
Activity,
Calendar,
SlidersHorizontal,
Copy,
Check,
Smartphone,
MessageSquare,
Globe,
Terminal,
Server,
Database,
Hash,
ChevronDown,
ChevronRight,
User as UserIcon,
} from 'lucide-react';
import {
getUsers,
getUser,
updateUserStatus,
syncIntegration,
syncUser,
getUserSyncRecord,
getInvites,
sendInvite,
getApplicantInvite,
getApplicantInvites,
getOrganisations,
getAwards,
getContracts,
getAuditLogs,
getNotifications,
} from '../services/api';
import { Modal } from '../components/ui/Modal';
import { HelpTooltip } from '../components/ui/HelpTooltip';
import { useToast } from '../components/ui/Toast';
import { ExportButton } from '../components/ui/ExportButton';
import { ExportColumn } from '../utils/exportUtils';
import { useTableSort, SortableTh } from '../utils/tableSort';
import { useAuth } from '../auth/AuthProvider';
import {
EmailStatuses,
NotificationStatus,
NotificationPlatform,
ActivityStatus,
User,
Invite,
InviteRequest,
UserFilterRequest,
AuditLog,
Notification,
} from '../types';
import {
formatDate,
getEmailStatusLabel,
getEmailStatusBadge,
getNotificationStatusLabel,
getNotificationStatusBadge,
getNotificationPlatformLabel,
getUserEmail,
registerUserEmails,
getActivityStatusBadge,
EMAIL_STATUSES_OPTIONS,
NOTIFICATION_STATUS_OPTIONS,
NOTIFICATION_PLATFORM_OPTIONS,
} from '../lib/formatters';
export const SettingsPage: React.FC = () => {
const queryClient = useQueryClient();
const { user } = useAuth();
const { success, error: toastError } = useToast();
const [activeTab, setActiveTab] = useState<'users' | 'invites' | 'notifications' | 'audit'>('users');
// ==========================================
// 1. User Directory State
// ==========================================
const [userEmailSearch, setUserEmailSearch] = useState('');
const [userStatusFilter, setUserStatusFilter] = useState<ActivityStatus | 'all'>('all');
const [selectedUserForDetail, setSelectedUserForDetail] = useState<User | null>(null);
const [isUserDetailModalOpen, setIsUserDetailModalOpen] = useState(false);
const userFilter: UserFilterRequest = {
email: userEmailSearch.trim() || undefined,
status: userStatusFilter !== 'all' ? userStatusFilter : undefined,
};
const { data: usersDirectory = [] } = useQuery({
queryKey: ['users-directory'],
queryFn: () => getUsers(1, 200),
staleTime: 5 * 60 * 1000,
});
const { data: usersList = [], isLoading: loadingUsers, refetch: refetchUsers } = useQuery({
queryKey: ['users', userStatusFilter, userEmailSearch],
queryFn: () => getUsers(1, 100, userFilter),
});
React.useEffect(() => {
if (usersDirectory && usersDirectory.length > 0) {
registerUserEmails(usersDirectory);
}
}, [usersDirectory]);
React.useEffect(() => {
if (usersList && usersList.length > 0) {
registerUserEmails(usersList);
}
}, [usersList]);
const { data: userFreshDetail, isLoading: loadingUserFreshDetail } = useQuery({
queryKey: ['user-canonical', selectedUserForDetail?.id],
queryFn: () => (selectedUserForDetail?.id ? getUser(selectedUserForDetail.id) : null),
enabled: !!selectedUserForDetail?.id && isUserDetailModalOpen,
});
const syncMutation = useMutation({
mutationFn: () => syncUser(undefined, true),
onSuccess: () => {
success('User Sync Complete', 'Authenticated user enabled and directory synced.');
queryClient.invalidateQueries({ queryKey: ['users'] });
},
onError: (err: any) => {
toastError('Sync Failed', err);
},
});
const syncIntegrationMutation = useMutation({
mutationFn: () => syncIntegration(true),
onSuccess: () => {
success('Backend Integration Synced', 'System records and telemetry successfully synchronized.');
queryClient.invalidateQueries({ queryKey: ['users'] });
},
onError: (err: any) => {
toastError('Integration Sync Failed', err);
},
});
const updateUserStatusMutation = useMutation({
mutationFn: ({ userId, status }: { userId: string; status: ActivityStatus }) =>
updateUserStatus(userId, status),
onSuccess: (_, vars) => {
success('User Status Updated', `User status changed to ${getActivityStatusBadge(vars.status).label}.`);
queryClient.invalidateQueries({ queryKey: ['users'] });
if (selectedUserForDetail && selectedUserForDetail.id === vars.userId) {
setSelectedUserForDetail({ ...selectedUserForDetail, status: vars.status });
}
},
onError: (err: any) => {
toastError('Update Failed', err);
},
});
// ==========================================
// 2. Communications & Invites State
// ==========================================
const [isSendInviteModalOpen, setIsSendInviteModalOpen] = useState(false);
const [applicantSearchId, setApplicantSearchId] = useState('');
const [applicantInvitesResults, setApplicantInvitesResults] = useState<Invite[] | null>(null);
const [searchingApplicant, setSearchingApplicant] = useState(false);
// Invite Form State
const [inviteOrgId, setInviteOrgId] = useState<string | number>('');
const [invitedOrgId, setInvitedOrgId] = useState<string | number>('');
const [inviteAwardId, setInviteAwardId] = useState<string | number>('');
const [inviteContractId, setInviteContractId] = useState<string | number>('');
const [inviteRecipientUuid, setInviteRecipientUuid] = useState('');
const [inviteMessage, setInviteMessage] = useState('');
const [invitePlatform, setInvitePlatform] = useState<NotificationPlatform>(NotificationPlatform.Email);
const { data: invitesList = [], isLoading: loadingInvites } = useQuery({
queryKey: ['invites'],
queryFn: () => getInvites(1, 100),
});
const { data: organisations = [] } = useQuery({
queryKey: ['organisations'],
queryFn: () => getOrganisations(1, 50),
});
const { data: awards = [] } = useQuery({
queryKey: ['awards'],
queryFn: () => getAwards(1, 50),
});
const { data: contracts = [] } = useQuery({
queryKey: ['contracts'],
queryFn: () => getContracts(1, 50),
});
const sendInviteMutation = useMutation({
mutationFn: (payload: InviteRequest) => sendInvite(payload),
onSuccess: () => {
success('Invitation Dispatched', 'Statutory communications invitation sent successfully.');
queryClient.invalidateQueries({ queryKey: ['invites'] });
setIsSendInviteModalOpen(false);
setInviteRecipientUuid('');
setInviteMessage('');
},
onError: (err: any) => {
toastError('Dispatch Error', err);
},
});
const handleSearchApplicantInvites = async (e?: React.FormEvent) => {
if (e) e.preventDefault();
if (!applicantSearchId.trim()) return;
try {
setSearchingApplicant(true);
const res = await getApplicantInvites(applicantSearchId.trim());
setApplicantInvitesResults(res);
success('Lookup Complete', `Retrieved ${res.length} invitation records for applicant.`);
} catch (err: any) {
toastError('Lookup Error', err);
setApplicantInvitesResults([]);
} finally {
setSearchingApplicant(false);
}
};
const handleLookupSingleApplicantInvite = async () => {
if (!applicantSearchId.trim()) return;
try {
setSearchingApplicant(true);
const res = await getApplicantInvite(applicantSearchId.trim());
setApplicantInvitesResults(res ? [res] : []);
success('Primary Invite Found', `Retrieved primary invitation for applicant.`);
} catch (err: any) {
toastError('Lookup Error', err);
setApplicantInvitesResults([]);
} finally {
setSearchingApplicant(false);
}
};
// ==========================================
// 3. Email Gateway & Notifications State
// ==========================================
const [emailGatewayStatus, setEmailGatewayStatus] = useState<EmailStatuses>(EmailStatuses.Connected);
const [notificationSearchTerm, setNotificationSearchTerm] = useState('');
const [notificationOrgFilter, setNotificationOrgFilter] = useState<string>('');
const [notificationPlatformFilter, setNotificationPlatformFilter] = useState<string>('all');
const [notificationStatusFilter, setNotificationStatusFilter] = useState<string>('all');
const [selectedNotificationForDetail, setSelectedNotificationForDetail] = useState<Notification | null>(null);
const [isNotificationDetailModalOpen, setIsNotificationDetailModalOpen] = useState(false);
const [copiedNotificationPayload, setCopiedNotificationPayload] = useState(false);
const effectiveNotificationOrgId =
notificationOrgFilter || (organisations[0]?.id ? String(organisations[0].id) : '1');
const {
data: fetchedNotifications = [],
isLoading: loadingNotifications,
refetch: refetchNotifications,
isFetching: isFetchingNotifications,
} = useQuery({
queryKey: ['notifications', effectiveNotificationOrgId],
queryFn: () => getNotifications(effectiveNotificationOrgId),
enabled: !!effectiveNotificationOrgId,
});
const notificationExportColumns: ExportColumn<Notification>[] = [
{ header: 'Notification 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) },
{ header: 'Delivery Status', key: 'status', format: (val) => getNotificationStatusLabel(val) },
{ 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: 'Message Content', key: 'message', format: (val) => val || '—' },
{ header: 'Error Diagnostics', key: 'error', format: (val) => val || 'None' },
];
const filteredNotifications = fetchedNotifications.filter((item) => {
if (notificationPlatformFilter !== 'all' && String(item.platform) !== String(notificationPlatformFilter)) {
return false;
}
if (notificationStatusFilter !== 'all' && String(item.status) !== String(notificationStatusFilter)) {
return false;
}
if (!notificationSearchTerm.trim()) return true;
const q = notificationSearchTerm.toLowerCase();
const id = String(item.id).toLowerCase();
const subj = (item.subject || '').toLowerCase();
const msg = (item.message || '').toLowerCase();
const recip = (item.recipient || '').toLowerCase();
const dest = (item.destination || '').toLowerCase();
const err = (item.error || '').toLowerCase();
return (
id.includes(q) ||
subj.includes(q) ||
msg.includes(q) ||
recip.includes(q) ||
dest.includes(q) ||
err.includes(q)
);
});
const {
sortedData: sortedNotifications,
sortColumn: notifSortCol,
sortDirection: notifSortDir,
handleSort: handleNotifSort,
} = useTableSort(filteredNotifications, 'createdAt', 'desc');
// ==========================================
// 4. Audit Log Ledger State
// ==========================================
const [auditSearchTerm, setAuditSearchTerm] = useState('');
const [auditEntityFilter, setAuditEntityFilter] = useState<string>('all');
const [auditActionFilter, setAuditActionFilter] = useState<string>('all');
const [selectedAuditLog, setSelectedAuditLog] = useState<AuditLog | null>(null);
const [isAuditDetailModalOpen, setIsAuditDetailModalOpen] = useState(false);
const [copiedAuditPayload, setCopiedAuditPayload] = useState(false);
const [expandedAuditLogIds, setExpandedAuditLogIds] = useState<Record<string | number, boolean>>({});
const [copiedUserId, setCopiedUserId] = useState<string | number | null>(null);
const toggleExpandAuditLog = (id: string | number) => {
setExpandedAuditLogIds((prev) => ({
...prev,
[id]: !prev[id],
}));
};
const handleCopyUserId = (userId: string | number, e?: React.MouseEvent) => {
if (e) e.stopPropagation();
if (!userId) return;
const strId = String(userId);
navigator.clipboard.writeText(strId);
setCopiedUserId(userId);
success('User ID Copied', `Copied ${strId} to clipboard.`);
setTimeout(() => {
setCopiedUserId((curr) => (curr === userId ? null : curr));
}, 2000);
};
const {
data: auditLogs = [],
isLoading: loadingAuditLogs,
refetch: refetchAuditLogs,
isFetching: isFetchingAuditLogs,
} = useQuery({
queryKey: ['audit-logs'],
queryFn: () => getAuditLogs(1, 100),
});
const auditExportColumns: ExportColumn<AuditLog>[] = [
{ header: 'Event ID', key: 'id' },
{ header: 'Timestamp', key: 'timestamp', format: (val) => formatDate(val) },
{ header: 'Action', key: 'action', format: (val) => val || '—' },
{ header: 'Entity Name', key: 'entityName', format: (val) => val || '—' },
{ header: 'Entity ID', key: 'entityId', format: (val) => val || '—' },
{
header: 'Performed By (Email)',
key: 'performedBy',
format: (val) => {
const email = getUserEmail(val);
return email && email !== '—' ? `${email} (${val})` : String(val || 'System');
},
},
{ header: 'Tenant ID', key: 'tenantId', format: (val) => (val ? `Tenant #${val}` : '—') },
{
header: 'Changes',
key: 'changes',
format: (val) => (Array.isArray(val) ? val.join('; ') : val || '—'),
},
];
const uniqueAuditEntities = Array.from(
new Set(auditLogs.map((l) => l.entityName).filter((e): e is string => Boolean(e)))
);
const uniqueAuditActions = Array.from(
new Set(auditLogs.map((l) => l.action).filter((a): a is string => Boolean(a)))
);
const filteredAuditLogs = auditLogs.filter((log) => {
if (auditEntityFilter !== 'all' && (log.entityName || '').toLowerCase() !== auditEntityFilter.toLowerCase()) {
return false;
}
if (auditActionFilter !== 'all' && (log.action || '').toLowerCase() !== auditActionFilter.toLowerCase()) {
return false;
}
if (!auditSearchTerm.trim()) return true;
const q = auditSearchTerm.toLowerCase();
const id = String(log.id || '').toLowerCase();
const action = (log.action || '').toLowerCase();
const entityName = (log.entityName || '').toLowerCase();
const entityId = String(log.entityId || '').toLowerCase();
const performedBy = (log.performedBy || '').toLowerCase();
const tenant = String(log.tenantId || '').toLowerCase();
const changes = Array.isArray(log.changes) ? log.changes.join(' ').toLowerCase() : '';
return (
id.includes(q) ||
action.includes(q) ||
entityName.includes(q) ||
entityId.includes(q) ||
performedBy.includes(q) ||
tenant.includes(q) ||
changes.includes(q)
);
});
const {
sortedData: sortedAuditLogs,
sortColumn: auditSortCol,
sortDirection: auditSortDir,
handleSort: handleAuditSort,
} = useTableSort(filteredAuditLogs, 'timestamp', 'desc');
const {
sortedData: sortedUsers,
sortColumn: userSortCol,
sortDirection: userSortDir,
handleSort: handleUserSort,
} = useTableSort(usersList, 'email', 'asc');
const {
sortedData: sortedInvites,
sortColumn: inviteSortCol,
sortDirection: inviteSortDir,
handleSort: handleInviteSort,
} = useTableSort(invitesList, 'createdAt', 'desc');
const getActionBadge = (action?: string | null) => {
const act = (action || 'GENERAL_EVENT').toUpperCase();
if (act.includes('APPROV') || act.includes('RELEASE') || act.includes('AUTH') || act.includes('VERIF')) {
return { label: action || 'APPROVED', className: 'bg-emerald-500/10 text-emerald-400 border-emerald-500/30' };
}
if (act.includes('CREATE') || act.includes('REGISTER') || act.includes('ADD') || act.includes('POST') || act.includes('INSERT')) {
return { label: action || 'CREATED', className: 'bg-blue-500/10 text-blue-400 border-blue-500/30' };
}
if (act.includes('UPDATE') || act.includes('SYNC') || act.includes('EDIT') || act.includes('PUT') || act.includes('PATCH')) {
return { label: action || 'UPDATED', className: 'bg-indigo-500/10 text-indigo-400 border-indigo-500/30' };
}
if (act.includes('DISPATCH') || act.includes('INVIT') || act.includes('SEND') || act.includes('NOTIF')) {
return { label: action || 'DISPATCHED', className: 'bg-purple-500/10 text-purple-400 border-purple-500/30' };
}
if (act.includes('DELETE') || act.includes('SUSPEND') || act.includes('REJECT') || act.includes('LOCK')) {
return { label: action || 'REJECTED', className: 'bg-rose-500/10 text-rose-400 border-rose-500/30' };
}
return { label: action || 'AUDIT_EVENT', className: 'bg-slate-800 text-slate-300 border-slate-700' };
};
return (
<div className="space-y-6 max-w-7xl mx-auto">
{/* Header Banner */}
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4 bg-gradient-to-r from-[#0d1322] via-[#0f172a] to-[#0d1322] p-6 sm:p-8 rounded-3xl border border-slate-800/80 shadow-2xl relative overflow-hidden">
<div className="absolute -right-10 -bottom-10 w-60 h-60 bg-blue-600/10 rounded-full blur-3xl pointer-events-none" />
<div className="space-y-2 relative z-10">
<div className="flex items-center gap-3">
<div className="h-12 w-12 rounded-2xl bg-blue-600/20 border border-blue-500/30 flex items-center justify-center text-blue-400 shadow-lg shadow-blue-500/10">
<Shield className="w-6 h-6" />
</div>
<div>
<div className="flex items-center gap-2.5 flex-wrap">
<h1 className="text-2xl sm:text-3xl font-bold tracking-tight text-white flex items-center gap-2.5">
System Administration &amp; Audit
</h1>
<HelpTooltip
title="System Administration & Audit"
content="Manage platform security, active user directory, statutory invitations, communications gateways, and comprehensive audit trail ledger."
placement="right"
/>
</div>
<p className="text-xs sm:text-sm text-slate-400 mt-1">
User access directory, statutory invitations, gateway broadcasts, and immutable system audit ledger.
</p>
</div>
</div>
</div>
<div className="flex items-center gap-2 relative z-10 self-start md:self-center flex-wrap">
{activeTab === 'users' && (
<div className="flex items-center gap-2 flex-wrap">
<span
className="hidden sm:inline-flex items-center gap-1.5 px-3 py-1.5 rounded-xl text-[11px] font-medium bg-emerald-500/10 text-emerald-400 border border-emerald-500/20"
title="User directory synchronization active in browser"
>
<span className="w-1.5 h-1.5 rounded-full bg-emerald-400 animate-pulse" />
Browser Sync Saved
</span>
<button
onClick={() => syncMutation.mutate()}
disabled={syncMutation.isPending}
className="inline-flex items-center gap-1.5 px-4 py-2.5 text-xs font-semibold text-white bg-slate-900/90 hover:bg-slate-800 border border-slate-700/80 rounded-xl shadow-md transition cursor-pointer disabled:opacity-50"
title="Force refresh user sync and update browser cache"
>
<RefreshCw className={`w-3.5 h-3.5 ${syncMutation.isPending ? 'animate-spin' : ''}`} />
<span>{syncMutation.isPending ? 'Syncing...' : 'Sync User Directory'}</span>
</button>
</div>
)}
{activeTab === 'invites' && (
<button
onClick={() => setIsSendInviteModalOpen(true)}
className="inline-flex items-center gap-1.5 px-4 py-2.5 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-600/25 transition cursor-pointer"
>
<Send className="w-4 h-4" />
<span>Dispatch Invitation</span>
</button>
)}
{activeTab === 'notifications' && (
<button
onClick={() => {
setInviteOrgId(effectiveNotificationOrgId);
setIsSendInviteModalOpen(true);
}}
className="inline-flex items-center gap-1.5 px-4 py-2.5 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-600/25 transition cursor-pointer"
>
<Plus className="w-4 h-4" />
<span>Add Notification</span>
</button>
)}
</div>
</div>
{/* Navigation Tabs */}
<div className="flex gap-2 border-b border-slate-800/80 pb-3 overflow-x-auto">
<button
onClick={() => setActiveTab('users')}
className={`px-4 py-2 text-xs font-medium rounded-xl transition flex items-center gap-2 whitespace-nowrap cursor-pointer ${
activeTab === 'users'
? 'bg-blue-600 text-white shadow-lg shadow-blue-600/30'
: 'text-slate-400 hover:text-slate-200 hover:bg-slate-800/50'
}`}
>
<Users className="w-4 h-4" />
<span>User Directory ({usersList.length})</span>
<HelpTooltip
title="User Directory"
content="Authorized system users, role memberships, and account lifecycle states."
size="sm"
placement="top"
/>
</button>
<button
onClick={() => setActiveTab('invites')}
className={`px-4 py-2 text-xs font-medium rounded-xl transition flex items-center gap-2 whitespace-nowrap cursor-pointer ${
activeTab === 'invites'
? 'bg-blue-600 text-white shadow-lg shadow-blue-600/30'
: 'text-slate-400 hover:text-slate-200 hover:bg-slate-800/50'
}`}
>
<Mail className="w-4 h-4" />
<span>Communications &amp; Invites ({invitesList.length})</span>
<HelpTooltip
title="Communications Dispatch"
content="Statutory onboarding invitations and applicant notification ledger."
size="sm"
placement="top"
/>
</button>
<button
onClick={() => setActiveTab('notifications')}
className={`px-4 py-2 text-xs font-medium rounded-xl transition flex items-center gap-2 whitespace-nowrap cursor-pointer ${
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" />
<span>Gateways &amp; Notifications ({fetchedNotifications.length})</span>
<HelpTooltip
title="Notification Gateways"
content="Real-time status of email dispatch SMTP engines, SMS gateways, and broadcast logs."
size="sm"
placement="top"
/>
</button>
<button
onClick={() => setActiveTab('audit')}
className={`px-4 py-2 text-xs font-medium rounded-xl transition flex items-center gap-2 whitespace-nowrap cursor-pointer ${
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" />
<span>Audit &amp; Telemetry Ledger ({auditLogs.length})</span>
<HelpTooltip
title="Audit Trail"
content="Comprehensive immutable audit log of administrative actions, statutory approvals, grant disbursements, and security events."
size="sm"
placement="top"
/>
</button>
</div>
{/* ============================================================== */}
{/* TAB 1: User Directory & Access */}
{/* ============================================================== */}
{activeTab === 'users' && (
<div className="space-y-4">
{/* Filter and Search Bar */}
<div className="bg-[#0f1523]/80 backdrop-blur-md p-4 rounded-2xl border border-slate-800/80 shadow-xl flex flex-col sm:flex-row items-center justify-between gap-3">
<div className="relative w-full sm:w-80">
<Search className="w-4 h-4 text-slate-500 absolute left-3 top-2.5" />
<input
type="text"
value={userEmailSearch}
onChange={(e) => setUserEmailSearch(e.target.value)}
placeholder="Search users by email..."
className="w-full pl-9 pr-3 py-1.5 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"
/>
</div>
<div className="flex items-center gap-2 w-full sm:w-auto">
<span className="text-xs font-semibold text-slate-400 whitespace-nowrap">Filter Status:</span>
<select
value={userStatusFilter}
onChange={(e) => setUserStatusFilter(e.target.value === 'all' ? 'all' : Number(e.target.value))}
className="text-xs bg-slate-900/80 border border-slate-700/80 rounded-xl px-3 py-1.5 text-white focus:outline-none focus:ring-2 focus:ring-blue-500/50 [&>option]:bg-slate-900 [&>option]:text-white"
>
<option value="all">All Statuses</option>
<option value={ActivityStatus.Active}>Active</option>
<option value={ActivityStatus.Inactive}>Inactive</option>
<option value={ActivityStatus.Suspended}>Suspended</option>
</select>
<button
onClick={() => refetchUsers()}
className="p-1.5 rounded-xl border border-slate-700 bg-slate-800/80 hover:bg-slate-700 text-slate-300 transition cursor-pointer"
title="Refresh user list"
>
<RefreshCw className="w-4 h-4" />
</button>
</div>
</div>
{/* Users 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">
<SortableTh column="email" currentColumn={userSortCol} direction={userSortDir} onSort={handleUserSort} className="py-3.5 px-5">
User Email
</SortableTh>
<SortableTh column="id" currentColumn={userSortCol} direction={userSortDir} onSort={handleUserSort} className="py-3.5 px-4">
User Identifier
</SortableTh>
<SortableTh column="status" currentColumn={userSortCol} direction={userSortDir} onSort={handleUserSort} className="py-3.5 px-4">
Status
</SortableTh>
<SortableTh column="lastLoginAt" currentColumn={userSortCol} direction={userSortDir} onSort={handleUserSort} className="py-3.5 px-4">
Last Login
</SortableTh>
<th className="py-3.5 px-5 text-right">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-800/50">
{loadingUsers ? (
<tr>
<td colSpan={5} className="py-8 text-center text-slate-500">
Loading user directory...
</td>
</tr>
) : sortedUsers.length === 0 ? (
<tr>
<td colSpan={5} className="py-8 text-center text-slate-500">
No users found matching current filters.
</td>
</tr>
) : (
sortedUsers.map((u) => {
const badge = getActivityStatusBadge(u.status);
return (
<tr key={u.id} className="hover:bg-slate-800/40 transition-colors">
<td className="py-3.5 px-5 text-white font-medium">
{u.email || getUserEmail(u.id) || <span className="text-slate-500 italic">No email recorded</span>}
</td>
<td className="py-3.5 px-4 font-mono text-slate-400 text-[11px]">
{u.id}
</td>
<td className="py-3.5 px-4">
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-[10px] font-semibold border ${badge.className}`}>
{badge.label}
</span>
</td>
<td className="py-3.5 px-4 text-slate-400 font-mono text-[11px]">
{u.lastLoginAt ? formatDate(u.lastLoginAt) : 'Never'}
</td>
<td className="py-3.5 px-5 text-right space-x-1.5 whitespace-nowrap">
<button
onClick={() => {
setSelectedUserForDetail(u);
setIsUserDetailModalOpen(true);
}}
className="inline-flex items-center gap-1 px-2.5 py-1 text-[11px] font-semibold text-slate-300 bg-slate-800 hover:bg-slate-700 border border-slate-700 rounded-lg transition cursor-pointer"
>
<Eye className="w-3 h-3 text-blue-400" />
Inspect
</button>
{/* Status switch actions */}
{u.status === ActivityStatus.Active ? (
<button
onClick={() =>
updateUserStatusMutation.mutate({
userId: u.id,
status: ActivityStatus.Suspended,
})
}
disabled={updateUserStatusMutation.isPending}
className="inline-flex items-center px-2.5 py-1 text-[11px] font-semibold text-amber-400 bg-amber-500/10 hover:bg-amber-500/20 border border-amber-500/20 rounded-lg transition cursor-pointer"
>
Suspend
</button>
) : (
<button
onClick={() =>
updateUserStatusMutation.mutate({
userId: u.id,
status: ActivityStatus.Active,
})
}
disabled={updateUserStatusMutation.isPending}
className="inline-flex items-center px-2.5 py-1 text-[11px] font-semibold text-emerald-400 bg-emerald-500/10 hover:bg-emerald-500/20 border border-emerald-500/20 rounded-lg transition cursor-pointer"
>
Activate
</button>
)}
</td>
</tr>
);
})
)}
</tbody>
</table>
</div>
</div>
</div>
)}
{/* ============================================================== */}
{/* TAB 2: Communications & Invites */}
{/* ============================================================== */}
{activeTab === 'invites' && (
<div className="space-y-6">
{/* Applicant Invites Lookup */}
<div className="bg-[#0f1523]/80 backdrop-blur-md p-5 rounded-2xl border border-slate-800/80 shadow-xl">
<h3 className="text-sm font-bold text-white mb-1">Applicant Invitations Lookup</h3>
<p className="text-xs text-slate-400 mb-3">
Query invitations dispatched to a specific candidate or grantee using their unique Applicant User UUID.
</p>
<form onSubmit={handleSearchApplicantInvites} className="flex gap-2">
<input
type="text"
value={applicantSearchId}
onChange={(e) => setApplicantSearchId(e.target.value)}
placeholder="Enter Applicant User ID (UUID)..."
className="flex-1 px-3 py-2 text-xs bg-slate-900/80 border border-slate-700/80 rounded-xl text-white placeholder-slate-500 font-mono focus:outline-none focus:ring-2 focus:ring-blue-500/50"
/>
<button
type="submit"
disabled={searchingApplicant || !applicantSearchId.trim()}
className="px-3.5 py-2 text-xs font-semibold text-white bg-blue-600 hover:bg-blue-500 rounded-xl transition cursor-pointer disabled:opacity-50"
title="Lookup all invites"
>
{searchingApplicant ? 'Searching...' : 'All Invites'}
</button>
<button
type="button"
onClick={handleLookupSingleApplicantInvite}
disabled={searchingApplicant || !applicantSearchId.trim()}
className="px-3.5 py-2 text-xs font-semibold text-blue-300 bg-blue-500/10 hover:bg-blue-500/20 border border-blue-500/30 rounded-xl transition cursor-pointer disabled:opacity-50"
title="Lookup primary invite"
>
Primary Invite
</button>
</form>
{applicantInvitesResults && (
<div className="mt-4 pt-4 border-t border-slate-800/80">
<span className="text-xs font-semibold text-slate-300">
Lookup Results ({applicantInvitesResults.length} matches):
</span>
{applicantInvitesResults.length === 0 ? (
<p className="text-xs text-slate-500 mt-2">No invitations found for this applicant UUID.</p>
) : (
<div className="mt-2 space-y-2">
{applicantInvitesResults.map((inv, idx) => (
<div
key={idx}
className="p-3 bg-slate-900/60 rounded-xl border border-slate-800 text-xs flex flex-col sm:flex-row sm:items-center justify-between gap-2"
>
<div>
<span className="font-semibold text-white">{inv.message}</span>
<div className="text-[11px] text-slate-400 mt-0.5">
Org #{inv.organisationId} &bull; Award #{inv.awardId} &bull; Contract #{inv.contractId}
</div>
</div>
<span className="text-[10px] font-mono px-2 py-0.5 rounded bg-blue-500/10 text-blue-400 border border-blue-500/20">
{getNotificationPlatformLabel(inv.platform)}
</span>
</div>
))}
</div>
)}
</div>
)}
</div>
{/* Sent Invites Ledger */}
<div className="bg-[#0f1523]/80 backdrop-blur-md rounded-2xl border border-slate-800/80 shadow-xl overflow-hidden">
<div className="px-5 py-3.5 border-b border-slate-800/80 bg-slate-900/40 flex items-center justify-between">
<span className="text-xs font-bold text-white uppercase tracking-wider">
Sent Invitations Ledger
</span>
<span className="text-xs text-slate-400">{invitesList.length} invitations dispatched</span>
</div>
<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">
<SortableTh column="recipient" currentColumn={inviteSortCol} direction={inviteSortDir} onSort={handleInviteSort} className="py-3.5 px-5">
Recipient (UUID)
</SortableTh>
<SortableTh column="platform" currentColumn={inviteSortCol} direction={inviteSortDir} onSort={handleInviteSort} className="py-3.5 px-4">
Channel
</SortableTh>
<SortableTh column="message" currentColumn={inviteSortCol} direction={inviteSortDir} onSort={handleInviteSort} className="py-3.5 px-4">
Message
</SortableTh>
<SortableTh column="invitedOrganisationId" currentColumn={inviteSortCol} direction={inviteSortDir} onSort={handleInviteSort} className="py-3.5 px-4">
Target Org / Grant
</SortableTh>
<SortableTh column="createdAt" currentColumn={inviteSortCol} direction={inviteSortDir} onSort={handleInviteSort} align="right" className="py-3.5 px-5 text-right">
Created Date
</SortableTh>
</tr>
</thead>
<tbody className="divide-y divide-slate-800/50">
{loadingInvites ? (
<tr>
<td colSpan={5} className="py-8 text-center text-slate-500">
Loading invitations ledger...
</td>
</tr>
) : sortedInvites.length === 0 ? (
<tr>
<td colSpan={5} className="py-8 text-center text-slate-500">
No invitations recorded yet. Use &ldquo;Dispatch Invitation&rdquo; to send one.
</td>
</tr>
) : (
sortedInvites.map((inv, idx) => (
<tr key={idx} className="hover:bg-slate-800/40 transition-colors">
<td className="py-3.5 px-5 font-mono text-slate-300 text-[11px]">
{inv.recipient}
</td>
<td className="py-3.5 px-4">
<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(inv.platform)}
</span>
</td>
<td className="py-3.5 px-4 text-white font-medium max-w-xs truncate">
{inv.message}
</td>
<td className="py-3.5 px-4 text-slate-400 text-[11px]">
Org #{inv.invitedOrganisationId || inv.organisationId} &bull; Award #{inv.awardId}
</td>
<td className="py-3.5 px-5 text-right font-mono text-slate-400 text-[11px]">
{inv.createdAt ? formatDate(inv.createdAt) : 'Recently'}
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
</div>
)}
{/* ============================================================== */}
{/* TAB 3: Notifications & Gateways */}
{/* ============================================================== */}
{activeTab === 'notifications' && (
<div className="space-y-4">
{/* Summary Metric Cards */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-5 gap-3">
<div className="bg-[#0f1523]/80 backdrop-blur-md p-4 rounded-2xl border border-slate-800/80 shadow-xl space-y-2">
<div className="flex items-center justify-between">
<span className="text-[11px] font-semibold text-slate-400 uppercase tracking-wider">Email Gateway</span>
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-[10px] font-semibold border ${getEmailStatusBadge(emailGatewayStatus).className}`}>
{getEmailStatusBadge(emailGatewayStatus).label}
</span>
</div>
<div className="pt-0.5">
<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 [&>option]:bg-slate-900 [&>option]:text-white cursor-pointer"
>
{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-4 rounded-2xl border border-slate-800/80 shadow-xl space-y-1">
<span className="text-[11px] font-semibold text-slate-400 block uppercase tracking-wider">
Total Notifications
</span>
<div className="text-xl font-bold font-mono text-white">{fetchedNotifications.length}</div>
<span className="text-[10px] text-slate-500">System records</span>
</div>
<div className="bg-[#0f1523]/80 backdrop-blur-md p-4 rounded-2xl border border-slate-800/80 shadow-xl space-y-1">
<span className="text-[11px] font-semibold text-emerald-400 block uppercase tracking-wider">
Delivered / Sent
</span>
<div className="text-xl font-bold font-mono text-emerald-300">
{fetchedNotifications.filter(
(n) =>
n.status === NotificationStatus.Delivered ||
n.status === NotificationStatus.Sent ||
n.status === NotificationStatus.Read
).length}
</div>
<span className="text-[10px] text-slate-500">Successful transmissions</span>
</div>
<div className="bg-[#0f1523]/80 backdrop-blur-md p-4 rounded-2xl border border-slate-800/80 shadow-xl space-y-1">
<span className="text-[11px] font-semibold text-amber-400 block uppercase tracking-wider">
Queued / Pending
</span>
<div className="text-xl font-bold font-mono text-amber-300">
{fetchedNotifications.filter(
(n) =>
n.status === NotificationStatus.Pending ||
n.status === NotificationStatus.Queued ||
n.status === NotificationStatus.Processing
).length}
</div>
<span className="text-[10px] text-slate-500">Active retry pipeline</span>
</div>
<div className="bg-[#0f1523]/80 backdrop-blur-md p-4 rounded-2xl border border-slate-800/80 shadow-xl space-y-1">
<span className="text-[11px] font-semibold text-rose-400 block uppercase tracking-wider">
Failed / Bounced
</span>
<div className="text-xl font-bold font-mono text-rose-300">
{fetchedNotifications.filter(
(n) => n.status === NotificationStatus.Failed || n.status === NotificationStatus.Bounced
).length}
</div>
<span className="text-[10px] text-slate-500">Diagnostic errors</span>
</div>
</div>
{/* Filter & Search Toolbar */}
<div className="bg-[#0d121f]/90 p-4 rounded-2xl border border-slate-800/80 backdrop-blur-sm shadow-xl flex flex-col lg:flex-row lg:items-center justify-between gap-3 relative z-20">
<div className="flex flex-col sm:flex-row sm:items-center gap-3 flex-1 flex-wrap">
<div className="relative flex-1 min-w-[240px] max-w-md">
<Search className="w-4 h-4 text-slate-500 absolute left-3 top-3" />
<input
type="text"
value={notificationSearchTerm}
onChange={(e) => setNotificationSearchTerm(e.target.value)}
placeholder="Search notifications by subject, message, recipient, destination..."
className="w-full pl-9 pr-3 py-2 text-xs bg-[#090d16] border border-slate-700/80 rounded-xl text-white placeholder-slate-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
/>
</div>
<div className="flex items-center gap-1.5">
<span className="text-xs font-semibold text-slate-400 whitespace-nowrap">Target Org:</span>
<Building2 className="w-3.5 h-3.5 text-blue-400" />
<select
value={notificationOrgFilter}
onChange={(e) => setNotificationOrgFilter(e.target.value)}
className="text-xs bg-[#090d16] border border-blue-500/40 rounded-xl px-2.5 py-2 text-white font-medium focus:outline-none focus:ring-1 focus:ring-blue-500 [&>option]:bg-slate-900 [&>option]:text-white"
>
{organisations.map((org) => (
<option key={org.id} value={org.id}>
{org.name || `Organisation #${org.id}`} (#{org.id})
</option>
))}
{organisations.length === 0 && (
<option value="1">Default Organisation (Org #1)</option>
)}
</select>
</div>
<div className="flex items-center gap-1.5">
<Radio className="w-3.5 h-3.5 text-slate-400" />
<select
value={notificationPlatformFilter}
onChange={(e) => setNotificationPlatformFilter(e.target.value)}
className="text-xs bg-[#090d16] border border-slate-700/80 rounded-xl px-2.5 py-2 text-slate-300 focus:outline-none focus:ring-1 focus:ring-blue-500 [&>option]:bg-slate-900 [&>option]:text-white"
>
<option value="all">All Channels</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">
<Filter className="w-3.5 h-3.5 text-slate-400" />
<select
value={notificationStatusFilter}
onChange={(e) => setNotificationStatusFilter(e.target.value)}
className="text-xs bg-[#090d16] border border-slate-700/80 rounded-xl px-2.5 py-2 text-slate-300 focus:outline-none focus:ring-1 focus:ring-blue-500 [&>option]:bg-slate-900 [&>option]:text-white"
>
<option value="all">All Statuses</option>
{NOTIFICATION_STATUS_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
{(notificationSearchTerm ||
notificationPlatformFilter !== 'all' ||
notificationStatusFilter !== 'all') && (
<button
onClick={() => {
setNotificationSearchTerm('');
setNotificationPlatformFilter('all');
setNotificationStatusFilter('all');
}}
className="px-2.5 py-2 text-xs text-slate-400 hover:text-white bg-slate-800/80 hover:bg-slate-700 rounded-xl transition cursor-pointer"
title="Clear all filters"
>
Reset
</button>
)}
</div>
<div className="flex items-center gap-2.5 self-end sm:self-center shrink-0 flex-wrap">
<button
type="button"
onClick={() => {
refetchNotifications();
success('Refreshed', `Transmissions updated for Organisation #${effectiveNotificationOrgId}.`);
}}
disabled={isFetchingNotifications}
className="inline-flex items-center justify-center gap-1.5 px-3.5 py-2 text-xs font-semibold text-slate-300 bg-[#0d121f] hover:bg-slate-800 border border-slate-700/80 rounded-xl transition cursor-pointer shadow-xs disabled:opacity-50"
title="Force refresh notifications from API"
>
<RefreshCw className={`w-3.5 h-3.5 text-blue-400 ${isFetchingNotifications ? 'animate-spin' : ''}`} />
<span>Refresh</span>
</button>
<ExportButton
title="Communications &amp; Notifications Ledger"
subtitle={`Transmission telemetry report for Organisation #${effectiveNotificationOrgId}`}
filename={`pfm-notifications-org-${effectiveNotificationOrgId}-${new Date().toISOString().split('T')[0]}`}
columns={notificationExportColumns}
data={filteredNotifications}
/>
</div>
</div>
{/* Returned Notification Data Table */}
<div className="bg-[#0f1523]/80 backdrop-blur-md rounded-2xl border border-slate-800/80 shadow-xl overflow-hidden">
<div className="p-4 border-b border-slate-800/80 flex items-center justify-between">
<div className="flex items-center gap-2">
<Bell className="w-4 h-4 text-blue-400" />
<h3 className="text-sm font-semibold text-white">
Returned Communications Report ({filteredNotifications.length} of {fetchedNotifications.length})
</h3>
<HelpTooltip
title="Communications Telemetry"
content="Real-time notification reports. Displays live delivery status across Email, SMS, WhatsApp, Webhooks, and In-App channels."
size="sm"
placement="right"
/>
</div>
<span className="text-xs text-slate-400 font-mono">
Org #{effectiveNotificationOrgId}
</span>
</div>
<div className="overflow-x-auto">
<table className="w-full text-left border-collapse text-xs">
<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>
<SortableTh column="platform" currentColumn={notifSortCol} direction={notifSortDir} onSort={handleNotifSort} className="py-3.5 px-4">
Channel / Platform
</SortableTh>
<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 &amp; Message Payload
</SortableTh>
<SortableTh column="recipient" currentColumn={notifSortCol} direction={notifSortDir} onSort={handleNotifSort} className="py-3.5 px-4">
Recipient &amp; Destination
</SortableTh>
<SortableTh column="organisationId" currentColumn={notifSortCol} direction={notifSortDir} onSort={handleNotifSort} className="py-3.5 px-4">
Target Org
</SortableTh>
<SortableTh column="createdAt" currentColumn={notifSortCol} direction={notifSortDir} onSort={handleNotifSort} className="py-3.5 px-4">
Timestamp
</SortableTh>
<th className="py-3.5 px-4 text-right font-medium">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-800/50">
{loadingNotifications ? (
<tr>
<td colSpan={8} 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">
<Bell className="w-8 h-8 text-slate-600 mx-auto mb-2" />
No notification records returned for Organisation #{effectiveNotificationOrgId}.
</td>
</tr>
) : (
sortedNotifications.map((item) => {
const statusBadge = getNotificationStatusBadge(item.status);
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]">
#{item.id}
</td>
<td className="py-3 px-4">
<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(item.platform)}
</span>
</td>
<td className="py-3 px-4">
<span
className={`inline-flex items-center px-2 py-0.5 rounded-full text-[10px] font-semibold border ${statusBadge.className}`}
>
{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>
)}
</td>
<td className="py-3 px-4 text-slate-400 text-[11px]">
Org #{item.organisationId}
</td>
<td className="py-3 px-4 font-mono text-slate-400 text-[11px]">
{formatDate(item.createdAt)}
</td>
<td className="py-3 px-4 text-right">
<button
onClick={() => {
setSelectedNotificationForDetail(item);
setIsNotificationDetailModalOpen(true);
}}
className="inline-flex items-center gap-1 px-2.5 py-1 text-[11px] font-semibold text-slate-300 bg-slate-800 hover:bg-slate-700 border border-slate-700 rounded-lg transition cursor-pointer"
title="Inspect returned notification payload and data structure"
>
<Eye className="w-3 h-3 text-blue-400" />
Inspect
</button>
</td>
</tr>
);
})
)}
</tbody>
</table>
</div>
</div>
</div>
)}
{/* ============================================================== */}
{/* TAB 4: Audit & Telemetry Ledger */}
{/* ============================================================== */}
{activeTab === 'audit' && (
<div className="space-y-4">
{/* Summary Metric Cards */}
<div className="grid grid-cols-1 sm:grid-cols-4 gap-3">
<div className="bg-[#0f1523]/80 backdrop-blur-md p-4 rounded-2xl border border-slate-800/80 shadow-xl space-y-1">
<span className="text-[11px] font-semibold text-slate-400 block uppercase tracking-wider">
Total Audit Events
</span>
<div className="text-xl font-bold font-mono text-white">{auditLogs.length}</div>
<span className="text-[10px] text-slate-500">Live API ledger records</span>
</div>
<div className="bg-[#0f1523]/80 backdrop-blur-md p-4 rounded-2xl border border-slate-800/80 shadow-xl space-y-1">
<span className="text-[11px] font-semibold text-emerald-400 block uppercase tracking-wider">
Audited Entities
</span>
<div className="text-xl font-bold font-mono text-emerald-300">
{uniqueAuditEntities.length}
</div>
<span className="text-[10px] text-slate-500">Distinct system resources</span>
</div>
<div className="bg-[#0f1523]/80 backdrop-blur-md p-4 rounded-2xl border border-slate-800/80 shadow-xl space-y-1">
<span className="text-[11px] font-semibold text-blue-400 block uppercase tracking-wider">
Active Actors
</span>
<div className="text-xl font-bold font-mono text-blue-300">
{new Set(auditLogs.map((l) => l.performedBy).filter(Boolean)).size}
</div>
<span className="text-[10px] text-slate-500">Distinct caller UUIDs</span>
</div>
<div className="bg-[#0f1523]/80 backdrop-blur-md p-4 rounded-2xl border border-slate-800/80 shadow-xl space-y-1">
<span className="text-[11px] font-semibold text-purple-400 block uppercase tracking-wider">
Action Categories
</span>
<div className="text-xl font-bold font-mono text-purple-300">
{uniqueAuditActions.length}
</div>
<span className="text-[10px] text-slate-500">Registered action types</span>
</div>
</div>
{/* Filter & Action Toolbar */}
<div className="bg-[#0f1523]/80 backdrop-blur-md p-4 rounded-2xl border border-slate-800/80 shadow-xl flex flex-col lg:flex-row items-stretch lg:items-center justify-between gap-3">
<div className="relative flex-1 max-w-md">
<Search className="w-4 h-4 text-slate-500 absolute left-3 top-3" />
<input
type="text"
value={auditSearchTerm}
onChange={(e) => setAuditSearchTerm(e.target.value)}
placeholder="Search audit trail by action, entity name, ID, performed by UUID..."
className="w-full pl-9 pr-3 py-2 text-xs bg-[#090d16] border border-slate-700/80 rounded-xl text-white placeholder-slate-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
/>
</div>
<div className="flex flex-wrap items-center gap-2">
<div className="flex items-center gap-1.5">
<span className="text-xs font-semibold text-slate-400 whitespace-nowrap">Entity:</span>
<select
value={auditEntityFilter}
onChange={(e) => setAuditEntityFilter(e.target.value)}
className="text-xs bg-[#090d16] border border-slate-700/80 rounded-xl px-2.5 py-1.5 text-white focus:outline-none focus:ring-1 focus:ring-blue-500 [&>option]:bg-slate-900 [&>option]:text-white cursor-pointer"
>
<option value="all">All Entities</option>
{uniqueAuditEntities.map((ent) => (
<option key={ent} value={ent}>
{ent}
</option>
))}
{uniqueAuditEntities.length === 0 && (
<>
<option value="User">User</option>
<option value="Award">Award</option>
<option value="Budget">Budget</option>
<option value="Disbursement">Disbursement</option>
<option value="Risk">Risk</option>
</>
)}
</select>
</div>
<div className="flex items-center gap-1.5">
<span className="text-xs font-semibold text-slate-400 whitespace-nowrap">Action:</span>
<select
value={auditActionFilter}
onChange={(e) => setAuditActionFilter(e.target.value)}
className="text-xs bg-[#090d16] border border-slate-700/80 rounded-xl px-2.5 py-1.5 text-white focus:outline-none focus:ring-1 focus:ring-blue-500 [&>option]:bg-slate-900 [&>option]:text-white cursor-pointer"
>
<option value="all">All Actions</option>
{uniqueAuditActions.map((act) => (
<option key={act} value={act}>
{act}
</option>
))}
</select>
</div>
{(auditSearchTerm || auditEntityFilter !== 'all' || auditActionFilter !== 'all') && (
<button
type="button"
onClick={() => {
setAuditSearchTerm('');
setAuditEntityFilter('all');
setAuditActionFilter('all');
}}
className="px-2.5 py-1.5 text-xs text-slate-400 hover:text-white bg-slate-800 hover:bg-slate-700 border border-slate-700/80 rounded-xl transition cursor-pointer"
>
Reset
</button>
)}
<button
type="button"
onClick={() => {
refetchAuditLogs();
success('Refreshed', 'Latest audit log entries loaded from API.');
}}
disabled={isFetchingAuditLogs}
className="inline-flex items-center justify-center gap-1.5 px-3.5 py-2 text-xs font-semibold text-slate-300 bg-[#0d121f] hover:bg-slate-800 border border-slate-700/80 rounded-xl transition cursor-pointer shadow-xs disabled:opacity-50"
title="Refresh audit trail from API"
>
<RefreshCw className={`w-3.5 h-3.5 text-blue-400 ${isFetchingAuditLogs ? 'animate-spin' : ''}`} />
<span>Refresh</span>
</button>
<ExportButton
title="System Audit &amp; Security Log"
subtitle="Complete audit trail retrieved directly from /v1/api/governance/auditlog"
filename={`pfm-audit-trail-${new Date().toISOString().split('T')[0]}`}
columns={auditExportColumns}
data={filteredAuditLogs}
/>
</div>
</div>
{/* Audit Logs Table */}
<div className="bg-[#0f1523]/80 backdrop-blur-md rounded-2xl border border-slate-800/80 shadow-xl overflow-hidden">
<div className="p-4 border-b border-slate-800/80 flex items-center justify-between">
<div className="flex items-center gap-2">
<History className="w-4 h-4 text-blue-400" />
<h3 className="text-sm font-semibold text-white">
Audit Log Ledger ({filteredAuditLogs.length} of {auditLogs.length})
</h3>
<HelpTooltip
title="Audit Trail"
content="Authoritative record of state changes and system activities."
size="sm"
placement="right"
/>
</div>
<span className="text-xs text-slate-400 font-mono">
{filteredAuditLogs.length} entries
</span>
</div>
<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-3 w-10 text-center"></th>
<SortableTh column="timestamp" currentColumn={auditSortCol} direction={auditSortDir} onSort={handleAuditSort} className="py-3.5 px-4">
Timestamp
</SortableTh>
<SortableTh column="id" currentColumn={auditSortCol} direction={auditSortDir} onSort={handleAuditSort} className="py-3.5 px-4">
Event ID
</SortableTh>
<SortableTh column="action" currentColumn={auditSortCol} direction={auditSortDir} onSort={handleAuditSort} className="py-3.5 px-4">
Action
</SortableTh>
<SortableTh column="entityName" currentColumn={auditSortCol} direction={auditSortDir} onSort={handleAuditSort} className="py-3.5 px-4">
Entity
</SortableTh>
<SortableTh column="performedBy" currentColumn={auditSortCol} direction={auditSortDir} onSort={handleAuditSort} className="py-3.5 px-4">
Performed By
</SortableTh>
<SortableTh column="changes" currentColumn={auditSortCol} direction={auditSortDir} onSort={handleAuditSort} className="py-3.5 px-4">
Changes
</SortableTh>
<th className="py-3.5 px-5 text-right font-medium">Inspect</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-800/50">
{loadingAuditLogs ? (
<tr>
<td colSpan={8} className="py-12 text-center text-slate-500">
<RefreshCw className="w-6 h-6 animate-spin mx-auto mb-2 text-blue-400" />
Loading governance audit records...
</td>
</tr>
) : sortedAuditLogs.length === 0 ? (
<tr>
<td colSpan={8} className="py-12 text-center text-slate-500">
<History className="w-8 h-8 text-slate-600 mx-auto mb-2" />
{auditSearchTerm || auditEntityFilter !== 'all' || auditActionFilter !== 'all'
? 'No audit log entries match your filter criteria.'
: 'No audit records found.'}
</td>
</tr>
) : (
sortedAuditLogs.map((log) => {
const badge = getActionBadge(log.action);
const isExpanded = !!expandedAuditLogIds[log.id];
const changesCount = Array.isArray(log.changes) ? log.changes.length : 0;
const performerEmail = getUserEmail(log.performedBy);
const hasResolvedEmail = performerEmail && performerEmail !== '—' && !performerEmail.includes('—');
const displayEmail = hasResolvedEmail ? performerEmail : (log.performedBy || 'System');
return (
<React.Fragment key={log.id}>
<tr
onClick={() => toggleExpandAuditLog(log.id)}
className={`hover:bg-slate-800/40 transition-colors cursor-pointer ${
isExpanded ? 'bg-slate-800/30' : ''
}`}
>
<td className="py-3.5 px-3 text-center">
<button
type="button"
onClick={(e) => {
e.stopPropagation();
toggleExpandAuditLog(log.id);
}}
className="p-1 rounded-md hover:bg-slate-700/80 text-slate-400 hover:text-white transition cursor-pointer"
title={isExpanded ? 'Collapse changes shelf' : 'Expand changes shelf'}
>
<ChevronRight
className={`w-3.5 h-3.5 transition-transform duration-200 ${
isExpanded ? 'rotate-90 text-blue-400' : 'text-slate-500'
}`}
/>
</button>
</td>
<td className="py-3.5 px-4 font-mono text-slate-400 whitespace-nowrap text-[11px]">
{formatDate(log.timestamp)}
</td>
<td className="py-3.5 px-4 font-mono font-bold text-white whitespace-nowrap">
#{log.id}
</td>
<td className="py-3.5 px-4 whitespace-nowrap">
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-[10px] font-semibold border ${badge.className}`}>
{badge.label}
</span>
</td>
<td className="py-3.5 px-4 whitespace-nowrap">
<span className="font-semibold text-slate-200 block">{log.entityName || 'General'}</span>
{log.entityId && (
<span className="text-[10px] text-slate-500 font-mono">#{log.entityId}</span>
)}
</td>
<td
className="py-3.5 px-4"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center gap-1.5 min-w-0 max-w-[220px]">
<span
className="text-slate-200 font-mono text-[11px] truncate block"
title={`${displayEmail}${log.performedBy ? ` (User ID: ${log.performedBy})` : ''}`}
>
{displayEmail}
</span>
{log.performedBy && (
<button
type="button"
onClick={(e) => handleCopyUserId(log.performedBy, e)}
className="p-1 rounded-md hover:bg-slate-700/80 text-slate-400 hover:text-white transition cursor-pointer shrink-0"
title={`Click to copy User ID (${log.performedBy})`}
>
{copiedUserId === log.performedBy ? (
<Check className="w-3.5 h-3.5 text-emerald-400" />
) : (
<Copy className="w-3.5 h-3.5 hover:text-blue-400" />
)}
</button>
)}
</div>
</td>
<td className="py-3.5 px-4">
<button
type="button"
onClick={(e) => {
e.stopPropagation();
toggleExpandAuditLog(log.id);
}}
className="inline-flex items-center gap-1.5 px-2 py-1 rounded-lg bg-slate-800/90 hover:bg-slate-700 border border-slate-700/80 text-[11px] text-slate-300 hover:text-white transition cursor-pointer"
title="Click to toggle changes shelf"
>
<Layers className="w-3 h-3 text-blue-400" />
<span>{changesCount} {changesCount === 1 ? 'change' : 'changes'}</span>
<ChevronDown
className={`w-3 h-3 text-slate-400 transition-transform duration-200 ${
isExpanded ? 'rotate-180 text-blue-400' : ''
}`}
/>
</button>
</td>
<td className="py-3.5 px-5 text-right whitespace-nowrap">
<button
type="button"
onClick={(e) => {
e.stopPropagation();
setSelectedAuditLog(log);
setIsAuditDetailModalOpen(true);
}}
className="inline-flex items-center gap-1 px-2.5 py-1 text-[11px] font-semibold text-slate-300 bg-slate-800 hover:bg-slate-700 border border-slate-700 rounded-lg transition cursor-pointer"
>
<Eye className="w-3 h-3 text-blue-400" />
Inspect
</button>
</td>
</tr>
{/* Expanding Shelf for Changes Content */}
{isExpanded && (
<tr className="bg-[#080c14] border-b border-slate-800/80">
<td colSpan={8} className="p-4 sm:p-5 bg-gradient-to-r from-[#0d121f] via-[#090d16] to-[#0d121f]">
<div className="rounded-xl border border-slate-800/90 bg-[#070a12]/90 p-4 space-y-3 shadow-inner">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-2 border-b border-slate-800/80 pb-3">
<div className="flex items-center gap-2">
<Layers className="w-4 h-4 text-blue-400" />
<h4 className="text-xs font-bold text-white uppercase tracking-wider">
Recorded Changes Content (Event #{log.id})
</h4>
<span className="text-[10px] font-mono px-2 py-0.5 rounded-full bg-blue-500/10 text-blue-400 border border-blue-500/20 font-semibold">
{changesCount} {changesCount === 1 ? 'Entry' : 'Entries'}
</span>
</div>
<div className="flex items-center gap-3 text-xs text-slate-400">
<span>
Entity: <strong className="text-white">{log.entityName || 'General'}</strong> {log.entityId ? `(#${log.entityId})` : ''}
</span>
<span>•</span>
<span className="flex items-center gap-1">
Performer: <strong className="text-slate-200 font-mono text-[11px]">{displayEmail}</strong>
{log.performedBy && (
<button
type="button"
onClick={(e) => handleCopyUserId(log.performedBy, e)}
className="p-1 rounded hover:bg-slate-800 text-slate-400 hover:text-white transition cursor-pointer"
title={`Copy User ID: ${log.performedBy}`}
>
{copiedUserId === log.performedBy ? (
<Check className="w-3 h-3 text-emerald-400" />
) : (
<Copy className="w-3 h-3 text-slate-400 hover:text-blue-400" />
)}
</button>
)}
</span>
</div>
</div>
{/* Changes List */}
{Array.isArray(log.changes) && log.changes.length > 0 ? (
<div className="space-y-2">
{log.changes.map((changeItem, idx) => (
<div
key={idx}
className="flex items-start gap-3 p-3 bg-slate-900/90 rounded-xl border border-slate-800/80 text-xs font-mono shadow-xs hover:border-slate-700 transition"
>
<span className="px-2 py-0.5 rounded-md bg-blue-500/10 text-blue-400 border border-blue-500/20 text-[10px] font-bold shrink-0 mt-0.5">
#{idx + 1}
</span>
<div className="flex-1 text-emerald-400 leading-relaxed break-all">
{changeItem}
</div>
</div>
))}
</div>
) : (
<div className="p-4 rounded-xl bg-slate-900/50 border border-slate-800/80 text-center text-slate-400 text-xs font-mono">
No itemized field changes content recorded for this event.
</div>
)}
</div>
</td>
</tr>
)}
</React.Fragment>
);
})
)}
</tbody>
</table>
</div>
</div>
</div>
)}
{/* ============================================================== */}
{/* Modal: Audit Log Entry Inspection */}
{/* ============================================================== */}
<Modal
isOpen={isAuditDetailModalOpen}
onClose={() => {
setIsAuditDetailModalOpen(false);
setSelectedAuditLog(null);
setCopiedAuditPayload(false);
}}
title={`Audit Event: #${selectedAuditLog?.id || ''}`}
subtitle="Authoritative event payload and activity ledger"
>
{selectedAuditLog && (
<div className="space-y-4 text-xs">
<div className="p-4 bg-[#0d121f] rounded-xl border border-slate-800 space-y-3">
<div className="flex justify-between items-center border-b border-slate-800/80 pb-2">
<span className="text-slate-400 font-semibold uppercase text-[11px]">Action Type:</span>
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-semibold border ${getActionBadge(selectedAuditLog.action).className}`}>
{selectedAuditLog.action || 'EVENT'}
</span>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<span className="text-slate-400 font-semibold block text-[11px] uppercase">Entity Name:</span>
<span className="text-white font-semibold">{selectedAuditLog.entityName || '—'}</span>
</div>
<div>
<span className="text-slate-400 font-semibold block text-[11px] uppercase">Entity ID:</span>
<span className="font-mono text-slate-300">{selectedAuditLog.entityId || '—'}</span>
</div>
</div>
<div className="grid grid-cols-2 gap-3 border-t border-slate-800/80 pt-2">
<div>
<span className="text-slate-400 font-semibold block text-[11px] uppercase">Performed By:</span>
<div className="flex items-center gap-1.5 mt-0.5">
<span className="font-mono text-slate-200 text-[11px] truncate">
{getUserEmail(selectedAuditLog.performedBy) !== '—'
? getUserEmail(selectedAuditLog.performedBy)
: (selectedAuditLog.performedBy || 'System')}
</span>
{selectedAuditLog.performedBy && (
<button
type="button"
onClick={(e) => handleCopyUserId(selectedAuditLog.performedBy, e)}
className="p-1 rounded hover:bg-slate-700/80 text-slate-400 hover:text-white transition cursor-pointer shrink-0"
title={`Copy User ID: ${selectedAuditLog.performedBy}`}
>
{copiedUserId === selectedAuditLog.performedBy ? (
<Check className="w-3.5 h-3.5 text-emerald-400" />
) : (
<Copy className="w-3.5 h-3.5 hover:text-blue-400" />
)}
</button>
)}
</div>
{selectedAuditLog.performedBy && (
<span className="text-[10px] text-slate-500 font-mono block mt-0.5 truncate">
ID: {selectedAuditLog.performedBy}
</span>
)}
</div>
<div>
<span className="text-slate-400 font-semibold block text-[11px] uppercase">Logged Timestamp:</span>
<span className="font-mono text-slate-300 block mt-0.5">{formatDate(selectedAuditLog.timestamp)}</span>
</div>
</div>
{selectedAuditLog.tenantId && (
<div className="border-t border-slate-800/80 pt-2">
<span className="text-slate-400 font-semibold block text-[11px] uppercase">Tenant Scope:</span>
<span className="font-mono text-slate-300">Tenant #{selectedAuditLog.tenantId}</span>
</div>
)}
<div className="border-t border-slate-800/80 pt-2 space-y-1">
<span className="text-slate-400 font-semibold block text-[11px] uppercase mb-1">State Changes / Delta:</span>
{Array.isArray(selectedAuditLog.changes) && selectedAuditLog.changes.length > 0 ? (
<ul className="space-y-1 p-2 rounded-lg bg-slate-900 border border-slate-800">
{selectedAuditLog.changes.map((ch, idx) => (
<li key={idx} className="font-mono text-emerald-400/90 text-[11px] flex items-center gap-1.5">
<span className="w-1.5 h-1.5 rounded-full bg-emerald-400" />
{ch}
</li>
))}
</ul>
) : (
<p className="p-3 rounded-lg bg-slate-900 border border-slate-800 text-slate-400 leading-relaxed font-mono text-[11px]">
No granular delta changes specified in audit payload.
</p>
)}
</div>
{/* Raw JSON Data Viewer */}
<div className="border-t border-slate-800/80 pt-2.5 space-y-1.5">
<div className="flex items-center justify-between">
<span className="text-slate-400 font-semibold text-[11px] uppercase">
Raw Audit Record (JSON):
</span>
<button
onClick={() => {
navigator.clipboard.writeText(JSON.stringify(selectedAuditLog, null, 2));
setCopiedAuditPayload(true);
setTimeout(() => setCopiedAuditPayload(false), 2000);
}}
className="inline-flex items-center gap-1 text-[10px] font-medium text-slate-400 hover:text-white bg-slate-800 px-2 py-1 rounded transition cursor-pointer"
>
{copiedAuditPayload ? (
<>
<Check className="w-3 h-3 text-emerald-400" />
<span className="text-emerald-400">Copied</span>
</>
) : (
<>
<Copy className="w-3 h-3" />
<span>Copy JSON</span>
</>
)}
</button>
</div>
<pre className="p-3 rounded-lg bg-[#070a12] border border-slate-800/80 text-emerald-300/90 font-mono text-[10.5px] overflow-x-auto max-h-48">
{JSON.stringify(selectedAuditLog, null, 2)}
</pre>
</div>
</div>
<div className="pt-2 flex justify-end gap-2">
<button
type="button"
onClick={() => {
setIsAuditDetailModalOpen(false);
setSelectedAuditLog(null);
setCopiedAuditPayload(false);
}}
className="px-4 py-2 text-xs font-semibold text-white bg-slate-800 hover:bg-slate-700 rounded-xl transition cursor-pointer"
>
Close
</button>
</div>
</div>
)}
</Modal>
{/* ============================================================== */}
{/* Modal: Dispatch Invitation */}
{/* ============================================================== */}
<Modal
isOpen={isSendInviteModalOpen}
onClose={() => setIsSendInviteModalOpen(false)}
title="Dispatch Statutory Invitation"
subtitle="Onboard enterprise entities, project PIs, or external reviewers"
helpText="Generates cryptographic invitations linked to organizations, awards, or contracts to initiate formal onboarding workflows."
>
<form
onSubmit={(e) => {
e.preventDefault();
if (!inviteRecipientUuid || !inviteMessage) {
toastError('Validation Error', 'Recipient UUID and message content are mandatory.');
return;
}
sendInviteMutation.mutate({
organisationId: inviteOrgId || (organisations[0]?.id ? Number(organisations[0].id) : 101),
invitedOrganisationId: invitedOrgId || (organisations[1]?.id ? Number(organisations[1].id) : 102),
awardId: inviteAwardId || (awards[0]?.id ? Number(awards[0].id) : 301),
contractId: inviteContractId || (contracts[0]?.id ? Number(contracts[0].id) : 401),
recipient: inviteRecipientUuid,
message: inviteMessage,
platform: invitePlatform,
});
}}
className="space-y-4"
>
<div>
<div className="flex items-center gap-1 mb-1.5">
<label className="block text-xs font-semibold text-slate-300">
Recipient User UUID *
</label>
<HelpTooltip
title="Recipient UUID"
content="Unique directory user identifier or email for targeted delivery."
size="sm"
placement="right"
/>
</div>
<input
type="text"
value={inviteRecipientUuid}
onChange={(e) => setInviteRecipientUuid(e.target.value)}
placeholder="e.g. 550e8400-e29b-41d4-a716-446655440000"
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 font-mono focus:outline-none focus:ring-2 focus:ring-blue-500/50"
required
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<div className="flex items-center gap-1 mb-1.5">
<label className="block text-xs font-semibold text-slate-300">
Dispatch Platform *
</label>
<HelpTooltip
title="Channel Protocol"
content="Select delivery transport: Email SMTP, In-App Dispatch, or SMS alert."
size="sm"
placement="top"
/>
</div>
<select
value={invitePlatform}
onChange={(e) => setInvitePlatform(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 [&>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>
<div className="flex items-center gap-1 mb-1.5">
<label className="block text-xs font-semibold text-slate-300">
Originating Organisation
</label>
<HelpTooltip
title="Inviting Authority"
content="Statutory entity under whose jurisdiction this invitation is issued."
size="sm"
placement="top"
/>
</div>
<select
value={inviteOrgId}
onChange={(e) => setInviteOrgId(e.target.value)}
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 [&>option]:bg-slate-900 [&>option]:text-white"
>
{organisations.map((org) => (
<option key={org.id} value={org.id}>
{org.name} (#{org.id})
</option>
))}
</select>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<div className="flex items-center gap-1 mb-1.5">
<label className="block text-xs font-semibold text-slate-300">
Associated Award
</label>
<HelpTooltip
title="Linked Grant Award"
content="Optional award attachment to link recipient permissions directly to grant administration."
size="sm"
placement="top"
/>
</div>
<select
value={inviteAwardId}
onChange={(e) => setInviteAwardId(e.target.value)}
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 [&>option]:bg-slate-900 [&>option]:text-white"
>
{awards.map((aw) => (
<option key={aw.id} value={aw.id}>
Award #{aw.id}
</option>
))}
</select>
</div>
<div>
<div className="flex items-center gap-1 mb-1.5">
<label className="block text-xs font-semibold text-slate-300">
Associated Contract
</label>
<HelpTooltip
title="Linked Contract"
content="Optional bilateral agreement linking the recipient to contract execution."
size="sm"
placement="top"
/>
</div>
<select
value={inviteContractId}
onChange={(e) => setInviteContractId(e.target.value)}
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 [&>option]:bg-slate-900 [&>option]:text-white"
>
{contracts.map((ctr) => (
<option key={ctr.id} value={ctr.id}>
Contract #{ctr.id}
</option>
))}
</select>
</div>
</div>
<div>
<div className="flex items-center gap-1 mb-1.5">
<label className="block text-xs font-semibold text-slate-300">
Invitation Message *
</label>
<HelpTooltip
title="Instructional Body"
content="Clear instructions and statutory obligations displayed to the invitee upon opening."
size="sm"
placement="right"
/>
</div>
<textarea
rows={3}
value={inviteMessage}
onChange={(e) => setInviteMessage(e.target.value)}
placeholder="State the purpose of the invite and recipient onboarding instructions..."
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"
required
/>
</div>
<div className="pt-3 border-t border-slate-800/80 flex justify-end gap-2">
<button
type="button"
onClick={() => setIsSendInviteModalOpen(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"
disabled={sendInviteMutation.isPending}
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 disabled:opacity-50"
>
{sendInviteMutation.isPending ? 'Sending...' : 'Send Invitation'}
</button>
</div>
</form>
</Modal>
{/* ============================================================== */}
{/* Modal: User Inspection */}
{/* ============================================================== */}
<Modal
isOpen={isUserDetailModalOpen}
onClose={() => setIsUserDetailModalOpen(false)}
title="User Access & Security Details"
>
{selectedUserForDetail && (
<div className="space-y-4 text-xs">
{loadingUserFreshDetail ? (
<div className="p-6 text-center text-slate-400">
<RefreshCw className="w-5 h-5 animate-spin mx-auto mb-2 text-blue-400" />
Fetching user profile details...
</div>
) : (
<div className="p-4 bg-slate-900/60 rounded-xl border border-slate-800 space-y-2">
<div>
<span className="text-slate-400 font-semibold block text-[11px] uppercase">User Email:</span>
<span className="text-white font-semibold text-xs">
{userFreshDetail?.email || selectedUserForDetail.email || getUserEmail(selectedUserForDetail.id) || 'Not specified'}
</span>
</div>
<div>
<span className="text-slate-400 font-semibold block text-[11px] uppercase">User Identifier:</span>
<span className="font-mono text-slate-300 text-xs break-all">
{userFreshDetail?.id || selectedUserForDetail.id}
</span>
</div>
<div className="flex items-center justify-between pt-1">
<div>
<span className="text-slate-400 font-semibold block text-[11px] uppercase">Status:</span>
<span className={`inline-flex items-center px-2 py-0.5 rounded text-[10px] font-semibold border ${getActivityStatusBadge(userFreshDetail?.status ?? selectedUserForDetail.status).className}`}>
{getActivityStatusBadge(userFreshDetail?.status ?? selectedUserForDetail.status).label}
</span>
</div>
<div>
<span className="text-slate-400 font-semibold block text-[11px] uppercase">Last Login:</span>
<span className="font-mono text-slate-300">
{(userFreshDetail?.lastLoginAt || selectedUserForDetail.lastLoginAt) ? formatDate((userFreshDetail?.lastLoginAt || selectedUserForDetail.lastLoginAt)!) : 'Never'}
</span>
</div>
</div>
</div>
)}
<div className="pt-2 flex justify-end gap-2">
<button
type="button"
onClick={() => setIsUserDetailModalOpen(false)}
className="px-4 py-2 text-xs font-semibold text-white bg-slate-800 hover:bg-slate-700 rounded-xl transition"
>
Close
</button>
</div>
</div>
)}
</Modal>
{/* ============================================================== */}
{/* Modal: Notification Payload & Data Inspection */}
{/* ============================================================== */}
<Modal
isOpen={isNotificationDetailModalOpen}
onClose={() => {
setIsNotificationDetailModalOpen(false);
setSelectedNotificationForDetail(null);
setCopiedNotificationPayload(false);
}}
title={`Notification Record: #${selectedNotificationForDetail?.id || ''}`}
subtitle="Returned communication transmission telemetry and payload metadata"
>
{selectedNotificationForDetail && (
<div className="space-y-4 text-xs">
<div className="p-4 bg-[#0d121f] rounded-xl border border-slate-800 space-y-3">
{/* Header Status Bar */}
<div className="flex justify-between items-center border-b border-slate-800/80 pb-2.5">
<div className="flex items-center gap-2">
<span className="text-slate-400 font-semibold uppercase text-[11px]">Channel:</span>
<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>
</div>
<div className="flex items-center gap-2">
<span className="text-slate-400 font-semibold uppercase text-[11px]">Delivery Status:</span>
<span
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-[10px] font-semibold border ${getNotificationStatusBadge(selectedNotificationForDetail.status).className}`}
>
{getNotificationStatusBadge(selectedNotificationForDetail.status).label}
</span>
</div>
</div>
{/* Subject */}
<div>
<span className="text-slate-400 font-semibold block text-[11px] uppercase mb-0.5">Subject:</span>
<span className="text-white font-semibold text-sm">
{selectedNotificationForDetail.subject || 'Statutory System Notification'}
</span>
</div>
{/* 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">
{selectedNotificationForDetail.message || '—'}
</div>
</div>
{/* Recipient & Destination 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">Recipient User UUID:</span>
<span className="font-mono text-slate-200 break-all text-[11px]">
{selectedNotificationForDetail.recipient}
</span>
</div>
<div>
<span className="text-slate-400 font-semibold block text-[11px] uppercase">Destination Address:</span>
<span className="font-mono text-slate-200 break-all text-[11px]">
{selectedNotificationForDetail.destination || 'In-App User Inbox'}
</span>
</div>
</div>
{/* 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>
</div>
<div>
<span className="text-slate-400 font-semibold block text-[11px] uppercase">Tenant Scope:</span>
<span className="text-white font-medium">Tenant #{selectedNotificationForDetail.tenantId ?? 1}</span>
</div>
</div>
{/* Timestamps */}
<div className="grid grid-cols-3 gap-2 border-t border-slate-800/80 pt-2.5">
<div>
<span className="text-slate-400 font-semibold block text-[10px] uppercase">Created At:</span>
<span className="font-mono text-slate-300 text-[11px]">
{formatDate(selectedNotificationForDetail.createdAt)}
</span>
</div>
<div>
<span className="text-slate-400 font-semibold block text-[10px] uppercase">Sent At:</span>
<span className="font-mono text-slate-300 text-[11px]">
{selectedNotificationForDetail.sentAt ? formatDate(selectedNotificationForDetail.sentAt) : 'In Queue / Pending'}
</span>
</div>
<div>
<span className="text-slate-400 font-semibold block text-[10px] uppercase">Updated At:</span>
<span className="font-mono text-slate-300 text-[11px]">
{selectedNotificationForDetail.updatedAt ? formatDate(selectedNotificationForDetail.updatedAt) : '—'}
</span>
</div>
</div>
{/* Error Alert if present */}
{selectedNotificationForDetail.error && (
<div className="border border-rose-500/30 bg-rose-500/10 rounded-xl p-3 space-y-1">
<div className="flex items-center gap-1.5 text-rose-400 font-semibold text-xs">
<AlertCircle className="w-4 h-4" />
<span>Transmission Failure Diagnostic</span>
</div>
<p className="font-mono text-rose-300 text-[11px] break-all">
{selectedNotificationForDetail.error}
</p>
</div>
)}
{/* Raw JSON Data Viewer */}
<div className="border-t border-slate-800/80 pt-2.5 space-y-1.5">
<div className="flex items-center justify-between">
<span className="text-slate-400 font-semibold text-[11px] uppercase">
Raw Returned Data Payload (JSON):
</span>
<button
onClick={() => {
navigator.clipboard.writeText(JSON.stringify(selectedNotificationForDetail, null, 2));
setCopiedNotificationPayload(true);
setTimeout(() => setCopiedNotificationPayload(false), 2000);
}}
className="inline-flex items-center gap-1 text-[10px] font-medium text-slate-400 hover:text-white bg-slate-800 px-2 py-1 rounded transition cursor-pointer"
>
{copiedNotificationPayload ? (
<>
<Check className="w-3 h-3 text-emerald-400" />
<span className="text-emerald-400">Copied</span>
</>
) : (
<>
<Copy className="w-3 h-3" />
<span>Copy JSON</span>
</>
)}
</button>
</div>
<pre className="p-3 rounded-lg bg-[#070a12] border border-slate-800/80 text-emerald-300/90 font-mono text-[10.5px] overflow-x-auto max-h-48">
{JSON.stringify(selectedNotificationForDetail, null, 2)}
</pre>
</div>
{/* Read-only Engine Notice */}
<div className="p-2.5 bg-blue-500/5 border border-blue-500/20 rounded-xl text-[11px] text-slate-400 leading-relaxed">
<span className="font-semibold text-blue-400">Communication System Report: </span>
Notification status transitions and retry schedules are managed autonomously by communication gateway services.
</div>
</div>
<div className="pt-2 flex justify-end gap-2">
<button
type="button"
onClick={() => {
setIsNotificationDetailModalOpen(false);
setSelectedNotificationForDetail(null);
setCopiedNotificationPayload(false);
}}
className="px-4 py-2 text-xs font-semibold text-white bg-slate-800 hover:bg-slate-700 rounded-xl transition cursor-pointer"
>
Close
</button>
</div>
</div>
)}
</Modal>
</div>
);
};