2187 lines
108 KiB
TypeScript
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 & 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 & 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 & 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 & 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} • Award #{inv.awardId} • 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 “Dispatch Invitation” 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} • 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 & 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 & Message Payload
|
|
</SortableTh>
|
|
<SortableTh column="recipient" currentColumn={notifSortCol} direction={notifSortDir} onSort={handleNotifSort} className="py-3.5 px-4">
|
|
Recipient & 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 & 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>
|
|
);
|
|
};
|