From 11b15a2e30cbdf2c988f36054430ac84127de091 Mon Sep 17 00:00:00 2001 From: Satyam Rastogi Date: Wed, 20 May 2026 14:34:38 +0530 Subject: [PATCH] updates budget & cost feature of projects: added sub contrcator comm, team cost --- src/pages/owner/OwnerProjectDetail.jsx | 469 +++++++++++++++++-------- 1 file changed, 322 insertions(+), 147 deletions(-) diff --git a/src/pages/owner/OwnerProjectDetail.jsx b/src/pages/owner/OwnerProjectDetail.jsx index b450ff7..25f076f 100644 --- a/src/pages/owner/OwnerProjectDetail.jsx +++ b/src/pages/owner/OwnerProjectDetail.jsx @@ -1,4 +1,4 @@ -import React, { useState, useMemo, useRef } from 'react'; +import React, { useState, useMemo, useRef, useEffect } from 'react'; import { useParams, useNavigate } from 'react-router-dom'; import { useAuth } from '../../context/AuthContext'; import { useMockStore } from '../../data/mockStore'; @@ -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, Info, Settings, Building2, User as UserIcon, Briefcase, Users + Upload, Trash2, Eye, Pencil, X as XIcon, FolderOpen, Download, File, StickyNote, Banknote, ChevronLeft, ChevronDown as ChevronDownIcon, Info, Settings, Building2, User as UserIcon, Briefcase, Users, HardHat, Wrench, UserCheck, Wallet } from 'lucide-react'; import ChangeOrderDrawer from '../../components/owner/ChangeOrderDrawer'; import InvoiceDetailModal from '../../components/owner/InvoiceDetailModal'; @@ -125,6 +125,51 @@ function seedPaymentsMock(projectId) { const PAYMENTS_PER_PAGE = 5; +// ── Expense type taxonomy ────────────────────────────────────────────── +const EXPENSE_TYPE_CONFIG = { + material: { label: 'Material', icon: Wrench, color: 'text-sky-600 dark:text-[#00f0ff]', bg: 'bg-sky-50 dark:bg-blue-500/10', border: 'border-sky-200 dark:border-blue-500/20' }, + labor: { label: 'Labor', icon: HardHat, color: 'text-emerald-600 dark:text-emerald-400', bg: 'bg-emerald-50 dark:bg-emerald-500/10', border: 'border-emerald-200 dark:border-emerald-500/20' }, + team: { label: 'Team Commission', icon: UserCheck, color: 'text-purple-600 dark:text-purple-400', bg: 'bg-purple-50 dark:bg-purple-500/10', border: 'border-purple-200 dark:border-purple-500/20' }, + subcontractor: { label: 'Subcontractor', icon: HardHat, color: 'text-amber-600 dark:text-[#fda913]', bg: 'bg-amber-50 dark:bg-[#fda913]/10', border: 'border-amber-200 dark:border-[#fda913]/20' }, + other: { label: 'Other', icon: DollarSign, color: 'text-zinc-600 dark:text-zinc-400', bg: 'bg-zinc-100 dark:bg-white/5', border: 'border-zinc-200 dark:border-white/10' }, +}; + +const SUBCONTRACTOR_WORK_TYPES = ['Plumbing', 'Electrical', 'Painting', 'Roofing', 'Civil work', 'HVAC', 'Carpentry', 'Other']; + +const inferExpenseType = (category = '') => { + const c = (category || '').toLowerCase(); + if (c.includes('material')) return 'material'; + if (c.includes('labor')) return 'labor'; + if (c.includes('commission') || c.includes('sales rep')) return 'team'; + if (SUBCONTRACTOR_WORK_TYPES.some(w => c.includes(w.toLowerCase()))) return 'subcontractor'; + return 'other'; +}; + +function seedExpenseRowsMock(project) { + const baseRows = (project?.budgetBreakdown || []).map((b, i) => ({ + id: `exp_base_${i}`, + type: inferExpenseType(b.category), + name: b.name || '—', + category: b.category || '', + allocated: Number(b.allocated) || 0, + actual: Number(b.actual) || 0, + paid: Number(b.actual) > 0 && Number(b.actual) >= Number(b.allocated) * 0.9, + })); + const seed = (project?.id || '').charCodeAt((project?.id || 'x').length - 1) % 3; + const teamRows = [ + { id: 'exp_team_1', type: 'team', name: 'Jesus Gonzales', category: 'Sales Rep', allocated: 4200, actual: 4200, paid: true }, + { id: 'exp_team_2', type: 'team', name: 'Frank Agent', category: 'Canvasser', allocated: 1500, actual: 1500, paid: false }, + { id: 'exp_team_3', type: 'team', name: 'Sarah Sales', category: 'Estimator', allocated: 1200, actual: 0, paid: false }, + ].slice(0, 2 + seed); + const subRows = [ + { id: 'exp_sub_1', type: 'subcontractor', name: 'PlumbPro Services', category: 'Plumbing', allocated: 1800, actual: 1800, paid: true }, + { id: 'exp_sub_2', type: 'subcontractor', name: 'Bright Electric LLC', category: 'Electrical', allocated: 2400, actual: 2150, paid: false }, + { id: 'exp_sub_3', type: 'subcontractor', name: 'Premium Painters', category: 'Painting', allocated: 950, actual: 950, paid: true }, + { id: 'exp_sub_4', type: 'subcontractor', name: 'Solid Civil Works', category: 'Civil work', allocated: 3200, actual: 0, paid: false }, + ].slice(0, 3 + seed); + return [...baseRows, ...teamRows, ...subRows]; +} + const COMMISSION_TYPES = [ { value: 'flat', label: 'Flat Amount', hint: 'Fixed dollar amount' }, { value: 'percent_gross', label: '% of Gross', hint: 'Percentage of Total Received' }, @@ -158,6 +203,10 @@ const OwnerProjectDetail = () => { const [paymentSort, setPaymentSort] = useState({ field: 'date', dir: 'desc' }); const [paymentPage, setPaymentPage] = useState(0); + // ── Expense rows state (unified cost breakdown) ─────────────────────────── + const [expenseRows, setExpenseRows] = useState([]); + const [expensesSeeded, setExpensesSeeded] = useState(false); + const [openTooltip, setOpenTooltip] = useState(null); // ── Commission config state ───────────────────────────────────────────── @@ -248,6 +297,31 @@ const OwnerProjectDetail = () => { projects.find(p => p.id === projectId && p.ownerId === user?.id) , [projects, projectId, user]); + useEffect(() => { + if (project && !expensesSeeded) { + setExpenseRows(seedExpenseRowsMock(project)); + setExpensesSeeded(true); + } + }, [project, expensesSeeded]); + + const togglePaid = (id) => { + setExpenseRows(prev => prev.map(r => r.id === id ? { ...r, paid: !r.paid } : r)); + }; + + const handleAddExpense = (data, type) => { + const amt = parseFloat(data.amount) || 0; + const newRow = { + id: `exp_${Date.now()}`, + type: type || data.type || 'other', + name: data.name || '', + category: data.category || '', + allocated: amt, + actual: amt, + paid: false, + }; + setExpenseRows(prev => [newRow, ...prev]); + }; + const teamFinancials = useMemo(() => { if (!project) return { totalReceived: 0, grossProfit: 0 }; const paid = (project.paymentSchedule || []) @@ -283,9 +357,16 @@ const OwnerProjectDetail = () => { const activeCommissionConfig = resolvedCommission; + const expenseTotals = useMemo(() => { + const total = expenseRows.reduce((s, r) => s + (Number(r.actual) || Number(r.allocated) || 0), 0); + const paid = expenseRows.reduce((s, r) => s + (r.paid ? (Number(r.actual) || Number(r.allocated) || 0) : 0), 0); + const outstanding = Math.max(0, total - paid); + return { total, paid, outstanding }; + }, [expenseRows]); + const profitMetrics = useMemo(() => { - if (!project) return { totalCosts: 0, commission: 0, commissionFormula: '', grossProfit: 0, netProfit: 0, grossMargin: 0, netMargin: 0 }; - const totalCosts = Number(project.actualCost ?? project.spent) || 0; + if (!project) return { totalCosts: 0, totalExpenses: 0, paidExpenses: 0, outstandingExpenses: 0, totalBudget: 0, commission: 0, commissionFormula: '', grossProfit: 0, netProfit: 0, grossMargin: 0, netMargin: 0 }; + const totalCosts = expenseTotals.total || Number(project.actualCost ?? project.spent) || 0; const gross = totalReceived - totalCosts; const { type, rate } = activeCommissionConfig; @@ -315,11 +396,25 @@ const OwnerProjectDetail = () => { } commission = Number.isFinite(commission) ? commission : 0; - const net = gross - commission; + // Net Profit = Total Payments Received − Total Expenses (expense table already includes team commission + subcontractors) + const net = totalReceived - expenseTotals.total; const grossMargin = totalReceived > 0 ? (gross / totalReceived) * 100 : 0; const netMargin = totalReceived > 0 ? (net / totalReceived) * 100 : 0; - return { totalCosts, commission, commissionFormula, grossProfit: gross, netProfit: net, grossMargin, netMargin }; - }, [project, totalReceived, activeCommissionConfig]); + const totalBudget = Number(project.approvedBudget ?? project.budget) || 0; + return { + totalCosts, + totalExpenses: expenseTotals.total, + paidExpenses: expenseTotals.paid, + outstandingExpenses: expenseTotals.outstanding, + totalBudget, + commission, + commissionFormula, + grossProfit: gross, + netProfit: net, + grossMargin, + netMargin, + }; + }, [project, totalReceived, activeCommissionConfig, expenseTotals]); if (!project) { return ( @@ -691,97 +786,200 @@ const OwnerProjectDetail = () => { {/* BUDGET TAB */} {activeTab === 'budget' && (
- {budgetChartData.length > 0 ? ( - <> - -
-
- -

Budget Breakdown

+ {/* Budget & Costs Summary Cards */} +
+ {[ + { label: 'Total Budget', value: profitMetrics.totalBudget, icon: Wallet, color: 'text-sky-600 dark:text-[#00f0ff]', spotlight: 'rgba(0, 240, 255, 0.12)' }, + { label: 'Total Expenses', value: profitMetrics.totalExpenses, icon: Wrench, color: 'text-amber-600 dark:text-[#fda913]', spotlight: 'rgba(253, 169, 19, 0.12)' }, + { label: 'Payments Received', value: totalReceived, icon: Banknote, color: 'text-emerald-600 dark:text-[#39ff14]', spotlight: 'rgba(57, 255, 20, 0.12)' }, + { label: 'Outstanding Expenses', value: profitMetrics.outstandingExpenses, icon: AlertCircle, color: 'text-orange-600 dark:text-[#ff4500]', spotlight: 'rgba(255, 69, 0, 0.12)' }, + { + label: 'Net Profit', + value: profitMetrics.netProfit, + icon: TrendingUp, + color: profitMetrics.netProfit >= 0 ? 'text-emerald-600 dark:text-[#39ff14]' : 'text-red-600 dark:text-[#ff003c]', + spotlight: profitMetrics.netProfit >= 0 ? 'rgba(57, 255, 20, 0.12)' : 'rgba(255, 0, 60, 0.12)', + emphasis: true, + }, + ].map((c, i) => { + const CardIcon = c.icon; + return ( + +
+ +

{c.label}

- +
+ {c.value >= 0 ? '' : '-'}{formatCurrency(Math.abs(c.value))} +
+ {c.label === 'Net Profit' && ( +

+ Received − Expenses +

+ )} + {c.label === 'Outstanding Expenses' && ( +

+ Paid: {formatCurrency(profitMetrics.paidExpenses)} +

+ )} +
+ ); + })} +
+ + {budgetChartData.length > 0 && ( + +
+
+ +

Budget Breakdown

-

Allocated vs Actual ($k)

-
- - - - - } /> - - - - +
+

Allocated vs Actual ($k)

+
+ + + + + } /> + + + + +
+ + )} + +
+
+ +

Cost Breakdown / Expenses

+ + {expenseRows.length} + +
+
+ + + +
-
-
- +
- {['Name', 'Category', 'Allocated', 'Actual', 'Variance'].map(h => ( - - ))} + + + + + + - {(project.budgetBreakdown || []).map((b, i) => { - const variance = b.actual - b.allocated; + {expenseRows.map((row) => { + const cfg = EXPENSE_TYPE_CONFIG[row.type] || EXPENSE_TYPE_CONFIG.other; + const TypeIcon = cfg.icon; return ( - - - - - - + + + + + + ); })} + {expenseRows.length === 0 && ( + + + + )} - - - - {(() => { - const totalVariance = profitMetrics.totalCosts - totalReceived; - return ( - - ); - })()} + + +
{h}TypeNameCategory / RoleAllocatedActualPaid
{b.name || '—'}{b.category}{formatCurrency(b.allocated)}{formatCurrency(b.actual)} 0 ? 'text-[#ff4500]' : 'text-[#39ff14]'}`}> - {variance > 0 ? '+' : ''}{formatCurrency(variance)} +
+ + + {cfg.label} + + {row.name || '—'}{row.category || '—'}{formatCurrency(row.allocated)}{formatCurrency(row.actual)} +
+ No expenses recorded yet. +
+ Totals {formatCurrency(totalReceived)}{formatCurrency(profitMetrics.totalCosts)} 0 ? 'text-[#ff4500]' : 'text-[#39ff14]'}`}> - {totalVariance > 0 ? '+' : ''}{formatCurrency(totalVariance)} - + {formatCurrency(expenseRows.reduce((s, r) => s + (Number(r.allocated) || 0), 0))} + + {formatCurrency(profitMetrics.totalExpenses)} + + {expenseRows.filter(r => r.paid).length}/{expenseRows.length} +
- - ) : ( - - -

No detailed budget breakdown available.

-

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

-
- )} {/* Commission Configuration Card */} @@ -888,84 +1086,61 @@ const OwnerProjectDetail = () => {
- {/* 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)}

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

Net Profit Breakdown

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

Net Profit Formula

+

Total Payments Received − Total Expenses

+
+

{formatCurrency(totalReceived)} − {formatCurrency(profitMetrics.totalExpenses)} = {formatCurrency(profitMetrics.netProfit)}

- )} -
+

Total expenses include materials, labor, team commission, and subcontractor costs.

+
+ )}
-
= 0 ? NEON_GREEN : NEON_RED}`}> - {profitMetrics.grossProfit >= 0 ? '+' : ''}{formatCurrency(profitMetrics.grossProfit)} +
+
+
+

Payments Received

+

{formatCurrency(totalReceived)}

-
- = 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 - +
+

− Total Expenses

+

{formatCurrency(profitMetrics.totalExpenses)}

- - - = 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 ? 'bg-emerald-50/60 dark:bg-[#39ff14]/5 border-emerald-200 dark:border-[#39ff14]/20' : 'bg-red-50/60 dark:bg-[#ff003c]/5 border-red-200 dark:border-[#ff003c]/20'}`}> +

= Net Profit

+

= 0 ? NEON_GREEN : NEON_RED}`}> + {profitMetrics.netProfit >= 0 ? '+' : ''}{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 - -
- -
+
+
+ = 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 + + + Outstanding: {formatCurrency(profitMetrics.outstandingExpenses)} + +
+ {/* Payments Received Table */}