org level commission distribution for each member
This commit is contained in:
@@ -0,0 +1,180 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { DollarSign, Users } from 'lucide-react';
|
||||
|
||||
const COMMISSION_TYPES = {
|
||||
percent_gross: '% of Gross',
|
||||
percent_net: '% of Net',
|
||||
flat: 'Flat Amount',
|
||||
custom: 'Manual',
|
||||
};
|
||||
|
||||
const ROLE_COLORS = {
|
||||
'Project Manager': 'text-cyan-500 dark:text-cyan-400 bg-cyan-500/10 border-cyan-500/20',
|
||||
'Sales Rep': 'text-green-500 dark:text-green-400 bg-green-500/10 border-green-500/20',
|
||||
'Lead Installer': 'text-orange-500 dark:text-orange-400 bg-orange-500/10 border-orange-500/20',
|
||||
'Estimator': 'text-blue-500 dark:text-blue-400 bg-blue-500/10 border-blue-500/20',
|
||||
'Inspector': 'text-purple-500 dark:text-purple-400 bg-purple-500/10 border-purple-500/20',
|
||||
'Canvasser': 'text-teal-500 dark:text-teal-400 bg-teal-500/10 border-teal-500/20',
|
||||
'Subcontractor': 'text-rose-500 dark:text-rose-400 bg-rose-500/10 border-rose-500/20',
|
||||
'Supplier': 'text-yellow-500 dark:text-yellow-400 bg-yellow-500/10 border-yellow-500/20',
|
||||
};
|
||||
|
||||
function resolveCommission(member, userCommissionOverrides, roleCommissionOverrides, orgMembers, orgCommissionDefaults) {
|
||||
if (member.jobCommission) {
|
||||
return { type: member.jobCommission.type, rate: Number(member.jobCommission.rate) || 0 };
|
||||
}
|
||||
const userOvr = (userCommissionOverrides || []).find(o => o.enabled && o.userId === member.userId);
|
||||
if (userOvr) {
|
||||
return { type: userOvr.type, rate: Number(userOvr.rate) || 0 };
|
||||
}
|
||||
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 };
|
||||
}
|
||||
}
|
||||
return { type: orgCommissionDefaults?.type, rate: Number(orgCommissionDefaults?.rate) || 0 };
|
||||
}
|
||||
|
||||
function calcValue(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;
|
||||
}
|
||||
}
|
||||
|
||||
function getProjectFinancials(project) {
|
||||
const paid = (project.paymentSchedule || [])
|
||||
.filter(ps => ps.status === 'paid')
|
||||
.reduce((s, ps) => s + (ps.amount || 0), 0);
|
||||
const totalReceived = paid || (project.budget || 0);
|
||||
const totalCosts = Number(project.actualCost ?? project.spent) || 0;
|
||||
return { totalReceived, grossProfit: totalReceived - totalCosts };
|
||||
}
|
||||
|
||||
const fmt = (amt) =>
|
||||
new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', maximumFractionDigits: 0 }).format(Number(amt) || 0);
|
||||
|
||||
const CommissionDistributionPanel = ({
|
||||
ownerProjects,
|
||||
orgCommissionDefaults,
|
||||
userCommissionOverrides,
|
||||
roleCommissionOverrides,
|
||||
orgMembers,
|
||||
}) => {
|
||||
const rows = useMemo(() => {
|
||||
const entries = [];
|
||||
for (const project of ownerProjects) {
|
||||
const members = (project.teamMembers || []).filter(m => m.status === 'active');
|
||||
if (members.length === 0) continue;
|
||||
const fin = getProjectFinancials(project);
|
||||
for (const m of members) {
|
||||
const resolved = resolveCommission(m, userCommissionOverrides, roleCommissionOverrides, orgMembers, orgCommissionDefaults);
|
||||
const amount = calcValue(resolved, fin.totalReceived, fin.grossProfit);
|
||||
entries.push({
|
||||
key: `${project.id}_${m.id}`,
|
||||
name: m.name,
|
||||
role: m.jobRole,
|
||||
amount,
|
||||
projectId: project.id,
|
||||
projectLabel: project.projectType,
|
||||
commissionType: COMMISSION_TYPES[resolved.type] || resolved.type || '—',
|
||||
});
|
||||
}
|
||||
}
|
||||
return entries;
|
||||
}, [ownerProjects, userCommissionOverrides, roleCommissionOverrides, orgMembers, orgCommissionDefaults]);
|
||||
|
||||
const total = useMemo(() => rows.reduce((s, r) => s + r.amount, 0), [rows]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-3">
|
||||
<DollarSign className="text-emerald-500 dark:text-emerald-400 drop-shadow-[0_0_8px_rgba(16,185,129,0.4)]" size={20} />
|
||||
<h3 className="text-sm font-mono font-bold text-zinc-900 dark:text-white tracking-widest uppercase">
|
||||
Commission Distribution
|
||||
</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">
|
||||
{rows.length} entr{rows.length === 1 ? 'y' : 'ies'} across {ownerProjects.length} project{ownerProjects.length !== 1 ? 's' : ''}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{rows.length === 0 ? (
|
||||
<div className="py-10 text-center">
|
||||
<Users size={28} className="mx-auto mb-3 text-zinc-400" />
|
||||
<p className="text-sm text-zinc-500">No commission recipients across your projects.</p>
|
||||
<p className="text-xs text-zinc-400 mt-1">Add team members with commission rules to see the distribution.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto rounded-xl border border-zinc-200 dark:border-white/5">
|
||||
<table className="w-full text-left min-w-[600px]">
|
||||
<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">Name</th>
|
||||
<th className="px-4 py-2.5 text-[10px] font-mono font-bold text-zinc-500 uppercase tracking-widest">Role</th>
|
||||
<th className="px-4 py-2.5 text-[10px] font-mono font-bold text-zinc-500 uppercase tracking-widest text-right">Amount</th>
|
||||
<th className="px-4 py-2.5 text-[10px] font-mono font-bold text-zinc-500 uppercase tracking-widest">Project ID</th>
|
||||
<th className="px-4 py-2.5 text-[10px] font-mono font-bold text-zinc-500 uppercase tracking-widest">Commission Type</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row) => (
|
||||
<tr key={row.key} 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.5">
|
||||
<div className="w-7 h-7 rounded-full bg-emerald-500/15 flex items-center justify-center text-[10px] font-black text-emerald-600 dark:text-emerald-400 flex-shrink-0">
|
||||
{row.name?.[0] || '?'}
|
||||
</div>
|
||||
<span className="text-xs font-semibold text-zinc-900 dark:text-white truncate max-w-[140px]">{row.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 whitespace-nowrap ${ROLE_COLORS[row.role] || 'text-zinc-400 bg-zinc-500/10 border-zinc-500/20'}`}>
|
||||
{row.role}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
<span className="font-mono text-xs font-bold text-green-600 dark:text-[#39ff14]">
|
||||
{fmt(row.amount)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className="text-[11px] font-mono text-zinc-500 dark:text-zinc-400">
|
||||
{row.projectId}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className="text-xs text-zinc-600 dark:text-zinc-400 whitespace-nowrap">
|
||||
{row.commissionType}
|
||||
</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={2} className="px-4 py-3 text-[10px] font-mono font-bold text-zinc-500 uppercase tracking-widest">
|
||||
Total Commission Amount
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
<span className="font-mono text-sm font-extrabold text-green-600 dark:text-[#39ff14]">
|
||||
{fmt(total)}
|
||||
</span>
|
||||
</td>
|
||||
<td colSpan={2} />
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CommissionDistributionPanel;
|
||||
@@ -7,6 +7,7 @@ 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 CommissionDistributionPanel from '../../components/owner/CommissionDistributionPanel';
|
||||
import { SpotlightCard } from '../../components/SpotlightCard';
|
||||
import {
|
||||
BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, PieChart, Pie, Cell, Legend
|
||||
@@ -384,6 +385,25 @@ const OwnerSnapshot = () => {
|
||||
</SpotlightCard>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* ── Owner's Box — Org-Wide Commission Distribution ── */}
|
||||
{ownerProjects.length > 0 && (
|
||||
<section>
|
||||
<h2 className="text-xl font-bold text-zinc-900 dark:text-white/90 mb-4 flex items-center">
|
||||
<span className="w-2 h-8 bg-emerald-500 rounded-full mr-3"></span>
|
||||
Organization Commission Overview
|
||||
</h2>
|
||||
<SpotlightCard className="p-5 lg:p-6" spotlightColor="rgba(16, 185, 129, 0.06)">
|
||||
<CommissionDistributionPanel
|
||||
ownerProjects={ownerProjects}
|
||||
orgCommissionDefaults={orgCommissionDefaults}
|
||||
userCommissionOverrides={userCommissionOverrides}
|
||||
roleCommissionOverrides={roleCommissionOverrides}
|
||||
orgMembers={orgMembers}
|
||||
/>
|
||||
</SpotlightCard>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Interactive Modals */}
|
||||
|
||||
Reference in New Issue
Block a user