From 394641f899a7dfa0c863a184882b3c89362204c3 Mon Sep 17 00:00:00 2001 From: Satyam Rastogi Date: Mon, 11 May 2026 17:00:24 +0530 Subject: [PATCH 01/38] budgest table add name and remove commited column --- src/pages/owner/OwnerProjectDetail.jsx | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/src/pages/owner/OwnerProjectDetail.jsx b/src/pages/owner/OwnerProjectDetail.jsx index 06f125f..73e3a23 100644 --- a/src/pages/owner/OwnerProjectDetail.jsx +++ b/src/pages/owner/OwnerProjectDetail.jsx @@ -561,7 +561,7 @@ const OwnerProjectDetail = () => { Add Cost -

Allocated vs Committed vs Actual ($k)

+

Allocated vs Actual ($k)

@@ -569,7 +569,6 @@ const OwnerProjectDetail = () => { } /> - @@ -580,8 +579,8 @@ const OwnerProjectDetail = () => { - {['Category', 'Allocated', 'Committed', 'Actual', 'Variance'].map(h => ( - + {['Name', 'Category', 'Allocated', 'Actual', 'Variance'].map(h => ( + ))} @@ -590,9 +589,9 @@ const OwnerProjectDetail = () => { const variance = b.actual - b.allocated; return ( + - + + + + + + {(() => { + const totalVariance = profitMetrics.totalCosts - totalReceived; + return ( + + ); + })()} + +
{h}{h}
{b.name || '—'} {b.category} {formatCurrency(b.allocated)}{formatCurrency(b.committed)} {formatCurrency(b.actual)} 0 ? 'text-[#ff4500]' : 'text-[#39ff14]'}`}> {variance > 0 ? '+' : ''}{formatCurrency(variance)} From e9c67d8dda061c62274f30abe3146d92428fe0e2 Mon Sep 17 00:00:00 2001 From: Satyam Rastogi Date: Mon, 11 May 2026 18:02:29 +0530 Subject: [PATCH 02/38] addnew payments table --- src/pages/owner/OwnerProjectDetail.jsx | 232 ++++++++++++++++++++++++- 1 file changed, 231 insertions(+), 1 deletion(-) diff --git a/src/pages/owner/OwnerProjectDetail.jsx b/src/pages/owner/OwnerProjectDetail.jsx index 73e3a23..1a61d7d 100644 --- a/src/pages/owner/OwnerProjectDetail.jsx +++ b/src/pages/owner/OwnerProjectDetail.jsx @@ -12,7 +12,7 @@ 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 + Upload, Trash2, Eye, Pencil, X as XIcon, FolderOpen, Download, File, StickyNote, Banknote, ChevronLeft, ChevronDown as ChevronDownIcon } from 'lucide-react'; import ChangeOrderDrawer from '../../components/owner/ChangeOrderDrawer'; import InvoiceDetailModal from '../../components/owner/InvoiceDetailModal'; @@ -67,6 +67,7 @@ const tabs = [ { id: 'rfis', label: 'RFIs', icon: FileText }, { id: 'milestones', label: 'Milestones', icon: Milestone }, { id: 'invoices', label: 'Invoices', icon: DollarSign }, + { id: 'payments', label: 'Payments', icon: Banknote }, { id: 'risks', label: 'Risk & Issues', icon: AlertCircle }, { id: 'activity', label: 'Activity', icon: Clock }, { id: 'docs', label: 'Docs', icon: FolderOpen }, @@ -105,6 +106,21 @@ function seedDocsMock(projectId) { return all.slice(0, 4 + seed); } +function seedPaymentsMock(projectId) { + const all = [ + { id: 'pay_001', from: 'State Farm Insurance', method: 'ACH', amount: 18500, date: '2026-01-28', refNumber: 'ACH-2026-4411', memo: 'Initial claim payout — roof replacement' }, + { id: 'pay_002', from: 'Justin Johnson', method: 'Check', amount: 2500, date: '2026-02-05', refNumber: 'CHK-8832', memo: 'Homeowner deductible payment' }, + { id: 'pay_003', from: 'State Farm Insurance', method: 'ACH', amount: 6200, date: '2026-02-18', refNumber: 'ACH-2026-4499', memo: 'Supplement approval — additional scope' }, + { id: 'pay_004', from: 'Justin Johnson', method: 'Credit Card', amount: 1800.50, date: '2026-03-01', refNumber: 'CC-9901', memo: 'Upgrade — premium underlayment' }, + { id: 'pay_005', from: 'State Farm Insurance', method: 'Check', amount: 4100, date: '2026-03-12', refNumber: 'CHK-7761', memo: 'Final supplement payout' }, + { id: 'pay_006', from: 'Justin Johnson', method: 'Cash', amount: 500, date: '2026-03-20', refNumber: '', memo: 'Gutter guard add-on' }, + ]; + const seed = projectId.charCodeAt(projectId.length - 1) % 3; + return all.slice(0, 3 + seed); +} + +const PAYMENTS_PER_PAGE = 5; + const OwnerProjectDetail = () => { const { projectId } = useParams(); const { user } = useAuth(); @@ -115,6 +131,11 @@ const OwnerProjectDetail = () => { const [selectedInvoice, setSelectedInvoice] = useState(null); const [createModalConfig, setCreateModalConfig] = useState(null); + // ── Payments state ───────────────────────────────────────────────────────── + const [payments, setPayments] = useState(() => seedPaymentsMock(projectId)); + const [paymentSort, setPaymentSort] = useState({ field: 'date', dir: 'desc' }); + const [paymentPage, setPaymentPage] = useState(0); + // ── Docs state ──────────────────────────────────────────────────────────── const [docs, setDocs] = useState(() => seedDocsMock(projectId)); const [viewDoc, setViewDoc] = useState(null); // doc object being viewed @@ -160,6 +181,41 @@ const OwnerProjectDetail = () => { alert("Action successful! (Mock interface)"); }; + // ── Payment handlers ──────────────────────────────────────────────────── + const handleAddPayment = (data) => { + const newPayment = { + id: `pay_${Date.now()}`, + from: data.from || '', + method: data.method || 'Cash', + amount: parseFloat(data.amount) || 0, + date: data.date || new Date().toISOString().split('T')[0], + refNumber: data.refNumber || '', + memo: data.memo || '', + }; + setPayments(prev => [newPayment, ...prev]); + setPaymentPage(0); + }; + + const togglePaymentSort = (field) => { + setPaymentSort(prev => prev.field === field ? { field, dir: prev.dir === 'asc' ? 'desc' : 'asc' } : { field, dir: 'desc' }); + setPaymentPage(0); + }; + + const sortedPayments = useMemo(() => { + return [...payments].sort((a, b) => { + if (paymentSort.field === 'amount') return paymentSort.dir === 'asc' ? a.amount - b.amount : b.amount - a.amount; + if (paymentSort.field === 'date') return paymentSort.dir === 'asc' ? a.date.localeCompare(b.date) : b.date.localeCompare(a.date); + return 0; + }); + }, [payments, paymentSort]); + + // Pagination logic for payments + const paginatedPayments = sortedPayments.slice(paymentPage * PAYMENTS_PER_PAGE, (paymentPage + 1) * PAYMENTS_PER_PAGE); + // Calculate total number of pages for pagination + const totalPaymentPages = Math.ceil(sortedPayments.length / PAYMENTS_PER_PAGE); + // Calculate total received payments + const totalReceived = payments.reduce((sum, p) => sum + p.amount, 0); + const project = useMemo(() => projects.find(p => p.id === projectId && p.ownerId === user?.id) , [projects, projectId, user]); @@ -846,6 +902,180 @@ const OwnerProjectDetail = () => { )} + {/* PAYMENTS TAB */} + {activeTab === 'payments' && ( +
+ {/* Summary Row */} +
+ +
{formatCurrency(totalReceived)}
+
Total Received
+
+ +
{payments.length}
+
Payments
+
+ +
+ {payments.length > 0 ? [...payments].sort((a, b) => b.date.localeCompare(a.date))[0].date : '—'} +
+
Latest Payment
+
+
+ + {/* Payments Table */} + +
+
+ +

Payments Received

+ + {payments.length} + +
+ +
+ + {payments.length > 0 ? ( + <> + {/* Desktop Table */} +
+ + + + + + + + + + + + {paginatedPayments.map((pay) => ( + + + + + + + + ))} + +
From / Method togglePaymentSort('amount')} + > + Amount {paymentSort.field === 'amount' ? (paymentSort.dir === 'asc' ? '↑' : '↓') : ''} + togglePaymentSort('date')} + > + Date {paymentSort.field === 'date' ? (paymentSort.dir === 'asc' ? '↑' : '↓') : ''} + Check No. / RefMemo
+
{pay.from}
+ + {pay.method} + +
+ {formatCurrency(pay.amount)} + {pay.date}{pay.refNumber || '—'}{pay.memo || '—'}
+
+ + {/* Mobile Card View */} +
+ {paginatedPayments.map((pay) => ( +
+
+
+

{pay.from}

+ + {pay.method} + +
+ {formatCurrency(pay.amount)} +
+
+
+ Date +

{pay.date}

+
+
+ Ref # +

{pay.refNumber || '—'}

+
+
+ {pay.memo && ( +

{pay.memo}

+ )} +
+ ))} +
+ + {/* Pagination */} + {totalPaymentPages > 1 && ( +
+

+ {paymentPage * PAYMENTS_PER_PAGE + 1}–{Math.min((paymentPage + 1) * PAYMENTS_PER_PAGE, sortedPayments.length)} of {sortedPayments.length} +

+
+ + +
+
+ )} + + ) : ( +
+ +

No payments recorded yet.

+

Record your first payment to start tracking.

+
+ )} +
+
+ )} + {/* RISK & ISSUES TAB */} {activeTab === 'risks' && (
From 0b6ee9d626de16dade2dadd27a836f94952b95b5 Mon Sep 17 00:00:00 2001 From: Satyam Rastogi Date: Mon, 11 May 2026 18:56:04 +0530 Subject: [PATCH 03/38] Gross, Net Profit calculate --- src/data/mockStore.jsx | 24 ++++++ src/pages/owner/OwnerProjectDetail.jsx | 107 ++++++++++++++++++++++--- 2 files changed, 122 insertions(+), 9 deletions(-) diff --git a/src/data/mockStore.jsx b/src/data/mockStore.jsx index 0382e58..f10e536 100644 --- a/src/data/mockStore.jsx +++ b/src/data/mockStore.jsx @@ -2011,6 +2011,8 @@ const MOCK_PROJECTS = [ spent: 22500, variancePercent: -50.0, margin: 0.40, + commission: 4500, + additionalExpenses: 1200, startDate: '2026-01-20', endDate: '2026-03-05', completionPercentage: 55, @@ -2084,6 +2086,8 @@ const MOCK_PROJECTS = [ spent: 8500, variancePercent: 0, margin: 0.35, + commission: 850, + additionalExpenses: 350, startDate: '2025-11-15', endDate: '2025-12-10', completionPercentage: 100, @@ -2130,6 +2134,8 @@ const MOCK_PROJECTS = [ spent: 18400, variancePercent: -42.5, margin: 0.30, + commission: 3200, + additionalExpenses: 900, startDate: '2026-01-06', endDate: '2026-02-28', completionPercentage: 42, @@ -2202,6 +2208,8 @@ const MOCK_PROJECTS = [ spent: 62300, variancePercent: 13.3, margin: -0.13, + commission: 5500, + additionalExpenses: 3800, startDate: '2025-12-01', endDate: '2026-02-28', completionPercentage: 78, @@ -2285,6 +2293,8 @@ const MOCK_PROJECTS = [ spent: 14200, variancePercent: -49.3, margin: 0.25, + commission: 2800, + additionalExpenses: 600, startDate: '2026-01-06', endDate: '2026-02-20', completionPercentage: 38, @@ -2355,6 +2365,8 @@ const MOCK_PROJECTS = [ spent: 5600, variancePercent: -69.7, margin: 0.38, + commission: 1850, + additionalExpenses: 450, startDate: '2026-02-03', endDate: '2026-03-15', completionPercentage: 22, @@ -2404,6 +2416,8 @@ const MOCK_PROJECTS = [ spent: 48000, variancePercent: -61.6, margin: 0.35, + commission: 12500, + additionalExpenses: 2800, startDate: '2026-01-27', endDate: '2026-04-15', completionPercentage: 32, @@ -2495,6 +2509,8 @@ const MOCK_PROJECTS = [ spent: 91500, variancePercent: 22.0, margin: -0.22, + commission: 7500, + additionalExpenses: 4200, startDate: '2025-11-18', endDate: '2026-02-28', completionPercentage: 88, @@ -2542,6 +2558,8 @@ const MOCK_PROJECTS = [ budget: 42000, spent: 25800, margin: 0.28, + commission: 4200, + additionalExpenses: 1100, startDate: '2026-01-13', endDate: '2026-03-10', completionPercentage: 50, @@ -2587,6 +2605,8 @@ const MOCK_PROJECTS = [ budget: 35000, spent: 35000, margin: 0.30, + commission: 3500, + additionalExpenses: 800, startDate: '2025-09-15', endDate: '2025-10-28', completionPercentage: 100, @@ -2632,6 +2652,8 @@ const MOCK_PROJECTS = [ budget: 95000, spent: 68400, margin: 0.20, + commission: 9500, + additionalExpenses: 5200, startDate: '2025-12-08', endDate: '2026-03-20', completionPercentage: 62, @@ -2685,6 +2707,8 @@ const MOCK_PROJECTS = [ budget: 58000, spent: 22600, margin: 0.32, + commission: 5800, + additionalExpenses: 1500, startDate: '2026-01-13', endDate: '2026-04-01', completionPercentage: 28, diff --git a/src/pages/owner/OwnerProjectDetail.jsx b/src/pages/owner/OwnerProjectDetail.jsx index 1a61d7d..2f52522 100644 --- a/src/pages/owner/OwnerProjectDetail.jsx +++ b/src/pages/owner/OwnerProjectDetail.jsx @@ -12,7 +12,7 @@ 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 + Upload, Trash2, Eye, Pencil, X as XIcon, FolderOpen, Download, File, StickyNote, Banknote, ChevronLeft, ChevronDown as ChevronDownIcon, Info } from 'lucide-react'; import ChangeOrderDrawer from '../../components/owner/ChangeOrderDrawer'; import InvoiceDetailModal from '../../components/owner/InvoiceDetailModal'; @@ -220,6 +220,18 @@ const OwnerProjectDetail = () => { projects.find(p => p.id === projectId && p.ownerId === user?.id) , [projects, projectId, user]); + const profitMetrics = useMemo(() => { + if (!project) return { totalCosts: 0, commission: 0, additionalExp: 0, grossProfit: 0, netProfit: 0, grossMargin: 0, netMargin: 0 }; + const totalCosts = Number(project.actualCost ?? project.spent) || 0; + const commission = Number(project.commission) || 0; + const additionalExp = Number(project.additionalExpenses) || 0; + const gross = totalReceived - totalCosts; + const net = gross - commission - additionalExp; + const grossMargin = totalReceived > 0 ? (gross / totalReceived) * 100 : 0; + const netMargin = totalReceived > 0 ? (net / totalReceived) * 100 : 0; + return { totalCosts, commission, additionalExp, grossProfit: gross, netProfit: net, grossMargin, netMargin }; + }, [project, totalReceived]); + if (!project) { return (
@@ -905,22 +917,99 @@ const OwnerProjectDetail = () => { {/* PAYMENTS TAB */} {activeTab === 'payments' && (
- {/* Summary Row */} -
+ {/* Financial Summary Row */} +
{formatCurrency(totalReceived)}
Total Received
-
{payments.length}
-
Payments
+
{formatCurrency(profitMetrics.totalCosts)}
+
Total Costs
-
- {payments.length > 0 ? [...payments].sort((a, b) => b.date.localeCompare(a.date))[0].date : '—'} -
-
Latest Payment
+
{formatCurrency(profitMetrics.commission)}
+
Commission
+ +
{formatCurrency(profitMetrics.additionalExp)}
+
Addl. Expenses
+
+
+ + {/* Profit Summary Cards */} +
+ {/* Gross Profit */} + = 0 ? "rgba(57, 255, 20, 0.12)" : "rgba(255, 0, 60, 0.12)"} + innerClassName="!p-5" + > +
+
+ = 0 ? NEON_GREEN : NEON_RED} /> +

Gross Profit

+
+
+ +
+

Gross Profit Formula

+

Total Received − Total Actual Costs

+
+

{formatCurrency(totalReceived)} − {formatCurrency(profitMetrics.totalCosts)}

+

= {formatCurrency(profitMetrics.grossProfit)}

+
+
+
+
+
= 0 ? NEON_GREEN : NEON_RED}`}> + {profitMetrics.grossProfit >= 0 ? '+' : ''}{formatCurrency(profitMetrics.grossProfit)} +
+
+ = 0 + ? 'text-green-600 dark:text-[#39ff14] bg-green-50 dark:bg-[#39ff14]/10 border border-green-200 dark:border-[#39ff14]/20' + : 'text-red-600 dark:text-[#ff003c] bg-red-50 dark:bg-[#ff003c]/10 border border-red-200 dark:border-[#ff003c]/20' + }`}> + {profitMetrics.grossMargin >= 0 ? '+' : ''}{profitMetrics.grossMargin.toFixed(1)}% margin + +
+
+ + {/* Net Profit */} + = 0 ? "rgba(57, 255, 20, 0.12)" : "rgba(255, 0, 60, 0.12)"} + innerClassName="!p-5" + > +
+
+ = 0 ? NEON_GREEN : NEON_RED} /> +

Net Profit

+
+
+ +
+

Net Profit Formula

+

Gross Profit − Commission − Addl. Expenses

+
+

{formatCurrency(profitMetrics.grossProfit)} − {formatCurrency(profitMetrics.commission)} − {formatCurrency(profitMetrics.additionalExp)}

+

= {formatCurrency(profitMetrics.netProfit)}

+
+
+
+
+
= 0 ? NEON_GREEN : NEON_RED}`}> + {profitMetrics.netProfit >= 0 ? '+' : ''}{formatCurrency(profitMetrics.netProfit)} +
+
+ = 0 + ? 'text-green-600 dark:text-[#39ff14] bg-green-50 dark:bg-[#39ff14]/10 border border-green-200 dark:border-[#39ff14]/20' + : 'text-red-600 dark:text-[#ff003c] bg-red-50 dark:bg-[#ff003c]/10 border border-red-200 dark:border-[#ff003c]/20' + }`}> + {profitMetrics.netMargin >= 0 ? '+' : ''}{profitMetrics.netMargin.toFixed(1)}% margin + +
+
{/* Payments Table */} From 178d03386454170b045654a185fc30b15935d60b Mon Sep 17 00:00:00 2001 From: Satyam Rastogi Date: Mon, 11 May 2026 19:53:32 +0530 Subject: [PATCH 04/38] add commission section --- src/data/mockStore.jsx | 24 ++++ src/pages/owner/OwnerProjectDetail.jsx | 191 +++++++++++++++++++++---- 2 files changed, 184 insertions(+), 31 deletions(-) diff --git a/src/data/mockStore.jsx b/src/data/mockStore.jsx index f10e536..7e913b0 100644 --- a/src/data/mockStore.jsx +++ b/src/data/mockStore.jsx @@ -2011,6 +2011,8 @@ const MOCK_PROJECTS = [ spent: 22500, variancePercent: -50.0, margin: 0.40, + commissionType: 'percent_gross', + commissionRate: 10, commission: 4500, additionalExpenses: 1200, startDate: '2026-01-20', @@ -2086,6 +2088,8 @@ const MOCK_PROJECTS = [ spent: 8500, variancePercent: 0, margin: 0.35, + commissionType: 'flat', + commissionRate: 850, commission: 850, additionalExpenses: 350, startDate: '2025-11-15', @@ -2134,6 +2138,8 @@ const MOCK_PROJECTS = [ spent: 18400, variancePercent: -42.5, margin: 0.30, + commissionType: 'percent_net', + commissionRate: 15, commission: 3200, additionalExpenses: 900, startDate: '2026-01-06', @@ -2208,6 +2214,8 @@ const MOCK_PROJECTS = [ spent: 62300, variancePercent: 13.3, margin: -0.13, + commissionType: 'custom', + commissionRate: 5500, commission: 5500, additionalExpenses: 3800, startDate: '2025-12-01', @@ -2293,6 +2301,8 @@ const MOCK_PROJECTS = [ spent: 14200, variancePercent: -49.3, margin: 0.25, + commissionType: 'percent_gross', + commissionRate: 10, commission: 2800, additionalExpenses: 600, startDate: '2026-01-06', @@ -2365,6 +2375,8 @@ const MOCK_PROJECTS = [ spent: 5600, variancePercent: -69.7, margin: 0.38, + commissionType: 'flat', + commissionRate: 1850, commission: 1850, additionalExpenses: 450, startDate: '2026-02-03', @@ -2416,6 +2428,8 @@ const MOCK_PROJECTS = [ spent: 48000, variancePercent: -61.6, margin: 0.35, + commissionType: 'percent_gross', + commissionRate: 10, commission: 12500, additionalExpenses: 2800, startDate: '2026-01-27', @@ -2509,6 +2523,8 @@ const MOCK_PROJECTS = [ spent: 91500, variancePercent: 22.0, margin: -0.22, + commissionType: 'percent_net', + commissionRate: 12, commission: 7500, additionalExpenses: 4200, startDate: '2025-11-18', @@ -2558,6 +2574,8 @@ const MOCK_PROJECTS = [ budget: 42000, spent: 25800, margin: 0.28, + commissionType: 'percent_gross', + commissionRate: 10, commission: 4200, additionalExpenses: 1100, startDate: '2026-01-13', @@ -2605,6 +2623,8 @@ const MOCK_PROJECTS = [ budget: 35000, spent: 35000, margin: 0.30, + commissionType: 'custom', + commissionRate: 3500, commission: 3500, additionalExpenses: 800, startDate: '2025-09-15', @@ -2652,6 +2672,8 @@ const MOCK_PROJECTS = [ budget: 95000, spent: 68400, margin: 0.20, + commissionType: 'flat', + commissionRate: 9500, commission: 9500, additionalExpenses: 5200, startDate: '2025-12-08', @@ -2707,6 +2729,8 @@ const MOCK_PROJECTS = [ budget: 58000, spent: 22600, margin: 0.32, + commissionType: 'percent_gross', + commissionRate: 10, commission: 5800, additionalExpenses: 1500, startDate: '2026-01-13', diff --git a/src/pages/owner/OwnerProjectDetail.jsx b/src/pages/owner/OwnerProjectDetail.jsx index 2f52522..f1af201 100644 --- a/src/pages/owner/OwnerProjectDetail.jsx +++ b/src/pages/owner/OwnerProjectDetail.jsx @@ -121,6 +121,16 @@ function seedPaymentsMock(projectId) { const PAYMENTS_PER_PAGE = 5; +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 formatCurrency = (amt) => + new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', maximumFractionDigits: 0 }).format(Number(amt) || 0); + const OwnerProjectDetail = () => { const { projectId } = useParams(); const { user } = useAuth(); @@ -136,6 +146,11 @@ const OwnerProjectDetail = () => { const [paymentSort, setPaymentSort] = useState({ field: 'date', dir: 'desc' }); const [paymentPage, setPaymentPage] = useState(0); + const [openTooltip, setOpenTooltip] = useState(null); + + // ── Commission config state ───────────────────────────────────────────── + const [commissionConfig, setCommissionConfig] = useState(null); + // ── Docs state ──────────────────────────────────────────────────────────── const [docs, setDocs] = useState(() => seedDocsMock(projectId)); const [viewDoc, setViewDoc] = useState(null); // doc object being viewed @@ -220,17 +235,53 @@ const OwnerProjectDetail = () => { projects.find(p => p.id === projectId && p.ownerId === user?.id) , [projects, projectId, user]); + const activeCommissionConfig = useMemo(() => { + if (commissionConfig) return commissionConfig; + if (!project) return { type: 'flat', rate: 0 }; + return { + type: project.commissionType || 'flat', + rate: Number(project.commissionRate) || 0, + }; + }, [commissionConfig, project]); + const profitMetrics = useMemo(() => { - if (!project) return { totalCosts: 0, commission: 0, additionalExp: 0, grossProfit: 0, netProfit: 0, grossMargin: 0, netMargin: 0 }; + 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; - const commission = Number(project.commission) || 0; const additionalExp = Number(project.additionalExpenses) || 0; const gross = totalReceived - totalCosts; + + const { type, rate } = activeCommissionConfig; + const safeRate = Number(rate) || 0; + let commission = 0; + let commissionFormula = ''; + switch (type) { + case 'flat': + commission = safeRate; + commissionFormula = `Flat Amount: ${formatCurrency(safeRate)}`; + break; + case 'percent_gross': + commission = totalReceived * (safeRate / 100); + commissionFormula = `${safeRate}% of Total Received (${formatCurrency(totalReceived)})`; + break; + case 'percent_net': + commission = gross * (safeRate / 100); + commissionFormula = `${safeRate}% of Gross Profit (${formatCurrency(gross)})`; + break; + case 'custom': + commission = safeRate; + commissionFormula = `Manual Entry: ${formatCurrency(safeRate)}`; + break; + default: + commission = safeRate; + commissionFormula = `${formatCurrency(safeRate)}`; + } + commission = Number.isFinite(commission) ? commission : 0; + const net = gross - commission - additionalExp; const grossMargin = totalReceived > 0 ? (gross / totalReceived) * 100 : 0; const netMargin = totalReceived > 0 ? (net / totalReceived) * 100 : 0; - return { totalCosts, commission, additionalExp, grossProfit: gross, netProfit: net, grossMargin, netMargin }; - }, [project, totalReceived]); + return { totalCosts, commission, commissionFormula, additionalExp, grossProfit: gross, netProfit: net, grossMargin, netMargin }; + }, [project, totalReceived, activeCommissionConfig]); if (!project) { return ( @@ -252,9 +303,6 @@ const OwnerProjectDetail = () => { const contractor = vendors.find(v => v.id === project.contractorId) || null; - const formatCurrency = (amt) => - new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', maximumFractionDigits: 0 }).format(amt); - const getHealthColor = (s) => s >= 70 ? 'text-emerald-500' : s >= 40 ? 'text-amber-500' : 'text-red-500'; const getMilestoneColor = (status) => { @@ -916,9 +964,9 @@ const OwnerProjectDetail = () => { {/* PAYMENTS TAB */} {activeTab === 'payments' && ( -
+
openTooltip && setOpenTooltip(null)}> {/* Financial Summary Row */} -
+
{formatCurrency(totalReceived)}
Total Received
@@ -927,16 +975,91 @@ const OwnerProjectDetail = () => {
{formatCurrency(profitMetrics.totalCosts)}
Total Costs
- -
{formatCurrency(profitMetrics.commission)}
-
Commission
-
{formatCurrency(profitMetrics.additionalExp)}
Addl. Expenses
+ {/* Commission Configuration Card */} + +
+
+ +

Commission

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

Commission Types

+
+

Flat: Fixed dollar amount

+

% of Gross: Total Received x %

+

% of Net: Gross Profit x %

+

Custom: Manual dollar entry

+
+
+ )} +
+
+ +
+ {/* Type Selector */} +
+ + +
+ + {/* Rate / Value Input */} +
+ +
+ + {activeCommissionConfig.type === 'percent_gross' || activeCommissionConfig.type === 'percent_net' ? '%' : '$'} + + setCommissionConfig(prev => ({ ...(prev || activeCommissionConfig), type: activeCommissionConfig.type, rate: e.target.value }))} + placeholder={activeCommissionConfig.type === 'percent_gross' || activeCommissionConfig.type === 'percent_net' ? 'e.g. 10' : 'e.g. 5000'} + className="w-full bg-zinc-50 dark:bg-[#18181b] border border-zinc-200 dark:border-white/10 rounded-xl pl-8 pr-4 py-2.5 text-sm text-zinc-900 dark:text-white placeholder-zinc-400 dark:placeholder-zinc-600 outline-none focus:border-amber-500 dark:focus:border-amber-500 focus:ring-1 focus:ring-amber-500/30 transition-all" + /> +
+
+ + {/* Calculated Result */} +
+ +
+ {formatCurrency(profitMetrics.commission)} +
+
+
+ + {/* Live Formula Preview */} +
+

+ Formula: + {profitMetrics.commissionFormula} +

+
+
+ {/* Profit Summary Cards */}
{/* Gross Profit */} @@ -949,16 +1072,19 @@ const OwnerProjectDetail = () => { = 0 ? NEON_GREEN : NEON_RED} />

Gross Profit

-
- -
-

Gross Profit Formula

-

Total Received − Total Actual Costs

-
-

{formatCurrency(totalReceived)} − {formatCurrency(profitMetrics.totalCosts)}

-

= {formatCurrency(profitMetrics.grossProfit)}

+
+ + {openTooltip === 'gross' && ( +
+

Gross Profit Formula

+

Total Received − Total Actual Costs

+
+

{formatCurrency(totalReceived)} − {formatCurrency(profitMetrics.totalCosts)} = {formatCurrency(profitMetrics.grossProfit)}

+
-
+ )}
= 0 ? NEON_GREEN : NEON_RED}`}> @@ -985,16 +1111,19 @@ const OwnerProjectDetail = () => { = 0 ? NEON_GREEN : NEON_RED} />

Net Profit

-
- -
-

Net Profit Formula

-

Gross Profit − Commission − Addl. Expenses

-
-

{formatCurrency(profitMetrics.grossProfit)} − {formatCurrency(profitMetrics.commission)} − {formatCurrency(profitMetrics.additionalExp)}

-

= {formatCurrency(profitMetrics.netProfit)}

+
+ + {openTooltip === 'net' && ( +
+

Net Profit Formula

+

Gross Profit − Commission − Addl. Expenses

+
+

{formatCurrency(profitMetrics.grossProfit)} − {formatCurrency(profitMetrics.commission)} − {formatCurrency(profitMetrics.additionalExp)} = {formatCurrency(profitMetrics.netProfit)}

+
-
+ )}
= 0 ? NEON_GREEN : NEON_RED}`}> From 6ab3f4c2d411783a9b519ac3640999db0868e280 Mon Sep 17 00:00:00 2001 From: Satyam Rastogi Date: Tue, 12 May 2026 15:54:01 +0530 Subject: [PATCH 05/38] configurable commission setting --- .../owner/CommissionSettingsModal.jsx | 499 ++++++++++++++++++ src/data/mockStore.jsx | 32 +- src/pages/owner/OwnerProjectDetail.jsx | 120 ++++- 3 files changed, 626 insertions(+), 25 deletions(-) create mode 100644 src/components/owner/CommissionSettingsModal.jsx 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 = () => {
+ Totals + {formatCurrency(totalReceived)}{formatCurrency(profitMetrics.totalCosts)} 0 ? 'text-[#ff4500]' : 'text-[#39ff14]'}`}> + {totalVariance > 0 ? '+' : ''}{formatCurrency(totalVariance)} +
@@ -747,6 +762,329 @@ const OwnerProjectDetail = () => {

Budget: {formatCurrency(project.approvedBudget || project.budget)}  ·  Spent: {formatCurrency(project.actualCost || project.spent)}

)} + + {/* Commission Configuration Card */} + +
openTooltip && setOpenTooltip(null)}> +
+ +

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 Hierarchy

+
+

Job Override: Highest priority — set per job

+

User Override: Per-user commission settings

+

Org Default: Organization-wide fallback

+
+
+

Priority: Job → User → Org

+
+
+ )} +
+
+
+ +
+
+ + +
+
+ +
+ + {activeCommissionConfig.type === 'percent_gross' || activeCommissionConfig.type === 'percent_net' ? '%' : '$'} + + setCommissionConfig(prev => ({ ...(prev || { type: activeCommissionConfig.type, rate: activeCommissionConfig.rate }), type: activeCommissionConfig.type, rate: e.target.value }))} + placeholder={activeCommissionConfig.type === 'percent_gross' || activeCommissionConfig.type === 'percent_net' ? 'e.g. 10' : 'e.g. 5000'} + className="w-full bg-zinc-50 dark:bg-[#18181b] border border-zinc-200 dark:border-white/10 rounded-xl pl-8 pr-4 py-2.5 text-sm text-zinc-900 dark:text-white placeholder-zinc-400 dark:placeholder-zinc-600 outline-none focus:border-amber-500 dark:focus:border-amber-500 focus:ring-1 focus:ring-amber-500/30 transition-all" + /> +
+
+
+ +
+ {formatCurrency(profitMetrics.commission)} +
+
+
+ +
+

+ Formula: + {profitMetrics.commissionFormula} +

+
+
+ + {/* Profit Summary Cards */} +
+ = 0 ? "rgba(57, 255, 20, 0.12)" : "rgba(255, 0, 60, 0.12)"} + innerClassName="!p-5" + > +
+
+ = 0 ? NEON_GREEN : NEON_RED} /> +

Gross Profit

