diff --git a/src/components/owner/CommissionSettingsModal.jsx b/src/components/owner/CommissionSettingsModal.jsx new file mode 100644 index 0000000..8253e84 --- /dev/null +++ b/src/components/owner/CommissionSettingsModal.jsx @@ -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 ( + + ); +}; + +const CommissionForm = ({ type, rate, onTypeChange, onRateChange, accentColor = '#fda913' }) => ( +
+
+ + +
+
+ +
+ + {type === 'percent_gross' || type === 'percent_net' ? '%' : '$'} + + 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" + /> +
+
+
+); + +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( +
+
+ + {/* Header */} +
+
+
+ +
+
+

Commission Settings

+

Configure commission hierarchy: Job → User → Org

+
+
+ +
+ + {/* Override Priority Visualization */} +
+
+ {['org', 'user', 'job'].map((level, i) => { + const cfg = LEVEL_CONFIG[level]; + const Icon = cfg.icon; + const isActive = activeSource === level; + return ( + + {i > 0 && } +
+ + {cfg.label} + {isActive && ( + + )} +
+
+ ); + })} +
+

+ Currently using: + {LEVEL_CONFIG[activeSource]?.label || 'Org Default'} + +

+
+ + {/* Scrollable Content */} +
+ + {/* ── ORG DEFAULTS ── */} +
+ toggleSection('org')} /> + + {openSections.org && ( + +
+ {isAdmin ? ( + setLocalOrgDefaults(prev => ({ ...prev, type: v, rate: '' }))} + onRateChange={v => setLocalOrgDefaults(prev => ({ ...prev, rate: v }))} + accentColor="#00f0ff" + /> + ) : ( +
+ + + {COMMISSION_TYPES.find(c => c.value === orgDefaults.type)?.label} — { + orgDefaults.type === 'percent_gross' || orgDefaults.type === 'percent_net' + ? `${orgDefaults.rate}%` + : formatCurrency(orgDefaults.rate) + } + + (Admin only) +
+ )} +
+
+ )} +
+
+ + {/* ── USER OVERRIDES ── */} +
+ toggleSection('user')} /> + + {openSections.user && ( + +
+ {!isAdmin ? ( +
+ + {localUserOverrides.length} user override(s) configured + (Admin only) +
+ ) : ( + <> + {localUserOverrides.length === 0 && !addingUser && ( +

No user overrides configured.

+ )} + + {localUserOverrides.map((ov, idx) => ( +
+
+
+ + {ov.userName} +
+
+ + +
+
+ {ov.enabled && ( + 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 && ( +

Override disabled — org default applies

+ )} +
+ ))} + + {/* Add New User Override */} + {addingUser ? ( +
+
+ + +
+ setNewUserOverride(prev => ({ ...prev, type: v, rate: '' }))} + onRateChange={v => setNewUserOverride(prev => ({ ...prev, rate: v }))} + accentColor="#a855f7" + /> +
+ + +
+
+ ) : ( + + )} + + )} +
+
+ )} +
+
+ + {/* ── JOB OVERRIDE ── */} +
+ toggleSection('job')} /> + + {openSections.job && ( + +
+
+ Enable job-level override + +
+ {localJobOverride.enabled ? ( + setLocalJobOverride(prev => ({ ...prev, type: v, rate: '' }))} + onRateChange={v => setLocalJobOverride(prev => ({ ...prev, rate: v }))} + accentColor="#fda913" + /> + ) : ( +

+ No job override — falls back to {activeSource === 'user' ? 'user override' : 'org default'} +

+ )} +
+
+ )} +
+
+ + {/* Hierarchy Info */} +
+
+ +
+

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.

