380 lines
14 KiB
TypeScript
380 lines
14 KiB
TypeScript
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';
|
|
import { useAuth } from '../../auth/AuthProvider';
|
|
|
|
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,
|
|
placeholder = 'Search by name, email, or role...',
|
|
}) => {
|
|
const { user, claims } = useAuth();
|
|
const currentUserId = String(user?.id || claims?.sub || '').trim().toLowerCase();
|
|
const currentUserEmail = String(user?.email || claims?.email || '').trim().toLowerCase();
|
|
|
|
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),
|
|
});
|
|
|
|
// Filter active users and exclude the current logged-in user so they can never message themselves
|
|
const activeUsers = useMemo(() => {
|
|
return users.filter(u => {
|
|
if (u.status !== ActivityStatus.Active || !u.email) return false;
|
|
const uid = String(u.id || '').trim().toLowerCase();
|
|
const uemail = u.email.trim().toLowerCase();
|
|
if (currentUserId && uid === currentUserId) return false;
|
|
if (currentUserEmail && uemail === currentUserEmail) return false;
|
|
return true;
|
|
});
|
|
}, [users, currentUserId, currentUserEmail]);
|
|
|
|
// 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 = String(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 = String(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(' ');
|
|
|
|
// Strictly single-recipient selection
|
|
onSelectUser({
|
|
id: u.id,
|
|
email: u.email,
|
|
name: formattedName,
|
|
role: String(u.role ?? 'User'),
|
|
});
|
|
setSearchTerm('');
|
|
setIsDropdownOpen(false);
|
|
}
|
|
};
|
|
|
|
const singleSelectedUser = selectedUsers[0];
|
|
|
|
return (
|
|
<div className="relative w-full">
|
|
{/* Single Selected Recipient or Search Input Box */}
|
|
{singleSelectedUser ? (
|
|
<div className="flex items-center justify-between px-3 py-2 rounded-xl bg-blue-500/10 border border-blue-500/30 text-blue-100 text-xs shadow-xs">
|
|
<div className="flex items-center gap-2.5 min-w-0">
|
|
<div className="w-6 h-6 rounded-full bg-blue-600/40 text-[10px] font-bold flex items-center justify-center text-blue-200 shrink-0 border border-blue-400/30">
|
|
{(singleSelectedUser.name || singleSelectedUser.email).slice(0, 2).toUpperCase()}
|
|
</div>
|
|
<div className="flex items-center gap-2 min-w-0">
|
|
<span className="font-semibold text-white truncate">
|
|
{singleSelectedUser.name || singleSelectedUser.email}
|
|
</span>
|
|
<span className="text-[11px] text-slate-400 font-mono truncate">
|
|
<{singleSelectedUser.email}>
|
|
</span>
|
|
{singleSelectedUser.role && (
|
|
<span className="text-[10px] text-blue-300 bg-blue-900/40 px-1.5 py-0.5 rounded border border-blue-800/40 shrink-0">
|
|
{singleSelectedUser.role}
|
|
</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
onClick={() => onRemoveUser(singleSelectedUser.id)}
|
|
className="flex items-center gap-1 text-[11px] text-blue-300 hover:text-white hover:bg-blue-600/30 px-2 py-1 rounded-lg transition cursor-pointer shrink-0"
|
|
title="Remove recipient to select another user"
|
|
>
|
|
<X className="w-3.5 h-3.5" />
|
|
<span>Change</span>
|
|
</button>
|
|
</div>
|
|
) : (
|
|
<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 (only when no recipient is locked in) */}
|
|
{isDropdownOpen && !singleSelectedUser && (
|
|
<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>
|
|
);
|
|
};
|
|
|