+
+
+ + {openTooltip === 'gross' && ( +
+

Gross Profit Formula

+

Total Received − Total Actual Costs

+
+

{formatCurrency(totalReceived)} − {formatCurrency(profitMetrics.totalCosts)} = {formatCurrency(profitMetrics.grossProfit)}

+
+
+ )} +
+
+
= 0 ? NEON_GREEN : NEON_RED}`}> + {profitMetrics.grossProfit >= 0 ? '+' : ''}{formatCurrency(profitMetrics.grossProfit)} +
+
+ = 0 + ? 'text-green-600 dark:text-[#39ff14] bg-green-50 dark:bg-[#39ff14]/10 border border-green-200 dark:border-[#39ff14]/20' + : 'text-red-600 dark:text-[#ff003c] bg-red-50 dark:bg-[#ff003c]/10 border border-red-200 dark:border-[#ff003c]/20' + }`}> + {profitMetrics.grossMargin >= 0 ? '+' : ''}{profitMetrics.grossMargin.toFixed(1)}% margin + +
+
+ + = 0 ? "rgba(57, 255, 20, 0.12)" : "rgba(255, 0, 60, 0.12)"} + innerClassName="!p-5" + > +
+
+ = 0 ? NEON_GREEN : NEON_RED} /> +

Net Profit

+
+
+ + {openTooltip === 'net' && ( +
+

Net Profit Formula

+

Gross Profit − Commission

+
+

{formatCurrency(profitMetrics.grossProfit)} − {formatCurrency(profitMetrics.commission)} = {formatCurrency(profitMetrics.netProfit)}

+
+
+ )} +
+
+
= 0 ? NEON_GREEN : NEON_RED}`}> + {profitMetrics.netProfit >= 0 ? '+' : ''}{formatCurrency(profitMetrics.netProfit)} +
+
+ = 0 + ? 'text-green-600 dark:text-[#39ff14] bg-green-50 dark:bg-[#39ff14]/10 border border-green-200 dark:border-[#39ff14]/20' + : 'text-red-600 dark:text-[#ff003c] bg-red-50 dark:bg-[#ff003c]/10 border border-red-200 dark:border-[#ff003c]/20' + }`}> + {profitMetrics.netMargin >= 0 ? '+' : ''}{profitMetrics.netMargin.toFixed(1)}% margin + +
+
+
+ + {/* Payments Received Table */} + +
+
+ +

Payments Received

+ + {payments.length} + +
+ +
+ + {payments.length > 0 ? ( + <> +
+ + + + + + + + + + + + {paginatedPayments.map((pay) => ( + + + + + + + + ))} + +
From / Method togglePaymentSort('amount')}> + Amount {paymentSort.field === 'amount' ? (paymentSort.dir === 'asc' ? '↑' : '↓') : ''} + togglePaymentSort('date')}> + Date {paymentSort.field === 'date' ? (paymentSort.dir === 'asc' ? '↑' : '↓') : ''} + Check No. / RefMemo
+
{pay.from}
+ + {pay.method} + +
+ {formatCurrency(pay.amount)} + {pay.date}{pay.refNumber || '—'}{pay.memo || '—'}
+
+ +
+ {paginatedPayments.map((pay) => ( +
+
+
+

{pay.from}

+ + {pay.method} + +
+ {formatCurrency(pay.amount)} +
+
+
+ Date +

{pay.date}

+
+
+ Ref # +

{pay.refNumber || '—'}

+
+
+ {pay.memo && ( +

{pay.memo}

+ )} +
+ ))} +
+ + {totalPaymentPages > 1 && ( +
+

+ {paymentPage * PAYMENTS_PER_PAGE + 1}–{Math.min((paymentPage + 1) * PAYMENTS_PER_PAGE, sortedPayments.length)} of {sortedPayments.length} +

+
+ + +
+
+ )} + + ) : ( +
+ +

No payments recorded yet.

+

Record your first payment to start tracking.

+
+ )} +
)} @@ -982,367 +1320,6 @@ const OwnerProjectDetail = () => { )} - {/* PAYMENTS TAB */} - {activeTab === 'payments' && ( -
openTooltip && setOpenTooltip(null)}> - {/* Financial Summary Row */} -
- -
{formatCurrency(totalReceived)}
-
Total Received
-
- -
{formatCurrency(profitMetrics.totalCosts)}
-
Total Costs
-
- -
{formatCurrency(profitMetrics.additionalExp)}
-
Addl. Expenses
-
-
- - {/* Commission Configuration Card */} - -
-
- -

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 Hierarchy

-
-

Job Override: Highest priority — set per job

-

User Override: Per-user commission settings

-

Org Default: Organization-wide fallback

-
-
-

Priority: Job → User → Org

-
-
- )} -
-
-
- -
- {/* Type Selector */} -
- - -
- - {/* Rate / Value Input */} -
- -
- - {activeCommissionConfig.type === 'percent_gross' || activeCommissionConfig.type === 'percent_net' ? '%' : '$'} - - setCommissionConfig(prev => ({ ...(prev || { type: activeCommissionConfig.type, rate: activeCommissionConfig.rate }), type: activeCommissionConfig.type, rate: e.target.value }))} - placeholder={activeCommissionConfig.type === 'percent_gross' || activeCommissionConfig.type === 'percent_net' ? 'e.g. 10' : 'e.g. 5000'} - className="w-full bg-zinc-50 dark:bg-[#18181b] border border-zinc-200 dark:border-white/10 rounded-xl pl-8 pr-4 py-2.5 text-sm text-zinc-900 dark:text-white placeholder-zinc-400 dark:placeholder-zinc-600 outline-none focus:border-amber-500 dark:focus:border-amber-500 focus:ring-1 focus:ring-amber-500/30 transition-all" - /> -
-
- - {/* Calculated Result */} -
- -
- {formatCurrency(profitMetrics.commission)} -
-
-
- - {/* Live Formula Preview */} -
-

- Formula: - {profitMetrics.commissionFormula} -

-
-
- - {/* Profit Summary Cards */} -
- {/* Gross Profit */} - = 0 ? "rgba(57, 255, 20, 0.12)" : "rgba(255, 0, 60, 0.12)"} - innerClassName="!p-5" - > -
-
- = 0 ? NEON_GREEN : NEON_RED} /> -

Gross Profit

-
-
- - {openTooltip === 'gross' && ( -
-

Gross Profit Formula

-

Total Received − Total Actual Costs

-
-

{formatCurrency(totalReceived)} − {formatCurrency(profitMetrics.totalCosts)} = {formatCurrency(profitMetrics.grossProfit)}

-
-
- )} -
-
-
= 0 ? NEON_GREEN : NEON_RED}`}> - {profitMetrics.grossProfit >= 0 ? '+' : ''}{formatCurrency(profitMetrics.grossProfit)} -
-
- = 0 - ? 'text-green-600 dark:text-[#39ff14] bg-green-50 dark:bg-[#39ff14]/10 border border-green-200 dark:border-[#39ff14]/20' - : 'text-red-600 dark:text-[#ff003c] bg-red-50 dark:bg-[#ff003c]/10 border border-red-200 dark:border-[#ff003c]/20' - }`}> - {profitMetrics.grossMargin >= 0 ? '+' : ''}{profitMetrics.grossMargin.toFixed(1)}% margin - -
-
- - {/* Net Profit */} - = 0 ? "rgba(57, 255, 20, 0.12)" : "rgba(255, 0, 60, 0.12)"} - innerClassName="!p-5" - > -
-
- = 0 ? NEON_GREEN : NEON_RED} /> -

Net Profit

-
-
- - {openTooltip === 'net' && ( -
-

Net Profit Formula

-

Gross Profit − Commission − Addl. Expenses

-
-

{formatCurrency(profitMetrics.grossProfit)} − {formatCurrency(profitMetrics.commission)} − {formatCurrency(profitMetrics.additionalExp)} = {formatCurrency(profitMetrics.netProfit)}

-
-
- )} -
-
-
= 0 ? NEON_GREEN : NEON_RED}`}> - {profitMetrics.netProfit >= 0 ? '+' : ''}{formatCurrency(profitMetrics.netProfit)} -
-
- = 0 - ? 'text-green-600 dark:text-[#39ff14] bg-green-50 dark:bg-[#39ff14]/10 border border-green-200 dark:border-[#39ff14]/20' - : 'text-red-600 dark:text-[#ff003c] bg-red-50 dark:bg-[#ff003c]/10 border border-red-200 dark:border-[#ff003c]/20' - }`}> - {profitMetrics.netMargin >= 0 ? '+' : ''}{profitMetrics.netMargin.toFixed(1)}% margin - -
-
-
- - {/* Payments Table */} - -
-
- -

Payments Received

