Refactored UI
Added Role filed to user table and endpoint implementation
This commit is contained in:
@@ -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 & 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 & 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">
|
||||
|
||||
Reference in New Issue
Block a user