+
+
+
+
+ + {/* Footer */} +
+ + +
+
+
, + document.body + ); +}; + +export default CommissionSettingsModal; diff --git a/src/data/mockStore.jsx b/src/data/mockStore.jsx index 7e913b0..a1b0a5d 100644 --- a/src/data/mockStore.jsx +++ b/src/data/mockStore.jsx @@ -4917,6 +4917,16 @@ export const MockStoreProvider = ({ children }) => { const [kanbanColumns, setKanbanColumns] = useState(KANBAN_COLUMNS_INITIAL); const [kanbanLeads, setKanbanLeads] = useState(KANBAN_LEADS_INITIAL); + // Commission Settings — Org defaults + per-user overrides + const [orgCommissionDefaults, setOrgCommissionDefaults] = useState({ + type: 'percent_gross', + rate: 10, + }); + const [userCommissionOverrides, setUserCommissionOverrides] = useState([ + { userId: 'p1', userName: 'Jesus Gonzales', enabled: true, type: 'percent_gross', rate: 12 }, + { userId: 'p2', userName: 'Sarah Sales', enabled: true, type: 'flat', rate: 2000 }, + ]); + // Initialize properties once useEffect(() => { const data = generateProperties(); @@ -5104,7 +5114,27 @@ export const MockStoreProvider = ({ children }) => { }, assignAgent, markLeadContacted, - sendReminder + sendReminder, + + // Commission Settings + orgCommissionDefaults, + userCommissionOverrides, + updateOrgCommissionDefaults: (defaults) => { + setOrgCommissionDefaults(prev => ({ ...prev, ...defaults })); + }, + setUserCommissionOverride: (override) => { + setUserCommissionOverrides(prev => { + const idx = prev.findIndex(o => o.userId === override.userId); + if (idx >= 0) return prev.map((o, i) => i === idx ? { ...o, ...override } : o); + return [...prev, override]; + }); + }, + removeUserCommissionOverride: (userId) => { + setUserCommissionOverrides(prev => prev.filter(o => o.userId !== userId)); + }, + updateProject: (projectId, data) => { + setProjects(prev => prev.map(p => p.id === projectId ? { ...p, ...data } : p)); + }, }}> {children} diff --git a/src/pages/owner/OwnerProjectDetail.jsx b/src/pages/owner/OwnerProjectDetail.jsx index f1af201..8ae540a 100644 --- a/src/pages/owner/OwnerProjectDetail.jsx +++ b/src/pages/owner/OwnerProjectDetail.jsx @@ -12,11 +12,12 @@ import { ArrowLeft, CheckCircle, Clock, AlertTriangle, PauseCircle, ShieldAlert, FileText, DollarSign, GitPullRequest, AlertCircle, Activity, Milestone, Shield, ChevronRight, Phone, MessageSquare, Mail, AlertOctagon, TrendingUp, Key, Zap, Camera, Plus, Map, User, Crosshair, Image as ImageIcon, MapPin, - Upload, Trash2, Eye, Pencil, X as XIcon, FolderOpen, Download, File, StickyNote, Banknote, ChevronLeft, ChevronDown as ChevronDownIcon, Info + Upload, Trash2, Eye, Pencil, X as XIcon, FolderOpen, Download, File, StickyNote, Banknote, ChevronLeft, ChevronDown as ChevronDownIcon, Info, Settings, Building2, User as UserIcon, Briefcase } from 'lucide-react'; import ChangeOrderDrawer from '../../components/owner/ChangeOrderDrawer'; import InvoiceDetailModal from '../../components/owner/InvoiceDetailModal'; import CreateItemModal from '../../components/owner/CreateItemModal'; +import CommissionSettingsModal from '../../components/owner/CommissionSettingsModal'; // --- 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"; @@ -134,7 +135,12 @@ const formatCurrency = (amt) => const OwnerProjectDetail = () => { const { projectId } = useParams(); const { user } = useAuth(); - const { projects, vendors } = useMockStore(); + const { + projects, vendors, personnel, + orgCommissionDefaults, userCommissionOverrides, + updateOrgCommissionDefaults, setUserCommissionOverride, + removeUserCommissionOverride, updateProject, + } = useMockStore(); const navigate = useNavigate(); const [activeTab, setActiveTab] = useState('overview'); const [selectedCO, setSelectedCO] = useState(null); @@ -150,6 +156,7 @@ const OwnerProjectDetail = () => { // ── Commission config state ───────────────────────────────────────────── const [commissionConfig, setCommissionConfig] = useState(null); + const [commissionSettingsOpen, setCommissionSettingsOpen] = useState(false); // ── Docs state ──────────────────────────────────────────────────────────── const [docs, setDocs] = useState(() => seedDocsMock(projectId)); @@ -235,15 +242,28 @@ const OwnerProjectDetail = () => { projects.find(p => p.id === projectId && p.ownerId === user?.id) , [projects, projectId, user]); - const activeCommissionConfig = useMemo(() => { + // ── Commission hierarchy resolution: Job → User → Org ────────────────── + const jobCommissionOverride = useMemo(() => { if (commissionConfig) return commissionConfig; - if (!project) return { type: 'flat', rate: 0 }; - return { - type: project.commissionType || 'flat', - rate: Number(project.commissionRate) || 0, - }; + if (!project) return null; + if (project.commissionOverride) return project.commissionOverride; + 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 activeCommissionConfig = resolvedCommission; + const profitMetrics = useMemo(() => { if (!project) return { totalCosts: 0, commission: 0, commissionFormula: '', additionalExp: 0, grossProfit: 0, netProfit: 0, grossMargin: 0, netMargin: 0 }; const totalCosts = Number(project.actualCost ?? project.spent) || 0; @@ -987,22 +1007,51 @@ const OwnerProjectDetail = () => {

Commission

+ {/* Source Badge */} + {(() => { + 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' }, + 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' }, + }; + const b = badgeCfg[src]; + const BadgeIcon = b.icon; + return ( + + + {b.label} + + ); + })()}
-
- - {openTooltip === 'commission' && ( -
-

Commission Types

-
-

Flat: Fixed dollar amount

-

% of Gross: Total Received x %

-

% of Net: Gross Profit x %

-

Custom: Manual dollar entry

+
+ + {openTooltip === 'commission' && ( +
+

Commission Hierarchy

+
+

Job Override: Highest priority — set per job

+

User Override: Per-user commission settings

+

Org Default: Organization-wide fallback

+
+
+

Priority: Job → User → Org

+
-
- )} + )} +
@@ -1012,7 +1061,7 @@ const OwnerProjectDetail = () => {