updates budget & cost feature of projects: added sub contrcator comm, team cost
This commit is contained in:
@@ -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,29 +786,54 @@ const OwnerProjectDetail = () => {
|
||||
{/* BUDGET TAB */}
|
||||
{activeTab === 'budget' && (
|
||||
<div className="space-y-6 max-w-7xl mx-auto">
|
||||
{budgetChartData.length > 0 ? (
|
||||
<>
|
||||
{/* Budget & Costs Summary Cards */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-3 sm:gap-4">
|
||||
{[
|
||||
{ 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 (
|
||||
<NeoCard key={i} spotlightColor={c.spotlight} innerClassName="!p-4">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<CardIcon size={14} className={c.color} />
|
||||
<h4 className="text-[10px] font-mono font-bold text-zinc-500 dark:text-zinc-400 uppercase tracking-widest truncate">{c.label}</h4>
|
||||
</div>
|
||||
<div className={`font-mono font-black tracking-tight ${c.emphasis ? 'text-2xl sm:text-3xl' : 'text-lg sm:text-xl'} ${c.color}`}>
|
||||
{c.value >= 0 ? '' : '-'}{formatCurrency(Math.abs(c.value))}
|
||||
</div>
|
||||
{c.label === 'Net Profit' && (
|
||||
<p className="mt-1 text-[10px] font-mono text-zinc-500 dark:text-zinc-500 truncate">
|
||||
Received − Expenses
|
||||
</p>
|
||||
)}
|
||||
{c.label === 'Outstanding Expenses' && (
|
||||
<p className="mt-1 text-[10px] font-mono text-zinc-500 dark:text-zinc-500 truncate">
|
||||
Paid: {formatCurrency(profitMetrics.paidExpenses)}
|
||||
</p>
|
||||
)}
|
||||
</NeoCard>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{budgetChartData.length > 0 && (
|
||||
<NeoCard spotlightColor="rgba(0, 240, 255, 0.1)">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<div className="flex items-center gap-3">
|
||||
<DollarSign className={NEON_BLUE} size={20} />
|
||||
<h3 className="text-lg font-bold text-zinc-900 dark:text-white tracking-widest uppercase">Budget Breakdown</h3>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setCreateModalConfig({
|
||||
title: 'Add Budget Cost', icon: DollarSign, iconColor: 'text-[#39ff14]', iconBg: 'bg-[#39ff14]/10',
|
||||
submitLabel: 'Save Cost', submitColor: 'bg-[#39ff14]/20 text-[#39ff14] border-[#39ff14]/30',
|
||||
fields: [
|
||||
{ name: 'category', label: 'Category', type: 'text', required: true },
|
||||
{ name: 'allocated', label: 'Allocated Amount', type: 'number', required: true },
|
||||
{ name: 'spent', label: 'Actual Spent', type: 'number' }
|
||||
],
|
||||
onSubmit: handleCreateSubmit
|
||||
})}
|
||||
className="bg-blue-500/10 hover:bg-blue-500/20 text-[#00f0ff] border border-blue-500/30 px-3 py-1.5 rounded-lg text-xs font-bold transition flex items-center gap-2"
|
||||
>
|
||||
<Plus size={14} /> Add Cost
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-xs text-zinc-400 mb-6 font-mono">Allocated vs Actual ($k)</p>
|
||||
<div className="h-64 bg-zinc-50 dark:bg-black/20 rounded-xl p-4 border border-zinc-200 dark:border-white/5">
|
||||
@@ -728,60 +848,138 @@ const OwnerProjectDetail = () => {
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</NeoCard>
|
||||
)}
|
||||
<NeoCard spotlightColor="rgba(253, 169, 19, 0.1)" innerClassName="p-0 lg:p-0">
|
||||
<div className="p-5 border-b border-zinc-200 dark:border-white/5 flex flex-col sm:flex-row sm:items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<DollarSign className={NEON_GOLD} size={20} />
|
||||
<h3 className="text-lg font-bold text-zinc-900 dark:text-white tracking-widest uppercase">Cost Breakdown / Expenses</h3>
|
||||
<span className="text-xs font-bold bg-zinc-100 dark:bg-white/5 border border-zinc-200 dark:border-white/5 px-2 py-0.5 rounded-full text-zinc-500 dark:text-zinc-400">
|
||||
{expenseRows.length}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<button
|
||||
onClick={() => setCreateModalConfig({
|
||||
title: 'Add Team Commission Cost', icon: UserCheck, iconColor: 'text-purple-500 dark:text-purple-400', iconBg: 'bg-purple-500/10',
|
||||
submitLabel: 'Save Team Cost', submitColor: 'bg-purple-500/20 text-purple-500 dark:text-purple-400 border-purple-500/30',
|
||||
fields: [
|
||||
{ name: 'name', label: 'Team Member Name', type: 'text', required: true, placeholder: 'e.g. Jesus Gonzales' },
|
||||
{ name: 'category', label: 'Role / Category', type: 'text', required: true, placeholder: 'e.g. Sales Rep' },
|
||||
{ name: 'amount', label: 'Commission Amount ($)', type: 'number', required: true },
|
||||
],
|
||||
onSubmit: (d) => handleAddExpense(d, 'team'),
|
||||
})}
|
||||
className="bg-purple-500/10 hover:bg-purple-500/20 text-purple-600 dark:text-purple-400 border border-purple-500/30 px-3 py-1.5 rounded-lg text-xs font-bold transition flex items-center gap-2"
|
||||
>
|
||||
<UserCheck size={14} /> Add Team Cost
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setCreateModalConfig({
|
||||
title: 'Add Subcontractor Cost', icon: HardHat, iconColor: 'text-amber-500 dark:text-[#fda913]', iconBg: 'bg-amber-500/10',
|
||||
submitLabel: 'Save Subcontractor Cost', submitColor: 'bg-amber-500/20 text-amber-500 dark:text-[#fda913] border-amber-500/30',
|
||||
fields: [
|
||||
{ name: 'name', label: 'Subcontractor Name', type: 'text', required: true, placeholder: 'e.g. PlumbPro Services' },
|
||||
{ name: 'category', label: 'Category / Work Type', type: 'select', required: true, options: SUBCONTRACTOR_WORK_TYPES.map(w => ({ label: w, value: w })) },
|
||||
{ name: 'amount', label: 'Amount ($)', type: 'number', required: true },
|
||||
],
|
||||
onSubmit: (d) => handleAddExpense(d, 'subcontractor'),
|
||||
})}
|
||||
className="bg-amber-500/10 hover:bg-amber-500/20 text-amber-600 dark:text-[#fda913] border border-amber-500/30 px-3 py-1.5 rounded-lg text-xs font-bold transition flex items-center gap-2"
|
||||
>
|
||||
<HardHat size={14} /> Add Subcontractor
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setCreateModalConfig({
|
||||
title: 'Add Cost', icon: DollarSign, iconColor: 'text-[#39ff14]', iconBg: 'bg-[#39ff14]/10',
|
||||
submitLabel: 'Save Cost', submitColor: 'bg-[#39ff14]/20 text-[#39ff14] border-[#39ff14]/30',
|
||||
fields: [
|
||||
{ name: 'name', label: 'Vendor / Source Name', type: 'text', required: true },
|
||||
{ name: 'category', label: 'Category', type: 'text', required: true, placeholder: 'e.g. Materials - Shingles' },
|
||||
{ name: 'amount', label: 'Amount ($)', type: 'number', required: true },
|
||||
],
|
||||
onSubmit: (d) => handleAddExpense(d, inferExpenseType(d.category)),
|
||||
})}
|
||||
className="bg-blue-500/10 hover:bg-blue-500/20 text-[#00f0ff] border border-blue-500/30 px-3 py-1.5 rounded-lg text-xs font-bold transition flex items-center gap-2"
|
||||
>
|
||||
<Plus size={14} /> Add Cost
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="overflow-x-auto w-full rounded-2xl">
|
||||
<table className="w-full text-left border-collapse min-w-[600px]">
|
||||
<table className="w-full text-left border-collapse min-w-[860px]">
|
||||
<thead className="bg-zinc-100 dark:bg-[#18181b]/80 border-b border-zinc-200 dark:border-white/10">
|
||||
<tr>
|
||||
{['Name', 'Category', 'Allocated', 'Actual', 'Variance'].map(h => (
|
||||
<th key={h} className={`px-5 py-4 text-xs font-bold uppercase tracking-wider text-zinc-400 ${h !== 'Name' && h !== 'Category' ? 'text-right' : ''}`}>{h}</th>
|
||||
))}
|
||||
<th className="px-5 py-4 text-xs font-bold uppercase tracking-wider text-zinc-500 dark:text-zinc-400">Type</th>
|
||||
<th className="px-5 py-4 text-xs font-bold uppercase tracking-wider text-zinc-500 dark:text-zinc-400">Name</th>
|
||||
<th className="px-5 py-4 text-xs font-bold uppercase tracking-wider text-zinc-500 dark:text-zinc-400">Category / Role</th>
|
||||
<th className="px-5 py-4 text-xs font-bold uppercase tracking-wider text-zinc-500 dark:text-zinc-400 text-right">Allocated</th>
|
||||
<th className="px-5 py-4 text-xs font-bold uppercase tracking-wider text-zinc-500 dark:text-zinc-400 text-right">Actual</th>
|
||||
<th className="px-5 py-4 text-xs font-bold uppercase tracking-wider text-zinc-500 dark:text-zinc-400 text-center">Paid</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-zinc-100 dark:divide-white/5">
|
||||
{(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 (
|
||||
<tr key={i} className="hover:bg-zinc-50 dark:hover:bg-white/5 transition-colors">
|
||||
<td className="px-5 py-4 text-sm text-zinc-500 dark:text-zinc-400">{b.name || '—'}</td>
|
||||
<td className="px-5 py-4 font-bold text-sm text-zinc-900 dark:text-white">{b.category}</td>
|
||||
<td className="px-5 py-4 text-right font-mono text-sm text-zinc-600 dark:text-zinc-300">{formatCurrency(b.allocated)}</td>
|
||||
<td className="px-5 py-4 text-right font-mono text-sm text-[#00f0ff]">{formatCurrency(b.actual)}</td>
|
||||
<td className={`px-5 py-4 text-right font-mono text-sm font-bold ${variance > 0 ? 'text-[#ff4500]' : 'text-[#39ff14]'}`}>
|
||||
{variance > 0 ? '+' : ''}{formatCurrency(variance)}
|
||||
<tr key={row.id} className="hover:bg-zinc-50 dark:hover:bg-white/5 transition-colors">
|
||||
<td className="px-5 py-4">
|
||||
<span className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-[10px] font-bold uppercase tracking-widest border ${cfg.color} ${cfg.bg} ${cfg.border}`}>
|
||||
<TypeIcon size={11} />
|
||||
{cfg.label}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-5 py-4 font-bold text-sm text-zinc-900 dark:text-white">{row.name || '—'}</td>
|
||||
<td className="px-5 py-4 text-sm text-zinc-500 dark:text-zinc-400">{row.category || '—'}</td>
|
||||
<td className="px-5 py-4 text-right font-mono text-sm text-zinc-600 dark:text-zinc-300">{formatCurrency(row.allocated)}</td>
|
||||
<td className="px-5 py-4 text-right font-mono text-sm text-[#00f0ff]">{formatCurrency(row.actual)}</td>
|
||||
<td className="px-5 py-4 text-center">
|
||||
<label className="inline-flex items-center justify-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!!row.paid}
|
||||
onChange={() => togglePaid(row.id)}
|
||||
className="sr-only peer"
|
||||
aria-label={`Mark ${row.name || 'expense'} paid`}
|
||||
/>
|
||||
<span className={`w-5 h-5 rounded-md border flex items-center justify-center transition-colors ${row.paid
|
||||
? 'bg-emerald-500 border-emerald-500 dark:bg-[#39ff14] dark:border-[#39ff14]'
|
||||
: 'bg-white dark:bg-[#18181b] border-zinc-300 dark:border-white/20 hover:border-emerald-400'}`}>
|
||||
{row.paid && <CheckCircle size={12} className="text-white dark:text-black" strokeWidth={3} />}
|
||||
</span>
|
||||
</label>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
{expenseRows.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={6} className="px-5 py-10 text-center text-sm text-zinc-500 dark:text-zinc-400">
|
||||
No expenses recorded yet.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
<tfoot className="border-t-2 border-zinc-300 dark:border-white/10">
|
||||
<tr className="bg-zinc-50 dark:bg-white/[0.03]">
|
||||
<td className="px-5 py-4" colSpan={2}>
|
||||
<td className="px-5 py-4" colSpan={3}>
|
||||
<span className="text-xs font-bold uppercase tracking-widest text-zinc-500 dark:text-zinc-400">Totals</span>
|
||||
</td>
|
||||
<td className="px-5 py-4 text-right font-mono text-sm font-bold text-green-600 dark:text-[#39ff14]">{formatCurrency(totalReceived)}</td>
|
||||
<td className="px-5 py-4 text-right font-mono text-sm font-bold text-[#00f0ff]">{formatCurrency(profitMetrics.totalCosts)}</td>
|
||||
{(() => {
|
||||
const totalVariance = profitMetrics.totalCosts - totalReceived;
|
||||
return (
|
||||
<td className={`px-5 py-4 text-right font-mono text-sm font-bold ${totalVariance > 0 ? 'text-[#ff4500]' : 'text-[#39ff14]'}`}>
|
||||
{totalVariance > 0 ? '+' : ''}{formatCurrency(totalVariance)}
|
||||
<td className="px-5 py-4 text-right font-mono text-sm font-bold text-zinc-600 dark:text-zinc-300">
|
||||
{formatCurrency(expenseRows.reduce((s, r) => s + (Number(r.allocated) || 0), 0))}
|
||||
</td>
|
||||
<td className="px-5 py-4 text-right font-mono text-sm font-bold text-[#00f0ff]">
|
||||
{formatCurrency(profitMetrics.totalExpenses)}
|
||||
</td>
|
||||
<td className="px-5 py-4 text-center text-[10px] font-mono font-bold text-zinc-500 dark:text-zinc-400 uppercase tracking-widest">
|
||||
{expenseRows.filter(r => r.paid).length}/{expenseRows.length}
|
||||
</td>
|
||||
);
|
||||
})()}
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
</NeoCard>
|
||||
</>
|
||||
) : (
|
||||
<NeoCard className="p-12 text-center text-zinc-500">
|
||||
<DollarSign size={48} className="mx-auto mb-4 text-zinc-600" />
|
||||
<p className="font-bold text-lg text-zinc-900 dark:text-white">No detailed budget breakdown available.</p>
|
||||
<p className="text-sm mt-2 text-zinc-400 font-mono">Budget: {formatCurrency(project.approvedBudget || project.budget)} · Spent: {formatCurrency(project.actualCost || project.spent)}</p>
|
||||
</NeoCard>
|
||||
)}
|
||||
|
||||
{/* Commission Configuration Card */}
|
||||
<NeoCard spotlightColor="rgba(253, 169, 19, 0.1)" innerClassName="!p-5">
|
||||
@@ -888,54 +1086,15 @@ const OwnerProjectDetail = () => {
|
||||
</div>
|
||||
</NeoCard>
|
||||
|
||||
{/* Profit Summary Cards */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<NeoCard
|
||||
spotlightColor={profitMetrics.grossProfit >= 0 ? "rgba(57, 255, 20, 0.12)" : "rgba(255, 0, 60, 0.12)"}
|
||||
innerClassName="!p-5"
|
||||
>
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<TrendingUp size={18} className={profitMetrics.grossProfit >= 0 ? NEON_GREEN : NEON_RED} />
|
||||
<h4 className="text-xs font-mono font-bold text-zinc-500 dark:text-zinc-400 uppercase tracking-widest">Gross Profit</h4>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<button type="button" onClick={(e) => { e.stopPropagation(); setOpenTooltip(prev => prev === 'gross' ? null : 'gross'); }} className="p-1 rounded-lg hover:bg-zinc-100 dark:hover:bg-white/10 transition-colors">
|
||||
<Info size={14} className="text-zinc-400 dark:text-zinc-500" />
|
||||
</button>
|
||||
{openTooltip === 'gross' && (
|
||||
<div className="absolute right-0 top-2 z-30 w-64 p-3 rounded-xl bg-white dark:bg-zinc-800 border border-zinc-200 dark:border-white/10 shadow-xl text-xs text-zinc-600 dark:text-zinc-300">
|
||||
<p className="font-bold text-zinc-900 dark:text-white mb-1">Gross Profit Formula</p>
|
||||
<p className="font-mono text-[11px]">Total Received − Total Actual Costs</p>
|
||||
<div className="mt-2 pt-2 border-t border-zinc-100 dark:border-white/5 space-y-1">
|
||||
<p>{formatCurrency(totalReceived)} − {formatCurrency(profitMetrics.totalCosts)} = {formatCurrency(profitMetrics.grossProfit)}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className={`text-3xl sm:text-4xl font-black font-mono tracking-tight ${profitMetrics.grossProfit >= 0 ? NEON_GREEN : NEON_RED}`}>
|
||||
{profitMetrics.grossProfit >= 0 ? '+' : ''}{formatCurrency(profitMetrics.grossProfit)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<span className={`text-xs font-bold px-2 py-0.5 rounded-full ${
|
||||
profitMetrics.grossMargin >= 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
|
||||
</span>
|
||||
</div>
|
||||
</NeoCard>
|
||||
|
||||
{/* Net Profit Breakdown */}
|
||||
<NeoCard
|
||||
spotlightColor={profitMetrics.netProfit >= 0 ? "rgba(57, 255, 20, 0.12)" : "rgba(255, 0, 60, 0.12)"}
|
||||
innerClassName="!p-5"
|
||||
>
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<DollarSign size={18} className={profitMetrics.netProfit >= 0 ? NEON_GREEN : NEON_RED} />
|
||||
<h4 className="text-xs font-mono font-bold text-zinc-500 dark:text-zinc-400 uppercase tracking-widest">Net Profit</h4>
|
||||
<TrendingUp size={18} className={profitMetrics.netProfit >= 0 ? NEON_GREEN : NEON_RED} />
|
||||
<h4 className="text-sm font-mono font-bold text-zinc-900 dark:text-white uppercase tracking-widest">Net Profit Breakdown</h4>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<button type="button" onClick={(e) => { e.stopPropagation(); setOpenTooltip(prev => prev === 'net' ? null : 'net'); }} className="p-1 rounded-lg hover:bg-zinc-100 dark:hover:bg-white/10 transition-colors">
|
||||
@@ -944,18 +1103,32 @@ const OwnerProjectDetail = () => {
|
||||
{openTooltip === 'net' && (
|
||||
<div className="absolute right-0 top-2 z-30 w-72 p-3 rounded-xl bg-white dark:bg-zinc-800 border border-zinc-200 dark:border-white/10 shadow-xl text-xs text-zinc-600 dark:text-zinc-300">
|
||||
<p className="font-bold text-zinc-900 dark:text-white mb-1">Net Profit Formula</p>
|
||||
<p className="font-mono text-[11px]">Gross Profit − Commission</p>
|
||||
<p className="font-mono text-[11px]">Total Payments Received − Total Expenses</p>
|
||||
<div className="mt-2 pt-2 border-t border-zinc-100 dark:border-white/5 space-y-1">
|
||||
<p>{formatCurrency(profitMetrics.grossProfit)} − {formatCurrency(profitMetrics.commission)} = {formatCurrency(profitMetrics.netProfit)}</p>
|
||||
<p>{formatCurrency(totalReceived)} − {formatCurrency(profitMetrics.totalExpenses)} = {formatCurrency(profitMetrics.netProfit)}</p>
|
||||
</div>
|
||||
<p className="mt-2 text-[10px] text-zinc-500">Total expenses include materials, labor, team commission, and subcontractor costs.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className={`text-3xl sm:text-4xl font-black font-mono tracking-tight ${profitMetrics.netProfit >= 0 ? NEON_GREEN : NEON_RED}`}>
|
||||
{profitMetrics.netProfit >= 0 ? '+' : ''}{formatCurrency(profitMetrics.netProfit)}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3 mb-4">
|
||||
<div className="bg-zinc-50 dark:bg-black/30 rounded-xl px-4 py-3 border border-zinc-100 dark:border-white/5">
|
||||
<p className="text-[10px] font-mono font-bold text-zinc-500 dark:text-zinc-500 uppercase tracking-widest mb-1">Payments Received</p>
|
||||
<p className="font-mono text-lg font-black text-emerald-600 dark:text-[#39ff14]">{formatCurrency(totalReceived)}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<div className="bg-zinc-50 dark:bg-black/30 rounded-xl px-4 py-3 border border-zinc-100 dark:border-white/5">
|
||||
<p className="text-[10px] font-mono font-bold text-zinc-500 dark:text-zinc-500 uppercase tracking-widest mb-1">− Total Expenses</p>
|
||||
<p className="font-mono text-lg font-black text-amber-600 dark:text-[#fda913]">{formatCurrency(profitMetrics.totalExpenses)}</p>
|
||||
</div>
|
||||
<div className={`rounded-xl px-4 py-3 border ${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'}`}>
|
||||
<p className="text-[10px] font-mono font-bold text-zinc-500 dark:text-zinc-500 uppercase tracking-widest mb-1">= Net Profit</p>
|
||||
<p className={`font-mono text-lg font-black ${profitMetrics.netProfit >= 0 ? NEON_GREEN : NEON_RED}`}>
|
||||
{profitMetrics.netProfit >= 0 ? '+' : ''}{formatCurrency(profitMetrics.netProfit)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`text-xs font-bold px-2 py-0.5 rounded-full ${
|
||||
profitMetrics.netMargin >= 0
|
||||
? 'text-green-600 dark:text-[#39ff14] bg-green-50 dark:bg-[#39ff14]/10 border border-green-200 dark:border-[#39ff14]/20'
|
||||
@@ -963,9 +1136,11 @@ const OwnerProjectDetail = () => {
|
||||
}`}>
|
||||
{profitMetrics.netMargin >= 0 ? '+' : ''}{profitMetrics.netMargin.toFixed(1)}% margin
|
||||
</span>
|
||||
<span className="text-[10px] font-mono text-zinc-500 dark:text-zinc-500">
|
||||
Outstanding: {formatCurrency(profitMetrics.outstandingExpenses)}
|
||||
</span>
|
||||
</div>
|
||||
</NeoCard>
|
||||
</div>
|
||||
|
||||
{/* Payments Received Table */}
|
||||
<NeoCard spotlightColor="rgba(57, 255, 20, 0.1)" innerClassName="p-0 lg:p-0">
|
||||
|
||||
Reference in New Issue
Block a user