team management feature in Owners Box

This commit is contained in:
Satyam-Rastogi
2026-05-12 19:29:52 +05:30
parent 91392bebcb
commit 17c3dc7307
3 changed files with 873 additions and 3 deletions
@@ -0,0 +1,730 @@
import React, { useState, useMemo, useEffect, useRef } from 'react';
import { createPortal } from 'react-dom';
import { motion, AnimatePresence } from 'framer-motion';
import {
UserPlus, UserMinus, Shield, ChevronDown, Search, Mail,
X as XIcon, Users, Briefcase, DollarSign, MoreVertical,
Check, Clock, Crown, ChevronUp
} from 'lucide-react';
const JOB_ROLES = [
'Project Manager',
'Sales Rep',
'Lead Installer',
'Estimator',
'Inspector',
'Canvasser',
'Subcontractor',
'Supplier',
];
const COMMISSION_TYPES = {
percent_gross: '% of Gross',
percent_net: '% of Net',
flat: 'Flat Amount',
custom: 'Custom / Manual',
};
const COMMISSION_SOURCE_CFG = {
org: { label: 'Org Default', color: 'text-cyan-400', bg: 'bg-cyan-500/10 border-cyan-500/30', dot: 'bg-cyan-400' },
user: { label: 'User', color: 'text-violet-400', bg: 'bg-violet-500/10 border-violet-500/30', dot: 'bg-violet-400' },
job: { label: 'Job', color: 'text-amber-400', bg: 'bg-amber-500/10 border-amber-500/30', dot: 'bg-amber-400' },
};
const ROLE_COLORS = {
'Project Manager': 'text-cyan-400 bg-cyan-500/10 border-cyan-500/20',
'Sales Rep': 'text-green-400 bg-green-500/10 border-green-500/20',
'Lead Installer': 'text-orange-400 bg-orange-500/10 border-orange-500/20',
'Estimator': 'text-blue-400 bg-blue-500/10 border-blue-500/20',
'Inspector': 'text-purple-400 bg-purple-500/10 border-purple-500/20',
'Canvasser': 'text-teal-400 bg-teal-500/10 border-teal-500/20',
'Subcontractor': 'text-rose-400 bg-rose-500/10 border-rose-500/20',
'Supplier': 'text-yellow-400 bg-yellow-500/10 border-yellow-500/20',
};
function resolveCommissionForMember(member, userCommissionOverrides, orgCommissionDefaults) {
if (member.jobCommission) {
return { type: member.jobCommission.type, rate: Number(member.jobCommission.rate) || 0, source: 'job' };
}
const userOvr = (userCommissionOverrides || []).find(o => o.enabled && o.userId === member.userId);
if (userOvr) {
return { type: userOvr.type, rate: Number(userOvr.rate) || 0, source: 'user' };
}
return { type: orgCommissionDefaults.type, rate: Number(orgCommissionDefaults.rate) || 0, source: 'org' };
}
function calcCommissionValue(comm, totalReceived, grossProfit) {
const r = Number(comm.rate) || 0;
switch (comm.type) {
case 'percent_gross': return totalReceived * (r / 100);
case 'percent_net': return grossProfit * (r / 100);
case 'flat': return r;
case 'custom': return r;
default: return 0;
}
}
const formatCurrency = (amt) =>
new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', maximumFractionDigits: 0 }).format(Number(amt) || 0);
const TeamManagementPanel = ({
project,
teamMembers = [],
allUsers = [],
personnel = [],
userRole,
orgCommissionDefaults,
userCommissionOverrides,
totalReceived = 0,
grossProfit = 0,
onAddMember,
onRemoveMember,
onUpdateMember,
}) => {
const [inviteOpen, setInviteOpen] = useState(false);
const [removeConfirm, setRemoveConfirm] = useState(null);
const [actionMenu, setActionMenu] = useState(null);
const [editingRole, setEditingRole] = useState(null);
const [commissionOpen, setCommissionOpen] = useState(false);
const [editingCommission, setEditingCommission] = useState(null);
const menuRef = useRef(null);
const canManageTeam = ['OWNER', 'ADMIN'].includes(userRole);
const canManageCommissions = ['OWNER', 'ADMIN'].includes(userRole);
const canViewCommissions = ['OWNER', 'ADMIN', 'CONTRACTOR'].includes(userRole);
const isSubcontractor = userRole === 'SUBCONTRACTOR';
useEffect(() => {
if (!actionMenu) return;
const handleClick = (e) => {
if (menuRef.current && !menuRef.current.contains(e.target)) setActionMenu(null);
};
document.addEventListener('mousedown', handleClick);
return () => document.removeEventListener('mousedown', handleClick);
}, [actionMenu]);
const visibleMembers = useMemo(() => {
if (isSubcontractor) {
const currentUserId = allUsers?.find(u => u.role === 'SUBCONTRACTOR')?.id;
return teamMembers.filter(m => m.userId === currentUserId);
}
return teamMembers;
}, [teamMembers, isSubcontractor, allUsers]);
const memberCommissions = useMemo(() => {
return visibleMembers.map(m => {
const resolved = resolveCommissionForMember(m, userCommissionOverrides, orgCommissionDefaults);
const value = calcCommissionValue(resolved, totalReceived, grossProfit);
return { ...m, resolved, value };
});
}, [visibleMembers, userCommissionOverrides, orgCommissionDefaults, totalReceived, grossProfit]);
const activeMembers = visibleMembers.filter(m => m.status === 'active');
const pendingMembers = visibleMembers.filter(m => m.status === 'pending');
return (
<>
<div className="flex flex-col gap-4">
{/* Header */}
<div className="flex items-center justify-between flex-wrap gap-3">
<div className="flex items-center gap-3">
<Users className="text-violet-500 dark:text-violet-400 drop-shadow-[0_0_8px_rgba(139,92,246,0.4)]" size={20} />
<h3 className="text-sm font-mono font-bold text-zinc-900 dark:text-white tracking-widest uppercase">
Team Management
</h3>
<span className="text-[10px] font-mono text-zinc-500 bg-zinc-100 dark:bg-white/5 border border-zinc-200 dark:border-white/10 rounded-full px-2.5 py-0.5">
{activeMembers.length} active{pendingMembers.length > 0 && ` · ${pendingMembers.length} pending`}
</span>
</div>
{canManageTeam && (
<motion.button
whileHover={{ scale: 1.03 }}
whileTap={{ scale: 0.97 }}
onClick={() => setInviteOpen(true)}
className="flex items-center gap-2 px-4 py-2 bg-violet-500/10 hover:bg-violet-500/20 text-violet-600 dark:text-violet-400 border border-violet-500/30 rounded-xl text-xs font-bold transition-colors"
>
<UserPlus size={14} /> Invite Member
</motion.button>
)}
</div>
{/* Member Grid */}
{visibleMembers.length === 0 ? (
<div className="py-12 text-center">
<Users size={32} className="mx-auto mb-3 text-zinc-400" />
<p className="text-sm text-zinc-500">No team members assigned to this job yet.</p>
{canManageTeam && (
<button onClick={() => setInviteOpen(true)} className="mt-3 text-xs font-bold text-violet-500 hover:text-violet-400 transition-colors">
+ Add your first member
</button>
)}
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 gap-3">
<AnimatePresence mode="popLayout">
{visibleMembers.map((member) => (
<motion.div
key={member.id}
layout
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.9 }}
transition={{ duration: 0.2 }}
className={`relative p-4 rounded-2xl border transition-colors
${member.status === 'pending'
? 'bg-zinc-50 dark:bg-white/[0.02] border-dashed border-zinc-300 dark:border-white/10'
: 'bg-white dark:bg-white/[0.03] border-zinc-200 dark:border-white/5 hover:border-zinc-300 dark:hover:border-white/10'
}`}
>
<div className="flex items-start gap-3">
{/* Avatar */}
<div className={`w-10 h-10 rounded-full flex items-center justify-center flex-shrink-0 text-sm font-black
${member.status === 'pending'
? 'bg-zinc-200 dark:bg-zinc-800 text-zinc-500'
: 'bg-violet-500/15 text-violet-600 dark:text-violet-400'
}`}>
{member.name?.[0] || '?'}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-bold text-zinc-900 dark:text-white truncate">{member.name}</span>
{member.status === 'pending' && (
<span className="flex items-center gap-1 text-[10px] font-mono text-amber-500 bg-amber-500/10 border border-amber-500/20 rounded-full px-1.5 py-0.5">
<Clock size={8} /> Pending
</span>
)}
</div>
<p className="text-[11px] text-zinc-500 truncate">{member.email}</p>
{/* Job Role Badge */}
<div className="mt-2 flex items-center gap-2 flex-wrap">
{editingRole === member.id ? (
<select
value={member.jobRole}
onChange={(e) => {
onUpdateMember(project.id, member.id, { jobRole: e.target.value });
setEditingRole(null);
}}
onBlur={() => setEditingRole(null)}
autoFocus
className="text-[11px] font-bold rounded-lg px-2 py-1 bg-zinc-100 dark:bg-zinc-800 border border-zinc-300 dark:border-white/10 text-zinc-900 dark:text-white outline-none"
>
{JOB_ROLES.map(r => (
<option key={r} value={r}>{r}</option>
))}
</select>
) : (
<button
onClick={() => canManageTeam && setEditingRole(member.id)}
className={`text-[11px] font-bold rounded-lg px-2 py-0.5 border transition-colors
${ROLE_COLORS[member.jobRole] || 'text-zinc-400 bg-zinc-500/10 border-zinc-500/20'}
${canManageTeam ? 'cursor-pointer hover:opacity-80' : 'cursor-default'}`}
>
<Briefcase size={10} className="inline mr-1 -mt-px" />
{member.jobRole}
</button>
)}
<span className="text-[10px] text-zinc-400 font-mono">
org: {member.orgRole?.replace(/_/g, ' ')}
</span>
</div>
</div>
{/* Actions Menu */}
{canManageTeam && (
<div className="relative" ref={actionMenu === member.id ? menuRef : null}>
<button
onClick={() => setActionMenu(actionMenu === member.id ? null : member.id)}
className="p-1.5 rounded-lg hover:bg-zinc-100 dark:hover:bg-white/5 text-zinc-400 transition-colors"
>
<MoreVertical size={14} />
</button>
<AnimatePresence>
{actionMenu === member.id && (
<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-8 z-30 w-44 bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-white/10 rounded-xl shadow-2xl overflow-hidden"
>
<button
onClick={() => { setEditingRole(member.id); setActionMenu(null); }}
className="w-full px-3 py-2 text-left text-xs font-semibold text-zinc-700 dark:text-zinc-300 hover:bg-zinc-50 dark:hover:bg-white/5 flex items-center gap-2 transition-colors"
>
<Shield size={12} /> Change Job Role
</button>
{canManageCommissions && (
<button
onClick={() => { setEditingCommission(member); setActionMenu(null); }}
className="w-full px-3 py-2 text-left text-xs font-semibold text-zinc-700 dark:text-zinc-300 hover:bg-zinc-50 dark:hover:bg-white/5 flex items-center gap-2 transition-colors"
>
<DollarSign size={12} /> Edit Commission
</button>
)}
<button
onClick={() => { setRemoveConfirm(member); setActionMenu(null); }}
className="w-full px-3 py-2 text-left text-xs font-semibold text-red-500 hover:bg-red-50 dark:hover:bg-red-500/5 flex items-center gap-2 transition-colors"
>
<UserMinus size={12} /> Remove from Job
</button>
</motion.div>
)}
</AnimatePresence>
</div>
)}
</div>
</motion.div>
))}
</AnimatePresence>
</div>
)}
{/* Commission Distribution */}
{canViewCommissions && visibleMembers.length > 0 && (
<div className="mt-2">
<button
onClick={() => setCommissionOpen(!commissionOpen)}
className="flex items-center gap-2 text-xs font-mono font-bold text-zinc-500 hover:text-zinc-700 dark:hover:text-zinc-300 uppercase tracking-widest transition-colors mb-3"
>
<DollarSign size={14} />
Commission Distribution
{commissionOpen ? <ChevronUp size={12} /> : <ChevronDown size={12} />}
</button>
<AnimatePresence>
{commissionOpen && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.25 }}
className="overflow-hidden"
>
<div className="overflow-x-auto rounded-xl border border-zinc-200 dark:border-white/5">
<table className="w-full text-left">
<thead>
<tr className="border-b border-zinc-200 dark:border-white/5 bg-zinc-50 dark:bg-white/[0.02]">
<th className="px-4 py-2.5 text-[10px] font-mono font-bold text-zinc-500 uppercase tracking-widest">Member</th>
<th className="px-4 py-2.5 text-[10px] font-mono font-bold text-zinc-500 uppercase tracking-widest">Job Role</th>
<th className="px-4 py-2.5 text-[10px] font-mono font-bold text-zinc-500 uppercase tracking-widest">Type</th>
<th className="px-4 py-2.5 text-[10px] font-mono font-bold text-zinc-500 uppercase tracking-widest text-right">Rate</th>
<th className="px-4 py-2.5 text-[10px] font-mono font-bold text-zinc-500 uppercase tracking-widest text-right">Value</th>
<th className="px-4 py-2.5 text-[10px] font-mono font-bold text-zinc-500 uppercase tracking-widest text-center">Source</th>
</tr>
</thead>
<tbody>
{memberCommissions.filter(m => m.status === 'active').map((mc) => {
const srcCfg = COMMISSION_SOURCE_CFG[mc.resolved.source] || COMMISSION_SOURCE_CFG.org;
return (
<tr key={mc.id} className="border-b border-zinc-100 dark:border-white/[0.03] hover:bg-zinc-50 dark:hover:bg-white/[0.02] transition-colors">
<td className="px-4 py-3">
<div className="flex items-center gap-2">
<div className="w-6 h-6 rounded-full bg-violet-500/15 flex items-center justify-center text-[10px] font-black text-violet-400 flex-shrink-0">
{mc.name?.[0]}
</div>
<span className="text-xs font-semibold text-zinc-900 dark:text-white truncate">{mc.name}</span>
</div>
</td>
<td className="px-4 py-3">
<span className={`text-[11px] font-bold rounded-md px-1.5 py-0.5 border ${ROLE_COLORS[mc.jobRole] || 'text-zinc-400 bg-zinc-500/10 border-zinc-500/20'}`}>
{mc.jobRole}
</span>
</td>
<td className="px-4 py-3 text-xs text-zinc-600 dark:text-zinc-400">{COMMISSION_TYPES[mc.resolved.type] || mc.resolved.type}</td>
<td className="px-4 py-3 text-right font-mono text-xs text-zinc-900 dark:text-white">
{mc.resolved.type === 'flat' || mc.resolved.type === 'custom'
? formatCurrency(mc.resolved.rate)
: `${mc.resolved.rate}%`}
</td>
<td className="px-4 py-3 text-right font-mono text-xs font-bold text-green-600 dark:text-[#39ff14]">
{formatCurrency(mc.value)}
</td>
<td className="px-4 py-3 text-center">
<span className={`inline-flex items-center gap-1 text-[10px] font-bold rounded-full px-2 py-0.5 border ${srcCfg.bg}`}>
<span className={`w-1.5 h-1.5 rounded-full ${srcCfg.dot}`} />
<span className={srcCfg.color}>{srcCfg.label}</span>
</span>
</td>
</tr>
);
})}
</tbody>
<tfoot className="border-t-2 border-zinc-300 dark:border-white/10">
<tr className="bg-zinc-50 dark:bg-white/[0.03]">
<td colSpan={4} className="px-4 py-3 text-[10px] font-mono font-bold text-zinc-500 uppercase tracking-widest">Total Distribution</td>
<td className="px-4 py-3 text-right font-mono text-xs font-bold text-green-600 dark:text-[#39ff14]">
{formatCurrency(memberCommissions.filter(m => m.status === 'active').reduce((s, mc) => s + mc.value, 0))}
</td>
<td />
</tr>
</tfoot>
</table>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
)}
</div>
{/* ── Invite Modal ── */}
{inviteOpen && createPortal(
<InviteModal
allUsers={allUsers}
personnel={personnel}
existingIds={teamMembers.map(m => m.userId)}
onInvite={(member) => {
onAddMember(project.id, member);
setInviteOpen(false);
}}
onClose={() => setInviteOpen(false)}
/>,
document.body
)}
{/* ── Remove Confirmation ── */}
{removeConfirm && createPortal(
<RemoveConfirmModal
member={removeConfirm}
projectName={project.projectType}
onConfirm={() => {
onRemoveMember(project.id, removeConfirm.id);
setRemoveConfirm(null);
}}
onClose={() => setRemoveConfirm(null)}
/>,
document.body
)}
{/* ── Edit Commission Modal ── */}
{editingCommission && createPortal(
<EditCommissionModal
member={editingCommission}
orgDefaults={orgCommissionDefaults}
userOverrides={userCommissionOverrides}
onSave={(memberId, jobCommission) => {
onUpdateMember(project.id, memberId, { jobCommission });
setEditingCommission(null);
}}
onClear={(memberId) => {
onUpdateMember(project.id, memberId, { jobCommission: null });
setEditingCommission(null);
}}
onClose={() => setEditingCommission(null)}
/>,
document.body
)}
</>
);
};
// ─── Invite Modal ──────────────────────────────────────────────────────────────
const InviteModal = ({ allUsers, personnel, existingIds, onInvite, onClose }) => {
const [tab, setTab] = useState('search');
const [query, setQuery] = useState('');
const [selectedRole, setSelectedRole] = useState('Sales Rep');
const [emailForm, setEmailForm] = useState({ name: '', email: '' });
const searchable = useMemo(() => {
const fromUsers = allUsers
.filter(u => !['customer'].includes(u.type))
.map(u => ({ userId: u.id, name: u.name, email: u.email, orgRole: u.role || u.type?.toUpperCase() || 'EMPLOYEE' }));
const fromPersonnel = personnel
.map(p => ({ userId: p.id, name: p.name, email: p.email, orgRole: p.role }));
const merged = new Map();
[...fromUsers, ...fromPersonnel].forEach(s => merged.set(s.userId, s));
return Array.from(merged.values()).filter(s => !existingIds.includes(s.userId));
}, [allUsers, personnel, existingIds]);
const filtered = useMemo(() => {
if (!query.trim()) return searchable;
const q = query.toLowerCase();
return searchable.filter(s => s.name.toLowerCase().includes(q) || s.email.toLowerCase().includes(q));
}, [searchable, query]);
const handleSearchInvite = (person) => {
onInvite({
userId: person.userId,
name: person.name,
email: person.email,
orgRole: person.orgRole,
jobRole: selectedRole,
status: 'active',
jobCommission: null,
});
};
const handleEmailInvite = () => {
if (!emailForm.name.trim() || !emailForm.email.trim()) return;
onInvite({
userId: `inv_${Date.now()}`,
name: emailForm.name.trim(),
email: emailForm.email.trim(),
orgRole: 'INVITED',
jobRole: selectedRole,
status: 'pending',
jobCommission: null,
});
};
return (
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4 bg-black/70 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={{ type: 'spring', stiffness: 300, damping: 30 }}
className="bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-white/10 rounded-3xl shadow-2xl w-full max-w-lg overflow-hidden"
onClick={e => e.stopPropagation()}
>
{/* Header */}
<div className="flex items-center justify-between p-5 border-b border-zinc-200 dark:border-white/5">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-2xl bg-violet-500/15 flex items-center justify-center">
<UserPlus size={18} className="text-violet-500" />
</div>
<div>
<h3 className="text-base font-bold text-zinc-900 dark:text-white">Invite to Job</h3>
<p className="text-[11px] text-zinc-500">Add team members to this project</p>
</div>
</div>
<button onClick={onClose} className="p-2 rounded-xl hover:bg-zinc-100 dark:hover:bg-white/5 text-zinc-400 transition-colors">
<XIcon size={18} />
</button>
</div>
{/* Tabs */}
<div className="flex border-b border-zinc-200 dark:border-white/5">
{[
{ key: 'search', label: 'Search Users', icon: Search },
{ key: 'email', label: 'Invite by Email', icon: Mail },
].map(t => (
<button
key={t.key}
onClick={() => setTab(t.key)}
className={`flex-1 py-3 flex items-center justify-center gap-2 text-xs font-bold transition-colors border-b-2
${tab === t.key
? 'text-violet-600 dark:text-violet-400 border-violet-500'
: 'text-zinc-500 border-transparent hover:text-zinc-700 dark:hover:text-zinc-300'}`}
>
<t.icon size={14} /> {t.label}
</button>
))}
</div>
{/* Role Selector */}
<div className="px-5 pt-4 pb-2">
<label className="text-[10px] font-mono text-zinc-500 uppercase tracking-widest block mb-1.5">Assign Job Role</label>
<select
value={selectedRole}
onChange={e => setSelectedRole(e.target.value)}
className="w-full text-sm font-semibold rounded-xl px-3 py-2.5 bg-zinc-50 dark:bg-zinc-800 border border-zinc-200 dark:border-white/10 text-zinc-900 dark:text-white outline-none focus:border-violet-500 transition-colors"
>
{JOB_ROLES.map(r => <option key={r} value={r}>{r}</option>)}
</select>
</div>
{/* Search Tab */}
{tab === 'search' && (
<div className="p-5 pt-2">
<div className="relative mb-3">
<Search size={14} className="absolute left-3 top-1/2 -translate-y-1/2 text-zinc-400" />
<input
value={query}
onChange={e => setQuery(e.target.value)}
placeholder="Search by name or email..."
autoFocus
className="w-full pl-9 pr-3 py-2.5 text-sm rounded-xl bg-zinc-50 dark:bg-zinc-800 border border-zinc-200 dark:border-white/10 text-zinc-900 dark:text-white placeholder-zinc-400 outline-none focus:border-violet-500 transition-colors"
/>
</div>
<div className="max-h-56 overflow-y-auto space-y-1">
{filtered.length === 0 ? (
<p className="text-xs text-zinc-500 text-center py-6">No matching users found</p>
) : filtered.map(person => (
<button
key={person.userId}
onClick={() => handleSearchInvite(person)}
className="w-full flex items-center gap-3 p-3 rounded-xl hover:bg-zinc-50 dark:hover:bg-white/5 transition-colors text-left"
>
<div className="w-8 h-8 rounded-full bg-violet-500/15 flex items-center justify-center text-xs font-black text-violet-400 flex-shrink-0">
{person.name?.[0]}
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-semibold text-zinc-900 dark:text-white truncate">{person.name}</p>
<p className="text-[11px] text-zinc-500 truncate">{person.email} · {person.orgRole?.replace(/_/g, ' ')}</p>
</div>
<UserPlus size={14} className="text-violet-500 flex-shrink-0" />
</button>
))}
</div>
</div>
)}
{/* Email Tab */}
{tab === 'email' && (
<div className="p-5 pt-2 space-y-3">
<div>
<label className="text-[10px] font-mono text-zinc-500 uppercase tracking-widest block mb-1">Full Name</label>
<input
value={emailForm.name}
onChange={e => setEmailForm(prev => ({ ...prev, name: e.target.value }))}
placeholder="John Smith"
className="w-full px-3 py-2.5 text-sm rounded-xl bg-zinc-50 dark:bg-zinc-800 border border-zinc-200 dark:border-white/10 text-zinc-900 dark:text-white placeholder-zinc-400 outline-none focus:border-violet-500 transition-colors"
/>
</div>
<div>
<label className="text-[10px] font-mono text-zinc-500 uppercase tracking-widest block mb-1">Email Address</label>
<input
value={emailForm.email}
onChange={e => setEmailForm(prev => ({ ...prev, email: e.target.value }))}
placeholder="john@example.com"
type="email"
className="w-full px-3 py-2.5 text-sm rounded-xl bg-zinc-50 dark:bg-zinc-800 border border-zinc-200 dark:border-white/10 text-zinc-900 dark:text-white placeholder-zinc-400 outline-none focus:border-violet-500 transition-colors"
/>
</div>
<button
onClick={handleEmailInvite}
disabled={!emailForm.name.trim() || !emailForm.email.trim()}
className="w-full py-2.5 bg-violet-600 hover:bg-violet-700 disabled:opacity-40 disabled:cursor-not-allowed text-white text-sm font-bold rounded-xl transition-colors flex items-center justify-center gap-2"
>
<Mail size={14} /> Send Invitation
</button>
<p className="text-[10px] text-zinc-500 text-center">
Invitation will be sent with a <strong>pending</strong> status until accepted.
</p>
</div>
)}
</motion.div>
</div>
);
};
// ─── Remove Confirmation Modal ─────────────────────────────────────────────────
const RemoveConfirmModal = ({ member, projectName, onConfirm, onClose }) => (
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4 bg-black/70 backdrop-blur-sm" onClick={onClose}>
<motion.div
initial={{ scale: 0.95, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
className="bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-white/10 rounded-3xl shadow-2xl w-full max-w-sm p-6"
onClick={e => e.stopPropagation()}
>
<div className="w-12 h-12 rounded-2xl bg-red-500/15 flex items-center justify-center mx-auto mb-4">
<UserMinus size={22} className="text-red-400" />
</div>
<h3 className="font-bold text-zinc-900 dark:text-white text-base text-center">Remove from Job?</h3>
<p className="text-sm text-zinc-500 text-center mt-2 mb-1">
<strong className="text-zinc-700 dark:text-zinc-300">{member.name}</strong> will be removed from this {projectName} job.
</p>
<p className="text-xs text-zinc-400 text-center mb-5">
This will not affect their organization role or other job assignments.
</p>
<div className="flex gap-3">
<button onClick={onClose}
className="flex-1 px-4 py-2.5 rounded-xl border border-zinc-200 dark:border-white/10 text-zinc-600 dark:text-zinc-400 text-sm font-semibold hover:bg-zinc-50 dark:hover:bg-white/5 transition-colors">
Cancel
</button>
<button onClick={onConfirm}
className="flex-1 px-4 py-2.5 rounded-xl bg-red-600 hover:bg-red-700 text-white text-sm font-bold transition-colors">
Remove
</button>
</div>
</motion.div>
</div>
);
// ─── Edit Commission Modal ─────────────────────────────────────────────────────
const EditCommissionModal = ({ member, orgDefaults, userOverrides, onSave, onClear, onClose }) => {
const resolved = resolveCommissionForMember(member, userOverrides, orgDefaults);
const hasJobOverride = !!member.jobCommission;
const [type, setType] = useState(hasJobOverride ? member.jobCommission.type : resolved.type);
const [rate, setRate] = useState(hasJobOverride ? member.jobCommission.rate : resolved.rate);
return (
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4 bg-black/70 backdrop-blur-sm" onClick={onClose}>
<motion.div
initial={{ scale: 0.95, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
className="bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-white/10 rounded-3xl shadow-2xl w-full max-w-sm overflow-hidden"
onClick={e => e.stopPropagation()}
>
<div className="p-5 border-b border-zinc-200 dark:border-white/5">
<h3 className="text-base font-bold text-zinc-900 dark:text-white">Job Commission Override</h3>
<p className="text-[11px] text-zinc-500 mt-1">Override commission for <strong className="text-zinc-700 dark:text-zinc-300">{member.name}</strong> on this job only.</p>
</div>
{/* Current Resolution */}
<div className="px-5 pt-4">
<div className="bg-zinc-50 dark:bg-white/[0.03] rounded-xl p-3 border border-zinc-200 dark:border-white/5 mb-4">
<p className="text-[10px] font-mono text-zinc-500 uppercase tracking-widest mb-1">Currently Resolved From</p>
<div className="flex items-center gap-2">
{(() => {
const cfg = COMMISSION_SOURCE_CFG[resolved.source] || COMMISSION_SOURCE_CFG.org;
return (
<>
<span className={`w-2 h-2 rounded-full ${cfg.dot}`} />
<span className={`text-xs font-bold ${cfg.color}`}>{cfg.label}</span>
<span className="text-xs text-zinc-500"> {COMMISSION_TYPES[resolved.type]} @ {resolved.type === 'flat' || resolved.type === 'custom' ? formatCurrency(resolved.rate) : `${resolved.rate}%`}</span>
</>
);
})()}
</div>
</div>
</div>
<div className="px-5 pb-2 space-y-3">
<div>
<label className="text-[10px] font-mono text-zinc-500 uppercase tracking-widest block mb-1">Commission Type</label>
<select
value={type}
onChange={e => setType(e.target.value)}
className="w-full text-sm font-semibold rounded-xl px-3 py-2.5 bg-zinc-50 dark:bg-zinc-800 border border-zinc-200 dark:border-white/10 text-zinc-900 dark:text-white outline-none focus:border-amber-500 transition-colors"
>
{Object.entries(COMMISSION_TYPES).map(([k, v]) => (
<option key={k} value={k}>{v}</option>
))}
</select>
</div>
<div>
<label className="text-[10px] font-mono text-zinc-500 uppercase tracking-widest block mb-1">
{type === 'flat' || type === 'custom' ? 'Amount ($)' : 'Rate (%)'}
</label>
<input
type="number"
value={rate}
onChange={e => setRate(e.target.value)}
min="0"
step={type === 'flat' || type === 'custom' ? '100' : '0.5'}
className="w-full px-3 py-2.5 text-sm font-mono rounded-xl bg-zinc-50 dark:bg-zinc-800 border border-zinc-200 dark:border-white/10 text-zinc-900 dark:text-white outline-none focus:border-amber-500 transition-colors"
/>
</div>
</div>
<div className="p-5 flex flex-col gap-2">
<button
onClick={() => onSave(member.id, { type, rate: Number(rate) || 0 })}
className="w-full py-2.5 bg-amber-500 hover:bg-amber-600 text-black text-sm font-bold rounded-xl transition-colors"
>
Save Job Override
</button>
{hasJobOverride && (
<button
onClick={() => onClear(member.id)}
className="w-full py-2 text-xs font-semibold text-zinc-500 hover:text-red-500 transition-colors"
>
Clear job override (revert to {COMMISSION_SOURCE_CFG[resolveCommissionForMember({ ...member, jobCommission: null }, userOverrides, orgDefaults).source]?.label || 'default'})
</button>
)}
</div>
</motion.div>
</div>
);
};
export default TeamManagementPanel;
export { JOB_ROLES };