commission overrides in org Settings page
This commit is contained in:
@@ -15,6 +15,7 @@ const COMMISSION_TYPES = [
|
|||||||
|
|
||||||
const LEVEL_CONFIG = {
|
const LEVEL_CONFIG = {
|
||||||
org: { label: 'Org Default', icon: Building2, color: '#00f0ff', bg: 'rgba(0,240,255,0.1)', border: 'rgba(0,240,255,0.2)' },
|
org: { label: 'Org Default', icon: Building2, color: '#00f0ff', bg: 'rgba(0,240,255,0.1)', border: 'rgba(0,240,255,0.2)' },
|
||||||
|
role: { label: 'Role Override', icon: Shield, color: '#10b981', bg: 'rgba(16,185,129,0.1)', border: 'rgba(16,185,129,0.2)' },
|
||||||
user: { label: 'User Override', icon: User, color: '#a855f7', bg: 'rgba(168,85,247,0.1)', border: 'rgba(168,85,247,0.2)' },
|
user: { label: 'User Override', icon: User, color: '#a855f7', bg: 'rgba(168,85,247,0.1)', border: 'rgba(168,85,247,0.2)' },
|
||||||
job: { label: 'Job Override', icon: Briefcase, color: '#fda913', bg: 'rgba(253,169,19,0.1)', border: 'rgba(253,169,19,0.2)' },
|
job: { label: 'Job Override', icon: Briefcase, color: '#fda913', bg: 'rgba(253,169,19,0.1)', border: 'rgba(253,169,19,0.2)' },
|
||||||
};
|
};
|
||||||
@@ -40,6 +41,7 @@ const SectionHeader = ({ level, isOpen, onToggle }) => {
|
|||||||
<h3 className="text-sm font-bold text-white tracking-widest uppercase">{cfg.label}</h3>
|
<h3 className="text-sm font-bold text-white tracking-widest uppercase">{cfg.label}</h3>
|
||||||
<p className="text-[10px] text-zinc-500 mt-0.5">
|
<p className="text-[10px] text-zinc-500 mt-0.5">
|
||||||
{level === 'org' && 'Default commission for all jobs'}
|
{level === 'org' && 'Default commission for all jobs'}
|
||||||
|
{level === 'role' && 'Per-role commission overrides'}
|
||||||
{level === 'user' && 'Per-user commission overrides'}
|
{level === 'user' && 'Per-user commission overrides'}
|
||||||
{level === 'job' && 'Override commission for this specific job'}
|
{level === 'job' && 'Override commission for this specific job'}
|
||||||
</p>
|
</p>
|
||||||
@@ -96,6 +98,9 @@ const CommissionSettingsModal = ({
|
|||||||
onClose,
|
onClose,
|
||||||
orgDefaults,
|
orgDefaults,
|
||||||
userOverrides,
|
userOverrides,
|
||||||
|
roleOverrides = [],
|
||||||
|
orgRoles = [],
|
||||||
|
assignedUserRoleKey,
|
||||||
jobOverride,
|
jobOverride,
|
||||||
activeSource,
|
activeSource,
|
||||||
personnel,
|
personnel,
|
||||||
@@ -106,7 +111,7 @@ const CommissionSettingsModal = ({
|
|||||||
onClearJobOverride,
|
onClearJobOverride,
|
||||||
userRole,
|
userRole,
|
||||||
}) => {
|
}) => {
|
||||||
const [openSections, setOpenSections] = useState({ org: true, user: false, job: true });
|
const [openSections, setOpenSections] = useState({ org: true, role: false, user: false, job: true });
|
||||||
const [localOrgDefaults, setLocalOrgDefaults] = useState({ type: 'percent_gross', rate: 10 });
|
const [localOrgDefaults, setLocalOrgDefaults] = useState({ type: 'percent_gross', rate: 10 });
|
||||||
const [localJobOverride, setLocalJobOverride] = useState({ enabled: false, type: 'flat', rate: 0 });
|
const [localJobOverride, setLocalJobOverride] = useState({ enabled: false, type: 'flat', rate: 0 });
|
||||||
const [localUserOverrides, setLocalUserOverrides] = useState([]);
|
const [localUserOverrides, setLocalUserOverrides] = useState([]);
|
||||||
@@ -192,7 +197,7 @@ const CommissionSettingsModal = ({
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-lg font-bold text-white tracking-widest uppercase">Commission Settings</h2>
|
<h2 className="text-lg font-bold text-white tracking-widest uppercase">Commission Settings</h2>
|
||||||
<p className="text-[10px] text-zinc-500 mt-0.5">Configure commission hierarchy: Job → User → Org</p>
|
<p className="text-[10px] text-zinc-500 mt-0.5">Configure commission hierarchy: Job → User → Role → Org</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button onClick={onClose} className="p-2 rounded-lg hover:bg-white/10 text-zinc-500 hover:text-white transition-colors">
|
<button onClick={onClose} className="p-2 rounded-lg hover:bg-white/10 text-zinc-500 hover:text-white transition-colors">
|
||||||
@@ -202,8 +207,8 @@ const CommissionSettingsModal = ({
|
|||||||
|
|
||||||
{/* Override Priority Visualization */}
|
{/* Override Priority Visualization */}
|
||||||
<div className="px-6 pt-4 pb-2 shrink-0">
|
<div className="px-6 pt-4 pb-2 shrink-0">
|
||||||
<div className="flex items-center justify-center gap-2 text-[10px] font-bold uppercase tracking-widest">
|
<div className="flex items-center justify-center gap-1 text-[10px] font-bold uppercase tracking-widest flex-wrap">
|
||||||
{['org', 'user', 'job'].map((level, i) => {
|
{['org', 'role', 'user', 'job'].map((level, i) => {
|
||||||
const cfg = LEVEL_CONFIG[level];
|
const cfg = LEVEL_CONFIG[level];
|
||||||
const Icon = cfg.icon;
|
const Icon = cfg.icon;
|
||||||
const isActive = activeSource === level;
|
const isActive = activeSource === level;
|
||||||
@@ -282,6 +287,67 @@ const CommissionSettingsModal = ({
|
|||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* ── ROLE OVERRIDES ── */}
|
||||||
|
<div>
|
||||||
|
<SectionHeader level="role" isOpen={openSections.role} onToggle={() => toggleSection('role')} />
|
||||||
|
<AnimatePresence>
|
||||||
|
{openSections.role && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ height: 0, opacity: 0 }}
|
||||||
|
animate={{ height: 'auto', opacity: 1 }}
|
||||||
|
exit={{ height: 0, opacity: 0 }}
|
||||||
|
transition={{ duration: 0.2 }}
|
||||||
|
className="overflow-hidden"
|
||||||
|
>
|
||||||
|
<div className="pt-3 pb-1 pl-4 border-l-2 ml-7 space-y-3" style={{ borderColor: 'rgba(16,185,129,0.2)' }}>
|
||||||
|
{roleOverrides.length === 0 ? (
|
||||||
|
<p className="text-xs text-zinc-600 italic px-2">No role overrides configured.</p>
|
||||||
|
) : (
|
||||||
|
roleOverrides.map(ov => {
|
||||||
|
const role = orgRoles.find(r => r.key === ov.roleKey);
|
||||||
|
const isApplicable = ov.roleKey === assignedUserRoleKey;
|
||||||
|
const typeLabel = COMMISSION_TYPES.find(c => c.value === ov.type)?.label || ov.type;
|
||||||
|
const rateDisplay = (ov.type === 'percent_gross' || ov.type === 'percent_net') ? `${ov.rate}%` : formatCurrency(ov.rate);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={ov.roleKey}
|
||||||
|
className={`flex items-center gap-3 px-4 py-3 rounded-xl border transition-all ${
|
||||||
|
isApplicable
|
||||||
|
? 'bg-emerald-500/10 border-emerald-500/30'
|
||||||
|
: 'bg-white/[0.03] border-white/5 opacity-60'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="w-2 h-2 rounded-full shrink-0" style={{ backgroundColor: role?.color || '#10b981' }} />
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<span className="text-sm font-bold text-white">{role?.name || ov.roleKey}</span>
|
||||||
|
<span className="text-xs text-zinc-500 ml-2">
|
||||||
|
{typeLabel} — {rateDisplay}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
|
{ov.enabled ? (
|
||||||
|
<span className="text-[10px] font-bold text-emerald-400 uppercase">Active</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-[10px] font-bold text-zinc-600 uppercase">Disabled</span>
|
||||||
|
)}
|
||||||
|
{isApplicable && (
|
||||||
|
<span className="text-[10px] font-bold text-emerald-400 bg-emerald-500/20 px-1.5 py-0.5 rounded-full">Applies</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
<div className="flex items-start gap-2 px-2 pt-1">
|
||||||
|
<Info size={12} className="text-zinc-600 mt-0.5 shrink-0" />
|
||||||
|
<p className="text-[10px] text-zinc-600">Role overrides are managed in <span className="font-bold text-zinc-400">Org Settings → Commission Rules</span>.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* ── USER OVERRIDES ── */}
|
{/* ── USER OVERRIDES ── */}
|
||||||
<div>
|
<div>
|
||||||
<SectionHeader level="user" isOpen={openSections.user} onToggle={() => toggleSection('user')} />
|
<SectionHeader level="user" isOpen={openSections.user} onToggle={() => toggleSection('user')} />
|
||||||
@@ -466,8 +532,8 @@ const CommissionSettingsModal = ({
|
|||||||
<div className="flex items-start gap-2">
|
<div className="flex items-start gap-2">
|
||||||
<Info size={14} className="text-zinc-600 mt-0.5 shrink-0" />
|
<Info size={14} className="text-zinc-600 mt-0.5 shrink-0" />
|
||||||
<div className="text-[11px] text-zinc-500 space-y-1">
|
<div className="text-[11px] text-zinc-500 space-y-1">
|
||||||
<p><span className="font-bold text-zinc-400">Override Priority:</span> Job Override → User Override → Org Default</p>
|
<p><span className="font-bold text-zinc-400">Override Priority:</span> Job Override → User Override → Role Override → Org Default</p>
|
||||||
<p>The most specific level always takes precedence. If a job override is set it will be used regardless of user or org settings.</p>
|
<p>The most specific level always takes precedence. If a job override is set it will be used regardless of user, role, or org settings.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ const COMMISSION_TYPES = {
|
|||||||
|
|
||||||
const COMMISSION_SOURCE_CFG = {
|
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' },
|
org: { label: 'Org Default', color: 'text-cyan-400', bg: 'bg-cyan-500/10 border-cyan-500/30', dot: 'bg-cyan-400' },
|
||||||
|
role: { label: 'Role', color: 'text-emerald-400', bg: 'bg-emerald-500/10 border-emerald-500/30', dot: 'bg-emerald-400' },
|
||||||
user: { label: 'User', color: 'text-violet-400', bg: 'bg-violet-500/10 border-violet-500/30', dot: 'bg-violet-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' },
|
job: { label: 'Job', color: 'text-amber-400', bg: 'bg-amber-500/10 border-amber-500/30', dot: 'bg-amber-400' },
|
||||||
};
|
};
|
||||||
@@ -42,7 +43,7 @@ const ROLE_COLORS = {
|
|||||||
'Supplier': 'text-yellow-400 bg-yellow-500/10 border-yellow-500/20',
|
'Supplier': 'text-yellow-400 bg-yellow-500/10 border-yellow-500/20',
|
||||||
};
|
};
|
||||||
|
|
||||||
function resolveCommissionForMember(member, userCommissionOverrides, orgCommissionDefaults) {
|
function resolveCommissionForMember(member, userCommissionOverrides, roleCommissionOverrides, orgMembers, orgCommissionDefaults) {
|
||||||
if (member.jobCommission) {
|
if (member.jobCommission) {
|
||||||
return { type: member.jobCommission.type, rate: Number(member.jobCommission.rate) || 0, source: 'job' };
|
return { type: member.jobCommission.type, rate: Number(member.jobCommission.rate) || 0, source: 'job' };
|
||||||
}
|
}
|
||||||
@@ -50,6 +51,13 @@ function resolveCommissionForMember(member, userCommissionOverrides, orgCommissi
|
|||||||
if (userOvr) {
|
if (userOvr) {
|
||||||
return { type: userOvr.type, rate: Number(userOvr.rate) || 0, source: 'user' };
|
return { type: userOvr.type, rate: Number(userOvr.rate) || 0, source: 'user' };
|
||||||
}
|
}
|
||||||
|
const orgMember = (orgMembers || []).find(m => m.userId === member.userId);
|
||||||
|
if (orgMember) {
|
||||||
|
const roleOvr = (roleCommissionOverrides || []).find(o => o.enabled && o.roleKey === orgMember.roleKey);
|
||||||
|
if (roleOvr) {
|
||||||
|
return { type: roleOvr.type, rate: Number(roleOvr.rate) || 0, source: 'role' };
|
||||||
|
}
|
||||||
|
}
|
||||||
return { type: orgCommissionDefaults.type, rate: Number(orgCommissionDefaults.rate) || 0, source: 'org' };
|
return { type: orgCommissionDefaults.type, rate: Number(orgCommissionDefaults.rate) || 0, source: 'org' };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -75,6 +83,8 @@ const TeamManagementPanel = ({
|
|||||||
userRole,
|
userRole,
|
||||||
orgCommissionDefaults,
|
orgCommissionDefaults,
|
||||||
userCommissionOverrides,
|
userCommissionOverrides,
|
||||||
|
roleCommissionOverrides = [],
|
||||||
|
orgMembers = [],
|
||||||
totalReceived = 0,
|
totalReceived = 0,
|
||||||
grossProfit = 0,
|
grossProfit = 0,
|
||||||
onAddMember,
|
onAddMember,
|
||||||
@@ -114,11 +124,11 @@ const TeamManagementPanel = ({
|
|||||||
|
|
||||||
const memberCommissions = useMemo(() => {
|
const memberCommissions = useMemo(() => {
|
||||||
return visibleMembers.map(m => {
|
return visibleMembers.map(m => {
|
||||||
const resolved = resolveCommissionForMember(m, userCommissionOverrides, orgCommissionDefaults);
|
const resolved = resolveCommissionForMember(m, userCommissionOverrides, roleCommissionOverrides, orgMembers, orgCommissionDefaults);
|
||||||
const value = calcCommissionValue(resolved, totalReceived, grossProfit);
|
const value = calcCommissionValue(resolved, totalReceived, grossProfit);
|
||||||
return { ...m, resolved, value };
|
return { ...m, resolved, value };
|
||||||
});
|
});
|
||||||
}, [visibleMembers, userCommissionOverrides, orgCommissionDefaults, totalReceived, grossProfit]);
|
}, [visibleMembers, userCommissionOverrides, roleCommissionOverrides, orgMembers, orgCommissionDefaults, totalReceived, grossProfit]);
|
||||||
|
|
||||||
const activeMembers = visibleMembers.filter(m => m.status === 'active');
|
const activeMembers = visibleMembers.filter(m => m.status === 'active');
|
||||||
const pendingMembers = visibleMembers.filter(m => m.status === 'pending');
|
const pendingMembers = visibleMembers.filter(m => m.status === 'pending');
|
||||||
@@ -404,6 +414,8 @@ const TeamManagementPanel = ({
|
|||||||
member={editingCommission}
|
member={editingCommission}
|
||||||
orgDefaults={orgCommissionDefaults}
|
orgDefaults={orgCommissionDefaults}
|
||||||
userOverrides={userCommissionOverrides}
|
userOverrides={userCommissionOverrides}
|
||||||
|
roleOverrides={roleCommissionOverrides}
|
||||||
|
orgMembers={orgMembers}
|
||||||
onSave={(memberId, jobCommission) => {
|
onSave={(memberId, jobCommission) => {
|
||||||
onUpdateMember(project.id, memberId, { jobCommission });
|
onUpdateMember(project.id, memberId, { jobCommission });
|
||||||
setEditingCommission(null);
|
setEditingCommission(null);
|
||||||
@@ -638,8 +650,8 @@ const RemoveConfirmModal = ({ member, projectName, onConfirm, onClose }) => (
|
|||||||
|
|
||||||
// ─── Edit Commission Modal ─────────────────────────────────────────────────────
|
// ─── Edit Commission Modal ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
const EditCommissionModal = ({ member, orgDefaults, userOverrides, onSave, onClear, onClose }) => {
|
const EditCommissionModal = ({ member, orgDefaults, userOverrides, roleOverrides, orgMembers, onSave, onClear, onClose }) => {
|
||||||
const resolved = resolveCommissionForMember(member, userOverrides, orgDefaults);
|
const resolved = resolveCommissionForMember(member, userOverrides, roleOverrides, orgMembers, orgDefaults);
|
||||||
const hasJobOverride = !!member.jobCommission;
|
const hasJobOverride = !!member.jobCommission;
|
||||||
|
|
||||||
const [type, setType] = useState(hasJobOverride ? member.jobCommission.type : resolved.type);
|
const [type, setType] = useState(hasJobOverride ? member.jobCommission.type : resolved.type);
|
||||||
@@ -717,7 +729,7 @@ const EditCommissionModal = ({ member, orgDefaults, userOverrides, onSave, onCle
|
|||||||
onClick={() => onClear(member.id)}
|
onClick={() => onClear(member.id)}
|
||||||
className="w-full py-2 text-xs font-semibold text-zinc-500 hover:text-red-500 transition-colors"
|
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'})
|
Clear job override (revert to {COMMISSION_SOURCE_CFG[resolveCommissionForMember({ ...member, jobCommission: null }, userOverrides, roleOverrides, orgMembers, orgDefaults).source]?.label || 'default'})
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5274,6 +5274,24 @@ export const MockStoreProvider = ({ children }) => {
|
|||||||
updateProject: (projectId, data) => {
|
updateProject: (projectId, data) => {
|
||||||
setProjects(prev => prev.map(p => p.id === projectId ? { ...p, ...data } : p));
|
setProjects(prev => prev.map(p => p.id === projectId ? { ...p, ...data } : p));
|
||||||
},
|
},
|
||||||
|
addJobTeamMember: (projectId, member) => {
|
||||||
|
setProjects(prev => prev.map(p => p.id === projectId
|
||||||
|
? { ...p, teamMembers: [...(p.teamMembers || []), { ...member, id: `jtm_${Date.now()}` }] }
|
||||||
|
: p
|
||||||
|
));
|
||||||
|
},
|
||||||
|
removeJobTeamMember: (projectId, memberId) => {
|
||||||
|
setProjects(prev => prev.map(p => p.id === projectId
|
||||||
|
? { ...p, teamMembers: (p.teamMembers || []).filter(m => m.id !== memberId) }
|
||||||
|
: p
|
||||||
|
));
|
||||||
|
},
|
||||||
|
updateJobTeamMember: (projectId, memberId, data) => {
|
||||||
|
setProjects(prev => prev.map(p => p.id === projectId
|
||||||
|
? { ...p, teamMembers: (p.teamMembers || []).map(m => m.id === memberId ? { ...m, ...data } : m) }
|
||||||
|
: p
|
||||||
|
));
|
||||||
|
},
|
||||||
|
|
||||||
// Org Settings & Access Control
|
// Org Settings & Access Control
|
||||||
orgSettings,
|
orgSettings,
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import InvoiceDetailModal from '../../components/owner/InvoiceDetailModal';
|
|||||||
import CreateItemModal from '../../components/owner/CreateItemModal';
|
import CreateItemModal from '../../components/owner/CreateItemModal';
|
||||||
import CommissionSettingsModal from '../../components/owner/CommissionSettingsModal';
|
import CommissionSettingsModal from '../../components/owner/CommissionSettingsModal';
|
||||||
import { usePermissions } from '../../hooks/usePermissions';
|
import { usePermissions } from '../../hooks/usePermissions';
|
||||||
|
import { resolveCommission, findAssignedUserRoleKey } from '../../utils/commissionResolver';
|
||||||
|
|
||||||
// --- NEOMORPHIC STYLING CONSTANTS ---
|
// --- NEOMORPHIC STYLING CONSTANTS ---
|
||||||
const NEO_PANEL_CLASS = "dark:bg-zinc-900/60 dark:backdrop-blur-3xl border border-zinc-200 dark:border-white/5 dark:shadow-[8px_8px_16px_rgba(0,0,0,0.6),-8px_-8px_16px_rgba(255,255,255,0.02)] rounded-3xl relative overflow-hidden transition-all duration-300";
|
const NEO_PANEL_CLASS = "dark:bg-zinc-900/60 dark:backdrop-blur-3xl border border-zinc-200 dark:border-white/5 dark:shadow-[8px_8px_16px_rgba(0,0,0,0.6),-8px_-8px_16px_rgba(255,255,255,0.02)] rounded-3xl relative overflow-hidden transition-all duration-300";
|
||||||
@@ -138,6 +139,7 @@ const OwnerProjectDetail = () => {
|
|||||||
const {
|
const {
|
||||||
projects, vendors, personnel,
|
projects, vendors, personnel,
|
||||||
orgCommissionDefaults, userCommissionOverrides,
|
orgCommissionDefaults, userCommissionOverrides,
|
||||||
|
roleCommissionOverrides, orgMembers, orgRoles,
|
||||||
updateOrgCommissionDefaults, setUserCommissionOverride,
|
updateOrgCommissionDefaults, setUserCommissionOverride,
|
||||||
removeUserCommissionOverride, updateProject,
|
removeUserCommissionOverride, updateProject,
|
||||||
} = useMockStore();
|
} = useMockStore();
|
||||||
@@ -243,7 +245,7 @@ const OwnerProjectDetail = () => {
|
|||||||
projects.find(p => p.id === projectId && p.ownerId === user?.id)
|
projects.find(p => p.id === projectId && p.ownerId === user?.id)
|
||||||
, [projects, projectId, user]);
|
, [projects, projectId, user]);
|
||||||
|
|
||||||
// ── Commission hierarchy resolution: Job → User → Org ──────────────────
|
// ── Commission hierarchy: Job → Individual → Role → Org ──────────────
|
||||||
const jobCommissionOverride = useMemo(() => {
|
const jobCommissionOverride = useMemo(() => {
|
||||||
if (commissionConfig) return commissionConfig;
|
if (commissionConfig) return commissionConfig;
|
||||||
if (!project) return null;
|
if (!project) return null;
|
||||||
@@ -251,17 +253,20 @@ const OwnerProjectDetail = () => {
|
|||||||
return null;
|
return null;
|
||||||
}, [commissionConfig, project]);
|
}, [commissionConfig, project]);
|
||||||
|
|
||||||
const resolvedCommission = useMemo(() => {
|
const assignedUserId = project?.salesRepId || project?.contractorId;
|
||||||
// Priority: Job Override → User Override → Org Default
|
const assignedUserRoleKey = useMemo(
|
||||||
if (jobCommissionOverride) {
|
() => findAssignedUserRoleKey(assignedUserId, orgMembers),
|
||||||
return { source: 'job', type: jobCommissionOverride.type, rate: Number(jobCommissionOverride.rate) || 0 };
|
[assignedUserId, orgMembers]
|
||||||
}
|
);
|
||||||
const userOverride = userCommissionOverrides.find(o => o.enabled && o.userId === (project?.salesRepId || project?.contractorId));
|
|
||||||
if (userOverride) {
|
const resolvedCommission = useMemo(() => resolveCommission({
|
||||||
return { source: 'user', type: userOverride.type, rate: Number(userOverride.rate) || 0 };
|
jobOverride: jobCommissionOverride,
|
||||||
}
|
userId: assignedUserId,
|
||||||
return { source: 'org', type: orgCommissionDefaults.type, rate: Number(orgCommissionDefaults.rate) || 0 };
|
userCommissionOverrides,
|
||||||
}, [jobCommissionOverride, userCommissionOverrides, orgCommissionDefaults, project]);
|
orgMembers,
|
||||||
|
roleCommissionOverrides,
|
||||||
|
orgCommissionDefaults,
|
||||||
|
}), [jobCommissionOverride, assignedUserId, userCommissionOverrides, orgMembers, roleCommissionOverrides, orgCommissionDefaults]);
|
||||||
|
|
||||||
const activeCommissionConfig = resolvedCommission;
|
const activeCommissionConfig = resolvedCommission;
|
||||||
|
|
||||||
@@ -776,6 +781,7 @@ const OwnerProjectDetail = () => {
|
|||||||
const src = resolvedCommission.source;
|
const src = resolvedCommission.source;
|
||||||
const badgeCfg = {
|
const badgeCfg = {
|
||||||
org: { icon: Building2, label: 'Org Default', color: 'text-sky-500 dark:text-[#00f0ff]', bg: 'bg-sky-50 dark:bg-[#00f0ff]/10', border: 'border-sky-200 dark:border-[#00f0ff]/20' },
|
org: { icon: Building2, label: 'Org Default', color: 'text-sky-500 dark:text-[#00f0ff]', bg: 'bg-sky-50 dark:bg-[#00f0ff]/10', border: 'border-sky-200 dark:border-[#00f0ff]/20' },
|
||||||
|
role: { icon: Shield, label: 'Role Override', color: 'text-emerald-500 dark:text-emerald-400', bg: 'bg-emerald-50 dark:bg-emerald-500/10', border: 'border-emerald-200 dark:border-emerald-500/20' },
|
||||||
user: { icon: UserIcon, label: 'User Override', color: 'text-purple-500 dark:text-purple-400', bg: 'bg-purple-50 dark:bg-purple-500/10', border: 'border-purple-200 dark:border-purple-500/20' },
|
user: { icon: UserIcon, label: 'User Override', color: 'text-purple-500 dark:text-purple-400', bg: 'bg-purple-50 dark:bg-purple-500/10', border: 'border-purple-200 dark:border-purple-500/20' },
|
||||||
job: { icon: Briefcase, label: 'Job Override', color: 'text-amber-600 dark:text-[#fda913]', bg: 'bg-amber-50 dark:bg-[#fda913]/10', border: 'border-amber-200 dark:border-[#fda913]/20' },
|
job: { icon: Briefcase, label: 'Job Override', color: 'text-amber-600 dark:text-[#fda913]', bg: 'bg-amber-50 dark:bg-[#fda913]/10', border: 'border-amber-200 dark:border-[#fda913]/20' },
|
||||||
};
|
};
|
||||||
@@ -1845,6 +1851,9 @@ const OwnerProjectDetail = () => {
|
|||||||
onClose={() => setCommissionSettingsOpen(false)}
|
onClose={() => setCommissionSettingsOpen(false)}
|
||||||
orgDefaults={orgCommissionDefaults}
|
orgDefaults={orgCommissionDefaults}
|
||||||
userOverrides={userCommissionOverrides}
|
userOverrides={userCommissionOverrides}
|
||||||
|
roleOverrides={roleCommissionOverrides}
|
||||||
|
orgRoles={orgRoles}
|
||||||
|
assignedUserRoleKey={assignedUserRoleKey}
|
||||||
jobOverride={project?.commissionOverride || (commissionConfig ? { type: commissionConfig.type, rate: commissionConfig.rate } : null)}
|
jobOverride={project?.commissionOverride || (commissionConfig ? { type: commissionConfig.type, rate: commissionConfig.rate } : null)}
|
||||||
activeSource={resolvedCommission.source}
|
activeSource={resolvedCommission.source}
|
||||||
personnel={personnel}
|
personnel={personnel}
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ const OwnerSnapshot = () => {
|
|||||||
const {
|
const {
|
||||||
owners, projects, users, personnel,
|
owners, projects, users, personnel,
|
||||||
orgCommissionDefaults, userCommissionOverrides,
|
orgCommissionDefaults, userCommissionOverrides,
|
||||||
|
roleCommissionOverrides, orgMembers,
|
||||||
addJobTeamMember, removeJobTeamMember, updateJobTeamMember,
|
addJobTeamMember, removeJobTeamMember, updateJobTeamMember,
|
||||||
} = useMockStore();
|
} = useMockStore();
|
||||||
|
|
||||||
@@ -372,6 +373,8 @@ const OwnerSnapshot = () => {
|
|||||||
userRole={user?.role}
|
userRole={user?.role}
|
||||||
orgCommissionDefaults={orgCommissionDefaults}
|
orgCommissionDefaults={orgCommissionDefaults}
|
||||||
userCommissionOverrides={userCommissionOverrides}
|
userCommissionOverrides={userCommissionOverrides}
|
||||||
|
roleCommissionOverrides={roleCommissionOverrides}
|
||||||
|
orgMembers={orgMembers}
|
||||||
totalReceived={teamFinancials.totalReceived}
|
totalReceived={teamFinancials.totalReceived}
|
||||||
grossProfit={teamFinancials.grossProfit}
|
grossProfit={teamFinancials.grossProfit}
|
||||||
onAddMember={addJobTeamMember}
|
onAddMember={addJobTeamMember}
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
/**
|
||||||
|
* Centralized commission resolution — 4-level hierarchy:
|
||||||
|
* Job Override → Individual Override → Role Override → Org Default
|
||||||
|
*/
|
||||||
|
export function resolveCommission({
|
||||||
|
jobOverride,
|
||||||
|
userId,
|
||||||
|
userCommissionOverrides = [],
|
||||||
|
orgMembers = [],
|
||||||
|
roleCommissionOverrides = [],
|
||||||
|
orgCommissionDefaults = {},
|
||||||
|
}) {
|
||||||
|
// Level 1: Job Override (highest priority)
|
||||||
|
if (jobOverride) {
|
||||||
|
return { source: 'job', type: jobOverride.type, rate: Number(jobOverride.rate) || 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Level 2: Individual User Override
|
||||||
|
if (userId) {
|
||||||
|
const userOverride = userCommissionOverrides.find(o => o.enabled && o.userId === userId);
|
||||||
|
if (userOverride) {
|
||||||
|
return { source: 'user', type: userOverride.type, rate: Number(userOverride.rate) || 0 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Level 3: Role Override
|
||||||
|
if (userId) {
|
||||||
|
const member = orgMembers.find(m => m.userId === userId);
|
||||||
|
if (member) {
|
||||||
|
const roleOverride = roleCommissionOverrides.find(o => o.enabled && o.roleKey === member.roleKey);
|
||||||
|
if (roleOverride) {
|
||||||
|
return { source: 'role', type: roleOverride.type, rate: Number(roleOverride.rate) || 0, roleKey: member.roleKey };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Level 4: Org Default (lowest priority)
|
||||||
|
return { source: 'org', type: orgCommissionDefaults.type, rate: Number(orgCommissionDefaults.rate) || 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findAssignedUserRoleKey(userId, orgMembers = []) {
|
||||||
|
if (!userId) return null;
|
||||||
|
const member = orgMembers.find(m => m.userId === userId);
|
||||||
|
return member?.roleKey || null;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user