From 4f57469e301c81e514874b2a0c0d4bca8174a3b2 Mon Sep 17 00:00:00 2001 From: Mayur Shinde Date: Fri, 5 Jun 2026 13:41:20 +0530 Subject: [PATCH] Project details: cost & breakdown section change table styling & added cost type dropdown in Team Cost model --- src/components/owner/AddTeamCostModal.jsx | 264 ++++++++++++++++++++++ src/data/mockStore.jsx | 141 +++++++++--- src/pages/owner/OwnerProjectDetail.jsx | 78 ++++--- 3 files changed, 424 insertions(+), 59 deletions(-) create mode 100644 src/components/owner/AddTeamCostModal.jsx 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 */} +
+
+ + {/* Team Member Name */} +
+ + setForm(f => ({ ...f, name: e.target.value }))} + /> +
+ + {/* Role / Category */} +
+ + setForm(f => ({ ...f, role: e.target.value }))} + /> +
+ + {/* Cost Type */} +
+ + setForm(f => ({ ...f, customCostType: e.target.value }))} + autoFocus + /> + + )} + +
+ + {/* Amount */} +
+ +
+ $ + setForm(f => ({ ...f, amount: e.target.value }))} + /> +
+
+ + {/* Live preview */} + {(form.name || resolvedCostType || form.amount) && ( +
+
+ +
+
+

+ {form.name || '—'} · {resolvedCostType || '—'} +

+

+ {form.amount ? `$${parseFloat(form.amount).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}` : '—'} +

+
+
+ )} +
+ + {/* Footer */} +
+ + +
+
+ +
+ )} + , + 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 = () => {