- - {payments.length} - -
- -
- - {payments.length > 0 ? ( - <> - {/* Desktop Table */} -
- - - - - - - - - - - - {paginatedPayments.map((pay) => ( - - - - - - - - ))} - -
From / Method togglePaymentSort('amount')} - > - Amount {paymentSort.field === 'amount' ? (paymentSort.dir === 'asc' ? '↑' : '↓') : ''} - togglePaymentSort('date')} - > - Date {paymentSort.field === 'date' ? (paymentSort.dir === 'asc' ? '↑' : '↓') : ''} - Check No. / RefMemo
-
{pay.from}
- - {pay.method} - -
- {formatCurrency(pay.amount)} - {pay.date}{pay.refNumber || '—'}{pay.memo || '—'}
-
- - {/* Mobile Card View */} -
- {paginatedPayments.map((pay) => ( -
-
-
-

{pay.from}

- - {pay.method} - -
- {formatCurrency(pay.amount)} -
-
-
- Date -

{pay.date}

-
-
- Ref # -

{pay.refNumber || '—'}

-
-
- {pay.memo && ( -

{pay.memo}

- )} -
- ))} -
- - {/* Pagination */} - {totalPaymentPages > 1 && ( -
-

- {paymentPage * PAYMENTS_PER_PAGE + 1}–{Math.min((paymentPage + 1) * PAYMENTS_PER_PAGE, sortedPayments.length)} of {sortedPayments.length} -

-
- - -
-
- )} - - ) : ( -
- -

No payments recorded yet.

-

Record your first payment to start tracking.

-
- )} -
-
- )} - {/* RISK & ISSUES TAB */} {activeTab === 'risks' && (
From ab701c5bd9e07673a339ebb70c09baa8c1f2f59e Mon Sep 17 00:00:00 2001 From: Satyam Rastogi Date: Tue, 12 May 2026 17:37:11 +0530 Subject: [PATCH 07/38] add name for each entry of budget & cost table --- src/data/mockStore.jsx | 196 ++++++++++++++++++++--------------------- 1 file changed, 98 insertions(+), 98 deletions(-) diff --git a/src/data/mockStore.jsx b/src/data/mockStore.jsx index cf3fa90..15105b5 100644 --- a/src/data/mockStore.jsx +++ b/src/data/mockStore.jsx @@ -2022,12 +2022,12 @@ const MOCK_PROJECTS = [ changeOrderCount: 0, pendingInvoiceCount: 2, budgetBreakdown: [ - { category: 'Materials - Shingles', allocated: 15000, committed: 14200, actual: 8500 }, - { category: 'Materials - Underlayment', allocated: 3500, committed: 3355, actual: 3355 }, - { category: 'Labor - Tear Off', allocated: 8000, committed: 8000, actual: 8000 }, - { category: 'Labor - Install', allocated: 12000, committed: 7200, actual: 0 }, - { category: 'Permits & Inspection', allocated: 2500, committed: 2500, actual: 2645 }, - { category: 'Cleanup & Haul-Away', allocated: 4000, committed: 3245, actual: 0 } + { name: 'ABC Supply Co.', category: 'Materials - Shingles', allocated: 15000, committed: 14200, actual: 8500 }, + { name: 'ABC Supply Co.', category: 'Materials - Underlayment', allocated: 3500, committed: 3355, actual: 3355 }, + { name: 'Texas Roofing Crew A', category: 'Labor - Tear Off', allocated: 8000, committed: 8000, actual: 8000 }, + { name: 'Texas Roofing Crew A', category: 'Labor - Install', allocated: 12000, committed: 7200, actual: 0 }, + { name: 'City of Plano', category: 'Permits & Inspection', allocated: 2500, committed: 2500, actual: 2645 }, + { name: 'Junk King DFW', category: 'Cleanup & Haul-Away', allocated: 4000, committed: 3245, actual: 0 } ], changeOrders: [], rfis: [], @@ -2156,11 +2156,11 @@ const MOCK_PROJECTS = [ { id: 'ms_003_6', name: 'System Test and Commissioning', dueDate: '2026-02-25', status: 'pending', assignedTo: 'v6' } ], budgetBreakdown: [ - { category: 'Equipment & Materials', allocated: 15000, committed: 15000, actual: 12500 }, - { category: 'Labor - HVAC', allocated: 8000, committed: 8000, actual: 4000 }, - { category: 'Labor - Electrical', allocated: 3000, committed: 3000, actual: 0 }, - { category: 'Permits & Fees', allocated: 1000, committed: 1000, actual: 950 }, - { category: 'Contingency', allocated: 5000, committed: 1200, actual: 950 } + { name: 'Comfort Air Systems', category: 'Equipment & Materials', allocated: 15000, committed: 15000, actual: 12500 }, + { name: 'Comfort Air Systems', category: 'Labor - HVAC', allocated: 8000, committed: 8000, actual: 4000 }, + { name: 'Sparky Electric', category: 'Labor - Electrical', allocated: 3000, committed: 3000, actual: 0 }, + { name: 'City of Plano', category: 'Permits & Fees', allocated: 1000, committed: 1000, actual: 950 }, + { name: 'Reserve Fund', category: 'Contingency', allocated: 5000, committed: 1200, actual: 950 } ], invoices: [ { id: 'inv_003_1', amount: 8200, submittedBy: 'con_001', status: 'paid', dueDate: '2026-01-15', datePaid: '2026-01-16' }, @@ -2232,13 +2232,13 @@ const MOCK_PROJECTS = [ { id: 'ms_004_7', name: 'Final Punch List', dueDate: '2026-02-25', status: 'pending', assignedTo: 'con_001' } ], budgetBreakdown: [ - { category: 'Demolition', allocated: 5000, committed: 5500, actual: 5500 }, - { category: 'Framing & Structural', allocated: 8000, committed: 8500, actual: 8500 }, - { category: 'Electrical', allocated: 7000, committed: 7000, actual: 4800 }, - { category: 'Plumbing', allocated: 6000, committed: 6500, actual: 6500 }, - { category: 'Drywall & Paint', allocated: 9000, committed: 10000, actual: 10000 }, - { category: 'Flooring & Trim', allocated: 12000, committed: 15500, actual: 15500 }, - { category: 'Contingency', allocated: 8000, committed: 9300, actual: 11500 } + { name: 'DemoCrew Pro', category: 'Demolition', allocated: 5000, committed: 5500, actual: 5500 }, + { name: 'Texas Builders LLC', category: 'Framing & Structural', allocated: 8000, committed: 8500, actual: 8500 }, + { name: 'Sparky Electric', category: 'Electrical', allocated: 7000, committed: 7000, actual: 4800 }, + { name: 'DFW Foundation Pros', category: 'Plumbing', allocated: 6000, committed: 6500, actual: 6500 }, + { name: 'Premier Finishes', category: 'Drywall & Paint', allocated: 9000, committed: 10000, actual: 10000 }, + { name: 'FloorCraft Inc.', category: 'Flooring & Trim', allocated: 12000, committed: 15500, actual: 15500 }, + { name: 'Reserve Fund', category: 'Contingency', allocated: 8000, committed: 9300, actual: 11500 } ], changeOrders: [ { id: 'CO-004-01', title: 'Scope Expansion - Kitchen Island', amount: 4500, status: 'approved', dateSubmitted: '2026-01-10', description: 'Add a custom 6ft island with quartz countertop and built-in microwave.' }, @@ -2316,11 +2316,11 @@ const MOCK_PROJECTS = [ { id: 'ms_005_5', name: 'Trim and Caulking', dueDate: '2026-02-15', status: 'pending', assignedTo: 'con_001' } ], budgetBreakdown: [ - { category: 'Materials - Siding', allocated: 12000, committed: 12000, actual: 8000 }, - { category: 'Labor - Prep & Install', allocated: 9000, committed: 9000, actual: 3500 }, - { category: 'Equipment Rental', allocated: 2000, committed: 2000, actual: 1500 }, - { category: 'Structural Repairs (CO)', allocated: 0, committed: 850, actual: 850 }, - { category: 'Contingency', allocated: 5000, committed: 5000, actual: 350 } + { name: 'ABC Supply Co.', category: 'Materials - Siding', allocated: 12000, committed: 12000, actual: 8000 }, + { name: 'Siding Solutions TX', category: 'Labor - Prep & Install', allocated: 9000, committed: 9000, actual: 3500 }, + { name: 'Sunbelt Rentals', category: 'Equipment Rental', allocated: 2000, committed: 2000, actual: 1500 }, + { name: 'Texas Builders LLC', category: 'Structural Repairs (CO)', allocated: 0, committed: 850, actual: 850 }, + { name: 'Reserve Fund', category: 'Contingency', allocated: 5000, committed: 5000, actual: 350 } ], invoices: [ { id: 'inv_005_1', amount: 8400, submittedBy: 'con_001', status: 'paid', dueDate: '2026-01-12', datePaid: '2026-01-13' }, @@ -2433,14 +2433,14 @@ const MOCK_PROJECTS = [ changeOrderCount: 0, pendingInvoiceCount: 2, budgetBreakdown: [ - { category: 'Materials - TPO Membrane', allocated: 38000, committed: 35400, actual: 11400 }, - { category: 'Materials - Insulation', allocated: 18000, committed: 16800, actual: 5550 }, - { category: 'Materials - Fasteners/Adhesive', allocated: 8000, committed: 7200, actual: 2080 }, - { category: 'Labor - Section A', allocated: 22000, committed: 18500, actual: 18500 }, - { category: 'Labor - Section B', allocated: 22000, committed: 8000, actual: 0 }, - { category: 'Crane Rental', allocated: 8000, committed: 5600, actual: 4200 }, - { category: 'Permits & Inspection', allocated: 4000, committed: 3500, actual: 3270 }, - { category: 'Safety Equipment', allocated: 5000, committed: 3000, actual: 3000 } + { name: 'Firestone Building Products', category: 'Materials - TPO Membrane', allocated: 38000, committed: 35400, actual: 11400 }, + { name: 'Owens Corning', category: 'Materials - Insulation', allocated: 18000, committed: 16800, actual: 5550 }, + { name: 'ABC Supply Co.', category: 'Materials - Fasteners/Adhesive', allocated: 8000, committed: 7200, actual: 2080 }, + { name: 'Texas Roofing Crew A', category: 'Labor - Section A', allocated: 22000, committed: 18500, actual: 18500 }, + { name: 'Texas Roofing Crew A', category: 'Labor - Section B', allocated: 22000, committed: 8000, actual: 0 }, + { name: 'All American Crane', category: 'Crane Rental', allocated: 8000, committed: 5600, actual: 4200 }, + { name: 'City of Plano', category: 'Permits & Inspection', allocated: 4000, committed: 3500, actual: 3270 }, + { name: 'SafetyFirst Supply', category: 'Safety Equipment', allocated: 5000, committed: 3000, actual: 3000 } ], changeOrders: [ { id: 'CO-007-1', title: 'Additional Drainage Scuppers', amount: 4200, status: 'pending', dateSubmitted: '2026-02-10', description: 'Building management requested 4 additional scuppers on east elevation for improved water management' } @@ -4363,10 +4363,10 @@ const KANBAN_PROJECT_DATA = { estimatedAmount: 18200, actualCost: 0, variancePercent: 0, completionPct: 25, healthScore: 88, contractorName: 'Plano Roofing Crew A', contractorPhone: '(972) 555-9001', budgetBreakdown: [ - { category: 'Materials', allocated: 9800, committed: 9800, actual: 0 }, - { category: 'Labor', allocated: 6200, committed: 6200, actual: 0 }, - { category: 'Permits', allocated: 600, committed: 600, actual: 0 }, - { category: 'Contingency',allocated: 1600, committed: 0, actual: 0 }, + { name: 'ABC Supply Co.', category: 'Materials', allocated: 9800, committed: 9800, actual: 0 }, + { name: 'Plano Roofing Crew A', category: 'Labor', allocated: 6200, committed: 6200, actual: 0 }, + { name: 'City of Plano', category: 'Permits', allocated: 600, committed: 600, actual: 0 }, + { name: 'Reserve Fund', category: 'Contingency',allocated: 1600, committed: 0, actual: 0 }, ], milestones: [ { id: 'm1', name: 'Contract Signed', status: 'completed', date: '2026-03-22', notes: 'Deposit received.' }, @@ -4394,10 +4394,10 @@ const KANBAN_PROJECT_DATA = { estimatedAmount: 31500, actualCost: 0, variancePercent: 0, completionPct: 20, healthScore: 85, contractorName: 'Plano Siding Crew', contractorPhone: '(972) 555-9002', budgetBreakdown: [ - { category: 'Materials', allocated: 17000, committed: 17000, actual: 0 }, - { category: 'Labor', allocated: 11500, committed: 11500, actual: 0 }, - { category: 'Permits', allocated: 800, committed: 800, actual: 0 }, - { category: 'Contingency',allocated: 2200, committed: 0, actual: 0 }, + { name: 'Owens Corning', category: 'Materials', allocated: 17000, committed: 17000, actual: 0 }, + { name: 'Plano Siding Crew', category: 'Labor', allocated: 11500, committed: 11500, actual: 0 }, + { name: 'City of Plano', category: 'Permits', allocated: 800, committed: 800, actual: 0 }, + { name: 'Reserve Fund', category: 'Contingency',allocated: 2200, committed: 0, actual: 0 }, ], milestones: [ { id: 'm1', name: 'Contract Signed', status: 'completed', date: '2026-03-20', notes: 'Insurance approved full scope.' }, @@ -4428,10 +4428,10 @@ const KANBAN_PROJECT_DATA = { estimatedAmount: 27800, actualCost: 0, variancePercent: 0, completionPct: 28, healthScore: 92, contractorName: 'Plano Roofing Crew B', contractorPhone: '(972) 555-9003', budgetBreakdown: [ - { category: 'Materials', allocated: 15200, committed: 15200, actual: 0 }, - { category: 'Labor', allocated: 9800, committed: 9800, actual: 0 }, - { category: 'Permits', allocated: 600, committed: 600, actual: 0 }, - { category: 'Contingency',allocated: 2200, committed: 0, actual: 0 }, + { name: 'GAF Materials', category: 'Materials', allocated: 15200, committed: 15200, actual: 0 }, + { name: 'Plano Roofing Crew B', category: 'Labor', allocated: 9800, committed: 9800, actual: 0 }, + { name: 'City of Plano', category: 'Permits', allocated: 600, committed: 600, actual: 0 }, + { name: 'Reserve Fund', category: 'Contingency',allocated: 2200, committed: 0, actual: 0 }, ], milestones: [ { id: 'm1', name: 'Contract Signed', status: 'completed', date: '2026-03-11', notes: '50% deposit received.' }, @@ -4458,11 +4458,11 @@ const KANBAN_PROJECT_DATA = { estimatedAmount: 22400, actualCost: 0, variancePercent: 0, completionPct: 22, healthScore: 86, contractorName: 'Plano Roofing Crew A', contractorPhone: '(972) 555-9001', budgetBreakdown: [ - { category: 'Roofing Materials', allocated: 10800, committed: 10800, actual: 0 }, - { category: 'Gutter Materials', allocated: 3400, committed: 3400, actual: 0 }, - { category: 'Labor', allocated: 6400, committed: 6400, actual: 0 }, - { category: 'Permits', allocated: 600, committed: 600, actual: 0 }, - { category: 'Contingency', allocated: 1200, committed: 0, actual: 0 }, + { name: 'ABC Supply Co.', category: 'Roofing Materials', allocated: 10800, committed: 10800, actual: 0 }, + { name: 'LeafGuard DFW', category: 'Gutter Materials', allocated: 3400, committed: 3400, actual: 0 }, + { name: 'Plano Roofing Crew A', category: 'Labor', allocated: 6400, committed: 6400, actual: 0 }, + { name: 'City of Plano', category: 'Permits', allocated: 600, committed: 600, actual: 0 }, + { name: 'Reserve Fund', category: 'Contingency', allocated: 1200, committed: 0, actual: 0 }, ], milestones: [ { id: 'm1', name: 'Contract Signed', status: 'completed', date: '2026-03-25', notes: 'Install Apr 18.' }, @@ -4489,10 +4489,10 @@ const KANBAN_PROJECT_DATA = { estimatedAmount: 19600, actualCost: 0, variancePercent: 0, completionPct: 24, healthScore: 80, contractorName: 'Plano Roofing Crew C', contractorPhone: '(972) 555-9004', budgetBreakdown: [ - { category: 'Materials', allocated: 10500, committed: 10500, actual: 0 }, - { category: 'Labor', allocated: 7000, committed: 7000, actual: 0 }, - { category: 'Permits', allocated: 600, committed: 600, actual: 0 }, - { category: 'Contingency',allocated: 1500, committed: 0, actual: 0 }, + { name: 'James Hardie', category: 'Materials', allocated: 10500, committed: 10500, actual: 0 }, + { name: 'Plano Roofing Crew C', category: 'Labor', allocated: 7000, committed: 7000, actual: 0 }, + { name: 'City of Plano', category: 'Permits', allocated: 600, committed: 600, actual: 0 }, + { name: 'Reserve Fund', category: 'Contingency',allocated: 1500, committed: 0, actual: 0 }, ], milestones: [ { id: 'm1', name: 'Contract Signed', status: 'completed', date: '2026-03-28', notes: 'Supplement approved after adjuster re-inspection.' }, @@ -4523,10 +4523,10 @@ const KANBAN_PROJECT_DATA = { estimatedAmount: 16800, actualCost: 9200, variancePercent: -3.6, completionPct: 65, healthScore: 82, contractorName: 'Plano Roofing Crew A', contractorPhone: '(972) 555-9001', budgetBreakdown: [ - { category: 'Materials', allocated: 9000, committed: 9000, actual: 8600 }, - { category: 'Labor', allocated: 5800, committed: 5800, actual: 600 }, - { category: 'Permits', allocated: 600, committed: 600, actual: 600 }, - { category: 'Contingency',allocated: 1400, committed: 0, actual: 0 }, + { name: 'ABC Supply Co.', category: 'Materials', allocated: 9000, committed: 9000, actual: 8600 }, + { name: 'Plano Roofing Crew A', category: 'Labor', allocated: 5800, committed: 5800, actual: 600 }, + { name: 'City of Plano', category: 'Permits', allocated: 600, committed: 600, actual: 600 }, + { name: 'Reserve Fund', category: 'Contingency',allocated: 1400, committed: 0, actual: 0 }, ], milestones: [ { id: 'm1', name: 'Contract Signed', status: 'completed', date: '2026-02-28', notes: '' }, @@ -4557,10 +4557,10 @@ const KANBAN_PROJECT_DATA = { estimatedAmount: 28500, actualCost: 14800, variancePercent: 1.4, completionPct: 55, healthScore: 78, contractorName: 'Plano Siding Crew', contractorPhone: '(972) 555-9002', budgetBreakdown: [ - { category: 'Materials', allocated: 15400, committed: 15400, actual: 14200 }, - { category: 'Labor', allocated: 10400, committed: 10400, actual: 600 }, - { category: 'Permits', allocated: 800, committed: 800, actual: 800 }, - { category: 'Contingency', allocated: 1900, committed: 400, actual: 0 }, + { name: 'Owens Corning', category: 'Materials', allocated: 15400, committed: 15400, actual: 14200 }, + { name: 'Plano Siding Crew', category: 'Labor', allocated: 10400, committed: 10400, actual: 600 }, + { name: 'City of Plano', category: 'Permits', allocated: 800, committed: 800, actual: 800 }, + { name: 'Reserve Fund', category: 'Contingency', allocated: 1900, committed: 400, actual: 0 }, ], milestones: [ { id: 'm1', name: 'Contract Signed', status: 'completed', date: '2026-02-25', notes: '' }, @@ -4591,11 +4591,11 @@ const KANBAN_PROJECT_DATA = { estimatedAmount: 21200, actualCost: 16100, variancePercent: -4.2, completionPct: 78, healthScore: 88, contractorName: 'Plano Roofing Crew B', contractorPhone: '(972) 555-9003', budgetBreakdown: [ - { category: 'Roofing Materials', allocated: 10200, committed: 10200, actual: 9800 }, - { category: 'Gutter Materials', allocated: 3200, committed: 3200, actual: 3100 }, - { category: 'Labor', allocated: 6000, committed: 6000, actual: 3200 }, - { category: 'Permits', allocated: 600, committed: 600, actual: 600 }, - { category: 'Contingency', allocated: 1200, committed: 0, actual: 0 }, + { name: 'ABC Supply Co.', category: 'Roofing Materials', allocated: 10200, committed: 10200, actual: 9800 }, + { name: 'LeafGuard DFW', category: 'Gutter Materials', allocated: 3200, committed: 3200, actual: 3100 }, + { name: 'Plano Roofing Crew B', category: 'Labor', allocated: 6000, committed: 6000, actual: 3200 }, + { name: 'City of Plano', category: 'Permits', allocated: 600, committed: 600, actual: 600 }, + { name: 'Reserve Fund', category: 'Contingency', allocated: 1200, committed: 0, actual: 0 }, ], milestones: [ { id: 'm1', name: 'Contract Signed', status: 'completed', date: '2026-02-24', notes: '' }, @@ -4625,10 +4625,10 @@ const KANBAN_PROJECT_DATA = { estimatedAmount: 34500, actualCost: 22000, variancePercent: 2.9, completionPct: 60, healthScore: 90, contractorName: 'Plano Roofing Crew C', contractorPhone: '(972) 555-9004', budgetBreakdown: [ - { category: 'Premium Materials', allocated: 19200, committed: 19200, actual: 18800 }, - { category: 'Labor', allocated: 12000, committed: 12000, actual: 3200 }, - { category: 'Permits', allocated: 700, committed: 700, actual: 700 }, - { category: 'Contingency', allocated: 2600, committed: 1000, actual: 0 }, + { name: 'Owens Corning', category: 'Premium Materials', allocated: 19200, committed: 19200, actual: 18800 }, + { name: 'Plano Roofing Crew C', category: 'Labor', allocated: 12000, committed: 12000, actual: 3200 }, + { name: 'City of Plano', category: 'Permits', allocated: 700, committed: 700, actual: 700 }, + { name: 'Reserve Fund', category: 'Contingency', allocated: 2600, committed: 1000, actual: 0 }, ], milestones: [ { id: 'm1', name: 'Contract Signed', status: 'completed', date: '2026-03-03', notes: 'Cash client. Barkwood color selected.' }, @@ -4656,10 +4656,10 @@ const KANBAN_PROJECT_DATA = { estimatedAmount: 9400, actualCost: 6200, variancePercent: -1.1, completionPct: 70, healthScore: 84, contractorName: 'Plano Siding Crew', contractorPhone: '(972) 555-9002', budgetBreakdown: [ - { category: 'Materials', allocated: 4800, committed: 4800, actual: 4600 }, - { category: 'Labor', allocated: 3600, committed: 3600, actual: 1600 }, - { category: 'Permits', allocated: 500, committed: 500, actual: 500 }, - { category: 'Contingency',allocated: 500, committed: 0, actual: 0 }, + { name: 'GAF Materials', category: 'Materials', allocated: 4800, committed: 4800, actual: 4600 }, + { name: 'Plano Siding Crew', category: 'Labor', allocated: 3600, committed: 3600, actual: 1600 }, + { name: 'City of Plano', category: 'Permits', allocated: 500, committed: 500, actual: 500 }, + { name: 'Reserve Fund', category: 'Contingency',allocated: 500, committed: 0, actual: 0 }, ], milestones: [ { id: 'm1', name: 'Contract Signed', status: 'completed', date: '2026-03-07', notes: '' }, @@ -4686,10 +4686,10 @@ const KANBAN_PROJECT_DATA = { estimatedAmount: 42000, actualCost: 18000, variancePercent: 0.8, completionPct: 45, healthScore: 74, contractorName: 'Plano Roofing Crew A', contractorPhone: '(972) 555-9001', budgetBreakdown: [ - { category: 'Materials', allocated: 22000, committed: 22000, actual: 17200 }, - { category: 'Labor', allocated: 15500, committed: 15500, actual: 800 }, - { category: 'Permits', allocated: 900, committed: 900, actual: 900 }, - { category: 'Contingency', allocated: 3600, committed: 2000, actual: 0 }, + { name: 'James Hardie', category: 'Materials', allocated: 22000, committed: 22000, actual: 17200 }, + { name: 'Plano Roofing Crew A', category: 'Labor', allocated: 15500, committed: 15500, actual: 800 }, + { name: 'City of Plano', category: 'Permits', allocated: 900, committed: 900, actual: 900 }, + { name: 'Reserve Fund', category: 'Contingency', allocated: 3600, committed: 2000, actual: 0 }, ], milestones: [ { id: 'm1', name: 'Contract Signed', status: 'completed', date: '2026-02-15', notes: 'Largest job this quarter.' }, @@ -4721,10 +4721,10 @@ const KANBAN_PROJECT_DATA = { estimatedAmount: 17200, actualCost: 16950, variancePercent: -1.5, completionPct: 100, healthScore: 95, contractorName: 'Plano Roofing Crew B', contractorPhone: '(972) 555-9003', budgetBreakdown: [ - { category: 'Materials', allocated: 9200, committed: 9200, actual: 8980 }, - { category: 'Labor', allocated: 6000, committed: 6000, actual: 6000 }, - { category: 'Permits', allocated: 600, committed: 600, actual: 600 }, - { category: 'Contingency', allocated: 1400, committed: 400, actual: 0 }, + { name: 'ABC Supply Co.', category: 'Materials', allocated: 9200, committed: 9200, actual: 8980 }, + { name: 'Plano Roofing Crew B', category: 'Labor', allocated: 6000, committed: 6000, actual: 6000 }, + { name: 'City of Plano', category: 'Permits', allocated: 600, committed: 600, actual: 600 }, + { name: 'Reserve Fund', category: 'Contingency', allocated: 1400, committed: 400, actual: 0 }, ], milestones: [ { id: 'm1', name: 'Contract Signed', status: 'completed', date: '2026-02-01', notes: '' }, @@ -4754,10 +4754,10 @@ const KANBAN_PROJECT_DATA = { estimatedAmount: 26400, actualCost: 26100, variancePercent: -1.1, completionPct: 100, healthScore: 93, contractorName: 'Plano Siding Crew', contractorPhone: '(972) 555-9002', budgetBreakdown: [ - { category: 'Materials', allocated: 14200, committed: 14200, actual: 13900 }, - { category: 'Labor', allocated: 9600, committed: 9600, actual: 9600 }, - { category: 'Permits', allocated: 800, committed: 800, actual: 800 }, - { category: 'Contingency', allocated: 1800, committed: 200, actual: 0 }, + { name: 'Owens Corning', category: 'Materials', allocated: 14200, committed: 14200, actual: 13900 }, + { name: 'Plano Siding Crew', category: 'Labor', allocated: 9600, committed: 9600, actual: 9600 }, + { name: 'City of Plano', category: 'Permits', allocated: 800, committed: 800, actual: 800 }, + { name: 'Reserve Fund', category: 'Contingency', allocated: 1800, committed: 200, actual: 0 }, ], milestones: [ { id: 'm1', name: 'Contract Signed', status: 'completed', date: '2026-01-28', notes: '' }, @@ -4786,10 +4786,10 @@ const KANBAN_PROJECT_DATA = { estimatedAmount: 22100, actualCost: 21800, variancePercent: -1.4, completionPct: 100, healthScore: 97, contractorName: 'Plano Roofing Crew C', contractorPhone: '(972) 555-9004', budgetBreakdown: [ - { category: 'Materials', allocated: 12000, committed: 12000, actual: 11800 }, - { category: 'Labor', allocated: 7800, committed: 7800, actual: 7800 }, - { category: 'Permits', allocated: 600, committed: 600, actual: 600 }, - { category: 'Contingency', allocated: 1700, committed: 0, actual: 0 }, + { name: 'GAF Materials', category: 'Materials', allocated: 12000, committed: 12000, actual: 11800 }, + { name: 'Plano Roofing Crew C', category: 'Labor', allocated: 7800, committed: 7800, actual: 7800 }, + { name: 'City of Plano', category: 'Permits', allocated: 600, committed: 600, actual: 600 }, + { name: 'Reserve Fund', category: 'Contingency', allocated: 1700, committed: 0, actual: 0 }, ], milestones: [ { id: 'm1', name: 'Contract Signed', status: 'completed', date: '2026-01-30', notes: 'Full payment upfront.' }, @@ -4815,11 +4815,11 @@ const KANBAN_PROJECT_DATA = { estimatedAmount: 8900, actualCost: 8650, variancePercent: -2.8, completionPct: 100, healthScore: 96, contractorName: 'Plano Roofing Crew A', contractorPhone: '(972) 555-9001', budgetBreakdown: [ - { category: 'Gutter Materials', allocated: 3800, committed: 3800, actual: 3600 }, - { category: 'Fascia Materials', allocated: 1800, committed: 1800, actual: 1800 }, - { category: 'Labor', allocated: 2600, committed: 2600, actual: 2600 }, - { category: 'Permits', allocated: 400, committed: 400, actual: 400 }, - { category: 'Contingency', allocated: 300, committed: 0, actual: 0 }, + { name: 'LeafGuard DFW', category: 'Gutter Materials', allocated: 3800, committed: 3800, actual: 3600 }, + { name: 'James Hardie', category: 'Fascia Materials', allocated: 1800, committed: 1800, actual: 1800 }, + { name: 'Plano Roofing Crew A', category: 'Labor', allocated: 2600, committed: 2600, actual: 2600 }, + { name: 'City of Plano', category: 'Permits', allocated: 400, committed: 400, actual: 400 }, + { name: 'Reserve Fund', category: 'Contingency', allocated: 300, committed: 0, actual: 0 }, ], milestones: [ { id: 'm1', name: 'Contract Signed', status: 'completed', date: '2026-02-10', notes: '' }, @@ -4845,10 +4845,10 @@ const KANBAN_PROJECT_DATA = { estimatedAmount: 19400, actualCost: 20100, variancePercent: 3.6, completionPct: 100, healthScore: 87, contractorName: 'Plano Roofing Crew B', contractorPhone: '(972) 555-9003', budgetBreakdown: [ - { category: 'Materials', allocated: 10400, committed: 10400, actual: 11200 }, - { category: 'Labor', allocated: 6800, committed: 6800, actual: 6800 }, - { category: 'Permits', allocated: 600, committed: 600, actual: 600 }, - { category: 'Contingency', allocated: 1600, committed: 1600, actual: 0 }, + { name: 'James Hardie', category: 'Materials', allocated: 10400, committed: 10400, actual: 11200 }, + { name: 'Plano Roofing Crew B', category: 'Labor', allocated: 6800, committed: 6800, actual: 6800 }, + { name: 'City of Plano', category: 'Permits', allocated: 600, committed: 600, actual: 600 }, + { name: 'Reserve Fund', category: 'Contingency', allocated: 1600, committed: 1600, actual: 0 }, ], milestones: [ { id: 'm1', name: 'Contract Signed', status: 'completed', date: '2026-02-04', notes: '' }, From 3b1a77f5002639598929420e778f9252b22e0bb2 Mon Sep 17 00:00:00 2001 From: Satyam Rastogi Date: Tue, 12 May 2026 19:29:52 +0530 Subject: [PATCH 08/38] team management feature in Owners Box --- src/components/owner/TeamManagementPanel.jsx | 730 +++++++++++++++++++ src/data/mockStore.jsx | 78 +- src/pages/owner/OwnerSnapshot.jsx | 68 +- 3 files changed, 873 insertions(+), 3 deletions(-) create mode 100644 src/components/owner/TeamManagementPanel.jsx diff --git a/src/components/owner/TeamManagementPanel.jsx b/src/components/owner/TeamManagementPanel.jsx new file mode 100644 index 0000000..0a87ae9 --- /dev/null +++ b/src/components/owner/TeamManagementPanel.jsx @@ -0,0 +1,730 @@ +import React, { useState, useMemo, useEffect, useRef } from 'react'; +import { createPortal } from 'react-dom'; +import { motion, AnimatePresence } from 'framer-motion'; +import { + UserPlus, UserMinus, Shield, ChevronDown, Search, Mail, + X as XIcon, Users, Briefcase, DollarSign, MoreVertical, + Check, Clock, Crown, ChevronUp +} from 'lucide-react'; + +const JOB_ROLES = [ + 'Project Manager', + 'Sales Rep', + 'Lead Installer', + 'Estimator', + 'Inspector', + 'Canvasser', + 'Subcontractor', + 'Supplier', +]; + +const COMMISSION_TYPES = { + percent_gross: '% of Gross', + percent_net: '% of Net', + flat: 'Flat Amount', + custom: 'Custom / Manual', +}; + +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' }, + 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' }, +}; + +const ROLE_COLORS = { + 'Project Manager': 'text-cyan-400 bg-cyan-500/10 border-cyan-500/20', + 'Sales Rep': 'text-green-400 bg-green-500/10 border-green-500/20', + 'Lead Installer': 'text-orange-400 bg-orange-500/10 border-orange-500/20', + 'Estimator': 'text-blue-400 bg-blue-500/10 border-blue-500/20', + 'Inspector': 'text-purple-400 bg-purple-500/10 border-purple-500/20', + 'Canvasser': 'text-teal-400 bg-teal-500/10 border-teal-500/20', + 'Subcontractor': 'text-rose-400 bg-rose-500/10 border-rose-500/20', + 'Supplier': 'text-yellow-400 bg-yellow-500/10 border-yellow-500/20', +}; + +function resolveCommissionForMember(member, userCommissionOverrides, orgCommissionDefaults) { + if (member.jobCommission) { + return { type: member.jobCommission.type, rate: Number(member.jobCommission.rate) || 0, source: 'job' }; + } + const userOvr = (userCommissionOverrides || []).find(o => o.enabled && o.userId === member.userId); + if (userOvr) { + return { type: userOvr.type, rate: Number(userOvr.rate) || 0, source: 'user' }; + } + return { type: orgCommissionDefaults.type, rate: Number(orgCommissionDefaults.rate) || 0, source: 'org' }; +} + +function calcCommissionValue(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; + } +} + +const formatCurrency = (amt) => + new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', maximumFractionDigits: 0 }).format(Number(amt) || 0); + +const TeamManagementPanel = ({ + project, + teamMembers = [], + allUsers = [], + personnel = [], + userRole, + orgCommissionDefaults, + userCommissionOverrides, + totalReceived = 0, + grossProfit = 0, + onAddMember, + onRemoveMember, + onUpdateMember, +}) => { + const [inviteOpen, setInviteOpen] = useState(false); + const [removeConfirm, setRemoveConfirm] = useState(null); + const [actionMenu, setActionMenu] = useState(null); + const [editingRole, setEditingRole] = useState(null); + const [commissionOpen, setCommissionOpen] = useState(false); + const [editingCommission, setEditingCommission] = useState(null); + + const menuRef = useRef(null); + + const canManageTeam = ['OWNER', 'ADMIN'].includes(userRole); + const canManageCommissions = ['OWNER', 'ADMIN'].includes(userRole); + const canViewCommissions = ['OWNER', 'ADMIN', 'CONTRACTOR'].includes(userRole); + const isSubcontractor = userRole === 'SUBCONTRACTOR'; + + useEffect(() => { + if (!actionMenu) return; + const handleClick = (e) => { + if (menuRef.current && !menuRef.current.contains(e.target)) setActionMenu(null); + }; + document.addEventListener('mousedown', handleClick); + return () => document.removeEventListener('mousedown', handleClick); + }, [actionMenu]); + + const visibleMembers = useMemo(() => { + if (isSubcontractor) { + const currentUserId = allUsers?.find(u => u.role === 'SUBCONTRACTOR')?.id; + return teamMembers.filter(m => m.userId === currentUserId); + } + return teamMembers; + }, [teamMembers, isSubcontractor, allUsers]); + + const memberCommissions = useMemo(() => { + return visibleMembers.map(m => { + const resolved = resolveCommissionForMember(m, userCommissionOverrides, orgCommissionDefaults); + const value = calcCommissionValue(resolved, totalReceived, grossProfit); + return { ...m, resolved, value }; + }); + }, [visibleMembers, userCommissionOverrides, orgCommissionDefaults, totalReceived, grossProfit]); + + const activeMembers = visibleMembers.filter(m => m.status === 'active'); + const pendingMembers = visibleMembers.filter(m => m.status === 'pending'); + + return ( + <> +
+ {/* Header */} +
+
+ +

+ Team Management +

+ + {activeMembers.length} active{pendingMembers.length > 0 && ` · ${pendingMembers.length} pending`} + +
+ {canManageTeam && ( + setInviteOpen(true)} + className="flex items-center gap-2 px-4 py-2 bg-violet-500/10 hover:bg-violet-500/20 text-violet-600 dark:text-violet-400 border border-violet-500/30 rounded-xl text-xs font-bold transition-colors" + > + Invite Member + + )} +
+ + {/* Member Grid */} + {visibleMembers.length === 0 ? ( +
+ +

No team members assigned to this job yet.

+ {canManageTeam && ( + + )} +
+ ) : ( +
+ + {visibleMembers.map((member) => ( + +
+ {/* Avatar */} +
+ {member.name?.[0] || '?'} +
+ +
+
+ {member.name} + {member.status === 'pending' && ( + + Pending + + )} +
+

{member.email}

+ + {/* Job Role Badge */} +
+ {editingRole === member.id ? ( + + ) : ( + + )} + + org: {member.orgRole?.replace(/_/g, ' ')} + +
+
+ + {/* Actions Menu */} + {canManageTeam && ( +
+ + + {actionMenu === member.id && ( + + + {canManageCommissions && ( + + )} + + + )} + +
+ )} +
+
+ ))} +
+
+ )} + + {/* Commission Distribution */} + {canViewCommissions && visibleMembers.length > 0 && ( +
+ + + {commissionOpen && ( + +
+ + + + + + + + + + + + + {memberCommissions.filter(m => m.status === 'active').map((mc) => { + const srcCfg = COMMISSION_SOURCE_CFG[mc.resolved.source] || COMMISSION_SOURCE_CFG.org; + return ( + + + + + + + + + ); + })} + + + + + + + +
MemberJob RoleTypeRateValueSource
+
+
+ {mc.name?.[0]} +
+ {mc.name} +
+
+ + {mc.jobRole} + + {COMMISSION_TYPES[mc.resolved.type] || mc.resolved.type} + {mc.resolved.type === 'flat' || mc.resolved.type === 'custom' + ? formatCurrency(mc.resolved.rate) + : `${mc.resolved.rate}%`} + + {formatCurrency(mc.value)} + + + + {srcCfg.label} + +
Total Distribution + {formatCurrency(memberCommissions.filter(m => m.status === 'active').reduce((s, mc) => s + mc.value, 0))} + +
+
+
+ )} +
+
+ )} +
+ + {/* ── Invite Modal ── */} + {inviteOpen && createPortal( + m.userId)} + onInvite={(member) => { + onAddMember(project.id, member); + setInviteOpen(false); + }} + onClose={() => setInviteOpen(false)} + />, + document.body + )} + + {/* ── Remove Confirmation ── */} + {removeConfirm && createPortal( + { + onRemoveMember(project.id, removeConfirm.id); + setRemoveConfirm(null); + }} + onClose={() => setRemoveConfirm(null)} + />, + document.body + )} + + {/* ── Edit Commission Modal ── */} + {editingCommission && createPortal( + { + onUpdateMember(project.id, memberId, { jobCommission }); + setEditingCommission(null); + }} + onClear={(memberId) => { + onUpdateMember(project.id, memberId, { jobCommission: null }); + setEditingCommission(null); + }} + onClose={() => setEditingCommission(null)} + />, + document.body + )} + + ); +}; + +// ─── Invite Modal ────────────────────────────────────────────────────────────── + +const InviteModal = ({ allUsers, personnel, existingIds, onInvite, onClose }) => { + const [tab, setTab] = useState('search'); + const [query, setQuery] = useState(''); + const [selectedRole, setSelectedRole] = useState('Sales Rep'); + const [emailForm, setEmailForm] = useState({ name: '', email: '' }); + + const searchable = useMemo(() => { + const fromUsers = allUsers + .filter(u => !['customer'].includes(u.type)) + .map(u => ({ userId: u.id, name: u.name, email: u.email, orgRole: u.role || u.type?.toUpperCase() || 'EMPLOYEE' })); + const fromPersonnel = personnel + .map(p => ({ userId: p.id, name: p.name, email: p.email, orgRole: p.role })); + const merged = new Map(); + [...fromUsers, ...fromPersonnel].forEach(s => merged.set(s.userId, s)); + return Array.from(merged.values()).filter(s => !existingIds.includes(s.userId)); + }, [allUsers, personnel, existingIds]); + + const filtered = useMemo(() => { + if (!query.trim()) return searchable; + const q = query.toLowerCase(); + return searchable.filter(s => s.name.toLowerCase().includes(q) || s.email.toLowerCase().includes(q)); + }, [searchable, query]); + + const handleSearchInvite = (person) => { + onInvite({ + userId: person.userId, + name: person.name, + email: person.email, + orgRole: person.orgRole, + jobRole: selectedRole, + status: 'active', + jobCommission: null, + }); + }; + + const handleEmailInvite = () => { + if (!emailForm.name.trim() || !emailForm.email.trim()) return; + onInvite({ + userId: `inv_${Date.now()}`, + name: emailForm.name.trim(), + email: emailForm.email.trim(), + orgRole: 'INVITED', + jobRole: selectedRole, + status: 'pending', + jobCommission: null, + }); + }; + + return ( +
+ e.stopPropagation()} + > + {/* Header */} +
+
+
+ +
+
+

Invite to Job

+

Add team members to this project

+
+
+ +
+ + {/* Tabs */} +
+ {[ + { key: 'search', label: 'Search Users', icon: Search }, + { key: 'email', label: 'Invite by Email', icon: Mail }, + ].map(t => ( + + ))} +
+ + {/* Role Selector */} +
+ + +
+ + {/* Search Tab */} + {tab === 'search' && ( +
+
+ + setQuery(e.target.value)} + placeholder="Search by name or email..." + autoFocus + className="w-full pl-9 pr-3 py-2.5 text-sm rounded-xl bg-zinc-50 dark:bg-zinc-800 border border-zinc-200 dark:border-white/10 text-zinc-900 dark:text-white placeholder-zinc-400 outline-none focus:border-violet-500 transition-colors" + /> +
+
+ {filtered.length === 0 ? ( +

No matching users found

+ ) : filtered.map(person => ( + + ))} +
+
+ )} + + {/* Email Tab */} + {tab === 'email' && ( +
+
+ + setEmailForm(prev => ({ ...prev, name: e.target.value }))} + placeholder="John Smith" + className="w-full px-3 py-2.5 text-sm rounded-xl bg-zinc-50 dark:bg-zinc-800 border border-zinc-200 dark:border-white/10 text-zinc-900 dark:text-white placeholder-zinc-400 outline-none focus:border-violet-500 transition-colors" + /> +
+
+ + setEmailForm(prev => ({ ...prev, email: e.target.value }))} + placeholder="john@example.com" + type="email" + className="w-full px-3 py-2.5 text-sm rounded-xl bg-zinc-50 dark:bg-zinc-800 border border-zinc-200 dark:border-white/10 text-zinc-900 dark:text-white placeholder-zinc-400 outline-none focus:border-violet-500 transition-colors" + /> +
+ +

+ Invitation will be sent with a pending status until accepted. +

+
+ )} +
+
+ ); +}; + +// ─── Remove Confirmation Modal ───────────────────────────────────────────────── + +const RemoveConfirmModal = ({ member, projectName, onConfirm, onClose }) => ( +
+ e.stopPropagation()} + > +
+ +
+

Remove from Job?

+

+ {member.name} will be removed from this {projectName} job. +

+

+ This will not affect their organization role or other job assignments. +

+
+ + +
+
+
+); + +// ─── Edit Commission Modal ───────────────────────────────────────────────────── + +const EditCommissionModal = ({ member, orgDefaults, userOverrides, onSave, onClear, onClose }) => { + const resolved = resolveCommissionForMember(member, userOverrides, orgDefaults); + const hasJobOverride = !!member.jobCommission; + + const [type, setType] = useState(hasJobOverride ? member.jobCommission.type : resolved.type); + const [rate, setRate] = useState(hasJobOverride ? member.jobCommission.rate : resolved.rate); + + return ( +
+ e.stopPropagation()} + > +
+

Job Commission Override

+

Override commission for {member.name} on this job only.

+
+ + {/* Current Resolution */} +
+
+

Currently Resolved From

+
+ {(() => { + const cfg = COMMISSION_SOURCE_CFG[resolved.source] || COMMISSION_SOURCE_CFG.org; + return ( + <> + + {cfg.label} + — {COMMISSION_TYPES[resolved.type]} @ {resolved.type === 'flat' || resolved.type === 'custom' ? formatCurrency(resolved.rate) : `${resolved.rate}%`} + + ); + })()} +
+
+
+ +
+
+ + +
+
+ + setRate(e.target.value)} + min="0" + step={type === 'flat' || type === 'custom' ? '100' : '0.5'} + className="w-full px-3 py-2.5 text-sm font-mono rounded-xl bg-zinc-50 dark:bg-zinc-800 border border-zinc-200 dark:border-white/10 text-zinc-900 dark:text-white outline-none focus:border-amber-500 transition-colors" + /> +
+
+ +
+ + {hasJobOverride && ( + + )} +
+
+
+ ); +}; + +export default TeamManagementPanel; +export { JOB_ROLES }; diff --git a/src/data/mockStore.jsx b/src/data/mockStore.jsx index 15105b5..187eb56 100644 --- a/src/data/mockStore.jsx +++ b/src/data/mockStore.jsx @@ -2066,6 +2066,13 @@ const MOCK_PROJECTS = [ { name: 'Scope of Work - Roof Replacement', url: '#', type: 'contract' }, { name: 'Permit #26-0401', url: '#', type: 'permit' }, { name: 'Material Order PO-2604', url: '#', type: 'invoice' } + ], + teamMembers: [ + { id: 'jtm_001_1', userId: 'p1', name: 'Jesus Gonzales', email: 'jesus@lynkeduppro.com', orgRole: 'W2_EMPLOYEE', jobRole: 'Sales Rep', status: 'active', jobCommission: { type: 'percent_gross', rate: 12 } }, + { id: 'jtm_001_2', userId: 'con_001', name: 'Mike Contractor', email: 'mike@texasbuilders.com', orgRole: 'CONTRACTOR', jobRole: 'Lead Installer', status: 'active', jobCommission: null }, + { id: 'jtm_001_3', userId: 'sub_001', name: 'Carlos Subcontractor', email: 'carlos@electricpro.com', orgRole: 'SUBCONTRACTOR', jobRole: 'Subcontractor', status: 'active', jobCommission: null }, + { id: 'jtm_001_4', userId: 'e1', name: 'Frank Agent', email: 'agent1@plano.com', orgRole: 'FIELD_AGENT', jobRole: 'Canvasser', status: 'active', jobCommission: null }, + { id: 'jtm_001_5', userId: 'p2', name: 'Sarah Sales', email: 'sarah@lynkeduppro.com', orgRole: '1099_SALES_REP', jobRole: 'Estimator', status: 'pending', jobCommission: null } ] }, @@ -2115,6 +2122,10 @@ const MOCK_PROJECTS = [ { name: 'Completion Certificate', url: '#', type: 'certificate' }, { name: 'Final Invoice Package', url: '#', type: 'invoice' }, { name: 'Warranty Registration', url: '#', type: 'warranty' } + ], + teamMembers: [ + { id: 'jtm_002_1', userId: 'p1', name: 'Jesus Gonzales', email: 'jesus@lynkeduppro.com', orgRole: 'W2_EMPLOYEE', jobRole: 'Sales Rep', status: 'active', jobCommission: null }, + { id: 'jtm_002_2', userId: 'con_001', name: 'Mike Contractor', email: 'mike@texasbuilders.com', orgRole: 'CONTRACTOR', jobRole: 'Lead Installer', status: 'active', jobCommission: null } ] }, @@ -2190,6 +2201,11 @@ const MOCK_PROJECTS = [ { name: 'HVAC Design Plans', url: '#', type: 'plans' }, { name: 'Mechanical Permit #26-0187', url: '#', type: 'permit' }, { name: 'Equipment Spec Sheets', url: '#', type: 'specification' } + ], + teamMembers: [ + { id: 'jtm_003_1', userId: 'p2', name: 'Sarah Sales', email: 'sarah@lynkeduppro.com', orgRole: '1099_SALES_REP', jobRole: 'Sales Rep', status: 'active', jobCommission: { type: 'flat', rate: 2000 } }, + { id: 'jtm_003_2', userId: 'con_001', name: 'Mike Contractor', email: 'mike@texasbuilders.com', orgRole: 'CONTRACTOR', jobRole: 'Project Manager', status: 'active', jobCommission: null }, + { id: 'jtm_003_3', userId: 'sub_001', name: 'Carlos Subcontractor', email: 'carlos@electricpro.com', orgRole: 'SUBCONTRACTOR', jobRole: 'Subcontractor', status: 'active', jobCommission: null } ] }, @@ -2276,6 +2292,11 @@ const MOCK_PROJECTS = [ { name: 'Change Order #1 - Scope Expansion', url: '#', type: 'change_order' }, { name: 'Change Order #2 - Material Upgrade', url: '#', type: 'change_order' }, { name: 'Asbestos Abatement Report', url: '#', type: 'report' } + ], + teamMembers: [ + { id: 'jtm_004_1', userId: 'p1', name: 'Jesus Gonzales', email: 'jesus@lynkeduppro.com', orgRole: 'W2_EMPLOYEE', jobRole: 'Project Manager', status: 'active', jobCommission: { type: 'percent_net', rate: 8 } }, + { id: 'jtm_004_2', userId: 'con_001', name: 'Mike Contractor', email: 'mike@texasbuilders.com', orgRole: 'CONTRACTOR', jobRole: 'Lead Installer', status: 'active', jobCommission: null }, + { id: 'jtm_004_3', userId: 'e2', name: 'Fiona Field', email: 'agent2@plano.com', orgRole: 'FIELD_AGENT', jobRole: 'Inspector', status: 'active', jobCommission: null } ] }, @@ -2349,6 +2370,10 @@ const MOCK_PROJECTS = [ { name: 'Dispute Notice - Material Defect', url: '#', type: 'dispute' }, { name: 'Manufacturer Warranty Claim', url: '#', type: 'warranty' }, { name: 'Site Photos - Defective Panels', url: '#', type: 'photos' } + ], + teamMembers: [ + { id: 'jtm_005_1', userId: 'p2', name: 'Sarah Sales', email: 'sarah@lynkeduppro.com', orgRole: '1099_SALES_REP', jobRole: 'Sales Rep', status: 'active', jobCommission: null }, + { id: 'jtm_005_2', userId: 'con_001', name: 'Mike Contractor', email: 'mike@texasbuilders.com', orgRole: 'CONTRACTOR', jobRole: 'Lead Installer', status: 'active', jobCommission: null } ] }, @@ -2396,6 +2421,10 @@ const MOCK_PROJECTS = [ { name: 'Electrical Upgrade Proposal', url: '#', type: 'contract' }, { name: 'Electrical Permit #26-0223', url: '#', type: 'permit' }, { name: 'Load Calculation Report', url: '#', type: 'report' } + ], + teamMembers: [ + { id: 'jtm_006_1', userId: 'p1', name: 'Jesus Gonzales', email: 'jesus@lynkeduppro.com', orgRole: 'W2_EMPLOYEE', jobRole: 'Sales Rep', status: 'active', jobCommission: null }, + { id: 'jtm_006_2', userId: 'sub_001', name: 'Carlos Subcontractor', email: 'carlos@electricpro.com', orgRole: 'SUBCONTRACTOR', jobRole: 'Lead Installer', status: 'active', jobCommission: null } ] }, @@ -2495,6 +2524,12 @@ const MOCK_PROJECTS = [ { name: 'TPO Manufacturer Spec', url: '#', type: 'specification' }, { name: 'Roof Survey Report', url: '#', type: 'report' }, { name: 'Safety Plan - Fall Protection', url: '#', type: 'safety' } + ], + teamMembers: [ + { id: 'jtm_007_1', userId: 'p1', name: 'Jesus Gonzales', email: 'jesus@lynkeduppro.com', orgRole: 'W2_EMPLOYEE', jobRole: 'Project Manager', status: 'active', jobCommission: { type: 'percent_gross', rate: 5 } }, + { id: 'jtm_007_2', userId: 'con_001', name: 'Mike Contractor', email: 'mike@texasbuilders.com', orgRole: 'CONTRACTOR', jobRole: 'Lead Installer', status: 'active', jobCommission: null }, + { id: 'jtm_007_3', userId: 'sub_001', name: 'Carlos Subcontractor', email: 'carlos@electricpro.com', orgRole: 'SUBCONTRACTOR', jobRole: 'Subcontractor', status: 'active', jobCommission: null }, + { id: 'jtm_007_4', userId: 'e4', name: 'Felicity Fast', email: 'agent4@plano.com', orgRole: 'FIELD_AGENT', jobRole: 'Canvasser', status: 'active', jobCommission: null } ] }, @@ -2550,6 +2585,10 @@ const MOCK_PROJECTS = [ { name: 'Change Order #1 - Additional Piers', url: '#', type: 'change_order' }, { name: 'Change Order #2 - Plumbing Reroute', url: '#', type: 'change_order' }, { name: 'Soil Analysis Report', url: '#', type: 'report' } + ], + teamMembers: [ + { id: 'jtm_008_1', userId: 'p2', name: 'Sarah Sales', email: 'sarah@lynkeduppro.com', orgRole: '1099_SALES_REP', jobRole: 'Sales Rep', status: 'active', jobCommission: null }, + { id: 'jtm_008_2', userId: 'con_001', name: 'Mike Contractor', email: 'mike@texasbuilders.com', orgRole: 'CONTRACTOR', jobRole: 'Lead Installer', status: 'active', jobCommission: null } ] }, @@ -2598,6 +2637,10 @@ const MOCK_PROJECTS = [ { name: 'Window Replacement Agreement', url: '#', type: 'contract' }, { name: 'Custom Window Order Confirmation', url: '#', type: 'purchase_order' }, { name: 'Energy Rating Certificates', url: '#', type: 'certificate' } + ], + teamMembers: [ + { id: 'jtm_009_1', userId: 'p1', name: 'Jesus Gonzales', email: 'jesus@lynkeduppro.com', orgRole: 'W2_EMPLOYEE', jobRole: 'Sales Rep', status: 'active', jobCommission: null }, + { id: 'jtm_009_2', userId: 'e2', name: 'Fiona Field', email: 'agent2@plano.com', orgRole: 'FIELD_AGENT', jobRole: 'Inspector', status: 'active', jobCommission: null } ] }, @@ -2646,6 +2689,11 @@ const MOCK_PROJECTS = [ { name: 'Completion Certificate', url: '#', type: 'certificate' }, { name: 'ADA Compliance Verification', url: '#', type: 'report' }, { name: 'Final Payment Release', url: '#', type: 'invoice' } + ], + teamMembers: [ + { id: 'jtm_010_1', userId: 'p2', name: 'Sarah Sales', email: 'sarah@lynkeduppro.com', orgRole: '1099_SALES_REP', jobRole: 'Estimator', status: 'active', jobCommission: { type: 'flat', rate: 1500 } }, + { id: 'jtm_010_2', userId: 'con_001', name: 'Mike Contractor', email: 'mike@texasbuilders.com', orgRole: 'CONTRACTOR', jobRole: 'Lead Installer', status: 'active', jobCommission: null }, + { id: 'jtm_010_3', userId: 'e5', name: 'Felix Fixer', email: 'agent5@plano.com', orgRole: 'FIELD_AGENT', jobRole: 'Canvasser', status: 'pending', jobCommission: null } ] }, @@ -2702,6 +2750,10 @@ const MOCK_PROJECTS = [ { name: 'Dispute Filing - Scope Disagreement', url: '#', type: 'dispute' }, { name: 'RFI #1 - Original vs Current Scope', url: '#', type: 'rfi' }, { name: 'RFI #2 - Waterproofing Method', url: '#', type: 'rfi' } + ], + teamMembers: [ + { id: 'jtm_011_1', userId: 'p1', name: 'Jesus Gonzales', email: 'jesus@lynkeduppro.com', orgRole: 'W2_EMPLOYEE', jobRole: 'Project Manager', status: 'active', jobCommission: null }, + { id: 'jtm_011_2', userId: 'con_001', name: 'Mike Contractor', email: 'mike@texasbuilders.com', orgRole: 'CONTRACTOR', jobRole: 'Lead Installer', status: 'active', jobCommission: null } ] }, @@ -2752,6 +2804,10 @@ const MOCK_PROJECTS = [ { name: 'Plumbing Permit #26-0156', url: '#', type: 'permit' }, { name: 'Scope Hold Notice - Permit Review', url: '#', type: 'notice' }, { name: 'Material Delivery Receipt', url: '#', type: 'receipt' } + ], + teamMembers: [ + { id: 'jtm_012_1', userId: 'p2', name: 'Sarah Sales', email: 'sarah@lynkeduppro.com', orgRole: '1099_SALES_REP', jobRole: 'Sales Rep', status: 'active', jobCommission: null }, + { id: 'jtm_012_2', userId: 'e3', name: 'Fred Flyer', email: 'agent3@plano.com', orgRole: 'FIELD_AGENT', jobRole: 'Canvasser', status: 'active', jobCommission: null } ] } ]; @@ -4993,7 +5049,7 @@ export const MockStoreProvider = ({ children }) => { meetings, salesHistory, // Exported for Leaderboard - // New Owners Box Data + // New Data owners, personnel, vendors, @@ -5123,6 +5179,26 @@ export const MockStoreProvider = ({ children }) => { updateProject: (projectId, data) => { setProjects(prev => prev.map(p => p.id === projectId ? { ...p, ...data } : p)); }, + + // Team Management (job-scoped) + addJobTeamMember: (projectId, member) => { + setProjects(prev => prev.map(p => { + if (p.id !== projectId) return p; + return { ...p, teamMembers: [...(p.teamMembers || []), { ...member, id: `jtm_${Date.now()}_${Math.random().toString(36).slice(2, 6)}` }] }; + })); + }, + removeJobTeamMember: (projectId, memberId) => { + setProjects(prev => prev.map(p => { + if (p.id !== projectId) return p; + return { ...p, teamMembers: (p.teamMembers || []).filter(m => m.id !== memberId) }; + })); + }, + updateJobTeamMember: (projectId, memberId, updates) => { + setProjects(prev => prev.map(p => { + if (p.id !== projectId) return p; + return { ...p, teamMembers: (p.teamMembers || []).map(m => m.id === memberId ? { ...m, ...updates } : m) }; + })); + }, }}> {children} diff --git a/src/pages/owner/OwnerSnapshot.jsx b/src/pages/owner/OwnerSnapshot.jsx index 2135d0d..a8c4e3c 100644 --- a/src/pages/owner/OwnerSnapshot.jsx +++ b/src/pages/owner/OwnerSnapshot.jsx @@ -6,18 +6,23 @@ import FinancialKPICards from '../../components/owner/FinancialKPICards'; 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 { SpotlightCard } from '../../components/SpotlightCard'; import { BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, PieChart, Pie, Cell, Legend } from 'recharts'; import { - Briefcase, TrendingUp, AlertTriangle, Shield, ChevronRight, ArrowUpRight + Briefcase, TrendingUp, AlertTriangle, Shield, ChevronRight, ArrowUpRight, Users, ChevronDown } from 'lucide-react'; const OwnerSnapshot = () => { const { user } = useAuth(); const navigate = useNavigate(); - const { owners, projects } = useMockStore(); + const { + owners, projects, users, personnel, + orgCommissionDefaults, userCommissionOverrides, + addJobTeamMember, removeJobTeamMember, updateJobTeamMember, + } = useMockStore(); // Find the current owner profile const owner = useMemo(() => @@ -33,6 +38,23 @@ const OwnerSnapshot = () => { const [financialModal, setFinancialModal] = useState({ isOpen: false, type: 'revenue' }); const [actionModal, setActionModal] = useState({ isOpen: false, filter: 'all' }); + // ── Team Management (Owner's Box) ────────────────────────────────────── + const [selectedProjectId, setSelectedProjectId] = useState(null); + const selectedProject = useMemo(() => { + const id = selectedProjectId || ownerProjects.find(p => p.status === 'active')?.id; + return ownerProjects.find(p => p.id === id) || ownerProjects[0] || null; + }, [selectedProjectId, ownerProjects]); + + const teamFinancials = useMemo(() => { + if (!selectedProject) return { totalReceived: 0, grossProfit: 0 }; + const paid = (selectedProject.paymentSchedule || []) + .filter(ps => ps.status === 'paid') + .reduce((s, ps) => s + (ps.amount || 0), 0); + const totalReceived = paid || (selectedProject.budget || 0); + const totalCosts = Number(selectedProject.actualCost ?? selectedProject.spent) || 0; + return { totalReceived, grossProfit: totalReceived - totalCosts }; + }, [selectedProject]); + const handleFinancialClick = (type) => { setFinancialModal({ isOpen: true, type }); }; @@ -317,6 +339,48 @@ const OwnerSnapshot = () => {
+ + {/* ── Owner's Box — Team Management ── */} + {selectedProject && ( +
+
+

+ + Team Management +

+
+ + +
+
+ + + +
+ )} {/* Interactive Modals */} From fea4ad2d22a9efa002c6b1811fc8789ea05743b7 Mon Sep 17 00:00:00 2001 From: Satyam Rastogi Date: Tue, 12 May 2026 20:36:44 +0530 Subject: [PATCH 09/38] Org settings module and user roles --- src/App.jsx | 6 + src/components/Layout.jsx | 3 +- src/data/mockStore.jsx | 148 +++++- src/pages/owner/OrgSettings.jsx | 860 ++++++++++++++++++++++++++++++++ 4 files changed, 998 insertions(+), 19 deletions(-) create mode 100644 src/pages/owner/OrgSettings.jsx diff --git a/src/App.jsx b/src/App.jsx index accfdc8..5100797 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -19,6 +19,7 @@ import VendorDirectory from './pages/owner/VendorDirectory'; import DocumentManagement from './pages/owner/DocumentManagement'; import OwnerProjectList from './pages/owner/OwnerProjectList'; import OwnerProjectDetail from './pages/owner/OwnerProjectDetail'; +import OrgSettings from './pages/owner/OrgSettings'; import PlaceholderDashboard from './pages/PlaceholderDashboard'; import ProCanvas from './pages/ProCanvas'; import ProjectList from './pages/contractor/ProjectList'; @@ -213,6 +214,11 @@ function App() { } /> + + + + } /> {/* Protected Admin Routes — Owner has full admin access */} { { to: "/owner/estimates", icon: Calculator, label: "Estimates" }, { to: "/admin/schedule", icon: Calendar, label: "Team Schedule" }, { to: "/admin/leaderboard", icon: Trophy, label: "Leaderboard" }, + { to: "/owner/settings", icon: Settings, label: "Org Settings" }, ...commonItems, ]; case 'ADMIN': diff --git a/src/data/mockStore.jsx b/src/data/mockStore.jsx index 187eb56..d08a748 100644 --- a/src/data/mockStore.jsx +++ b/src/data/mockStore.jsx @@ -4971,6 +4971,104 @@ export const MockStoreProvider = ({ children }) => { { userId: 'p2', userName: 'Sarah Sales', enabled: true, type: 'flat', rate: 2000 }, ]); + // ── Org Settings & Access Control ── + const [orgSettings, setOrgSettings] = useState({ + orgName: 'LynkedUp Pro Roofing', + orgEmail: 'info@lynkeduppro.com', + orgPhone: '214-555-1000', + orgAddress: '5800 Legacy Dr, Plano, TX 75024', + timezone: 'America/Chicago', + }); + + const [orgRoles, setOrgRoles] = useState([ + { + id: 'role_owner', + name: 'Org Owner', + key: 'OWNER', + description: 'Full access to all modules, settings, billing, and access control', + isSystem: true, + memberCount: 2, + color: '#fda913', + }, + { + id: 'role_admin', + name: 'Admin / Manager', + key: 'ADMIN', + description: 'Manage jobs, view financials, assign team, verify leads, configure commissions', + isSystem: true, + memberCount: 3, + color: '#3b82f6', + }, + { + id: 'role_sales', + name: 'Sales Rep', + key: 'SALES_REP', + description: 'View/manage assigned jobs, verify canvasser leads, view own commission', + isSystem: true, + memberCount: 2, + color: '#a855f7', + }, + { + id: 'role_canvasser', + name: 'Canvasser', + key: 'CANVASSER', + description: 'Log door knocks, add leads, view leaderboard, view verified leads', + isSystem: true, + memberCount: 5, + color: '#10b981', + }, + { + id: 'role_subcontractor', + name: 'Subcontractor', + key: 'SUBCONTRACTOR', + description: 'Receive tasks, update status, reply to tasks, log expenses', + isSystem: true, + memberCount: 1, + color: '#06b6d4', + }, + ]); + + const [orgPermissions, setOrgPermissions] = useState({ + jobs: { OWNER: ['view','edit','delete'], ADMIN: ['view','edit','delete'], SALES_REP: ['view','edit'], CANVASSER: [], SUBCONTRACTOR: [] }, + financials: { OWNER: ['view','edit'], ADMIN: ['view','edit'], SALES_REP: ['view'], CANVASSER: [], SUBCONTRACTOR: [] }, + commission: { OWNER: ['view','edit'], ADMIN: ['view','edit'], SALES_REP: ['view'], CANVASSER: [], SUBCONTRACTOR: [] }, + team: { OWNER: ['view','invite','remove'], ADMIN: ['view','invite'], SALES_REP: ['view'], CANVASSER: [], SUBCONTRACTOR: [] }, + leads: { OWNER: ['view','log','verify'], ADMIN: ['view','log','verify'], SALES_REP: ['view','verify'], CANVASSER: ['view','log'], SUBCONTRACTOR: [] }, + leaderboard: { OWNER: ['view'], ADMIN: ['view'], SALES_REP: ['view'], CANVASSER: ['view'], SUBCONTRACTOR: [] }, + subcontractor_tasks: { OWNER: ['view','assign'], ADMIN: ['view','assign'], SALES_REP: [], CANVASSER: [], SUBCONTRACTOR: ['view','receive'] }, + }); + + const PERMISSION_MODULES = { + jobs: { label: 'Jobs', actions: ['view','edit','delete'] }, + financials: { label: 'Financials', actions: ['view','edit'] }, + commission: { label: 'Commission Settings', actions: ['view','edit'] }, + team: { label: 'Team Members', actions: ['view','invite','remove'] }, + leads: { label: 'Leads & Door Knocks', actions: ['view','log','verify'] }, + leaderboard: { label: 'Leaderboard', actions: ['view'] }, + subcontractor_tasks: { label: 'Subcontractor Tasks', actions: ['view','assign','receive'] }, + }; + + const [roleCommissionOverrides, setRoleCommissionOverrides] = useState([ + { roleKey: 'SALES_REP', enabled: true, type: 'percent_net', rate: 5 }, + { roleKey: 'CANVASSER', enabled: true, type: 'flat', rate: 300 }, + ]); + + const [orgMembers, setOrgMembers] = useState([ + { id: 'om_001', userId: 'owner_001', name: 'Justin Johnson', email: 'justin@lynkeduppro.com', roleKey: 'OWNER', status: 'active', joinedAt: '2024-01-15' }, + { id: 'om_002', userId: 'owner_002', name: 'Maria Garcia', email: 'maria@lynkeduppro.com', roleKey: 'OWNER', status: 'active', joinedAt: '2024-01-15' }, + { id: 'om_003', userId: 'ADM01', name: 'Admin One', email: 'admin1@lynkeduppro.com', roleKey: 'ADMIN', status: 'active', joinedAt: '2024-02-10' }, + { id: 'om_004', userId: 'ADM02', name: 'Admin Two', email: 'admin2@lynkeduppro.com', roleKey: 'ADMIN', status: 'active', joinedAt: '2024-03-01' }, + { id: 'om_005', userId: 'ADM03', name: 'Admin Three', email: 'admin3@lynkeduppro.com', roleKey: 'ADMIN', status: 'active', joinedAt: '2024-04-20' }, + { id: 'om_006', userId: 'p1', name: 'Jesus Gonzales', email: 'jesus@lynkeduppro.com', roleKey: 'SALES_REP', status: 'active', joinedAt: '2024-03-15', individualCommission: { enabled: true, type: 'percent_gross', rate: 12 } }, + { id: 'om_007', userId: 'p2', name: 'Sarah Sales', email: 'sarah@lynkeduppro.com', roleKey: 'SALES_REP', status: 'active', joinedAt: '2024-04-01', individualCommission: { enabled: true, type: 'flat', rate: 2000 } }, + { id: 'om_008', userId: 'FA001', name: 'Frank Agent', email: 'agent1@plano.com', roleKey: 'CANVASSER', status: 'active', joinedAt: '2024-05-10' }, + { id: 'om_009', userId: 'FA002', name: 'Fiona Field', email: 'agent2@plano.com', roleKey: 'CANVASSER', status: 'active', joinedAt: '2024-05-15' }, + { id: 'om_010', userId: 'FA003', name: 'Fred Flyer', email: 'agent3@plano.com', roleKey: 'CANVASSER', status: 'active', joinedAt: '2024-06-01' }, + { id: 'om_011', userId: 'FA004', name: 'Felicity Fast', email: 'agent4@plano.com', roleKey: 'CANVASSER', status: 'active', joinedAt: '2024-06-10' }, + { id: 'om_012', userId: 'FA005', name: 'Felix Fixer', email: 'agent5@plano.com', roleKey: 'CANVASSER', status: 'active', joinedAt: '2024-07-01' }, + { id: 'om_013', userId: 'sub_001', name: 'Carlos Subcontractor', email: 'carlos@electricpro.com', roleKey: 'SUBCONTRACTOR', status: 'active', joinedAt: '2024-08-01' }, + ]); + // Initialize properties once useEffect(() => { const data = generateProperties(); @@ -5048,8 +5146,6 @@ export const MockStoreProvider = ({ children }) => { users, meetings, salesHistory, // Exported for Leaderboard - - // New Data owners, personnel, vendors, @@ -5057,7 +5153,6 @@ export const MockStoreProvider = ({ children }) => { projects, orders, vendorInvoices, - leads, addLead: (lead) => { const newLead = { @@ -5180,24 +5275,41 @@ export const MockStoreProvider = ({ children }) => { setProjects(prev => prev.map(p => p.id === projectId ? { ...p, ...data } : p)); }, - // Team Management (job-scoped) - addJobTeamMember: (projectId, member) => { - setProjects(prev => prev.map(p => { - if (p.id !== projectId) return p; - return { ...p, teamMembers: [...(p.teamMembers || []), { ...member, id: `jtm_${Date.now()}_${Math.random().toString(36).slice(2, 6)}` }] }; + // Org Settings & Access Control + orgSettings, + updateOrgSettings: (data) => setOrgSettings(prev => ({ ...prev, ...data })), + orgRoles, + updateOrgRole: (roleId, data) => setOrgRoles(prev => prev.map(r => r.id === roleId ? { ...r, ...data } : r)), + orgPermissions, + PERMISSION_MODULES, + updateOrgPermissions: (moduleKey, roleKey, actions) => { + setOrgPermissions(prev => ({ + ...prev, + [moduleKey]: { ...prev[moduleKey], [roleKey]: actions }, })); }, - removeJobTeamMember: (projectId, memberId) => { - setProjects(prev => prev.map(p => { - if (p.id !== projectId) return p; - return { ...p, teamMembers: (p.teamMembers || []).filter(m => m.id !== memberId) }; - })); + roleCommissionOverrides, + setRoleCommissionOverride: (override) => { + setRoleCommissionOverrides(prev => { + const idx = prev.findIndex(o => o.roleKey === override.roleKey); + if (idx >= 0) return prev.map((o, i) => i === idx ? { ...o, ...override } : o); + return [...prev, override]; + }); }, - updateJobTeamMember: (projectId, memberId, updates) => { - setProjects(prev => prev.map(p => { - if (p.id !== projectId) return p; - return { ...p, teamMembers: (p.teamMembers || []).map(m => m.id === memberId ? { ...m, ...updates } : m) }; - })); + removeRoleCommissionOverride: (roleKey) => { + setRoleCommissionOverrides(prev => prev.filter(o => o.roleKey !== roleKey)); + }, + orgMembers, + updateOrgMember: (memberId, data) => { + setOrgMembers(prev => prev.map(m => m.id === memberId ? { ...m, ...data } : m)); + }, + addOrgMember: (member) => { + const newMember = { ...member, id: `om_${Date.now()}`, joinedAt: new Date().toISOString().slice(0, 10) }; + setOrgMembers(prev => [...prev, newMember]); + return newMember; + }, + removeOrgMember: (memberId) => { + setOrgMembers(prev => prev.filter(m => m.id !== memberId)); }, }}> {children} diff --git a/src/pages/owner/OrgSettings.jsx b/src/pages/owner/OrgSettings.jsx new file mode 100644 index 0000000..71898b8 --- /dev/null +++ b/src/pages/owner/OrgSettings.jsx @@ -0,0 +1,860 @@ +import React, { useState, useMemo } from 'react'; +import { motion, AnimatePresence } from 'framer-motion'; +import { + Settings, Building2, Shield, Users, DollarSign, ChevronDown, ChevronRight, + Search, Check, X, Plus, Trash2, Edit3, Save, User, Crown, + ToggleLeft, ToggleRight, Info, ArrowRight, Mail, Phone, MapPin, Clock, + AlertTriangle, UserPlus, UserMinus, Eye, Pencil, Lock +} from 'lucide-react'; +import { useMockStore } from '../../data/mockStore'; +import { useAuth } from '../../context/AuthContext'; +import { toast } from 'sonner'; + +const COMMISSION_TYPES = [ + { value: 'flat', label: 'Flat Amount', hint: 'Fixed dollar amount' }, + { value: 'percent_gross', label: '% of Gross', hint: '% of Total Received' }, + { value: 'percent_net', label: '% of Net', hint: '% of Gross Profit' }, + { value: 'custom', label: 'Custom / Manual', hint: 'Manual entry' }, +]; + +const formatCurrency = (amt) => + new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', maximumFractionDigits: 0 }).format(Number(amt) || 0); + +const formatCommission = (type, rate) => { + if (type === 'percent_gross' || type === 'percent_net') return `${rate}%`; + return formatCurrency(rate); +}; + +const getCommissionLabel = (type) => + COMMISSION_TYPES.find(c => c.value === type)?.label || type; + +// ──────────────────────────────────────────────────── +// Section Wrapper +// ──────────────────────────────────────────────────── +const SettingsSection = ({ id, icon: Icon, title, subtitle, color, isOpen, onToggle, children }) => ( +
+ + + {isOpen && ( + +
+ {children} +
+
+ )} +
+
+); + +// ──────────────────────────────────────────────────── +// 1. Organization Info Section +// ──────────────────────────────────────────────────── +const OrgInfoSection = ({ orgSettings, onSave }) => { + const [editing, setEditing] = useState(false); + const [local, setLocal] = useState({ ...orgSettings }); + + const handleSave = () => { + onSave(local); + setEditing(false); + toast.success('Organization settings saved'); + }; + + const handleCancel = () => { + setLocal({ ...orgSettings }); + setEditing(false); + }; + + const fields = [ + { key: 'orgName', label: 'Organization Name', icon: Building2 }, + { key: 'orgEmail', label: 'Email', icon: Mail }, + { key: 'orgPhone', label: 'Phone', icon: Phone }, + { key: 'orgAddress', label: 'Address', icon: MapPin }, + { key: 'timezone', label: 'Timezone', icon: Clock }, + ]; + + return ( +
+
+

Manage your organization's core details

+ {!editing ? ( + + ) : ( +
+ + +
+ )} +
+
+ {fields.map(({ key, label, icon: FIcon }) => ( +
+ + {editing ? ( + setLocal(prev => ({ ...prev, [key]: e.target.value }))} + className="w-full bg-zinc-50 dark:bg-[#18181b] border border-zinc-200 dark:border-white/10 rounded-xl px-4 py-2.5 text-sm text-zinc-900 dark:text-white outline-none focus:border-blue-500/50 transition-colors" + /> + ) : ( +
+ {orgSettings[key]} +
+ )} +
+ ))} +
+
+ ); +}; + +// ──────────────────────────────────────────────────── +// 2. Role Management Section +// ──────────────────────────────────────────────────── +const RoleManagementSection = ({ roles, members }) => { + const [expandedRole, setExpandedRole] = useState(null); + + return ( +
+

