Implemented mailbox in ConsoleUI

This commit is contained in:
2026-10-22 18:01:22 +02:00
parent 6ff016e7ec
commit cb89817d88
20 changed files with 3208 additions and 19 deletions
@@ -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>
);
};