Implemented mailbox in ConsoleUI
This commit is contained in:
@@ -0,0 +1,373 @@
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import {
|
||||
Search,
|
||||
User as UserIcon,
|
||||
Check,
|
||||
X,
|
||||
Shield,
|
||||
Briefcase,
|
||||
Coins,
|
||||
ShieldAlert,
|
||||
Users,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
} from 'lucide-react';
|
||||
import { getUsers } from '../../services/api';
|
||||
import { User, ActivityStatus } from '../../types';
|
||||
|
||||
export interface SelectedUser {
|
||||
id: string;
|
||||
email: string;
|
||||
name?: string;
|
||||
role?: string;
|
||||
}
|
||||
|
||||
interface UserSearchPickerProps {
|
||||
selectedUsers: SelectedUser[];
|
||||
onSelectUser: (user: SelectedUser) => void;
|
||||
onRemoveUser: (userId: string) => void;
|
||||
multiple?: boolean;
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
interface UserGroup {
|
||||
id: string;
|
||||
label: string;
|
||||
icon: React.ElementType;
|
||||
color: string;
|
||||
users: User[];
|
||||
}
|
||||
|
||||
export const UserSearchPicker: React.FC<UserSearchPickerProps> = ({
|
||||
selectedUsers,
|
||||
onSelectUser,
|
||||
onRemoveUser,
|
||||
multiple = false,
|
||||
placeholder = 'Search by name, email, or role...',
|
||||
}) => {
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
|
||||
const [collapsedGroups, setCollapsedGroups] = useState<Record<string, boolean>>({});
|
||||
|
||||
// Query tenant users
|
||||
const { data: users = [], isLoading } = useQuery<User[]>({
|
||||
queryKey: ['users-directory'],
|
||||
queryFn: () => getUsers(1, 200),
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
|
||||
// Filter active users
|
||||
const activeUsers = useMemo(() => {
|
||||
return users.filter(u => u.status === ActivityStatus.Active && !!u.email);
|
||||
}, [users]);
|
||||
|
||||
// Organize users into intuitive functional groups
|
||||
const groupedUsers = useMemo(() => {
|
||||
const term = searchTerm.toLowerCase().trim();
|
||||
|
||||
const filtered = activeUsers.filter(u => {
|
||||
if (!term) return true;
|
||||
const email = (u.email || '').toLowerCase();
|
||||
const role = (u.role || '').toLowerCase();
|
||||
return email.includes(term) || role.includes(term);
|
||||
});
|
||||
|
||||
const adminUsers: User[] = [];
|
||||
const pmUsers: User[] = [];
|
||||
const financeUsers: User[] = [];
|
||||
const meUsers: User[] = [];
|
||||
const otherUsers: User[] = [];
|
||||
|
||||
filtered.forEach(u => {
|
||||
const r = (u.role || '').toLowerCase();
|
||||
if (r.includes('admin') || r.includes('executive') || r.includes('director')) {
|
||||
adminUsers.push(u);
|
||||
} else if (r.includes('programme') || r.includes('project') || r.includes('manager')) {
|
||||
pmUsers.push(u);
|
||||
} else if (r.includes('finance') || r.includes('disbursement') || r.includes('accountant') || r.includes('budget')) {
|
||||
financeUsers.push(u);
|
||||
} else if (r.includes('m&e') || r.includes('monitoring') || r.includes('eval') || r.includes('risk') || r.includes('compliance')) {
|
||||
meUsers.push(u);
|
||||
} else {
|
||||
otherUsers.push(u);
|
||||
}
|
||||
});
|
||||
|
||||
const groups: UserGroup[] = [];
|
||||
|
||||
if (adminUsers.length > 0) {
|
||||
groups.push({
|
||||
id: 'admin',
|
||||
label: 'Leadership & Administration',
|
||||
icon: Shield,
|
||||
color: 'text-amber-400 bg-amber-500/10 border-amber-500/20',
|
||||
users: adminUsers,
|
||||
});
|
||||
}
|
||||
|
||||
if (pmUsers.length > 0) {
|
||||
groups.push({
|
||||
id: 'pm',
|
||||
label: 'Programme & Project Management',
|
||||
icon: Briefcase,
|
||||
color: 'text-blue-400 bg-blue-500/10 border-blue-500/20',
|
||||
users: pmUsers,
|
||||
});
|
||||
}
|
||||
|
||||
if (financeUsers.length > 0) {
|
||||
groups.push({
|
||||
id: 'finance',
|
||||
label: 'Finance & Disbursements',
|
||||
icon: Coins,
|
||||
color: 'text-emerald-400 bg-emerald-500/10 border-emerald-500/20',
|
||||
users: financeUsers,
|
||||
});
|
||||
}
|
||||
|
||||
if (meUsers.length > 0) {
|
||||
groups.push({
|
||||
id: 'me',
|
||||
label: 'M&E, Compliance & Risks',
|
||||
icon: ShieldAlert,
|
||||
color: 'text-purple-400 bg-purple-500/10 border-purple-500/20',
|
||||
users: meUsers,
|
||||
});
|
||||
}
|
||||
|
||||
if (otherUsers.length > 0) {
|
||||
groups.push({
|
||||
id: 'team',
|
||||
label: 'Tenant Staff & Team Members',
|
||||
icon: Users,
|
||||
color: 'text-slate-400 bg-slate-500/10 border-slate-500/20',
|
||||
users: otherUsers,
|
||||
});
|
||||
}
|
||||
|
||||
return groups;
|
||||
}, [activeUsers, searchTerm]);
|
||||
|
||||
const toggleGroupCollapse = (groupId: string) => {
|
||||
setCollapsedGroups(prev => ({
|
||||
...prev,
|
||||
[groupId]: !prev[groupId],
|
||||
}));
|
||||
};
|
||||
|
||||
const isUserSelected = (userId: string) => {
|
||||
return selectedUsers.some(u => u.id === userId);
|
||||
};
|
||||
|
||||
const handleUserClick = (u: User) => {
|
||||
if (!u.email) return;
|
||||
|
||||
if (isUserSelected(u.id)) {
|
||||
onRemoveUser(u.id);
|
||||
} else {
|
||||
const displayName = u.email.split('@')[0].replace(/[._-]/g, ' ');
|
||||
const formattedName = displayName
|
||||
.split(' ')
|
||||
.map(w => w.charAt(0).toUpperCase() + w.slice(1))
|
||||
.join(' ');
|
||||
|
||||
if (!multiple) {
|
||||
// Single selection replaces
|
||||
onSelectUser({
|
||||
id: u.id,
|
||||
email: u.email,
|
||||
name: formattedName,
|
||||
role: u.role || 'User',
|
||||
});
|
||||
setIsDropdownOpen(false);
|
||||
} else {
|
||||
onSelectUser({
|
||||
id: u.id,
|
||||
email: u.email,
|
||||
name: formattedName,
|
||||
role: u.role || 'User',
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative w-full">
|
||||
{/* Selected Users Chips */}
|
||||
{selectedUsers.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5 mb-2">
|
||||
{selectedUsers.map(su => (
|
||||
<span
|
||||
key={su.id}
|
||||
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg bg-blue-500/15 border border-blue-500/30 text-blue-200 text-xs font-medium shadow-xs"
|
||||
>
|
||||
<div className="w-4 h-4 rounded-full bg-blue-600/40 text-[9px] font-bold flex items-center justify-center text-blue-300">
|
||||
{su.name ? su.name.slice(0, 2).toUpperCase() : su.email.slice(0, 2).toUpperCase()}
|
||||
</div>
|
||||
<span className="truncate max-w-[180px]">{su.name || su.email}</span>
|
||||
{su.role && (
|
||||
<span className="text-[10px] text-blue-400 bg-blue-900/40 px-1 py-0.2 rounded border border-blue-800/40">
|
||||
{su.role}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onRemoveUser(su.id)}
|
||||
className="text-blue-400 hover:text-white hover:bg-blue-600/30 p-0.5 rounded transition cursor-pointer"
|
||||
title="Remove recipient"
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Search Input Box */}
|
||||
<div className="relative">
|
||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none text-slate-400">
|
||||
<Search className="w-4 h-4" />
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
value={searchTerm}
|
||||
onChange={e => {
|
||||
setSearchTerm(e.target.value);
|
||||
if (!isDropdownOpen) setIsDropdownOpen(true);
|
||||
}}
|
||||
onFocus={() => setIsDropdownOpen(true)}
|
||||
placeholder={placeholder}
|
||||
className="w-full pl-9 pr-8 py-2 rounded-xl bg-slate-900/90 border border-slate-700/80 hover:border-slate-600 focus:border-blue-500 text-slate-100 placeholder-slate-400 text-xs focus:outline-none transition shadow-inner"
|
||||
/>
|
||||
{searchTerm && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSearchTerm('')}
|
||||
className="absolute inset-y-0 right-0 pr-3 flex items-center text-slate-400 hover:text-white"
|
||||
>
|
||||
<X className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Organized User Directory Dropdown */}
|
||||
{isDropdownOpen && (
|
||||
<div className="absolute left-0 right-0 top-full mt-1.5 max-h-72 overflow-y-auto bg-[#0d1322] border border-slate-700/80 rounded-2xl shadow-2xl z-50 p-2 space-y-3 backdrop-blur-md animate-in fade-in slide-in-from-top-1">
|
||||
{isLoading ? (
|
||||
<div className="py-6 text-center text-xs text-slate-400 flex items-center justify-center gap-2">
|
||||
<div className="w-4 h-4 border-2 border-blue-500 border-t-transparent rounded-full animate-spin" />
|
||||
<span>Loading tenant users directory...</span>
|
||||
</div>
|
||||
) : groupedUsers.length === 0 ? (
|
||||
<div className="py-6 text-center text-xs text-slate-400">
|
||||
No matching users found for "{searchTerm}".
|
||||
</div>
|
||||
) : (
|
||||
groupedUsers.map(group => {
|
||||
const GroupIcon = group.icon;
|
||||
const isCollapsed = collapsedGroups[group.id];
|
||||
|
||||
return (
|
||||
<div key={group.id} className="space-y-1">
|
||||
{/* Category Header */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleGroupCollapse(group.id)}
|
||||
className="w-full flex items-center justify-between px-2.5 py-1.5 rounded-lg bg-slate-900/60 hover:bg-slate-800/60 text-slate-300 text-[11px] font-semibold transition cursor-pointer border border-slate-800/60"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`p-1 rounded-md border ${group.color}`}>
|
||||
<GroupIcon className="w-3 h-3" />
|
||||
</span>
|
||||
<span>{group.label}</span>
|
||||
<span className="text-[10px] text-slate-400 bg-slate-800 px-1.5 py-0.2 rounded-full border border-slate-700 font-mono">
|
||||
{group.users.length}
|
||||
</span>
|
||||
</div>
|
||||
{isCollapsed ? (
|
||||
<ChevronDown className="w-3.5 h-3.5 text-slate-400" />
|
||||
) : (
|
||||
<ChevronUp className="w-3.5 h-3.5 text-slate-400" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Users inside group */}
|
||||
{!isCollapsed && (
|
||||
<div className="grid grid-cols-1 gap-1 pl-1 pr-1 pt-0.5">
|
||||
{group.users.map(u => {
|
||||
const selected = isUserSelected(u.id);
|
||||
const displayName = (u.email || '').split('@')[0].replace(/[._-]/g, ' ');
|
||||
const formattedName = displayName
|
||||
.split(' ')
|
||||
.map(w => w.charAt(0).toUpperCase() + w.slice(1))
|
||||
.join(' ');
|
||||
|
||||
return (
|
||||
<div
|
||||
key={u.id}
|
||||
onClick={() => handleUserClick(u)}
|
||||
className={`flex items-center justify-between px-3 py-2 rounded-xl text-xs transition cursor-pointer border ${
|
||||
selected
|
||||
? 'bg-blue-600/20 border-blue-500/40 text-white'
|
||||
: 'bg-slate-900/40 hover:bg-slate-800/70 border-slate-800/40 text-slate-200'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2.5 min-w-0">
|
||||
<div
|
||||
className={`w-7 h-7 shrink-0 rounded-full flex items-center justify-center font-bold text-[10px] border ${
|
||||
selected
|
||||
? 'bg-blue-600 border-blue-400 text-white'
|
||||
: 'bg-slate-800 border-slate-700 text-slate-300'
|
||||
}`}
|
||||
>
|
||||
{formattedName.slice(0, 2).toUpperCase()}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="font-medium text-xs truncate text-white">{formattedName}</p>
|
||||
<p className="text-[10px] text-slate-400 font-mono truncate">{u.email}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
{u.role && (
|
||||
<span className="text-[10px] font-semibold text-blue-400 bg-blue-500/10 border border-blue-500/20 px-1.5 py-0.5 rounded truncate max-w-[120px]">
|
||||
{u.role}
|
||||
</span>
|
||||
)}
|
||||
<div
|
||||
className={`w-4 h-4 rounded-md border flex items-center justify-center ${
|
||||
selected
|
||||
? 'bg-blue-600 border-blue-500 text-white'
|
||||
: 'border-slate-700 bg-slate-950/60'
|
||||
}`}
|
||||
>
|
||||
{selected && <Check className="w-3 h-3 stroke-[3]" />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
|
||||
{/* Close button at bottom */}
|
||||
<div className="pt-2 border-t border-slate-800 flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsDropdownOpen(false)}
|
||||
className="text-[11px] text-slate-400 hover:text-white px-3 py-1 rounded-lg hover:bg-slate-800 transition cursor-pointer"
|
||||
>
|
||||
Done Selecting
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user