System roles define base-level access for your organization members

+ {roles.map(role => { + const roleMemberCount = members.filter(m => m.roleKey === role.key).length; + const roleMembers = members.filter(m => m.roleKey === role.key); + const isExpanded = expandedRole === role.id; + + return ( +
+ + + {isExpanded && ( + +
+ {roleMembers.length === 0 ? ( +

No members assigned to this role

+ ) : ( +
+ {roleMembers.map(member => ( +
+
+
+ {member.name.charAt(0)} +
+
+ {member.name} + {member.email} +
+
+
+ + {member.status} + +
+
+ ))} +
+ )} +
+
+ )} +
+
+ ); + })} +
+ ); +}; + +// ──────────────────────────────────────────────────── +// 3. Access Control Matrix +// ──────────────────────────────────────────────────── +const AccessControlMatrix = ({ permissions, permissionModules, roles, onUpdate }) => { + const togglePermission = (moduleKey, roleKey, action) => { + const current = permissions[moduleKey]?.[roleKey] || []; + const updated = current.includes(action) + ? current.filter(a => a !== action) + : [...current, action]; + onUpdate(moduleKey, roleKey, updated); + }; + + const moduleEntries = Object.entries(permissionModules); + + return ( +
+
+ Click cells to toggle permissions per role +
+ + {/* Desktop Matrix */} +
+ + + + + + {roles.map(role => ( + + ))} + + + + {moduleEntries.map(([moduleKey, module]) => + module.actions.map((action, actionIdx) => ( + + {actionIdx === 0 && ( + + )} + + {roles.map(role => { + const hasPermission = (permissions[moduleKey]?.[role.key] || []).includes(action); + const isOwner = role.key === 'OWNER'; + return ( + + ); + })} + + )) + )} + +
ModuleAction + {role.name.split(' ')[0]} +
+
+ + {module.label} +
+
{action} + {isOwner ? ( +
+
+ +
+
+ ) : ( + + )} +
+
+ + {/* Mobile Cards */} +
+ {roles.filter(r => r.key !== 'OWNER').map(role => ( +
+
+
+ {role.name} +
+ {moduleEntries.map(([moduleKey, module]) => ( +
+ {module.label} +
+ {module.actions.map(action => { + const hasPermission = (permissions[moduleKey]?.[role.key] || []).includes(action); + return ( + + ); + })} +
+
+ ))} +
+ ))} +
+
+ ); +}; + +// ──────────────────────────────────────────────────── +// 4. Commission Rules Section +// ──────────────────────────────────────────────────── +const CommissionRulesSection = ({ + orgDefaults, onSaveOrgDefaults, + roleOverrides, roles, onSaveRoleOverride, onRemoveRoleOverride, +}) => { + const [localOrgDefaults, setLocalOrgDefaults] = useState({ ...orgDefaults }); + const [editingOrg, setEditingOrg] = useState(false); + const [addingRole, setAddingRole] = useState(false); + const [newRoleOverride, setNewRoleOverride] = useState({ roleKey: '', type: 'flat', rate: 0 }); + + const availableRolesForOverride = useMemo(() => { + const overriddenKeys = new Set(roleOverrides.map(o => o.roleKey)); + return roles.filter(r => r.key !== 'OWNER' && !overriddenKeys.has(r.key)); + }, [roles, roleOverrides]); + + const handleSaveOrg = () => { + onSaveOrgDefaults({ type: localOrgDefaults.type, rate: Number(localOrgDefaults.rate) || 0 }); + setEditingOrg(false); + toast.success('Org commission defaults updated'); + }; + + const handleAddRoleOverride = () => { + if (!newRoleOverride.roleKey) return; + onSaveRoleOverride({ ...newRoleOverride, enabled: true, rate: Number(newRoleOverride.rate) || 0 }); + setAddingRole(false); + setNewRoleOverride({ roleKey: '', type: 'flat', rate: 0 }); + toast.success('Role commission override added'); + }; + + const inputClasses = "w-full bg-zinc-50 dark:bg-[#18181b] border border-zinc-200 dark:border-white/10 rounded-xl px-4 py-2.5 text-sm text-zinc-900 dark:text-white outline-none focus:border-blue-400 dark:focus:border-blue-500/50 transition-colors"; + + return ( +
+ {/* Hierarchy Visualization */} +
+ {[ + { label: 'Org Default', color: '#00f0ff', icon: Building2 }, + { label: 'Role Override', color: '#10b981', icon: Shield }, + { label: 'Individual', color: '#a855f7', icon: User }, + { label: 'Job Override', color: '#fda913', icon: DollarSign }, + ].map((level, i) => ( + + {i > 0 && } +
+ + {level.label} +
+
+ ))} +
+

Highest priority on the right. Job override supersedes all.

+ + {/* Org Default */} +
+
+
+ + Org Default Commission +
+ {!editingOrg ? ( + + ) : ( +
+ + +
+ )} +
+ {editingOrg ? ( +
+
+ + +
+
+ + setLocalOrgDefaults(prev => ({ ...prev, rate: e.target.value }))} className={inputClasses} /> +
+
+ ) : ( +
+ + {getCommissionLabel(orgDefaults.type)} + {formatCommission(orgDefaults.type, orgDefaults.rate)} +
+ )} +
+ + {/* Role Overrides */} +
+
+
+ + Role Overrides +
+
+ + {roleOverrides.length === 0 && !addingRole && ( +

No role overrides — all roles use org default

+ )} + + {roleOverrides.map(ov => { + const role = roles.find(r => r.key === ov.roleKey); + return ( +
+
+
+ {role?.name || ov.roleKey} + + {getCommissionLabel(ov.type)} — {formatCommission(ov.type, ov.rate)} +
+
+ + +
+
+ ); + })} + + {addingRole ? ( +
+
+
+ + +
+
+ + +
+
+ + setNewRoleOverride(prev => ({ ...prev, rate: e.target.value }))} className={inputClasses} /> +
+
+
+ + +
+
+ ) : ( + + )} +
+ + {/* Info */} +
+
+ +
+

Commission Hierarchy: Job Override → Individual Override → Role Override → Org Default

+

The most specific level takes precedence. Job-level overrides (set on each project) always win.

+
+
+
+
+ ); +}; + +// ──────────────────────────────────────────────────── +// 5. User Overrides Section +// ──────────────────────────────────────────────────── +const UserOverridesSection = ({ members, roles, orgDefaults, roleOverrides, userCommissionOverrides, onSaveOverride, onRemoveOverride }) => { + const [searchTerm, setSearchTerm] = useState(''); + const [editingMember, setEditingMember] = useState(null); + const [editForm, setEditForm] = useState({ type: 'flat', rate: 0 }); + + const filteredMembers = useMemo(() => { + const term = searchTerm.toLowerCase(); + return members.filter(m => + m.name.toLowerCase().includes(term) || + m.email.toLowerCase().includes(term) || + m.roleKey.toLowerCase().includes(term) + ); + }, [members, searchTerm]); + + const getEffectiveCommission = (member) => { + const userOv = userCommissionOverrides.find(o => o.userId === member.userId && o.enabled); + if (userOv) return { source: 'individual', type: userOv.type, rate: userOv.rate }; + + const indOv = member.individualCommission; + if (indOv?.enabled) return { source: 'individual', type: indOv.type, rate: indOv.rate }; + + const roleOv = roleOverrides.find(o => o.roleKey === member.roleKey && o.enabled); + if (roleOv) return { source: 'role', type: roleOv.type, rate: roleOv.rate }; + + return { source: 'org', type: orgDefaults.type, rate: orgDefaults.rate }; + }; + + const sourceColors = { + individual: { bg: 'bg-purple-50 dark:bg-purple-500/10', border: 'border-purple-200 dark:border-purple-500/20', text: 'text-purple-600 dark:text-purple-400', label: 'Individual' }, + role: { bg: 'bg-emerald-50 dark:bg-emerald-500/10', border: 'border-emerald-200 dark:border-emerald-500/20', text: 'text-emerald-600 dark:text-emerald-400', label: 'Role' }, + org: { bg: 'bg-cyan-50 dark:bg-cyan-500/10', border: 'border-cyan-200 dark:border-cyan-500/20', text: 'text-cyan-600 dark:text-cyan-400', label: 'Org Default' }, + }; + + const startEdit = (member) => { + const effective = getEffectiveCommission(member); + setEditingMember(member.id); + setEditForm({ type: effective.type, rate: effective.rate }); + }; + + const handleSaveIndividual = (member) => { + onSaveOverride({ + userId: member.userId, + userName: member.name, + enabled: true, + type: editForm.type, + rate: Number(editForm.rate) || 0, + }); + setEditingMember(null); + toast.success(`Individual commission set for ${member.name}`); + }; + + const handleRemoveIndividual = (member) => { + onRemoveOverride(member.userId); + setEditingMember(null); + toast.success(`Individual override removed for ${member.name}`); + }; + + const inputClasses = "w-full bg-zinc-50 dark:bg-[#18181b] border border-zinc-200 dark:border-white/10 rounded-xl px-4 py-2.5 text-sm text-zinc-900 dark:text-white outline-none focus:border-purple-400 dark:focus:border-purple-500/50 transition-colors"; + + return ( +
+ {/* Search */} +
+ + setSearchTerm(e.target.value)} + className="w-full bg-zinc-50 dark:bg-[#18181b] border border-zinc-200 dark:border-white/10 rounded-xl pl-9 pr-4 py-2.5 text-sm text-zinc-900 dark:text-white placeholder-zinc-400 dark:placeholder-zinc-600 outline-none focus:border-purple-400 dark:focus:border-purple-500/50 transition-colors" + /> +
+ + {/* Member Cards */} +
+ {filteredMembers.map(member => { + const effective = getEffectiveCommission(member); + const sc = sourceColors[effective.source]; + const role = roles.find(r => r.key === member.roleKey); + const hasIndividualOverride = userCommissionOverrides.some(o => o.userId === member.userId && o.enabled) || member.individualCommission?.enabled; + const isEditing = editingMember === member.id; + + return ( +
+
+
+
+ {member.name.charAt(0)} +
+
+
+ {member.name} + {role?.name || member.roleKey} +
+ {member.email} +
+
+
+ {/* Commission Source Badge */} +
+ {sc.label} + {formatCommission(effective.type, effective.rate)} +
+ {member.roleKey !== 'OWNER' && ( + + )} +
+
+ + {/* Edit Form */} + + {isEditing && ( + +
+
+
+ + +
+
+ + setEditForm(prev => ({ ...prev, rate: e.target.value }))} className={inputClasses} /> +
+
+
+ {hasIndividualOverride && ( + + )} +
+ + +
+
+ + )} + +
+ ); + })} + + {filteredMembers.length === 0 && ( +
+ No members found matching "{searchTerm}" +
+ )} +
+
+ ); +}; + +// ──────────────────────────────────────────────────── +// Main Page +// ──────────────────────────────────────────────────── +const OrgSettings = () => { + const { user } = useAuth(); + const store = useMockStore(); + + const [openSections, setOpenSections] = useState({ + org: true, + roles: false, + access: false, + commission: false, + overrides: false, + }); + + const toggleSection = (key) => setOpenSections(prev => ({ ...prev, [key]: !prev[key] })); + + const sections = [ + { + id: 'org', + icon: Building2, + title: 'Organization Settings', + subtitle: 'Company name, contact info, and general preferences', + color: '#3b82f6', + content: ( + + ), + }, + { + id: 'roles', + icon: Crown, + title: 'Role Management', + subtitle: `${store.orgRoles.length} system roles configured`, + color: '#fda913', + content: ( + + ), + }, + { + id: 'access', + icon: Shield, + title: 'Access Control Matrix', + subtitle: 'Configure module-level permissions per role', + color: '#10b981', + content: ( + + ), + }, + { + id: 'commission', + icon: DollarSign, + title: 'Commission Rules', + subtitle: 'Org defaults and role-level commission overrides', + color: '#06b6d4', + content: ( + + ), + }, + { + id: 'overrides', + icon: Users, + title: 'User Overrides', + subtitle: `${store.orgMembers.length} members — manage individual commission overrides`, + color: '#a855f7', + content: ( + + ), + }, + ]; + + return ( +
+ {/* Header */} +
+
+
+ +
+
+

+ Org Settings +

+

+ Manage roles, permissions, and commission rules for your organization +

