team management feature in Owners Box
This commit is contained in:
@@ -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 };
|
||||
+77
-1
@@ -2066,6 +2066,13 @@ const MOCK_PROJECTS = [
|
||||
{ name: 'Scope of Work - Roof Replacement', url: '#', type: 'contract' },
|
||||
{ name: 'Permit #26-0401', url: '#', type: 'permit' },
|
||||
{ name: 'Material Order PO-2604', url: '#', type: 'invoice' }
|
||||
],
|
||||
teamMembers: [
|
||||
{ id: 'jtm_001_1', userId: 'p1', name: 'Jesus Gonzales', email: 'jesus@lynkeduppro.com', orgRole: 'W2_EMPLOYEE', jobRole: 'Sales Rep', status: 'active', jobCommission: { type: 'percent_gross', rate: 12 } },
|
||||
{ id: 'jtm_001_2', userId: 'con_001', name: 'Mike Contractor', email: 'mike@texasbuilders.com', orgRole: 'CONTRACTOR', jobRole: 'Lead Installer', status: 'active', jobCommission: null },
|
||||
{ id: 'jtm_001_3', userId: 'sub_001', name: 'Carlos Subcontractor', email: 'carlos@electricpro.com', orgRole: 'SUBCONTRACTOR', jobRole: 'Subcontractor', status: 'active', jobCommission: null },
|
||||
{ id: 'jtm_001_4', userId: 'e1', name: 'Frank Agent', email: 'agent1@plano.com', orgRole: 'FIELD_AGENT', jobRole: 'Canvasser', status: 'active', jobCommission: null },
|
||||
{ id: 'jtm_001_5', userId: 'p2', name: 'Sarah Sales', email: 'sarah@lynkeduppro.com', orgRole: '1099_SALES_REP', jobRole: 'Estimator', status: 'pending', jobCommission: null }
|
||||
]
|
||||
},
|
||||
|
||||
@@ -2115,6 +2122,10 @@ const MOCK_PROJECTS = [
|
||||
{ name: 'Completion Certificate', url: '#', type: 'certificate' },
|
||||
{ name: 'Final Invoice Package', url: '#', type: 'invoice' },
|
||||
{ name: 'Warranty Registration', url: '#', type: 'warranty' }
|
||||
],
|
||||
teamMembers: [
|
||||
{ id: 'jtm_002_1', userId: 'p1', name: 'Jesus Gonzales', email: 'jesus@lynkeduppro.com', orgRole: 'W2_EMPLOYEE', jobRole: 'Sales Rep', status: 'active', jobCommission: null },
|
||||
{ id: 'jtm_002_2', userId: 'con_001', name: 'Mike Contractor', email: 'mike@texasbuilders.com', orgRole: 'CONTRACTOR', jobRole: 'Lead Installer', status: 'active', jobCommission: null }
|
||||
]
|
||||
},
|
||||
|
||||
@@ -2190,6 +2201,11 @@ const MOCK_PROJECTS = [
|
||||
{ name: 'HVAC Design Plans', url: '#', type: 'plans' },
|
||||
{ name: 'Mechanical Permit #26-0187', url: '#', type: 'permit' },
|
||||
{ name: 'Equipment Spec Sheets', url: '#', type: 'specification' }
|
||||
],
|
||||
teamMembers: [
|
||||
{ id: 'jtm_003_1', userId: 'p2', name: 'Sarah Sales', email: 'sarah@lynkeduppro.com', orgRole: '1099_SALES_REP', jobRole: 'Sales Rep', status: 'active', jobCommission: { type: 'flat', rate: 2000 } },
|
||||
{ id: 'jtm_003_2', userId: 'con_001', name: 'Mike Contractor', email: 'mike@texasbuilders.com', orgRole: 'CONTRACTOR', jobRole: 'Project Manager', status: 'active', jobCommission: null },
|
||||
{ id: 'jtm_003_3', userId: 'sub_001', name: 'Carlos Subcontractor', email: 'carlos@electricpro.com', orgRole: 'SUBCONTRACTOR', jobRole: 'Subcontractor', status: 'active', jobCommission: null }
|
||||
]
|
||||
},
|
||||
|
||||
@@ -2276,6 +2292,11 @@ const MOCK_PROJECTS = [
|
||||
{ name: 'Change Order #1 - Scope Expansion', url: '#', type: 'change_order' },
|
||||
{ name: 'Change Order #2 - Material Upgrade', url: '#', type: 'change_order' },
|
||||
{ name: 'Asbestos Abatement Report', url: '#', type: 'report' }
|
||||
],
|
||||
teamMembers: [
|
||||
{ id: 'jtm_004_1', userId: 'p1', name: 'Jesus Gonzales', email: 'jesus@lynkeduppro.com', orgRole: 'W2_EMPLOYEE', jobRole: 'Project Manager', status: 'active', jobCommission: { type: 'percent_net', rate: 8 } },
|
||||
{ id: 'jtm_004_2', userId: 'con_001', name: 'Mike Contractor', email: 'mike@texasbuilders.com', orgRole: 'CONTRACTOR', jobRole: 'Lead Installer', status: 'active', jobCommission: null },
|
||||
{ id: 'jtm_004_3', userId: 'e2', name: 'Fiona Field', email: 'agent2@plano.com', orgRole: 'FIELD_AGENT', jobRole: 'Inspector', status: 'active', jobCommission: null }
|
||||
]
|
||||
},
|
||||
|
||||
@@ -2349,6 +2370,10 @@ const MOCK_PROJECTS = [
|
||||
{ name: 'Dispute Notice - Material Defect', url: '#', type: 'dispute' },
|
||||
{ name: 'Manufacturer Warranty Claim', url: '#', type: 'warranty' },
|
||||
{ name: 'Site Photos - Defective Panels', url: '#', type: 'photos' }
|
||||
],
|
||||
teamMembers: [
|
||||
{ id: 'jtm_005_1', userId: 'p2', name: 'Sarah Sales', email: 'sarah@lynkeduppro.com', orgRole: '1099_SALES_REP', jobRole: 'Sales Rep', status: 'active', jobCommission: null },
|
||||
{ id: 'jtm_005_2', userId: 'con_001', name: 'Mike Contractor', email: 'mike@texasbuilders.com', orgRole: 'CONTRACTOR', jobRole: 'Lead Installer', status: 'active', jobCommission: null }
|
||||
]
|
||||
},
|
||||
|
||||
@@ -2396,6 +2421,10 @@ const MOCK_PROJECTS = [
|
||||
{ name: 'Electrical Upgrade Proposal', url: '#', type: 'contract' },
|
||||
{ name: 'Electrical Permit #26-0223', url: '#', type: 'permit' },
|
||||
{ name: 'Load Calculation Report', url: '#', type: 'report' }
|
||||
],
|
||||
teamMembers: [
|
||||
{ id: 'jtm_006_1', userId: 'p1', name: 'Jesus Gonzales', email: 'jesus@lynkeduppro.com', orgRole: 'W2_EMPLOYEE', jobRole: 'Sales Rep', status: 'active', jobCommission: null },
|
||||
{ id: 'jtm_006_2', userId: 'sub_001', name: 'Carlos Subcontractor', email: 'carlos@electricpro.com', orgRole: 'SUBCONTRACTOR', jobRole: 'Lead Installer', status: 'active', jobCommission: null }
|
||||
]
|
||||
},
|
||||
|
||||
@@ -2495,6 +2524,12 @@ const MOCK_PROJECTS = [
|
||||
{ name: 'TPO Manufacturer Spec', url: '#', type: 'specification' },
|
||||
{ name: 'Roof Survey Report', url: '#', type: 'report' },
|
||||
{ name: 'Safety Plan - Fall Protection', url: '#', type: 'safety' }
|
||||
],
|
||||
teamMembers: [
|
||||
{ id: 'jtm_007_1', userId: 'p1', name: 'Jesus Gonzales', email: 'jesus@lynkeduppro.com', orgRole: 'W2_EMPLOYEE', jobRole: 'Project Manager', status: 'active', jobCommission: { type: 'percent_gross', rate: 5 } },
|
||||
{ id: 'jtm_007_2', userId: 'con_001', name: 'Mike Contractor', email: 'mike@texasbuilders.com', orgRole: 'CONTRACTOR', jobRole: 'Lead Installer', status: 'active', jobCommission: null },
|
||||
{ id: 'jtm_007_3', userId: 'sub_001', name: 'Carlos Subcontractor', email: 'carlos@electricpro.com', orgRole: 'SUBCONTRACTOR', jobRole: 'Subcontractor', status: 'active', jobCommission: null },
|
||||
{ id: 'jtm_007_4', userId: 'e4', name: 'Felicity Fast', email: 'agent4@plano.com', orgRole: 'FIELD_AGENT', jobRole: 'Canvasser', status: 'active', jobCommission: null }
|
||||
]
|
||||
},
|
||||
|
||||
@@ -2550,6 +2585,10 @@ const MOCK_PROJECTS = [
|
||||
{ name: 'Change Order #1 - Additional Piers', url: '#', type: 'change_order' },
|
||||
{ name: 'Change Order #2 - Plumbing Reroute', url: '#', type: 'change_order' },
|
||||
{ name: 'Soil Analysis Report', url: '#', type: 'report' }
|
||||
],
|
||||
teamMembers: [
|
||||
{ id: 'jtm_008_1', userId: 'p2', name: 'Sarah Sales', email: 'sarah@lynkeduppro.com', orgRole: '1099_SALES_REP', jobRole: 'Sales Rep', status: 'active', jobCommission: null },
|
||||
{ id: 'jtm_008_2', userId: 'con_001', name: 'Mike Contractor', email: 'mike@texasbuilders.com', orgRole: 'CONTRACTOR', jobRole: 'Lead Installer', status: 'active', jobCommission: null }
|
||||
]
|
||||
},
|
||||
|
||||
@@ -2598,6 +2637,10 @@ const MOCK_PROJECTS = [
|
||||
{ name: 'Window Replacement Agreement', url: '#', type: 'contract' },
|
||||
{ name: 'Custom Window Order Confirmation', url: '#', type: 'purchase_order' },
|
||||
{ name: 'Energy Rating Certificates', url: '#', type: 'certificate' }
|
||||
],
|
||||
teamMembers: [
|
||||
{ id: 'jtm_009_1', userId: 'p1', name: 'Jesus Gonzales', email: 'jesus@lynkeduppro.com', orgRole: 'W2_EMPLOYEE', jobRole: 'Sales Rep', status: 'active', jobCommission: null },
|
||||
{ id: 'jtm_009_2', userId: 'e2', name: 'Fiona Field', email: 'agent2@plano.com', orgRole: 'FIELD_AGENT', jobRole: 'Inspector', status: 'active', jobCommission: null }
|
||||
]
|
||||
},
|
||||
|
||||
@@ -2646,6 +2689,11 @@ const MOCK_PROJECTS = [
|
||||
{ name: 'Completion Certificate', url: '#', type: 'certificate' },
|
||||
{ name: 'ADA Compliance Verification', url: '#', type: 'report' },
|
||||
{ name: 'Final Payment Release', url: '#', type: 'invoice' }
|
||||
],
|
||||
teamMembers: [
|
||||
{ id: 'jtm_010_1', userId: 'p2', name: 'Sarah Sales', email: 'sarah@lynkeduppro.com', orgRole: '1099_SALES_REP', jobRole: 'Estimator', status: 'active', jobCommission: { type: 'flat', rate: 1500 } },
|
||||
{ id: 'jtm_010_2', userId: 'con_001', name: 'Mike Contractor', email: 'mike@texasbuilders.com', orgRole: 'CONTRACTOR', jobRole: 'Lead Installer', status: 'active', jobCommission: null },
|
||||
{ id: 'jtm_010_3', userId: 'e5', name: 'Felix Fixer', email: 'agent5@plano.com', orgRole: 'FIELD_AGENT', jobRole: 'Canvasser', status: 'pending', jobCommission: null }
|
||||
]
|
||||
},
|
||||
|
||||
@@ -2702,6 +2750,10 @@ const MOCK_PROJECTS = [
|
||||
{ name: 'Dispute Filing - Scope Disagreement', url: '#', type: 'dispute' },
|
||||
{ name: 'RFI #1 - Original vs Current Scope', url: '#', type: 'rfi' },
|
||||
{ name: 'RFI #2 - Waterproofing Method', url: '#', type: 'rfi' }
|
||||
],
|
||||
teamMembers: [
|
||||
{ id: 'jtm_011_1', userId: 'p1', name: 'Jesus Gonzales', email: 'jesus@lynkeduppro.com', orgRole: 'W2_EMPLOYEE', jobRole: 'Project Manager', status: 'active', jobCommission: null },
|
||||
{ id: 'jtm_011_2', userId: 'con_001', name: 'Mike Contractor', email: 'mike@texasbuilders.com', orgRole: 'CONTRACTOR', jobRole: 'Lead Installer', status: 'active', jobCommission: null }
|
||||
]
|
||||
},
|
||||
|
||||
@@ -2752,6 +2804,10 @@ const MOCK_PROJECTS = [
|
||||
{ name: 'Plumbing Permit #26-0156', url: '#', type: 'permit' },
|
||||
{ name: 'Scope Hold Notice - Permit Review', url: '#', type: 'notice' },
|
||||
{ name: 'Material Delivery Receipt', url: '#', type: 'receipt' }
|
||||
],
|
||||
teamMembers: [
|
||||
{ id: 'jtm_012_1', userId: 'p2', name: 'Sarah Sales', email: 'sarah@lynkeduppro.com', orgRole: '1099_SALES_REP', jobRole: 'Sales Rep', status: 'active', jobCommission: null },
|
||||
{ id: 'jtm_012_2', userId: 'e3', name: 'Fred Flyer', email: 'agent3@plano.com', orgRole: 'FIELD_AGENT', jobRole: 'Canvasser', status: 'active', jobCommission: null }
|
||||
]
|
||||
}
|
||||
];
|
||||
@@ -4993,7 +5049,7 @@ export const MockStoreProvider = ({ children }) => {
|
||||
meetings,
|
||||
salesHistory, // Exported for Leaderboard
|
||||
|
||||
// New Owners Box Data
|
||||
// New Data
|
||||
owners,
|
||||
personnel,
|
||||
vendors,
|
||||
@@ -5123,6 +5179,26 @@ export const MockStoreProvider = ({ children }) => {
|
||||
updateProject: (projectId, data) => {
|
||||
setProjects(prev => prev.map(p => p.id === projectId ? { ...p, ...data } : p));
|
||||
},
|
||||
|
||||
// Team Management (job-scoped)
|
||||
addJobTeamMember: (projectId, member) => {
|
||||
setProjects(prev => prev.map(p => {
|
||||
if (p.id !== projectId) return p;
|
||||
return { ...p, teamMembers: [...(p.teamMembers || []), { ...member, id: `jtm_${Date.now()}_${Math.random().toString(36).slice(2, 6)}` }] };
|
||||
}));
|
||||
},
|
||||
removeJobTeamMember: (projectId, memberId) => {
|
||||
setProjects(prev => prev.map(p => {
|
||||
if (p.id !== projectId) return p;
|
||||
return { ...p, teamMembers: (p.teamMembers || []).filter(m => m.id !== memberId) };
|
||||
}));
|
||||
},
|
||||
updateJobTeamMember: (projectId, memberId, updates) => {
|
||||
setProjects(prev => prev.map(p => {
|
||||
if (p.id !== projectId) return p;
|
||||
return { ...p, teamMembers: (p.teamMembers || []).map(m => m.id === memberId ? { ...m, ...updates } : m) };
|
||||
}));
|
||||
},
|
||||
}}>
|
||||
{children}
|
||||
</MockStoreContext.Provider>
|
||||
|
||||
@@ -6,18 +6,23 @@ import FinancialKPICards from '../../components/owner/FinancialKPICards';
|
||||
import UrgentItemsPanel from '../../components/owner/UrgentItemsPanel';
|
||||
import FinancialDetailsModal from '../../components/owner/FinancialDetailsModal';
|
||||
import ActionCenterModal from '../../components/owner/ActionCenterModal';
|
||||
import TeamManagementPanel from '../../components/owner/TeamManagementPanel';
|
||||
import { SpotlightCard } from '../../components/SpotlightCard';
|
||||
import {
|
||||
BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, PieChart, Pie, Cell, Legend
|
||||
} from 'recharts';
|
||||
import {
|
||||
Briefcase, TrendingUp, AlertTriangle, Shield, ChevronRight, ArrowUpRight
|
||||
Briefcase, TrendingUp, AlertTriangle, Shield, ChevronRight, ArrowUpRight, Users, ChevronDown
|
||||
} from 'lucide-react';
|
||||
|
||||
const OwnerSnapshot = () => {
|
||||
const { user } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const { owners, projects } = useMockStore();
|
||||
const {
|
||||
owners, projects, users, personnel,
|
||||
orgCommissionDefaults, userCommissionOverrides,
|
||||
addJobTeamMember, removeJobTeamMember, updateJobTeamMember,
|
||||
} = useMockStore();
|
||||
|
||||
// Find the current owner profile
|
||||
const owner = useMemo(() =>
|
||||
@@ -33,6 +38,23 @@ const OwnerSnapshot = () => {
|
||||
const [financialModal, setFinancialModal] = useState({ isOpen: false, type: 'revenue' });
|
||||
const [actionModal, setActionModal] = useState({ isOpen: false, filter: 'all' });
|
||||
|
||||
// ── Team Management (Owner's Box) ──────────────────────────────────────
|
||||
const [selectedProjectId, setSelectedProjectId] = useState(null);
|
||||
const selectedProject = useMemo(() => {
|
||||
const id = selectedProjectId || ownerProjects.find(p => p.status === 'active')?.id;
|
||||
return ownerProjects.find(p => p.id === id) || ownerProjects[0] || null;
|
||||
}, [selectedProjectId, ownerProjects]);
|
||||
|
||||
const teamFinancials = useMemo(() => {
|
||||
if (!selectedProject) return { totalReceived: 0, grossProfit: 0 };
|
||||
const paid = (selectedProject.paymentSchedule || [])
|
||||
.filter(ps => ps.status === 'paid')
|
||||
.reduce((s, ps) => s + (ps.amount || 0), 0);
|
||||
const totalReceived = paid || (selectedProject.budget || 0);
|
||||
const totalCosts = Number(selectedProject.actualCost ?? selectedProject.spent) || 0;
|
||||
return { totalReceived, grossProfit: totalReceived - totalCosts };
|
||||
}, [selectedProject]);
|
||||
|
||||
const handleFinancialClick = (type) => {
|
||||
setFinancialModal({ isOpen: true, type });
|
||||
};
|
||||
@@ -317,6 +339,48 @@ const OwnerSnapshot = () => {
|
||||
</SpotlightCard>
|
||||
|
||||
</div>
|
||||
|
||||
{/* ── Owner's Box — Team Management ── */}
|
||||
{selectedProject && (
|
||||
<section>
|
||||
<div className="flex items-center justify-between mb-4 flex-wrap gap-3">
|
||||
<h2 className="text-xl font-bold text-zinc-900 dark:text-white/90 flex items-center">
|
||||
<span className="w-2 h-8 bg-violet-500 rounded-full mr-3"></span>
|
||||
Team Management
|
||||
</h2>
|
||||
<div className="relative">
|
||||
<select
|
||||
value={selectedProject.id}
|
||||
onChange={e => setSelectedProjectId(e.target.value)}
|
||||
className="appearance-none pl-3 pr-8 py-2 text-sm font-semibold rounded-xl bg-zinc-100 dark:bg-white/5 border border-zinc-200 dark:border-white/10 text-zinc-900 dark:text-white outline-none focus:border-violet-500 transition-colors cursor-pointer"
|
||||
>
|
||||
{ownerProjects.map(p => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.projectType} — {p.address?.split(',')[0] || p.id}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<ChevronDown size={14} className="absolute right-2.5 top-1/2 -translate-y-1/2 text-zinc-400 pointer-events-none" />
|
||||
</div>
|
||||
</div>
|
||||
<SpotlightCard className="p-5 lg:p-6" spotlightColor="rgba(139, 92, 246, 0.06)">
|
||||
<TeamManagementPanel
|
||||
project={selectedProject}
|
||||
teamMembers={selectedProject.teamMembers || []}
|
||||
allUsers={users}
|
||||
personnel={personnel}
|
||||
userRole={user?.role}
|
||||
orgCommissionDefaults={orgCommissionDefaults}
|
||||
userCommissionOverrides={userCommissionOverrides}
|
||||
totalReceived={teamFinancials.totalReceived}
|
||||
grossProfit={teamFinancials.grossProfit}
|
||||
onAddMember={addJobTeamMember}
|
||||
onRemoveMember={removeJobTeamMember}
|
||||
onUpdateMember={updateJobTeamMember}
|
||||
/>
|
||||
</SpotlightCard>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Interactive Modals */}
|
||||
|
||||
Reference in New Issue
Block a user