diff --git a/src/data/mockStore.jsx b/src/data/mockStore.jsx index fd0a296..288efa1 100644 --- a/src/data/mockStore.jsx +++ b/src/data/mockStore.jsx @@ -10378,3 +10378,112 @@ export const MOCK_STORM_ATTRIBUTION_LEADS = [ { id: 'SAL-034', firstName: 'Brittany', lastName: 'Baker', address: '12020 Coit Rd', city: 'Plano', state: 'TX', urgency: 'standard', leadSource: 'Canvassers', status: 'Contacted', contractValue: null, createdAt: '2025-09-21T09:30:00Z', stormSource: { id: 'SE-007', areaName: 'Frisco / N Plano', date: '2025-09-18', maxHailSize: 1.1, severity: 'moderate' } }, { 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 []; + + // 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; + + if (paid) { + if (actual <= 1000) { + return [ + { + id: `req_${rowId}_1`, + requestDate: '2026-05-02', + requestedAmount: actual, + amountPaid: actual, + paymentDate: '2026-05-05', + } + ]; + } else { + const part1 = Math.round(actual * 0.6); + const part2 = actual - part1; + return [ + { + id: `req_${rowId}_1`, + requestDate: '2026-04-20', + requestedAmount: part1, + amountPaid: part1, + paymentDate: '2026-04-22', + }, + { + id: `req_${rowId}_2`, + requestDate: '2026-05-05', + requestedAmount: part2, + amountPaid: part2, + paymentDate: '2026-05-07', + } + ]; + } + } else { + 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 + const part2Paid = Math.round(part2 * 0.4); + return [ + { + id: `req_${rowId}_1`, + requestDate: '2026-05-10', + requestedAmount: part1, + amountPaid: part1, + paymentDate: '2026-05-12', + }, + { + id: `req_${rowId}_2`, + requestDate: '2026-05-20', + requestedAmount: part2, + amountPaid: part2Paid, + paymentDate: '2026-05-22', + } + ]; + } else { + // Return one paid request and one unpaid request + return [ + { + id: `req_${rowId}_1`, + requestDate: '2026-05-10', + requestedAmount: part1, + amountPaid: part1, + paymentDate: '2026-05-12', + }, + { + id: `req_${rowId}_2`, + requestDate: '2026-05-20', + requestedAmount: part2, + amountPaid: 0, + paymentDate: null, + } + ]; + } + } else { + if (seed === 1) { + // Return a single partially paid request + return [ + { + id: `req_${rowId}_1`, + requestDate: '2026-05-15', + requestedAmount: actual, + amountPaid: Math.round(actual * 0.5), + paymentDate: '2026-05-18', + } + ]; + } else { + // Return a single unpaid request + return [ + { + id: `req_${rowId}_1`, + requestDate: '2026-05-15', + requestedAmount: actual, + amountPaid: 0, + paymentDate: null, + } + ]; + } + } + } +}; diff --git a/src/pages/owner/OwnerProjectDetail.jsx b/src/pages/owner/OwnerProjectDetail.jsx index 20c1cf5..467e697 100644 --- a/src/pages/owner/OwnerProjectDetail.jsx +++ b/src/pages/owner/OwnerProjectDetail.jsx @@ -1,11 +1,11 @@ import React, { useState, useMemo, useRef, useEffect } from 'react'; import { useParams, useNavigate, useSearchParams } from 'react-router-dom'; import { useAuth } from '../../context/AuthContext'; -import { useMockStore } from '../../data/mockStore'; +import { useMockStore, generateMockPaymentRequests } from '../../data/mockStore'; import { LIFECYCLE_STAGES, REWORK_STAGE, nextStage, prevStage, stageToPct } from '../../data/lifecycle'; import { SpotlightCard } from '../../components/SpotlightCard'; import { AnimatedCounter } from '../../components/AnimatedCounter'; -import { motion } from 'framer-motion'; +import { motion, AnimatePresence } from 'framer-motion'; import { BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer } from 'recharts'; @@ -150,27 +150,37 @@ const inferExpenseType = (category = '') => { }; 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 baseRows = (project?.budgetBreakdown || []).map((b, i) => { + const id = `exp_base_${i}`; + const type = inferExpenseType(b.category); + const name = b.name || '—'; + const category = b.category || ''; + const allocated = Number(b.allocated) || 0; + const actual = Number(b.actual) || 0; + 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) + }; + }); 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: 'Cody Tatum', category: 'Canvasser', allocated: 1500, actual: 1500, paid: false }, { id: 'exp_team_3', type: 'team', name: 'Sarah Calloway', category: 'Estimator', allocated: 1200, actual: 0, paid: false }, - ].slice(0, 2 + seed); + ].slice(0, 2 + seed).map(row => ({ + ...row, + paymentRequests: generateMockPaymentRequests(row.id, row.actual, row.paid) + })); 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); + ].slice(0, 3 + seed).map(row => ({ + ...row, + paymentRequests: generateMockPaymentRequests(row.id, row.actual, row.paid) + })); return [...baseRows, ...teamRows, ...subRows]; } @@ -222,6 +232,70 @@ const OwnerProjectDetail = () => { const [expenseRows, setExpenseRows] = useState([]); const [expensesSeeded, setExpensesSeeded] = useState(false); + // ── Row Expansion state for Cost Breakdown ───────────────────────────────── + const [expandedRowIds, setExpandedRowIds] = useState(new Set()); + const toggleRowExpanded = (rowId) => { + setExpandedRowIds(prev => { + const next = new Set(prev); + if (next.has(rowId)) { + next.delete(rowId); + } else { + next.add(rowId); + } + return next; + }); + }; + + // Helper functions for payment requests & status calculations + const getRequestRemaining = (req) => (Number(req.requestedAmount) || 0) - (Number(req.amountPaid) || 0); + const getRequestStatus = (req) => { + const remaining = getRequestRemaining(req); + const paid = Number(req.amountPaid) || 0; + if (remaining <= 0) return 'Paid'; + if (paid > 0) return 'Partially Paid'; + return 'Unpaid'; + }; + + const getRowTotalPaid = (row) => { + const reqs = row.paymentRequests || []; + return reqs.reduce((sum, r) => sum + (Number(r.amountPaid) || 0), 0); + }; + const getRowRemaining = (row) => { + const actual = Number(row.actual) || 0; + const totalPaid = getRowTotalPaid(row); + return Math.max(0, actual - totalPaid); + }; + const getRowStatus = (row) => { + const actual = Number(row.actual) || 0; + const totalPaid = getRowTotalPaid(row); + const remaining = getRowRemaining(row); + + if (actual === 0) { + return 'Unpaid'; + } + if (remaining <= 0) { + return 'Paid'; + } + if (totalPaid > 0) { + return 'Partially Paid'; + } + return 'Unpaid'; + }; + + const processedExpenseRows = useMemo(() => { + return expenseRows.map(row => { + const remaining = getRowRemaining(row); + const status = getRowStatus(row); + const paid = Number(row.actual) > 0 && remaining === 0; + return { + ...row, + remaining, + status, + paid + }; + }); + }, [expenseRows]); + const [openTooltip, setOpenTooltip] = useState(null); // ── Commission config state ───────────────────────────────────────────── @@ -339,6 +413,7 @@ const OwnerProjectDetail = () => { allocated: amt, actual: amt, paid: false, + paymentRequests: [], }; setExpenseRows(prev => [newRow, ...prev]); }; @@ -379,11 +454,11 @@ 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 total = processedExpenseRows.reduce((s, r) => s + (Number(r.actual) || Number(r.allocated) || 0), 0); + const paid = processedExpenseRows.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]); + }, [processedExpenseRows]); const profitMetrics = useMemo(() => { if (!project) return { totalCosts: 0, totalExpenses: 0, paidExpenses: 0, outstandingExpenses: 0, totalBudget: 0, commission: 0, commissionFormula: '', grossProfit: 0, netProfit: 0, grossMargin: 0, netMargin: 0 }; @@ -1072,47 +1147,120 @@ const OwnerProjectDetail = () => { Category / Role Allocated Actual - Paid + Remaining + Status + - {expenseRows.map((row) => { + {processedExpenseRows.map((row) => { const cfg = EXPENSE_TYPE_CONFIG[row.type] || EXPENSE_TYPE_CONFIG.other; const TypeIcon = cfg.icon; + const isExpanded = expandedRowIds.has(row.id); return ( - - - - - {cfg.label} - - - {row.name || '—'} - {row.category || '—'} - {formatCurrency(row.allocated)} - {formatCurrency(row.actual)} - - - - + + {row.name || '—'} + {row.category || '—'} + {formatCurrency(row.allocated)} + {formatCurrency(row.actual)} + {formatCurrency(row.remaining)} + + + {row.status} + + + + + + + + {isExpanded && ( + + + +
+

Payment Request History

+ {row.paymentRequests && row.paymentRequests.length > 0 ? ( +
+ + + + + + + + + + + + + {row.paymentRequests.map((req) => { + const reqRemaining = getRequestRemaining(req); + const reqStatus = getRequestStatus(req); + return ( + + + + + + + + + ); + })} + +
Request DateRequested AmountAmount PaidRemainingPayment DateStatus
{req.requestDate || '—'}{formatCurrency(req.requestedAmount)}{formatCurrency(req.amountPaid)}{formatCurrency(reqRemaining)}{req.paymentDate || '—'} + + {reqStatus} + +
+
+ ) : ( +

No payment requests recorded for this expense.

+ )} +
+
+ + + )} +
+ ); })} - {expenseRows.length === 0 && ( + {processedExpenseRows.length === 0 && ( - + No expenses recorded yet. @@ -1124,13 +1272,16 @@ const OwnerProjectDetail = () => { Totals - {formatCurrency(expenseRows.reduce((s, r) => s + (Number(r.allocated) || 0), 0))} + {formatCurrency(processedExpenseRows.reduce((s, r) => s + (Number(r.allocated) || 0), 0))} {formatCurrency(profitMetrics.totalExpenses)} - - {expenseRows.filter(r => r.paid).length}/{expenseRows.length} + + {formatCurrency(processedExpenseRows.reduce((s, r) => s + r.remaining, 0))} + + + {processedExpenseRows.filter(r => r.paid).length}/{processedExpenseRows.length}