+
+
+ + {/* Quick Stats */} +
+ {[ + { label: 'Roles', value: store.orgRoles.length, color: '#fda913' }, + { label: 'Members', value: store.orgMembers.length, color: '#3b82f6' }, + { label: 'Modules', value: Object.keys(store.PERMISSION_MODULES).length, color: '#10b981' }, + { label: 'Commission Overrides', value: store.roleCommissionOverrides.length + store.userCommissionOverrides.length, color: '#a855f7' }, + ].map(stat => ( +
+
{stat.value}
+
{stat.label}
+
+ ))} +
+
+ + {/* Sections */} +
+ {sections.map(section => ( + toggleSection(section.id)} + > + {section.content} + + ))} +
+
+ ); +}; + +export default OrgSettings; From 455462ded44a3664faf349c3bb4e486edc21b44c Mon Sep 17 00:00:00 2001 From: Satyam Rastogi Date: Tue, 12 May 2026 21:08:19 +0530 Subject: [PATCH 10/38] adds access modifiers for each role --- src/components/PermissionGate.jsx | 38 +++ src/hooks/usePermissions.js | 44 +++ src/pages/LeaderboardPage.jsx | 18 +- src/pages/LeadsListPage.jsx | 30 +- src/pages/owner/OrgSettings.jsx | 386 +++++++++++++++++++++---- src/pages/owner/OwnerProjectDetail.jsx | 20 +- src/utils/permissions.js | 100 ++++--- 7 files changed, 517 insertions(+), 119 deletions(-) create mode 100644 src/components/PermissionGate.jsx create mode 100644 src/hooks/usePermissions.js diff --git a/src/components/PermissionGate.jsx b/src/components/PermissionGate.jsx new file mode 100644 index 0000000..b9c8ba1 --- /dev/null +++ b/src/components/PermissionGate.jsx @@ -0,0 +1,38 @@ +import { usePermissions } from '../hooks/usePermissions'; + +/** + * Conditionally renders children based on org-level permissions. + * + * Props: + * module – permission module key (e.g. 'jobs', 'leads', 'financials') + * action – single action string (e.g. 'view', 'edit', 'delete') + * any – array of actions; renders if user has ANY of them + * all – array of actions; renders if user has ALL of them + * fallback – optional element rendered when access is denied + * + * Usage: + * + * + * + * + * }> + * + * + */ +const PermissionGate = ({ module, action, any, all, fallback = null, children }) => { + const { can, canAny, canAll } = usePermissions(); + + let allowed = false; + + if (action) { + allowed = can(module, action); + } else if (any) { + allowed = canAny(module, any); + } else if (all) { + allowed = canAll(module, all); + } + + return allowed ? children : fallback; +}; + +export default PermissionGate; diff --git a/src/hooks/usePermissions.js b/src/hooks/usePermissions.js new file mode 100644 index 0000000..83e5fe0 --- /dev/null +++ b/src/hooks/usePermissions.js @@ -0,0 +1,44 @@ +import { useCallback, useMemo } from 'react'; +import { useAuth } from '../context/AuthContext'; +import { useMockStore } from '../data/mockStore'; +import { resolveOrgRoleKey } from '../utils/permissions'; + +/** + * Hook that provides permission checks against the dynamic orgPermissions matrix. + * + * Usage: + * const { can, canAny, canAll, orgRoleKey } = usePermissions(); + * if (can('jobs', 'edit')) { ... } + * if (canAny('leads', ['log', 'verify'])) { ... } + */ +export const usePermissions = () => { + const { user } = useAuth(); + const { orgPermissions, orgMembers } = useMockStore(); + + const orgRoleKey = useMemo( + () => resolveOrgRoleKey(user, orgMembers), + [user, orgMembers], + ); + + const can = useCallback( + (moduleKey, action) => { + if (!orgRoleKey) return false; + if (orgRoleKey === 'OWNER') return true; + const actions = orgPermissions[moduleKey]?.[orgRoleKey] || []; + return actions.includes(action); + }, + [orgRoleKey, orgPermissions], + ); + + const canAny = useCallback( + (moduleKey, actions) => actions.some(a => can(moduleKey, a)), + [can], + ); + + const canAll = useCallback( + (moduleKey, actions) => actions.every(a => can(moduleKey, a)), + [can], + ); + + return { can, canAny, canAll, orgRoleKey }; +}; diff --git a/src/pages/LeaderboardPage.jsx b/src/pages/LeaderboardPage.jsx index f571d5c..7473a31 100644 --- a/src/pages/LeaderboardPage.jsx +++ b/src/pages/LeaderboardPage.jsx @@ -1,11 +1,13 @@ import React, { useState, useMemo } from 'react'; import { useMockStore } from '../data/mockStore'; -import { Trophy, TrendingUp, DollarSign, Target, Calendar, Medal, ArrowUpRight, Crown } from 'lucide-react'; +import { Trophy, TrendingUp, DollarSign, Target, Calendar, Medal, ArrowUpRight, Crown, ShieldOff } from 'lucide-react'; import { SpotlightCard } from '../components/SpotlightCard'; import AnimatedNumber from '../components/AnimatedNumber'; +import { usePermissions } from '../hooks/usePermissions'; const LeaderboardPage = () => { const { users, salesHistory } = useMockStore(); + const { can } = usePermissions(); const [timeframe, setTimeframe] = useState('month'); // 'week', 'month', 'custom' const [metric, setMetric] = useState('revenue'); // 'revenue', 'volume', 'winRate' @@ -108,6 +110,20 @@ const LeaderboardPage = () => { ); }; + if (!can('leaderboard', 'view')) { + return ( +
+
+
+ +
+

Access Restricted

+

You don't have permission to view the Leaderboard. Contact your org admin for access.

+
+
+ ); + } + return (
{/* Header */} diff --git a/src/pages/LeadsListPage.jsx b/src/pages/LeadsListPage.jsx index 4d6e267..8f2ab7b 100644 --- a/src/pages/LeadsListPage.jsx +++ b/src/pages/LeadsListPage.jsx @@ -3,9 +3,11 @@ import { useNavigate } from 'react-router-dom'; import { motion, AnimatePresence } from 'framer-motion'; import { useTheme } from '../context/ThemeContext'; import { useMockStore } from '../data/mockStore'; +import { usePermissions } from '../hooks/usePermissions'; +import PermissionGate from '../components/PermissionGate'; import { Plus, Search, MapPin, Phone, Zap, FileText, - Clock, User, ChevronRight, Filter, + Clock, User, ChevronRight, Filter, Lock, } from 'lucide-react'; // --------------------------------------------------------------------------- @@ -183,6 +185,7 @@ function EmptyState({ isDark, onNewLead }) { export default function LeadsListPage() { const { theme } = useTheme(); const { leads } = useMockStore(); + const { can } = usePermissions(); const navigate = useNavigate(); const isDark = theme === 'dark'; @@ -214,15 +217,22 @@ export default function LeadsListPage() { {leads.length} total lead{leads.length !== 1 ? 's' : ''}

- + {can('leads', 'log') ? ( + + ) : ( +
+ + New Lead +
+ )}
{/* --- Search bar --- */} diff --git a/src/pages/owner/OrgSettings.jsx b/src/pages/owner/OrgSettings.jsx index 71898b8..708644a 100644 --- a/src/pages/owner/OrgSettings.jsx +++ b/src/pages/owner/OrgSettings.jsx @@ -1,14 +1,16 @@ -import React, { useState, useMemo } from 'react'; +import React, { useState, useMemo, useCallback, useRef } from 'react'; import { motion, AnimatePresence } from 'framer-motion'; import { Settings, Building2, Shield, Users, DollarSign, ChevronDown, ChevronRight, Search, Check, X, Plus, Trash2, Edit3, Save, User, Crown, ToggleLeft, ToggleRight, Info, ArrowRight, Mail, Phone, MapPin, Clock, - AlertTriangle, UserPlus, UserMinus, Eye, Pencil, Lock + AlertTriangle, UserPlus, UserMinus, Eye, Pencil, Lock, + RotateCcw, Filter, CheckSquare, Square, BarChart3, History } from 'lucide-react'; import { useMockStore } from '../../data/mockStore'; import { useAuth } from '../../context/AuthContext'; import { toast } from 'sonner'; +import { computeRolePermissionStats } from '../../utils/permissions'; const COMMISSION_TYPES = [ { value: 'flat', label: 'Flat Amount', hint: 'Fixed dollar amount' }, @@ -225,35 +227,213 @@ const RoleManagementSection = ({ roles, members }) => { }; // ──────────────────────────────────────────────────── -// 3. Access Control Matrix +// 3. Access Control Matrix (Full) // ──────────────────────────────────────────────────── const AccessControlMatrix = ({ permissions, permissionModules, roles, onUpdate }) => { - const togglePermission = (moduleKey, roleKey, action) => { - const current = permissions[moduleKey]?.[roleKey] || []; - const updated = current.includes(action) - ? current.filter(a => a !== action) - : [...current, action]; - onUpdate(moduleKey, roleKey, updated); - }; + const [filterModule, setFilterModule] = useState('all'); + const [filterRole, setFilterRole] = useState('all'); + const [changeLog, setChangeLog] = useState([]); + const [showLog, setShowLog] = useState(false); + const snapshotRef = useRef(JSON.stringify(permissions)); - const moduleEntries = Object.entries(permissionModules); + const moduleEntries = useMemo(() => { + const entries = Object.entries(permissionModules); + if (filterModule === 'all') return entries; + return entries.filter(([key]) => key === filterModule); + }, [permissionModules, filterModule]); + + const visibleRoles = useMemo(() => { + if (filterRole === 'all') return roles; + return roles.filter(r => r.key === filterRole); + }, [roles, filterRole]); + + const isDirty = JSON.stringify(permissions) !== snapshotRef.current; + + const togglePermission = useCallback((moduleKey, roleKey, action) => { + const current = permissions[moduleKey]?.[roleKey] || []; + const has = current.includes(action); + const updated = has ? current.filter(a => a !== action) : [...current, action]; + onUpdate(moduleKey, roleKey, updated); + + const modLabel = permissionModules[moduleKey]?.label || moduleKey; + const roleName = roles.find(r => r.key === roleKey)?.name || roleKey; + setChangeLog(prev => [ + { id: Date.now(), module: modLabel, role: roleName, action, granted: !has, time: new Date().toLocaleTimeString() }, + ...prev.slice(0, 49), + ]); + }, [permissions, onUpdate, permissionModules, roles]); + + const toggleAllForRole = useCallback((roleKey, grant) => { + for (const [moduleKey, mod] of Object.entries(permissionModules)) { + onUpdate(moduleKey, roleKey, grant ? [...mod.actions] : []); + } + const roleName = roles.find(r => r.key === roleKey)?.name || roleKey; + toast.success(grant ? `All permissions granted for ${roleName}` : `All permissions revoked for ${roleName}`); + }, [permissionModules, onUpdate, roles]); + + const toggleAllForModule = useCallback((moduleKey, grant) => { + const mod = permissionModules[moduleKey]; + if (!mod) return; + for (const role of roles) { + if (role.key === 'OWNER') continue; + onUpdate(moduleKey, role.key, grant ? [...mod.actions] : []); + } + toast.success(grant ? `All roles granted access to ${mod.label}` : `All roles revoked from ${mod.label}`); + }, [permissionModules, roles, onUpdate]); + + const handleReset = useCallback(() => { + const snap = JSON.parse(snapshotRef.current); + for (const [moduleKey, roleMap] of Object.entries(snap)) { + for (const [roleKey, actions] of Object.entries(roleMap)) { + onUpdate(moduleKey, roleKey, actions); + } + } + setChangeLog([]); + toast.success('Permissions reset to last saved state'); + }, [onUpdate]); + + const handleSave = useCallback(() => { + snapshotRef.current = JSON.stringify(permissions); + setChangeLog([]); + toast.success('Access control permissions saved'); + }, [permissions]); + + const roleStats = useMemo(() => { + const map = {}; + for (const role of roles) { + if (role.key === 'OWNER') { + const total = Object.values(permissionModules).reduce((s, m) => s + m.actions.length, 0); + map[role.key] = { granted: total, total }; + } else { + map[role.key] = computeRolePermissionStats(role.key, permissions, permissionModules); + } + } + return map; + }, [roles, permissions, permissionModules]); return ( -
-
- Click cells to toggle permissions per role +
+ {/* ── Toolbar ── */} +
+
+ {/* Module filter */} +
+ + +
+ {/* Role filter */} + + {/* Change log toggle */} + +
+
+ {isDirty && ( + + )} + +
- {/* Desktop Matrix */} -
+ {/* ── Role Summary Stats ── */} +
+ {roles.map(role => { + const stats = roleStats[role.key]; + const pct = stats.total > 0 ? Math.round((stats.granted / stats.total) * 100) : 0; + return ( +
+
+
+ {role.name.split(' ')[0]} +
+
+ {stats.granted} + / {stats.total} +
+
+
+
+
+ ); + })} +
+ + {/* ── Desktop Matrix ── */} +
- + - {roles.map(role => ( - ))} @@ -267,14 +447,34 @@ const AccessControlMatrix = ({ permissions, permissionModules, roles, onUpdate } className="text-xs font-semibold text-zinc-900 dark:text-white px-4 py-2.5 border-b border-zinc-100 dark:border-white/5 align-top" rowSpan={module.actions.length} > -
- - {module.label} +
+
+ + {module.label} +
+
+ + +
)}
- {roles.map(role => { + {visibleRoles.map(role => { const hasPermission = (permissions[moduleKey]?.[role.key] || []).includes(action); const isOwner = role.key === 'OWNER'; return ( @@ -289,13 +489,14 @@ const AccessControlMatrix = ({ permissions, permissionModules, roles, onUpdate } )} @@ -308,40 +509,111 @@ const AccessControlMatrix = ({ permissions, permissionModules, roles, onUpdate }
Module Action - {role.name.split(' ')[0]} + {visibleRoles.map(role => ( + +
+ {role.name.split(' ')[0]} + {role.key !== 'OWNER' && ( +
+ + +
+ )} +
{action}
- {/* Mobile Cards */} + {/* ── Mobile Cards ── */}
- {roles.filter(r => r.key !== 'OWNER').map(role => ( -
-
-
- {role.name} -
- {moduleEntries.map(([moduleKey, module]) => ( -
- {module.label} -
- {module.actions.map(action => { - const hasPermission = (permissions[moduleKey]?.[role.key] || []).includes(action); - return ( - - ); - })} + {visibleRoles.filter(r => r.key !== 'OWNER').map(role => { + const stats = roleStats[role.key]; + return ( +
+
+
+
+ {role.name} + {stats.granted}/{stats.total} +
+
+ +
- ))} +
+ {moduleEntries.map(([moduleKey, module]) => ( +
+ {module.label} +
+ {module.actions.map(action => { + const hasPermission = (permissions[moduleKey]?.[role.key] || []).includes(action); + return ( + + ); + })} +
+
+ ))} +
+
+ ); + })} +
+ + {/* ── Change Log ── */} + + {showLog && ( + +
+
+
+ + Change Log + (unsaved session) +
+ {changeLog.length > 0 && ( + + )} +
+ {changeLog.length === 0 ? ( +

No changes yet

+ ) : ( +
+ {changeLog.map(entry => ( +
+ {entry.time} + + {entry.granted ? '+' : '-'} + + + {entry.role} + / + {entry.module} + / + {entry.action} + +
+ ))} +
+ )} +
+
+ )} +
+ + {/* ── Info Footer ── */} +
+
+ +
+

Org Owner always has full access to all modules and cannot be restricted.

+

Permission changes apply immediately to all users with the affected role. Use the Save button to persist changes or Reset to revert.

- ))} +
); diff --git a/src/pages/owner/OwnerProjectDetail.jsx b/src/pages/owner/OwnerProjectDetail.jsx index 23a8842..62e6e67 100644 --- a/src/pages/owner/OwnerProjectDetail.jsx +++ b/src/pages/owner/OwnerProjectDetail.jsx @@ -18,6 +18,7 @@ import ChangeOrderDrawer from '../../components/owner/ChangeOrderDrawer'; import InvoiceDetailModal from '../../components/owner/InvoiceDetailModal'; import CreateItemModal from '../../components/owner/CreateItemModal'; import CommissionSettingsModal from '../../components/owner/CommissionSettingsModal'; +import { usePermissions } from '../../hooks/usePermissions'; // --- 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"; @@ -141,6 +142,7 @@ const OwnerProjectDetail = () => { removeUserCommissionOverride, updateProject, } = useMockStore(); const navigate = useNavigate(); + const { can } = usePermissions(); const [activeTab, setActiveTab] = useState('overview'); const [selectedCO, setSelectedCO] = useState(null); const [selectedInvoice, setSelectedInvoice] = useState(null); @@ -788,14 +790,16 @@ const OwnerProjectDetail = () => { })()}
- + {can('commission', 'edit') && ( + + )}

Commission Settings

-

Configure commission hierarchy: Job → User → Org

+

Configure commission hierarchy: Job → User → Role → Org

)}
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; +} From 315191c01b64d0785cfa96ed4695a876da6bbff1 Mon Sep 17 00:00:00 2001 From: Satyam Rastogi Date: Wed, 13 May 2026 17:47:48 +0530 Subject: [PATCH 12/38] allow admin to access orgSetting page --- src/App.jsx | 2 +- src/components/Layout.jsx | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/App.jsx b/src/App.jsx index 5100797..ad5198d 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -215,7 +215,7 @@ function App() { } /> + } /> diff --git a/src/components/Layout.jsx b/src/components/Layout.jsx index dab1282..25caef0 100644 --- a/src/components/Layout.jsx +++ b/src/components/Layout.jsx @@ -167,6 +167,7 @@ const Layout = () => { label: ProCanvas }, { to: "/admin/estimates", icon: Calculator, label: "Estimates" }, + { to: "/owner/settings", icon: Settings, label: "Org Settings" }, ...commonItems, ]; case 'CONTRACTOR': From 2299cda11db4569c377e62e014561b7d87a4540d Mon Sep 17 00:00:00 2001 From: Satyam Rastogi Date: Wed, 13 May 2026 18:55:09 +0530 Subject: [PATCH 13/38] org team management section in org Settings module --- src/components/owner/OrgTeamManagement.jsx | 1009 ++++++++++++++++++++ src/data/mockStore.jsx | 44 +- src/pages/owner/OrgSettings.jsx | 34 +- 3 files changed, 1070 insertions(+), 17 deletions(-) create mode 100644 src/components/owner/OrgTeamManagement.jsx diff --git a/src/components/owner/OrgTeamManagement.jsx b/src/components/owner/OrgTeamManagement.jsx new file mode 100644 index 0000000..e2a103b --- /dev/null +++ b/src/components/owner/OrgTeamManagement.jsx @@ -0,0 +1,1009 @@ +import React, { useState, useMemo, useCallback } from 'react'; +import { motion, AnimatePresence } from 'framer-motion'; +import { + Search, UserPlus, Filter, MoreVertical, Edit3, Trash2, Mail, Shield, + Eye, DollarSign, X, Check, ChevronDown, Crown, AlertTriangle, + Users, RefreshCw, Ban, UserCheck, Clock, Briefcase, ArrowUpDown, +} from 'lucide-react'; +import { toast } from 'sonner'; + +const ROLE_OPTIONS = [ + { key: 'ADMIN', label: 'Admin / Manager' }, + { key: 'SALES_REP', label: 'Sales Rep' }, + { key: 'CANVASSER', label: 'Canvasser' }, + { key: 'SUBCONTRACTOR', label: 'Subcontractor' }, +]; + +const STATUS_CONFIG = { + active: { label: 'Active', bg: 'bg-emerald-50 dark:bg-emerald-500/10', border: 'border-emerald-200 dark:border-emerald-500/20', text: 'text-emerald-600 dark:text-emerald-400' }, + invited: { label: 'Invited', bg: 'bg-amber-50 dark:bg-amber-500/10', border: 'border-amber-200 dark:border-amber-500/20', text: 'text-amber-600 dark:text-amber-400' }, + suspended: { label: 'Suspended', bg: 'bg-red-50 dark:bg-red-500/10', border: 'border-red-200 dark:border-red-500/20', text: 'text-red-500 dark:text-red-400' }, +}; + +const inputClasses = "w-full bg-zinc-50 dark:bg-[#18181b] border border-zinc-200 dark:border-white/10 rounded-xl px-4 py-2.5 text-sm text-zinc-900 dark:text-white outline-none focus:border-blue-500/50 transition-colors"; + +// ──────────────────────────────────────────────────── +// Invite Member Modal +// ──────────────────────────────────────────────────── +const InviteMemberModal = ({ isOpen, onClose, onInvite, existingMembers, users }) => { + const [inviteMethod, setInviteMethod] = useState('email'); + const [email, setEmail] = useState(''); + const [name, setName] = useState(''); + const [roleKey, setRoleKey] = useState(''); + const [userSearch, setUserSearch] = useState(''); + const [selectedUser, setSelectedUser] = useState(null); + + const existingIds = new Set(existingMembers.map(m => m.userId)); + const existingEmails = new Set(existingMembers.map(m => m.email.toLowerCase())); + + const searchResults = useMemo(() => { + if (!userSearch.trim() || inviteMethod !== 'username') return []; + const term = userSearch.toLowerCase(); + return (users || []) + .filter(u => + (u.type === 'employee' || u.type === 'owner' || u.type === 'contractor' || u.type === 'subcontractor') && + !existingIds.has(u.id) && + (u.name?.toLowerCase().includes(term) || u.email?.toLowerCase().includes(term)) + ) + .slice(0, 5); + }, [userSearch, users, existingIds, inviteMethod]); + + const handleSubmit = () => { + if (!roleKey) { + toast.error('Please select a role'); + return; + } + + if (inviteMethod === 'email') { + if (!email.trim() || !email.includes('@')) { + toast.error('Please enter a valid email address'); + return; + } + if (existingEmails.has(email.toLowerCase())) { + toast.error('This email is already a member of the organization'); + return; + } + onInvite({ + userId: `usr_${Date.now()}`, + name: name.trim() || email.split('@')[0], + email: email.trim().toLowerCase(), + roleKey, + status: 'invited', + }); + } else { + if (!selectedUser) { + toast.error('Please select a user'); + return; + } + onInvite({ + userId: selectedUser.id, + name: selectedUser.name, + email: selectedUser.email, + roleKey, + status: 'invited', + }); + } + + setEmail(''); + setName(''); + setRoleKey(''); + setUserSearch(''); + setSelectedUser(null); + onClose(); + toast.success('Invitation sent successfully'); + }; + + if (!isOpen) return null; + + return ( + + + e.stopPropagation()} + > + {/* Header */} +
+
+
+ +
+
+

Invite Team Member

+

Add a new member to your organization

+
+
+ +
+ + {/* Body */} +
+ {/* Invite Method Tabs */} +
+ {[ + { key: 'email', label: 'Email Invite', icon: Mail }, + { key: 'username', label: 'Search Users', icon: Search }, + ].map(tab => ( + + ))} +
+ + {inviteMethod === 'email' ? ( +
+
+ + setEmail(e.target.value)} + placeholder="colleague@company.com" + className={inputClasses} + /> +
+
+ + setName(e.target.value)} + placeholder="John Doe (optional)" + className={inputClasses} + /> +
+
+ ) : ( +
+
+ +
+ + { setUserSearch(e.target.value); setSelectedUser(null); }} + placeholder="Search by name or email..." + className={`${inputClasses} pl-9`} + /> +
+
+ {searchResults.length > 0 && !selectedUser && ( +
+ {searchResults.map(u => ( + + ))} +
+ )} + {selectedUser && ( +
+
+ {selectedUser.name?.charAt(0)} +
+
+
{selectedUser.name}
+
{selectedUser.email}
+
+ +
+ )} + {userSearch.trim() && searchResults.length === 0 && !selectedUser && ( +

No matching users found

+ )} +
+ )} + + {/* Role Selection */} +
+ + +
+
+ + {/* Footer */} +
+ + +
+
+
+
+ ); +}; + +// ──────────────────────────────────────────────────── +// Confirmation Dialog +// ──────────────────────────────────────────────────── +const ConfirmDialog = ({ isOpen, onClose, onConfirm, title, message, confirmLabel, confirmColor }) => { + if (!isOpen) return null; + const colorMap = { + red: 'bg-red-500 hover:bg-red-600', + blue: 'bg-blue-500 hover:bg-blue-600', + emerald: 'bg-emerald-500 hover:bg-emerald-600', + amber: 'bg-amber-500 hover:bg-amber-600', + }; + return ( + + + e.stopPropagation()} + > +
+
+ +
+

{title}

+
+

{message}

+
+ + +
+
+
+
+ ); +}; + +// ──────────────────────────────────────────────────── +// View Permissions Modal +// ──────────────────────────────────────────────────── +const ViewPermissionsModal = ({ isOpen, onClose, member, permissions, permissionModules, roles }) => { + if (!isOpen || !member) return null; + const role = roles.find(r => r.key === member.roleKey); + const isOwner = member.roleKey === 'OWNER'; + + return ( + + + e.stopPropagation()} + > +
+
+
+ {member.name.charAt(0)} +
+
+

{member.name}

+

{role?.name || member.roleKey} — Permissions

