731 lines
44 KiB
React
731 lines
44 KiB
React
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 };
|