1010 lines
57 KiB
React
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;
|