+
+
+ +
+
+ {isOwner && ( +
+ Org Owner has full access to all modules +
+ )} + {Object.entries(permissionModules).map(([moduleKey, mod]) => { + const memberPerms = isOwner ? mod.actions : (permissions[moduleKey]?.[member.roleKey] || []); + return ( +
+
+ + {mod.label} +
+
+ {mod.actions.map(action => { + const has = memberPerms.includes(action); + return ( + + {has && } + {action} + + ); + })} +
+
+ ); + })} +
+
+ +
+
+
+
+ ); +}; + +// ──────────────────────────────────────────────────── +// Member Action Menu +// ──────────────────────────────────────────────────── +const MemberActionMenu = ({ member, onAction, isOpen, onToggle, currentUserRoleKey }) => { + const isOwnerMember = member.roleKey === 'OWNER'; + const canManage = currentUserRoleKey === 'OWNER' || (currentUserRoleKey === 'ADMIN' && !isOwnerMember && member.roleKey !== 'ADMIN'); + const canManageAdmin = currentUserRoleKey === 'OWNER'; + + const isManageable = isOwnerMember + ? false + : member.roleKey === 'ADMIN' + ? canManageAdmin + : canManage; + + const actions = []; + + if (isManageable) { + actions.push({ key: 'editRole', label: 'Edit Role', icon: Edit3 }); + } + + if (member.status === 'invited' && isManageable) { + actions.push({ key: 'resendInvite', label: 'Resend Invite', icon: Mail }); + } + + if (member.status === 'active' && isManageable) { + actions.push({ key: 'suspend', label: 'Disable Account', icon: Ban }); + } + + if (member.status === 'suspended' && isManageable) { + actions.push({ key: 'reactivate', label: 'Reactivate', icon: UserCheck }); + } + + actions.push({ key: 'viewPermissions', label: 'View Permissions', icon: Eye }); + actions.push({ key: 'viewCommission', label: 'Commission Settings', icon: DollarSign }); + + if (isManageable) { + actions.push({ key: 'remove', label: 'Remove Member', icon: Trash2, danger: true }); + } + + return ( +
+ + + {isOpen && ( + + {actions.map(action => ( + + ))} + + )} + +
+ ); +}; + +// ──────────────────────────────────────────────────── +// Edit Role Inline +// ──────────────────────────────────────────────────── +const EditRolePanel = ({ member, roles, onSave, onCancel }) => { + const [newRole, setNewRole] = useState(member.roleKey); + const currentRole = roles.find(r => r.key === member.roleKey); + const targetRole = roles.find(r => r.key === newRole); + + const isPromotion = () => { + const order = ['SUBCONTRACTOR', 'CANVASSER', 'SALES_REP', 'ADMIN']; + return order.indexOf(newRole) > order.indexOf(member.roleKey); + }; + + return ( + +
+
+ Current: + + {currentRole?.name} + + + New: + {newRole !== member.roleKey && ( + + {targetRole?.name} + + )} +
+ + {newRole !== member.roleKey && ( +
+ {isPromotion() ? '↑ Promotion' : '↓ Demotion'}: {currentRole?.name} → {targetRole?.name} +
+ )} +
+ + +
+
+
+ ); +}; + +// ──────────────────────────────────────────────────── +// Main Component +// ──────────────────────────────────────────────────── +const OrgTeamManagement = ({ + members, + roles, + users, + permissions, + permissionModules, + currentUserRoleKey, + onAddMember, + onUpdateMember, + onRemoveMember, + onSuspendMember, + onReactivateMember, + onResendInvite, +}) => { + const [searchTerm, setSearchTerm] = useState(''); + const [filterRole, setFilterRole] = useState('all'); + const [filterStatus, setFilterStatus] = useState('all'); + const [sortField, setSortField] = useState('name'); + const [sortDir, setSortDir] = useState('asc'); + const [showInviteModal, setShowInviteModal] = useState(false); + const [openMenuId, setOpenMenuId] = useState(null); + const [editingRoleId, setEditingRoleId] = useState(null); + const [confirmDialog, setConfirmDialog] = useState(null); + const [viewPermsMember, setViewPermsMember] = useState(null); + + const canInvite = currentUserRoleKey === 'OWNER' || currentUserRoleKey === 'ADMIN'; + + const filteredMembers = useMemo(() => { + let result = [...members]; + + if (searchTerm.trim()) { + const term = searchTerm.toLowerCase(); + result = result.filter(m => + m.name.toLowerCase().includes(term) || + m.email.toLowerCase().includes(term) || + m.roleKey.toLowerCase().includes(term) + ); + } + + if (filterRole !== 'all') { + result = result.filter(m => m.roleKey === filterRole); + } + + if (filterStatus !== 'all') { + result = result.filter(m => m.status === filterStatus); + } + + result.sort((a, b) => { + let valA, valB; + switch (sortField) { + case 'name': valA = a.name.toLowerCase(); valB = b.name.toLowerCase(); break; + case 'role': valA = a.roleKey; valB = b.roleKey; break; + case 'status': valA = a.status; valB = b.status; break; + case 'joinedAt': valA = a.joinedAt; valB = b.joinedAt; break; + case 'lastActivity': valA = a.lastActivity || ''; valB = b.lastActivity || ''; break; + case 'jobs': valA = a.assignedJobsCount || 0; valB = b.assignedJobsCount || 0; break; + default: valA = a.name; valB = b.name; + } + if (valA < valB) return sortDir === 'asc' ? -1 : 1; + if (valA > valB) return sortDir === 'asc' ? 1 : -1; + return 0; + }); + + return result; + }, [members, searchTerm, filterRole, filterStatus, sortField, sortDir]); + + const toggleSort = useCallback((field) => { + if (sortField === field) { + setSortDir(prev => prev === 'asc' ? 'desc' : 'asc'); + } else { + setSortField(field); + setSortDir('asc'); + } + }, [sortField]); + + const handleAction = useCallback((actionKey, member) => { + switch (actionKey) { + case 'editRole': + setEditingRoleId(member.id); + break; + case 'resendInvite': + onResendInvite(member.id); + break; + case 'suspend': + setConfirmDialog({ + title: 'Disable Account', + message: `Are you sure you want to suspend ${member.name}? They will lose access to the organization until reactivated.`, + confirmLabel: 'Suspend', + confirmColor: 'red', + onConfirm: () => { + onSuspendMember(member.id); + toast.success(`${member.name} has been suspended`); + }, + }); + break; + case 'reactivate': + onReactivateMember(member.id); + toast.success(`${member.name} has been reactivated`); + break; + case 'viewPermissions': + setViewPermsMember(member); + break; + case 'viewCommission': + toast.info(`Commission settings for ${member.name} — check the User Overrides section`); + break; + case 'remove': + setConfirmDialog({ + title: 'Remove Member', + message: `Are you sure you want to remove ${member.name} from the organization? This action cannot be undone.`, + confirmLabel: 'Remove', + confirmColor: 'red', + onConfirm: () => { + onRemoveMember(member.id); + toast.success(`${member.name} has been removed from the organization`); + }, + }); + break; + default: + break; + } + }, [onResendInvite, onSuspendMember, onReactivateMember, onRemoveMember]); + + const handleRoleSave = useCallback((memberId, newRoleKey) => { + const member = members.find(m => m.id === memberId); + const newRole = roles.find(r => r.key === newRoleKey); + onUpdateMember(memberId, { roleKey: newRoleKey }); + setEditingRoleId(null); + toast.success(`${member?.name}'s role updated to ${newRole?.name}`); + }, [members, roles, onUpdateMember]); + + const roleCounts = useMemo(() => { + const counts = {}; + roles.forEach(r => { counts[r.key] = members.filter(m => m.roleKey === r.key).length; }); + return counts; + }, [members, roles]); + + const statusCounts = useMemo(() => ({ + active: members.filter(m => m.status === 'active').length, + invited: members.filter(m => m.status === 'invited').length, + suspended: members.filter(m => m.status === 'suspended').length, + }), [members]); + + const SortHeader = ({ field, children, className = '' }) => ( + + ); + + return ( +
+ {/* Quick Stats */} +
+
+
{members.length}
+
Total Members
+
+
+
{statusCounts.active}
+
Active
+
+
+
{statusCounts.invited}
+
Invited
+
+
+
{statusCounts.suspended}
+
Suspended
+
+
+ + {/* Toolbar */} +
+
+ {/* Search */} +
+ + setSearchTerm(e.target.value)} + className="w-full bg-zinc-50 dark:bg-[#18181b] border border-zinc-200 dark:border-white/10 rounded-xl pl-9 pr-4 py-2 text-sm text-zinc-900 dark:text-white placeholder-zinc-400 dark:placeholder-zinc-600 outline-none focus:border-blue-500/50 transition-colors" + /> +
+ {/* Role Filter */} +
+ + +
+ {/* Status Filter */} + +
+ {canInvite && ( + + )} +
+ + {/* Desktop Table */} +
+ + + + + + + + + + + + + + {filteredMembers.map(member => { + const role = roles.find(r => r.key === member.roleKey); + const sc = STATUS_CONFIG[member.status] || STATUS_CONFIG.active; + const isEditing = editingRoleId === member.id; + + return ( + + + {/* Member */} + + {/* Role */} + + {/* Status */} + + {/* Joined */} + + {/* Last Active */} + + {/* Jobs */} + + {/* Actions */} + + + {/* Edit Role Inline */} + + {isEditing && ( + + + + )} + + + ); + })} + +
+ Member + + Org Role + + Status + + Joined + + Last Active + + Jobs + + Actions +
+
+
+ {member.name.charAt(0)} +
+
+
+ {member.name} + {member.roleKey === 'OWNER' && } +
+ {member.email} +
+
+
+ + {role?.name || member.roleKey} + + + + {sc.label} + + +
+ + {member.joinedAt} +
+
+ + {member.lastActivity || '—'} + + +
+ + {member.assignedJobsCount || 0} +
+
+ setOpenMenuId(openMenuId === member.id ? null : member.id)} + /> +
+ setEditingRoleId(null)} + /> +
+ {filteredMembers.length === 0 && ( +
+ +

No members found

+

Try adjusting your search or filters

+
+ )} +
+ + {/* Mobile Cards */} +
+ {filteredMembers.map(member => { + const role = roles.find(r => r.key === member.roleKey); + const sc = STATUS_CONFIG[member.status] || STATUS_CONFIG.active; + const isEditing = editingRoleId === member.id; + + return ( +
+
+
+
+ {member.name.charAt(0)} +
+
+
+ {member.name} + {member.roleKey === 'OWNER' && } +
+ {member.email} +
+
+ setOpenMenuId(openMenuId === member.id ? null : member.id)} + /> +
+
+ + {role?.name} + + + {sc.label} + + + {member.assignedJobsCount || 0} jobs + + + {member.joinedAt} + +
+ + {isEditing && ( + setEditingRoleId(null)} + /> + )} + +
+ ); + })} + {filteredMembers.length === 0 && ( +
+ +

No members found

+

Try adjusting your search or filters

+
+ )} +
+ + {/* Result Count */} +
+ Showing {filteredMembers.length} of {members.length} members + {(filterRole !== 'all' || filterStatus !== 'all' || searchTerm.trim()) && ( + + )} +
+ + {/* Modals */} + setShowInviteModal(false)} + onInvite={onAddMember} + existingMembers={members} + users={users} + /> + + setConfirmDialog(null)} + onConfirm={confirmDialog?.onConfirm || (() => {})} + title={confirmDialog?.title || ''} + message={confirmDialog?.message || ''} + confirmLabel={confirmDialog?.confirmLabel || 'Confirm'} + confirmColor={confirmDialog?.confirmColor || 'red'} + /> + + setViewPermsMember(null)} + member={viewPermsMember} + permissions={permissions} + permissionModules={permissionModules} + roles={roles} + /> +
+ ); +}; + +export default OrgTeamManagement; diff --git a/src/data/mockStore.jsx b/src/data/mockStore.jsx index 74a6727..c3b5c9b 100644 --- a/src/data/mockStore.jsx +++ b/src/data/mockStore.jsx @@ -5054,19 +5054,19 @@ export const MockStoreProvider = ({ children }) => { ]); const [orgMembers, setOrgMembers] = useState([ - { id: 'om_001', userId: 'owner_001', name: 'Justin Johnson', email: 'justin@lynkeduppro.com', roleKey: 'OWNER', status: 'active', joinedAt: '2024-01-15' }, - { id: 'om_002', userId: 'owner_002', name: 'Maria Garcia', email: 'maria@lynkeduppro.com', roleKey: 'OWNER', status: 'active', joinedAt: '2024-01-15' }, - { id: 'om_003', userId: 'ADM01', name: 'Admin One', email: 'admin1@lynkeduppro.com', roleKey: 'ADMIN', status: 'active', joinedAt: '2024-02-10' }, - { id: 'om_004', userId: 'ADM02', name: 'Admin Two', email: 'admin2@lynkeduppro.com', roleKey: 'ADMIN', status: 'active', joinedAt: '2024-03-01' }, - { id: 'om_005', userId: 'ADM03', name: 'Admin Three', email: 'admin3@lynkeduppro.com', roleKey: 'ADMIN', status: 'active', joinedAt: '2024-04-20' }, - { id: 'om_006', userId: 'p1', name: 'Jesus Gonzales', email: 'jesus@lynkeduppro.com', roleKey: 'SALES_REP', status: 'active', joinedAt: '2024-03-15', individualCommission: { enabled: true, type: 'percent_gross', rate: 12 } }, - { id: 'om_007', userId: 'p2', name: 'Sarah Sales', email: 'sarah@lynkeduppro.com', roleKey: 'SALES_REP', status: 'active', joinedAt: '2024-04-01', individualCommission: { enabled: true, type: 'flat', rate: 2000 } }, - { id: 'om_008', userId: 'FA001', name: 'Frank Agent', email: 'agent1@plano.com', roleKey: 'CANVASSER', status: 'active', joinedAt: '2024-05-10' }, - { id: 'om_009', userId: 'FA002', name: 'Fiona Field', email: 'agent2@plano.com', roleKey: 'CANVASSER', status: 'active', joinedAt: '2024-05-15' }, - { id: 'om_010', userId: 'FA003', name: 'Fred Flyer', email: 'agent3@plano.com', roleKey: 'CANVASSER', status: 'active', joinedAt: '2024-06-01' }, - { id: 'om_011', userId: 'FA004', name: 'Felicity Fast', email: 'agent4@plano.com', roleKey: 'CANVASSER', status: 'active', joinedAt: '2024-06-10' }, - { id: 'om_012', userId: 'FA005', name: 'Felix Fixer', email: 'agent5@plano.com', roleKey: 'CANVASSER', status: 'active', joinedAt: '2024-07-01' }, - { id: 'om_013', userId: 'sub_001', name: 'Carlos Subcontractor', email: 'carlos@electricpro.com', roleKey: 'SUBCONTRACTOR', status: 'active', joinedAt: '2024-08-01' }, + { id: 'om_001', userId: 'owner_001', name: 'Justin Johnson', email: 'justin@lynkeduppro.com', roleKey: 'OWNER', status: 'active', joinedAt: '2024-01-15', lastActivity: '2025-05-12', assignedJobsCount: 14 }, + { id: 'om_002', userId: 'owner_002', name: 'Maria Garcia', email: 'maria@lynkeduppro.com', roleKey: 'OWNER', status: 'active', joinedAt: '2024-01-15', lastActivity: '2025-05-10', assignedJobsCount: 8 }, + { id: 'om_003', userId: 'ADM01', name: 'Admin One', email: 'admin1@lynkeduppro.com', roleKey: 'ADMIN', status: 'active', joinedAt: '2024-02-10', lastActivity: '2025-05-11', assignedJobsCount: 6 }, + { id: 'om_004', userId: 'ADM02', name: 'Admin Two', email: 'admin2@lynkeduppro.com', roleKey: 'ADMIN', status: 'active', joinedAt: '2024-03-01', lastActivity: '2025-05-09', assignedJobsCount: 4 }, + { id: 'om_005', userId: 'ADM03', name: 'Admin Three', email: 'admin3@lynkeduppro.com', roleKey: 'ADMIN', status: 'active', joinedAt: '2024-04-20', lastActivity: '2025-05-08', assignedJobsCount: 3 }, + { id: 'om_006', userId: 'p1', name: 'Jesus Gonzales', email: 'jesus@lynkeduppro.com', roleKey: 'SALES_REP', status: 'active', joinedAt: '2024-03-15', lastActivity: '2025-05-12', assignedJobsCount: 9, individualCommission: { enabled: true, type: 'percent_gross', rate: 12 } }, + { id: 'om_007', userId: 'p2', name: 'Sarah Sales', email: 'sarah@lynkeduppro.com', roleKey: 'SALES_REP', status: 'active', joinedAt: '2024-04-01', lastActivity: '2025-05-11', assignedJobsCount: 7, individualCommission: { enabled: true, type: 'flat', rate: 2000 } }, + { id: 'om_008', userId: 'FA001', name: 'Frank Agent', email: 'agent1@plano.com', roleKey: 'CANVASSER', status: 'active', joinedAt: '2024-05-10', lastActivity: '2025-05-12', assignedJobsCount: 2 }, + { id: 'om_009', userId: 'FA002', name: 'Fiona Field', email: 'agent2@plano.com', roleKey: 'CANVASSER', status: 'active', joinedAt: '2024-05-15', lastActivity: '2025-05-07', assignedJobsCount: 3 }, + { id: 'om_010', userId: 'FA003', name: 'Fred Flyer', email: 'agent3@plano.com', roleKey: 'CANVASSER', status: 'invited', joinedAt: '2024-06-01', lastActivity: null, assignedJobsCount: 0 }, + { id: 'om_011', userId: 'FA004', name: 'Felicity Fast', email: 'agent4@plano.com', roleKey: 'CANVASSER', status: 'active', joinedAt: '2024-06-10', lastActivity: '2025-05-06', assignedJobsCount: 1 }, + { id: 'om_012', userId: 'FA005', name: 'Felix Fixer', email: 'agent5@plano.com', roleKey: 'CANVASSER', status: 'suspended', joinedAt: '2024-07-01', lastActivity: '2025-03-20', assignedJobsCount: 0 }, + { id: 'om_013', userId: 'sub_001', name: 'Carlos Subcontractor', email: 'carlos@electricpro.com', roleKey: 'SUBCONTRACTOR', status: 'active', joinedAt: '2024-08-01', lastActivity: '2025-05-10', assignedJobsCount: 5 }, ]); // Initialize properties once @@ -5322,13 +5322,29 @@ export const MockStoreProvider = ({ children }) => { setOrgMembers(prev => prev.map(m => m.id === memberId ? { ...m, ...data } : m)); }, addOrgMember: (member) => { - const newMember = { ...member, id: `om_${Date.now()}`, joinedAt: new Date().toISOString().slice(0, 10) }; + const newMember = { + ...member, + id: `om_${Date.now()}`, + joinedAt: new Date().toISOString().slice(0, 10), + lastActivity: null, + assignedJobsCount: 0, + status: member.status || 'invited', + }; setOrgMembers(prev => [...prev, newMember]); return newMember; }, removeOrgMember: (memberId) => { setOrgMembers(prev => prev.filter(m => m.id !== memberId)); }, + suspendOrgMember: (memberId) => { + setOrgMembers(prev => prev.map(m => m.id === memberId ? { ...m, status: 'suspended' } : m)); + }, + reactivateOrgMember: (memberId) => { + setOrgMembers(prev => prev.map(m => m.id === memberId ? { ...m, status: 'active' } : m)); + }, + resendOrgInvite: (memberId) => { + toast.success('Invite resent successfully'); + }, }}> {children} diff --git a/src/pages/owner/OrgSettings.jsx b/src/pages/owner/OrgSettings.jsx index 708644a..ae7afd0 100644 --- a/src/pages/owner/OrgSettings.jsx +++ b/src/pages/owner/OrgSettings.jsx @@ -5,12 +5,14 @@ import { Search, Check, X, Plus, Trash2, Edit3, Save, User, Crown, ToggleLeft, ToggleRight, Info, ArrowRight, Mail, Phone, MapPin, Clock, AlertTriangle, UserPlus, UserMinus, Eye, Pencil, Lock, - RotateCcw, Filter, CheckSquare, Square, BarChart3, History + RotateCcw, Filter, CheckSquare, Square, BarChart3, History, UsersRound } from 'lucide-react'; import { useMockStore } from '../../data/mockStore'; import { useAuth } from '../../context/AuthContext'; +import { usePermissions } from '../../hooks/usePermissions'; import { toast } from 'sonner'; import { computeRolePermissionStats } from '../../utils/permissions'; +import OrgTeamManagement from '../../components/owner/OrgTeamManagement'; const COMMISSION_TYPES = [ { value: 'flat', label: 'Flat Amount', hint: 'Fixed dollar amount' }, @@ -984,9 +986,11 @@ const UserOverridesSection = ({ members, roles, orgDefaults, roleOverrides, user const OrgSettings = () => { const { user } = useAuth(); const store = useMockStore(); + const { orgRoleKey } = usePermissions(); const [openSections, setOpenSections] = useState({ org: true, + team: false, roles: false, access: false, commission: false, @@ -1009,6 +1013,29 @@ const OrgSettings = () => { /> ), }, + { + id: 'team', + icon: UsersRound, + title: 'Organization Team Management', + subtitle: `${store.orgMembers.length} members — invite, manage roles, and administer your team`, + color: '#8b5cf6', + content: ( + + ), + }, { id: 'roles', icon: Crown, @@ -1093,11 +1120,12 @@ const OrgSettings = () => {
{/* Quick Stats */} -
+
{[ { label: 'Roles', value: store.orgRoles.length, color: '#fda913' }, { label: 'Members', value: store.orgMembers.length, color: '#3b82f6' }, - { label: 'Modules', value: Object.keys(store.PERMISSION_MODULES).length, color: '#10b981' }, + { label: 'Active', value: store.orgMembers.filter(m => m.status === 'active').length, color: '#10b981' }, + { label: 'Modules', value: Object.keys(store.PERMISSION_MODULES).length, color: '#8b5cf6' }, { label: 'Commission Overrides', value: store.roleCommissionOverrides.length + store.userCommissionOverrides.length, color: '#a855f7' }, ].map(stat => (
From 46605fd31e59f1682951f99ad81c176ccff6099a Mon Sep 17 00:00:00 2001 From: Satyam Rastogi Date: Wed, 13 May 2026 19:59:37 +0530 Subject: [PATCH 14/38] Team Members section added in Projects List page --- src/pages/owner/OwnerProjectList.jsx | 71 +++++++++++++++++++++++++++- 1 file changed, 69 insertions(+), 2 deletions(-) diff --git a/src/pages/owner/OwnerProjectList.jsx b/src/pages/owner/OwnerProjectList.jsx index cc03fad..9eec28a 100644 --- a/src/pages/owner/OwnerProjectList.jsx +++ b/src/pages/owner/OwnerProjectList.jsx @@ -3,9 +3,10 @@ import { useNavigate } from 'react-router-dom'; import { useAuth } from '../../context/AuthContext'; import { useMockStore } from '../../data/mockStore'; import { SpotlightCard } from '../../components/SpotlightCard'; +import TeamManagementPanel from '../../components/owner/TeamManagementPanel'; import { Search, ChevronRight, ArrowUpDown, Briefcase, LayoutGrid, - CheckCircle, Clock, AlertTriangle, PauseCircle, ShieldAlert, User + CheckCircle, Clock, AlertTriangle, PauseCircle, ShieldAlert, User, Users, ChevronDown } from 'lucide-react'; const statusConfig = { @@ -49,7 +50,12 @@ function getPipelineHealth(lead) { const OwnerProjectList = () => { const { user } = useAuth(); - const { projects, vendors, kanbanLeads, kanbanColumns } = useMockStore(); + const { + projects, vendors, kanbanLeads, kanbanColumns, + users, personnel, orgCommissionDefaults, userCommissionOverrides, + roleCommissionOverrides, orgMembers, + addJobTeamMember, removeJobTeamMember, updateJobTeamMember, + } = useMockStore(); const navigate = useNavigate(); const [activeSection, setActiveSection] = useState('construction'); // 'construction' | 'pipeline' @@ -69,6 +75,23 @@ const OwnerProjectList = () => { projects.filter(p => p.ownerId === user?.id) , [projects, user]); + // ── Team Management ── + const [selectedProjectId, setSelectedProjectId] = useState(null); + const selectedProject = useMemo(() => { + const id = selectedProjectId || ownerProjects.find(p => p.status === 'active')?.id; + return ownerProjects.find(p => p.id === id) || ownerProjects[0] || null; + }, [selectedProjectId, ownerProjects]); + + const teamFinancials = useMemo(() => { + if (!selectedProject) return { totalReceived: 0, grossProfit: 0 }; + const paid = (selectedProject.paymentSchedule || []) + .filter(ps => ps.status === 'paid') + .reduce((s, ps) => s + (ps.amount || 0), 0); + const totalReceived = paid || (selectedProject.budget || 0); + const totalCosts = Number(selectedProject.actualCost ?? selectedProject.spent) || 0; + return { totalReceived, grossProfit: totalReceived - totalCosts }; + }, [selectedProject]); + // Apply filters const filteredProjects = useMemo(() => { return ownerProjects.filter(p => { @@ -635,6 +658,50 @@ const OwnerProjectList = () => { )} + {/* ── Owner's Box — Team Management ── */} + {selectedProject && ( +
+
+

+ + Team Management +

+
+ + +
+
+ + + +
+ )} +
igotsar.matyas | LynkedUpPro - Turns out roofing CRMs don't build themselves. Who knew?
From 661005491567582352f0412ed862a8c42635c0b8 Mon Sep 17 00:00:00 2001 From: Satyam Rastogi Date: Wed, 13 May 2026 20:11:30 +0530 Subject: [PATCH 15/38] change org default commission from 10 to 0 --- src/data/mockStore.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/data/mockStore.jsx b/src/data/mockStore.jsx index c3b5c9b..60312a3 100644 --- a/src/data/mockStore.jsx +++ b/src/data/mockStore.jsx @@ -4964,7 +4964,7 @@ export const MockStoreProvider = ({ children }) => { // Commission Settings — Org defaults + per-user overrides const [orgCommissionDefaults, setOrgCommissionDefaults] = useState({ type: 'percent_gross', - rate: 10, + rate: 0, }); const [userCommissionOverrides, setUserCommissionOverrides] = useState([ { userId: 'p1', userName: 'Jesus Gonzales', enabled: true, type: 'percent_gross', rate: 12 }, From 1798743dde7b007dfe3983fc681d7be8aecf2dd2 Mon Sep 17 00:00:00 2001 From: Satyam Rastogi Date: Wed, 13 May 2026 20:24:33 +0530 Subject: [PATCH 16/38] create Teams tab in projectdetails page and display team managment section, removed from projects lists page --- src/pages/owner/OwnerProjectDetail.jsx | 38 +++++++++++++- src/pages/owner/OwnerProjectList.jsx | 71 +------------------------- 2 files changed, 38 insertions(+), 71 deletions(-) diff --git a/src/pages/owner/OwnerProjectDetail.jsx b/src/pages/owner/OwnerProjectDetail.jsx index f89007c..b450ff7 100644 --- a/src/pages/owner/OwnerProjectDetail.jsx +++ b/src/pages/owner/OwnerProjectDetail.jsx @@ -12,12 +12,13 @@ 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, Settings, Building2, User as UserIcon, Briefcase + Upload, Trash2, Eye, Pencil, X as XIcon, FolderOpen, Download, File, StickyNote, Banknote, ChevronLeft, ChevronDown as ChevronDownIcon, Info, Settings, Building2, User as UserIcon, Briefcase, Users } 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'; +import TeamManagementPanel from '../../components/owner/TeamManagementPanel'; import { usePermissions } from '../../hooks/usePermissions'; import { resolveCommission, findAssignedUserRoleKey } from '../../utils/commissionResolver'; @@ -66,6 +67,7 @@ const statusConfig = { const tabs = [ { id: 'overview', label: 'Overview', icon: Activity }, { id: 'budget', label: 'Budget & Costs', icon: DollarSign }, + { id: 'team', label: 'Team', icon: Users }, { id: 'changeOrders', label: 'Change Orders', icon: GitPullRequest }, { id: 'rfis', label: 'RFIs', icon: FileText }, { id: 'milestones', label: 'Milestones', icon: Milestone }, @@ -137,11 +139,12 @@ const OwnerProjectDetail = () => { const { projectId } = useParams(); const { user } = useAuth(); const { - projects, vendors, personnel, + projects, vendors, personnel, users, orgCommissionDefaults, userCommissionOverrides, roleCommissionOverrides, orgMembers, orgRoles, updateOrgCommissionDefaults, setUserCommissionOverride, removeUserCommissionOverride, updateProject, + addJobTeamMember, removeJobTeamMember, updateJobTeamMember, } = useMockStore(); const navigate = useNavigate(); const { can } = usePermissions(); @@ -245,6 +248,16 @@ const OwnerProjectDetail = () => { projects.find(p => p.id === projectId && p.ownerId === user?.id) , [projects, projectId, user]); + const teamFinancials = useMemo(() => { + if (!project) return { totalReceived: 0, grossProfit: 0 }; + const paid = (project.paymentSchedule || []) + .filter(ps => ps.status === 'paid') + .reduce((s, ps) => s + (ps.amount || 0), 0); + const tr = paid || (project.budget || 0); + const totalCosts = Number(project.actualCost ?? project.spent) || 0; + return { totalReceived: tr, grossProfit: tr - totalCosts }; + }, [project]); + // ── Commission hierarchy: Job → Individual → Role → Org ────────────── const jobCommissionOverride = useMemo(() => { if (commissionConfig) return commissionConfig; @@ -1620,6 +1633,27 @@ const OwnerProjectDetail = () => { )} )} + + {activeTab === 'team' && ( + + + + )}
diff --git a/src/pages/owner/OwnerProjectList.jsx b/src/pages/owner/OwnerProjectList.jsx index 9eec28a..cc03fad 100644 --- a/src/pages/owner/OwnerProjectList.jsx +++ b/src/pages/owner/OwnerProjectList.jsx @@ -3,10 +3,9 @@ import { useNavigate } from 'react-router-dom'; import { useAuth } from '../../context/AuthContext'; import { useMockStore } from '../../data/mockStore'; import { SpotlightCard } from '../../components/SpotlightCard'; -import TeamManagementPanel from '../../components/owner/TeamManagementPanel'; import { Search, ChevronRight, ArrowUpDown, Briefcase, LayoutGrid, - CheckCircle, Clock, AlertTriangle, PauseCircle, ShieldAlert, User, Users, ChevronDown + CheckCircle, Clock, AlertTriangle, PauseCircle, ShieldAlert, User } from 'lucide-react'; const statusConfig = { @@ -50,12 +49,7 @@ function getPipelineHealth(lead) { const OwnerProjectList = () => { const { user } = useAuth(); - const { - projects, vendors, kanbanLeads, kanbanColumns, - users, personnel, orgCommissionDefaults, userCommissionOverrides, - roleCommissionOverrides, orgMembers, - addJobTeamMember, removeJobTeamMember, updateJobTeamMember, - } = useMockStore(); + const { projects, vendors, kanbanLeads, kanbanColumns } = useMockStore(); const navigate = useNavigate(); const [activeSection, setActiveSection] = useState('construction'); // 'construction' | 'pipeline' @@ -75,23 +69,6 @@ const OwnerProjectList = () => { projects.filter(p => p.ownerId === user?.id) , [projects, user]); - // ── Team Management ── - const [selectedProjectId, setSelectedProjectId] = useState(null); - const selectedProject = useMemo(() => { - const id = selectedProjectId || ownerProjects.find(p => p.status === 'active')?.id; - return ownerProjects.find(p => p.id === id) || ownerProjects[0] || null; - }, [selectedProjectId, ownerProjects]); - - const teamFinancials = useMemo(() => { - if (!selectedProject) return { totalReceived: 0, grossProfit: 0 }; - const paid = (selectedProject.paymentSchedule || []) - .filter(ps => ps.status === 'paid') - .reduce((s, ps) => s + (ps.amount || 0), 0); - const totalReceived = paid || (selectedProject.budget || 0); - const totalCosts = Number(selectedProject.actualCost ?? selectedProject.spent) || 0; - return { totalReceived, grossProfit: totalReceived - totalCosts }; - }, [selectedProject]); - // Apply filters const filteredProjects = useMemo(() => { return ownerProjects.filter(p => { @@ -658,50 +635,6 @@ const OwnerProjectList = () => { )} - {/* ── Owner's Box — Team Management ── */} - {selectedProject && ( -
-
-

- - Team Management -

-
- - -
-
- - - -
- )} -
igotsar.matyas | LynkedUpPro - Turns out roofing CRMs don't build themselves. Who knew?
From 42a1306aca95a2a72f9fb2257c3aa0950f78deab Mon Sep 17 00:00:00 2001 From: Satyam Rastogi Date: Thu, 14 May 2026 12:15:46 +0530 Subject: [PATCH 17/38] door knock and referral attribution --- src/components/leads/CanvasserSelector.jsx | 215 +++++++++++++++++++++ src/components/leads/LeadJobSection.jsx | 47 +++++ src/pages/CreateLeadPage.jsx | 13 +- src/pages/LeadsListPage.jsx | 13 ++ 4 files changed, 287 insertions(+), 1 deletion(-) create mode 100644 src/components/leads/CanvasserSelector.jsx diff --git a/src/components/leads/CanvasserSelector.jsx b/src/components/leads/CanvasserSelector.jsx new file mode 100644 index 0000000..ba794e6 --- /dev/null +++ b/src/components/leads/CanvasserSelector.jsx @@ -0,0 +1,215 @@ +import React, { useState, useRef, useEffect, useMemo } from 'react'; +import { createPortal } from 'react-dom'; +import { motion, AnimatePresence } from 'framer-motion'; +import { Check, ChevronDown, Search } from 'lucide-react'; +import { useMockStore } from '../../data/mockStore'; + +function FieldLabel({ children }) { + return ( + + ); +} + +export default function CanvasserSelector({ value, onChange, onNameChange }) { + const { orgMembers } = useMockStore(); + const [open, setOpen] = useState(false); + const [query, setQuery] = useState(''); + const [pos, setPos] = useState(null); + const inputRef = useRef(null); + const dropdownRef = useRef(null); + + const canvassers = useMemo(() => + orgMembers.filter(m => m.roleKey === 'CANVASSER' && m.status === 'active'), + [orgMembers] + ); + + const filtered = useMemo(() => { + if (!query.trim()) return canvassers; + const q = query.toLowerCase(); + return canvassers.filter(m => + m.name.toLowerCase().includes(q) || m.email.toLowerCase().includes(q) + ); + }, [query, canvassers]); + + const selectedMember = useMemo(() => + canvassers.find(m => m.userId === value), + [canvassers, value] + ); + + const computePos = () => { + if (inputRef.current) { + const r = inputRef.current.getBoundingClientRect(); + setPos({ + top: r.bottom + window.scrollY + 4, + left: r.left + window.scrollX, + width: Math.max(r.width, 280), + }); + } + }; + + const handleFocus = () => { + computePos(); + setQuery(''); + setOpen(true); + }; + + const handleChange = (e) => { + setQuery(e.target.value); + if (!open) { + computePos(); + setOpen(true); + } + }; + + const handleSelect = (member) => { + onChange(member.userId); + if (onNameChange) onNameChange(member.name); + setQuery(''); + setOpen(false); + inputRef.current?.blur(); + }; + + const handleBlur = (e) => { + if (dropdownRef.current?.contains(e.relatedTarget)) return; + setOpen(false); + setQuery(''); + }; + + const handleKeyDown = (e) => { + if (e.key === 'Escape') { setOpen(false); setQuery(''); inputRef.current?.blur(); } + if (e.key === 'Enter' && filtered.length > 0) { handleSelect(filtered[0]); } + }; + + useEffect(() => { + if (!open) return; + const handler = (e) => { + const inInput = inputRef.current?.contains(e.target); + const inDropdown = dropdownRef.current?.contains(e.target); + if (!inInput && !inDropdown) { setOpen(false); setQuery(''); } + }; + document.addEventListener('mousedown', handler); + return () => document.removeEventListener('mousedown', handler); + }, [open]); + + useEffect(() => { + if (!open) return; + const close = (e) => { + const path = e.composedPath?.() ?? []; + if (dropdownRef.current && path.includes(dropdownRef.current)) return; + setOpen(false); + setQuery(''); + }; + window.addEventListener('scroll', close, true); + window.addEventListener('resize', close); + return () => { + window.removeEventListener('scroll', close, true); + window.removeEventListener('resize', close); + }; + }, [open]); + + const displayValue = open ? query : (selectedMember?.name || ''); + + return ( +
+ Canvasser * + +
+ + + + + +
+ + {createPortal( + + {open && pos && ( + +
+ {filtered.length === 0 ? ( +

+ No canvassers match "{query}" +

+ ) : ( + filtered.map(member => { + const isSelected = member.userId === value; + return ( + + ); + }) + )} +
+
+ )} +
, + document.body + )} +
+ ); +} diff --git a/src/components/leads/LeadJobSection.jsx b/src/components/leads/LeadJobSection.jsx index 2b3e01c..7d6db1a 100644 --- a/src/components/leads/LeadJobSection.jsx +++ b/src/components/leads/LeadJobSection.jsx @@ -3,6 +3,7 @@ import { motion, AnimatePresence } from 'framer-motion'; import { LEAD_FORM_OPTIONS } from '../../data/mockStore'; import UrgencyPillSelector from './UrgencyPillSelector'; import LeadCustomSelect from './LeadCustomSelect'; +import CanvasserSelector from './CanvasserSelector'; // --------------------------------------------------------------------------- // Shared field primitives (local to Job section) @@ -61,6 +62,52 @@ export default function LeadJobSection({ formData, updateField, isQuickCapture } accent="blue" /> + {/* --- Door Knock → Canvasser selector --- */} + + {formData.leadSource === 'Door Knock' && ( + +
+ updateField('canvasserId', v)} + onNameChange={v => updateField('canvasserName', v)} + /> +
+
+ )} +
+ + {/* --- Referral → Referral Note --- */} + + {formData.leadSource === 'Referral' && ( + +
+ updateField('referralNote', v)} + placeholder="e.g. Referred by John Smith, existing customer on Oak Street…" + rows={2} + /> +
+
+ )} +
+ {/* --- Lead Type — Full Form only --- */} {!isQuickCapture && ( diff --git a/src/pages/CreateLeadPage.jsx b/src/pages/CreateLeadPage.jsx index e9afb3a..2e05302 100644 --- a/src/pages/CreateLeadPage.jsx +++ b/src/pages/CreateLeadPage.jsx @@ -69,6 +69,9 @@ const SECTIONS = [ const INITIAL_FORM = { // Job Details leadSource: '', + canvasserId: '', + canvasserName: '', + referralNote: '', leadType: '', workType: '', tradeType: '', @@ -156,7 +159,14 @@ export default function CreateLeadPage() { }, [progress]); const updateField = (field, value) => { - setFormData(prev => ({ ...prev, [field]: value })); + setFormData(prev => { + const next = { ...prev, [field]: value }; + if (field === 'leadSource') { + if (value !== 'Door Knock') { next.canvasserId = ''; next.canvasserName = ''; } + if (value !== 'Referral') { next.referralNote = ''; } + } + return next; + }); }; const toggleSection = (id) => { @@ -173,6 +183,7 @@ export default function CreateLeadPage() { if (!formData.address) missingFields.push('Street Address'); if (!formData.city) missingFields.push('City'); if (!formData.leadSource) missingFields.push('Lead Source'); + if (formData.leadSource === 'Door Knock' && !formData.canvasserId) missingFields.push('Canvasser'); if (missingFields.length > 0) { setValidationError(`Required: ${missingFields.join(', ')}`); diff --git a/src/pages/LeadsListPage.jsx b/src/pages/LeadsListPage.jsx index 8f2ab7b..d8e2f72 100644 --- a/src/pages/LeadsListPage.jsx +++ b/src/pages/LeadsListPage.jsx @@ -8,6 +8,7 @@ import PermissionGate from '../components/PermissionGate'; import { Plus, Search, MapPin, Phone, Zap, FileText, Clock, User, ChevronRight, Filter, Lock, + DoorOpen, MessageSquare, } from 'lucide-react'; // --------------------------------------------------------------------------- @@ -117,6 +118,18 @@ function LeadCard({ lead, index }) { {lead.leadSource} )} + {lead.canvasserName && ( + + + {lead.canvasserName} + + )} + {lead.referralNote && ( + + + {lead.referralNote} + + )}
From 45301dbabd0d249c0ebdca41866e61161533b9c8 Mon Sep 17 00:00:00 2001 From: Satyam Rastogi Date: Thu, 14 May 2026 13:24:16 +0530 Subject: [PATCH 18/38] org level commission distribution for each member --- .../owner/CommissionDistributionPanel.jsx | 180 ++++++++++++++++++ src/pages/owner/OwnerSnapshot.jsx | 20 ++ 2 files changed, 200 insertions(+) create mode 100644 src/components/owner/CommissionDistributionPanel.jsx diff --git a/src/components/owner/CommissionDistributionPanel.jsx b/src/components/owner/CommissionDistributionPanel.jsx new file mode 100644 index 0000000..7a6441e --- /dev/null +++ b/src/components/owner/CommissionDistributionPanel.jsx @@ -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 ( +
+ {/* Header */} +
+ +

+ Commission Distribution +

+ + {rows.length} entr{rows.length === 1 ? 'y' : 'ies'} across {ownerProjects.length} project{ownerProjects.length !== 1 ? 's' : ''} + +
+ + {rows.length === 0 ? ( +
+ +

No commission recipients across your projects.

+

Add team members with commission rules to see the distribution.

+
+ ) : ( +
+ + + + + + + + + + + + {rows.map((row) => ( + + + + + + + + ))} + + + + + + + +
NameRoleAmountProject IDCommission Type
+
+
+ {row.name?.[0] || '?'} +
+ {row.name} +
+
+ + {row.role} + + + + {fmt(row.amount)} + + + + {row.projectId} + + + + {row.commissionType} + +
+ Total Commission Amount + + + {fmt(total)} + + +
+
+ )} +
+ ); +}; + +export default CommissionDistributionPanel; diff --git a/src/pages/owner/OwnerSnapshot.jsx b/src/pages/owner/OwnerSnapshot.jsx index cf04d49..c9b1ac5 100644 --- a/src/pages/owner/OwnerSnapshot.jsx +++ b/src/pages/owner/OwnerSnapshot.jsx @@ -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 = () => { )} + + {/* ── Owner's Box — Org-Wide Commission Distribution ── */} + {ownerProjects.length > 0 && ( +
+

+ + Organization Commission Overview +

+ + + +
+ )}
{/* Interactive Modals */} From 72be14369e5f337de3527a338b67f413bae34ce4 Mon Sep 17 00:00:00 2001 From: Satyam Rastogi Date: Thu, 14 May 2026 18:13:42 +0530 Subject: [PATCH 19/38] access control by role plus by person in orgsettings --- .../owner/AccessControlPersonModal.jsx | 293 ++++++++++++++++++ src/data/mockStore.jsx | 19 ++ src/hooks/usePermissions.js | 13 +- src/pages/owner/OrgSettings.jsx | 69 ++++- 4 files changed, 388 insertions(+), 6 deletions(-) create mode 100644 src/components/owner/AccessControlPersonModal.jsx diff --git a/src/components/owner/AccessControlPersonModal.jsx b/src/components/owner/AccessControlPersonModal.jsx new file mode 100644 index 0000000..c17a8a5 --- /dev/null +++ b/src/components/owner/AccessControlPersonModal.jsx @@ -0,0 +1,293 @@ +import React, { useState, useMemo } from 'react'; +import ReactDOM from 'react-dom'; +import { motion, AnimatePresence } from 'framer-motion'; +import { X, Search, Shield, Users, Check } from 'lucide-react'; + +function StateChip({ label, variant }) { + const styles = { + role: 'bg-blue-50 dark:bg-blue-500/10 text-blue-600 dark:text-blue-400', + granted: 'bg-emerald-50 dark:bg-emerald-500/10 text-emerald-600 dark:text-emerald-400', + denied: 'bg-amber-50 dark:bg-amber-500/10 text-amber-600 dark:text-amber-400', + }; + return ( + + {label} + + ); +} + +export default function AccessControlPersonModal({ + isOpen, onClose, + moduleKey, moduleLabel, actions, + permissions, roles, members, + userPermissionOverrides, + onSetOverride, onRemoveOverride, +}) { + const [search, setSearch] = useState(''); + + const nonOwnerMembers = useMemo(() => + (members || []).filter(m => m.roleKey !== 'OWNER' && m.status === 'active'), + [members] + ); + + const nonOwnerRoles = useMemo(() => + (roles || []).filter(r => r.key !== 'OWNER'), + [roles] + ); + + const moduleOverrides = useMemo(() => + (userPermissionOverrides || []).filter(o => o.moduleKey === moduleKey), + [userPermissionOverrides, moduleKey] + ); + + const getEffectiveAccess = (userId, roleKey, action) => { + const override = moduleOverrides.find( + o => o.userId === userId && o.action === action + ); + if (override) return { access: override.effect === 'grant', source: override.effect === 'grant' ? 'granted' : 'denied' }; + const roleActions = permissions[moduleKey]?.[roleKey] || []; + return { access: roleActions.includes(action), source: 'role' }; + }; + + const handleToggle = (userId, roleKey, action) => { + const override = moduleOverrides.find( + o => o.userId === userId && o.action === action + ); + if (override) { + onRemoveOverride(userId, moduleKey, action); + } else { + const roleActions = permissions[moduleKey]?.[roleKey] || []; + onSetOverride(userId, moduleKey, action, roleActions.includes(action) ? 'deny' : 'grant'); + } + }; + + const handleClearModuleOverrides = (userId) => { + const toRemove = moduleOverrides.filter(o => o.userId === userId); + toRemove.forEach(o => onRemoveOverride(userId, moduleKey, o.action)); + }; + + const filteredMembers = useMemo(() => { + const q = search.toLowerCase(); + const list = nonOwnerMembers.filter(m => + !q || m.name.toLowerCase().includes(q) || m.email.toLowerCase().includes(q) || m.roleKey.toLowerCase().includes(q) + ); + return [...list].sort((a, b) => { + const aHas = moduleOverrides.some(o => o.userId === a.userId); + const bHas = moduleOverrides.some(o => o.userId === b.userId); + if (aHas && !bHas) return -1; + if (!aHas && bHas) return 1; + return 0; + }); + }, [nonOwnerMembers, search, moduleOverrides]); + + const overrideCount = useMemo(() => { + const userIds = new Set(moduleOverrides.map(o => o.userId)); + return userIds.size; + }, [moduleOverrides]); + + if (!isOpen) return null; + + return ReactDOM.createPortal( + + {isOpen && ( +
+ e.stopPropagation()} + > + {/* Header */} +
+
+
+ +
+
+

{moduleLabel} Access

+

+ {overrideCount > 0 + ? `${overrideCount} person-level override${overrideCount !== 1 ? 's' : ''}` + : 'No person-level overrides'} +

+
+
+ +
+ +
+ {/* By Role */} +
+

By Role

+
+ {nonOwnerRoles.map(role => { + const roleActions = permissions[moduleKey]?.[role.key] || []; + const grantedCount = roleActions.length; + return ( +
+
+ +
+
+

{role.name}

+
+ {actions.map(action => { + const has = roleActions.includes(action); + return ( + + {has && } + {action} + + ); + })} +
+
+ {grantedCount}/{actions.length} +
+ ); + })} +
+
+ + {/* Divider */} +
+
+
+
+
+ By Person +
+
+ + {/* Search */} +
+ + setSearch(e.target.value)} + placeholder="Search by name, email, or role..." + className="w-full pl-8 pr-4 py-2 rounded-xl border border-zinc-200 dark:border-zinc-700 bg-white dark:bg-zinc-800 text-zinc-900 dark:text-zinc-100 text-sm outline-none focus:ring-2 focus:ring-emerald-500/30 focus:border-emerald-500 transition-shadow" + /> +
+ + {/* Person list */} +
+ {filteredMembers.map(member => { + const role = roles.find(r => r.key === member.roleKey); + const hasOverrides = moduleOverrides.some(o => o.userId === member.userId); + const initials = member.name.split(' ').map(w => w[0]).join('').slice(0, 2).toUpperCase(); + + return ( +
+
+
+ {initials} +
+
+
+ {member.name} + {hasOverrides && o.userId === member.userId && o.effect === 'deny').length > 0 ? 'Has Overrides' : 'Overridden'} variant="granted" />} +
+
+ {member.email} + + {role?.name || member.roleKey} + +
+
+ {hasOverrides && ( + + )} +
+ +
+ {actions.map(action => { + const eff = getEffectiveAccess(member.userId, member.roleKey, action); + const hasOverride = eff.source !== 'role'; + return ( + + ); + })} +
+
+ ); + })} + + {filteredMembers.length === 0 && ( +

No members found

+ )} +
+
+ + {/* Footer */} +
+

+ Person overrides take priority over role permissions +

+ +
+ +
+ )} + , + document.body + ); +} diff --git a/src/data/mockStore.jsx b/src/data/mockStore.jsx index 60312a3..ec359a9 100644 --- a/src/data/mockStore.jsx +++ b/src/data/mockStore.jsx @@ -5048,6 +5048,10 @@ export const MockStoreProvider = ({ children }) => { subcontractor_tasks: { label: 'Subcontractor Tasks', actions: ['view','assign','receive'] }, }; + // Per-user permission overrides: grant or deny specific module+action regardless of role + // Shape: [{ userId, moduleKey, action, effect: 'grant' | 'deny' }] + const [userPermissionOverrides, setUserPermissionOverrides] = useState([]); + const [roleCommissionOverrides, setRoleCommissionOverrides] = useState([ { roleKey: 'SALES_REP', enabled: true, type: 'percent_net', rate: 5 }, { roleKey: 'CANVASSER', enabled: true, type: 'flat', rate: 300 }, @@ -5306,6 +5310,21 @@ export const MockStoreProvider = ({ children }) => { [moduleKey]: { ...prev[moduleKey], [roleKey]: actions }, })); }, + userPermissionOverrides, + setUserPermissionOverride: (userId, moduleKey, action, effect) => { + setUserPermissionOverrides(prev => { + const filtered = prev.filter(o => !(o.userId === userId && o.moduleKey === moduleKey && o.action === action)); + return [...filtered, { userId, moduleKey, action, effect }]; + }); + }, + removeUserPermissionOverride: (userId, moduleKey, action) => { + setUserPermissionOverrides(prev => + prev.filter(o => !(o.userId === userId && o.moduleKey === moduleKey && o.action === action)) + ); + }, + clearUserPermissionOverrides: (userId) => { + setUserPermissionOverrides(prev => prev.filter(o => o.userId !== userId)); + }, roleCommissionOverrides, setRoleCommissionOverride: (override) => { setRoleCommissionOverrides(prev => { diff --git a/src/hooks/usePermissions.js b/src/hooks/usePermissions.js index 83e5fe0..7450661 100644 --- a/src/hooks/usePermissions.js +++ b/src/hooks/usePermissions.js @@ -13,7 +13,7 @@ import { resolveOrgRoleKey } from '../utils/permissions'; */ export const usePermissions = () => { const { user } = useAuth(); - const { orgPermissions, orgMembers } = useMockStore(); + const { orgPermissions, orgMembers, userPermissionOverrides } = useMockStore(); const orgRoleKey = useMemo( () => resolveOrgRoleKey(user, orgMembers), @@ -24,10 +24,19 @@ export const usePermissions = () => { (moduleKey, action) => { if (!orgRoleKey) return false; if (orgRoleKey === 'OWNER') return true; + + // Person-level override takes priority + if (user?.id && userPermissionOverrides) { + const override = userPermissionOverrides.find( + o => o.userId === user.id && o.moduleKey === moduleKey && o.action === action + ); + if (override) return override.effect === 'grant'; + } + const actions = orgPermissions[moduleKey]?.[orgRoleKey] || []; return actions.includes(action); }, - [orgRoleKey, orgPermissions], + [orgRoleKey, orgPermissions, user, userPermissionOverrides], ); const canAny = useCallback( diff --git a/src/pages/owner/OrgSettings.jsx b/src/pages/owner/OrgSettings.jsx index ae7afd0..b350c16 100644 --- a/src/pages/owner/OrgSettings.jsx +++ b/src/pages/owner/OrgSettings.jsx @@ -13,6 +13,7 @@ import { usePermissions } from '../../hooks/usePermissions'; import { toast } from 'sonner'; import { computeRolePermissionStats } from '../../utils/permissions'; import OrgTeamManagement from '../../components/owner/OrgTeamManagement'; +import AccessControlPersonModal from '../../components/owner/AccessControlPersonModal'; const COMMISSION_TYPES = [ { value: 'flat', label: 'Flat Amount', hint: 'Fixed dollar amount' }, @@ -231,13 +232,25 @@ const RoleManagementSection = ({ roles, members }) => { // ──────────────────────────────────────────────────── // 3. Access Control Matrix (Full) // ──────────────────────────────────────────────────── -const AccessControlMatrix = ({ permissions, permissionModules, roles, onUpdate }) => { +const AccessControlMatrix = ({ permissions, permissionModules, roles, onUpdate, members, userPermissionOverrides, onSetUserOverride, onRemoveUserOverride }) => { const [filterModule, setFilterModule] = useState('all'); const [filterRole, setFilterRole] = useState('all'); const [changeLog, setChangeLog] = useState([]); const [showLog, setShowLog] = useState(false); + const [personModalModule, setPersonModalModule] = useState(null); const snapshotRef = useRef(JSON.stringify(permissions)); + const moduleOverrideCounts = useMemo(() => { + const counts = {}; + for (const o of (userPermissionOverrides || [])) { + if (!counts[o.moduleKey]) counts[o.moduleKey] = new Set(); + counts[o.moduleKey].add(o.userId); + } + const result = {}; + for (const [k, v] of Object.entries(counts)) result[k] = v.size; + return result; + }, [userPermissionOverrides]); + const moduleEntries = useMemo(() => { const entries = Object.entries(permissionModules); if (filterModule === 'all') return entries; @@ -454,7 +467,7 @@ const AccessControlMatrix = ({ permissions, permissionModules, roles, onUpdate } {module.label}
-
+
+
@@ -531,7 +557,22 @@ const AccessControlMatrix = ({ permissions, permissionModules, roles, onUpdate }
{moduleEntries.map(([moduleKey, module]) => (
- {module.label} +
+ {module.label} + +
{module.actions.map(action => { const hasPermission = (permissions[moduleKey]?.[role.key] || []).includes(action); @@ -607,6 +648,21 @@ const AccessControlMatrix = ({ permissions, permissionModules, roles, onUpdate } )} + {/* ── Person Override Modal ── */} + setPersonModalModule(null)} + moduleKey={personModalModule || ''} + moduleLabel={personModalModule ? (permissionModules[personModalModule]?.label || personModalModule) : ''} + actions={personModalModule ? (permissionModules[personModalModule]?.actions || []) : []} + permissions={permissions} + roles={roles} + members={members} + userPermissionOverrides={userPermissionOverrides} + onSetOverride={onSetUserOverride} + onRemoveOverride={onRemoveUserOverride} + /> + {/* ── Info Footer ── */}
@@ -614,6 +670,7 @@ const AccessControlMatrix = ({ permissions, permissionModules, roles, onUpdate }

Org Owner always has full access to all modules and cannot be restricted.

Permission changes apply immediately to all users with the affected role. Use the Save button to persist changes or Reset to revert.

+

Person overrides take priority over role permissions. A denied person override blocks access even if the role grants it.

@@ -1053,7 +1110,7 @@ const OrgSettings = () => { id: 'access', icon: Shield, title: 'Access Control Matrix', - subtitle: 'Configure module-level permissions per role', + subtitle: 'Configure module-level permissions by role and by person', color: '#10b981', content: ( { permissionModules={store.PERMISSION_MODULES} roles={store.orgRoles} onUpdate={store.updateOrgPermissions} + members={store.orgMembers} + userPermissionOverrides={store.userPermissionOverrides} + onSetUserOverride={store.setUserPermissionOverride} + onRemoveUserOverride={store.removeUserPermissionOverride} /> ), }, From 7525871510417316e39cdf3995eeb284e956cd89 Mon Sep 17 00:00:00 2001 From: Satyam Rastogi Date: Thu, 14 May 2026 18:21:13 +0530 Subject: [PATCH 20/38] org team managment removed promotion demotion infor section from org Settings page --- src/components/owner/OrgTeamManagement.jsx | 13 ------------- 1 file changed, 13 deletions(-) diff --git a/src/components/owner/OrgTeamManagement.jsx b/src/components/owner/OrgTeamManagement.jsx index e2a103b..6a15207 100644 --- a/src/components/owner/OrgTeamManagement.jsx +++ b/src/components/owner/OrgTeamManagement.jsx @@ -483,10 +483,6 @@ const EditRolePanel = ({ member, roles, onSave, onCancel }) => { const currentRole = roles.find(r => r.key === member.roleKey); const targetRole = roles.find(r => r.key === newRole); - const isPromotion = () => { - const order = ['SUBCONTRACTOR', 'CANVASSER', 'SALES_REP', 'ADMIN']; - return order.indexOf(newRole) > order.indexOf(member.roleKey); - }; return ( { - {newRole !== member.roleKey && ( -
- {isPromotion() ? '↑ Promotion' : '↓ Demotion'}: {currentRole?.name} → {targetRole?.name} -
- )}
+ + {/* Commission Toggle */} {canManageCommissions && ( - +
+
+ + Commission +
+ +
)} + + {/* Edit Commission — visible only when toggle is ON */} + + {canManageCommissions && commissionToggle[member.id] && ( + + + + )} + + + {/* Remove from Job */} diff --git a/src/data/mockStore.jsx b/src/data/mockStore.jsx index ec359a9..0bedd8a 100644 --- a/src/data/mockStore.jsx +++ b/src/data/mockStore.jsx @@ -2068,11 +2068,11 @@ const MOCK_PROJECTS = [ { name: 'Material Order PO-2604', url: '#', type: 'invoice' } ], teamMembers: [ - { id: 'jtm_001_1', userId: 'p1', name: 'Jesus Gonzales', email: 'jesus@lynkeduppro.com', orgRole: 'W2_EMPLOYEE', jobRole: 'Sales Rep', status: 'active', jobCommission: { type: 'percent_gross', rate: 12 } }, - { id: 'jtm_001_2', userId: 'con_001', name: 'Mike Contractor', email: 'mike@texasbuilders.com', orgRole: 'CONTRACTOR', jobRole: 'Lead Installer', status: 'active', jobCommission: null }, - { id: 'jtm_001_3', userId: 'sub_001', name: 'Carlos Subcontractor', email: 'carlos@electricpro.com', orgRole: 'SUBCONTRACTOR', jobRole: 'Subcontractor', status: 'active', jobCommission: null }, - { id: 'jtm_001_4', userId: 'e1', name: 'Frank Agent', email: 'agent1@plano.com', orgRole: 'FIELD_AGENT', jobRole: 'Canvasser', status: 'active', jobCommission: null }, - { id: 'jtm_001_5', userId: 'p2', name: 'Sarah Sales', email: 'sarah@lynkeduppro.com', orgRole: '1099_SALES_REP', jobRole: 'Estimator', status: 'pending', jobCommission: null } + { id: 'jtm_001_1', userId: 'p1', name: 'Jesus Gonzales', email: 'jesus@lynkeduppro.com', orgRole: 'W2_EMPLOYEE', jobRole: 'Sales Rep', status: 'active', commissionEnabled: true, jobCommission: { type: 'percent_gross', rate: 12 } }, + { id: 'jtm_001_2', userId: 'con_001', name: 'Mike Contractor', email: 'mike@texasbuilders.com', orgRole: 'CONTRACTOR', jobRole: 'Lead Installer', status: 'active', commissionEnabled: false, jobCommission: null }, + { id: 'jtm_001_3', userId: 'sub_001', name: 'Carlos Subcontractor', email: 'carlos@electricpro.com', orgRole: 'SUBCONTRACTOR', jobRole: 'Subcontractor', status: 'active', commissionEnabled: false, jobCommission: null }, + { id: 'jtm_001_4', userId: 'e1', name: 'Frank Agent', email: 'agent1@plano.com', orgRole: 'FIELD_AGENT', jobRole: 'Canvasser', status: 'active', commissionEnabled: true, jobCommission: null }, + { id: 'jtm_001_5', userId: 'p2', name: 'Sarah Sales', email: 'sarah@lynkeduppro.com', orgRole: '1099_SALES_REP', jobRole: 'Estimator', status: 'pending', commissionEnabled: true, jobCommission: null } ] }, @@ -2124,8 +2124,8 @@ const MOCK_PROJECTS = [ { name: 'Warranty Registration', url: '#', type: 'warranty' } ], teamMembers: [ - { id: 'jtm_002_1', userId: 'p1', name: 'Jesus Gonzales', email: 'jesus@lynkeduppro.com', orgRole: 'W2_EMPLOYEE', jobRole: 'Sales Rep', status: 'active', jobCommission: null }, - { id: 'jtm_002_2', userId: 'con_001', name: 'Mike Contractor', email: 'mike@texasbuilders.com', orgRole: 'CONTRACTOR', jobRole: 'Lead Installer', status: 'active', jobCommission: null } + { id: 'jtm_002_1', userId: 'p1', name: 'Jesus Gonzales', email: 'jesus@lynkeduppro.com', orgRole: 'W2_EMPLOYEE', jobRole: 'Sales Rep', status: 'active', commissionEnabled: true, jobCommission: null }, + { id: 'jtm_002_2', userId: 'con_001', name: 'Mike Contractor', email: 'mike@texasbuilders.com', orgRole: 'CONTRACTOR', jobRole: 'Lead Installer', status: 'active', commissionEnabled: false, jobCommission: null } ] }, @@ -2203,9 +2203,9 @@ const MOCK_PROJECTS = [ { name: 'Equipment Spec Sheets', url: '#', type: 'specification' } ], teamMembers: [ - { id: 'jtm_003_1', userId: 'p2', name: 'Sarah Sales', email: 'sarah@lynkeduppro.com', orgRole: '1099_SALES_REP', jobRole: 'Sales Rep', status: 'active', jobCommission: { type: 'flat', rate: 2000 } }, - { id: 'jtm_003_2', userId: 'con_001', name: 'Mike Contractor', email: 'mike@texasbuilders.com', orgRole: 'CONTRACTOR', jobRole: 'Project Manager', status: 'active', jobCommission: null }, - { id: 'jtm_003_3', userId: 'sub_001', name: 'Carlos Subcontractor', email: 'carlos@electricpro.com', orgRole: 'SUBCONTRACTOR', jobRole: 'Subcontractor', status: 'active', jobCommission: null } + { id: 'jtm_003_1', userId: 'p2', name: 'Sarah Sales', email: 'sarah@lynkeduppro.com', orgRole: '1099_SALES_REP', jobRole: 'Sales Rep', status: 'active', commissionEnabled: true, jobCommission: { type: 'flat', rate: 2000 } }, + { id: 'jtm_003_2', userId: 'con_001', name: 'Mike Contractor', email: 'mike@texasbuilders.com', orgRole: 'CONTRACTOR', jobRole: 'Project Manager', status: 'active', commissionEnabled: false, jobCommission: null }, + { id: 'jtm_003_3', userId: 'sub_001', name: 'Carlos Subcontractor', email: 'carlos@electricpro.com', orgRole: 'SUBCONTRACTOR', jobRole: 'Subcontractor', status: 'active', commissionEnabled: false, jobCommission: null } ] }, @@ -2294,9 +2294,9 @@ const MOCK_PROJECTS = [ { name: 'Asbestos Abatement Report', url: '#', type: 'report' } ], teamMembers: [ - { id: 'jtm_004_1', userId: 'p1', name: 'Jesus Gonzales', email: 'jesus@lynkeduppro.com', orgRole: 'W2_EMPLOYEE', jobRole: 'Project Manager', status: 'active', jobCommission: { type: 'percent_net', rate: 8 } }, - { id: 'jtm_004_2', userId: 'con_001', name: 'Mike Contractor', email: 'mike@texasbuilders.com', orgRole: 'CONTRACTOR', jobRole: 'Lead Installer', status: 'active', jobCommission: null }, - { id: 'jtm_004_3', userId: 'e2', name: 'Fiona Field', email: 'agent2@plano.com', orgRole: 'FIELD_AGENT', jobRole: 'Inspector', status: 'active', jobCommission: null } + { id: 'jtm_004_1', userId: 'p1', name: 'Jesus Gonzales', email: 'jesus@lynkeduppro.com', orgRole: 'W2_EMPLOYEE', jobRole: 'Project Manager', status: 'active', commissionEnabled: true, jobCommission: { type: 'percent_net', rate: 8 } }, + { id: 'jtm_004_2', userId: 'con_001', name: 'Mike Contractor', email: 'mike@texasbuilders.com', orgRole: 'CONTRACTOR', jobRole: 'Lead Installer', status: 'active', commissionEnabled: false, jobCommission: null }, + { id: 'jtm_004_3', userId: 'e2', name: 'Fiona Field', email: 'agent2@plano.com', orgRole: 'FIELD_AGENT', jobRole: 'Inspector', status: 'active', commissionEnabled: false, jobCommission: null } ] }, @@ -2372,8 +2372,8 @@ const MOCK_PROJECTS = [ { name: 'Site Photos - Defective Panels', url: '#', type: 'photos' } ], teamMembers: [ - { id: 'jtm_005_1', userId: 'p2', name: 'Sarah Sales', email: 'sarah@lynkeduppro.com', orgRole: '1099_SALES_REP', jobRole: 'Sales Rep', status: 'active', jobCommission: null }, - { id: 'jtm_005_2', userId: 'con_001', name: 'Mike Contractor', email: 'mike@texasbuilders.com', orgRole: 'CONTRACTOR', jobRole: 'Lead Installer', status: 'active', jobCommission: null } + { id: 'jtm_005_1', userId: 'p2', name: 'Sarah Sales', email: 'sarah@lynkeduppro.com', orgRole: '1099_SALES_REP', jobRole: 'Sales Rep', status: 'active', commissionEnabled: true, jobCommission: null }, + { id: 'jtm_005_2', userId: 'con_001', name: 'Mike Contractor', email: 'mike@texasbuilders.com', orgRole: 'CONTRACTOR', jobRole: 'Lead Installer', status: 'active', commissionEnabled: false, jobCommission: null } ] }, @@ -2423,8 +2423,8 @@ const MOCK_PROJECTS = [ { name: 'Load Calculation Report', url: '#', type: 'report' } ], teamMembers: [ - { id: 'jtm_006_1', userId: 'p1', name: 'Jesus Gonzales', email: 'jesus@lynkeduppro.com', orgRole: 'W2_EMPLOYEE', jobRole: 'Sales Rep', status: 'active', jobCommission: null }, - { id: 'jtm_006_2', userId: 'sub_001', name: 'Carlos Subcontractor', email: 'carlos@electricpro.com', orgRole: 'SUBCONTRACTOR', jobRole: 'Lead Installer', status: 'active', jobCommission: null } + { id: 'jtm_006_1', userId: 'p1', name: 'Jesus Gonzales', email: 'jesus@lynkeduppro.com', orgRole: 'W2_EMPLOYEE', jobRole: 'Sales Rep', status: 'active', commissionEnabled: true, jobCommission: null }, + { id: 'jtm_006_2', userId: 'sub_001', name: 'Carlos Subcontractor', email: 'carlos@electricpro.com', orgRole: 'SUBCONTRACTOR', jobRole: 'Lead Installer', status: 'active', commissionEnabled: false, jobCommission: null } ] }, @@ -2526,10 +2526,10 @@ const MOCK_PROJECTS = [ { name: 'Safety Plan - Fall Protection', url: '#', type: 'safety' } ], teamMembers: [ - { id: 'jtm_007_1', userId: 'p1', name: 'Jesus Gonzales', email: 'jesus@lynkeduppro.com', orgRole: 'W2_EMPLOYEE', jobRole: 'Project Manager', status: 'active', jobCommission: { type: 'percent_gross', rate: 5 } }, - { id: 'jtm_007_2', userId: 'con_001', name: 'Mike Contractor', email: 'mike@texasbuilders.com', orgRole: 'CONTRACTOR', jobRole: 'Lead Installer', status: 'active', jobCommission: null }, - { id: 'jtm_007_3', userId: 'sub_001', name: 'Carlos Subcontractor', email: 'carlos@electricpro.com', orgRole: 'SUBCONTRACTOR', jobRole: 'Subcontractor', status: 'active', jobCommission: null }, - { id: 'jtm_007_4', userId: 'e4', name: 'Felicity Fast', email: 'agent4@plano.com', orgRole: 'FIELD_AGENT', jobRole: 'Canvasser', status: 'active', jobCommission: null } + { id: 'jtm_007_1', userId: 'p1', name: 'Jesus Gonzales', email: 'jesus@lynkeduppro.com', orgRole: 'W2_EMPLOYEE', jobRole: 'Project Manager', status: 'active', commissionEnabled: true, jobCommission: { type: 'percent_gross', rate: 5 } }, + { id: 'jtm_007_2', userId: 'con_001', name: 'Mike Contractor', email: 'mike@texasbuilders.com', orgRole: 'CONTRACTOR', jobRole: 'Lead Installer', status: 'active', commissionEnabled: false, jobCommission: null }, + { id: 'jtm_007_3', userId: 'sub_001', name: 'Carlos Subcontractor', email: 'carlos@electricpro.com', orgRole: 'SUBCONTRACTOR', jobRole: 'Subcontractor', status: 'active', commissionEnabled: false, jobCommission: null }, + { id: 'jtm_007_4', userId: 'e4', name: 'Felicity Fast', email: 'agent4@plano.com', orgRole: 'FIELD_AGENT', jobRole: 'Canvasser', status: 'active', commissionEnabled: true, jobCommission: null } ] }, @@ -2587,8 +2587,8 @@ const MOCK_PROJECTS = [ { name: 'Soil Analysis Report', url: '#', type: 'report' } ], teamMembers: [ - { id: 'jtm_008_1', userId: 'p2', name: 'Sarah Sales', email: 'sarah@lynkeduppro.com', orgRole: '1099_SALES_REP', jobRole: 'Sales Rep', status: 'active', jobCommission: null }, - { id: 'jtm_008_2', userId: 'con_001', name: 'Mike Contractor', email: 'mike@texasbuilders.com', orgRole: 'CONTRACTOR', jobRole: 'Lead Installer', status: 'active', jobCommission: null } + { id: 'jtm_008_1', userId: 'p2', name: 'Sarah Sales', email: 'sarah@lynkeduppro.com', orgRole: '1099_SALES_REP', jobRole: 'Sales Rep', status: 'active', commissionEnabled: true, jobCommission: null }, + { id: 'jtm_008_2', userId: 'con_001', name: 'Mike Contractor', email: 'mike@texasbuilders.com', orgRole: 'CONTRACTOR', jobRole: 'Lead Installer', status: 'active', commissionEnabled: false, jobCommission: null } ] }, @@ -2639,8 +2639,8 @@ const MOCK_PROJECTS = [ { name: 'Energy Rating Certificates', url: '#', type: 'certificate' } ], teamMembers: [ - { id: 'jtm_009_1', userId: 'p1', name: 'Jesus Gonzales', email: 'jesus@lynkeduppro.com', orgRole: 'W2_EMPLOYEE', jobRole: 'Sales Rep', status: 'active', jobCommission: null }, - { id: 'jtm_009_2', userId: 'e2', name: 'Fiona Field', email: 'agent2@plano.com', orgRole: 'FIELD_AGENT', jobRole: 'Inspector', status: 'active', jobCommission: null } + { id: 'jtm_009_1', userId: 'p1', name: 'Jesus Gonzales', email: 'jesus@lynkeduppro.com', orgRole: 'W2_EMPLOYEE', jobRole: 'Sales Rep', status: 'active', commissionEnabled: true, jobCommission: null }, + { id: 'jtm_009_2', userId: 'e2', name: 'Fiona Field', email: 'agent2@plano.com', orgRole: 'FIELD_AGENT', jobRole: 'Inspector', status: 'active', commissionEnabled: false, jobCommission: null } ] }, @@ -2691,9 +2691,9 @@ const MOCK_PROJECTS = [ { name: 'Final Payment Release', url: '#', type: 'invoice' } ], teamMembers: [ - { id: 'jtm_010_1', userId: 'p2', name: 'Sarah Sales', email: 'sarah@lynkeduppro.com', orgRole: '1099_SALES_REP', jobRole: 'Estimator', status: 'active', jobCommission: { type: 'flat', rate: 1500 } }, - { id: 'jtm_010_2', userId: 'con_001', name: 'Mike Contractor', email: 'mike@texasbuilders.com', orgRole: 'CONTRACTOR', jobRole: 'Lead Installer', status: 'active', jobCommission: null }, - { id: 'jtm_010_3', userId: 'e5', name: 'Felix Fixer', email: 'agent5@plano.com', orgRole: 'FIELD_AGENT', jobRole: 'Canvasser', status: 'pending', jobCommission: null } + { id: 'jtm_010_1', userId: 'p2', name: 'Sarah Sales', email: 'sarah@lynkeduppro.com', orgRole: '1099_SALES_REP', jobRole: 'Estimator', status: 'active', commissionEnabled: true, jobCommission: { type: 'flat', rate: 1500 } }, + { id: 'jtm_010_2', userId: 'con_001', name: 'Mike Contractor', email: 'mike@texasbuilders.com', orgRole: 'CONTRACTOR', jobRole: 'Lead Installer', status: 'active', commissionEnabled: false, jobCommission: null }, + { id: 'jtm_010_3', userId: 'e5', name: 'Felix Fixer', email: 'agent5@plano.com', orgRole: 'FIELD_AGENT', jobRole: 'Canvasser', status: 'pending', commissionEnabled: false, jobCommission: null } ] }, @@ -2752,8 +2752,8 @@ const MOCK_PROJECTS = [ { name: 'RFI #2 - Waterproofing Method', url: '#', type: 'rfi' } ], teamMembers: [ - { id: 'jtm_011_1', userId: 'p1', name: 'Jesus Gonzales', email: 'jesus@lynkeduppro.com', orgRole: 'W2_EMPLOYEE', jobRole: 'Project Manager', status: 'active', jobCommission: null }, - { id: 'jtm_011_2', userId: 'con_001', name: 'Mike Contractor', email: 'mike@texasbuilders.com', orgRole: 'CONTRACTOR', jobRole: 'Lead Installer', status: 'active', jobCommission: null } + { id: 'jtm_011_1', userId: 'p1', name: 'Jesus Gonzales', email: 'jesus@lynkeduppro.com', orgRole: 'W2_EMPLOYEE', jobRole: 'Project Manager', status: 'active', commissionEnabled: false, jobCommission: null }, + { id: 'jtm_011_2', userId: 'con_001', name: 'Mike Contractor', email: 'mike@texasbuilders.com', orgRole: 'CONTRACTOR', jobRole: 'Lead Installer', status: 'active', commissionEnabled: false, jobCommission: null } ] }, @@ -2806,8 +2806,8 @@ const MOCK_PROJECTS = [ { name: 'Material Delivery Receipt', url: '#', type: 'receipt' } ], teamMembers: [ - { id: 'jtm_012_1', userId: 'p2', name: 'Sarah Sales', email: 'sarah@lynkeduppro.com', orgRole: '1099_SALES_REP', jobRole: 'Sales Rep', status: 'active', jobCommission: null }, - { id: 'jtm_012_2', userId: 'e3', name: 'Fred Flyer', email: 'agent3@plano.com', orgRole: 'FIELD_AGENT', jobRole: 'Canvasser', status: 'active', jobCommission: null } + { id: 'jtm_012_1', userId: 'p2', name: 'Sarah Sales', email: 'sarah@lynkeduppro.com', orgRole: '1099_SALES_REP', jobRole: 'Sales Rep', status: 'active', commissionEnabled: true, jobCommission: null }, + { id: 'jtm_012_2', userId: 'e3', name: 'Fred Flyer', email: 'agent3@plano.com', orgRole: 'FIELD_AGENT', jobRole: 'Canvasser', status: 'active', commissionEnabled: false, jobCommission: null } ] } ]; From 949d96a7c7f492dabbce45700146acf03ae6197c Mon Sep 17 00:00:00 2001 From: Satyam Rastogi Date: Fri, 15 May 2026 12:05:05 +0530 Subject: [PATCH 22/38] add leaderboard for canvassar --- src/components/AnimatedNumber.jsx | 2 +- src/data/mockStore.jsx | 106 +++++- src/pages/LeaderboardPage.jsx | 577 ++++++++++++++++++++---------- 3 files changed, 474 insertions(+), 211 deletions(-) diff --git a/src/components/AnimatedNumber.jsx b/src/components/AnimatedNumber.jsx index e585716..50754c0 100644 --- a/src/components/AnimatedNumber.jsx +++ b/src/components/AnimatedNumber.jsx @@ -106,7 +106,7 @@ const AnimatedNumber = ({ }; }, [value, duration, prefix, suffix, decimals, useLocaleString, delay]); - return 0; + return {value ?? 0}; }; export default AnimatedNumber; diff --git a/src/data/mockStore.jsx b/src/data/mockStore.jsx index 0bedd8a..6c79776 100644 --- a/src/data/mockStore.jsx +++ b/src/data/mockStore.jsx @@ -736,33 +736,100 @@ const MOCK_USERS = [ // Amounts reflect residential storm-damage roofing jobs ($12K–$42K per deal). const MOCK_SALES_HISTORY = [ // Frank Agent (High Revenue, Low Volume - "The Sniper") — fewer deals, bigger jobs - { id: 'tx_1', agentId: 'e1', date: '2026-03-19', amount: 38500, status: 'closed_won' }, // this week - { id: 'tx_2', agentId: 'e1', date: '2026-03-12', amount: 42000, status: 'closed_won' }, - { id: 'tx_3', agentId: 'e1', date: '2026-03-06', amount: 31800, status: 'closed_won' }, - { id: 'tx_4', agentId: 'e1', date: '2026-03-03', amount: 0, status: 'closed_lost' }, + { id: 'tx_1', agentId: 'e1', date: '2026-05-14', amount: 38500, status: 'closed_won' }, // this week + { id: 'tx_2', agentId: 'e1', date: '2026-05-12', amount: 42000, status: 'closed_won' }, // this week + { id: 'tx_3', agentId: 'e1', date: '2026-05-06', amount: 31800, status: 'closed_won' }, + { id: 'tx_4', agentId: 'e1', date: '2026-05-03', amount: 0, status: 'closed_lost' }, // Fiona Field (High Volume, Mid Revenue - "The Grinder") — more deals, consistent value - { id: 'tx_5', agentId: 'e2', date: '2026-03-17', amount: 18200, status: 'closed_won' }, // this week - { id: 'tx_6', agentId: 'e2', date: '2026-03-16', amount: 16800, status: 'closed_won' }, // this week - { id: 'tx_7', agentId: 'e2', date: '2026-03-11', amount: 21400, status: 'closed_won' }, - { id: 'tx_8', agentId: 'e2', date: '2026-03-08', amount: 19600, status: 'closed_won' }, - { id: 'tx_9', agentId: 'e2', date: '2026-03-05', amount: 17300, status: 'closed_won' }, - { id: 'tx_10', agentId: 'e2', date: '2026-03-02', amount: 0, status: 'closed_lost' }, - { id: 'tx_11', agentId: 'e2', date: '2026-03-01', amount: 0, status: 'closed_lost' }, + { id: 'tx_5', agentId: 'e2', date: '2026-05-13', amount: 18200, status: 'closed_won' }, // this week + { id: 'tx_6', agentId: 'e2', date: '2026-05-11', amount: 16800, status: 'closed_won' }, // this week + { id: 'tx_7', agentId: 'e2', date: '2026-05-08', amount: 21400, status: 'closed_won' }, + { id: 'tx_8', agentId: 'e2', date: '2026-05-06', amount: 19600, status: 'closed_won' }, + { id: 'tx_9', agentId: 'e2', date: '2026-05-05', amount: 17300, status: 'closed_won' }, + { id: 'tx_10', agentId: 'e2', date: '2026-05-02', amount: 0, status: 'closed_lost' }, + { id: 'tx_11', agentId: 'e2', date: '2026-05-01', amount: 0, status: 'closed_lost' }, // Fred Flyer (New/Low Performance) — one close this month, struggling - { id: 'tx_12', agentId: 'e3', date: '2026-03-18', amount: 12400, status: 'closed_won' }, // this week - { id: 'tx_13', agentId: 'e3', date: '2026-03-09', amount: 0, status: 'closed_lost' }, - { id: 'tx_14', agentId: 'e3', date: '2026-03-04', amount: 0, status: 'closed_lost' }, + { id: 'tx_12', agentId: 'e3', date: '2026-05-13', amount: 12400, status: 'closed_won' }, // this week + { id: 'tx_13', agentId: 'e3', date: '2026-05-07', amount: 0, status: 'closed_lost' }, + { id: 'tx_14', agentId: 'e3', date: '2026-05-04', amount: 0, status: 'closed_lost' }, // Felicity Fast (Consistent) — reliable mid-tier producer - { id: 'tx_15', agentId: 'e4', date: '2026-03-15', amount: 24800, status: 'closed_won' }, // this week - { id: 'tx_16', agentId: 'e4', date: '2026-03-10', amount: 23200, status: 'closed_won' }, - { id: 'tx_17', agentId: 'e4', date: '2026-03-07', amount: 26500, status: 'closed_won' }, + { id: 'tx_15', agentId: 'e4', date: '2026-05-12', amount: 24800, status: 'closed_won' }, // this week + { id: 'tx_16', agentId: 'e4', date: '2026-05-08', amount: 23200, status: 'closed_won' }, + { id: 'tx_17', agentId: 'e4', date: '2026-05-05', amount: 26500, status: 'closed_won' }, // Felix Fixer (Closer - High Win Rate) — doesn't lose, picks his battles - { id: 'tx_18', agentId: 'e5', date: '2026-03-20', amount: 29500, status: 'closed_won' }, // this week - { id: 'tx_19', agentId: 'e5', date: '2026-03-11', amount: 34800, status: 'closed_won' }, + { id: 'tx_18', agentId: 'e5', date: '2026-05-14', amount: 29500, status: 'closed_won' }, // this week + { id: 'tx_19', agentId: 'e5', date: '2026-05-08', amount: 34800, status: 'closed_won' }, +]; + +// --- MOCK CANVASSER DOOR-KNOCK HISTORY --- +// Each record = one door-knock session / lead attempt by a canvasser. +// status: 'verified' | 'unverified' | 'converted' +// verified = lead confirmed by team lead / admin +// unverified = logged by canvasser, pending review +// converted = verified lead that became a paying client +// Dates in May 2026 — days 11-14 fall in current week, all in current month. +const MOCK_CANVASSER_HISTORY = [ + // Frank Agent (e1) — solid verifier, good conversion + { id: 'ck_001', agentId: 'e1', date: '2026-05-14', status: 'verified', address: '2612 Dunwick Dr' }, + { id: 'ck_002', agentId: 'e1', date: '2026-05-14', status: 'converted', address: '2608 Dunwick Dr' }, + { id: 'ck_003', agentId: 'e1', date: '2026-05-13', status: 'verified', address: '2604 Dunwick Dr' }, + { id: 'ck_004', agentId: 'e1', date: '2026-05-13', status: 'unverified', address: '2600 Dunwick Dr' }, + { id: 'ck_005', agentId: 'e1', date: '2026-05-12', status: 'converted', address: '2516 Dunwick Dr' }, + { id: 'ck_006', agentId: 'e1', date: '2026-05-08', status: 'verified', address: '2637 Rothland Ln' }, + { id: 'ck_007', agentId: 'e1', date: '2026-05-08', status: 'unverified', address: '2633 Rothland Ln' }, + { id: 'ck_008', agentId: 'e1', date: '2026-05-06', status: 'verified', address: '2629 Rothland Ln' }, + { id: 'ck_009', agentId: 'e1', date: '2026-05-04', status: 'verified', address: '6613 Phoenix Pl' }, + { id: 'ck_010', agentId: 'e1', date: '2026-05-02', status: 'converted', address: '6609 Phoenix Pl' }, + + // Fiona Field (e2) — high volume, moderate verification rate + { id: 'ck_011', agentId: 'e2', date: '2026-05-14', status: 'verified', address: '6605 Phoenix Pl' }, + { id: 'ck_012', agentId: 'e2', date: '2026-05-14', status: 'unverified', address: '6601 Phoenix Pl' }, + { id: 'ck_013', agentId: 'e2', date: '2026-05-13', status: 'converted', address: '6617 Phoenix Pl' }, + { id: 'ck_014', agentId: 'e2', date: '2026-05-13', status: 'verified', address: '3913 Arizona Pl' }, + { id: 'ck_015', agentId: 'e2', date: '2026-05-11', status: 'unverified', address: '3917 Arizona Pl' }, + { id: 'ck_016', agentId: 'e2', date: '2026-05-08', status: 'verified', address: '3921 Arizona Pl' }, + { id: 'ck_017', agentId: 'e2', date: '2026-05-08', status: 'unverified', address: '3920 Arizona Pl' }, + { id: 'ck_018', agentId: 'e2', date: '2026-05-06', status: 'converted', address: '3916 Arizona Pl' }, + { id: 'ck_019', agentId: 'e2', date: '2026-05-05', status: 'verified', address: '3912 Arizona Pl' }, + { id: 'ck_020', agentId: 'e2', date: '2026-05-04', status: 'unverified', address: '3908 Arizona Pl' }, + { id: 'ck_021', agentId: 'e2', date: '2026-05-03', status: 'verified', address: '3904 Arizona Pl' }, + { id: 'ck_022', agentId: 'e2', date: '2026-05-01', status: 'unverified', address: '3900 Arizona Pl' }, + + // Fred Flyer (e3) — low volume, mostly unverified (new canvasser) + { id: 'ck_023', agentId: 'e3', date: '2026-05-14', status: 'unverified', address: '3917 Sailmaker Ln' }, + { id: 'ck_024', agentId: 'e3', date: '2026-05-12', status: 'verified', address: '3913 Sailmaker Ln' }, + { id: 'ck_025', agentId: 'e3', date: '2026-05-07', status: 'unverified', address: '3909 Sailmaker Ln' }, + { id: 'ck_026', agentId: 'e3', date: '2026-05-05', status: 'unverified', address: '3905 Sailmaker Ln' }, + { id: 'ck_027', agentId: 'e3', date: '2026-05-03', status: 'converted', address: '3901 Sailmaker Ln' }, + + // Felicity Fast (e4) — top performer, best verified count + conversion + { id: 'ck_028', agentId: 'e4', date: '2026-05-14', status: 'converted', address: '2612 Dunwick Dr' }, + { id: 'ck_029', agentId: 'e4', date: '2026-05-14', status: 'verified', address: '2608 Dunwick Dr' }, + { id: 'ck_030', agentId: 'e4', date: '2026-05-13', status: 'converted', address: '2604 Dunwick Dr' }, + { id: 'ck_031', agentId: 'e4', date: '2026-05-12', status: 'verified', address: '2600 Dunwick Dr' }, + { id: 'ck_032', agentId: 'e4', date: '2026-05-11', status: 'verified', address: '6613 Phoenix Pl' }, + { id: 'ck_033', agentId: 'e4', date: '2026-05-08', status: 'converted', address: '6609 Phoenix Pl' }, + { id: 'ck_034', agentId: 'e4', date: '2026-05-07', status: 'verified', address: '6605 Phoenix Pl' }, + { id: 'ck_035', agentId: 'e4', date: '2026-05-06', status: 'unverified', address: '6601 Phoenix Pl' }, + { id: 'ck_036', agentId: 'e4', date: '2026-05-05', status: 'converted', address: '6617 Phoenix Pl' }, + { id: 'ck_037', agentId: 'e4', date: '2026-05-04', status: 'verified', address: '3913 Arizona Pl' }, + { id: 'ck_038', agentId: 'e4', date: '2026-05-03', status: 'verified', address: '3917 Arizona Pl' }, + { id: 'ck_039', agentId: 'e4', date: '2026-05-02', status: 'unverified', address: '3921 Arizona Pl' }, + { id: 'ck_040', agentId: 'e4', date: '2026-05-01', status: 'converted', address: '3920 Arizona Pl' }, + { id: 'ck_041', agentId: 'e4', date: '2026-05-01', status: 'verified', address: '3916 Arizona Pl' }, + + // Felix Fixer (e5) — selective, fewer knocks, decent conversion rate + { id: 'ck_042', agentId: 'e5', date: '2026-05-14', status: 'converted', address: '3912 Arizona Pl' }, + { id: 'ck_043', agentId: 'e5', date: '2026-05-13', status: 'verified', address: '3908 Arizona Pl' }, + { id: 'ck_044', agentId: 'e5', date: '2026-05-11', status: 'unverified', address: '3904 Arizona Pl' }, + { id: 'ck_045', agentId: 'e5', date: '2026-05-07', status: 'converted', address: '3900 Arizona Pl' }, + { id: 'ck_046', agentId: 'e5', date: '2026-05-05', status: 'verified', address: '3917 Sailmaker Ln' }, + { id: 'ck_047', agentId: 'e5', date: '2026-05-03', status: 'unverified', address: '3913 Sailmaker Ln' }, + { id: 'ck_048', agentId: 'e5', date: '2026-05-01', status: 'verified', address: '3909 Sailmaker Ln' }, ]; // Helper to generate meetings @@ -5150,6 +5217,7 @@ export const MockStoreProvider = ({ children }) => { users, meetings, salesHistory, // Exported for Leaderboard + canvasserHistory: MOCK_CANVASSER_HISTORY, // Exported for Canvasser Leaderboard owners, personnel, vendors, diff --git a/src/pages/LeaderboardPage.jsx b/src/pages/LeaderboardPage.jsx index 7473a31..3bf8dd9 100644 --- a/src/pages/LeaderboardPage.jsx +++ b/src/pages/LeaderboardPage.jsx @@ -1,115 +1,181 @@ import React, { useState, useMemo } from 'react'; import { useMockStore } from '../data/mockStore'; -import { Trophy, TrendingUp, DollarSign, Target, Calendar, Medal, ArrowUpRight, Crown, ShieldOff } from 'lucide-react'; +import { + Trophy, TrendingUp, DollarSign, Target, Crown, ShieldOff, + Users, CheckCircle2, Clock, BarChart2, MapPin +} from 'lucide-react'; import { SpotlightCard } from '../components/SpotlightCard'; import AnimatedNumber from '../components/AnimatedNumber'; import { usePermissions } from '../hooks/usePermissions'; -const LeaderboardPage = () => { - const { users, salesHistory } = useMockStore(); - const { can } = usePermissions(); - const [timeframe, setTimeframe] = useState('month'); // 'week', 'month', 'custom' - const [metric, setMetric] = useState('revenue'); // 'revenue', 'volume', 'winRate' +// ── Rank badge helper ──────────────────────────────────────────────────────── +const rankBadge = (i) => + i === 0 ? 'bg-yellow-100 text-yellow-700 dark:bg-yellow-500/20 dark:text-yellow-300' : + i === 1 ? 'bg-zinc-100 text-zinc-700 dark:bg-white/10 dark:text-zinc-300' : + i === 2 ? 'bg-orange-100 text-orange-700 dark:bg-orange-500/20 dark:text-orange-300' : + 'text-zinc-500'; - // Filter agents +// ── Podium (shared) ────────────────────────────────────────────────────────── +const TopPodium = ({ data, metricKey, formatVal, accentColor = 'border-yellow-400' }) => { + if (data.length < 3) return null; + const [first, second, third] = data; + const Step = ({ agent, rank, height, color, glow }) => ( +
+
+
+
+ + {agent.name.split(' ').map(n => n[0]).join('')} + +
+ {rank === 1 && } +
+
+

{agent.name}

+

+ {formatVal(agent[metricKey])} +

+
+
+
+
{rank}
+
+
+ ); + return ( +
+ + + +
+ ); +}; + +// ── Tab pill ───────────────────────────────────────────────────────────────── +const TabBtn = ({ active, onClick, icon: Icon, label, accent }) => ( + +); + +// ── Filter pill row ─────────────────────────────────────────────────────────── +const FilterPills = ({ options, value, onChange }) => ( +
+ {options.map(o => ( + + ))} +
+); + +// ════════════════════════════════════════════════════════════════════════════ +// MAIN PAGE +// ════════════════════════════════════════════════════════════════════════════ +const LeaderboardPage = () => { + const { users, salesHistory, canvasserHistory } = useMockStore(); + const { can } = usePermissions(); + + // Tab: 'sales' | 'canvasser' + const [tab, setTab] = useState('sales'); + + // ── Sales filters ──────────────────────────────────────────────────────── + const [timeframe, setTimeframe] = useState('all'); + const [metric, setMetric] = useState('revenue'); + + // ── Canvasser filters ──────────────────────────────────────────────────── + const [cvTimeframe, setCvTimeframe] = useState('all'); + const [cvMetric, setCvMetric] = useState('verified'); + + // ── Agents ─────────────────────────────────────────────────────────────── const agents = useMemo(() => users.filter(u => u.role === 'FIELD_AGENT'), [users]); - // Calculate metrics based on timeframe - const leaderboardData = useMemo(() => { + // ── Date windows ───────────────────────────────────────────────────────── + // Returns null for 'all' (no date filter), or a Date boundary for week/month. + const getWindow = (tf) => { + if (tf === 'all') return null; const now = new Date(); - const startOfWeek = new Date(now.setDate(now.getDate() - now.getDay())); - startOfWeek.setHours(0, 0, 0, 0); - - const startOfMonth = new Date(new Date().getFullYear(), new Date().getMonth(), 1); + if (tf === 'week') { + const d = new Date(now); + d.setDate(d.getDate() - d.getDay()); + d.setHours(0, 0, 0, 0); + return d; + } + return new Date(now.getFullYear(), now.getMonth(), 1); + }; + // ── Sales leaderboard data ─────────────────────────────────────────────── + const salesData = useMemo(() => { + const since = getWindow(timeframe); return agents.map(agent => { - const agentTxals = salesHistory.filter(tx => { - const txDate = new Date(tx.date); - const isAgent = tx.agentId === agent.id; - - if (!isAgent) return false; - - if (timeframe === 'week') return txDate >= startOfWeek; - if (timeframe === 'month') return txDate >= startOfMonth; - return true; // All time / Custom + const txs = salesHistory.filter(tx => { + if (tx.agentId !== agent.id) return false; + if (since === null) return true; // 'all time' — no date filter + return new Date(tx.date) >= since; }); - - // Calculate Metrics - const revenue = agentTxals - .filter(tx => tx.status === 'closed_won') - .reduce((sum, tx) => sum + tx.amount, 0); - - const volume = agentTxals - .filter(tx => tx.status === 'closed_won') - .length; - - const won = agentTxals.filter(tx => tx.status === 'closed_won').length; - const lost = agentTxals.filter(tx => tx.status === 'closed_lost').length; - const totalDeals = won + lost; - const winRate = totalDeals > 0 ? (won / totalDeals) * 100 : 0; - - return { - ...agent, - revenue, - volume, - winRate, - totalDeals - }; + const revenue = txs.filter(t => t.status === 'closed_won').reduce((s, t) => s + t.amount, 0); + const volume = txs.filter(t => t.status === 'closed_won').length; + const won = txs.filter(t => t.status === 'closed_won').length; + const lost = txs.filter(t => t.status === 'closed_lost').length; + const winRate = (won + lost) > 0 ? (won / (won + lost)) * 100 : 0; + return { ...agent, revenue, volume, winRate, totalDeals: won + lost }; }).sort((a, b) => b[metric] - a[metric]); - }, [agents, salesHistory, timeframe, metric]); - const formatValue = (val, type) => { - if (type === 'revenue') return `$${val.toLocaleString('en-US')}`; - if (type === 'volume') return `${val} Deals`; - if (type === 'winRate') return `${val.toFixed(1)}%`; + const formatSales = (val, m) => { + if (m === 'revenue') return `$${val.toLocaleString('en-US')}`; + if (m === 'volume') return `${val} Deals`; + if (m === 'winRate') return `${val.toFixed(1)}%`; return val; }; - const TopPodium = ({ data }) => { - if (data.length < 3) return null; - const [first, second, third] = data; + // ── Canvasser leaderboard data ─────────────────────────────────────────── + // Single source of truth: canvasserHistory records, filtered by date window. + // verified = records with status 'verified' OR 'converted' (converted is also verified) + // unverified = records with status 'unverified' (pending review) + // converted = records with status 'converted' (became paying client) + // total = all door-knock records in the window + // convRate = converted / verified — share of verified leads that closed + const canvasserData = useMemo(() => { + const since = getWindow(cvTimeframe); + return agents.map(agent => { + const records = (canvasserHistory || []).filter(r => { + if (r.agentId !== agent.id) return false; + if (!since) return true; + return new Date(r.date) >= since; + }); - const PodiumStep = ({ agent, rank, height, color, glow }) => ( -
-
-
-
- {/* Placeholder Avatar */} - - {agent.name.split(' ').map(n => n[0]).join('')} - -
- {rank === 1 && } -
-
-

