Merge pull request 'Added Role column to user directory' (#117) from finishline into main
Reviewed-on: #117
This commit was merged in pull request #117.
This commit is contained in:
@@ -1,6 +1,11 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { ApprovalStatus, Priority, ActivityStatus } from '../../types';
|
import { ApprovalStatus, Priority, ActivityStatus, UserRole } from '../../types';
|
||||||
import { getApprovalStatusBadge, getPriorityBadge, getActivityStatusBadge } from '../../lib/formatters';
|
import {
|
||||||
|
getApprovalStatusBadge,
|
||||||
|
getPriorityBadge,
|
||||||
|
getActivityStatusBadge,
|
||||||
|
getUserRoleBadge,
|
||||||
|
} from '../../lib/formatters';
|
||||||
|
|
||||||
export const ApprovalStatusBadge: React.FC<{ status: ApprovalStatus | number | null | undefined }> = ({ status }) => {
|
export const ApprovalStatusBadge: React.FC<{ status: ApprovalStatus | number | null | undefined }> = ({ status }) => {
|
||||||
const badge = getApprovalStatusBadge(status ?? ApprovalStatus.Pending);
|
const badge = getApprovalStatusBadge(status ?? ApprovalStatus.Pending);
|
||||||
@@ -29,3 +34,18 @@ export const ActivityStatusBadge: React.FC<{ status: ActivityStatus | number | s
|
|||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const UserRoleBadge: React.FC<{ role: string | number | UserRole | null | undefined; className?: string }> = ({
|
||||||
|
role,
|
||||||
|
className = '',
|
||||||
|
}) => {
|
||||||
|
const badge = getUserRoleBadge(role);
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={`inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-[11px] font-medium border whitespace-nowrap ${badge.className} ${className}`}
|
||||||
|
>
|
||||||
|
{badge.dot && <span className={`w-1.5 h-1.5 rounded-full shrink-0 ${badge.dot}`} />}
|
||||||
|
<span>{badge.label}</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
NotificationPlatform,
|
NotificationPlatform,
|
||||||
NotificationStatus,
|
NotificationStatus,
|
||||||
EmailStatuses,
|
EmailStatuses,
|
||||||
|
UserRole,
|
||||||
} from '../types';
|
} from '../types';
|
||||||
|
|
||||||
export const formatZAR = (amount: number): string => {
|
export const formatZAR = (amount: number): string => {
|
||||||
@@ -570,3 +571,80 @@ export const getUserEmail = (userIdOrEmail?: string | number | null): string =>
|
|||||||
|
|
||||||
return str;
|
return str;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// User Role Badges & Formatter
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
export const USER_ROLE_OPTIONS = [
|
||||||
|
{ value: 'Admin', label: 'Admin' },
|
||||||
|
{ value: 'Executive', label: 'Executive' },
|
||||||
|
{ value: 'Supervisor', label: 'Supervisor' },
|
||||||
|
{ value: 'Manager', label: 'Manager' },
|
||||||
|
{ value: 'Contributor', label: 'Contributor' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const getUserRoleBadge = (role?: string | number | UserRole | null) => {
|
||||||
|
if (role === null || role === undefined || role === '') {
|
||||||
|
return {
|
||||||
|
label: 'Contributor',
|
||||||
|
className: 'bg-emerald-500/15 text-emerald-300 border-emerald-500/30',
|
||||||
|
dot: 'bg-emerald-400',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const roleStr = String(role).trim().toLowerCase();
|
||||||
|
|
||||||
|
// 1. Admin - High-privilege Crimson/Rose
|
||||||
|
if (roleStr === 'admin' || roleStr === 'superadmin' || roleStr === '1') {
|
||||||
|
return {
|
||||||
|
label: 'Admin',
|
||||||
|
className: 'bg-rose-500/15 text-rose-300 border-rose-500/30 shadow-xs shadow-rose-500/10 font-semibold',
|
||||||
|
dot: 'bg-rose-400',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Executive - Strategic Amber/Gold
|
||||||
|
if (roleStr === 'executive' || roleStr === '2') {
|
||||||
|
return {
|
||||||
|
label: 'Executive',
|
||||||
|
className: 'bg-amber-500/15 text-amber-300 border-amber-500/30 shadow-xs shadow-amber-500/10 font-semibold',
|
||||||
|
dot: 'bg-amber-400',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Supervisor - Operational Governance Purple/Violet
|
||||||
|
if (roleStr === 'supervisor' || roleStr === '3') {
|
||||||
|
return {
|
||||||
|
label: 'Supervisor',
|
||||||
|
className: 'bg-purple-500/15 text-purple-300 border-purple-500/30 shadow-xs shadow-purple-500/10 font-semibold',
|
||||||
|
dot: 'bg-purple-400',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Manager - Programme & Project Management Blue/Sky
|
||||||
|
if (roleStr === 'manager' || roleStr === 'programmemanager' || roleStr === 'projectmanager' || roleStr === '4') {
|
||||||
|
return {
|
||||||
|
label: 'Manager',
|
||||||
|
className: 'bg-blue-500/15 text-blue-300 border-blue-500/30 shadow-xs shadow-blue-500/10 font-semibold',
|
||||||
|
dot: 'bg-blue-400',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Contributor - Execution & Outreach Emerald/Mint
|
||||||
|
if (roleStr === 'contributor' || roleStr === 'viewer' || roleStr === 'recipientviewer' || roleStr === '5') {
|
||||||
|
return {
|
||||||
|
label: 'Contributor',
|
||||||
|
className: 'bg-emerald-500/15 text-emerald-300 border-emerald-500/30 shadow-xs shadow-emerald-500/10 font-semibold',
|
||||||
|
dot: 'bg-emerald-400',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback for custom formatted role strings
|
||||||
|
const formatted = roleStr.charAt(0).toUpperCase() + roleStr.slice(1);
|
||||||
|
return {
|
||||||
|
label: formatted,
|
||||||
|
className: 'bg-cyan-500/15 text-cyan-300 border-cyan-500/30',
|
||||||
|
dot: 'bg-cyan-400',
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ import { useToast } from '../components/ui/Toast';
|
|||||||
import { ExportButton } from '../components/ui/ExportButton';
|
import { ExportButton } from '../components/ui/ExportButton';
|
||||||
import { ExportColumn } from '../utils/exportUtils';
|
import { ExportColumn } from '../utils/exportUtils';
|
||||||
import { useTableSort, SortableTh } from '../utils/tableSort';
|
import { useTableSort, SortableTh } from '../utils/tableSort';
|
||||||
|
import { UserRoleBadge } from '../components/ui/StatusBadge';
|
||||||
import { useAuth } from '../auth/AuthProvider';
|
import { useAuth } from '../auth/AuthProvider';
|
||||||
import {
|
import {
|
||||||
EmailStatuses,
|
EmailStatuses,
|
||||||
@@ -84,6 +85,8 @@ import {
|
|||||||
getUserEmail,
|
getUserEmail,
|
||||||
registerUserEmails,
|
registerUserEmails,
|
||||||
getActivityStatusBadge,
|
getActivityStatusBadge,
|
||||||
|
getUserRoleBadge,
|
||||||
|
USER_ROLE_OPTIONS,
|
||||||
EMAIL_STATUSES_OPTIONS,
|
EMAIL_STATUSES_OPTIONS,
|
||||||
NOTIFICATION_STATUS_OPTIONS,
|
NOTIFICATION_STATUS_OPTIONS,
|
||||||
NOTIFICATION_PLATFORM_OPTIONS,
|
NOTIFICATION_PLATFORM_OPTIONS,
|
||||||
@@ -101,6 +104,7 @@ export const SettingsPage: React.FC = () => {
|
|||||||
// ==========================================
|
// ==========================================
|
||||||
const [userEmailSearch, setUserEmailSearch] = useState('');
|
const [userEmailSearch, setUserEmailSearch] = useState('');
|
||||||
const [userStatusFilter, setUserStatusFilter] = useState<ActivityStatus | 'all'>('all');
|
const [userStatusFilter, setUserStatusFilter] = useState<ActivityStatus | 'all'>('all');
|
||||||
|
const [userRoleFilter, setUserRoleFilter] = useState<string>('all');
|
||||||
const [selectedUserForDetail, setSelectedUserForDetail] = useState<User | null>(null);
|
const [selectedUserForDetail, setSelectedUserForDetail] = useState<User | null>(null);
|
||||||
const [isUserDetailModalOpen, setIsUserDetailModalOpen] = useState(false);
|
const [isUserDetailModalOpen, setIsUserDetailModalOpen] = useState(false);
|
||||||
|
|
||||||
@@ -125,6 +129,16 @@ export const SettingsPage: React.FC = () => {
|
|||||||
queryFn: () => getUsers(1, 100, userFilter),
|
queryFn: () => getUsers(1, 100, userFilter),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const filteredUsers = useMemo(() => {
|
||||||
|
return usersList.filter((u) => {
|
||||||
|
const matchRole =
|
||||||
|
userRoleFilter === 'all' ||
|
||||||
|
(u.role && String(u.role).toLowerCase() === userRoleFilter.toLowerCase()) ||
|
||||||
|
(!u.role && userRoleFilter.toLowerCase() === 'contributor');
|
||||||
|
return matchRole;
|
||||||
|
});
|
||||||
|
}, [usersList, userRoleFilter]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (usersDirectory && usersDirectory.length > 0) {
|
if (usersDirectory && usersDirectory.length > 0) {
|
||||||
registerUserEmails(usersDirectory);
|
registerUserEmails(usersDirectory);
|
||||||
@@ -462,6 +476,7 @@ export const SettingsPage: React.FC = () => {
|
|||||||
const userExportColumns: ExportColumn<User>[] = [
|
const userExportColumns: ExportColumn<User>[] = [
|
||||||
{ header: 'User Email', key: 'email', format: (val, u) => val || getUserEmail(u.id) || '—' },
|
{ header: 'User Email', key: 'email', format: (val, u) => val || getUserEmail(u.id) || '—' },
|
||||||
{ header: 'User Identifier', key: 'id' },
|
{ header: 'User Identifier', key: 'id' },
|
||||||
|
{ header: 'Role', key: 'role', format: (val) => getUserRoleBadge(val).label },
|
||||||
{ header: 'Status', key: 'status', format: (val) => getActivityStatusBadge(val).label },
|
{ header: 'Status', key: 'status', format: (val) => getActivityStatusBadge(val).label },
|
||||||
{ header: 'Last Login', key: 'lastLoginAt', format: (val) => (val ? formatDate(val) : 'Never') },
|
{ header: 'Last Login', key: 'lastLoginAt', format: (val) => (val ? formatDate(val) : 'Never') },
|
||||||
];
|
];
|
||||||
@@ -483,7 +498,9 @@ export const SettingsPage: React.FC = () => {
|
|||||||
sortColumn: userSortCol,
|
sortColumn: userSortCol,
|
||||||
sortDirection: userSortDir,
|
sortDirection: userSortDir,
|
||||||
handleSort: handleUserSort,
|
handleSort: handleUserSort,
|
||||||
} = useTableSort(usersList, 'email', 'asc');
|
} = useTableSort(filteredUsers, 'email', 'asc', {
|
||||||
|
role: (u: User) => getUserRoleBadge(u.role).label,
|
||||||
|
});
|
||||||
|
|
||||||
const {
|
const {
|
||||||
sortedData: sortedInvites,
|
sortedData: sortedInvites,
|
||||||
@@ -682,6 +699,21 @@ export const SettingsPage: React.FC = () => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</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">
|
<div className="flex items-center gap-1.5 text-xs text-slate-400">
|
||||||
<select
|
<select
|
||||||
value={userStatusFilter}
|
value={userStatusFilter}
|
||||||
@@ -695,12 +727,13 @@ export const SettingsPage: React.FC = () => {
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{(userEmailSearch.trim() !== '' || userStatusFilter !== 'all') && (
|
{(userEmailSearch.trim() !== '' || userStatusFilter !== 'all' || userRoleFilter !== 'all') && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setUserEmailSearch('');
|
setUserEmailSearch('');
|
||||||
setUserStatusFilter('all');
|
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"
|
className="inline-flex items-center gap-1 text-[11px] font-semibold text-slate-400 hover:text-slate-200 transition cursor-pointer"
|
||||||
>
|
>
|
||||||
@@ -772,6 +805,9 @@ export const SettingsPage: React.FC = () => {
|
|||||||
<SortableTh column="id" currentColumn={userSortCol} direction={userSortDir} onSort={handleUserSort} className="py-3.5 px-4">
|
<SortableTh column="id" currentColumn={userSortCol} direction={userSortDir} onSort={handleUserSort} className="py-3.5 px-4">
|
||||||
User Identifier
|
User Identifier
|
||||||
</SortableTh>
|
</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">
|
<SortableTh column="status" currentColumn={userSortCol} direction={userSortDir} onSort={handleUserSort} className="py-3.5 px-4">
|
||||||
Status
|
Status
|
||||||
</SortableTh>
|
</SortableTh>
|
||||||
@@ -784,13 +820,13 @@ export const SettingsPage: React.FC = () => {
|
|||||||
<tbody className="divide-y divide-slate-800/50">
|
<tbody className="divide-y divide-slate-800/50">
|
||||||
{loadingUsers ? (
|
{loadingUsers ? (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={5} className="py-8 text-center text-slate-500">
|
<td colSpan={6} className="py-8 text-center text-slate-500">
|
||||||
Loading user directory...
|
Loading user directory...
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
) : sortedUsers.length === 0 ? (
|
) : sortedUsers.length === 0 ? (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={5} className="py-8 text-center text-slate-500">
|
<td colSpan={6} className="py-8 text-center text-slate-500">
|
||||||
No users found matching current filters.
|
No users found matching current filters.
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -805,6 +841,9 @@ export const SettingsPage: React.FC = () => {
|
|||||||
<td className="py-3.5 px-4 font-mono text-slate-400 text-[11px]">
|
<td className="py-3.5 px-4 font-mono text-slate-400 text-[11px]">
|
||||||
{u.id}
|
{u.id}
|
||||||
</td>
|
</td>
|
||||||
|
<td className="py-3.5 px-4">
|
||||||
|
<UserRoleBadge role={u.role} />
|
||||||
|
</td>
|
||||||
<td className="py-3.5 px-4">
|
<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}`}>
|
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-[10px] font-semibold border ${badge.className}`}>
|
||||||
{badge.label}
|
{badge.label}
|
||||||
@@ -2173,6 +2212,10 @@ export const SettingsPage: React.FC = () => {
|
|||||||
{userFreshDetail?.id || selectedUserForDetail.id}
|
{userFreshDetail?.id || selectedUserForDetail.id}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-slate-400 font-semibold block text-[11px] uppercase mb-1">System Role:</span>
|
||||||
|
<UserRoleBadge role={userFreshDetail?.role ?? selectedUserForDetail.role} />
|
||||||
|
</div>
|
||||||
<div className="flex items-center justify-between pt-1">
|
<div className="flex items-center justify-between pt-1">
|
||||||
<div>
|
<div>
|
||||||
<span className="text-slate-400 font-semibold block text-[11px] uppercase">Status:</span>
|
<span className="text-slate-400 font-semibold block text-[11px] uppercase">Status:</span>
|
||||||
|
|||||||
@@ -245,8 +245,12 @@ export interface Dashboard {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export enum UserRole {
|
export enum UserRole {
|
||||||
|
Admin = 'Admin',
|
||||||
|
Executive = 'Executive',
|
||||||
|
Supervisor = 'Supervisor',
|
||||||
|
Manager = 'Manager',
|
||||||
|
Contributor = 'Contributor',
|
||||||
SuperAdmin = 1,
|
SuperAdmin = 1,
|
||||||
Executive = 2,
|
|
||||||
ProgrammeManager = 3,
|
ProgrammeManager = 3,
|
||||||
FinanceOfficer = 4,
|
FinanceOfficer = 4,
|
||||||
RecipientViewer = 5,
|
RecipientViewer = 5,
|
||||||
@@ -257,7 +261,7 @@ export interface User {
|
|||||||
email: string | null;
|
email: string | null;
|
||||||
status: ActivityStatus;
|
status: ActivityStatus;
|
||||||
lastLoginAt: string | null;
|
lastLoginAt: string | null;
|
||||||
role?: string | UserRole;
|
role?: string | UserRole | null;
|
||||||
name?: string;
|
name?: string;
|
||||||
organisationId?: number | string;
|
organisationId?: number | string;
|
||||||
organisationName?: string;
|
organisationName?: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user