-
- {['org', 'user', 'job'].map((level, i) => {
+
+ {['org', 'role', 'user', 'job'].map((level, i) => {
const cfg = LEVEL_CONFIG[level];
const Icon = cfg.icon;
const isActive = activeSource === level;
@@ -282,6 +287,67 @@ const CommissionSettingsModal = ({
+ {/* ── ROLE OVERRIDES ── */}
+
+
toggleSection('role')} />
+
+ {openSections.role && (
+
+
+ {roleOverrides.length === 0 ? (
+
No role overrides configured.
+ ) : (
+ 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 (
+
+
+
+ {role?.name || ov.roleKey}
+
+ {typeLabel} — {rateDisplay}
+
+
+
+ {ov.enabled ? (
+ Active
+ ) : (
+ Disabled
+ )}
+ {isApplicable && (
+ Applies
+ )}
+
+
+ );
+ })
+ )}
+
+
+
Role overrides are managed in Org Settings → Commission Rules.
+
+
+
+ )}
+
+
+
{/* ── USER OVERRIDES ── */}
toggleSection('user')} />
@@ -466,8 +532,8 @@ const CommissionSettingsModal = ({
-
Override Priority: Job Override → User Override → Org Default
-
The most specific level always takes precedence. If a job override is set it will be used regardless of user or org settings.
+
Override Priority: Job Override → User Override → Role Override → Org Default
+
The most specific level always takes precedence. If a job override is set it will be used regardless of user, role, or org settings.
diff --git a/src/components/owner/TeamManagementPanel.jsx b/src/components/owner/TeamManagementPanel.jsx
index 0a87ae9..0702fe4 100644
--- a/src/components/owner/TeamManagementPanel.jsx
+++ b/src/components/owner/TeamManagementPanel.jsx
@@ -27,6 +27,7 @@ const COMMISSION_TYPES = {
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' },
+ 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' },
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',
};
-function resolveCommissionForMember(member, userCommissionOverrides, orgCommissionDefaults) {
+function resolveCommissionForMember(member, userCommissionOverrides, roleCommissionOverrides, orgMembers, orgCommissionDefaults) {
if (member.jobCommission) {
return { type: member.jobCommission.type, rate: Number(member.jobCommission.rate) || 0, source: 'job' };
}
@@ -50,6 +51,13 @@ function resolveCommissionForMember(member, userCommissionOverrides, orgCommissi
if (userOvr) {
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' };
}
@@ -75,6 +83,8 @@ const TeamManagementPanel = ({
userRole,
orgCommissionDefaults,
userCommissionOverrides,
+ roleCommissionOverrides = [],
+ orgMembers = [],
totalReceived = 0,
grossProfit = 0,
onAddMember,
@@ -114,11 +124,11 @@ const TeamManagementPanel = ({
const memberCommissions = useMemo(() => {
return visibleMembers.map(m => {
- const resolved = resolveCommissionForMember(m, userCommissionOverrides, orgCommissionDefaults);
+ const resolved = resolveCommissionForMember(m, userCommissionOverrides, roleCommissionOverrides, orgMembers, orgCommissionDefaults);
const value = calcCommissionValue(resolved, totalReceived, grossProfit);
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 pendingMembers = visibleMembers.filter(m => m.status === 'pending');
@@ -404,6 +414,8 @@ const TeamManagementPanel = ({
member={editingCommission}
orgDefaults={orgCommissionDefaults}
userOverrides={userCommissionOverrides}
+ roleOverrides={roleCommissionOverrides}
+ orgMembers={orgMembers}
onSave={(memberId, jobCommission) => {
onUpdateMember(project.id, memberId, { jobCommission });
setEditingCommission(null);
@@ -638,8 +650,8 @@ const RemoveConfirmModal = ({ member, projectName, onConfirm, onClose }) => (
// ─── Edit Commission Modal ─────────────────────────────────────────────────────
-const EditCommissionModal = ({ member, orgDefaults, userOverrides, onSave, onClear, onClose }) => {
- const resolved = resolveCommissionForMember(member, userOverrides, orgDefaults);
+const EditCommissionModal = ({ member, orgDefaults, userOverrides, roleOverrides, orgMembers, onSave, onClear, onClose }) => {
+ const resolved = resolveCommissionForMember(member, userOverrides, roleOverrides, orgMembers, orgDefaults);
const hasJobOverride = !!member.jobCommission;
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)}
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'})
)}
diff --git a/src/data/mockStore.jsx b/src/data/mockStore.jsx
index d08a748..74a6727 100644
--- a/src/data/mockStore.jsx
+++ b/src/data/mockStore.jsx
@@ -5274,6 +5274,24 @@ export const MockStoreProvider = ({ children }) => {
updateProject: (projectId, data) => {
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
orgSettings,
diff --git a/src/pages/owner/OwnerProjectDetail.jsx b/src/pages/owner/OwnerProjectDetail.jsx
index 62e6e67..f89007c 100644
--- a/src/pages/owner/OwnerProjectDetail.jsx
+++ b/src/pages/owner/OwnerProjectDetail.jsx
@@ -19,6 +19,7 @@ import InvoiceDetailModal from '../../components/owner/InvoiceDetailModal';
import CreateItemModal from '../../components/owner/CreateItemModal';
import CommissionSettingsModal from '../../components/owner/CommissionSettingsModal';
import { usePermissions } from '../../hooks/usePermissions';
+import { resolveCommission, findAssignedUserRoleKey } from '../../utils/commissionResolver';
// --- 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";
@@ -138,6 +139,7 @@ const OwnerProjectDetail = () => {
const {
projects, vendors, personnel,
orgCommissionDefaults, userCommissionOverrides,
+ roleCommissionOverrides, orgMembers, orgRoles,
updateOrgCommissionDefaults, setUserCommissionOverride,
removeUserCommissionOverride, updateProject,
} = useMockStore();
@@ -243,7 +245,7 @@ const OwnerProjectDetail = () => {
projects.find(p => p.id === projectId && p.ownerId === user?.id)
, [projects, projectId, user]);
- // ── Commission hierarchy resolution: Job → User → Org ──────────────────
+ // ── Commission hierarchy: Job → Individual → Role → Org ──────────────
const jobCommissionOverride = useMemo(() => {
if (commissionConfig) return commissionConfig;
if (!project) return null;
@@ -251,17 +253,20 @@ const OwnerProjectDetail = () => {
return null;
}, [commissionConfig, project]);
- const resolvedCommission = useMemo(() => {
- // Priority: Job Override → User Override → Org Default
- if (jobCommissionOverride) {
- return { source: 'job', type: jobCommissionOverride.type, rate: Number(jobCommissionOverride.rate) || 0 };
- }
- const userOverride = userCommissionOverrides.find(o => o.enabled && o.userId === (project?.salesRepId || project?.contractorId));
- if (userOverride) {
- return { source: 'user', type: userOverride.type, rate: Number(userOverride.rate) || 0 };
- }
- return { source: 'org', type: orgCommissionDefaults.type, rate: Number(orgCommissionDefaults.rate) || 0 };
- }, [jobCommissionOverride, userCommissionOverrides, orgCommissionDefaults, project]);
+ const assignedUserId = project?.salesRepId || project?.contractorId;
+ const assignedUserRoleKey = useMemo(
+ () => findAssignedUserRoleKey(assignedUserId, orgMembers),
+ [assignedUserId, orgMembers]
+ );
+
+ const resolvedCommission = useMemo(() => resolveCommission({
+ jobOverride: jobCommissionOverride,
+ userId: assignedUserId,
+ userCommissionOverrides,
+ orgMembers,
+ roleCommissionOverrides,
+ orgCommissionDefaults,
+ }), [jobCommissionOverride, assignedUserId, userCommissionOverrides, orgMembers, roleCommissionOverrides, orgCommissionDefaults]);
const activeCommissionConfig = resolvedCommission;
@@ -776,6 +781,7 @@ const OwnerProjectDetail = () => {
const src = resolvedCommission.source;
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' },
+ 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' },
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)}
orgDefaults={orgCommissionDefaults}
userOverrides={userCommissionOverrides}
+ roleOverrides={roleCommissionOverrides}
+ orgRoles={orgRoles}
+ assignedUserRoleKey={assignedUserRoleKey}
jobOverride={project?.commissionOverride || (commissionConfig ? { type: commissionConfig.type, rate: commissionConfig.rate } : null)}
activeSource={resolvedCommission.source}
personnel={personnel}
diff --git a/src/pages/owner/OwnerSnapshot.jsx b/src/pages/owner/OwnerSnapshot.jsx
index a8c4e3c..cf04d49 100644
--- a/src/pages/owner/OwnerSnapshot.jsx
+++ b/src/pages/owner/OwnerSnapshot.jsx
@@ -21,6 +21,7 @@ const OwnerSnapshot = () => {
const {
owners, projects, users, personnel,
orgCommissionDefaults, userCommissionOverrides,
+ roleCommissionOverrides, orgMembers,
addJobTeamMember, removeJobTeamMember, updateJobTeamMember,
} = useMockStore();
@@ -372,6 +373,8 @@ const OwnerSnapshot = () => {
userRole={user?.role}
orgCommissionDefaults={orgCommissionDefaults}
userCommissionOverrides={userCommissionOverrides}
+ roleCommissionOverrides={roleCommissionOverrides}
+ orgMembers={orgMembers}
totalReceived={teamFinancials.totalReceived}
grossProfit={teamFinancials.grossProfit}
onAddMember={addJobTeamMember}
diff --git a/src/utils/commissionResolver.js b/src/utils/commissionResolver.js
new file mode 100644
index 0000000..a359460
--- /dev/null
+++ b/src/utils/commissionResolver.js
@@ -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;
+}