{agent.name}

-

- {metric === 'revenue' && } - {metric === 'volume' && <> Deals} - {metric === 'winRate' && <>%} -

-
+ const verified = records.filter(r => r.status === 'verified' || r.status === 'converted').length; + const unverified = records.filter(r => r.status === 'unverified').length; + const converted = records.filter(r => r.status === 'converted').length; + const total = records.length; + const convRate = verified > 0 ? (converted / verified) * 100 : 0; - {/* The Step Block */} -
-
-
- {rank} -
-
-
- ); + return { ...agent, verified, unverified, converted, total, convRate }; + }).sort((a, b) => b[cvMetric] - a[cvMetric]); + }, [agents, canvasserHistory, cvTimeframe, cvMetric]); - return ( -
- - - -
- ); + const formatCv = (val, m) => { + if (m === 'convRate') return `${val.toFixed(1)}%`; + return `${val}`; }; + // ── Permission guard ───────────────────────────────────────────────────── if (!can('leaderboard', 'view')) { return (
@@ -124,119 +190,248 @@ const LeaderboardPage = () => { ); } + // ── Render ─────────────────────────────────────────────────────────────── return (
- {/* Header */} + + {/* ── Page header ── */}
-

Sales Leaderboard

-

Real-time performance rankings for the sales team.

