configurable commission setting

This commit is contained in:
Satyam-Rastogi
2026-05-12 15:54:01 +05:30
parent d32b3ced21
commit c82cadd547
3 changed files with 626 additions and 25 deletions
@@ -0,0 +1,499 @@
import React, { useState, useEffect, useMemo } from 'react';
import { createPortal } from 'react-dom';
import {
X, DollarSign, Building2, User, Briefcase, ChevronDown,
Plus, Trash2, ToggleLeft, ToggleRight, Info, Shield, ArrowRight
} from 'lucide-react';
import { motion, AnimatePresence } from 'framer-motion';
const COMMISSION_TYPES = [
{ value: 'flat', label: 'Flat Amount', hint: 'Fixed dollar amount' },
{ value: 'percent_gross', label: '% of Gross', hint: 'Percentage of Total Received' },
{ value: 'percent_net', label: '% of Net', hint: 'Percentage of Gross Profit' },
{ value: 'custom', label: 'Custom / Manual', hint: 'Manually entered value' },
];
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)' },
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)' },
};
const formatCurrency = (amt) =>
new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', maximumFractionDigits: 0 }).format(Number(amt) || 0);
const SectionHeader = ({ level, isOpen, onToggle }) => {
const cfg = LEVEL_CONFIG[level];
const Icon = cfg.icon;
return (
<button
type="button"
onClick={onToggle}
className="w-full flex items-center justify-between px-5 py-4 rounded-2xl border transition-all duration-200 hover:brightness-110"
style={{ backgroundColor: cfg.bg, borderColor: cfg.border }}
>
<div className="flex items-center gap-3">
<div className="p-2 rounded-xl" style={{ backgroundColor: cfg.bg }}>
<Icon size={18} style={{ color: cfg.color }} />
</div>
<div className="text-left">
<h3 className="text-sm font-bold text-white tracking-widest uppercase">{cfg.label}</h3>
<p className="text-[10px] text-zinc-500 mt-0.5">
{level === 'org' && 'Default commission for all jobs'}
{level === 'user' && 'Per-user commission overrides'}
{level === 'job' && 'Override commission for this specific job'}
</p>
</div>
</div>
<ChevronDown
size={16}
className="text-zinc-400 transition-transform duration-200"
style={{ transform: isOpen ? 'rotate(180deg)' : 'rotate(0deg)' }}
/>
</button>
);
};
const CommissionForm = ({ type, rate, onTypeChange, onRateChange, accentColor = '#fda913' }) => (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div>
<label className="text-[10px] font-bold uppercase tracking-wider text-zinc-500 block mb-1.5">Type</label>
<select
value={type}
onChange={e => onTypeChange(e.target.value)}
className="w-full bg-[#18181b] border border-white/10 rounded-xl px-4 py-2.5 text-sm text-white outline-none transition-all"
style={{ '--tw-ring-color': accentColor }}
>
{COMMISSION_TYPES.map(ct => (
<option key={ct.value} value={ct.value}>{ct.label}</option>
))}
</select>
</div>
<div>
<label className="text-[10px] font-bold uppercase tracking-wider text-zinc-500 block mb-1.5">
{type === 'percent_gross' || type === 'percent_net' ? 'Rate (%)' : 'Amount ($)'}
</label>
<div className="relative">
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-sm text-zinc-500 pointer-events-none">
{type === 'percent_gross' || type === 'percent_net' ? '%' : '$'}
</span>
<input
type="number"
step="any"
min="0"
value={rate}
onChange={e => onRateChange(e.target.value)}
placeholder={type === 'percent_gross' || type === 'percent_net' ? 'e.g. 10' : 'e.g. 5000'}
className="w-full bg-[#18181b] border border-white/10 rounded-xl pl-8 pr-4 py-2.5 text-sm text-white placeholder-zinc-600 outline-none transition-all"
/>
</div>
</div>
</div>
);
const CommissionSettingsModal = ({
isOpen,
onClose,
orgDefaults,
userOverrides,
jobOverride,
activeSource,
personnel,
onSaveOrgDefaults,
onSaveUserOverride,
onRemoveUserOverride,
onSaveJobOverride,
onClearJobOverride,
userRole,
}) => {
const [openSections, setOpenSections] = useState({ org: true, user: false, job: true });
const [localOrgDefaults, setLocalOrgDefaults] = useState({ type: 'percent_gross', rate: 10 });
const [localJobOverride, setLocalJobOverride] = useState({ enabled: false, type: 'flat', rate: 0 });
const [localUserOverrides, setLocalUserOverrides] = useState([]);
const [addingUser, setAddingUser] = useState(false);
const [newUserOverride, setNewUserOverride] = useState({ userId: '', type: 'flat', rate: 0 });
useEffect(() => {
if (isOpen) {
setLocalOrgDefaults({ ...orgDefaults });
setLocalUserOverrides(userOverrides.map(o => ({ ...o })));
setLocalJobOverride(
jobOverride
? { enabled: true, ...jobOverride }
: { enabled: false, type: orgDefaults.type, rate: 0 }
);
setAddingUser(false);
setNewUserOverride({ userId: '', type: 'flat', rate: 0 });
}
}, [isOpen, orgDefaults, userOverrides, jobOverride]);
useEffect(() => {
const handleEsc = (e) => { if (e.key === 'Escape') onClose(); };
if (isOpen) window.addEventListener('keydown', handleEsc);
return () => window.removeEventListener('keydown', handleEsc);
}, [isOpen, onClose]);
const availablePersonnel = useMemo(() => {
const overriddenIds = new Set(localUserOverrides.map(o => o.userId));
return (personnel || []).filter(p => !overriddenIds.has(p.id));
}, [personnel, localUserOverrides]);
const isAdmin = userRole === 'OWNER' || userRole === 'ADMIN';
const toggleSection = (key) => setOpenSections(prev => ({ ...prev, [key]: !prev[key] }));
const handleSave = () => {
if (isAdmin) {
onSaveOrgDefaults(localOrgDefaults);
localUserOverrides.forEach(o => onSaveUserOverride(o));
const removedIds = userOverrides
.filter(o => !localUserOverrides.find(lo => lo.userId === o.userId))
.map(o => o.userId);
removedIds.forEach(id => onRemoveUserOverride(id));
}
if (localJobOverride.enabled) {
onSaveJobOverride({ type: localJobOverride.type, rate: Number(localJobOverride.rate) || 0 });
} else {
onClearJobOverride();
}
onClose();
};
const handleAddUserOverride = () => {
if (!newUserOverride.userId) return;
const person = (personnel || []).find(p => p.id === newUserOverride.userId);
if (!person) return;
setLocalUserOverrides(prev => [
...prev,
{ userId: person.id, userName: person.name, enabled: true, type: newUserOverride.type, rate: Number(newUserOverride.rate) || 0 }
]);
setAddingUser(false);
setNewUserOverride({ userId: '', type: 'flat', rate: 0 });
};
if (!isOpen) return null;
return createPortal(
<div className="fixed inset-0 z-[9999] flex items-center justify-center p-4" role="dialog" aria-modal="true">
<div className="absolute inset-0 bg-black/70 backdrop-blur-sm" onClick={onClose} />
<motion.div
initial={{ opacity: 0, scale: 0.95, y: 10 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95 }}
transition={{ duration: 0.2 }}
className="relative w-full max-w-2xl max-h-[90vh] bg-[#121214] rounded-2xl shadow-[0_0_60px_rgba(0,0,0,0.9)] border border-white/10 overflow-hidden flex flex-col"
>
{/* Header */}
<div className="px-6 py-5 border-b border-white/10 flex justify-between items-center bg-white/[0.03] shrink-0">
<div className="flex items-center gap-3">
<div className="p-2 rounded-xl bg-amber-500/10">
<DollarSign size={20} className="text-[#fda913]" />
</div>
<div>
<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 &rarr; User &rarr; Org</p>
</div>
</div>
<button onClick={onClose} className="p-2 rounded-lg hover:bg-white/10 text-zinc-500 hover:text-white transition-colors">
<X size={18} />
</button>
</div>
{/* Override Priority Visualization */}
<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">
{['org', 'user', 'job'].map((level, i) => {
const cfg = LEVEL_CONFIG[level];
const Icon = cfg.icon;
const isActive = activeSource === level;
return (
<React.Fragment key={level}>
{i > 0 && <ArrowRight size={12} className="text-zinc-600 mx-1" />}
<div
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-full border transition-all ${
isActive
? 'ring-1 shadow-lg'
: 'opacity-40'
}`}
style={{
backgroundColor: cfg.bg,
borderColor: isActive ? cfg.color : cfg.border,
...(isActive ? { boxShadow: `0 0 12px ${cfg.color}40`, ringColor: cfg.color } : {}),
}}
>
<Icon size={12} style={{ color: cfg.color }} />
<span style={{ color: cfg.color }}>{cfg.label}</span>
{isActive && (
<span className="ml-1 w-1.5 h-1.5 rounded-full animate-pulse" style={{ backgroundColor: cfg.color }} />
)}
</div>
</React.Fragment>
);
})}
</div>
<p className="text-center text-[10px] text-zinc-600 mt-2">
Currently using: <span className="font-bold" style={{ color: LEVEL_CONFIG[activeSource]?.color || '#fda913' }}>
{LEVEL_CONFIG[activeSource]?.label || 'Org Default'}
</span>
</p>
</div>
{/* Scrollable Content */}
<div className="flex-1 overflow-y-auto px-6 py-4 space-y-3 custom-scrollbar">
{/* ── ORG DEFAULTS ── */}
<div>
<SectionHeader level="org" isOpen={openSections.org} onToggle={() => toggleSection('org')} />
<AnimatePresence>
{openSections.org && (
<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(0,240,255,0.2)' }}>
{isAdmin ? (
<CommissionForm
type={localOrgDefaults.type}
rate={localOrgDefaults.rate}
onTypeChange={v => setLocalOrgDefaults(prev => ({ ...prev, type: v, rate: '' }))}
onRateChange={v => setLocalOrgDefaults(prev => ({ ...prev, rate: v }))}
accentColor="#00f0ff"
/>
) : (
<div className="flex items-center gap-2 px-4 py-3 bg-white/[0.03] rounded-xl border border-white/5">
<Shield size={14} className="text-zinc-500" />
<span className="text-xs text-zinc-400">
{COMMISSION_TYPES.find(c => c.value === orgDefaults.type)?.label} {
orgDefaults.type === 'percent_gross' || orgDefaults.type === 'percent_net'
? `${orgDefaults.rate}%`
: formatCurrency(orgDefaults.rate)
}
</span>
<span className="text-[10px] text-zinc-600 ml-auto">(Admin only)</span>
</div>
)}
</div>
</motion.div>
)}
</AnimatePresence>
</div>
{/* ── USER OVERRIDES ── */}
<div>
<SectionHeader level="user" isOpen={openSections.user} onToggle={() => toggleSection('user')} />
<AnimatePresence>
{openSections.user && (
<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(168,85,247,0.2)' }}>
{!isAdmin ? (
<div className="flex items-center gap-2 px-4 py-3 bg-white/[0.03] rounded-xl border border-white/5">
<Shield size={14} className="text-zinc-500" />
<span className="text-xs text-zinc-400">{localUserOverrides.length} user override(s) configured</span>
<span className="text-[10px] text-zinc-600 ml-auto">(Admin only)</span>
</div>
) : (
<>
{localUserOverrides.length === 0 && !addingUser && (
<p className="text-xs text-zinc-600 italic px-2">No user overrides configured.</p>
)}
{localUserOverrides.map((ov, idx) => (
<div key={ov.userId} className="bg-white/[0.03] border border-white/5 rounded-xl p-4 space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<User size={14} className="text-purple-400" />
<span className="text-sm font-bold text-white">{ov.userName}</span>
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => {
setLocalUserOverrides(prev =>
prev.map((o, i) => i === idx ? { ...o, enabled: !o.enabled } : o)
);
}}
className="p-1 rounded-lg hover:bg-white/10 transition-colors"
title={ov.enabled ? 'Disable override' : 'Enable override'}
>
{ov.enabled
? <ToggleRight size={20} className="text-purple-400" />
: <ToggleLeft size={20} className="text-zinc-600" />
}
</button>
<button
type="button"
onClick={() => setLocalUserOverrides(prev => prev.filter((_, i) => i !== idx))}
className="p-1 rounded-lg hover:bg-red-500/10 text-zinc-600 hover:text-red-400 transition-colors"
>
<Trash2 size={14} />
</button>
</div>
</div>
{ov.enabled && (
<CommissionForm
type={ov.type}
rate={ov.rate}
onTypeChange={v => setLocalUserOverrides(prev =>
prev.map((o, i) => i === idx ? { ...o, type: v, rate: '' } : o)
)}
onRateChange={v => setLocalUserOverrides(prev =>
prev.map((o, i) => i === idx ? { ...o, rate: v } : o)
)}
accentColor="#a855f7"
/>
)}
{!ov.enabled && (
<p className="text-[10px] text-zinc-600 italic">Override disabled org default applies</p>
)}
</div>
))}
{/* Add New User Override */}
{addingUser ? (
<div className="bg-purple-500/5 border border-purple-500/20 rounded-xl p-4 space-y-3">
<div>
<label className="text-[10px] font-bold uppercase tracking-wider text-zinc-500 block mb-1.5">Select User</label>
<select
value={newUserOverride.userId}
onChange={e => setNewUserOverride(prev => ({ ...prev, userId: e.target.value }))}
className="w-full bg-[#18181b] border border-white/10 rounded-xl px-4 py-2.5 text-sm text-white outline-none"
>
<option value=""> Select </option>
{availablePersonnel.map(p => (
<option key={p.id} value={p.id}>{p.name} ({p.role})</option>
))}
</select>
</div>
<CommissionForm
type={newUserOverride.type}
rate={newUserOverride.rate}
onTypeChange={v => setNewUserOverride(prev => ({ ...prev, type: v, rate: '' }))}
onRateChange={v => setNewUserOverride(prev => ({ ...prev, rate: v }))}
accentColor="#a855f7"
/>
<div className="flex gap-2 pt-1">
<button
type="button"
onClick={() => setAddingUser(false)}
className="flex-1 px-4 py-2 rounded-xl border border-white/10 text-zinc-400 text-xs font-semibold hover:bg-white/5 transition-colors"
>
Cancel
</button>
<button
type="button"
onClick={handleAddUserOverride}
disabled={!newUserOverride.userId}
className="flex-1 px-4 py-2 rounded-xl bg-purple-500/20 text-purple-300 border border-purple-500/30 text-xs font-bold hover:bg-purple-500/30 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
>
Add Override
</button>
</div>
</div>
) : (
<button
type="button"
onClick={() => setAddingUser(true)}
className="w-full flex items-center justify-center gap-2 px-4 py-2.5 rounded-xl border border-dashed border-purple-500/30 text-purple-400 text-xs font-bold hover:bg-purple-500/5 transition-colors"
>
<Plus size={14} /> Add User Override
</button>
)}
</>
)}
</div>
</motion.div>
)}
</AnimatePresence>
</div>
{/* ── JOB OVERRIDE ── */}
<div>
<SectionHeader level="job" isOpen={openSections.job} onToggle={() => toggleSection('job')} />
<AnimatePresence>
{openSections.job && (
<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(253,169,19,0.2)' }}>
<div className="flex items-center justify-between">
<span className="text-xs text-zinc-400">Enable job-level override</span>
<button
type="button"
onClick={() => setLocalJobOverride(prev => ({ ...prev, enabled: !prev.enabled }))}
className="p-1 rounded-lg hover:bg-white/10 transition-colors"
>
{localJobOverride.enabled
? <ToggleRight size={24} className="text-[#fda913]" />
: <ToggleLeft size={24} className="text-zinc-600" />
}
</button>
</div>
{localJobOverride.enabled ? (
<CommissionForm
type={localJobOverride.type}
rate={localJobOverride.rate}
onTypeChange={v => setLocalJobOverride(prev => ({ ...prev, type: v, rate: '' }))}
onRateChange={v => setLocalJobOverride(prev => ({ ...prev, rate: v }))}
accentColor="#fda913"
/>
) : (
<p className="text-[10px] text-zinc-600 italic px-2">
No job override falls back to {activeSource === 'user' ? 'user override' : 'org default'}
</p>
)}
</div>
</motion.div>
)}
</AnimatePresence>
</div>
{/* Hierarchy Info */}
<div className="mt-2 p-4 bg-white/[0.02] rounded-xl border border-white/5">
<div className="flex items-start gap-2">
<Info size={14} className="text-zinc-600 mt-0.5 shrink-0" />
<div className="text-[11px] text-zinc-500 space-y-1">
<p><span className="font-bold text-zinc-400">Override Priority:</span> Job Override &rarr; User Override &rarr; 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>
</div>
</div>
</div>
</div>
{/* Footer */}
<div className="px-6 py-4 border-t border-white/10 flex items-center justify-between bg-white/[0.02] shrink-0">
<button
type="button"
onClick={onClose}
className="px-5 py-2.5 rounded-xl border border-white/10 text-zinc-400 text-sm font-semibold hover:bg-white/5 transition-colors"
>
Cancel
</button>
<button
type="button"
onClick={handleSave}
className="px-6 py-2.5 rounded-xl bg-[#fda913]/20 text-[#fda913] border border-[#fda913]/30 text-sm font-bold hover:bg-[#fda913]/30 transition-colors"
>
Save Settings
</button>
</div>
</motion.div>
</div>,
document.body
);
};
export default CommissionSettingsModal;