2381 lines
117 KiB
TypeScript
2381 lines
117 KiB
TypeScript
import React, { useState, useMemo } 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,
|
|
ChevronLeft,
|
|
User as UserIcon,
|
|
RotateCcw,
|
|
} from 'lucide-react';
|
|
import { CreateUserModal } from '../components/users/CreateUserModal';
|
|
import { UserSecurityModal } from '../components/users/UserSecurityModal';
|
|
import { ObfuscatedIdNumber } from '../components/ui/ObfuscatedIdNumber';
|
|
import { useRbac } from '../auth/rbac';
|
|
import {
|
|
getUsers,
|
|
getUser,
|
|
updateUserStatus,
|
|
syncIntegration,
|
|
syncUser,
|
|
getUserSyncRecord,
|
|
getInvites,
|
|
sendInvite,
|
|
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 { UserRoleBadge } from '../components/ui/StatusBadge';
|
|
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,
|
|
getUserRoleBadge,
|
|
USER_ROLE_OPTIONS,
|
|
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');
|
|
|
|
const { canView, canAdd, canExport } = useRbac();
|
|
const canViewUsers = canView('settings_users') || canView('tenant_self_service');
|
|
const canViewInvites = canView('settings_invites');
|
|
const canViewNotifications = canView('settings_notifications');
|
|
const canViewAudit = canView('settings_auditlog');
|
|
|
|
const canAddUser = canAdd('settings_users') || canAdd('tenant_self_service');
|
|
const canAddInvite = canAdd('settings_invites');
|
|
|
|
React.useEffect(() => {
|
|
if (activeTab === 'users' && !canViewUsers) {
|
|
if (canViewInvites) setActiveTab('invites');
|
|
else if (canViewNotifications) setActiveTab('notifications');
|
|
else if (canViewAudit) setActiveTab('audit');
|
|
}
|
|
}, [activeTab, canViewUsers, canViewInvites, canViewNotifications, canViewAudit]);
|
|
|
|
// ==========================================
|
|
// 1. User Directory State
|
|
// ==========================================
|
|
const [userEmailSearch, setUserEmailSearch] = useState('');
|
|
const [userStatusFilter, setUserStatusFilter] = useState<ActivityStatus | 'all'>('all');
|
|
const [userRoleFilter, setUserRoleFilter] = useState<string>('all');
|
|
const [selectedUserForDetail, setSelectedUserForDetail] = useState<User | null>(null);
|
|
const [isUserDetailModalOpen, setIsUserDetailModalOpen] = useState(false);
|
|
const [isCreateUserModalOpen, setIsCreateUserModalOpen] = useState(false);
|
|
|
|
const [userPage, setUserPage] = useState(1);
|
|
const [userPageSize, setUserPageSize] = useState(10);
|
|
|
|
React.useEffect(() => {
|
|
setUserPage(1);
|
|
}, [userEmailSearch, userStatusFilter, userRoleFilter]);
|
|
|
|
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,
|
|
isFetching: isFetchingUsers,
|
|
refetch: refetchUsers,
|
|
} = useQuery({
|
|
queryKey: ['users', userStatusFilter, userEmailSearch],
|
|
queryFn: () => getUsers(1, 1000, userFilter),
|
|
});
|
|
|
|
const filteredUsers = useMemo(() => {
|
|
return usersList.filter((u) => {
|
|
const roleLabel = getUserRoleBadge(u.role).label;
|
|
const matchRole =
|
|
userRoleFilter === 'all' ||
|
|
roleLabel.toLowerCase() === userRoleFilter.toLowerCase() ||
|
|
(u.role && String(u.role).toLowerCase() === userRoleFilter.toLowerCase());
|
|
return matchRole;
|
|
});
|
|
}, [usersList, userRoleFilter]);
|
|
|
|
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: unknown) => {
|
|
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: unknown) => {
|
|
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: unknown) => {
|
|
toastError('Update Failed', err);
|
|
},
|
|
});
|
|
|
|
// ==========================================
|
|
// 2. Communications & Invites State
|
|
// ==========================================
|
|
const [isSendInviteModalOpen, setIsSendInviteModalOpen] = 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);
|
|
|
|
// Invites filtering & search
|
|
const [inviteSearchTerm, setInviteSearchTerm] = useState('');
|
|
const [invitePlatformFilter, setInvitePlatformFilter] = useState<'all' | number>('all');
|
|
|
|
const {
|
|
data: invitesList = [],
|
|
isLoading: loadingInvites,
|
|
isFetching: isFetchingInvites,
|
|
refetch: refetchInvites,
|
|
} = useQuery({
|
|
queryKey: ['invites'],
|
|
queryFn: () => getInvites(1, 100),
|
|
});
|
|
|
|
const filteredInvites = useMemo(() => {
|
|
return invitesList.filter((inv) => {
|
|
const matchSearch =
|
|
!inviteSearchTerm.trim() ||
|
|
(inv.recipient && inv.recipient.toLowerCase().includes(inviteSearchTerm.toLowerCase())) ||
|
|
(inv.message && inv.message.toLowerCase().includes(inviteSearchTerm.toLowerCase()));
|
|
const matchPlatform =
|
|
invitePlatformFilter === 'all' || Number(inv.platform) === Number(invitePlatformFilter);
|
|
return matchSearch && matchPlatform;
|
|
});
|
|
}, [invitesList, inviteSearchTerm, invitePlatformFilter]);
|
|
|
|
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: unknown) => {
|
|
toastError('Dispatch Error', err);
|
|
},
|
|
});
|
|
|
|
// ==========================================
|
|
// 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 userExportColumns: ExportColumn<User>[] = [
|
|
{ header: 'User Email', key: 'email', format: (val, u) => val || getUserEmail(u.id) || '—' },
|
|
{ header: 'User Identifier', key: 'id' },
|
|
{ header: 'Id Number', key: 'idNumber', format: (val) => val || '—' },
|
|
{ header: 'Role', key: 'role', format: (val) => getUserRoleBadge(val).label },
|
|
{ header: 'Status', key: 'status', format: (val) => getActivityStatusBadge(val).label },
|
|
{ header: 'Last Login', key: 'lastLoginAt', format: (val) => (val ? formatDate(val) : 'Never') },
|
|
];
|
|
|
|
const inviteExportColumns: ExportColumn<Invite>[] = [
|
|
{ header: 'Recipient (UUID)', key: 'recipient' },
|
|
{ header: 'Channel / Platform', key: 'platform', format: (val) => getNotificationPlatformLabel(val) },
|
|
{ header: 'Invitation Message', key: 'message' },
|
|
{
|
|
header: 'Target Organisation / Grant',
|
|
key: 'invitedOrganisationId',
|
|
format: (val, inv) => `Org #${val || inv.organisationId || '—'} (Award #${inv.awardId || '—'})`,
|
|
},
|
|
{ header: 'Date Dispatched', key: 'createdAt', format: (val) => (val ? formatDate(val) : '—') },
|
|
];
|
|
|
|
const {
|
|
sortedData: sortedUsers,
|
|
sortColumn: userSortCol,
|
|
sortDirection: userSortDir,
|
|
handleSort: handleUserSort,
|
|
} = useTableSort(filteredUsers, 'email', 'asc', {
|
|
role: (u: User) => getUserRoleBadge(u.role).label,
|
|
});
|
|
|
|
const totalUsers = sortedUsers.length;
|
|
const totalPages = Math.max(1, Math.ceil(totalUsers / userPageSize));
|
|
const paginatedUsers = React.useMemo(() => {
|
|
const start = (userPage - 1) * userPageSize;
|
|
return sortedUsers.slice(start, start + userPageSize);
|
|
}, [sortedUsers, userPage, userPageSize]);
|
|
|
|
const {
|
|
sortedData: sortedInvites,
|
|
sortColumn: inviteSortCol,
|
|
sortDirection: inviteSortDir,
|
|
handleSort: handleInviteSort,
|
|
} = useTableSort(filteredInvites, '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 min-w-0 w-full">
|
|
{/* 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">
|
|
{canViewUsers && (
|
|
<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>
|
|
)}
|
|
|
|
{canViewInvites && (
|
|
<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>
|
|
)}
|
|
|
|
{canViewNotifications && (
|
|
<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>
|
|
)}
|
|
|
|
{canViewAudit && (
|
|
<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">
|
|
{/* Standardized Toolbar & Filter Bar directly above Users Table */}
|
|
<div className="bg-[#0f1523]/80 backdrop-blur-md p-3.5 sm: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">
|
|
{/* Filters on the left */}
|
|
<div className="flex items-center gap-2.5 flex-wrap flex-1">
|
|
<div className="relative flex-1 min-w-[200px] max-w-sm">
|
|
<Search className="w-3.5 h-3.5 text-slate-400 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-8 pr-3 py-1.5 text-xs bg-slate-900 border border-slate-800 rounded-xl text-white placeholder-slate-500 focus:outline-none focus:border-blue-500 shadow-xs"
|
|
/>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-1.5 text-xs text-slate-400">
|
|
<select
|
|
value={userRoleFilter}
|
|
onChange={(e) => setUserRoleFilter(e.target.value)}
|
|
className="text-xs bg-slate-900 border border-slate-800 rounded-xl px-3 py-1.5 text-white focus:outline-none focus:border-blue-500"
|
|
>
|
|
<option value="all">All Roles</option>
|
|
{USER_ROLE_OPTIONS.map((opt) => (
|
|
<option key={opt.value} value={opt.value}>
|
|
{opt.label}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-1.5 text-xs text-slate-400">
|
|
<select
|
|
value={userStatusFilter}
|
|
onChange={(e) => setUserStatusFilter(e.target.value === 'all' ? 'all' : Number(e.target.value))}
|
|
className="text-xs bg-slate-900 border border-slate-800 rounded-xl px-3 py-1.5 text-white focus:outline-none focus:border-blue-500"
|
|
>
|
|
<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>
|
|
</div>
|
|
|
|
{(userEmailSearch.trim() !== '' || userStatusFilter !== 'all' || userRoleFilter !== 'all') && (
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
setUserEmailSearch('');
|
|
setUserStatusFilter('all');
|
|
setUserRoleFilter('all');
|
|
}}
|
|
className="inline-flex items-center gap-1 text-[11px] font-semibold text-slate-400 hover:text-slate-200 transition cursor-pointer"
|
|
>
|
|
<RotateCcw className="w-3 h-3" />
|
|
<span>Reset</span>
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
{/* Actions on the right: Refresh, Export, Sync */}
|
|
<div className="flex items-center gap-2 flex-wrap justify-end">
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
refetchUsers();
|
|
success('Refreshed', 'User directory updated from API.');
|
|
}}
|
|
disabled={isFetchingUsers}
|
|
className="inline-flex items-center justify-center gap-1.5 px-3.5 py-1.5 text-xs font-semibold text-slate-300 bg-slate-900 hover:bg-slate-800 border border-slate-800 rounded-xl transition cursor-pointer shadow-xs disabled:opacity-50"
|
|
title="Force refresh user directory from API"
|
|
>
|
|
<RefreshCw className={`w-3.5 h-3.5 text-blue-400 ${isFetchingUsers ? 'animate-spin' : ''}`} />
|
|
<span>Refresh</span>
|
|
</button>
|
|
|
|
{canAddUser && (
|
|
<button
|
|
type="button"
|
|
onClick={() => setIsCreateUserModalOpen(true)}
|
|
className="inline-flex items-center gap-1.5 px-3.5 py-1.5 rounded-xl text-xs font-semibold bg-gradient-to-r from-blue-600 to-indigo-600 text-white hover:from-blue-500 hover:to-indigo-500 shadow-md shadow-blue-600/25 transition cursor-pointer"
|
|
title="Provision a new tenant user with Hashids Hex ID and role assignment"
|
|
>
|
|
<Plus className="w-3.5 h-3.5" />
|
|
<span>Provision User</span>
|
|
</button>
|
|
)}
|
|
|
|
<ExportButton
|
|
title="User Directory & Access Register"
|
|
subtitle="Canonical directory of authorized users and account states"
|
|
filename={`pfm-users-directory-${new Date().toISOString().split('T')[0]}`}
|
|
columns={userExportColumns}
|
|
data={sortedUsers}
|
|
claim="settings_users"
|
|
/>
|
|
|
|
<button
|
|
onClick={() => syncMutation.mutate()}
|
|
disabled={syncMutation.isPending}
|
|
className="inline-flex items-center gap-1.5 px-3.5 py-1.5 rounded-xl text-xs font-semibold bg-gradient-to-r from-blue-600 to-indigo-600 text-white hover:from-blue-500 hover:to-indigo-500 shadow-md shadow-blue-600/25 transition cursor-pointer disabled:opacity-50"
|
|
title="Synchronize user profile from JWT session and directory"
|
|
>
|
|
<UserCheck className="w-3.5 h-3.5" />
|
|
<span>{syncMutation.isPending ? 'Syncing...' : 'Sync Directory'}</span>
|
|
</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="p-4 border-b border-slate-800/80 flex items-center justify-between">
|
|
<div className="flex items-center gap-2">
|
|
<Users className="w-4 h-4 text-blue-400" />
|
|
<h3 className="text-sm font-semibold text-white">
|
|
Authorized User Directory ({sortedUsers.length} of {usersList.length})
|
|
</h3>
|
|
<HelpTooltip
|
|
title="User Directory"
|
|
content="Authorized system users, role memberships, and account lifecycle states."
|
|
size="sm"
|
|
placement="right"
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div className="overflow-x-auto min-w-0 w-full">
|
|
<table className="w-full text-left text-xs border-collapse min-w-[850px]">
|
|
<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="idNumber" currentColumn={userSortCol} direction={userSortDir} onSort={handleUserSort} className="py-3.5 px-4">
|
|
Id Number
|
|
</SortableTh>
|
|
<SortableTh column="role" currentColumn={userSortCol} direction={userSortDir} onSort={handleUserSort} className="py-3.5 px-4">
|
|
Role
|
|
</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={7} className="py-8 text-center text-slate-500">
|
|
Loading user directory...
|
|
</td>
|
|
</tr>
|
|
) : sortedUsers.length === 0 ? (
|
|
<tr>
|
|
<td colSpan={7} className="py-8 text-center text-slate-500">
|
|
No users found matching current filters.
|
|
</td>
|
|
</tr>
|
|
) : (
|
|
paginatedUsers.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 font-mono text-slate-300 text-[11px]">
|
|
<ObfuscatedIdNumber idNumber={u.idNumber} />
|
|
</td>
|
|
<td className="py-3.5 px-4">
|
|
<UserRoleBadge role={u.role} />
|
|
</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-blue-300 bg-blue-500/10 hover:bg-blue-500/20 border border-blue-500/20 rounded-lg transition cursor-pointer"
|
|
title="Manage User Roles, Claims, Status, and Security Sessions"
|
|
>
|
|
<Shield className="w-3 h-3 text-blue-400" />
|
|
Manage Access
|
|
</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>
|
|
|
|
{/* Pagination Controls */}
|
|
{totalUsers > 0 && (
|
|
<div className="flex flex-col sm:flex-row items-center justify-between gap-3 px-5 py-3 border-t border-slate-800/80 bg-slate-900/40 text-xs text-slate-400">
|
|
<div className="flex items-center gap-2">
|
|
<span>
|
|
Showing <strong className="text-white font-mono">{Math.min((userPage - 1) * userPageSize + 1, totalUsers)}</strong> to{' '}
|
|
<strong className="text-white font-mono">{Math.min(userPage * userPageSize, totalUsers)}</strong> of{' '}
|
|
<strong className="text-white font-mono">{totalUsers}</strong> users
|
|
</span>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-3">
|
|
<div className="flex items-center gap-1.5">
|
|
<span className="text-slate-400">Per page:</span>
|
|
<select
|
|
value={userPageSize}
|
|
onChange={(e) => {
|
|
setUserPageSize(Number(e.target.value));
|
|
setUserPage(1);
|
|
}}
|
|
className="bg-slate-900 border border-slate-700/80 rounded-lg px-2 py-1 text-xs text-white focus:outline-none focus:ring-1 focus:ring-blue-500"
|
|
>
|
|
<option value={10}>10</option>
|
|
<option value={25}>25</option>
|
|
<option value={50}>50</option>
|
|
<option value={100}>100</option>
|
|
</select>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-1">
|
|
<button
|
|
type="button"
|
|
onClick={() => setUserPage((p) => Math.max(1, p - 1))}
|
|
disabled={userPage <= 1}
|
|
className="p-1 rounded-lg border border-slate-800 bg-slate-900 text-slate-300 hover:bg-slate-800 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
|
|
title="Previous page"
|
|
>
|
|
<ChevronLeft className="w-4 h-4" />
|
|
</button>
|
|
|
|
<span className="px-2 font-mono text-slate-300">
|
|
{userPage} / {totalPages}
|
|
</span>
|
|
|
|
<button
|
|
type="button"
|
|
onClick={() => setUserPage((p) => Math.min(totalPages, p + 1))}
|
|
disabled={userPage >= totalPages}
|
|
className="p-1 rounded-lg border border-slate-800 bg-slate-900 text-slate-300 hover:bg-slate-800 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
|
|
title="Next page"
|
|
>
|
|
<ChevronRight className="w-4 h-4" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* ============================================================== */}
|
|
{/* TAB 2: Communications & Invites */}
|
|
{/* ============================================================== */}
|
|
{activeTab === 'invites' && (
|
|
<div className="space-y-4">
|
|
{/* Standardized Toolbar & Filter Bar directly above Sent Invitations Table */}
|
|
<div className="bg-[#0f1523]/80 backdrop-blur-md p-3.5 sm: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">
|
|
{/* Filters on the left */}
|
|
<div className="flex items-center gap-2.5 flex-wrap flex-1">
|
|
<div className="relative flex-1 min-w-[200px] max-w-sm">
|
|
<Search className="w-3.5 h-3.5 text-slate-400 absolute left-3 top-2.5" />
|
|
<input
|
|
type="text"
|
|
value={inviteSearchTerm}
|
|
onChange={(e) => setInviteSearchTerm(e.target.value)}
|
|
placeholder="Search recipient UUID, message..."
|
|
className="w-full pl-8 pr-3 py-1.5 text-xs bg-slate-900 border border-slate-800 rounded-xl text-white placeholder-slate-500 focus:outline-none focus:border-blue-500 shadow-xs font-mono"
|
|
/>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-1.5 text-xs text-slate-400">
|
|
<select
|
|
value={invitePlatformFilter}
|
|
onChange={(e) => setInvitePlatformFilter(e.target.value === 'all' ? 'all' : Number(e.target.value))}
|
|
className="text-xs bg-slate-900 border border-slate-800 rounded-xl px-3 py-1.5 text-white focus:outline-none focus:border-blue-500"
|
|
>
|
|
<option value="all">All Channels</option>
|
|
{NOTIFICATION_PLATFORM_OPTIONS.map((opt) => (
|
|
<option key={opt.value} value={opt.value}>
|
|
{opt.label}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
|
|
{(inviteSearchTerm.trim() !== '' || invitePlatformFilter !== 'all') && (
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
setInviteSearchTerm('');
|
|
setInvitePlatformFilter('all');
|
|
}}
|
|
className="inline-flex items-center gap-1 text-[11px] font-semibold text-slate-400 hover:text-slate-200 transition cursor-pointer"
|
|
>
|
|
<RotateCcw className="w-3 h-3" />
|
|
<span>Reset</span>
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
{/* Actions on the right: Refresh, Export, Dispatch */}
|
|
<div className="flex items-center gap-2 flex-wrap justify-end">
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
refetchInvites();
|
|
success('Refreshed', 'Sent invitations ledger updated from API.');
|
|
}}
|
|
disabled={isFetchingInvites}
|
|
className="inline-flex items-center justify-center gap-1.5 px-3.5 py-1.5 text-xs font-semibold text-slate-300 bg-slate-900 hover:bg-slate-800 border border-slate-800 rounded-xl transition cursor-pointer shadow-xs disabled:opacity-50"
|
|
title="Force refresh invitations from API"
|
|
>
|
|
<RefreshCw className={`w-3.5 h-3.5 text-blue-400 ${isFetchingInvites ? 'animate-spin' : ''}`} />
|
|
<span>Refresh</span>
|
|
</button>
|
|
|
|
<ExportButton
|
|
title="Sent Communications & Invitations Ledger"
|
|
subtitle="Telemetry records of candidate invitations and statutory communications dispatched"
|
|
filename={`pfm-sent-invitations-${new Date().toISOString().split('T')[0]}`}
|
|
columns={inviteExportColumns}
|
|
data={sortedInvites}
|
|
claim="settings_invites"
|
|
/>
|
|
|
|
{canAddInvite && (
|
|
<button
|
|
onClick={() => setIsSendInviteModalOpen(true)}
|
|
className="inline-flex items-center gap-1.5 px-3.5 py-1.5 rounded-xl text-xs font-semibold bg-gradient-to-r from-blue-600 to-indigo-600 text-white hover:from-blue-500 hover:to-indigo-500 shadow-md shadow-blue-600/25 transition cursor-pointer"
|
|
>
|
|
<Send className="w-3.5 h-3.5" />
|
|
<span>Dispatch Invitation</span>
|
|
</button>
|
|
)}
|
|
</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="p-4 border-b border-slate-800/80 flex items-center justify-between">
|
|
<div className="flex items-center gap-2">
|
|
<Mail className="w-4 h-4 text-blue-400" />
|
|
<h3 className="text-sm font-semibold text-white">
|
|
Sent Invitations Ledger ({sortedInvites.length} of {invitesList.length})
|
|
</h3>
|
|
<HelpTooltip
|
|
title="Sent Invitations"
|
|
content="Historical record of dispatched applicant invites, platform channels, and associated awards."
|
|
size="sm"
|
|
placement="right"
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div className="overflow-x-auto min-w-0 w-full">
|
|
<table className="w-full text-left text-xs border-collapse min-w-[850px]">
|
|
<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}
|
|
claim="settings_notifications"
|
|
/>
|
|
</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 min-w-0 w-full">
|
|
<table className="w-full text-left border-collapse text-xs min-w-[900px]">
|
|
<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="relative z-30 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}
|
|
claim="settings_auditlog"
|
|
/>
|
|
</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 min-w-0 w-full">
|
|
<table className="w-full text-left text-xs border-collapse min-w-[680px]">
|
|
<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="performedBy" currentColumn={auditSortCol} direction={auditSortDir} onSort={handleAuditSort} className="py-3.5 px-4">
|
|
Performed By
|
|
</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={6} 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={6} 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"
|
|
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-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={6} 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 flex-wrap">
|
|
<div className="flex items-center gap-2">
|
|
<Layers className="w-4 h-4 text-blue-400 shrink-0" />
|
|
<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 shrink-0">
|
|
{changesCount} {changesCount === 1 ? 'Entry' : 'Entries'}
|
|
</span>
|
|
</div>
|
|
<div className="flex items-center gap-2 sm:gap-3 text-xs text-slate-400 flex-wrap min-w-0">
|
|
<span className="truncate max-w-[260px] sm:max-w-none">
|
|
Entity: <strong className="text-white">{log.entityName || 'General'}</strong> {log.entityId ? `(#${log.entityId})` : ''}
|
|
</span>
|
|
<span>•</span>
|
|
<span className="flex items-center gap-1 min-w-0">
|
|
Performer: <strong className="text-slate-200 font-mono text-[11px] truncate max-w-[220px] sm:max-w-none">{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 shrink-0"
|
|
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"
|
|
maxWidth="xl"
|
|
>
|
|
{selectedAuditLog && (
|
|
<div className="space-y-4 text-xs min-w-0">
|
|
<div className="p-4 bg-[#0d121f] rounded-xl border border-slate-800 space-y-3 min-w-0">
|
|
<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 min-w-0">
|
|
<div className="min-w-0">
|
|
<span className="text-slate-400 font-semibold block text-[11px] uppercase">Entity Name:</span>
|
|
<span className="text-white font-semibold truncate block">{selectedAuditLog.entityName || '—'}</span>
|
|
</div>
|
|
<div className="min-w-0">
|
|
<span className="text-slate-400 font-semibold block text-[11px] uppercase">Entity ID:</span>
|
|
<span className="font-mono text-slate-300 break-all block">{selectedAuditLog.entityId || '—'}</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-2 gap-3 border-t border-slate-800/80 pt-2 min-w-0">
|
|
<div className="min-w-0">
|
|
<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 min-w-0">
|
|
<span className="font-mono text-slate-200 text-[11px] truncate block" title={selectedAuditLog.performedBy || 'System'}>
|
|
{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" title={`ID: ${selectedAuditLog.performedBy}`}>
|
|
ID: {selectedAuditLog.performedBy}
|
|
</span>
|
|
)}
|
|
</div>
|
|
<div className="min-w-0">
|
|
<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 min-w-0">
|
|
<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 min-w-0">
|
|
<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.5 p-2.5 rounded-lg bg-slate-900 border border-slate-800 min-w-0 max-h-56 overflow-y-auto">
|
|
{selectedAuditLog.changes.map((ch, idx) => (
|
|
<li key={idx} className="font-mono text-emerald-400/90 text-[11px] flex items-start gap-2 min-w-0">
|
|
<span className="w-1.5 h-1.5 rounded-full bg-emerald-400 shrink-0 mt-1.5" />
|
|
<span className="break-all whitespace-pre-wrap min-w-0 flex-1 leading-relaxed">
|
|
{ch}
|
|
</span>
|
|
</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 min-w-0">
|
|
<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 whitespace-pre-wrap break-all max-h-48 min-w-0">
|
|
{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 Administration & Role/Claim/Session Control */}
|
|
{/* ============================================================== */}
|
|
{isUserDetailModalOpen && selectedUserForDetail && (
|
|
<UserSecurityModal
|
|
isOpen={isUserDetailModalOpen}
|
|
onClose={() => {
|
|
setIsUserDetailModalOpen(false);
|
|
setSelectedUserForDetail(null);
|
|
}}
|
|
user={selectedUserForDetail}
|
|
/>
|
|
)}
|
|
|
|
{/* ============================================================== */}
|
|
{/* Modal: Provision Tenant User */}
|
|
{/* ============================================================== */}
|
|
<CreateUserModal
|
|
isOpen={isCreateUserModalOpen}
|
|
onClose={() => setIsCreateUserModalOpen(false)}
|
|
/>
|
|
|
|
{/* ============================================================== */}
|
|
{/* 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>
|
|
);
|
|
};
|