diff --git a/src/components/leads/LeadJobSection.jsx b/src/components/leads/LeadJobSection.jsx index 7d6db1a..a320f27 100644 --- a/src/components/leads/LeadJobSection.jsx +++ b/src/components/leads/LeadJobSection.jsx @@ -62,9 +62,9 @@ export default function LeadJobSection({ formData, updateField, isQuickCapture } accent="blue" /> - {/* --- Door Knock → Canvasser selector --- */} + {/* --- Canvassers → Canvasser selector --- */} - {formData.leadSource === 'Door Knock' && ( + {formData.leadSource === 'Canvassers' && ( { + 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/components/owner/CreateItemModal.jsx b/src/components/owner/CreateItemModal.jsx index 9f39182..e2f47bc 100644 --- a/src/components/owner/CreateItemModal.jsx +++ b/src/components/owner/CreateItemModal.jsx @@ -2,7 +2,7 @@ import React, { useEffect, useState } from 'react'; import { createPortal } from 'react-dom'; import { X } from 'lucide-react'; -const CreateItemModal = ({ isOpen, onClose, title, icon: Icon, iconColor = "text-[#00f0ff]", iconBg = "bg-blue-500/10", fields, onSubmit, submitLabel = "Submit", submitColor = "bg-blue-500/20 text-[#00f0ff] border-blue-500/30" }) => { +const CreateItemModal = ({ isOpen, onClose, title, icon: Icon, iconColor = "text-[#00f0ff]", iconBg = "bg-blue-500/10", description, fields = [], onSubmit, submitLabel = "Submit", submitColor = "bg-blue-500/20 text-[#00f0ff] border-blue-500/30" }) => { const [formData, setFormData] = useState({}); useEffect(() => { @@ -44,6 +44,9 @@ const CreateItemModal = ({ isOpen, onClose, title, icon: Icon, iconColor = "text {/* Content */}
+ {description && ( +

{description}

+ )} {fields.map((field, i) => (
diff --git a/src/components/owner/SubcontractorPaymentsPanel.jsx b/src/components/owner/SubcontractorPaymentsPanel.jsx index 546b71c..c33fa7c 100644 --- a/src/components/owner/SubcontractorPaymentsPanel.jsx +++ b/src/components/owner/SubcontractorPaymentsPanel.jsx @@ -1,6 +1,10 @@ import React, { useMemo, useState, useRef, useEffect } from 'react'; -import { Wallet, Users, Search, ChevronDown, X, Check, FilterX } from 'lucide-react'; +import { useNavigate } from 'react-router-dom'; +import { Wallet, Users, Search, ChevronDown, ChevronRight, X, Check, FilterX } from 'lucide-react'; +// ───────────────────────────────────────────────────────────────────────────── +// Helpers +// ───────────────────────────────────────────────────────────────────────────── const CATEGORY_COLORS = { 'Electrical': 'text-cyan-500 dark:text-cyan-400 bg-cyan-500/10 border-cyan-500/20', 'Roofing': 'text-orange-500 dark:text-orange-400 bg-orange-500/10 border-orange-500/20', @@ -9,47 +13,67 @@ const CATEGORY_COLORS = { 'Windows & Glazing': 'text-teal-500 dark:text-teal-400 bg-teal-500/10 border-teal-500/20', }; -const PAYMENT_STATUS_COLORS = { - 'Paid': 'text-emerald-600 dark:text-emerald-400 bg-emerald-500/10 border-emerald-500/20', - 'Unpaid': 'text-amber-600 dark:text-amber-400 bg-amber-500/10 border-amber-500/20', +// Contractor-level status: Paid | Partially Paid | Unpaid +const CONTRACTOR_STATUS = { + Paid: 'text-emerald-700 dark:text-emerald-400 bg-emerald-500/10 border-emerald-500/25', + 'Partially Paid':'text-amber-700 dark:text-amber-400 bg-amber-500/10 border-amber-500/25', + Unpaid: 'text-red-700 dark:text-red-400 bg-red-500/10 border-red-500/25', +}; + +// Request-level status colours +const REQUEST_STATUS = { + Paid: 'text-emerald-600 dark:text-emerald-400 bg-emerald-500/10 border-emerald-500/20', + 'Partially Paid':'text-amber-600 dark:text-amber-400 bg-amber-500/10 border-amber-500/20', + Unpaid: 'text-red-600 dark:text-red-400 bg-red-500/10 border-red-500/20', }; function taskTotal(task) { const expenses = (task.expenses || []).reduce((s, e) => s + (Number(e.amount) || 0), 0); - const fees = (task.fees || []).reduce((s, f) => s + (Number(f.amount) || 0), 0); + const fees = (task.fees || []).reduce((s, f) => s + (Number(f.amount) || 0), 0); return expenses + fees; } +function taskPaid(task) { + return Number(task.amountPaid || 0); +} + const fmt = (amt) => new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', maximumFractionDigits: 0 }).format(Number(amt) || 0); const fmtDate = (iso) => { if (!iso) return '—'; - try { - return new Date(iso).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' }); - } catch { - return iso; - } + try { return new Date(iso).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' }); } + catch { return iso; } }; -// Dark-mode-aware dropdown — replaces native setNameQuery(e.target.value)} className="w-full bg-zinc-100 dark:bg-black/40 border border-zinc-200 dark:border-white/10 rounded-xl py-2 pl-9 pr-8 text-sm text-zinc-900 dark:text-white placeholder:text-zinc-400 dark:placeholder:text-zinc-500 outline-none focus:ring-2 focus:ring-amber-500/20 focus:border-amber-500/40" /> {nameQuery && ( - )}
- {/* Project searchable dropdown */} -
- - {projectOpen && ( -
-
- - setProjectQuery(e.target.value)} - className="w-full bg-zinc-50 dark:bg-black/40 border border-zinc-200 dark:border-white/10 rounded-lg py-1.5 pl-8 pr-2 text-sm text-zinc-900 dark:text-white placeholder:text-zinc-400 dark:placeholder:text-zinc-500 outline-none" - /> -
-
- - {filteredProjectOptions.length === 0 ? ( -

No projects match "{projectQuery}"

- ) : ( - filteredProjectOptions.map(p => { - const isSelected = p.name === projectFilter; - return ( - - ); - }) - )} -
-
- )} -
- - {/* Work Category filter */} + {/* Category filter */} - {/* Payment Status filter */} + {/* Contractor-status filter */} {hasActiveFilters && ( -
-
@@ -358,122 +522,155 @@ const SubcontractorPaymentsPanel = ({
)} - {rows.length === 0 ? ( + {/* Empty state */} + {contractorGroups.length === 0 ? (

No subcontractor payments yet.

Tasks with logged fees or expenses will appear here.

) : ( -
- - - - - - - - - - - - - - {filteredRows.length === 0 && ( - - - - )} - {filteredRows.map((row) => ( - - - - - - - - - - ))} - - - - - - - - - - - -
SubcontractorProjectWork CategoryAmount to PayRequestedPaid DatePayment Status
- -

No payments match the current filters.

+
+ + {/* ── Parent table header ── */} +
+ {['Contractor Name', 'Payment Requested', 'Amount Left to be Paid', 'Status', ''].map((h, i) => ( +
= 1 && i <= 2 ? 'text-right' : ''}`}> + {h} +
+ ))} +
+ + {filteredContractors.length === 0 ? ( +
+ +

No contractors match the current filters.

+ +
+ ) : ( +
+ {filteredContractors.map((c) => { + const remaining = Math.max(0, c.totalRequested - c.totalPaid); + const cStatus = contractorStatus(c.totalRequested, c.totalPaid); + const isExpanded = expanded.has(c.subId); + + return ( +
+ {/* ── Parent row — desktop grid ── */} -
-
-
- {row.name?.[0] || '?'} -
-
-
{row.name}
- {row.companyName && ( -
{row.companyName}
- )} -
-
-
-
-
- {row.projectName} -
- {row.projectAddress && ( -
- {row.projectAddress} + {/* Desktop */} +
+ {/* Contractor name */} +
+
+ {c.subName?.[0] || '?'} +
+
+
{c.subName}
+ {c.companyName &&
{c.companyName}
} +
+ {c.projects.length} project{c.projects.length !== 1 ? 's' : ''} · {allRequests.filter(r => r.subId === c.subId).length} request{allRequests.filter(r => r.subId === c.subId).length !== 1 ? 's' : ''} +
+
- )} -
-
- - {row.workCategory} - - - - {fmt(row.amount)} - - - - {fmtDate(row.requestedAt)} - - - - {fmtDate(row.paidAt)} - - - - {row.paymentStatus} - -
- {hasActiveFilters ? 'Filtered Total' : 'Total Amount'} - - - {fmt(total)} + {/* Payment Requested */} +
+ {fmt(c.totalRequested)} +
+ {/* Amount Left */} +
+ 0 ? 'text-red-500 dark:text-red-400' : 'text-zinc-400 dark:text-zinc-500'}`}> + {remaining > 0 ? fmt(remaining) : '—'} + +
+ {/* Status */} +
+ +
+ {/* Chevron */} +
+ {isExpanded + ? + : + } +
+ + + {/* Mobile card */} +
+
+
+
+ {c.subName?.[0] || '?'} +
+
+
{c.subName}
+ {c.companyName &&
{c.companyName}
} +
+
+
+ + {isExpanded + ? + : + } +
+
+
+
Requested: {fmt(c.totalRequested)}
+
Outstanding: 0 ? 'text-red-500 dark:text-red-400' : 'text-zinc-400'}`}>{remaining > 0 ? fmt(remaining) : '—'}
+
+
+ + + {/* ── Expanded detail ── */} + {isExpanded && ( +
+ +
+ )} + + ); + })} + + )} + + {/* Footer totals */} + {filteredContractors.length > 0 && ( +
+ {/* Desktop totals row */} +
+
+ {hasActiveFilters ? 'Filtered Total' : 'Grand Total'} +
+
+ {fmt(grandTotalRequested)} +
+
+ 0 ? 'text-red-500 dark:text-red-400' : 'text-zinc-400'}`}> + {grandTotalRemaining > 0 ? fmt(grandTotalRemaining) : '—'} -
-
- Outstanding (Unpaid) - - - {fmt(outstanding)} - - -
+
+
+
+ {/* Mobile totals */} +
+ {hasActiveFilters ? 'Filtered Total' : 'Grand Total'} +
+ {fmt(grandTotalRequested)} requested + {grandTotalRemaining > 0 && {fmt(grandTotalRemaining)} outstanding} +
+
+
+ )} )} diff --git a/src/data/mockStore.jsx b/src/data/mockStore.jsx index 293e4ce..57c5c69 100644 --- a/src/data/mockStore.jsx +++ b/src/data/mockStore.jsx @@ -2158,6 +2158,43 @@ const MOCK_PROJECTS = [ { name: 'City of Plano', category: 'Permits & Inspection', allocated: 2500, committed: 2000, actual: 2000 }, { name: 'Junk King DFW', category: 'Cleanup & Haul-Away', allocated: 4000, committed: 1500, actual: 0 } ], + // Cost breakdown / expenses for the table (requested amounts never exceed actual) + expenses: [ + { id: 'exp_001_1', type: 'material', name: 'ABC Supply Co.', category: 'Materials - Shingles', allocated: 15000, actual: 7000, paid: false, paymentRequests: [ + { id: 'req_001_1_1', requestDate: '2026-01-26', requestedAmount: 5000, amountPaid: 5000, paymentDate: '2026-01-28' }, + { id: 'req_001_1_2', requestDate: '2026-02-10', requestedAmount: 2000, amountPaid: 1000, paymentDate: '2026-02-12' } + ] }, + { id: 'exp_001_2', type: 'material', name: 'ABC Supply Co.', category: 'Materials - Underlayment', allocated: 3500, actual: 2500, paid: true, paymentRequests: [ + { id: 'req_001_2_1', requestDate: '2026-01-27', requestedAmount: 2500, amountPaid: 2500, paymentDate: '2026-01-30' } + ] }, + { id: 'exp_001_3', type: 'labor', name: 'Texas Roofing Crew A', category: 'Labor - Tear Off', allocated: 8000, actual: 5000, paid: true, paymentRequests: [ + { id: 'req_001_3_1', requestDate: '2026-01-29', requestedAmount: 3000, amountPaid: 3000, paymentDate: '2026-02-01' }, + { id: 'req_001_3_2', requestDate: '2026-02-03', requestedAmount: 2000, amountPaid: 2000, paymentDate: '2026-02-05' } + ] }, + { id: 'exp_001_4', type: 'labor', name: 'Texas Roofing Crew A', category: 'Labor - Install', allocated: 12000, actual: 0, paid: false, paymentRequests: [] }, + { id: 'exp_001_5', type: 'other', name: 'City of Plano', category: 'Permits & Inspection', allocated: 2500, actual: 2000, paid: true, paymentRequests: [ + { id: 'req_001_5_1', requestDate: '2026-01-18', requestedAmount: 2000, amountPaid: 2000, paymentDate: '2026-01-20' } + ] }, + { id: 'exp_001_6', type: 'other', name: 'Junk King DFW', category: 'Cleanup & Haul-Away', allocated: 4000, actual: 0, paid: false, paymentRequests: [] }, + { id: 'exp_001_7', type: 'team', name: 'Jesus Gonzales', category: 'Sales Rep', costType: 'Commission', allocated: 4200, actual: 4200, paid: true, paymentRequests: [ + { id: 'req_001_7_1', requestDate: '2026-02-06', requestedAmount: 4200, amountPaid: 4200, paymentDate: '2026-02-08' } + ] }, + { id: 'exp_001_8', type: 'team', name: 'Cody Tatum', category: 'Canvasser', costType: 'Hourly Wage', allocated: 1500, actual: 1500, paid: false, paymentRequests: [ + { id: 'req_001_8_1', requestDate: '2026-02-09', requestedAmount: 1500, amountPaid: 750, paymentDate: '2026-02-11' } + ] }, + { id: 'exp_001_9', type: 'subcontractor', name: 'Carlos Mendoza', category: 'Electrical', allocated: 1800, actual: 1800, paid: true, paymentRequests: [ + { id: 'req_001_9_1', requestDate: '2026-02-04', requestedAmount: 1800, amountPaid: 1800, paymentDate: '2026-02-06' } + ] }, + { id: 'exp_001_10', type: 'subcontractor', name: 'Maya Patel', category: 'Roofing', allocated: 2400, actual: 2400, paid: false, paymentRequests: [ + { id: 'req_001_10_1', requestDate: '2026-02-12', requestedAmount: 1200, amountPaid: 1200, paymentDate: '2026-02-14' }, + { id: 'req_001_10_2', requestDate: '2026-02-20', requestedAmount: 1200, amountPaid: 0, paymentDate: null } + ] } + ], + paymentsReceived: [ + { id: 'rcv_001_1', from: 'State Farm Insurance', method: 'ACH', amount: 18500, date: '2026-01-28', refNumber: 'ACH-2026-4411', memo: 'Initial claim payout — roof replacement' }, + { id: 'rcv_001_2', from: 'Justin Johnson', method: 'Check', amount: 2500, date: '2026-02-05', refNumber: 'CHK-8832', memo: 'Homeowner deductible payment' }, + { id: 'rcv_001_3', from: 'State Farm Insurance', method: 'ACH', amount: 6200, date: '2026-02-18', refNumber: 'ACH-2026-4499', memo: 'Supplement approval — additional scope' } + ], changeOrders: [], rfis: [], riskLog: [ @@ -2214,6 +2251,65 @@ const MOCK_PROJECTS = [ ], }, + // TEST PROJECT: Contract Signed with NO team members — used to verify the + // "Add Team Members First" guard when advancing to "Work In Progress". + { + id: 'PRJ-2026-TEST', + ownerId: 'own_001', + propertyId: 'P-2602', + address: 'TEST — 100 Demo Street, Plano, TX 75023', + contractorId: 'con_001', + subcontractorIds: [], + projectType: 'Roof Replacement', + status: 'active', + phase: 'Pre-Construction', + budget: 32000, + approvedBudget: 32000, + committedCost: 12000, + actualCost: 0, + spent: 0, + variancePercent: 0, + margin: 0.30, + commissionType: 'percent_gross', + commissionRate: 10, + commission: 3200, + startDate: '2026-06-01', + endDate: '2026-07-15', + completionPercentage: 40, + healthScore: 78, + openRFIs: 0, + changeOrderCount: 0, + pendingInvoiceCount: 0, + budgetBreakdown: [], + // Cost breakdown / expenses for the table (Contract Signed — allocated only, nothing spent yet) + expenses: [], + // No funds received yet at Contract Signed + paymentsReceived: [], + changeOrders: [], + rfis: [], + riskLog: [], + issueLog: [], + paymentSchedule: [ + { id: 'ps_test_1', milestone: 'Mobilization (20%)', amount: 6400, dueDate: '2026-06-05', status: 'pending' } + ], + activityTimeline: [ + { date: '2026-06-01', action: 'Contract Signed', user: 'Justin Johnson', details: 'Contract executed; awaiting team assignment.' } + ], + milestones: [], + invoices: [], + documents: [ + { name: 'Scope of Work - Roof Replacement', url: '#', type: 'contract' } + ], + teamMembers: [], + lifecycleStage: 'Contract Signed', + stageHistory: [ + { stage: 'Lead', date: '2026-05-15', by: 'Justin Johnson', note: 'Storm damage lead identified — TEST project.' }, + { stage: 'Damage Verified', date: '2026-05-20', by: 'Justin Johnson', note: 'Adjuster confirmed hail damage; claim approved.' }, + { stage: 'Scope Approved', date: '2026-05-25', by: 'Justin Johnson', note: 'Full roof replacement scope signed off.' }, + { stage: 'Contract Signed', date: '2026-06-01', by: 'Justin Johnson', note: 'Contract executed; mobilization deposit pending.' }, + ], + }, + // Project 2: Completed, zero variance (perfectly on budget) { id: 'PRJ-2026-002', @@ -7388,8 +7484,10 @@ const MOCK_SUBCONTRACTOR_TASKS = [ }, ], projectId: 'PRJ-2026-004', - paymentStatus: 'Unpaid', + paymentStatus: 'Partially Paid', paymentRequestedAt: '2026-05-16T17:30:00Z', + amountPaid: 350, + paidAt: '2026-05-20T11:00:00Z', createdAt: '2026-05-15T09:12:00Z', updatedAt: '2026-05-16T17:30:00Z', }, @@ -7484,6 +7582,7 @@ const MOCK_SUBCONTRACTOR_TASKS = [ projectId: 'PRJ-2026-003', paymentStatus: 'Unpaid', paymentRequestedAt: '2026-05-12T17:00:00Z', + amountPaid: 0, createdAt: '2026-05-10T15:40:00Z', updatedAt: '2026-05-12T17:00:00Z', }, @@ -7561,6 +7660,7 @@ const MOCK_SUBCONTRACTOR_TASKS = [ projectId: 'PRJ-2026-006', paymentStatus: 'Paid', paymentRequestedAt: '2026-05-14T17:35:00Z', + amountPaid: 232, paidAt: '2026-05-18T10:15:00Z', createdAt: '2026-05-05T11:00:00Z', updatedAt: '2026-05-14T17:30:00Z', @@ -7603,140 +7703,11 @@ const MOCK_SUBCONTRACTOR_TASKS = [ projectId: 'PRJ-2026-001', paymentStatus: 'Unpaid', paymentRequestedAt: null, + amountPaid: 0, createdAt: '2026-05-18T22:40:00Z', updatedAt: '2026-05-18T23:10:00Z', }, - // ------------------------------------------------------------------- - // sct_005 — Maya (sub_002) — Attic ventilation inspection, On Hold - // ------------------------------------------------------------------- - { - id: 'sct_005', - companyId: 'lynkeduppro', - companyName: 'LynkedUp Pro Roofing', - subcontractorId: 'sub_002', - subcontractorName: 'Maya Patel', - assignedBy: 'a1', - assignedByName: 'Wade Hollis', - title: 'Inspect attic ventilation', - location: '6909 Custer Rd, Plano, TX 75023', - description: 'Customer reports unusually high attic temps. Check ridge vent, soffit airflow, and intake/exhaust balance. Recommend remediation.', - dueDate: '2026-05-30', - priority: 'medium', - category: 'Inspection', - assignedDate: '2026-05-12T10:00:00Z', - status: 'On Hold', - photos: [ - { id: 'sct_005_p1', name: 'Attic_View.jpg', url: '/assets/images/properties/Roof_Inspection_Angle.jpg', annotation: 'Access from south gable' }, - ], - statusHistory: [ - { id: 'sct_005_h1', status: 'Assigned', actorId: 'a1', actorName: 'Wade Hollis', comment: 'Task created.', at: '2026-05-12T10:00:00Z' }, - { id: 'sct_005_h2', status: 'In Progress', actorId: 'sub_002', actorName: 'Maya Patel', comment: 'Initial pass done; baffles look blocked.', at: '2026-05-14T11:00:00Z' }, - { id: 'sct_005_h3', status: 'On Hold', actorId: 'sub_002', actorName: 'Maya Patel', comment: 'Waiting on homeowner to clear stored boxes from access path.', at: '2026-05-15T16:00:00Z' }, - ], - thread: [ - { id: 'sct_005_t1', channel: 'group', senderId: 'sub_002', senderName: 'Maya Patel', senderRole: 'Subcontractor', body: 'Access is blocked — homeowner needs to move some storage before I can complete the inspection.', at: '2026-05-15T15:55:00Z', mine: true }, - { id: 'sct_005_t2', channel: 'direct', senderId: 'a1', senderName: 'Wade Hollis', senderRole: 'Admin', body: 'I will reach out to the homeowner and reschedule.', at: '2026-05-15T16:20:00Z' }, - ], - expenses: [ - { id: 'sct_005_e1', description: 'Infrared thermometer (rental)', category: 'Equipment Rental', amount: 35.0, notes: '', receiptUrl: '', createdAt: '2026-05-14T10:30:00Z' }, - ], - fees: [ - { id: 'sct_005_f1', description: 'Inspection — initial visit', type: 'Inspection Fee', amount: 125, createdAt: '2026-05-14T13:00:00Z' }, - ], - activities: [ - { - id: 'sct_005_a1', type: 'status_change', status: 'Assigned', - note: 'Task created. Customer reports unusually high attic temps.', - photos: [], - actorId: 'a1', actorName: 'Wade Hollis', actorRole: 'Admin', - at: '2026-05-12T10:00:00Z', - }, - { - id: 'sct_005_a2', type: 'status_change', status: 'In Progress', - note: 'Initial inspection pass — soffit baffles appear blocked. Need attic access for confirmation.', - photos: [ - { id: 'sct_005_a2_p1', url: '/assets/images/properties/Roof_Inspection_Angle.jpg', name: 'south-gable.jpg' }, - ], - actorId: 'sub_002', actorName: 'Maya Patel', actorRole: 'Subcontractor', - at: '2026-05-14T11:00:00Z', - }, - { - id: 'sct_005_a3', type: 'status_change', status: 'On Hold', - note: 'Work stopped — homeowner needs to clear stored boxes blocking attic access before I can finish.', - photos: [ - { id: 'sct_005_a3_p1', url: '/assets/images/properties/Beige_Two_Story_House.jpg', name: 'access-blocked.jpg' }, - ], - actorId: 'sub_002', actorName: 'Maya Patel', actorRole: 'Subcontractor', - at: '2026-05-15T16:00:00Z', - }, - ], - projectId: 'PRJ-2026-007', - paymentStatus: 'Unpaid', - paymentRequestedAt: '2026-05-14T13:00:00Z', - createdAt: '2026-05-12T10:00:00Z', - updatedAt: '2026-05-15T16:20:00Z', - }, - - // ------------------------------------------------------------------- - // sct_006 — Maya (sub_002) — Gutter replacement, Pre-Work Inspection - // ------------------------------------------------------------------- - { - id: 'sct_006', - companyId: 'lynkeduppro', - companyName: 'LynkedUp Pro Roofing', - subcontractorId: 'sub_002', - subcontractorName: 'Maya Patel', - assignedBy: 'own_001', - assignedByName: 'Justin Johnson', - title: 'Replace gutter on south elevation', - location: '7224 Independence Pkwy, Plano, TX 75025', - description: '30 ft section of K-style gutter — pulling away from fascia after storm. Replace with new 6" oversized gutter, match existing bronze color.', - dueDate: '2026-06-02', - priority: 'low', - category: 'Gutters', - assignedDate: '2026-05-17T14:00:00Z', - status: 'Pre-Work Inspection', - photos: [ - { id: 'sct_006_p1', name: 'Gutter_Damage.jpg', url: '/assets/images/properties/Broken_Roof_Surface.jpg', annotation: 'Gutter pulling away from fascia — south elevation' }, - ], - statusHistory: [ - { id: 'sct_006_h1', status: 'Assigned', actorId: 'own_001', actorName: 'Justin Johnson', comment: 'Task created and assigned.', at: '2026-05-17T14:00:00Z' }, - { id: 'sct_006_h2', status: 'Pre-Work Inspection', actorId: 'sub_002', actorName: 'Maya Patel', comment: 'Site walk done — materials staged, access confirmed. Measured 32 ft run, bronze 6" gutter ordered.', at: '2026-05-26T10:00:00Z' }, - ], - thread: [ - { id: 'sct_006_t1', channel: 'group', senderId: 'sub_002', senderName: 'Maya Patel', senderRole: 'Subcontractor', body: 'On site for pre-work walkthrough. Gutter is pulling at four hangers — 32 ft run needs full replacement. Ordering materials now.', at: '2026-05-26T10:05:00Z', mine: true }, - { id: 'sct_006_t2', channel: 'group', senderId: 'own_001', senderName: 'Justin Johnson', senderRole: 'Owner', body: 'Perfect. Confirm when materials arrive and I will coordinate access with the homeowner.', at: '2026-05-26T10:30:00Z' }, - ], - expenses: [ - { id: 'sct_006_e1', description: '6" K-style gutter bronze (32 ft)', category: 'Materials', amount: 118.0, notes: 'Special order — delivery expected May 28.', receiptUrl: '', createdAt: '2026-05-26T11:00:00Z' }, - ], - fees: [], - activities: [ - { - id: 'sct_006_a1', type: 'status_change', status: 'Assigned', - note: 'Task created and assigned. 30 ft gutter section pulling away from fascia after storm damage.', - photos: [], - actorId: 'own_001', actorName: 'Justin Johnson', actorRole: 'Owner', - at: '2026-05-17T14:00:00Z', - }, - { - id: 'sct_006_a2', type: 'status_change', status: 'Pre-Work Inspection', - note: 'Site walk done — measured 32 ft run, confirmed bronze colour match, four failed hangers identified. Materials ordered, work scheduled for May 29.', - photos: [ - { id: 'sct_006_a2_p1', url: '/assets/images/properties/Broken_Roof_Surface.jpg', name: 'gutter-inspection.jpg' }, - ], - actorId: 'sub_002', actorName: 'Maya Patel', actorRole: 'Subcontractor', - at: '2026-05-26T10:00:00Z', - }, - ], - projectId: 'PRJ-2026-009', - paymentStatus: 'Unpaid', - paymentRequestedAt: null, - createdAt: '2026-05-17T14:00:00Z', - updatedAt: '2026-05-26T11:00:00Z', - }, - // ------------------------------------------------------------------- // sct_007 — Maya (sub_002) — completed earlier this month // ------------------------------------------------------------------- @@ -7812,6 +7783,7 @@ const MOCK_SUBCONTRACTOR_TASKS = [ projectId: 'PRJ-2026-001', paymentStatus: 'Paid', paymentRequestedAt: '2026-05-07T16:50:00Z', + amountPaid: 1089, paidAt: '2026-05-12T14:00:00Z', createdAt: '2026-05-01T09:00:00Z', updatedAt: '2026-05-07T17:00:00Z', @@ -7908,6 +7880,7 @@ const MOCK_SUBCONTRACTOR_TASKS = [ projectId: 'PRJ-2026-004', paymentStatus: 'Unpaid', paymentRequestedAt: '2026-05-19T12:35:00Z', + amountPaid: 0, createdAt: '2026-05-16T13:00:00Z', updatedAt: '2026-05-19T18:00:00Z', }, @@ -7996,61 +7969,268 @@ const MOCK_SUBCONTRACTOR_TASKS = [ projectId: 'PRJ-2026-006', paymentStatus: 'Unpaid', paymentRequestedAt: null, + amountPaid: 0, createdAt: '2026-05-20T09:00:00Z', updatedAt: '2026-05-27T11:05:00Z', }, // ------------------------------------------------------------------- - // sct_009 — Jennifer Castillo (sub_004) — Cancelled plumbing + // sct_011 — Maya Patel (sub_002) — full roof replacement PRJ-2026-001, PAID // ------------------------------------------------------------------- { - id: 'sct_009', + id: 'sct_011', + companyId: 'lynkeduppro', + companyName: 'LynkedUp Pro Roofing', + subcontractorId: 'sub_002', + subcontractorName: 'Maya Patel', + assignedBy: 'own_001', + assignedByName: 'Justin Johnson', + title: 'Full roof replacement — PRJ-001', + location: '2604 Dunwick Dr, Plano, TX 75023', + description: 'Insurance-approved full tear-off and replacement. 28 sq IKO Cambridge Charcoal. Includes ice & water shield at eaves.', + dueDate: '2026-04-20', + priority: 'high', + category: 'Roofing', + assignedDate: '2026-04-10T09:00:00Z', + status: 'Completed', + photos: [], + statusHistory: [ + { id: 'sct_011_h1', status: 'Assigned', actorId: 'own_001', actorName: 'Justin Johnson', comment: 'Task assigned.', at: '2026-04-10T09:00:00Z' }, + { id: 'sct_011_h2', status: 'Completed', actorId: 'sub_002', actorName: 'Maya Patel', comment: 'Roof complete.', at: '2026-04-18T15:00:00Z' }, + ], + thread: [], + expenses: [ + { id: 'sct_011_e1', description: 'IKO Cambridge Charcoal — 28 sq', category: 'Materials', amount: 2940, notes: '', receiptUrl: '', createdAt: '2026-04-11T08:00:00Z' }, + { id: 'sct_011_e2', description: 'Ice & water shield — 4 rolls', category: 'Materials', amount: 320, notes: '', receiptUrl: '', createdAt: '2026-04-11T08:10:00Z' }, + { id: 'sct_011_e3', description: 'Dumpster — 3 days', category: 'Disposal / Dumpster', amount: 420, notes: '', receiptUrl: '', createdAt: '2026-04-18T16:00:00Z' }, + { id: 'sct_011_e4', description: 'Fuel & crew transport', category: 'Travel / Fuel', amount: 140, notes: '', receiptUrl: '', createdAt: '2026-04-18T17:00:00Z' }, + ], + fees: [ + { id: 'sct_011_f1', description: 'Labour — full replacement (3-day crew)', type: 'Labour / Service Fee', amount: 4200, createdAt: '2026-04-18T15:30:00Z' }, + { id: 'sct_011_f2', description: 'Mobilization', type: 'Mobilization Fee', amount: 200, createdAt: '2026-04-10T09:00:00Z' }, + ], + activities: [], + projectId: 'PRJ-2026-001', + paymentStatus: 'Paid', + paymentRequestedAt: '2026-04-18T16:00:00Z', + amountPaid: 8220, + paidAt: '2026-04-25T10:00:00Z', + createdAt: '2026-04-10T09:00:00Z', + updatedAt: '2026-04-18T17:00:00Z', + }, + + // ------------------------------------------------------------------- + // sct_012 — Maya Patel (sub_002) — shingle repair PRJ-2026-003, Partially Paid + // ------------------------------------------------------------------- + { + id: 'sct_012', + companyId: 'lynkeduppro', + companyName: 'LynkedUp Pro Roofing', + subcontractorId: 'sub_002', + subcontractorName: 'Maya Patel', + assignedBy: 'a1', + assignedByName: 'Wade Hollis', + title: 'Hail damage shingle repair — PRJ-003', + location: '6613 Phoenix Pl, Plano, TX 75023', + description: 'Replace 6 damaged shingle sections on west slope identified during adjuster inspection.', + dueDate: '2026-05-10', + priority: 'medium', + category: 'Roofing', + assignedDate: '2026-05-02T09:00:00Z', + status: 'Completed', + photos: [], + statusHistory: [ + { id: 'sct_012_h1', status: 'Assigned', actorId: 'a1', actorName: 'Wade Hollis', comment: 'Assigned.', at: '2026-05-02T09:00:00Z' }, + { id: 'sct_012_h2', status: 'Completed', actorId: 'sub_002', actorName: 'Maya Patel', comment: 'Complete.', at: '2026-05-09T14:00:00Z' }, + ], + thread: [], + expenses: [ + { id: 'sct_012_e1', description: 'Materials — premium shingles, underlayment & sealant', category: 'Materials', amount: 1200, notes: '', receiptUrl: '', createdAt: '2026-05-03T07:30:00Z' }, + ], + fees: [ + { id: 'sct_012_f1', description: 'Labour — shingle repair and roof patch', type: 'Labour / Service Fee', amount: 3800, createdAt: '2026-05-09T14:10:00Z' }, + ], + activities: [], + projectId: 'PRJ-2026-003', + paymentStatus: 'Partially Paid', + paymentRequestedAt: '2026-05-09T15:00:00Z', + amountPaid: 4000, + paidAt: '2026-05-15T09:30:00Z', + createdAt: '2026-05-02T09:00:00Z', + updatedAt: '2026-05-09T15:00:00Z', + }, + + // ------------------------------------------------------------------- + // sct_013 — Carlos Mendoza (sub_001) — electrical rough-in PRJ-2026-001, PAID + // ------------------------------------------------------------------- + { + id: 'sct_013', + companyId: 'lynkeduppro', + companyName: 'LynkedUp Pro Roofing', + subcontractorId: 'sub_001', + subcontractorName: 'Carlos Mendoza', + assignedBy: 'own_001', + assignedByName: 'Justin Johnson', + title: 'Electrical rough-in — PRJ-001 addition', + location: '2604 Dunwick Dr, Plano, TX 75023', + description: 'Rough-in wiring for garage addition: 2 circuits, 4 outlets, overhead LED fixture prewire.', + dueDate: '2026-04-15', + priority: 'medium', + category: 'Electrical', + assignedDate: '2026-04-08T10:00:00Z', + status: 'Completed', + photos: [], + statusHistory: [ + { id: 'sct_013_h1', status: 'Assigned', actorId: 'own_001', actorName: 'Justin Johnson', comment: 'Assigned.', at: '2026-04-08T10:00:00Z' }, + { id: 'sct_013_h2', status: 'Completed', actorId: 'sub_001', actorName: 'Carlos Mendoza', comment: 'Complete.', at: '2026-04-14T16:00:00Z' }, + ], + thread: [], + expenses: [ + { id: 'sct_013_e1', description: '14/2 NM cable — 100 ft', category: 'Materials', amount: 68, notes: '', receiptUrl: '', createdAt: '2026-04-09T07:00:00Z' }, + { id: 'sct_013_e2', description: 'Outlet boxes + covers', category: 'Materials', amount: 32, notes: '', receiptUrl: '', createdAt: '2026-04-09T07:05:00Z' }, + { id: 'sct_013_e3', description: 'Conduit + fittings', category: 'Materials', amount: 41, notes: '', receiptUrl: '', createdAt: '2026-04-09T07:10:00Z' }, + ], + fees: [ + { id: 'sct_013_f1', description: 'Labour — rough-in (1.5 days)', type: 'Labour / Service Fee', amount: 640, createdAt: '2026-04-14T16:10:00Z' }, + { id: 'sct_013_f2', description: 'Mobilization', type: 'Mobilization Fee', amount: 75, createdAt: '2026-04-08T10:00:00Z' }, + ], + activities: [], + projectId: 'PRJ-2026-001', + paymentStatus: 'Paid', + paymentRequestedAt: '2026-04-14T17:00:00Z', + amountPaid: 856, + paidAt: '2026-04-20T10:00:00Z', + createdAt: '2026-04-08T10:00:00Z', + updatedAt: '2026-04-14T17:00:00Z', + }, + + // ------------------------------------------------------------------- + // sct_014 — Jennifer Castillo (sub_004) — plumbing PRJ-2026-004, Paid + // ------------------------------------------------------------------- + { + id: 'sct_014', companyId: 'lynkeduppro', companyName: 'LynkedUp Pro Roofing', subcontractorId: 'sub_004', subcontractorName: 'Jennifer Castillo', assignedBy: 'own_001', assignedByName: 'Justin Johnson', - title: 'Rough-in plumbing for ADU bathroom', - location: '6112 Roundrock Dr, Plano, TX 75025', - description: 'New ADU build — bathroom rough-in. Coordinate with framing inspection.', - dueDate: '2026-06-15', + title: 'Re-pipe master bath supply lines', + location: '2612 Dunwick Dr, Plano, TX 75023', + description: 'Replace old galvanized supply lines in master bath with 1/2" PEX. Two shut-offs, one shower valve, one sink valve.', + dueDate: '2026-04-30', priority: 'medium', category: 'Plumbing', - assignedDate: '2026-05-09T10:00:00Z', - status: 'Cancelled', + assignedDate: '2026-04-22T08:00:00Z', + status: 'Completed', photos: [], statusHistory: [ - { id: 'sct_009_h1', status: 'Assigned', actorId: 'own_001', actorName: 'Justin Johnson', comment: 'Task created and assigned.', at: '2026-05-09T10:00:00Z' }, - { id: 'sct_009_h2', status: 'Cancelled', actorId: 'own_001', actorName: 'Justin Johnson', comment: 'Homeowner cancelled the ADU project — entire scope suspended. Task voided, no further work required.', at: '2026-05-20T15:00:00Z' }, + { id: 'sct_014_h1', status: 'Assigned', actorId: 'own_001', actorName: 'Justin Johnson', comment: 'Assigned.', at: '2026-04-22T08:00:00Z' }, + { id: 'sct_014_h2', status: 'Completed', actorId: 'sub_004', actorName: 'Jennifer Castillo', comment: 'Complete.', at: '2026-04-28T15:00:00Z' }, ], - thread: [ - { id: 'sct_009_t1', channel: 'group', senderId: 'own_001', senderName: 'Justin Johnson', senderRole: 'Owner', body: 'Jennifer — unfortunately the homeowner has decided to cancel the ADU build entirely. No further work needed on this task. Apologies for the inconvenience.', at: '2026-05-20T15:02:00Z' }, - { id: 'sct_009_t2', channel: 'group', senderId: 'sub_004', senderName: 'Jennifer Castillo', senderRole: 'Subcontractor', body: 'Understood — no work had been started so no impact on our end. Thanks for the heads-up.', at: '2026-05-20T15:30:00Z', mine: true }, + thread: [], + expenses: [ + { id: 'sct_014_e1', description: '1/2" PEX — 50 ft', category: 'Materials', amount: 62, notes: '', receiptUrl: '', createdAt: '2026-04-23T07:30:00Z' }, + { id: 'sct_014_e2', description: 'PEX fittings + crimp rings', category: 'Materials', amount: 44, notes: '', receiptUrl: '', createdAt: '2026-04-23T07:35:00Z' }, + { id: 'sct_014_e3', description: 'Shower valve (Moen 2520)', category: 'Materials', amount: 128, notes: '', receiptUrl: '', createdAt: '2026-04-23T07:40:00Z' }, ], - expenses: [], - fees: [], - activities: [ - { - id: 'sct_009_a1', type: 'status_change', status: 'Assigned', - note: 'Task created. New ADU build — bathroom rough-in to coordinate with framing inspection.', - photos: [], - actorId: 'own_001', actorName: 'Justin Johnson', actorRole: 'Owner', - at: '2026-05-09T10:00:00Z', - }, - { - id: 'sct_009_a2', type: 'status_change', status: 'Cancelled', - note: 'Task cancelled — homeowner suspended the ADU project. No work was started.', - photos: [], - actorId: 'own_001', actorName: 'Justin Johnson', actorRole: 'Owner', - at: '2026-05-20T15:00:00Z', - }, + fees: [ + { id: 'sct_014_f1', description: 'Labour — master bath re-pipe', type: 'Labour / Service Fee', amount: 740, createdAt: '2026-04-28T15:10:00Z' }, ], - projectId: null, - paymentStatus: 'Unpaid', - paymentRequestedAt: null, - createdAt: '2026-05-09T10:00:00Z', - updatedAt: '2026-05-20T15:30:00Z', + activities: [], + projectId: 'PRJ-2026-004', + paymentStatus: 'Paid', + paymentRequestedAt: '2026-04-28T16:00:00Z', + amountPaid: 974, + paidAt: '2026-05-05T10:00:00Z', + createdAt: '2026-04-22T08:00:00Z', + updatedAt: '2026-04-28T16:00:00Z', + }, + + // ------------------------------------------------------------------- + // sct_016 — Dwayne Boudreaux (sub_003) — interior repaint PRJ-2026-004, Paid + // ------------------------------------------------------------------- + { + id: 'sct_016', + companyId: 'lynkeduppro', + companyName: 'LynkedUp Pro Roofing', + subcontractorId: 'sub_003', + subcontractorName: 'Dwayne Boudreaux', + assignedBy: 'own_001', + assignedByName: 'Justin Johnson', + title: 'Interior repaint — living & dining', + location: '2612 Dunwick Dr, Plano, TX 75023', + description: 'Repaint living room and dining area after drywall repair. 2 coats SW Accessible Beige. Mask trim.', + dueDate: '2026-05-05', + priority: 'low', + category: 'Painting', + assignedDate: '2026-04-28T11:00:00Z', + status: 'Completed', + photos: [], + statusHistory: [ + { id: 'sct_016_h1', status: 'Assigned', actorId: 'own_001', actorName: 'Justin Johnson', comment: 'Assigned.', at: '2026-04-28T11:00:00Z' }, + { id: 'sct_016_h2', status: 'Completed', actorId: 'sub_003', actorName: 'Dwayne Boudreaux', comment: 'Complete.', at: '2026-05-03T15:00:00Z' }, + ], + thread: [], + expenses: [ + { id: 'sct_016_e1', description: 'SW Accessible Beige — 2 gal', category: 'Materials', amount: 104, notes: '', receiptUrl: '', createdAt: '2026-04-29T08:00:00Z' }, + { id: 'sct_016_e2', description: 'Tape, drop cloths, roller kit', category: 'Materials', amount: 38, notes: '', receiptUrl: '', createdAt: '2026-04-29T08:05:00Z' }, + ], + fees: [ + { id: 'sct_016_f1', description: 'Labour — 2-day interior repaint', type: 'Labour / Service Fee', amount: 760, createdAt: '2026-05-03T15:10:00Z' }, + ], + activities: [], + projectId: 'PRJ-2026-004', + paymentStatus: 'Paid', + paymentRequestedAt: '2026-05-03T16:00:00Z', + amountPaid: 902, + paidAt: '2026-05-10T09:00:00Z', + createdAt: '2026-04-28T11:00:00Z', + updatedAt: '2026-05-03T16:00:00Z', + }, + + // ------------------------------------------------------------------- + // sct_018 — Greg Alston (sub_005) — window replacement PRJ-2026-003, Partially Paid + // ------------------------------------------------------------------- + { + id: 'sct_018', + companyId: 'lynkeduppro', + companyName: 'LynkedUp Pro Roofing', + subcontractorId: 'sub_005', + subcontractorName: 'Greg Alston', + assignedBy: 'own_001', + assignedByName: 'Justin Johnson', + title: 'Replace 3 hail-damaged windows — PRJ-003', + location: '6613 Phoenix Pl, Plano, TX 75023', + description: 'Insurance scope: replace 3 double-hung windows (front elevation). Source Andersen 400 series double-pane. Include disposal of broken units.', + dueDate: '2026-05-15', + priority: 'high', + category: 'Windows & Glazing', + assignedDate: '2026-05-03T09:00:00Z', + status: 'Completed', + photos: [], + statusHistory: [ + { id: 'sct_018_h1', status: 'Assigned', actorId: 'own_001', actorName: 'Justin Johnson', comment: 'Assigned.', at: '2026-05-03T09:00:00Z' }, + { id: 'sct_018_h2', status: 'Completed', actorId: 'sub_005', actorName: 'Greg Alston', comment: 'Complete.', at: '2026-05-13T15:00:00Z' }, + ], + thread: [], + expenses: [ + { id: 'sct_018_e1', description: 'Andersen 400 DH windows (qty 3)', category: 'Materials', amount: 1470, notes: '', receiptUrl: '', createdAt: '2026-05-04T08:00:00Z' }, + { id: 'sct_018_e2', description: 'Weatherstrip + sealant', category: 'Materials', amount: 55, notes: '', receiptUrl: '', createdAt: '2026-05-04T08:05:00Z' }, + { id: 'sct_018_e3', description: 'Disposal — broken units', category: 'Disposal / Dumpster', amount: 90, notes: '', receiptUrl: '', createdAt: '2026-05-13T16:00:00Z' }, + ], + fees: [ + { id: 'sct_018_f1', description: 'Labour — window replacement (2 days)', type: 'Labour / Service Fee', amount: 960, createdAt: '2026-05-13T15:10:00Z' }, + { id: 'sct_018_f2', description: 'Mobilization', type: 'Mobilization Fee', amount: 100, createdAt: '2026-05-03T09:00:00Z' }, + ], + activities: [], + projectId: 'PRJ-2026-003', + paymentStatus: 'Partially Paid', + paymentRequestedAt: '2026-05-13T16:00:00Z', + amountPaid: 1500, + paidAt: '2026-05-20T10:00:00Z', + createdAt: '2026-05-03T09:00:00Z', + updatedAt: '2026-05-13T16:00:00Z', }, ]; @@ -8073,20 +8253,6 @@ const MOCK_NOTIFICATIONS = [ taskId: 'sct_004', fromCompanyId: 'lynkeduppro', fromCompanyName: 'LynkedUp Pro Roofing', isRead: false, createdAt: '2026-05-18T22:40:00Z', }, - { - id: 'notif_003', recipientUserId: 'sub_002', recipientRole: 'SUBCONTRACTOR', - type: 'task_assigned', - message: 'You have been assigned a task by LynkedUp Pro Roofing — "Replace gutter on south elevation"', - taskId: 'sct_006', fromCompanyId: 'lynkeduppro', fromCompanyName: 'LynkedUp Pro Roofing', - isRead: false, createdAt: '2026-05-17T14:00:00Z', - }, - { - id: 'notif_004', recipientUserId: 'sub_002', recipientRole: 'SUBCONTRACTOR', - type: 'task_assigned', - message: 'You have been assigned a task by LynkedUp Pro Roofing — "Inspect attic ventilation"', - taskId: 'sct_005', fromCompanyId: 'lynkeduppro', fromCompanyName: 'LynkedUp Pro Roofing', - isRead: true, createdAt: '2026-05-12T10:00:00Z', - }, // -- Message Received -------------------------- { id: 'notif_005', recipientUserId: 'sub_002', recipientRole: 'SUBCONTRACTOR', @@ -8102,13 +8268,6 @@ const MOCK_NOTIFICATIONS = [ taskId: 'sct_004', fromCompanyId: 'lynkeduppro', fromCompanyName: 'LynkedUp Pro Roofing', isRead: false, createdAt: '2026-05-18T22:42:00Z', }, - { - id: 'notif_007', recipientUserId: 'sub_002', recipientRole: 'SUBCONTRACTOR', - type: 'message_received', - message: 'Wade Hollis added an internal note on sct_005 — "I will reach out to the homeowner…"', - taskId: 'sct_005', fromCompanyId: 'lynkeduppro', fromCompanyName: 'LynkedUp Pro Roofing', - isRead: true, createdAt: '2026-05-15T16:20:00Z', - }, // -- Task Updated ------------------------------ { id: 'notif_008', recipientUserId: 'sub_002', recipientRole: 'SUBCONTRACTOR', @@ -8139,13 +8298,6 @@ const MOCK_NOTIFICATIONS = [ taskId: 'sct_001', fromCompanyId: 'lynkeduppro', fromCompanyName: 'LynkedUp Pro Roofing', isRead: true, createdAt: '2026-05-16T08:30:00Z', }, - { - id: 'notif_012', recipientUserId: 'sub_002', recipientRole: 'SUBCONTRACTOR', - type: 'status_changed', - message: 'Status for sct_005 changed to On Hold by Maya Patel.', - taskId: 'sct_005', fromCompanyId: 'lynkeduppro', fromCompanyName: 'LynkedUp Pro Roofing', - isRead: true, createdAt: '2026-05-15T16:00:00Z', - }, { id: 'notif_013', recipientUserId: 'sub_002', recipientRole: 'SUBCONTRACTOR', type: 'status_changed', @@ -10143,9 +10295,9 @@ export const LEAD_FORM_OPTIONS = { 'HOA / Community', ], leadSources: [ - 'Door Knock', + 'Canvassers', 'Referral', - 'Storm Chase', + 'Storm Chase/Self-Gen', 'Online / Web', 'Mailer / Postcard', 'Sign Call', @@ -10274,51 +10426,243 @@ export const INITIAL_STORM_ZONES = [ export const MOCK_STORM_ATTRIBUTION_LEADS = [ // ── SE-001 · Apr 28 2026 · Severe · NE Plano ────────────────────────────── - { id: 'SAL-001', firstName: 'John', lastName: 'Martinez', address: '4821 Spring Creek Pkwy', city: 'Plano', state: 'TX', urgency: 'high', leadSource: 'Door Knock', status: 'Contacted', contractValue: null, createdAt: '2026-04-29T10:14:00Z', stormSource: { id: 'SE-001', areaName: 'NE Plano / Spring Creek Pkwy', date: '2026-04-28', maxHailSize: 2.5, severity: 'severe' } }, - { id: 'SAL-002', firstName: 'Sarah', lastName: 'Kim', address: '4905 Custer Rd', city: 'Plano', state: 'TX', urgency: 'high', leadSource: 'Door Knock', status: 'Appointed', contractValue: null, createdAt: '2026-04-29T14:32:00Z', stormSource: { id: 'SE-001', areaName: 'NE Plano / Spring Creek Pkwy', date: '2026-04-28', maxHailSize: 2.5, severity: 'severe' } }, - { id: 'SAL-003', firstName: 'Robert', lastName: 'Chen', address: '5012 Independence Pkwy', city: 'Plano', state: 'TX', urgency: 'high', leadSource: 'Door Knock', status: 'Closed', contractValue: 18500, createdAt: '2026-04-30T09:05:00Z', stormSource: { id: 'SE-001', areaName: 'NE Plano / Spring Creek Pkwy', date: '2026-04-28', maxHailSize: 2.5, severity: 'severe' } }, - { id: 'SAL-004', firstName: 'Maria', lastName: 'Garcia', address: '4720 Alma Dr', city: 'Plano', state: 'TX', urgency: 'high', leadSource: 'Door Knock', status: 'Closed', contractValue: 24200, createdAt: '2026-04-30T11:48:00Z', stormSource: { id: 'SE-001', areaName: 'NE Plano / Spring Creek Pkwy', date: '2026-04-28', maxHailSize: 2.5, severity: 'severe' } }, - { id: 'SAL-005', firstName: 'David', lastName: 'Thompson', address: '5130 Spring Creek Pkwy', city: 'Plano', state: 'TX', urgency: 'high', leadSource: 'Door Knock', status: 'Appointed', contractValue: null, createdAt: '2026-05-01T08:22:00Z', stormSource: { id: 'SE-001', areaName: 'NE Plano / Spring Creek Pkwy', date: '2026-04-28', maxHailSize: 2.5, severity: 'severe' } }, - { id: 'SAL-006', firstName: 'Lisa', lastName: 'Anderson', address: '4845 Custer Rd', city: 'Plano', state: 'TX', urgency: 'standard', leadSource: 'Door Knock', status: 'Contacted', contractValue: null, createdAt: '2026-05-01T13:10:00Z', stormSource: { id: 'SE-001', areaName: 'NE Plano / Spring Creek Pkwy', date: '2026-04-28', maxHailSize: 2.5, severity: 'severe' } }, - { id: 'SAL-007', firstName: 'James', lastName: 'Wilson', address: '5020 Alma Dr', city: 'Plano', state: 'TX', urgency: 'standard', leadSource: 'Door Knock', status: 'New', contractValue: null, createdAt: '2026-05-02T10:00:00Z', stormSource: { id: 'SE-001', areaName: 'NE Plano / Spring Creek Pkwy', date: '2026-04-28', maxHailSize: 2.5, severity: 'severe' } }, - { id: 'SAL-008', firstName: 'Emily', lastName: 'Davis', address: '4980 Spring Creek Pkwy', city: 'Plano', state: 'TX', urgency: 'standard', leadSource: 'Door Knock', status: 'Contacted', contractValue: null, createdAt: '2026-05-03T09:45:00Z', stormSource: { id: 'SE-001', areaName: 'NE Plano / Spring Creek Pkwy', date: '2026-04-28', maxHailSize: 2.5, severity: 'severe' } }, + { id: 'SAL-001', firstName: 'John', lastName: 'Martinez', address: '4821 Spring Creek Pkwy', city: 'Plano', state: 'TX', urgency: 'high', leadSource: 'Canvassers', status: 'Contacted', contractValue: null, createdAt: '2026-04-29T10:14:00Z', stormSource: { id: 'SE-001', areaName: 'NE Plano / Spring Creek Pkwy', date: '2026-04-28', maxHailSize: 2.5, severity: 'severe' } }, + { id: 'SAL-002', firstName: 'Sarah', lastName: 'Kim', address: '4905 Custer Rd', city: 'Plano', state: 'TX', urgency: 'high', leadSource: 'Canvassers', status: 'Appointed', contractValue: null, createdAt: '2026-04-29T14:32:00Z', stormSource: { id: 'SE-001', areaName: 'NE Plano / Spring Creek Pkwy', date: '2026-04-28', maxHailSize: 2.5, severity: 'severe' } }, + { id: 'SAL-003', firstName: 'Robert', lastName: 'Chen', address: '5012 Independence Pkwy', city: 'Plano', state: 'TX', urgency: 'high', leadSource: 'Canvassers', status: 'Closed', contractValue: 18500, createdAt: '2026-04-30T09:05:00Z', stormSource: { id: 'SE-001', areaName: 'NE Plano / Spring Creek Pkwy', date: '2026-04-28', maxHailSize: 2.5, severity: 'severe' } }, + { id: 'SAL-004', firstName: 'Maria', lastName: 'Garcia', address: '4720 Alma Dr', city: 'Plano', state: 'TX', urgency: 'high', leadSource: 'Canvassers', status: 'Closed', contractValue: 24200, createdAt: '2026-04-30T11:48:00Z', stormSource: { id: 'SE-001', areaName: 'NE Plano / Spring Creek Pkwy', date: '2026-04-28', maxHailSize: 2.5, severity: 'severe' } }, + { id: 'SAL-005', firstName: 'David', lastName: 'Thompson', address: '5130 Spring Creek Pkwy', city: 'Plano', state: 'TX', urgency: 'high', leadSource: 'Canvassers', status: 'Appointed', contractValue: null, createdAt: '2026-05-01T08:22:00Z', stormSource: { id: 'SE-001', areaName: 'NE Plano / Spring Creek Pkwy', date: '2026-04-28', maxHailSize: 2.5, severity: 'severe' } }, + { id: 'SAL-006', firstName: 'Lisa', lastName: 'Anderson', address: '4845 Custer Rd', city: 'Plano', state: 'TX', urgency: 'standard', leadSource: 'Canvassers', status: 'Contacted', contractValue: null, createdAt: '2026-05-01T13:10:00Z', stormSource: { id: 'SE-001', areaName: 'NE Plano / Spring Creek Pkwy', date: '2026-04-28', maxHailSize: 2.5, severity: 'severe' } }, + { id: 'SAL-007', firstName: 'James', lastName: 'Wilson', address: '5020 Alma Dr', city: 'Plano', state: 'TX', urgency: 'standard', leadSource: 'Canvassers', status: 'New', contractValue: null, createdAt: '2026-05-02T10:00:00Z', stormSource: { id: 'SE-001', areaName: 'NE Plano / Spring Creek Pkwy', date: '2026-04-28', maxHailSize: 2.5, severity: 'severe' } }, + { id: 'SAL-008', firstName: 'Emily', lastName: 'Davis', address: '4980 Spring Creek Pkwy', city: 'Plano', state: 'TX', urgency: 'standard', leadSource: 'Canvassers', status: 'Contacted', contractValue: null, createdAt: '2026-05-03T09:45:00Z', stormSource: { id: 'SE-001', areaName: 'NE Plano / Spring Creek Pkwy', date: '2026-04-28', maxHailSize: 2.5, severity: 'severe' } }, // ── SE-002 · Mar 15 2026 · Moderate · Central Plano ────────────────────── - { id: 'SAL-009', firstName: 'Michael', lastName: 'Brown', address: '1520 W 14th St', city: 'Plano', state: 'TX', urgency: 'standard', leadSource: 'Door Knock', status: 'Contacted', contractValue: null, createdAt: '2026-03-16T10:20:00Z', stormSource: { id: 'SE-002', areaName: 'Central Plano / W 15th St', date: '2026-03-15', maxHailSize: 1.2, severity: 'moderate' } }, - { id: 'SAL-010', firstName: 'Jennifer', lastName: 'Taylor', address: '1608 W 15th St', city: 'Plano', state: 'TX', urgency: 'standard', leadSource: 'Door Knock', status: 'Appointed', contractValue: null, createdAt: '2026-03-17T11:05:00Z', stormSource: { id: 'SE-002', areaName: 'Central Plano / W 15th St', date: '2026-03-15', maxHailSize: 1.2, severity: 'moderate' } }, - { id: 'SAL-011', firstName: 'Christopher',lastName: 'Moore', address: '1542 Ave K', city: 'Plano', state: 'TX', urgency: 'standard', leadSource: 'Door Knock', status: 'Closed', contractValue: 16800, createdAt: '2026-03-18T09:30:00Z', stormSource: { id: 'SE-002', areaName: 'Central Plano / W 15th St', date: '2026-03-15', maxHailSize: 1.2, severity: 'moderate' } }, - { id: 'SAL-012', firstName: 'Amanda', lastName: 'Jackson', address: '1615 W 15th St', city: 'Plano', state: 'TX', urgency: 'standard', leadSource: 'Door Knock', status: 'Contacted', contractValue: null, createdAt: '2026-03-19T14:00:00Z', stormSource: { id: 'SE-002', areaName: 'Central Plano / W 15th St', date: '2026-03-15', maxHailSize: 1.2, severity: 'moderate' } }, - { id: 'SAL-013', firstName: 'Daniel', lastName: 'White', address: '1480 W 14th St', city: 'Plano', state: 'TX', urgency: 'standard', leadSource: 'Door Knock', status: 'New', contractValue: null, createdAt: '2026-03-20T10:15:00Z', stormSource: { id: 'SE-002', areaName: 'Central Plano / W 15th St', date: '2026-03-15', maxHailSize: 1.2, severity: 'moderate' } }, + { id: 'SAL-009', firstName: 'Michael', lastName: 'Brown', address: '1520 W 14th St', city: 'Plano', state: 'TX', urgency: 'standard', leadSource: 'Canvassers', status: 'Contacted', contractValue: null, createdAt: '2026-03-16T10:20:00Z', stormSource: { id: 'SE-002', areaName: 'Central Plano / W 15th St', date: '2026-03-15', maxHailSize: 1.2, severity: 'moderate' } }, + { id: 'SAL-010', firstName: 'Jennifer', lastName: 'Taylor', address: '1608 W 15th St', city: 'Plano', state: 'TX', urgency: 'standard', leadSource: 'Canvassers', status: 'Appointed', contractValue: null, createdAt: '2026-03-17T11:05:00Z', stormSource: { id: 'SE-002', areaName: 'Central Plano / W 15th St', date: '2026-03-15', maxHailSize: 1.2, severity: 'moderate' } }, + { id: 'SAL-011', firstName: 'Christopher',lastName: 'Moore', address: '1542 Ave K', city: 'Plano', state: 'TX', urgency: 'standard', leadSource: 'Canvassers', status: 'Closed', contractValue: 16800, createdAt: '2026-03-18T09:30:00Z', stormSource: { id: 'SE-002', areaName: 'Central Plano / W 15th St', date: '2026-03-15', maxHailSize: 1.2, severity: 'moderate' } }, + { id: 'SAL-012', firstName: 'Amanda', lastName: 'Jackson', address: '1615 W 15th St', city: 'Plano', state: 'TX', urgency: 'standard', leadSource: 'Canvassers', status: 'Contacted', contractValue: null, createdAt: '2026-03-19T14:00:00Z', stormSource: { id: 'SE-002', areaName: 'Central Plano / W 15th St', date: '2026-03-15', maxHailSize: 1.2, severity: 'moderate' } }, + { id: 'SAL-013', firstName: 'Daniel', lastName: 'White', address: '1480 W 14th St', city: 'Plano', state: 'TX', urgency: 'standard', leadSource: 'Canvassers', status: 'New', contractValue: null, createdAt: '2026-03-20T10:15:00Z', stormSource: { id: 'SE-002', areaName: 'Central Plano / W 15th St', date: '2026-03-15', maxHailSize: 1.2, severity: 'moderate' } }, // ── SE-003 · Feb 3 2026 · Significant · SE Plano ───────────────────────── - { id: 'SAL-014', firstName: 'Jessica', lastName: 'Harris', address: '3201 Parker Rd', city: 'Plano', state: 'TX', urgency: 'high', leadSource: 'Door Knock', status: 'Appointed', contractValue: null, createdAt: '2026-02-04T09:00:00Z', stormSource: { id: 'SE-003', areaName: 'SE Plano / Parker Rd Corridor', date: '2026-02-03', maxHailSize: 1.75, severity: 'significant' } }, - { id: 'SAL-015', firstName: 'Ryan', lastName: 'Martinez', address: '3150 Parker Rd', city: 'Plano', state: 'TX', urgency: 'high', leadSource: 'Door Knock', status: 'Closed', contractValue: 21400, createdAt: '2026-02-05T11:30:00Z', stormSource: { id: 'SE-003', areaName: 'SE Plano / Parker Rd Corridor', date: '2026-02-03', maxHailSize: 1.75, severity: 'significant' } }, - { id: 'SAL-016', firstName: 'Ashley', lastName: 'Thompson', address: '3280 Heritage Pkwy', city: 'Plano', state: 'TX', urgency: 'high', leadSource: 'Door Knock', status: 'Closed', contractValue: 19900, createdAt: '2026-02-05T14:20:00Z', stormSource: { id: 'SE-003', areaName: 'SE Plano / Parker Rd Corridor', date: '2026-02-03', maxHailSize: 1.75, severity: 'significant' } }, - { id: 'SAL-017', firstName: 'Kevin', lastName: 'Johnson', address: '3190 Parker Rd', city: 'Plano', state: 'TX', urgency: 'standard', leadSource: 'Door Knock', status: 'Contacted', contractValue: null, createdAt: '2026-02-06T10:00:00Z', stormSource: { id: 'SE-003', areaName: 'SE Plano / Parker Rd Corridor', date: '2026-02-03', maxHailSize: 1.75, severity: 'significant' } }, - { id: 'SAL-018', firstName: 'Stephanie', lastName: 'Davis', address: '3220 Heritage Pkwy', city: 'Plano', state: 'TX', urgency: 'standard', leadSource: 'Door Knock', status: 'Appointed', contractValue: null, createdAt: '2026-02-07T09:30:00Z', stormSource: { id: 'SE-003', areaName: 'SE Plano / Parker Rd Corridor', date: '2026-02-03', maxHailSize: 1.75, severity: 'significant' } }, - { id: 'SAL-019', firstName: 'Brian', lastName: 'Wilson', address: '3160 Parker Rd', city: 'Plano', state: 'TX', urgency: 'standard', leadSource: 'Door Knock', status: 'Contacted', contractValue: null, createdAt: '2026-02-08T11:00:00Z', stormSource: { id: 'SE-003', areaName: 'SE Plano / Parker Rd Corridor', date: '2026-02-03', maxHailSize: 1.75, severity: 'significant' } }, + { id: 'SAL-014', firstName: 'Jessica', lastName: 'Harris', address: '3201 Parker Rd', city: 'Plano', state: 'TX', urgency: 'high', leadSource: 'Canvassers', status: 'Appointed', contractValue: null, createdAt: '2026-02-04T09:00:00Z', stormSource: { id: 'SE-003', areaName: 'SE Plano / Parker Rd Corridor', date: '2026-02-03', maxHailSize: 1.75, severity: 'significant' } }, + { id: 'SAL-015', firstName: 'Ryan', lastName: 'Martinez', address: '3150 Parker Rd', city: 'Plano', state: 'TX', urgency: 'high', leadSource: 'Canvassers', status: 'Closed', contractValue: 21400, createdAt: '2026-02-05T11:30:00Z', stormSource: { id: 'SE-003', areaName: 'SE Plano / Parker Rd Corridor', date: '2026-02-03', maxHailSize: 1.75, severity: 'significant' } }, + { id: 'SAL-016', firstName: 'Ashley', lastName: 'Thompson', address: '3280 Heritage Pkwy', city: 'Plano', state: 'TX', urgency: 'high', leadSource: 'Canvassers', status: 'Closed', contractValue: 19900, createdAt: '2026-02-05T14:20:00Z', stormSource: { id: 'SE-003', areaName: 'SE Plano / Parker Rd Corridor', date: '2026-02-03', maxHailSize: 1.75, severity: 'significant' } }, + { id: 'SAL-017', firstName: 'Kevin', lastName: 'Johnson', address: '3190 Parker Rd', city: 'Plano', state: 'TX', urgency: 'standard', leadSource: 'Canvassers', status: 'Contacted', contractValue: null, createdAt: '2026-02-06T10:00:00Z', stormSource: { id: 'SE-003', areaName: 'SE Plano / Parker Rd Corridor', date: '2026-02-03', maxHailSize: 1.75, severity: 'significant' } }, + { id: 'SAL-018', firstName: 'Stephanie', lastName: 'Davis', address: '3220 Heritage Pkwy', city: 'Plano', state: 'TX', urgency: 'standard', leadSource: 'Canvassers', status: 'Appointed', contractValue: null, createdAt: '2026-02-07T09:30:00Z', stormSource: { id: 'SE-003', areaName: 'SE Plano / Parker Rd Corridor', date: '2026-02-03', maxHailSize: 1.75, severity: 'significant' } }, + { id: 'SAL-019', firstName: 'Brian', lastName: 'Wilson', address: '3160 Parker Rd', city: 'Plano', state: 'TX', urgency: 'standard', leadSource: 'Canvassers', status: 'Contacted', contractValue: null, createdAt: '2026-02-08T11:00:00Z', stormSource: { id: 'SE-003', areaName: 'SE Plano / Parker Rd Corridor', date: '2026-02-03', maxHailSize: 1.75, severity: 'significant' } }, // ── SE-004 · Jan 20 2026 · Moderate · W Plano ──────────────────────────── - { id: 'SAL-020', firstName: 'Nicole', lastName: 'Garcia', address: '7200 Legacy Dr', city: 'Plano', state: 'TX', urgency: 'standard', leadSource: 'Door Knock', status: 'Contacted', contractValue: null, createdAt: '2026-01-21T10:00:00Z', stormSource: { id: 'SE-004', areaName: 'W Plano / Legacy West', date: '2026-01-20', maxHailSize: 1.0, severity: 'moderate' } }, - { id: 'SAL-021', firstName: 'Andrew', lastName: 'Kim', address: '7150 Legacy Dr', city: 'Plano', state: 'TX', urgency: 'standard', leadSource: 'Door Knock', status: 'Appointed', contractValue: null, createdAt: '2026-01-22T13:45:00Z', stormSource: { id: 'SE-004', areaName: 'W Plano / Legacy West', date: '2026-01-20', maxHailSize: 1.0, severity: 'moderate' } }, - { id: 'SAL-022', firstName: 'Michelle', lastName: 'Lee', address: '7210 Legacy Dr', city: 'Plano', state: 'TX', urgency: 'standard', leadSource: 'Door Knock', status: 'New', contractValue: null, createdAt: '2026-01-23T09:15:00Z', stormSource: { id: 'SE-004', areaName: 'W Plano / Legacy West', date: '2026-01-20', maxHailSize: 1.0, severity: 'moderate' } }, + { id: 'SAL-020', firstName: 'Nicole', lastName: 'Garcia', address: '7200 Legacy Dr', city: 'Plano', state: 'TX', urgency: 'standard', leadSource: 'Canvassers', status: 'Contacted', contractValue: null, createdAt: '2026-01-21T10:00:00Z', stormSource: { id: 'SE-004', areaName: 'W Plano / Legacy West', date: '2026-01-20', maxHailSize: 1.0, severity: 'moderate' } }, + { id: 'SAL-021', firstName: 'Andrew', lastName: 'Kim', address: '7150 Legacy Dr', city: 'Plano', state: 'TX', urgency: 'standard', leadSource: 'Canvassers', status: 'Appointed', contractValue: null, createdAt: '2026-01-22T13:45:00Z', stormSource: { id: 'SE-004', areaName: 'W Plano / Legacy West', date: '2026-01-20', maxHailSize: 1.0, severity: 'moderate' } }, + { id: 'SAL-022', firstName: 'Michelle', lastName: 'Lee', address: '7210 Legacy Dr', city: 'Plano', state: 'TX', urgency: 'standard', leadSource: 'Canvassers', status: 'New', contractValue: null, createdAt: '2026-01-23T09:15:00Z', stormSource: { id: 'SE-004', areaName: 'W Plano / Legacy West', date: '2026-01-20', maxHailSize: 1.0, severity: 'moderate' } }, // ── SE-005 · Dec 10 2025 · Trace · Allen / N Plano ─────────────────────── - { id: 'SAL-023', firstName: 'Steven', lastName: 'Clark', address: '405 N Greenville Ave', city: 'Allen', state: 'TX', urgency: 'standard', leadSource: 'Door Knock', status: 'Closed', contractValue: 12400, createdAt: '2025-12-11T10:30:00Z', stormSource: { id: 'SE-005', areaName: 'Allen / N Plano Border', date: '2025-12-10', maxHailSize: 0.8, severity: 'trace' } }, - { id: 'SAL-024', firstName: 'Melissa', lastName: 'Robinson', address: '415 N Greenville Ave', city: 'Allen', state: 'TX', urgency: 'standard', leadSource: 'Door Knock', status: 'Contacted', contractValue: null, createdAt: '2025-12-12T11:00:00Z', stormSource: { id: 'SE-005', areaName: 'Allen / N Plano Border', date: '2025-12-10', maxHailSize: 0.8, severity: 'trace' } }, + { id: 'SAL-023', firstName: 'Steven', lastName: 'Clark', address: '405 N Greenville Ave', city: 'Allen', state: 'TX', urgency: 'standard', leadSource: 'Canvassers', status: 'Closed', contractValue: 12400, createdAt: '2025-12-11T10:30:00Z', stormSource: { id: 'SE-005', areaName: 'Allen / N Plano Border', date: '2025-12-10', maxHailSize: 0.8, severity: 'trace' } }, + { id: 'SAL-024', firstName: 'Melissa', lastName: 'Robinson', address: '415 N Greenville Ave', city: 'Allen', state: 'TX', urgency: 'standard', leadSource: 'Canvassers', status: 'Contacted', contractValue: null, createdAt: '2025-12-12T11:00:00Z', stormSource: { id: 'SE-005', areaName: 'Allen / N Plano Border', date: '2025-12-10', maxHailSize: 0.8, severity: 'trace' } }, // ── SE-006 · Nov 5 2025 · Significant · S Plano ────────────────────────── - { id: 'SAL-025', firstName: 'Timothy', lastName: 'Walker', address: '1801 K Ave', city: 'Plano', state: 'TX', urgency: 'high', leadSource: 'Door Knock', status: 'Closed', contractValue: 31200, createdAt: '2025-11-06T09:00:00Z', stormSource: { id: 'SE-006', areaName: 'S Plano / Haggard Park Area', date: '2025-11-05', maxHailSize: 1.6, severity: 'significant' } }, - { id: 'SAL-026', firstName: 'Kimberly', lastName: 'Hall', address: '1750 J Ave', city: 'Plano', state: 'TX', urgency: 'high', leadSource: 'Door Knock', status: 'Closed', contractValue: 18700, createdAt: '2025-11-07T10:15:00Z', stormSource: { id: 'SE-006', areaName: 'S Plano / Haggard Park Area', date: '2025-11-05', maxHailSize: 1.6, severity: 'significant' } }, - { id: 'SAL-027', firstName: 'Joshua', lastName: 'Allen', address: '1820 K Ave', city: 'Plano', state: 'TX', urgency: 'high', leadSource: 'Door Knock', status: 'Closed', contractValue: 22900, createdAt: '2025-11-08T11:30:00Z', stormSource: { id: 'SE-006', areaName: 'S Plano / Haggard Park Area', date: '2025-11-05', maxHailSize: 1.6, severity: 'significant' } }, - { id: 'SAL-028', firstName: 'Amy', lastName: 'Young', address: '1780 J Ave', city: 'Plano', state: 'TX', urgency: 'standard', leadSource: 'Door Knock', status: 'Appointed', contractValue: null, createdAt: '2025-11-09T09:45:00Z', stormSource: { id: 'SE-006', areaName: 'S Plano / Haggard Park Area', date: '2025-11-05', maxHailSize: 1.6, severity: 'significant' } }, - { id: 'SAL-029', firstName: 'Brandon', lastName: 'King', address: '1810 K Ave', city: 'Plano', state: 'TX', urgency: 'standard', leadSource: 'Door Knock', status: 'Contacted', contractValue: null, createdAt: '2025-11-10T10:00:00Z', stormSource: { id: 'SE-006', areaName: 'S Plano / Haggard Park Area', date: '2025-11-05', maxHailSize: 1.6, severity: 'significant' } }, - { id: 'SAL-030', firstName: 'Heather', lastName: 'Wright', address: '1760 J Ave', city: 'Plano', state: 'TX', urgency: 'standard', leadSource: 'Door Knock', status: 'Contacted', contractValue: null, createdAt: '2025-11-11T13:20:00Z', stormSource: { id: 'SE-006', areaName: 'S Plano / Haggard Park Area', date: '2025-11-05', maxHailSize: 1.6, severity: 'significant' } }, - { id: 'SAL-031', firstName: 'Tyler', lastName: 'Scott', address: '1795 K Ave', city: 'Plano', state: 'TX', urgency: 'standard', leadSource: 'Door Knock', status: 'Contacted', contractValue: null, createdAt: '2025-11-12T09:00:00Z', stormSource: { id: 'SE-006', areaName: 'S Plano / Haggard Park Area', date: '2025-11-05', maxHailSize: 1.6, severity: 'significant' } }, + { id: 'SAL-025', firstName: 'Timothy', lastName: 'Walker', address: '1801 K Ave', city: 'Plano', state: 'TX', urgency: 'high', leadSource: 'Canvassers', status: 'Closed', contractValue: 31200, createdAt: '2025-11-06T09:00:00Z', stormSource: { id: 'SE-006', areaName: 'S Plano / Haggard Park Area', date: '2025-11-05', maxHailSize: 1.6, severity: 'significant' } }, + { id: 'SAL-026', firstName: 'Kimberly', lastName: 'Hall', address: '1750 J Ave', city: 'Plano', state: 'TX', urgency: 'high', leadSource: 'Canvassers', status: 'Closed', contractValue: 18700, createdAt: '2025-11-07T10:15:00Z', stormSource: { id: 'SE-006', areaName: 'S Plano / Haggard Park Area', date: '2025-11-05', maxHailSize: 1.6, severity: 'significant' } }, + { id: 'SAL-027', firstName: 'Joshua', lastName: 'Allen', address: '1820 K Ave', city: 'Plano', state: 'TX', urgency: 'high', leadSource: 'Canvassers', status: 'Closed', contractValue: 22900, createdAt: '2025-11-08T11:30:00Z', stormSource: { id: 'SE-006', areaName: 'S Plano / Haggard Park Area', date: '2025-11-05', maxHailSize: 1.6, severity: 'significant' } }, + { id: 'SAL-028', firstName: 'Amy', lastName: 'Young', address: '1780 J Ave', city: 'Plano', state: 'TX', urgency: 'standard', leadSource: 'Canvassers', status: 'Appointed', contractValue: null, createdAt: '2025-11-09T09:45:00Z', stormSource: { id: 'SE-006', areaName: 'S Plano / Haggard Park Area', date: '2025-11-05', maxHailSize: 1.6, severity: 'significant' } }, + { id: 'SAL-029', firstName: 'Brandon', lastName: 'King', address: '1810 K Ave', city: 'Plano', state: 'TX', urgency: 'standard', leadSource: 'Canvassers', status: 'Contacted', contractValue: null, createdAt: '2025-11-10T10:00:00Z', stormSource: { id: 'SE-006', areaName: 'S Plano / Haggard Park Area', date: '2025-11-05', maxHailSize: 1.6, severity: 'significant' } }, + { id: 'SAL-030', firstName: 'Heather', lastName: 'Wright', address: '1760 J Ave', city: 'Plano', state: 'TX', urgency: 'standard', leadSource: 'Canvassers', status: 'Contacted', contractValue: null, createdAt: '2025-11-11T13:20:00Z', stormSource: { id: 'SE-006', areaName: 'S Plano / Haggard Park Area', date: '2025-11-05', maxHailSize: 1.6, severity: 'significant' } }, + { id: 'SAL-031', firstName: 'Tyler', lastName: 'Scott', address: '1795 K Ave', city: 'Plano', state: 'TX', urgency: 'standard', leadSource: 'Canvassers', status: 'Contacted', contractValue: null, createdAt: '2025-11-12T09:00:00Z', stormSource: { id: 'SE-006', areaName: 'S Plano / Haggard Park Area', date: '2025-11-05', maxHailSize: 1.6, severity: 'significant' } }, // ── SE-007 · Sep 18 2025 · Moderate · Frisco / N Plano ─────────────────── - { id: 'SAL-032', firstName: 'Amber', lastName: 'Green', address: '12001 Coit Rd', city: 'Plano', state: 'TX', urgency: 'standard', leadSource: 'Door Knock', status: 'Closed', contractValue: 17600, createdAt: '2025-09-19T10:00:00Z', stormSource: { id: 'SE-007', areaName: 'Frisco / N Plano', date: '2025-09-18', maxHailSize: 1.1, severity: 'moderate' } }, - { id: 'SAL-033', firstName: 'Nathan', lastName: 'Adams', address: '12050 Coit Rd', city: 'Plano', state: 'TX', urgency: 'standard', leadSource: 'Door Knock', status: 'Closed', contractValue: 14300, createdAt: '2025-09-20T11:15:00Z', stormSource: { id: 'SE-007', areaName: 'Frisco / N Plano', date: '2025-09-18', maxHailSize: 1.1, severity: 'moderate' } }, - { id: 'SAL-034', firstName: 'Brittany', lastName: 'Baker', address: '12020 Coit Rd', city: 'Plano', state: 'TX', urgency: 'standard', leadSource: 'Door Knock', 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: 'Door Knock', 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' } }, + { id: 'SAL-032', firstName: 'Amber', lastName: 'Green', address: '12001 Coit Rd', city: 'Plano', state: 'TX', urgency: 'standard', leadSource: 'Canvassers', status: 'Closed', contractValue: 17600, createdAt: '2025-09-19T10:00:00Z', stormSource: { id: 'SE-007', areaName: 'Frisco / N Plano', date: '2025-09-18', maxHailSize: 1.1, severity: 'moderate' } }, + { id: 'SAL-033', firstName: 'Nathan', lastName: 'Adams', address: '12050 Coit Rd', city: 'Plano', state: 'TX', urgency: 'standard', leadSource: 'Canvassers', status: 'Closed', contractValue: 14300, createdAt: '2025-09-20T11:15:00Z', stormSource: { id: 'SE-007', areaName: 'Frisco / N Plano', date: '2025-09-18', maxHailSize: 1.1, severity: 'moderate' } }, + { 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, 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 % 3; + + // ── Case: actual > 0 and fully paid ────────────────────────────────────── + if (actual > 0 && paid) { + if (actual <= 1000) { + return [ + { + id: `req_${rowId}_1`, + requestDate: '2026-04-28', + requestedAmount: actual, + amountPaid: actual, + paymentDate: '2026-05-02', + } + ]; + } else { + const part1 = Math.round(actual * 0.6); + const part2 = actual - part1; + return [ + { + id: `req_${rowId}_1`, + requestDate: '2026-04-15', + requestedAmount: part1, + amountPaid: part1, + paymentDate: '2026-04-18', + }, + { + id: `req_${rowId}_2`, + requestDate: '2026-05-01', + requestedAmount: part2, + amountPaid: part2, + paymentDate: '2026-05-04', + } + ]; + } + } + + // ── 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 === 0) { + // One paid + one partially paid + const part2Paid = Math.round(part2 * 0.4); + return [ + { + id: `req_${rowId}_1`, + requestDate: '2026-05-05', + requestedAmount: part1, + amountPaid: part1, + paymentDate: '2026-05-08', + }, + { + id: `req_${rowId}_2`, + requestDate: '2026-05-18', + requestedAmount: part2, + amountPaid: part2Paid, + paymentDate: '2026-05-22', + } + ]; + } else if (seed === 1) { + // One paid + one fully unpaid + return [ + { + id: `req_${rowId}_1`, + requestDate: '2026-05-08', + requestedAmount: part1, + amountPaid: part1, + paymentDate: '2026-05-10', + }, + { + id: `req_${rowId}_2`, + 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) { + // Single partially paid request + const partPaid = Math.round(actual * 0.5); + return [ + { + id: `req_${rowId}_1`, + requestDate: '2026-05-12', + requestedAmount: actual, + amountPaid: partPaid, + paymentDate: '2026-05-15', + } + ]; + } else { + // Single unpaid request + return [ + { + id: `req_${rowId}_1`, + requestDate: '2026-05-14', + requestedAmount: actual, + amountPaid: 0, + paymentDate: null, + } + ]; + } + } + } + + // ── 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/CreateLeadPage.jsx b/src/pages/CreateLeadPage.jsx index 2f5f737..6ffd637 100644 --- a/src/pages/CreateLeadPage.jsx +++ b/src/pages/CreateLeadPage.jsx @@ -140,7 +140,7 @@ export default function CreateLeadPage() { ...INITIAL_FORM, city: 'Plano', state: 'TX', - leadSource: 'Door Knock', + leadSource: 'Canvassers', urgency, notes: `Storm zone canvass — ${stormSource.areaName}.${stormSource.maxHailSize ? ` ${stormSource.maxHailSize}" hail` : ''}${stormDate ? ` on ${stormDate}` : ''}.`, stormSource, @@ -186,7 +186,7 @@ export default function CreateLeadPage() { setFormData(prev => { const next = { ...prev, [field]: value }; if (field === 'leadSource') { - if (value !== 'Door Knock') { next.canvasserId = ''; next.canvasserName = ''; } + if (value !== 'Canvassers') { next.canvasserId = ''; next.canvasserName = ''; } if (value !== 'Referral') { next.referralNote = ''; } } return next; @@ -215,7 +215,7 @@ export default function CreateLeadPage() { if (!formData.address) missingFields.push('Street Address'); if (!formData.city) missingFields.push('City'); if (!formData.leadSource) missingFields.push('Lead Source'); - if (formData.leadSource === 'Door Knock' && !formData.canvasserId) missingFields.push('Canvasser'); + if (formData.leadSource === 'Canvassers' && !formData.canvasserId) missingFields.push('Canvasser'); if (missingFields.length > 0) { setValidationError(`Required: ${missingFields.join(', ')}`); diff --git a/src/pages/owner/OwnerProjectDetail.jsx b/src/pages/owner/OwnerProjectDetail.jsx index bc7446a..4276cb5 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 } from 'react-router-dom'; +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'; @@ -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'; @@ -114,7 +115,12 @@ function seedDocsMock(projectId) { return all.slice(0, 4 + seed); } -function seedPaymentsMock(projectId) { +function seedPaymentsMock(project) { + // Prefer per-project payments-received data when defined on the project (even if empty) + if (Array.isArray(project?.paymentsReceived)) return project.paymentsReceived; + + // Fallback (legacy) for any project not yet migrated — generic pool sliced by id + const projectId = project?.id || ''; const all = [ { id: 'pay_001', from: 'State Farm Insurance', method: 'ACH', amount: 18500, date: '2026-01-28', refNumber: 'ACH-2026-4411', memo: 'Initial claim payout — roof replacement' }, { id: 'pay_002', from: 'Justin Johnson', method: 'Check', amount: 2500, date: '2026-02-05', refNumber: 'CHK-8832', memo: 'Homeowner deductible payment' }, @@ -123,6 +129,7 @@ function seedPaymentsMock(projectId) { { id: 'pay_005', from: 'State Farm Insurance', method: 'Check', amount: 4100, date: '2026-03-12', refNumber: 'CHK-7761', memo: 'Final supplement payout' }, { id: 'pay_006', from: 'Justin Johnson', method: 'Cash', amount: 500, date: '2026-03-20', refNumber: '', memo: 'Gutter guard add-on' }, ]; + if (!projectId) return all.slice(0, 3); const seed = projectId.charCodeAt(projectId.length - 1) % 3; return all.slice(0, 3 + seed); } @@ -133,13 +140,24 @@ 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' }, }; const SUBCONTRACTOR_WORK_TYPES = ['Plumbing', 'Electrical', 'Painting', 'Roofing', 'Civil work', 'HVAC', 'Carpentry', 'Other']; +// Short labels for team cost types — shown inline in the Type badge (e.g. "Team Cost · Comm") +const COST_TYPE_ABBR = { + 'Commission': 'Comm', + 'Salary': 'Salary', + 'Bonus': 'Bonus', + 'Hourly Wage': 'Hourly', + 'Per Diem': 'Per Diem', + 'Referral Fee': 'Referral', + 'Overtime': 'OT', +}; + const inferExpenseType = (category = '') => { const c = (category || '').toLowerCase(); if (c.includes('material')) return 'material'; @@ -150,27 +168,56 @@ 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, - })); + // Prefer per-project expenses data when defined on the project (even if empty) + if (Array.isArray(project?.expenses)) { + return project.expenses.map((e, i) => { + const allocated = Number(e.allocated) || 0; + const actual = Number(e.actual) || 0; + return { + id: e.id || `exp_${i}`, + type: e.type || inferExpenseType(e.category), + name: e.name || '—', + category: e.category || '', + costType: e.costType || '', + allocated, + actual, + paid: e.paid ?? (actual > 0 && actual >= allocated * 0.9), + paymentRequests: e.paymentRequests || [], + }; + }); + } + + 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, allocated) + }; + }); 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); + { id: 'exp_team_1', type: 'team', name: 'Jesus Gonzales', category: 'Sales Rep', costType: 'Commission', allocated: 4200, actual: 4200, paid: true }, + { id: 'exp_team_2', type: 'team', name: 'Cody Tatum', category: 'Canvasser', costType: 'Hourly Wage', allocated: 1500, actual: 1500, paid: false }, + { id: 'exp_team_3', type: 'team', name: 'Sarah Calloway', category: 'Estimator', costType: 'Salary', allocated: 1200, actual: 0, paid: false }, + ].slice(0, 2 + seed).map(row => ({ + ...row, + 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 }, { 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, row.allocated) + })); return [...baseRows, ...teamRows, ...subRows]; } @@ -199,14 +246,23 @@ const OwnerProjectDetail = () => { } = useMockStore(); const navigate = useNavigate(); const { can } = usePermissions(); - const [activeTab, setActiveTab] = useState('overview'); + const [searchParams, setSearchParams] = useSearchParams(); + const [activeTab, setActiveTab] = useState(() => searchParams.get('tab') || 'overview'); + + useEffect(() => { + const tab = searchParams.get('tab'); + if (tab && tab !== activeTab) { + setActiveTab(tab); + } + }, [searchParams, activeTab]); const [selectedCO, setSelectedCO] = useState(null); const [selectedInvoice, setSelectedInvoice] = useState(null); const [selectedVersion, setSelectedVersion] = useState(null); const [createModalConfig, setCreateModalConfig] = useState(null); // ── Payments state ───────────────────────────────────────────────────────── - const [payments, setPayments] = useState(() => seedPaymentsMock(projectId)); + // Seeded from the resolved project (see effect below) so each project shows its own payments + const [payments, setPayments] = useState([]); const [paymentSort, setPaymentSort] = useState({ field: 'date', dir: 'desc' }); const [paymentPage, setPaymentPage] = useState(0); @@ -214,6 +270,79 @@ const OwnerProjectDetail = () => { const [expenseRows, setExpenseRows] = useState([]); const [expensesSeeded, setExpensesSeeded] = useState(false); + // ── Row Expansion state for Cost Breakdown (accordion — one open at a time) ── + const [expandedRowId, setExpandedRowId] = useState(null); + const toggleRowExpanded = (rowId) => { + 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) => { + const remaining = getRequestRemaining(req); + const paid = Number(req.amountPaid) || 0; + if (remaining <= 0) return 'Paid'; + if (paid > 0) return 'Partially Paid'; + 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); + }; + const getRowRemaining = (row) => { + const actual = Number(row.actual) || 0; + const totalPaid = getRowTotalPaid(row); + 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); + const totalRequested = getRowTotalRequested(row); + + if (actual === 0 && totalRequested === 0) { + return 'Unpaid'; + } + if (remaining <= 0) { + return 'Paid'; + } + if (totalPaid > 0) { + return 'Partially Paid'; + } + // Has payment requests but nothing paid + if (totalRequested > 0) { + return 'Unpaid'; + } + 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 ───────────────────────────────────────────── @@ -313,6 +442,7 @@ const OwnerProjectDetail = () => { useEffect(() => { if (project && !expensesSeeded) { setExpenseRows(seedExpenseRowsMock(project)); + setPayments(seedPaymentsMock(project)); setExpensesSeeded(true); } }, [project, expensesSeeded]); @@ -328,9 +458,11 @@ const OwnerProjectDetail = () => { type: type || data.type || 'other', name: data.name || '', category: data.category || '', + costType: data.costType || '', allocated: amt, actual: amt, paid: false, + paymentRequests: [], }; setExpenseRows(prev => [newRow, ...prev]); }; @@ -371,11 +503,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 }; @@ -554,7 +686,10 @@ const OwnerProjectDetail = () => { return (