org level commission distribution for each member

This commit is contained in:
Satyam Rastogi
2026-05-14 13:24:16 +05:30
parent 42a1306aca
commit 45301dbabd
2 changed files with 200 additions and 0 deletions
@@ -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;