Refactored UI

Added Role filed to user table and endpoint implementation
This commit is contained in:
2026-09-28 10:25:00 +02:00
parent 7037ea72ca
commit ea71e55e2d
21 changed files with 3100 additions and 128 deletions
@@ -1,4 +1,4 @@
import React, { useState } from 'react';
import React, { useState, useMemo } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import {
Shield,
@@ -36,6 +36,7 @@ import {
ChevronDown,
ChevronRight,
User as UserIcon,
RotateCcw,
} from 'lucide-react';
import {
getUsers,
@@ -114,7 +115,12 @@ export const SettingsPage: React.FC = () => {
staleTime: 5 * 60 * 1000,
});
const { data: usersList = [], isLoading: loadingUsers, refetch: refetchUsers } = useQuery({
const {
data: usersList = [],
isLoading: loadingUsers,
isFetching: isFetchingUsers,
refetch: refetchUsers,
} = useQuery({
queryKey: ['users', userStatusFilter, userEmailSearch],
queryFn: () => getUsers(1, 100, userFilter),
});
@@ -191,11 +197,32 @@ export const SettingsPage: React.FC = () => {
const [inviteMessage, setInviteMessage] = useState('');
const [invitePlatform, setInvitePlatform] = useState<NotificationPlatform>(NotificationPlatform.Email);
const { data: invitesList = [], isLoading: loadingInvites } = useQuery({
// 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),
@@ -432,6 +459,25 @@ export const SettingsPage: React.FC = () => {
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: '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,
@@ -444,7 +490,7 @@ export const SettingsPage: React.FC = () => {
sortColumn: inviteSortCol,
sortDirection: inviteSortDir,
handleSort: handleInviteSort,
} = useTableSort(invitesList, 'createdAt', 'desc');
} = useTableSort(filteredInvites, 'createdAt', 'desc');
const getActionBadge = (action?: string | null) => {
const act = (action || 'GENERAL_EVENT').toUpperCase();
@@ -621,44 +667,101 @@ export const SettingsPage: React.FC = () => {
{/* ============================================================== */}
{activeTab === 'users' && (
<div className="space-y-4">
{/* Filter and Search Bar */}
<div className="bg-[#0f1523]/80 backdrop-blur-md p-4 rounded-2xl border border-slate-800/80 shadow-xl flex flex-col sm:flex-row items-center justify-between gap-3">
<div className="relative w-full sm:w-80">
<Search className="w-4 h-4 text-slate-500 absolute left-3 top-2.5" />
<input
type="text"
value={userEmailSearch}
onChange={(e) => setUserEmailSearch(e.target.value)}
placeholder="Search users by email..."
className="w-full pl-9 pr-3 py-1.5 text-xs bg-slate-900/80 border border-slate-700/80 rounded-xl text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-blue-500/50"
/>
{/* 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={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') && (
<button
type="button"
onClick={() => {
setUserEmailSearch('');
setUserStatusFilter('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>
<div className="flex items-center gap-2 w-full sm:w-auto">
<span className="text-xs font-semibold text-slate-400 whitespace-nowrap">Filter Status:</span>
<select
value={userStatusFilter}
onChange={(e) => setUserStatusFilter(e.target.value === 'all' ? 'all' : Number(e.target.value))}
className="text-xs bg-slate-900/80 border border-slate-700/80 rounded-xl px-3 py-1.5 text-white focus:outline-none focus:ring-2 focus:ring-blue-500/50 [&>option]:bg-slate-900 [&>option]:text-white"
{/* 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"
>
<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>
<RefreshCw className={`w-3.5 h-3.5 text-blue-400 ${isFetchingUsers ? 'animate-spin' : ''}`} />
<span>Refresh</span>
</button>
<ExportButton
title="User Directory &amp; 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}
/>
<button
onClick={() => refetchUsers()}
className="p-1.5 rounded-xl border border-slate-700 bg-slate-800/80 hover:bg-slate-700 text-slate-300 transition cursor-pointer"
title="Refresh user list"
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"
>
<RefreshCw className="w-4 h-4" />
<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">
<table className="w-full text-left text-xs border-collapse">
<thead>
@@ -831,13 +934,100 @@ export const SettingsPage: React.FC = () => {
)}
</div>
{/* 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 &amp; 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}
/>
<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="px-5 py-3.5 border-b border-slate-800/80 bg-slate-900/40 flex items-center justify-between">
<span className="text-xs font-bold text-white uppercase tracking-wider">
Sent Invitations Ledger
</span>
<span className="text-xs text-slate-400">{invitesList.length} invitations dispatched</span>
<div 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">
<table className="w-full text-left text-xs border-collapse">