diff --git a/src/components/owner/AddTeamCostModal.jsx b/src/components/owner/AddTeamCostModal.jsx
new file mode 100644
index 0000000..9cac0f8
--- /dev/null
+++ b/src/components/owner/AddTeamCostModal.jsx
@@ -0,0 +1,264 @@
+import React, { useState, useEffect, useRef } from 'react';
+import { createPortal } from 'react-dom';
+import { X, Users, DollarSign } from 'lucide-react';
+import { motion, AnimatePresence } from 'framer-motion';
+import Select from '../ui/Select';
+
+const PRESET_COST_TYPES = [
+ { value: 'Commission', label: 'Commission' },
+ { value: 'Salary', label: 'Salary' },
+ { value: 'Bonus', label: 'Bonus' },
+ { value: 'Hourly Wage', label: 'Hourly Wage' },
+ { value: 'Per Diem', label: 'Per Diem' },
+ { value: 'Referral Fee', label: 'Referral Fee' },
+ { value: 'Overtime', label: 'Overtime' },
+];
+
+const inputBase =
+ 'w-full bg-zinc-50 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-purple-500 dark:focus:border-purple-400 focus:ring-1 focus:ring-purple-500/30 transition-all';
+
+const AddTeamCostModal = ({ isOpen, onClose, onSubmit }) => {
+ const [form, setForm] = useState({ name: '', role: '', costType: '', customCostType: '', amount: '' });
+ const [showCustomInput, setShowCustomInput] = useState(false);
+ const firstInputRef = useRef(null);
+
+ // Reset form on open
+ useEffect(() => {
+ if (isOpen) {
+ setForm({ name: '', role: '', costType: '', customCostType: '', amount: '' });
+ setShowCustomInput(false);
+ setTimeout(() => firstInputRef.current?.focus(), 80);
+ }
+ }, [isOpen]);
+
+ // Esc to close
+ useEffect(() => {
+ const handler = (e) => { if (e.key === 'Escape') onClose(); };
+ if (isOpen) window.addEventListener('keydown', handler);
+ return () => window.removeEventListener('keydown', handler);
+ }, [isOpen, onClose]);
+
+ if (!isOpen) return null;
+
+
+
+ const resolvedCostType = form.costType === '__custom__' ? form.customCostType.trim() : form.costType;
+
+ const handleSubmit = (e) => {
+ e.preventDefault();
+ if (!form.name.trim() || !form.role.trim() || !resolvedCostType || !form.amount) return;
+ onSubmit({
+ name: form.name.trim(),
+ category: form.role.trim(),
+ costType: resolvedCostType,
+ amount: form.amount,
+ });
+ onClose();
+ };
+
+ return createPortal(
+
+ {isOpen && (
+
+ {/* Backdrop */}
+
+
+ {/* Sheet / Dialog */}
+
e.stopPropagation()}
+ >
+ {/* Drag handle (mobile) */}
+
+
+ {/* Header */}
+
+
+
+
+
+
+
+ Add Team Cost
+
+
+ Record a team member expense
+
+
+
+
+
+
+ {/* Form */}
+
+
+
+ )}
+ ,
+ document.body
+ );
+};
+
+export default AddTeamCostModal;
diff --git a/src/data/mockStore.jsx b/src/data/mockStore.jsx
index 288efa1..fb7027f 100644
--- a/src/data/mockStore.jsx
+++ b/src/data/mockStore.jsx
@@ -10379,22 +10379,21 @@ export const MOCK_STORM_ATTRIBUTION_LEADS = [
{ id: 'SAL-035', firstName: 'Dylan', lastName: 'Nelson', address: '12080 Coit Rd', city: 'Plano', state: 'TX', urgency: 'standard', leadSource: 'Canvassers', status: 'New', contractValue: null, createdAt: '2025-09-22T10:45:00Z', stormSource: { id: 'SE-007', areaName: 'Frisco / N Plano', date: '2025-09-18', maxHailSize: 1.1, severity: 'moderate' } },
];
-export const generateMockPaymentRequests = (rowId, actual, paid) => {
- if (actual <= 0) return [];
-
+export const generateMockPaymentRequests = (rowId, actual, paid, allocated = 0) => {
// Extract a numeric seed from the rowId (e.g. 'exp_base_0' -> 0, 'exp_team_1' -> 1)
const seedNum = parseInt(rowId.replace(/[^0-9]/g, '')) || 0;
- const seed = seedNum % 2;
+ const seed = seedNum % 3;
- if (paid) {
+ // ── Case: actual > 0 and fully paid ──────────────────────────────────────
+ if (actual > 0 && paid) {
if (actual <= 1000) {
return [
{
id: `req_${rowId}_1`,
- requestDate: '2026-05-02',
+ requestDate: '2026-04-28',
requestedAmount: actual,
amountPaid: actual,
- paymentDate: '2026-05-05',
+ paymentDate: '2026-05-02',
}
];
} else {
@@ -10403,81 +10402,114 @@ export const generateMockPaymentRequests = (rowId, actual, paid) => {
return [
{
id: `req_${rowId}_1`,
- requestDate: '2026-04-20',
+ requestDate: '2026-04-15',
requestedAmount: part1,
amountPaid: part1,
- paymentDate: '2026-04-22',
+ paymentDate: '2026-04-18',
},
{
id: `req_${rowId}_2`,
- requestDate: '2026-05-05',
+ requestDate: '2026-05-01',
requestedAmount: part2,
amountPaid: part2,
- paymentDate: '2026-05-07',
+ paymentDate: '2026-05-04',
}
];
}
- } else {
+ }
+
+ // ── Case: actual > 0, not fully paid ─────────────────────────────────────
+ if (actual > 0 && !paid) {
if (actual > 2000) {
const part1 = Math.round(actual * 0.5);
const part2 = actual - part1;
-
- if (seed === 1) {
- // Return one paid request and one partially paid request
+
+ if (seed === 0) {
+ // One paid + one partially paid
const part2Paid = Math.round(part2 * 0.4);
return [
{
id: `req_${rowId}_1`,
- requestDate: '2026-05-10',
+ requestDate: '2026-05-05',
requestedAmount: part1,
amountPaid: part1,
- paymentDate: '2026-05-12',
+ paymentDate: '2026-05-08',
},
{
id: `req_${rowId}_2`,
- requestDate: '2026-05-20',
+ requestDate: '2026-05-18',
requestedAmount: part2,
amountPaid: part2Paid,
paymentDate: '2026-05-22',
}
];
- } else {
- // Return one paid request and one unpaid request
+ } else if (seed === 1) {
+ // One paid + one fully unpaid
return [
{
id: `req_${rowId}_1`,
- requestDate: '2026-05-10',
+ requestDate: '2026-05-08',
requestedAmount: part1,
amountPaid: part1,
- paymentDate: '2026-05-12',
+ paymentDate: '2026-05-10',
},
{
id: `req_${rowId}_2`,
- requestDate: '2026-05-20',
+ requestDate: '2026-05-22',
requestedAmount: part2,
amountPaid: 0,
paymentDate: null,
}
];
+ } else {
+ // Three splits: paid, partial, unpaid
+ const p1 = Math.round(actual * 0.4);
+ const p2 = Math.round(actual * 0.35);
+ const p3 = actual - p1 - p2;
+ const p2Paid = Math.round(p2 * 0.6);
+ return [
+ {
+ id: `req_${rowId}_1`,
+ requestDate: '2026-04-20',
+ requestedAmount: p1,
+ amountPaid: p1,
+ paymentDate: '2026-04-23',
+ },
+ {
+ id: `req_${rowId}_2`,
+ requestDate: '2026-05-05',
+ requestedAmount: p2,
+ amountPaid: p2Paid,
+ paymentDate: '2026-05-09',
+ },
+ {
+ id: `req_${rowId}_3`,
+ requestDate: '2026-05-20',
+ requestedAmount: p3,
+ amountPaid: 0,
+ paymentDate: null,
+ }
+ ];
}
} else {
if (seed === 1) {
- // Return a single partially paid request
+ // Single partially paid request
+ const partPaid = Math.round(actual * 0.5);
return [
{
id: `req_${rowId}_1`,
- requestDate: '2026-05-15',
+ requestDate: '2026-05-12',
requestedAmount: actual,
- amountPaid: Math.round(actual * 0.5),
- paymentDate: '2026-05-18',
+ amountPaid: partPaid,
+ paymentDate: '2026-05-15',
}
];
} else {
- // Return a single unpaid request
+ // Single unpaid request
return [
{
id: `req_${rowId}_1`,
- requestDate: '2026-05-15',
+ requestDate: '2026-05-14',
requestedAmount: actual,
amountPaid: 0,
paymentDate: null,
@@ -10486,4 +10518,55 @@ export const generateMockPaymentRequests = (rowId, actual, paid) => {
}
}
}
+
+ // ── Case: actual === 0 but allocated > 0 — pending / not-started ─────────
+ // Show a pending payment request for the allocated amount (requested but not yet started)
+ if (actual <= 0 && allocated > 0) {
+ if (seed === 0) {
+ // Fully pending (submitted, no payment yet)
+ return [
+ {
+ id: `req_${rowId}_1`,
+ requestDate: '2026-05-25',
+ requestedAmount: allocated,
+ amountPaid: 0,
+ paymentDate: null,
+ }
+ ];
+ } else if (seed === 1) {
+ // Two-part pending — initial deposit requested, balance pending
+ const deposit = Math.round(allocated * 0.3);
+ const balance = allocated - deposit;
+ return [
+ {
+ id: `req_${rowId}_1`,
+ requestDate: '2026-05-20',
+ requestedAmount: deposit,
+ amountPaid: 0,
+ paymentDate: null,
+ },
+ {
+ id: `req_${rowId}_2`,
+ requestDate: '2026-05-28',
+ requestedAmount: balance,
+ amountPaid: 0,
+ paymentDate: null,
+ }
+ ];
+ } else {
+ // Single pending with partial deposit paid upfront
+ const deposit = Math.round(allocated * 0.25);
+ return [
+ {
+ id: `req_${rowId}_1`,
+ requestDate: '2026-05-22',
+ requestedAmount: allocated,
+ amountPaid: deposit,
+ paymentDate: '2026-05-24',
+ }
+ ];
+ }
+ }
+
+ return [];
};
diff --git a/src/pages/owner/OwnerProjectDetail.jsx b/src/pages/owner/OwnerProjectDetail.jsx
index 467e697..fe165bc 100644
--- a/src/pages/owner/OwnerProjectDetail.jsx
+++ b/src/pages/owner/OwnerProjectDetail.jsx
@@ -20,6 +20,7 @@ import InvoiceDetailModal from '../../components/owner/InvoiceDetailModal';
import EstimateDetailModal from '../../components/estimates/EstimateDetailModal';
import CreateItemModal from '../../components/owner/CreateItemModal';
import CommissionSettingsModal from '../../components/owner/CommissionSettingsModal';
+import AddTeamCostModal from '../../components/owner/AddTeamCostModal';
import TeamManagementPanel from '../../components/owner/TeamManagementPanel';
import { usePermissions } from '../../hooks/usePermissions';
import { resolveCommission, findAssignedUserRoleKey } from '../../utils/commissionResolver';
@@ -133,7 +134,7 @@ const PAYMENTS_PER_PAGE = 5;
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' },
+ team: { label: 'Team Cost', 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' },
};
@@ -160,7 +161,7 @@ function seedExpenseRowsMock(project) {
const paid = Number(b.actual) > 0 && Number(b.actual) >= Number(b.allocated) * 0.9;
return {
id, type, name, category, allocated, actual, paid,
- paymentRequests: generateMockPaymentRequests(id, actual, paid)
+ paymentRequests: generateMockPaymentRequests(id, actual, paid, allocated)
};
});
const seed = (project?.id || '').charCodeAt((project?.id || 'x').length - 1) % 3;
@@ -170,7 +171,7 @@ function seedExpenseRowsMock(project) {
{ id: 'exp_team_3', type: 'team', name: 'Sarah Calloway', category: 'Estimator', allocated: 1200, actual: 0, paid: false },
].slice(0, 2 + seed).map(row => ({
...row,
- paymentRequests: generateMockPaymentRequests(row.id, row.actual, row.paid)
+ paymentRequests: generateMockPaymentRequests(row.id, row.actual, row.paid, row.allocated)
}));
const subRows = [
{ id: 'exp_sub_1', type: 'subcontractor', name: 'PlumbPro Services', category: 'Plumbing', allocated: 1800, actual: 1800, paid: true },
@@ -179,7 +180,7 @@ function seedExpenseRowsMock(project) {
{ id: 'exp_sub_4', type: 'subcontractor', name: 'Solid Civil Works', category: 'Civil work', allocated: 3200, actual: 0, paid: false },
].slice(0, 3 + seed).map(row => ({
...row,
- paymentRequests: generateMockPaymentRequests(row.id, row.actual, row.paid)
+ paymentRequests: generateMockPaymentRequests(row.id, row.actual, row.paid, row.allocated)
}));
return [...baseRows, ...teamRows, ...subRows];
}
@@ -232,20 +233,15 @@ const OwnerProjectDetail = () => {
const [expenseRows, setExpenseRows] = useState([]);
const [expensesSeeded, setExpensesSeeded] = useState(false);
- // ── Row Expansion state for Cost Breakdown ─────────────────────────────────
- const [expandedRowIds, setExpandedRowIds] = useState(new Set());
+ // ── Row Expansion state for Cost Breakdown (accordion — one open at a time) ──
+ const [expandedRowId, setExpandedRowId] = useState(null);
const toggleRowExpanded = (rowId) => {
- setExpandedRowIds(prev => {
- const next = new Set(prev);
- if (next.has(rowId)) {
- next.delete(rowId);
- } else {
- next.add(rowId);
- }
- return next;
- });
+ setExpandedRowId(prev => (prev === rowId ? null : rowId));
};
+ // ── Add Team Cost modal ───────────────────────────────────────────────────
+ const [addTeamCostOpen, setAddTeamCostOpen] = useState(false);
+
// Helper functions for payment requests & status calculations
const getRequestRemaining = (req) => (Number(req.requestedAmount) || 0) - (Number(req.amountPaid) || 0);
const getRequestStatus = (req) => {
@@ -256,6 +252,10 @@ const OwnerProjectDetail = () => {
return 'Unpaid';
};
+ const getRowTotalRequested = (row) => {
+ const reqs = row.paymentRequests || [];
+ return reqs.reduce((sum, r) => sum + (Number(r.requestedAmount) || 0), 0);
+ };
const getRowTotalPaid = (row) => {
const reqs = row.paymentRequests || [];
return reqs.reduce((sum, r) => sum + (Number(r.amountPaid) || 0), 0);
@@ -263,14 +263,20 @@ const OwnerProjectDetail = () => {
const getRowRemaining = (row) => {
const actual = Number(row.actual) || 0;
const totalPaid = getRowTotalPaid(row);
- return Math.max(0, actual - totalPaid);
+ if (actual > 0) {
+ return Math.max(0, actual - totalPaid);
+ }
+ // For zero-actual rows, use total requested - total paid from payment requests
+ const totalRequested = getRowTotalRequested(row);
+ return Math.max(0, totalRequested - totalPaid);
};
const getRowStatus = (row) => {
const actual = Number(row.actual) || 0;
const totalPaid = getRowTotalPaid(row);
const remaining = getRowRemaining(row);
-
- if (actual === 0) {
+ const totalRequested = getRowTotalRequested(row);
+
+ if (actual === 0 && totalRequested === 0) {
return 'Unpaid';
}
if (remaining <= 0) {
@@ -279,6 +285,10 @@ const OwnerProjectDetail = () => {
if (totalPaid > 0) {
return 'Partially Paid';
}
+ // Has payment requests but nothing paid
+ if (totalRequested > 0) {
+ return 'Unpaid';
+ }
return 'Unpaid';
};
@@ -1092,16 +1102,7 @@ const OwnerProjectDetail = () => {