Files
LynkedUpPro_CRM/src/components/owner/OrgTeamManagement.jsx
T
2026-05-13 18:55:09 +05:30

1010 lines
57 KiB
React

import React, { useState, useMemo, useCallback } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import {
Search, UserPlus, Filter, MoreVertical, Edit3, Trash2, Mail, Shield,
Eye, DollarSign, X, Check, ChevronDown, Crown, AlertTriangle,
Users, RefreshCw, Ban, UserCheck, Clock, Briefcase, ArrowUpDown,
} from 'lucide-react';
import { toast } from 'sonner';
const ROLE_OPTIONS = [
{ key: 'ADMIN', label: 'Admin / Manager' },
{ key: 'SALES_REP', label: 'Sales Rep' },
{ key: 'CANVASSER', label: 'Canvasser' },
{ key: 'SUBCONTRACTOR', label: 'Subcontractor' },
];
const STATUS_CONFIG = {
active: { label: 'Active', bg: 'bg-emerald-50 dark:bg-emerald-500/10', border: 'border-emerald-200 dark:border-emerald-500/20', text: 'text-emerald-600 dark:text-emerald-400' },
invited: { label: 'Invited', bg: 'bg-amber-50 dark:bg-amber-500/10', border: 'border-amber-200 dark:border-amber-500/20', text: 'text-amber-600 dark:text-amber-400' },
suspended: { label: 'Suspended', bg: 'bg-red-50 dark:bg-red-500/10', border: 'border-red-200 dark:border-red-500/20', text: 'text-red-500 dark:text-red-400' },
};
const inputClasses = "w-full bg-zinc-50 dark:bg-[#18181b] border border-zinc-200 dark:border-white/10 rounded-xl px-4 py-2.5 text-sm text-zinc-900 dark:text-white outline-none focus:border-blue-500/50 transition-colors";
// ────────────────────────────────────────────────────
// Invite Member Modal
// ────────────────────────────────────────────────────
const InviteMemberModal = ({ isOpen, onClose, onInvite, existingMembers, users }) => {
const [inviteMethod, setInviteMethod] = useState('email');
const [email, setEmail] = useState('');
const [name, setName] = useState('');
const [roleKey, setRoleKey] = useState('');
const [userSearch, setUserSearch] = useState('');
const [selectedUser, setSelectedUser] = useState(null);
const existingIds = new Set(existingMembers.map(m => m.userId));
const existingEmails = new Set(existingMembers.map(m => m.email.toLowerCase()));
const searchResults = useMemo(() => {
if (!userSearch.trim() || inviteMethod !== 'username') return [];
const term = userSearch.toLowerCase();
return (users || [])
.filter(u =>
(u.type === 'employee' || u.type === 'owner' || u.type === 'contractor' || u.type === 'subcontractor') &&
!existingIds.has(u.id) &&
(u.name?.toLowerCase().includes(term) || u.email?.toLowerCase().includes(term))
)
.slice(0, 5);
}, [userSearch, users, existingIds, inviteMethod]);
const handleSubmit = () => {
if (!roleKey) {
toast.error('Please select a role');
return;
}
if (inviteMethod === 'email') {
if (!email.trim() || !email.includes('@')) {
toast.error('Please enter a valid email address');
return;
}
if (existingEmails.has(email.toLowerCase())) {
toast.error('This email is already a member of the organization');
return;
}
onInvite({
userId: `usr_${Date.now()}`,
name: name.trim() || email.split('@')[0],
email: email.trim().toLowerCase(),
roleKey,
status: 'invited',
});
} else {
if (!selectedUser) {
toast.error('Please select a user');
return;
}
onInvite({
userId: selectedUser.id,
name: selectedUser.name,
email: selectedUser.email,
roleKey,
status: 'invited',
});
}
setEmail('');
setName('');
setRoleKey('');
setUserSearch('');
setSelectedUser(null);
onClose();
toast.success('Invitation sent successfully');
};
if (!isOpen) return null;
return (
<AnimatePresence>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm"
onClick={onClose}
>
<motion.div
initial={{ scale: 0.95, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0.95, opacity: 0 }}
transition={{ duration: 0.2 }}
className="w-full max-w-lg bg-white dark:bg-zinc-900 rounded-2xl border border-zinc-200 dark:border-white/10 shadow-2xl overflow-hidden"
onClick={e => e.stopPropagation()}
>
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-zinc-100 dark:border-white/5">
<div className="flex items-center gap-3">
<div className="p-2 rounded-xl bg-blue-50 dark:bg-blue-500/10 border border-blue-200 dark:border-blue-500/20">
<UserPlus size={18} className="text-blue-500" />
</div>
<div>
<h3 className="text-base font-bold text-zinc-900 dark:text-white">Invite Team Member</h3>
<p className="text-[11px] text-zinc-500">Add a new member to your organization</p>
</div>
</div>
<button onClick={onClose} className="p-1.5 rounded-lg hover:bg-zinc-100 dark:hover:bg-white/5 text-zinc-400 transition-colors">
<X size={16} />
</button>
</div>
{/* Body */}
<div className="p-6 space-y-5">
{/* Invite Method Tabs */}
<div className="flex rounded-xl border border-zinc-200 dark:border-white/10 overflow-hidden">
{[
{ key: 'email', label: 'Email Invite', icon: Mail },
{ key: 'username', label: 'Search Users', icon: Search },
].map(tab => (
<button
key={tab.key}
type="button"
onClick={() => { setInviteMethod(tab.key); setSelectedUser(null); }}
className={`flex-1 flex items-center justify-center gap-2 px-4 py-2.5 text-xs font-bold transition-colors ${
inviteMethod === tab.key
? 'bg-blue-50 dark:bg-blue-500/10 text-blue-600 dark:text-blue-400'
: 'bg-zinc-50 dark:bg-white/[0.02] text-zinc-500 hover:text-zinc-700 dark:hover:text-zinc-300'
}`}
>
<tab.icon size={13} />
{tab.label}
</button>
))}
</div>
{inviteMethod === 'email' ? (
<div className="space-y-4">
<div>
<label className="text-[10px] font-bold uppercase tracking-wider text-zinc-500 block mb-1.5">Email Address *</label>
<input
type="email"
value={email}
onChange={e => setEmail(e.target.value)}
placeholder="colleague@company.com"
className={inputClasses}
/>
</div>
<div>
<label className="text-[10px] font-bold uppercase tracking-wider text-zinc-500 block mb-1.5">Full Name</label>
<input
type="text"
value={name}
onChange={e => setName(e.target.value)}
placeholder="John Doe (optional)"
className={inputClasses}
/>
</div>
</div>
) : (
<div className="space-y-3">
<div>
<label className="text-[10px] font-bold uppercase tracking-wider text-zinc-500 block mb-1.5">Search Platform Users</label>
<div className="relative">
<Search size={14} className="absolute left-3 top-1/2 -translate-y-1/2 text-zinc-400" />
<input
type="text"
value={userSearch}
onChange={e => { setUserSearch(e.target.value); setSelectedUser(null); }}
placeholder="Search by name or email..."
className={`${inputClasses} pl-9`}
/>
</div>
</div>
{searchResults.length > 0 && !selectedUser && (
<div className="space-y-1 max-h-40 overflow-y-auto rounded-xl border border-zinc-200 dark:border-white/10 p-1">
{searchResults.map(u => (
<button
key={u.id}
type="button"
onClick={() => { setSelectedUser(u); setUserSearch(u.name); }}
className="w-full flex items-center gap-3 px-3 py-2 rounded-lg hover:bg-zinc-50 dark:hover:bg-white/[0.03] transition-colors text-left"
>
<div className="w-7 h-7 rounded-full bg-blue-100 dark:bg-blue-500/10 flex items-center justify-center text-[10px] font-bold text-blue-600 dark:text-blue-400">
{u.name?.charAt(0) || '?'}
</div>
<div>
<div className="text-xs font-semibold text-zinc-900 dark:text-white">{u.name}</div>
<div className="text-[10px] text-zinc-500">{u.email}</div>
</div>
</button>
))}
</div>
)}
{selectedUser && (
<div className="flex items-center gap-3 px-4 py-3 rounded-xl bg-blue-50 dark:bg-blue-500/[0.05] border border-blue-200 dark:border-blue-500/20">
<div className="w-8 h-8 rounded-full bg-blue-100 dark:bg-blue-500/10 flex items-center justify-center text-xs font-bold text-blue-600 dark:text-blue-400">
{selectedUser.name?.charAt(0)}
</div>
<div className="flex-1">
<div className="text-sm font-semibold text-zinc-900 dark:text-white">{selectedUser.name}</div>
<div className="text-[11px] text-zinc-500">{selectedUser.email}</div>
</div>
<button onClick={() => { setSelectedUser(null); setUserSearch(''); }} className="p-1 rounded-lg hover:bg-blue-100 dark:hover:bg-blue-500/20 text-blue-400">
<X size={14} />
</button>
</div>
)}
{userSearch.trim() && searchResults.length === 0 && !selectedUser && (
<p className="text-xs text-zinc-400 dark:text-zinc-600 italic px-2">No matching users found</p>
)}
</div>
)}
{/* Role Selection */}
<div>
<label className="text-[10px] font-bold uppercase tracking-wider text-zinc-500 block mb-1.5">Organization Role *</label>
<select value={roleKey} onChange={e => setRoleKey(e.target.value)} className={inputClasses}>
<option value=""> Select Role </option>
{ROLE_OPTIONS.map(r => <option key={r.key} value={r.key}>{r.label}</option>)}
</select>
</div>
</div>
{/* Footer */}
<div className="flex gap-3 px-6 py-4 border-t border-zinc-100 dark:border-white/5 bg-zinc-50/50 dark:bg-white/[0.02]">
<button onClick={onClose} className="flex-1 px-4 py-2.5 rounded-xl border border-zinc-200 dark:border-white/10 text-zinc-500 text-xs font-semibold hover:bg-zinc-100 dark:hover:bg-white/5 transition-colors">
Cancel
</button>
<button
onClick={handleSubmit}
disabled={!roleKey || (inviteMethod === 'email' ? !email.trim() : !selectedUser)}
className="flex-1 flex items-center justify-center gap-2 px-4 py-2.5 rounded-xl bg-blue-500 text-white text-xs font-bold hover:bg-blue-600 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
>
<UserPlus size={14} />
Send Invite
</button>
</div>
</motion.div>
</motion.div>
</AnimatePresence>
);
};
// ────────────────────────────────────────────────────
// Confirmation Dialog
// ────────────────────────────────────────────────────
const ConfirmDialog = ({ isOpen, onClose, onConfirm, title, message, confirmLabel, confirmColor }) => {
if (!isOpen) return null;
const colorMap = {
red: 'bg-red-500 hover:bg-red-600',
blue: 'bg-blue-500 hover:bg-blue-600',
emerald: 'bg-emerald-500 hover:bg-emerald-600',
amber: 'bg-amber-500 hover:bg-amber-600',
};
return (
<AnimatePresence>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm"
onClick={onClose}
>
<motion.div
initial={{ scale: 0.95, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0.95, opacity: 0 }}
className="w-full max-w-sm bg-white dark:bg-zinc-900 rounded-2xl border border-zinc-200 dark:border-white/10 shadow-2xl p-6 space-y-4"
onClick={e => e.stopPropagation()}
>
<div className="flex items-center gap-3">
<div className="p-2 rounded-xl bg-amber-50 dark:bg-amber-500/10 border border-amber-200 dark:border-amber-500/20">
<AlertTriangle size={18} className="text-amber-500" />
</div>
<h3 className="text-base font-bold text-zinc-900 dark:text-white">{title}</h3>
</div>
<p className="text-sm text-zinc-600 dark:text-zinc-400">{message}</p>
<div className="flex gap-3 pt-2">
<button onClick={onClose} className="flex-1 px-4 py-2.5 rounded-xl border border-zinc-200 dark:border-white/10 text-zinc-500 text-xs font-semibold hover:bg-zinc-100 dark:hover:bg-white/5 transition-colors">
Cancel
</button>
<button
onClick={() => { onConfirm(); onClose(); }}
className={`flex-1 px-4 py-2.5 rounded-xl text-white text-xs font-bold transition-colors ${colorMap[confirmColor] || colorMap.red}`}
>
{confirmLabel}
</button>
</div>
</motion.div>
</motion.div>
</AnimatePresence>
);
};
// ────────────────────────────────────────────────────
// View Permissions Modal
// ────────────────────────────────────────────────────
const ViewPermissionsModal = ({ isOpen, onClose, member, permissions, permissionModules, roles }) => {
if (!isOpen || !member) return null;
const role = roles.find(r => r.key === member.roleKey);
const isOwner = member.roleKey === 'OWNER';
return (
<AnimatePresence>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm"
onClick={onClose}
>
<motion.div
initial={{ scale: 0.95, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0.95, opacity: 0 }}
className="w-full max-w-md bg-white dark:bg-zinc-900 rounded-2xl border border-zinc-200 dark:border-white/10 shadow-2xl overflow-hidden"
onClick={e => e.stopPropagation()}
>
<div className="flex items-center justify-between px-6 py-4 border-b border-zinc-100 dark:border-white/5">
<div className="flex items-center gap-3">
<div className="w-9 h-9 rounded-full flex items-center justify-center text-sm font-bold" style={{ backgroundColor: `${role?.color || '#3b82f6'}20`, color: role?.color || '#3b82f6' }}>
{member.name.charAt(0)}
</div>
<div>
<h3 className="text-base font-bold text-zinc-900 dark:text-white">{member.name}</h3>
<p className="text-[11px] text-zinc-500">{role?.name || member.roleKey} Permissions</p>
</div>
</div>
<button onClick={onClose} className="p-1.5 rounded-lg hover:bg-zinc-100 dark:hover:bg-white/5 text-zinc-400 transition-colors">
<X size={16} />
</button>
</div>
<div className="p-6 space-y-3 max-h-[400px] overflow-y-auto">
{isOwner && (
<div className="px-4 py-3 rounded-xl bg-amber-50 dark:bg-amber-500/[0.05] border border-amber-200 dark:border-amber-500/20 text-xs text-amber-700 dark:text-amber-400 font-semibold flex items-center gap-2">
<Crown size={14} /> Org Owner has full access to all modules
</div>
)}
{Object.entries(permissionModules).map(([moduleKey, mod]) => {
const memberPerms = isOwner ? mod.actions : (permissions[moduleKey]?.[member.roleKey] || []);
return (
<div key={moduleKey} className="px-4 py-3 rounded-xl bg-zinc-50 dark:bg-white/[0.02] border border-zinc-200 dark:border-white/5">
<div className="flex items-center gap-2 mb-2">
<Shield size={12} className="text-zinc-400" />
<span className="text-xs font-bold text-zinc-900 dark:text-white">{mod.label}</span>
</div>
<div className="flex flex-wrap gap-1.5">
{mod.actions.map(action => {
const has = memberPerms.includes(action);
return (
<span
key={action}
className={`px-2 py-0.5 rounded-md text-[10px] font-semibold capitalize border ${
has
? 'bg-emerald-50 dark:bg-emerald-500/10 border-emerald-200 dark:border-emerald-500/20 text-emerald-600 dark:text-emerald-400'
: 'bg-zinc-100 dark:bg-white/[0.03] border-zinc-200 dark:border-white/10 text-zinc-400 dark:text-zinc-600'
}`}
>
{has && <Check size={8} className="inline mr-0.5 -mt-0.5" />}
{action}
</span>
);
})}
</div>
</div>
);
})}
</div>
<div className="px-6 py-4 border-t border-zinc-100 dark:border-white/5 bg-zinc-50/50 dark:bg-white/[0.02]">
<button onClick={onClose} className="w-full px-4 py-2.5 rounded-xl border border-zinc-200 dark:border-white/10 text-zinc-500 text-xs font-semibold hover:bg-zinc-100 dark:hover:bg-white/5 transition-colors">
Close
</button>
</div>
</motion.div>
</motion.div>
</AnimatePresence>
);
};
// ────────────────────────────────────────────────────
// Member Action Menu
// ────────────────────────────────────────────────────
const MemberActionMenu = ({ member, onAction, isOpen, onToggle, currentUserRoleKey }) => {
const isOwnerMember = member.roleKey === 'OWNER';
const canManage = currentUserRoleKey === 'OWNER' || (currentUserRoleKey === 'ADMIN' && !isOwnerMember && member.roleKey !== 'ADMIN');
const canManageAdmin = currentUserRoleKey === 'OWNER';
const isManageable = isOwnerMember
? false
: member.roleKey === 'ADMIN'
? canManageAdmin
: canManage;
const actions = [];
if (isManageable) {
actions.push({ key: 'editRole', label: 'Edit Role', icon: Edit3 });
}
if (member.status === 'invited' && isManageable) {
actions.push({ key: 'resendInvite', label: 'Resend Invite', icon: Mail });
}
if (member.status === 'active' && isManageable) {
actions.push({ key: 'suspend', label: 'Disable Account', icon: Ban });
}
if (member.status === 'suspended' && isManageable) {
actions.push({ key: 'reactivate', label: 'Reactivate', icon: UserCheck });
}
actions.push({ key: 'viewPermissions', label: 'View Permissions', icon: Eye });
actions.push({ key: 'viewCommission', label: 'Commission Settings', icon: DollarSign });
if (isManageable) {
actions.push({ key: 'remove', label: 'Remove Member', icon: Trash2, danger: true });
}
return (
<div className="relative">
<button
type="button"
onClick={onToggle}
className="p-1.5 rounded-lg hover:bg-zinc-100 dark:hover:bg-white/10 text-zinc-400 dark:text-zinc-500 transition-colors"
>
<MoreVertical size={14} />
</button>
<AnimatePresence>
{isOpen && (
<motion.div
initial={{ opacity: 0, scale: 0.95, y: -4 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: -4 }}
transition={{ duration: 0.15 }}
className="absolute right-0 top-full mt-1 w-52 bg-white dark:bg-zinc-900 rounded-xl border border-zinc-200 dark:border-white/10 shadow-xl z-20 overflow-hidden"
>
{actions.map(action => (
<button
key={action.key}
type="button"
onClick={() => { onAction(action.key, member); onToggle(); }}
className={`w-full flex items-center gap-2.5 px-4 py-2.5 text-xs font-semibold transition-colors text-left ${
action.danger
? 'text-red-500 hover:bg-red-50 dark:hover:bg-red-500/10'
: 'text-zinc-700 dark:text-zinc-300 hover:bg-zinc-50 dark:hover:bg-white/[0.03]'
}`}
>
<action.icon size={13} />
{action.label}
</button>
))}
</motion.div>
)}
</AnimatePresence>
</div>
);
};
// ────────────────────────────────────────────────────
// Edit Role Inline
// ────────────────────────────────────────────────────
const EditRolePanel = ({ member, roles, onSave, onCancel }) => {
const [newRole, setNewRole] = useState(member.roleKey);
const currentRole = roles.find(r => r.key === member.roleKey);
const targetRole = roles.find(r => r.key === newRole);
const isPromotion = () => {
const order = ['SUBCONTRACTOR', 'CANVASSER', 'SALES_REP', 'ADMIN'];
return order.indexOf(newRole) > order.indexOf(member.roleKey);
};
return (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.2 }}
className="overflow-hidden"
>
<div className="px-4 pb-4 pt-3 border-t border-zinc-100 dark:border-white/5 space-y-3">
<div className="flex items-center gap-2 text-[11px] text-zinc-500">
<span>Current:</span>
<span className="px-2 py-0.5 rounded-full text-[10px] font-bold border" style={{ backgroundColor: `${currentRole?.color}15`, borderColor: `${currentRole?.color}30`, color: currentRole?.color }}>
{currentRole?.name}
</span>
<ArrowUpDown size={10} />
<span>New:</span>
{newRole !== member.roleKey && (
<span className="px-2 py-0.5 rounded-full text-[10px] font-bold border" style={{ backgroundColor: `${targetRole?.color}15`, borderColor: `${targetRole?.color}30`, color: targetRole?.color }}>
{targetRole?.name}
</span>
)}
</div>
<select value={newRole} onChange={e => setNewRole(e.target.value)} className={inputClasses}>
{ROLE_OPTIONS.map(r => <option key={r.key} value={r.key}>{r.label}</option>)}
</select>
{newRole !== member.roleKey && (
<div className={`px-3 py-2 rounded-lg text-[11px] font-semibold ${
isPromotion()
? 'bg-emerald-50 dark:bg-emerald-500/[0.05] text-emerald-600 dark:text-emerald-400 border border-emerald-200 dark:border-emerald-500/20'
: 'bg-amber-50 dark:bg-amber-500/[0.05] text-amber-600 dark:text-amber-400 border border-amber-200 dark:border-amber-500/20'
}`}>
{isPromotion() ? '↑ Promotion' : '↓ Demotion'}: {currentRole?.name} {targetRole?.name}
</div>
)}
<div className="flex gap-2">
<button onClick={onCancel} className="flex-1 px-4 py-2 rounded-xl border border-zinc-200 dark:border-white/10 text-zinc-500 text-xs font-semibold hover:bg-zinc-100 dark:hover:bg-white/5 transition-colors">
Cancel
</button>
<button
onClick={() => onSave(member.id, newRole)}
disabled={newRole === member.roleKey}
className="flex-1 px-4 py-2 rounded-xl bg-blue-500 text-white text-xs font-bold hover:bg-blue-600 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
>
Update Role
</button>
</div>
</div>
</motion.div>
);
};
// ────────────────────────────────────────────────────
// Main Component
// ────────────────────────────────────────────────────
const OrgTeamManagement = ({
members,
roles,
users,
permissions,
permissionModules,
currentUserRoleKey,
onAddMember,
onUpdateMember,
onRemoveMember,
onSuspendMember,
onReactivateMember,
onResendInvite,
}) => {
const [searchTerm, setSearchTerm] = useState('');
const [filterRole, setFilterRole] = useState('all');
const [filterStatus, setFilterStatus] = useState('all');
const [sortField, setSortField] = useState('name');
const [sortDir, setSortDir] = useState('asc');
const [showInviteModal, setShowInviteModal] = useState(false);
const [openMenuId, setOpenMenuId] = useState(null);
const [editingRoleId, setEditingRoleId] = useState(null);
const [confirmDialog, setConfirmDialog] = useState(null);
const [viewPermsMember, setViewPermsMember] = useState(null);
const canInvite = currentUserRoleKey === 'OWNER' || currentUserRoleKey === 'ADMIN';
const filteredMembers = useMemo(() => {
let result = [...members];
if (searchTerm.trim()) {
const term = searchTerm.toLowerCase();
result = result.filter(m =>
m.name.toLowerCase().includes(term) ||
m.email.toLowerCase().includes(term) ||
m.roleKey.toLowerCase().includes(term)
);
}
if (filterRole !== 'all') {
result = result.filter(m => m.roleKey === filterRole);
}
if (filterStatus !== 'all') {
result = result.filter(m => m.status === filterStatus);
}
result.sort((a, b) => {
let valA, valB;
switch (sortField) {
case 'name': valA = a.name.toLowerCase(); valB = b.name.toLowerCase(); break;
case 'role': valA = a.roleKey; valB = b.roleKey; break;
case 'status': valA = a.status; valB = b.status; break;
case 'joinedAt': valA = a.joinedAt; valB = b.joinedAt; break;
case 'lastActivity': valA = a.lastActivity || ''; valB = b.lastActivity || ''; break;
case 'jobs': valA = a.assignedJobsCount || 0; valB = b.assignedJobsCount || 0; break;
default: valA = a.name; valB = b.name;
}
if (valA < valB) return sortDir === 'asc' ? -1 : 1;
if (valA > valB) return sortDir === 'asc' ? 1 : -1;
return 0;
});
return result;
}, [members, searchTerm, filterRole, filterStatus, sortField, sortDir]);
const toggleSort = useCallback((field) => {
if (sortField === field) {
setSortDir(prev => prev === 'asc' ? 'desc' : 'asc');
} else {
setSortField(field);
setSortDir('asc');
}
}, [sortField]);
const handleAction = useCallback((actionKey, member) => {
switch (actionKey) {
case 'editRole':
setEditingRoleId(member.id);
break;
case 'resendInvite':
onResendInvite(member.id);
break;
case 'suspend':
setConfirmDialog({
title: 'Disable Account',
message: `Are you sure you want to suspend ${member.name}? They will lose access to the organization until reactivated.`,
confirmLabel: 'Suspend',
confirmColor: 'red',
onConfirm: () => {
onSuspendMember(member.id);
toast.success(`${member.name} has been suspended`);
},
});
break;
case 'reactivate':
onReactivateMember(member.id);
toast.success(`${member.name} has been reactivated`);
break;
case 'viewPermissions':
setViewPermsMember(member);
break;
case 'viewCommission':
toast.info(`Commission settings for ${member.name} — check the User Overrides section`);
break;
case 'remove':
setConfirmDialog({
title: 'Remove Member',
message: `Are you sure you want to remove ${member.name} from the organization? This action cannot be undone.`,
confirmLabel: 'Remove',
confirmColor: 'red',
onConfirm: () => {
onRemoveMember(member.id);
toast.success(`${member.name} has been removed from the organization`);
},
});
break;
default:
break;
}
}, [onResendInvite, onSuspendMember, onReactivateMember, onRemoveMember]);
const handleRoleSave = useCallback((memberId, newRoleKey) => {
const member = members.find(m => m.id === memberId);
const newRole = roles.find(r => r.key === newRoleKey);
onUpdateMember(memberId, { roleKey: newRoleKey });
setEditingRoleId(null);
toast.success(`${member?.name}'s role updated to ${newRole?.name}`);
}, [members, roles, onUpdateMember]);
const roleCounts = useMemo(() => {
const counts = {};
roles.forEach(r => { counts[r.key] = members.filter(m => m.roleKey === r.key).length; });
return counts;
}, [members, roles]);
const statusCounts = useMemo(() => ({
active: members.filter(m => m.status === 'active').length,
invited: members.filter(m => m.status === 'invited').length,
suspended: members.filter(m => m.status === 'suspended').length,
}), [members]);
const SortHeader = ({ field, children, className = '' }) => (
<button
type="button"
onClick={() => toggleSort(field)}
className={`flex items-center gap-1 text-[10px] font-bold uppercase tracking-wider text-zinc-500 hover:text-zinc-700 dark:hover:text-zinc-300 transition-colors ${className}`}
>
{children}
{sortField === field && (
<ChevronDown size={10} className={`transition-transform ${sortDir === 'desc' ? 'rotate-180' : ''}`} />
)}
</button>
);
return (
<div className="space-y-5">
{/* Quick Stats */}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2">
<div className="px-3 py-2.5 rounded-xl bg-zinc-50 dark:bg-white/[0.03] border border-zinc-200 dark:border-white/5">
<div className="text-lg font-bold font-mono text-blue-500">{members.length}</div>
<div className="text-[10px] font-bold uppercase tracking-wider text-zinc-500">Total Members</div>
</div>
<div className="px-3 py-2.5 rounded-xl bg-zinc-50 dark:bg-white/[0.03] border border-zinc-200 dark:border-white/5">
<div className="text-lg font-bold font-mono text-emerald-500">{statusCounts.active}</div>
<div className="text-[10px] font-bold uppercase tracking-wider text-zinc-500">Active</div>
</div>
<div className="px-3 py-2.5 rounded-xl bg-zinc-50 dark:bg-white/[0.03] border border-zinc-200 dark:border-white/5">
<div className="text-lg font-bold font-mono text-amber-500">{statusCounts.invited}</div>
<div className="text-[10px] font-bold uppercase tracking-wider text-zinc-500">Invited</div>
</div>
<div className="px-3 py-2.5 rounded-xl bg-zinc-50 dark:bg-white/[0.03] border border-zinc-200 dark:border-white/5">
<div className="text-lg font-bold font-mono text-red-500">{statusCounts.suspended}</div>
<div className="text-[10px] font-bold uppercase tracking-wider text-zinc-500">Suspended</div>
</div>
</div>
{/* Toolbar */}
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3">
<div className="flex items-center gap-2 flex-wrap flex-1 w-full sm:w-auto">
{/* Search */}
<div className="relative flex-1 min-w-[200px]">
<Search size={14} className="absolute left-3 top-1/2 -translate-y-1/2 text-zinc-400" />
<input
type="text"
placeholder="Search members..."
value={searchTerm}
onChange={e => setSearchTerm(e.target.value)}
className="w-full bg-zinc-50 dark:bg-[#18181b] border border-zinc-200 dark:border-white/10 rounded-xl pl-9 pr-4 py-2 text-sm text-zinc-900 dark:text-white placeholder-zinc-400 dark:placeholder-zinc-600 outline-none focus:border-blue-500/50 transition-colors"
/>
</div>
{/* Role Filter */}
<div className="flex items-center gap-1.5">
<Filter size={12} className="text-zinc-400" />
<select
value={filterRole}
onChange={e => setFilterRole(e.target.value)}
className="bg-zinc-50 dark:bg-[#18181b] border border-zinc-200 dark:border-white/10 rounded-lg px-3 py-1.5 text-xs text-zinc-700 dark:text-zinc-300 outline-none"
>
<option value="all">All Roles</option>
{roles.map(r => (
<option key={r.key} value={r.key}>{r.name} ({roleCounts[r.key] || 0})</option>
))}
</select>
</div>
{/* Status Filter */}
<select
value={filterStatus}
onChange={e => setFilterStatus(e.target.value)}
className="bg-zinc-50 dark:bg-[#18181b] border border-zinc-200 dark:border-white/10 rounded-lg px-3 py-1.5 text-xs text-zinc-700 dark:text-zinc-300 outline-none"
>
<option value="all">All Status</option>
<option value="active">Active ({statusCounts.active})</option>
<option value="invited">Invited ({statusCounts.invited})</option>
<option value="suspended">Suspended ({statusCounts.suspended})</option>
</select>
</div>
{canInvite && (
<button
type="button"
onClick={() => setShowInviteModal(true)}
className="flex items-center gap-2 px-4 py-2 rounded-xl bg-blue-500 text-white text-xs font-bold hover:bg-blue-600 transition-colors shrink-0"
>
<UserPlus size={14} />
Invite Member
</button>
)}
</div>
{/* Desktop Table */}
<div className="hidden lg:block overflow-x-auto rounded-xl border border-zinc-200 dark:border-white/[0.06]">
<table className="w-full border-collapse">
<thead>
<tr className="bg-zinc-50/50 dark:bg-white/[0.02]">
<th className="text-left px-4 py-3 border-b border-zinc-200 dark:border-white/5 w-64">
<SortHeader field="name">Member</SortHeader>
</th>
<th className="text-left px-4 py-3 border-b border-zinc-200 dark:border-white/5 w-36">
<SortHeader field="role">Org Role</SortHeader>
</th>
<th className="text-center px-4 py-3 border-b border-zinc-200 dark:border-white/5 w-28">
<SortHeader field="status" className="justify-center">Status</SortHeader>
</th>
<th className="text-left px-4 py-3 border-b border-zinc-200 dark:border-white/5 w-28">
<SortHeader field="joinedAt">Joined</SortHeader>
</th>
<th className="text-left px-4 py-3 border-b border-zinc-200 dark:border-white/5 w-28">
<SortHeader field="lastActivity">Last Active</SortHeader>
</th>
<th className="text-center px-4 py-3 border-b border-zinc-200 dark:border-white/5 w-20">
<SortHeader field="jobs" className="justify-center">Jobs</SortHeader>
</th>
<th className="text-center px-4 py-3 border-b border-zinc-200 dark:border-white/5 w-16">
<span className="text-[10px] font-bold uppercase tracking-wider text-zinc-500">Actions</span>
</th>
</tr>
</thead>
<tbody>
{filteredMembers.map(member => {
const role = roles.find(r => r.key === member.roleKey);
const sc = STATUS_CONFIG[member.status] || STATUS_CONFIG.active;
const isEditing = editingRoleId === member.id;
return (
<React.Fragment key={member.id}>
<tr className="hover:bg-zinc-50 dark:hover:bg-white/[0.02] transition-colors">
{/* Member */}
<td className="px-4 py-3 border-b border-zinc-100 dark:border-white/5">
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-full flex items-center justify-center text-xs font-bold shrink-0" style={{ backgroundColor: `${role?.color || '#3b82f6'}20`, color: role?.color || '#3b82f6' }}>
{member.name.charAt(0)}
</div>
<div className="min-w-0">
<div className="flex items-center gap-1.5">
<span className="text-sm font-semibold text-zinc-900 dark:text-white truncate">{member.name}</span>
{member.roleKey === 'OWNER' && <Crown size={11} className="text-amber-400 shrink-0" />}
</div>
<span className="text-[10px] text-zinc-500 truncate block">{member.email}</span>
</div>
</div>
</td>
{/* Role */}
<td className="px-4 py-3 border-b border-zinc-100 dark:border-white/5">
<span className="px-2.5 py-1 rounded-full text-[10px] font-bold border" style={{ backgroundColor: `${role?.color}10`, borderColor: `${role?.color}25`, color: role?.color }}>
{role?.name || member.roleKey}
</span>
</td>
{/* Status */}
<td className="px-4 py-3 border-b border-zinc-100 dark:border-white/5 text-center">
<span className={`inline-flex px-2.5 py-1 rounded-full text-[10px] font-bold border ${sc.bg} ${sc.border} ${sc.text}`}>
{sc.label}
</span>
</td>
{/* Joined */}
<td className="px-4 py-3 border-b border-zinc-100 dark:border-white/5">
<div className="flex items-center gap-1.5 text-[11px] text-zinc-500">
<Clock size={10} className="text-zinc-400 shrink-0" />
{member.joinedAt}
</div>
</td>
{/* Last Active */}
<td className="px-4 py-3 border-b border-zinc-100 dark:border-white/5">
<span className="text-[11px] text-zinc-500">
{member.lastActivity || '—'}
</span>
</td>
{/* Jobs */}
<td className="px-4 py-3 border-b border-zinc-100 dark:border-white/5 text-center">
<div className="flex items-center justify-center gap-1 text-xs font-mono text-zinc-600 dark:text-zinc-400">
<Briefcase size={10} className="text-zinc-400" />
{member.assignedJobsCount || 0}
</div>
</td>
{/* Actions */}
<td className="px-4 py-3 border-b border-zinc-100 dark:border-white/5 text-center">
<MemberActionMenu
member={member}
currentUserRoleKey={currentUserRoleKey}
onAction={handleAction}
isOpen={openMenuId === member.id}
onToggle={() => setOpenMenuId(openMenuId === member.id ? null : member.id)}
/>
</td>
</tr>
{/* Edit Role Inline */}
<AnimatePresence>
{isEditing && (
<tr>
<td colSpan={7} className="p-0">
<EditRolePanel
member={member}
roles={roles}
onSave={handleRoleSave}
onCancel={() => setEditingRoleId(null)}
/>
</td>
</tr>
)}
</AnimatePresence>
</React.Fragment>
);
})}
</tbody>
</table>
{filteredMembers.length === 0 && (
<div className="text-center py-12 text-zinc-400 dark:text-zinc-600">
<Users size={32} className="mx-auto mb-3 opacity-50" />
<p className="text-sm font-semibold">No members found</p>
<p className="text-xs mt-1">Try adjusting your search or filters</p>
</div>
)}
</div>
{/* Mobile Cards */}
<div className="lg:hidden space-y-2">
{filteredMembers.map(member => {
const role = roles.find(r => r.key === member.roleKey);
const sc = STATUS_CONFIG[member.status] || STATUS_CONFIG.active;
const isEditing = editingRoleId === member.id;
return (
<div key={member.id} className="rounded-xl border border-zinc-200 dark:border-white/[0.06] bg-white dark:bg-white/[0.02] overflow-hidden">
<div className="flex items-center justify-between px-4 py-3">
<div className="flex items-center gap-3 min-w-0 flex-1">
<div className="w-9 h-9 rounded-full flex items-center justify-center text-sm font-bold shrink-0" style={{ backgroundColor: `${role?.color || '#3b82f6'}20`, color: role?.color || '#3b82f6' }}>
{member.name.charAt(0)}
</div>
<div className="min-w-0">
<div className="flex items-center gap-1.5">
<span className="text-sm font-semibold text-zinc-900 dark:text-white truncate">{member.name}</span>
{member.roleKey === 'OWNER' && <Crown size={11} className="text-amber-400 shrink-0" />}
</div>
<span className="text-[10px] text-zinc-500 block truncate">{member.email}</span>
</div>
</div>
<MemberActionMenu
member={member}
currentUserRoleKey={currentUserRoleKey}
onAction={handleAction}
isOpen={openMenuId === member.id}
onToggle={() => setOpenMenuId(openMenuId === member.id ? null : member.id)}
/>
</div>
<div className="flex items-center gap-2 px-4 pb-3 flex-wrap">
<span className="px-2 py-0.5 rounded-full text-[10px] font-bold border" style={{ backgroundColor: `${role?.color}10`, borderColor: `${role?.color}25`, color: role?.color }}>
{role?.name}
</span>
<span className={`px-2 py-0.5 rounded-full text-[10px] font-bold border ${sc.bg} ${sc.border} ${sc.text}`}>
{sc.label}
</span>
<span className="text-[10px] text-zinc-400 flex items-center gap-1">
<Briefcase size={9} /> {member.assignedJobsCount || 0} jobs
</span>
<span className="text-[10px] text-zinc-400 flex items-center gap-1">
<Clock size={9} /> {member.joinedAt}
</span>
</div>
<AnimatePresence>
{isEditing && (
<EditRolePanel
member={member}
roles={roles}
onSave={handleRoleSave}
onCancel={() => setEditingRoleId(null)}
/>
)}
</AnimatePresence>
</div>
);
})}
{filteredMembers.length === 0 && (
<div className="text-center py-12 text-zinc-400 dark:text-zinc-600">
<Users size={32} className="mx-auto mb-3 opacity-50" />
<p className="text-sm font-semibold">No members found</p>
<p className="text-xs mt-1">Try adjusting your search or filters</p>
</div>
)}
</div>
{/* Result Count */}
<div className="flex items-center justify-between text-[11px] text-zinc-400">
<span>Showing {filteredMembers.length} of {members.length} members</span>
{(filterRole !== 'all' || filterStatus !== 'all' || searchTerm.trim()) && (
<button
type="button"
onClick={() => { setFilterRole('all'); setFilterStatus('all'); setSearchTerm(''); }}
className="flex items-center gap-1 text-blue-500 hover:text-blue-600 font-semibold transition-colors"
>
<RefreshCw size={10} /> Clear Filters
</button>
)}
</div>
{/* Modals */}
<InviteMemberModal
isOpen={showInviteModal}
onClose={() => setShowInviteModal(false)}
onInvite={onAddMember}
existingMembers={members}
users={users}
/>
<ConfirmDialog
isOpen={!!confirmDialog}
onClose={() => setConfirmDialog(null)}
onConfirm={confirmDialog?.onConfirm || (() => {})}
title={confirmDialog?.title || ''}
message={confirmDialog?.message || ''}
confirmLabel={confirmDialog?.confirmLabel || 'Confirm'}
confirmColor={confirmDialog?.confirmColor || 'red'}
/>
<ViewPermissionsModal
isOpen={!!viewPermsMember}
onClose={() => setViewPermsMember(null)}
member={viewPermsMember}
permissions={permissions}
permissionModules={permissionModules}
roles={roles}
/>
</div>
);
};
export default OrgTeamManagement;