+

+ {tab === 'sales' ? 'Sales Leaderboard' : 'Canvasser Leaderboard'} +

+

+ {tab === 'sales' + ? 'Real-time performance rankings for the sales team.' + : 'Verified door-knock lead rankings for canvassers.'} +

- {/* Controls */} -
- {/* Timeframe */} -
- {['week', 'month'].map(t => ( - - ))} -
- - {/* Metric */} -
- {[ - { id: 'revenue', icon: DollarSign, label: 'Revenue' }, - { id: 'volume', icon: Target, label: 'Volume' }, - { id: 'winRate', icon: TrendingUp, label: 'Win Rate' } - ].map(m => ( - - ))} -
+ {/* ── Tab switcher ── */} +
+ setTab('sales')} + icon={DollarSign} + label="Sales" + accent="bg-white dark:bg-zinc-800 border-zinc-200 dark:border-white/10 text-blue-600 dark:text-blue-400 shadow-sm" + /> + setTab('canvasser')} + icon={MapPin} + label="Canvasser" + accent="bg-white dark:bg-zinc-800 border-zinc-200 dark:border-white/10 text-emerald-600 dark:text-emerald-400 shadow-sm" + />
- {/* Podium Section */} -
- {/* Background Glow */} -
- -
- - {/* Detailed List */} - -
-

- - Full Rankings -

- -
- - - - - - - - - - - - {leaderboardData.map((agent, index) => ( - - - - - - - - ))} - -
RankAgentRevenueVolumeWin Rate
-
- {index + 1} -
-
-
-
- {agent.name.charAt(0)} -
-
-
{agent.name}
-
{agent.email}
-
-
-
- - - Deals - - % -
+ {/* ════════════════════════════════════════════════ + SALES TAB + ════════════════════════════════════════════════ */} + {tab === 'sales' && ( + <> + {/* Controls */} +
+ +
-
- + + {/* Podium */} +
+
+ formatSales(v, metric)} + accentColor="border-yellow-400" + /> +
+ + {/* Full table */} + +
+

+ Full Rankings +

+
+ + + + + + + + + + + + {salesData.map((agent, i) => ( + + + + + + + + ))} + +
RankAgentRevenueVolumeWin Rate
+
{i + 1}
+
+
+
+ {agent.name.charAt(0)} +
+
+
{agent.name}
+
{agent.email}
+
+
+
+ + + Deals + + % +
+
+
+
+ + )} + + {/* ════════════════════════════════════════════════ + CANVASSER TAB + ════════════════════════════════════════════════ */} + {tab === 'canvasser' && ( + <> + {/* Controls */} +
+ + +
+ + {/* Podium */} +
+
+ formatCv(v, cvMetric)} + accentColor="border-emerald-400" + /> +
+ + {/* Stat summary cards */} +
+ {[ + { label: 'Total Verified', value: canvasserData.reduce((s, a) => s + a.verified, 0), color: 'text-emerald-500', icon: CheckCircle2 }, + { label: 'Unverified (Pending)',value: canvasserData.reduce((s, a) => s + a.unverified, 0), color: 'text-amber-500', icon: Clock }, + { label: 'Converted to Client', value: canvasserData.reduce((s, a) => s + a.converted, 0), color: 'text-blue-500', icon: TrendingUp }, + { label: 'Total Door Knocks', value: canvasserData.reduce((s, a) => s + a.total, 0), color: 'text-violet-500', icon: MapPin }, + ].map(c => ( + +
+ + {c.label} +
+

+ +

+
+ ))} +
+ + {/* Full table */} + +
+

+ Canvasser Rankings +

+
+ + + + + + + + + + + + + {canvasserData.map((agent, i) => ( + + + + + + + + + ))} + +
RankCanvasserVerifiedUnverifiedConvertedConv. Rate
+
{i + 1}
+
+
+
+ {agent.name.charAt(0)} +
+
+
{agent.name}
+
{agent.email}
+
+
+
+ + + + + + + % +
+
+
+
+ + )} + igotsar.matyas | LynkedUpPro - Turns out roofing CRMs don't build themselves. Who knew?
); From d2be9b1abfd6eaafb0c3bb75dee9986128c4c521 Mon Sep 17 00:00:00 2001 From: Satyam Rastogi Date: Fri, 15 May 2026 14:30:37 +0530 Subject: [PATCH 23/38] responsive new lead forms --- src/components/leads/LeadPropertySection.jsx | 2 +- src/pages/CreateLeadPage.jsx | 35 +++++++++++++------- 2 files changed, 24 insertions(+), 13 deletions(-) diff --git a/src/components/leads/LeadPropertySection.jsx b/src/components/leads/LeadPropertySection.jsx index 0228e0f..7fcdbb9 100644 --- a/src/components/leads/LeadPropertySection.jsx +++ b/src/components/leads/LeadPropertySection.jsx @@ -198,7 +198,7 @@ export default function LeadPropertySection({ formData, updateField, isQuickCapt {/* Photo thumbnails */} {(formData.propertyPhotos || []).length > 0 && ( -
+
{(formData.propertyPhotos || []).map(photo => ( { - setOpenSections(prev => ({ ...prev, [id]: !prev[id] })); + setOpenSections(prev => { + const isOpening = !prev[id]; + const next = Object.keys(prev).reduce((acc, key) => { + acc[key] = false; + return acc; + }, {}); + next[id] = isOpening; + return next; + }); }; const handleSave = () => { @@ -187,13 +195,16 @@ export default function CreateLeadPage() { if (missingFields.length > 0) { setValidationError(`Required: ${missingFields.join(', ')}`); - // Open the first incomplete section + // Open the first incomplete section (close others for accordion behavior) + const openOnly = (id) => setOpenSections(prev => + Object.keys(prev).reduce((acc, key) => ({ ...acc, [key]: key === id }), {}) + ); if (!formData.firstName || !formData.lastName || !formData.phones.some(p => p.number.replace(/\D/g, '').length >= 10)) { - setOpenSections(prev => ({ ...prev, contact: true })); + openOnly('contact'); } else if (!formData.address || !formData.city) { - setOpenSections(prev => ({ ...prev, property: true })); + openOnly('property'); } else { - setOpenSections(prev => ({ ...prev, job: true })); + openOnly('job'); } toast.error(`Missing required fields: ${missingFields.join(', ')}`); return; @@ -288,7 +299,7 @@ export default function CreateLeadPage() { {/* ---------------------------------------------------------------- Page content ---------------------------------------------------------------- */} -
+
{/* --- Page Header --- */}
@@ -301,13 +312,13 @@ export default function CreateLeadPage() { Leads -
+
{/* Title */} -
-

+
+

New Lead

-

+

{isQuickCapture ? 'Essential fields only — fastest capture at the door.' : 'Full lead profile with insurance and assignment details.'} @@ -315,7 +326,7 @@ export default function CreateLeadPage() {

{/* Quick / Full Form toggle */} -
@@ -63,7 +63,7 @@ const CommissionForm = ({ type, rate, onTypeChange, onRateChange, accentColor = 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" + className="w-full bg-white dark:bg-[#18181b] border border-zinc-200 dark:border-white/10 rounded-xl px-4 py-2.5 text-sm text-zinc-900 dark:text-white outline-none" > {availablePersonnel.map(p => ( @@ -451,7 +451,7 @@ const CommissionSettingsModal = ({ @@ -459,7 +459,7 @@ const CommissionSettingsModal = ({ 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" + className="flex-1 px-4 py-2 rounded-xl bg-purple-500/20 text-purple-700 dark: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 @@ -469,7 +469,7 @@ const CommissionSettingsModal = ({ @@ -496,15 +496,15 @@ const CommissionSettingsModal = ({ >
- Enable job-level override + Enable job-level override
@@ -517,7 +517,7 @@ const CommissionSettingsModal = ({ accentColor="#fda913" /> ) : ( -

+

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

)} @@ -528,11 +528,11 @@ const CommissionSettingsModal = ({
{/* Hierarchy Info */} -
+
- +
-

Override Priority: Job Override → User Override → Role Override → Org Default

+

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.

@@ -540,11 +540,11 @@ const CommissionSettingsModal = ({
{/* Footer */} -
+
From a85b90d28377b02570bb9965b100d26eda5541de Mon Sep 17 00:00:00 2001 From: Satyam Rastogi Date: Fri, 15 May 2026 15:19:51 +0530 Subject: [PATCH 25/38] redesigned createItemModal with responsive and light /dark mode --- src/components/owner/CreateItemModal.jsx | 135 ++++++++++++----------- 1 file changed, 73 insertions(+), 62 deletions(-) diff --git a/src/components/owner/CreateItemModal.jsx b/src/components/owner/CreateItemModal.jsx index 89936d1..9f39182 100644 --- a/src/components/owner/CreateItemModal.jsx +++ b/src/components/owner/CreateItemModal.jsx @@ -20,84 +20,95 @@ const CreateItemModal = ({ isOpen, onClose, title, icon: Icon, iconColor = "text onClose(); }; + const inputBase = "w-full bg-white dark:bg-[#18181b] border border-zinc-200 dark:border-white/10 rounded-xl px-4 py-3 text-sm text-zinc-900 dark:text-white placeholder-zinc-400 dark:placeholder-zinc-600 focus:outline-none focus:border-[#00f0ff] focus:ring-1 focus:ring-[#00f0ff] transition-all"; + return createPortal( -
-
-
+
+
+
{/* Header */} -
-
+
+
{Icon && ( -
+
)} -

{title}

+

{title}

-
{/* Content */} -
- {fields.map((field, i) => ( -
- - {field.type === 'textarea' ? ( -