Merge pull request 'Subcontractor module changes' (#2) from subcontractor-module-changes into revamp

Reviewed-on: AITeam/LynkedUpPro_CRM_Demo_Old#2
This commit is contained in:
2026-06-09 08:08:58 +00:00
8 changed files with 1658 additions and 614 deletions
+2 -2
View File
@@ -62,9 +62,9 @@ export default function LeadJobSection({ formData, updateField, isQuickCapture }
accent="blue" accent="blue"
/> />
{/* --- Door Knock → Canvasser selector --- */} {/* --- Canvassers → Canvasser selector --- */}
<AnimatePresence initial={false}> <AnimatePresence initial={false}>
{formData.leadSource === 'Door Knock' && ( {formData.leadSource === 'Canvassers' && (
<motion.div <motion.div
key="canvasser" key="canvasser"
initial={{ height: 0, opacity: 0 }} initial={{ height: 0, opacity: 0 }}
+264
View File
@@ -0,0 +1,264 @@
import React, { useState, useEffect, useRef } from 'react';
import { createPortal } from 'react-dom';
import { X, Users, DollarSign } from 'lucide-react';
import { motion, AnimatePresence } from 'framer-motion';
import Select from '../ui/Select';
const PRESET_COST_TYPES = [
{ value: 'Commission', label: 'Commission' },
{ value: 'Salary', label: 'Salary' },
{ value: 'Bonus', label: 'Bonus' },
{ value: 'Hourly Wage', label: 'Hourly Wage' },
{ value: 'Per Diem', label: 'Per Diem' },
{ value: 'Referral Fee', label: 'Referral Fee' },
{ value: 'Overtime', label: 'Overtime' },
];
const inputBase =
'w-full bg-zinc-50 dark:bg-[#18181b] border border-zinc-200 dark:border-white/10 rounded-xl px-4 py-3 text-sm text-zinc-900 dark:text-white placeholder-zinc-400 dark:placeholder-zinc-600 focus:outline-none focus:border-purple-500 dark:focus:border-purple-400 focus:ring-1 focus:ring-purple-500/30 transition-all';
const AddTeamCostModal = ({ isOpen, onClose, onSubmit }) => {
const [form, setForm] = useState({ name: '', role: '', costType: '', customCostType: '', amount: '' });
const [showCustomInput, setShowCustomInput] = useState(false);
const firstInputRef = useRef(null);
// Reset form on open
useEffect(() => {
if (isOpen) {
setForm({ name: '', role: '', costType: '', customCostType: '', amount: '' });
setShowCustomInput(false);
setTimeout(() => firstInputRef.current?.focus(), 80);
}
}, [isOpen]);
// Esc to close
useEffect(() => {
const handler = (e) => { if (e.key === 'Escape') onClose(); };
if (isOpen) window.addEventListener('keydown', handler);
return () => window.removeEventListener('keydown', handler);
}, [isOpen, onClose]);
if (!isOpen) return null;
const resolvedCostType = form.costType === '__custom__' ? form.customCostType.trim() : form.costType;
const handleSubmit = (e) => {
e.preventDefault();
if (!form.name.trim() || !form.role.trim() || !resolvedCostType || !form.amount) return;
onSubmit({
name: form.name.trim(),
category: form.role.trim(),
costType: resolvedCostType,
amount: form.amount,
});
onClose();
};
return createPortal(
<AnimatePresence>
{isOpen && (
<div className="fixed inset-0 z-[9999] flex items-end sm:items-center justify-center p-0 sm:p-4" role="dialog" aria-modal="true" aria-labelledby="atcm-title">
{/* Backdrop */}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.18 }}
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
onClick={onClose}
/>
{/* Sheet / Dialog */}
<motion.div
initial={{ opacity: 0, y: 40, scale: 0.97 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 40, scale: 0.97 }}
transition={{ type: 'spring', stiffness: 380, damping: 32 }}
className="relative w-full sm:max-w-lg bg-white dark:bg-[#111113] rounded-t-3xl sm:rounded-2xl shadow-2xl border border-zinc-200 dark:border-white/10 overflow-hidden flex flex-col"
style={{ maxHeight: '92dvh' }}
onClick={e => e.stopPropagation()}
>
{/* Drag handle (mobile) */}
<div className="flex justify-center pt-3 pb-1 sm:hidden">
<div className="w-10 h-1 rounded-full bg-zinc-300 dark:bg-zinc-700" />
</div>
{/* Header */}
<div className="flex items-center justify-between px-5 sm:px-6 py-4 border-b border-zinc-100 dark:border-white/8 bg-zinc-50 dark:bg-white/[0.03] shrink-0">
<div className="flex items-center gap-3">
<div className="p-2 rounded-xl bg-purple-500/10 shrink-0">
<Users size={18} className="text-purple-500 dark:text-purple-400" />
</div>
<div>
<h2 id="atcm-title" className="text-sm sm:text-base font-bold text-zinc-900 dark:text-white tracking-wide uppercase">
Add Team Cost
</h2>
<p className="text-[11px] text-zinc-500 dark:text-zinc-500 mt-0.5">
Record a team member expense
</p>
</div>
</div>
<button
type="button"
onClick={onClose}
className="p-2 rounded-lg hover:bg-zinc-100 dark:hover:bg-white/10 text-zinc-400 hover:text-zinc-900 dark:hover:text-white transition-colors shrink-0"
aria-label="Close"
>
<X size={18} />
</button>
</div>
{/* Form */}
<form onSubmit={handleSubmit} className="flex-1 flex flex-col min-h-0" noValidate>
<div className="flex-1 overflow-y-auto px-5 sm:px-6 py-5 space-y-4">
{/* Team Member Name */}
<div className="space-y-1.5">
<label htmlFor="atcm-name" className="block text-xs font-bold uppercase tracking-wider text-zinc-500 dark:text-zinc-400">
Team Member Name <span className="text-purple-500">*</span>
</label>
<input
ref={firstInputRef}
id="atcm-name"
type="text"
className={inputBase}
placeholder="e.g. Jesus Gonzales"
required
value={form.name}
onChange={e => setForm(f => ({ ...f, name: e.target.value }))}
/>
</div>
{/* Role / Category */}
<div className="space-y-1.5">
<label htmlFor="atcm-role" className="block text-xs font-bold uppercase tracking-wider text-zinc-500 dark:text-zinc-400">
Role / Category <span className="text-purple-500">*</span>
</label>
<input
id="atcm-role"
type="text"
className={inputBase}
placeholder="e.g. Sales Rep, Canvasser, Estimator"
required
value={form.role}
onChange={e => setForm(f => ({ ...f, role: e.target.value }))}
/>
</div>
{/* Cost Type */}
<div className="space-y-1.5">
<label htmlFor="atcm-costtype" className="block text-xs font-bold uppercase tracking-wider text-zinc-500 dark:text-zinc-400">
Cost Type <span className="text-purple-500">*</span>
</label>
<Select
id="atcm-costtype"
value={form.costType}
onChange={(val) => {
if (val === '__custom__') {
setShowCustomInput(true);
setForm(f => ({ ...f, costType: '__custom__', customCostType: '' }));
} else {
setShowCustomInput(false);
setForm(f => ({ ...f, costType: val, customCostType: '' }));
}
}}
options={[
...PRESET_COST_TYPES,
{ value: '__custom__', label: 'Other / Custom…' },
]}
placeholder="Select cost type…"
className="py-3"
/>
<AnimatePresence>
{showCustomInput && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.18 }}
className="overflow-hidden"
>
<input
id="atcm-customtype"
type="text"
className={`${inputBase} mt-2`}
placeholder="Enter custom cost type…"
required={showCustomInput}
value={form.customCostType}
onChange={e => setForm(f => ({ ...f, customCostType: e.target.value }))}
autoFocus
/>
</motion.div>
)}
</AnimatePresence>
</div>
{/* Amount */}
<div className="space-y-1.5">
<label htmlFor="atcm-amount" className="block text-xs font-bold uppercase tracking-wider text-zinc-500 dark:text-zinc-400">
Amount <span className="text-purple-500">*</span>
</label>
<div className="relative">
<span className="absolute left-4 top-1/2 -translate-y-1/2 text-zinc-400 dark:text-zinc-500 text-sm pointer-events-none">$</span>
<input
id="atcm-amount"
type="number"
min="0"
step="0.01"
className={`${inputBase} pl-8`}
placeholder="0.00"
required
value={form.amount}
onChange={e => setForm(f => ({ ...f, amount: e.target.value }))}
/>
</div>
</div>
{/* Live preview */}
{(form.name || resolvedCostType || form.amount) && (
<div className="rounded-xl bg-purple-50 dark:bg-purple-500/[0.07] border border-purple-200 dark:border-purple-500/20 px-4 py-3 flex items-center gap-3">
<div className="p-1.5 rounded-lg bg-purple-500/15 shrink-0">
<DollarSign size={13} className="text-purple-600 dark:text-purple-400" />
</div>
<div className="min-w-0">
<p className="text-xs font-bold text-purple-900 dark:text-purple-300 truncate">
{form.name || '—'} · {resolvedCostType || '—'}
</p>
<p className="text-[11px] text-purple-700/70 dark:text-purple-400/70 mt-0.5">
{form.amount ? `$${parseFloat(form.amount).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}` : '—'}
</p>
</div>
</div>
)}
</div>
{/* Footer */}
<div className="px-5 sm:px-6 py-4 border-t border-zinc-100 dark:border-white/8 bg-zinc-50 dark:bg-white/[0.02] flex flex-col-reverse sm:flex-row sm:justify-end gap-2.5 sm:gap-3 shrink-0">
<button
type="button"
onClick={onClose}
className="w-full sm:w-auto px-5 py-2.5 text-sm font-semibold rounded-xl border border-zinc-200 dark:border-white/10 text-zinc-600 dark:text-zinc-400 hover:bg-zinc-100 dark:hover:bg-white/5 hover:text-zinc-900 dark:hover:text-white transition-colors"
>
Cancel
</button>
<button
type="submit"
disabled={!form.name.trim() || !form.role.trim() || !resolvedCostType || !form.amount}
className="w-full sm:w-auto px-6 py-2.5 text-sm font-bold rounded-xl border bg-purple-500/20 text-purple-600 dark:text-purple-300 border-purple-500/30 hover:bg-purple-500/30 disabled:opacity-40 disabled:cursor-not-allowed transition-all shadow-sm"
>
Save Team Cost
</button>
</div>
</form>
</motion.div>
</div>
)}
</AnimatePresence>,
document.body
);
};
export default AddTeamCostModal;
+4 -1
View File
@@ -2,7 +2,7 @@ import React, { useEffect, useState } from 'react';
import { createPortal } from 'react-dom'; import { createPortal } from 'react-dom';
import { X } from 'lucide-react'; 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({}); const [formData, setFormData] = useState({});
useEffect(() => { useEffect(() => {
@@ -44,6 +44,9 @@ const CreateItemModal = ({ isOpen, onClose, title, icon: Icon, iconColor = "text
{/* Content */} {/* Content */}
<form onSubmit={handleSubmit} className="flex-1 flex flex-col min-h-0"> <form onSubmit={handleSubmit} className="flex-1 flex flex-col min-h-0">
<div className="flex-1 overflow-y-auto px-5 sm:px-6 py-5 space-y-5"> <div className="flex-1 overflow-y-auto px-5 sm:px-6 py-5 space-y-5">
{description && (
<p className="text-sm text-zinc-600 dark:text-zinc-300 leading-relaxed">{description}</p>
)}
{fields.map((field, i) => ( {fields.map((field, i) => (
<div key={i} className="space-y-1.5"> <div key={i} className="space-y-1.5">
<label htmlFor={field.name} className="text-xs font-bold uppercase tracking-wider text-zinc-600 dark:text-zinc-400">{field.label}</label> <label htmlFor={field.name} className="text-xs font-bold uppercase tracking-wider text-zinc-600 dark:text-zinc-400">{field.label}</label>
@@ -1,6 +1,10 @@
import React, { useMemo, useState, useRef, useEffect } from 'react'; 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 = { const CATEGORY_COLORS = {
'Electrical': 'text-cyan-500 dark:text-cyan-400 bg-cyan-500/10 border-cyan-500/20', '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', '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', 'Windows & Glazing': 'text-teal-500 dark:text-teal-400 bg-teal-500/10 border-teal-500/20',
}; };
const PAYMENT_STATUS_COLORS = { // Contractor-level status: Paid | Partially Paid | Unpaid
'Paid': 'text-emerald-600 dark:text-emerald-400 bg-emerald-500/10 border-emerald-500/20', const CONTRACTOR_STATUS = {
'Unpaid': 'text-amber-600 dark:text-amber-400 bg-amber-500/10 border-amber-500/20', 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) { function taskTotal(task) {
const expenses = (task.expenses || []).reduce((s, e) => s + (Number(e.amount) || 0), 0); 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; return expenses + fees;
} }
function taskPaid(task) {
return Number(task.amountPaid || 0);
}
const fmt = (amt) => const fmt = (amt) =>
new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', maximumFractionDigits: 0 }).format(Number(amt) || 0); new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', maximumFractionDigits: 0 }).format(Number(amt) || 0);
const fmtDate = (iso) => { const fmtDate = (iso) => {
if (!iso) return '—'; if (!iso) return '—';
try { try { return new Date(iso).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' }); }
return new Date(iso).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' }); catch { return iso; }
} catch {
return iso;
}
}; };
// Dark-mode-aware dropdown — replaces native <select> so the option list /** Derive contractor-level status from totals */
// doesn't render with the OS default white background in dark mode. function contractorStatus(totalRequested, totalPaid) {
if (totalPaid <= 0) return 'Unpaid';
if (totalPaid >= totalRequested) return 'Paid';
return 'Partially Paid';
}
/** Derive request-level status */
function requestStatus(requested, paid) {
if (paid <= 0) return 'Unpaid';
if (paid >= requested) return 'Paid';
return 'Partially Paid';
}
// ─────────────────────────────────────────────────────────────────────────────
// FilterPopover — dark-mode-safe custom select
// ─────────────────────────────────────────────────────────────────────────────
function FilterPopover({ id, value, onChange, options, allLabel, ariaLabel }) { function FilterPopover({ id, value, onChange, options, allLabel, ariaLabel }) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const boxRef = useRef(null); const boxRef = useRef(null);
useEffect(() => { useEffect(() => {
if (!open) return; if (!open) return;
const onClick = (e) => { const onClick = (e) => { if (boxRef.current && !boxRef.current.contains(e.target)) setOpen(false); };
if (boxRef.current && !boxRef.current.contains(e.target)) setOpen(false); const onKey = (e) => { if (e.key === 'Escape') setOpen(false); };
};
const onKey = (e) => { if (e.key === 'Escape') setOpen(false); };
document.addEventListener('mousedown', onClick); document.addEventListener('mousedown', onClick);
document.addEventListener('keydown', onKey); document.addEventListener('keydown', onKey);
return () => { return () => { document.removeEventListener('mousedown', onClick); document.removeEventListener('keydown', onKey); };
document.removeEventListener('mousedown', onClick);
document.removeEventListener('keydown', onKey);
};
}, [open]); }, [open]);
const display = value === 'all' ? allLabel : value; const display = value === 'all' ? allLabel : value;
@@ -57,11 +81,8 @@ function FilterPopover({ id, value, onChange, options, allLabel, ariaLabel }) {
return ( return (
<div className="relative" ref={boxRef}> <div className="relative" ref={boxRef}>
<button <button
id={id} id={id} type="button"
type="button" aria-haspopup="listbox" aria-expanded={open} aria-label={ariaLabel}
aria-haspopup="listbox"
aria-expanded={open}
aria-label={ariaLabel}
onClick={() => setOpen(o => !o)} onClick={() => setOpen(o => !o)}
className="w-full flex items-center justify-between gap-2 pl-3 pr-2 py-2 text-sm rounded-xl bg-zinc-100 dark:bg-black/40 border border-zinc-200 dark:border-white/10 text-zinc-900 dark:text-white outline-none focus:ring-2 focus:ring-amber-500/20 focus:border-amber-500/40 transition-colors" className="w-full flex items-center justify-between gap-2 pl-3 pr-2 py-2 text-sm rounded-xl bg-zinc-100 dark:bg-black/40 border border-zinc-200 dark:border-white/10 text-zinc-900 dark:text-white outline-none focus:ring-2 focus:ring-amber-500/20 focus:border-amber-500/40 transition-colors"
> >
@@ -69,15 +90,10 @@ function FilterPopover({ id, value, onChange, options, allLabel, ariaLabel }) {
<ChevronDown size={14} className={`shrink-0 text-zinc-400 transition-transform ${open ? 'rotate-180' : ''}`} /> <ChevronDown size={14} className={`shrink-0 text-zinc-400 transition-transform ${open ? 'rotate-180' : ''}`} />
</button> </button>
{open && ( {open && (
<div <div role="listbox" className="absolute z-30 left-0 right-0 mt-1 rounded-xl bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-white/10 shadow-xl shadow-black/10 dark:shadow-black/50 overflow-hidden">
role="listbox"
className="absolute z-30 left-0 right-0 mt-1 rounded-xl bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-white/10 shadow-xl shadow-black/10 dark:shadow-black/50 overflow-hidden"
>
<div className="max-h-56 overflow-y-auto custom-scrollbar py-1"> <div className="max-h-56 overflow-y-auto custom-scrollbar py-1">
<button <button
type="button" type="button" role="option" aria-selected={value === 'all'}
role="option"
aria-selected={value === 'all'}
onClick={() => { onChange('all'); setOpen(false); }} onClick={() => { onChange('all'); setOpen(false); }}
className={`w-full flex items-center justify-between px-3 py-2 text-sm text-left transition-colors ${value === 'all' ? 'text-amber-600 dark:text-amber-400 bg-amber-50 dark:bg-amber-500/10' : 'text-zinc-700 dark:text-zinc-200 hover:bg-zinc-50 dark:hover:bg-white/5'}`} className={`w-full flex items-center justify-between px-3 py-2 text-sm text-left transition-colors ${value === 'all' ? 'text-amber-600 dark:text-amber-400 bg-amber-50 dark:bg-amber-500/10' : 'text-zinc-700 dark:text-zinc-200 hover:bg-zinc-50 dark:hover:bg-white/5'}`}
> >
@@ -85,18 +101,15 @@ function FilterPopover({ id, value, onChange, options, allLabel, ariaLabel }) {
{value === 'all' && <Check size={12} strokeWidth={2.5} className="shrink-0 opacity-70" />} {value === 'all' && <Check size={12} strokeWidth={2.5} className="shrink-0 opacity-70" />}
</button> </button>
{options.map(opt => { {options.map(opt => {
const isSelected = opt === value; const isSel = opt === value;
return ( return (
<button <button
key={opt} key={opt} type="button" role="option" aria-selected={isSel}
type="button"
role="option"
aria-selected={isSelected}
onClick={() => { onChange(opt); setOpen(false); }} onClick={() => { onChange(opt); setOpen(false); }}
className={`w-full flex items-center justify-between px-3 py-2 text-sm text-left transition-colors ${isSelected ? 'text-amber-600 dark:text-amber-400 bg-amber-50 dark:bg-amber-500/10' : 'text-zinc-700 dark:text-zinc-200 hover:bg-zinc-50 dark:hover:bg-white/5'}`} className={`w-full flex items-center justify-between px-3 py-2 text-sm text-left transition-colors ${isSel ? 'text-amber-600 dark:text-amber-400 bg-amber-50 dark:bg-amber-500/10' : 'text-zinc-700 dark:text-zinc-200 hover:bg-zinc-50 dark:hover:bg-white/5'}`}
> >
<span className="truncate">{opt}</span> <span className="truncate">{opt}</span>
{isSelected && <Check size={12} strokeWidth={2.5} className="shrink-0 opacity-70" />} {isSel && <Check size={12} strokeWidth={2.5} className="shrink-0 opacity-70" />}
</button> </button>
); );
})} })}
@@ -107,10 +120,172 @@ function FilterPopover({ id, value, onChange, options, allLabel, ariaLabel }) {
); );
} }
// ─────────────────────────────────────────────────────────────────────────────
// Status badge
// ─────────────────────────────────────────────────────────────────────────────
function StatusBadge({ status, colorMap }) {
return (
<span className={`inline-flex items-center text-[11px] font-bold rounded-md px-1.5 py-0.5 border whitespace-nowrap ${colorMap[status] || 'text-zinc-400 bg-zinc-500/10 border-zinc-500/20'}`}>
{status}
</span>
);
}
// ─────────────────────────────────────────────────────────────────────────────
// Expanded detail view (project → requests hierarchy)
// ─────────────────────────────────────────────────────────────────────────────
function ContractorDetail({ projects }) {
const navigate = useNavigate();
return (
<div className="flex flex-col gap-3 py-3 px-3 sm:px-5">
{projects.map((proj, pi) => (
<div key={proj.projectId || proj.projectName || pi} className="rounded-xl border border-zinc-200 dark:border-white/[0.06] overflow-hidden bg-white dark:bg-zinc-800/50">
{/* Project header */}
<div
onClick={() => proj.projectId && navigate(`/owner/projects/${proj.projectId}?tab=budget`)}
className={`flex flex-wrap items-center gap-x-4 gap-y-1 px-4 py-2.5 bg-zinc-50 dark:bg-white/[0.03] border-b border-zinc-200 dark:border-white/[0.06] ${proj.projectId ? 'cursor-pointer hover:bg-zinc-100 dark:hover:bg-white/[0.06] transition-colors' : ''}`}
>
<span className="text-[11px] font-black uppercase tracking-widest text-zinc-500 dark:text-zinc-400">Project</span>
<span className="text-xs font-bold text-zinc-800 dark:text-zinc-200 flex-1 min-w-0 truncate hover:text-amber-500 transition-colors">{proj.projectName}</span>
{proj.projectAddress && (
<span className="text-[10px] text-zinc-400 dark:text-zinc-500 truncate max-w-[200px]">{proj.projectAddress}</span>
)}
{proj.projectId && (
<ChevronRight size={12} className="text-zinc-400 dark:text-zinc-500 shrink-0" />
)}
</div>
{/* Requests — table on md+, cards on mobile */}
{/* ── Desktop table ── */}
{/* ── Desktop grid (stacked labels and values in columns) ── */}
<div className="hidden md:block overflow-x-auto">
<div className="min-w-[768px] divide-y divide-zinc-100 dark:divide-white/[0.04]">
{proj.requests.map((req, ri) => {
const remaining = Math.max(0, req.requested - req.paid);
const status = requestStatus(req.requested, req.paid);
return (
<div key={req.taskId || ri} className="px-4 py-3.5 hover:bg-zinc-50 dark:hover:bg-white/[0.02] transition-colors">
<div className="grid grid-cols-7 gap-4 text-xs">
{/* Work Category */}
<div className="flex flex-col gap-1">
<span className="text-[9px] font-mono font-bold text-zinc-400 dark:text-zinc-500 uppercase tracking-widest">Work Category</span>
<div>
<span className={`text-[11px] font-bold rounded-md px-1.5 py-0.5 border whitespace-nowrap ${CATEGORY_COLORS[req.workCategory] || 'text-zinc-400 bg-zinc-500/10 border-zinc-500/20'}`}>
{req.workCategory || '—'}
</span>
</div>
</div>
{/* Request Date */}
<div className="flex flex-col gap-1">
<span className="text-[9px] font-mono font-bold text-zinc-400 dark:text-zinc-500 uppercase tracking-widest">Request Date</span>
<span className="text-[11px] font-mono text-zinc-500 dark:text-zinc-400 whitespace-nowrap">{fmtDate(req.requestedAt)}</span>
</div>
{/* Requested */}
<div className="flex flex-col gap-1 text-right">
<span className="text-[9px] font-mono font-bold text-zinc-400 dark:text-zinc-500 uppercase tracking-widest">Requested</span>
<span className="font-mono text-xs font-bold text-zinc-700 dark:text-zinc-300 whitespace-nowrap">{fmt(req.requested)}</span>
</div>
{/* Paid */}
<div className="flex flex-col gap-1 text-right">
<span className="text-[9px] font-mono font-bold text-zinc-400 dark:text-zinc-500 uppercase tracking-widest">Paid</span>
<span className="font-mono text-xs font-semibold text-emerald-600 dark:text-emerald-400 whitespace-nowrap">{fmt(req.paid)}</span>
</div>
{/* Remaining */}
<div className="flex flex-col gap-1 text-right">
<span className="text-[9px] font-mono font-bold text-zinc-400 dark:text-zinc-500 uppercase tracking-widest">Remaining</span>
<span className="font-mono text-xs font-semibold text-red-500 dark:text-red-400 whitespace-nowrap">
{remaining > 0 ? fmt(remaining) : <span className="text-zinc-400"></span>}
</span>
</div>
{/* Paid Date */}
<div className="flex flex-col gap-1">
<span className="text-[9px] font-mono font-bold text-zinc-400 dark:text-zinc-500 uppercase tracking-widest">Paid Date</span>
<span className="text-[11px] font-mono text-zinc-500 dark:text-zinc-400 whitespace-nowrap">{fmtDate(req.paidAt)}</span>
</div>
{/* Status */}
<div className="flex flex-col gap-1">
<span className="text-[9px] font-mono font-bold text-zinc-400 dark:text-zinc-500 uppercase tracking-widest">Status</span>
<div>
<StatusBadge status={status} colorMap={REQUEST_STATUS} />
</div>
</div>
</div>
</div>
);
})}
</div>
</div>
{/* ── Mobile cards ── */}
<div className="md:hidden divide-y divide-zinc-100 dark:divide-white/[0.04]">
{proj.requests.map((req, ri) => {
const remaining = Math.max(0, req.requested - req.paid);
const status = requestStatus(req.requested, req.paid);
return (
<div key={req.taskId || ri} className="px-4 py-4 space-y-4">
<div className="grid grid-cols-2 sm:grid-cols-3 gap-x-4 gap-y-4 text-xs">
{/* Work Category */}
<div className="flex flex-col gap-1">
<span className="text-[10px] font-mono font-bold uppercase tracking-wider text-zinc-400 dark:text-zinc-500">Work Category</span>
<div>
<span className={`text-[11px] font-bold rounded-md px-1.5 py-0.5 border whitespace-nowrap ${CATEGORY_COLORS[req.workCategory] || 'text-zinc-400 bg-zinc-500/10 border-zinc-500/20'}`}>
{req.workCategory || '—'}
</span>
</div>
</div>
{/* Request Date */}
<div className="flex flex-col gap-1">
<span className="text-[10px] font-mono font-bold uppercase tracking-wider text-zinc-400 dark:text-zinc-500">Request Date</span>
<span className="font-mono text-zinc-700 dark:text-zinc-300">{fmtDate(req.requestedAt)}</span>
</div>
{/* Requested */}
<div className="flex flex-col gap-1">
<span className="text-[10px] font-mono font-bold uppercase tracking-wider text-zinc-400 dark:text-zinc-500">Requested</span>
<span className="font-mono font-bold text-zinc-800 dark:text-zinc-200">{fmt(req.requested)}</span>
</div>
{/* Paid */}
<div className="flex flex-col gap-1">
<span className="text-[10px] font-mono font-bold uppercase tracking-wider text-zinc-400 dark:text-zinc-500">Paid</span>
<span className="font-mono font-semibold text-emerald-600 dark:text-emerald-400">{fmt(req.paid)}</span>
</div>
{/* Remaining */}
<div className="flex flex-col gap-1">
<span className="text-[10px] font-mono font-bold uppercase tracking-wider text-zinc-400 dark:text-zinc-500">Remaining</span>
<span className="font-mono font-semibold text-red-500 dark:text-red-400">
{remaining > 0 ? fmt(remaining) : <span className="text-zinc-400"></span>}
</span>
</div>
{/* Paid Date */}
<div className="flex flex-col gap-1">
<span className="text-[10px] font-mono font-bold uppercase tracking-wider text-zinc-400 dark:text-zinc-500">Paid Date</span>
<span className="font-mono text-zinc-700 dark:text-zinc-300">{fmtDate(req.paidAt)}</span>
</div>
{/* Status */}
<div className="flex flex-col gap-1 col-span-2 sm:col-span-1">
<span className="text-[10px] font-mono font-bold uppercase tracking-wider text-zinc-400 dark:text-zinc-500">Status</span>
<div>
<StatusBadge status={status} colorMap={REQUEST_STATUS} />
</div>
</div>
</div>
</div>
);
})}
</div>
</div>
))}
</div>
);
}
// ─────────────────────────────────────────────────────────────────────────────
// Main panel
// ─────────────────────────────────────────────────────────────────────────────
const SubcontractorPaymentsPanel = ({ const SubcontractorPaymentsPanel = ({
subcontractorTasks = [], subcontractorTasks = [],
subcontractors = [], subcontractors = [],
projects = [], projects = [],
}) => { }) => {
const subById = useMemo(() => { const subById = useMemo(() => {
const m = new Map(); const m = new Map();
@@ -124,110 +299,146 @@ const SubcontractorPaymentsPanel = ({
return m; return m;
}, [projects]); }, [projects]);
const rows = useMemo(() => { // ── Build flat request rows (same logic as before) ──────────────────────
const allRequests = useMemo(() => {
const entries = []; const entries = [];
for (const task of subcontractorTasks) { for (const task of subcontractorTasks) {
const amount = taskTotal(task); const requested = taskTotal(task);
if (amount <= 0) continue; if (requested <= 0) continue;
const sub = subById.get(task.subcontractorId); const sub = subById.get(task.subcontractorId);
const project = task.projectId ? projectById.get(task.projectId) : null; const project = task.projectId ? projectById.get(task.projectId) : null;
if (!project) continue;
entries.push({ entries.push({
key: task.id, taskId: task.id,
name: task.subcontractorName, subId: task.subcontractorId,
companyName: sub?.companyName || '', subName: task.subcontractorName || '',
projectName: project ? (project.projectType || project.address) : '', companyName: sub?.companyName || '',
projectAddress: project?.address || '', workCategory: sub?.tradeType || '',
projectId: project?.id || null, projectId: project?.id || null,
workCategory: sub?.tradeType || '—', projectName: project ? (project.projectType || project.address) : '—',
amount, projectAddress: project?.address || '',
requestedAt: task.paymentRequestedAt || null, requested,
paidAt: task.paidAt || null, paid: taskPaid(task),
paymentStatus: task.paymentStatus || 'Unpaid', requestedAt: task.paymentRequestedAt || null,
paidAt: task.paidAt || null,
paymentStatus: task.paymentStatus || 'Unpaid',
}); });
} }
return entries; return entries;
}, [subcontractorTasks, subById, projectById]); }, [subcontractorTasks, subById, projectById]);
// ── Filter state ─────────────────────────────────────────────── // ── Filter state ─────────────────────────────────────────────────────────
const [nameQuery, setNameQuery] = useState(''); const [nameQuery, setNameQuery] = useState('');
const [projectFilter, setProjectFilter] = useState('all');
const [categoryFilter, setCategoryFilter] = useState('all'); const [categoryFilter, setCategoryFilter] = useState('all');
const [statusFilter, setStatusFilter] = useState('all'); const [statusFilter, setStatusFilter] = useState('all');
// Searchable project dropdown const categoryOptions = useMemo(() => Array.from(new Set(allRequests.map(r => r.workCategory).filter(Boolean))).sort(), [allRequests]);
const [projectOpen, setProjectOpen] = useState(false);
const [projectQuery, setProjectQuery] = useState('');
const projectBoxRef = useRef(null);
useEffect(() => { // Contractor-level status options for filter (computed after grouping)
if (!projectOpen) return; const contractorGroups = useMemo(() => {
const onClick = (e) => {
if (projectBoxRef.current && !projectBoxRef.current.contains(e.target)) {
setProjectOpen(false);
setProjectQuery('');
}
};
document.addEventListener('mousedown', onClick);
return () => document.removeEventListener('mousedown', onClick);
}, [projectOpen]);
const projectOptions = useMemo(() => {
const map = new Map(); const map = new Map();
for (const r of rows) { for (const req of allRequests) {
const key = r.projectName || '—'; if (!map.has(req.subId)) {
if (!map.has(key)) map.set(key, { name: key }); map.set(req.subId, {
} subId: req.subId,
return Array.from(map.values()).sort((a, b) => a.name.localeCompare(b.name)); subName: req.subName,
}, [rows]); companyName: req.companyName,
projects: new Map(),
totalRequested: 0,
totalPaid: 0,
});
}
const contractor = map.get(req.subId);
contractor.totalRequested += req.requested;
contractor.totalPaid += req.paid;
const categoryOptions = useMemo(() => { const projKey = req.projectId || req.projectName;
const set = new Set(rows.map(r => r.workCategory).filter(Boolean)); if (!contractor.projects.has(projKey)) {
return Array.from(set).sort(); contractor.projects.set(projKey, {
}, [rows]); projectId: req.projectId,
projectName: req.projectName,
projectAddress: req.projectAddress,
requests: [],
});
}
contractor.projects.get(projKey).requests.push(req);
}
// Sort requests within each project chronologically
const result = [];
for (const [, c] of map) {
const projArr = Array.from(c.projects.values()).map(proj => ({
...proj,
requests: proj.requests.sort((a, b) => (a.requestedAt || '') < (b.requestedAt || '') ? -1 : 1),
}));
result.push({ ...c, projects: projArr });
}
return result.sort((a, b) => a.subName.localeCompare(b.subName));
}, [allRequests]);
const statusOptions = useMemo(() => { const statusOptions = useMemo(() => {
const set = new Set(rows.map(r => r.paymentStatus).filter(Boolean)); const s = new Set(contractorGroups.map(c => contractorStatus(c.totalRequested, c.totalPaid)));
return Array.from(set).sort(); return Array.from(s).sort();
}, [rows]); }, [contractorGroups]);
const filteredProjectOptions = useMemo(() => { // ── Filtered contractor list ─────────────────────────────────────────────
const q = projectQuery.trim().toLowerCase(); const filteredContractors = useMemo(() => {
if (!q) return projectOptions;
return projectOptions.filter(p => (p.name || '').toLowerCase().includes(q));
}, [projectOptions, projectQuery]);
const selectedProjectLabel = projectFilter === 'all' ? 'All Projects' : projectFilter;
const filteredRows = useMemo(() => {
const q = nameQuery.trim().toLowerCase(); const q = nameQuery.trim().toLowerCase();
return rows.filter(r => { return contractorGroups.filter(c => {
if (q && !(r.name || '').toLowerCase().includes(q)) return false; // Name search
if (projectFilter !== 'all' && r.projectName !== projectFilter) return false; if (q && !(c.subName || '').toLowerCase().includes(q) && !(c.companyName || '').toLowerCase().includes(q)) return false;
if (categoryFilter !== 'all' && r.workCategory !== categoryFilter) return false;
if (statusFilter !== 'all' && r.paymentStatus !== statusFilter) return false; // Category filter — keep contractor if any request matches
if (categoryFilter !== 'all') {
const hasCategory = c.projects.some(proj => proj.requests.some(r => r.workCategory === categoryFilter));
if (!hasCategory) return false;
}
// Status filter
if (statusFilter !== 'all') {
if (contractorStatus(c.totalRequested, c.totalPaid) !== statusFilter) return false;
}
return true; return true;
}).map(c => {
// If category filter active, narrow down projects/requests shown
if (categoryFilter !== 'all') {
return {
...c,
projects: c.projects.map(proj => ({
...proj,
requests: proj.requests.filter(r => r.workCategory === categoryFilter),
})).filter(proj => proj.requests.length > 0),
};
}
return c;
}); });
}, [rows, nameQuery, projectFilter, categoryFilter, statusFilter]); }, [contractorGroups, nameQuery, categoryFilter, statusFilter]);
const total = useMemo(() => filteredRows.reduce((s, r) => s + r.amount, 0), [filteredRows]); const grandTotalRequested = useMemo(() => filteredContractors.reduce((s, c) => s + c.totalRequested, 0), [filteredContractors]);
const outstanding = useMemo( const grandTotalPaid = useMemo(() => filteredContractors.reduce((s, c) => s + c.totalPaid, 0), [filteredContractors]);
() => filteredRows.filter(r => r.paymentStatus === 'Unpaid').reduce((s, r) => s + r.amount, 0), const grandTotalRemaining = Math.max(0, grandTotalRequested - grandTotalPaid);
[filteredRows]
);
const hasActiveFilters = const hasActiveFilters = nameQuery.trim() !== '' || categoryFilter !== 'all' || statusFilter !== 'all';
nameQuery.trim() !== '' || projectFilter !== 'all' || categoryFilter !== 'all' || statusFilter !== 'all';
const clearFilters = () => { const clearFilters = () => {
setNameQuery(''); setNameQuery('');
setProjectFilter('all');
setCategoryFilter('all'); setCategoryFilter('all');
setStatusFilter('all'); setStatusFilter('all');
setProjectQuery('');
}; };
// ── Expand/collapse state ────────────────────────────────────────────────
const [expanded, setExpanded] = useState(new Set());
const toggle = (subId) => setExpanded(prev => {
const next = new Set(prev);
next.has(subId) ? next.delete(subId) : next.add(subId);
return next;
});
// ── Render ───────────────────────────────────────────────────────────────
return ( return (
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
{/* Header */} {/* Header */}
<div className="flex items-center gap-3 flex-wrap"> <div className="flex items-center gap-3 flex-wrap">
<Wallet className="text-amber-500 dark:text-amber-400 drop-shadow-[0_0_8px_rgba(245,158,11,0.4)]" size={20} /> <Wallet className="text-amber-500 dark:text-amber-400 drop-shadow-[0_0_8px_rgba(245,158,11,0.4)]" size={20} />
@@ -236,95 +447,51 @@ const SubcontractorPaymentsPanel = ({
</h3> </h3>
<span className="text-[10px] font-mono text-zinc-500 bg-zinc-100 dark:bg-white/5 border border-zinc-200 dark:border-white/10 rounded-full px-2.5 py-0.5"> <span className="text-[10px] font-mono text-zinc-500 bg-zinc-100 dark:bg-white/5 border border-zinc-200 dark:border-white/10 rounded-full px-2.5 py-0.5">
{hasActiveFilters {hasActiveFilters
? `${filteredRows.length} of ${rows.length} payment${rows.length === 1 ? '' : 's'}` ? `${filteredContractors.length} of ${contractorGroups.length} contractor${contractorGroups.length === 1 ? '' : 's'}`
: `${rows.length} payment${rows.length === 1 ? '' : 's'}` : `${contractorGroups.length} contractor${contractorGroups.length === 1 ? '' : 's'}`
} }
</span> </span>
</div> </div>
{/* Summary KPI row */}
{contractorGroups.length > 0 && (
<div className="grid grid-cols-3 gap-2">
{[
{ label: 'Total Requested', value: fmt(grandTotalRequested), color: 'text-zinc-800 dark:text-zinc-100' },
{ label: 'Total Paid', value: fmt(grandTotalPaid), color: 'text-emerald-600 dark:text-emerald-400' },
{ label: 'Outstanding', value: fmt(grandTotalRemaining), color: 'text-red-500 dark:text-red-400' },
].map(({ label, value, color }) => (
<div key={label} className="rounded-xl px-3 py-2.5 bg-zinc-50 dark:bg-white/[0.03] border border-zinc-200 dark:border-white/[0.05]">
<div className="text-[9px] font-mono font-bold uppercase tracking-widest text-zinc-400 dark:text-zinc-500 mb-0.5">{label}</div>
<div className={`font-mono text-sm font-extrabold ${color}`}>{value}</div>
</div>
))}
</div>
)}
{/* Filter bar */} {/* Filter bar */}
{rows.length > 0 && ( {contractorGroups.length > 0 && (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-2.5 p-3 rounded-xl bg-zinc-50 dark:bg-white/[0.02] border border-zinc-200 dark:border-white/5"> <div className="grid grid-cols-1 sm:grid-cols-3 gap-2.5 p-3 rounded-xl bg-zinc-50 dark:bg-white/[0.02] border border-zinc-200 dark:border-white/5">
{/* Search by Name */} {/* Name search */}
<div className="relative"> <div className="relative">
<label htmlFor="payments-name-search" className="sr-only">Search by subcontractor name</label> <label htmlFor="payments-name-search" className="sr-only">Search by subcontractor name</label>
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-zinc-400 pointer-events-none" size={14} /> <Search className="absolute left-3 top-1/2 -translate-y-1/2 text-zinc-400 pointer-events-none" size={14} />
<input <input
id="payments-name-search" id="payments-name-search" type="text"
type="text"
placeholder="Search by name..." placeholder="Search by name..."
value={nameQuery} value={nameQuery}
onChange={(e) => setNameQuery(e.target.value)} onChange={(e) => 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" 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 && ( {nameQuery && (
<button <button type="button" onClick={() => setNameQuery('')} aria-label="Clear name search"
type="button" className="absolute right-2 top-1/2 -translate-y-1/2 text-zinc-400 hover:text-zinc-700 dark:hover:text-zinc-200">
onClick={() => setNameQuery('')}
aria-label="Clear name search"
className="absolute right-2 top-1/2 -translate-y-1/2 text-zinc-400 hover:text-zinc-700 dark:hover:text-zinc-200"
>
<X size={14} /> <X size={14} />
</button> </button>
)} )}
</div> </div>
{/* Project searchable dropdown */} {/* Category filter */}
<div className="relative" ref={projectBoxRef}>
<button
type="button"
onClick={() => setProjectOpen(o => !o)}
className="w-full flex items-center justify-between gap-2 pl-3 pr-2 py-2 text-sm rounded-xl bg-zinc-100 dark:bg-black/40 border border-zinc-200 dark:border-white/10 text-zinc-900 dark:text-white outline-none focus:ring-2 focus:ring-amber-500/20 focus:border-amber-500/40 transition-colors"
>
<span className="truncate text-left">{selectedProjectLabel}</span>
<ChevronDown size={14} className={`shrink-0 text-zinc-400 transition-transform ${projectOpen ? 'rotate-180' : ''}`} />
</button>
{projectOpen && (
<div className="absolute z-30 left-0 right-0 mt-1 rounded-xl bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-white/10 shadow-xl shadow-black/10 dark:shadow-black/50 overflow-hidden">
<div className="relative p-2 border-b border-zinc-100 dark:border-white/5">
<Search className="absolute left-4 top-1/2 -translate-y-1/2 text-zinc-400 pointer-events-none" size={14} />
<input
autoFocus
type="text"
placeholder="Search projects..."
value={projectQuery}
onChange={(e) => 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"
/>
</div>
<div className="max-h-56 overflow-y-auto custom-scrollbar py-1">
<button
type="button"
onClick={() => { setProjectFilter('all'); setProjectOpen(false); setProjectQuery(''); }}
className={`w-full flex items-center justify-between px-3 py-2 text-sm text-left transition-colors ${projectFilter === 'all' ? 'text-amber-600 dark:text-amber-400 bg-amber-50 dark:bg-amber-500/10' : 'text-zinc-700 dark:text-zinc-200 hover:bg-zinc-50 dark:hover:bg-white/5'}`}
>
<span>All Projects</span>
{projectFilter === 'all' && <Check size={12} strokeWidth={2.5} className="shrink-0 opacity-70" />}
</button>
{filteredProjectOptions.length === 0 ? (
<p className="px-3 py-2 text-xs text-zinc-400 dark:text-zinc-500">No projects match "{projectQuery}"</p>
) : (
filteredProjectOptions.map(p => {
const isSelected = p.name === projectFilter;
return (
<button
key={p.name}
type="button"
onClick={() => { setProjectFilter(p.name); setProjectOpen(false); setProjectQuery(''); }}
className={`w-full flex items-center justify-between px-3 py-2 text-sm text-left transition-colors ${isSelected ? 'text-amber-600 dark:text-amber-400 bg-amber-50 dark:bg-amber-500/10' : 'text-zinc-700 dark:text-zinc-200 hover:bg-zinc-50 dark:hover:bg-white/5'}`}
>
<span className="truncate font-semibold">{p.name}</span>
{isSelected && <Check size={12} strokeWidth={2.5} className="shrink-0 opacity-70" />}
</button>
);
})
)}
</div>
</div>
)}
</div>
{/* Work Category filter */}
<FilterPopover <FilterPopover
id="payments-category-filter" id="payments-category-filter"
ariaLabel="Filter by work category" ariaLabel="Filter by work category"
@@ -334,7 +501,7 @@ const SubcontractorPaymentsPanel = ({
allLabel="All Work Categories" allLabel="All Work Categories"
/> />
{/* Payment Status filter */} {/* Contractor-status filter */}
<FilterPopover <FilterPopover
id="payments-status-filter" id="payments-status-filter"
ariaLabel="Filter by payment status" ariaLabel="Filter by payment status"
@@ -345,12 +512,9 @@ const SubcontractorPaymentsPanel = ({
/> />
{hasActiveFilters && ( {hasActiveFilters && (
<div className="sm:col-span-2 lg:col-span-4 flex justify-end"> <div className="sm:col-span-3 flex justify-end">
<button <button type="button" onClick={clearFilters}
type="button" className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-[11px] font-bold uppercase tracking-wider text-zinc-600 dark:text-zinc-400 hover:text-zinc-900 dark:hover:text-white bg-zinc-100 dark:bg-white/5 hover:bg-zinc-200 dark:hover:bg-white/10 border border-zinc-200 dark:border-white/10 transition-colors">
onClick={clearFilters}
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-[11px] font-bold uppercase tracking-wider text-zinc-600 dark:text-zinc-400 hover:text-zinc-900 dark:hover:text-white bg-zinc-100 dark:bg-white/5 hover:bg-zinc-200 dark:hover:bg-white/10 border border-zinc-200 dark:border-white/10 transition-colors"
>
<FilterX size={12} /> Clear Filters <FilterX size={12} /> Clear Filters
</button> </button>
</div> </div>
@@ -358,122 +522,155 @@ const SubcontractorPaymentsPanel = ({
</div> </div>
)} )}
{rows.length === 0 ? ( {/* Empty state */}
{contractorGroups.length === 0 ? (
<div className="py-10 text-center"> <div className="py-10 text-center">
<Users size={28} className="mx-auto mb-3 text-zinc-400" /> <Users size={28} className="mx-auto mb-3 text-zinc-400" />
<p className="text-sm text-zinc-500">No subcontractor payments yet.</p> <p className="text-sm text-zinc-500">No subcontractor payments yet.</p>
<p className="text-xs text-zinc-400 mt-1">Tasks with logged fees or expenses will appear here.</p> <p className="text-xs text-zinc-400 mt-1">Tasks with logged fees or expenses will appear here.</p>
</div> </div>
) : ( ) : (
<div className="overflow-x-auto rounded-xl border border-zinc-200 dark:border-white/5"> <div className="rounded-xl border border-zinc-200 dark:border-white/5 overflow-hidden">
<table className="w-full text-left min-w-[860px]">
<thead> {/* ── Parent table header ── */}
<tr className="border-b border-zinc-200 dark:border-white/5 bg-zinc-50 dark:bg-white/[0.02]"> <div className="hidden sm:grid grid-cols-[2fr_1fr_1fr_1fr_40px] border-b border-zinc-200 dark:border-white/5 bg-zinc-50 dark:bg-white/[0.02]">
<th className="px-4 py-2.5 text-[10px] font-mono font-bold text-zinc-500 uppercase tracking-widest">Subcontractor</th> {['Contractor Name', 'Payment Requested', 'Amount Left to be Paid', 'Status', ''].map((h, i) => (
<th className="px-4 py-2.5 text-[10px] font-mono font-bold text-zinc-500 uppercase tracking-widest">Project</th> <div key={i} className={`px-4 py-2.5 text-[10px] font-mono font-bold text-zinc-500 uppercase tracking-widest ${i >= 1 && i <= 2 ? 'text-right' : ''}`}>
<th className="px-4 py-2.5 text-[10px] font-mono font-bold text-zinc-500 uppercase tracking-widest">Work Category</th> {h}
<th className="px-4 py-2.5 text-[10px] font-mono font-bold text-zinc-500 uppercase tracking-widest text-right">Amount to Pay</th> </div>
<th className="px-4 py-2.5 text-[10px] font-mono font-bold text-zinc-500 uppercase tracking-widest">Requested</th> ))}
<th className="px-4 py-2.5 text-[10px] font-mono font-bold text-zinc-500 uppercase tracking-widest">Paid Date</th> </div>
<th className="px-4 py-2.5 text-[10px] font-mono font-bold text-zinc-500 uppercase tracking-widest">Payment Status</th>
</tr> {filteredContractors.length === 0 ? (
</thead> <div className="py-10 text-center">
<tbody> <Users size={22} className="mx-auto mb-2 text-zinc-400" />
{filteredRows.length === 0 && ( <p className="text-sm text-zinc-500">No contractors match the current filters.</p>
<tr> <button type="button" onClick={clearFilters}
<td colSpan={7} className="px-4 py-10 text-center"> className="mt-3 inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-[11px] font-bold uppercase tracking-wider text-amber-600 dark:text-amber-400 hover:bg-amber-50 dark:hover:bg-amber-500/10 border border-amber-200 dark:border-amber-500/20 transition-colors">
<Users size={22} className="mx-auto mb-2 text-zinc-400" /> <FilterX size={12} /> Clear Filters
<p className="text-sm text-zinc-500">No payments match the current filters.</p> </button>
</div>
) : (
<div className="divide-y divide-zinc-100 dark:divide-white/[0.04]">
{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 (
<div key={c.subId}>
{/* ── Parent row — desktop grid ── */}
<button <button
type="button" type="button"
onClick={clearFilters} onClick={() => toggle(c.subId)}
className="mt-3 inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-[11px] font-bold uppercase tracking-wider text-amber-600 dark:text-amber-400 hover:bg-amber-50 dark:hover:bg-amber-500/10 border border-amber-200 dark:border-amber-500/20 transition-colors" aria-expanded={isExpanded}
className="w-full text-left focus:outline-none focus:ring-2 focus:ring-inset focus:ring-amber-500/30"
> >
<FilterX size={12} /> Clear Filters {/* Desktop */}
</button> <div className="hidden sm:grid grid-cols-[2fr_1fr_1fr_1fr_40px] px-0 hover:bg-zinc-50 dark:hover:bg-white/[0.02] transition-colors">
</td> {/* Contractor name */}
</tr> <div className="px-4 py-3.5 flex items-center gap-2.5 min-w-0">
)} <div className="w-8 h-8 rounded-full bg-amber-500/15 flex items-center justify-center text-[11px] font-black text-amber-600 dark:text-amber-400 shrink-0">
{filteredRows.map((row) => ( {c.subName?.[0] || '?'}
<tr key={row.key} className="border-b border-zinc-100 dark:border-white/[0.03] hover:bg-zinc-50 dark:hover:bg-white/[0.02] transition-colors"> </div>
<td className="px-4 py-3"> <div className="min-w-0">
<div className="flex items-center gap-2.5"> <div className="text-xs font-semibold text-zinc-900 dark:text-white truncate">{c.subName}</div>
<div className="w-7 h-7 rounded-full bg-amber-500/15 flex items-center justify-center text-[10px] font-black text-amber-600 dark:text-amber-400 flex-shrink-0"> {c.companyName && <div className="text-[10px] text-zinc-500 dark:text-zinc-400 truncate">{c.companyName}</div>}
{row.name?.[0] || '?'} <div className="text-[10px] text-zinc-400 dark:text-zinc-500 mt-0.5">
</div> {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' : ''}
<div className="min-w-0"> </div>
<div className="text-xs font-semibold text-zinc-900 dark:text-white truncate max-w-[180px]">{row.name}</div> </div>
{row.companyName && (
<div className="text-[10px] text-zinc-500 dark:text-zinc-400 truncate max-w-[180px]">{row.companyName}</div>
)}
</div>
</div>
</td>
<td className="px-4 py-3">
<div className="min-w-0">
<div className="text-xs text-zinc-700 dark:text-zinc-300 truncate max-w-[200px]" title={row.projectName}>
{row.projectName}
</div>
{row.projectAddress && (
<div className="text-[10px] text-zinc-500 dark:text-zinc-400 truncate max-w-[200px]" title={row.projectAddress}>
{row.projectAddress}
</div> </div>
)} {/* Payment Requested */}
</div> <div className="px-4 py-3.5 flex items-center justify-end">
</td> <span className="font-mono text-xs font-bold text-amber-600 dark:text-amber-400">{fmt(c.totalRequested)}</span>
<td className="px-4 py-3"> </div>
<span className={`text-[11px] font-bold rounded-md px-1.5 py-0.5 border whitespace-nowrap ${CATEGORY_COLORS[row.workCategory] || 'text-zinc-400 bg-zinc-500/10 border-zinc-500/20'}`}> {/* Amount Left */}
{row.workCategory} <div className="px-4 py-3.5 flex items-center justify-end">
</span> <span className={`font-mono text-xs font-bold ${remaining > 0 ? 'text-red-500 dark:text-red-400' : 'text-zinc-400 dark:text-zinc-500'}`}>
</td> {remaining > 0 ? fmt(remaining) : '—'}
<td className="px-4 py-3 text-right"> </span>
<span className="font-mono text-xs font-bold text-amber-600 dark:text-amber-400"> </div>
{fmt(row.amount)} {/* Status */}
</span> <div className="px-4 py-3.5 flex items-center">
</td> <StatusBadge status={cStatus} colorMap={CONTRACTOR_STATUS} />
<td className="px-4 py-3"> </div>
<span className="text-[11px] font-mono text-zinc-500 dark:text-zinc-400 whitespace-nowrap"> {/* Chevron */}
{fmtDate(row.requestedAt)} <div className="px-2 py-3.5 flex items-center justify-center">
</span> {isExpanded
</td> ? <ChevronDown size={15} className="text-amber-500 dark:text-amber-400 transition-transform" />
<td className="px-4 py-3"> : <ChevronRight size={15} className="text-zinc-400 transition-transform" />
<span className={`text-[11px] font-mono whitespace-nowrap ${row.paidAt ? 'text-emerald-600 dark:text-emerald-400 font-semibold' : 'text-zinc-400 dark:text-zinc-500'}`}> }
{fmtDate(row.paidAt)} </div>
</span> </div>
</td>
<td className="px-4 py-3"> {/* Mobile card */}
<span className={`text-[11px] font-bold rounded-md px-1.5 py-0.5 border whitespace-nowrap ${PAYMENT_STATUS_COLORS[row.paymentStatus] || 'text-zinc-400 bg-zinc-500/10 border-zinc-500/20'}`}> <div className="sm:hidden px-4 py-3.5 hover:bg-zinc-50 dark:hover:bg-white/[0.02] transition-colors">
{row.paymentStatus} <div className="flex items-start justify-between gap-3">
</span> <div className="flex items-center gap-2.5 min-w-0">
</td> <div className="w-8 h-8 rounded-full bg-amber-500/15 flex items-center justify-center text-[11px] font-black text-amber-600 dark:text-amber-400 shrink-0">
</tr> {c.subName?.[0] || '?'}
))} </div>
</tbody> <div className="min-w-0">
<tfoot className="border-t-2 border-zinc-300 dark:border-white/10"> <div className="text-xs font-semibold text-zinc-900 dark:text-white truncate">{c.subName}</div>
<tr className="bg-zinc-50 dark:bg-white/[0.03]"> {c.companyName && <div className="text-[10px] text-zinc-500 truncate">{c.companyName}</div>}
<td colSpan={3} className="px-4 py-3 text-[10px] font-mono font-bold text-zinc-500 uppercase tracking-widest"> </div>
{hasActiveFilters ? 'Filtered Total' : 'Total Amount'} </div>
</td> <div className="flex items-center gap-2 shrink-0">
<td className="px-4 py-3 text-right"> <StatusBadge status={cStatus} colorMap={CONTRACTOR_STATUS} />
<span className="font-mono text-sm font-extrabold text-amber-600 dark:text-amber-400"> {isExpanded
{fmt(total)} ? <ChevronDown size={14} className="text-amber-500 dark:text-amber-400" />
: <ChevronRight size={14} className="text-zinc-400" />
}
</div>
</div>
<div className="grid grid-cols-2 gap-x-4 gap-y-0.5 mt-2.5 text-[11px]">
<div><span className="text-zinc-400 dark:text-zinc-500">Requested: </span><span className="font-bold text-amber-600 dark:text-amber-400">{fmt(c.totalRequested)}</span></div>
<div><span className="text-zinc-400 dark:text-zinc-500">Outstanding: </span><span className={`font-bold ${remaining > 0 ? 'text-red-500 dark:text-red-400' : 'text-zinc-400'}`}>{remaining > 0 ? fmt(remaining) : '—'}</span></div>
</div>
</div>
</button>
{/* ── Expanded detail ── */}
{isExpanded && (
<div className="border-t border-zinc-100 dark:border-white/[0.04] bg-zinc-50/60 dark:bg-white/[0.01]">
<ContractorDetail projects={c.projects} />
</div>
)}
</div>
);
})}
</div>
)}
{/* Footer totals */}
{filteredContractors.length > 0 && (
<div className="border-t-2 border-zinc-200 dark:border-white/10 bg-zinc-50 dark:bg-white/[0.02]">
{/* Desktop totals row */}
<div className="hidden sm:grid grid-cols-[2fr_1fr_1fr_1fr_40px]">
<div className="px-4 py-3 text-[10px] font-mono font-bold text-zinc-500 uppercase tracking-widest">
{hasActiveFilters ? 'Filtered Total' : 'Grand Total'}
</div>
<div className="px-4 py-3 text-right">
<span className="font-mono text-sm font-extrabold text-amber-600 dark:text-amber-400">{fmt(grandTotalRequested)}</span>
</div>
<div className="px-4 py-3 text-right">
<span className={`font-mono text-sm font-extrabold ${grandTotalRemaining > 0 ? 'text-red-500 dark:text-red-400' : 'text-zinc-400'}`}>
{grandTotalRemaining > 0 ? fmt(grandTotalRemaining) : '—'}
</span> </span>
</td> </div>
<td colSpan={3} /> <div className="px-4 py-3 col-span-2" />
</tr> </div>
<tr className="bg-zinc-50 dark:bg-white/[0.03]"> {/* Mobile totals */}
<td colSpan={3} className="px-4 py-3 text-[10px] font-mono font-bold text-zinc-500 uppercase tracking-widest"> <div className="sm:hidden px-4 py-3 flex items-center justify-between gap-4 flex-wrap">
Outstanding (Unpaid) <span className="text-[10px] font-mono font-bold text-zinc-500 uppercase tracking-widest">{hasActiveFilters ? 'Filtered Total' : 'Grand Total'}</span>
</td> <div className="flex gap-4 text-xs font-mono font-bold">
<td className="px-4 py-3 text-right"> <span className="text-amber-600 dark:text-amber-400">{fmt(grandTotalRequested)} requested</span>
<span className="font-mono text-sm font-extrabold text-amber-600 dark:text-amber-400"> {grandTotalRemaining > 0 && <span className="text-red-500 dark:text-red-400">{fmt(grandTotalRemaining)} outstanding</span>}
{fmt(outstanding)} </div>
</span> </div>
</td> </div>
<td colSpan={3} /> )}
</tr>
</tfoot>
</table>
</div> </div>
)} )}
</div> </div>
+575 -231
View File
@@ -2158,6 +2158,43 @@ const MOCK_PROJECTS = [
{ name: 'City of Plano', category: 'Permits & Inspection', allocated: 2500, committed: 2000, actual: 2000 }, { 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 } { 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: [], changeOrders: [],
rfis: [], rfis: [],
riskLog: [ 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) // Project 2: Completed, zero variance (perfectly on budget)
{ {
id: 'PRJ-2026-002', id: 'PRJ-2026-002',
@@ -7388,8 +7484,10 @@ const MOCK_SUBCONTRACTOR_TASKS = [
}, },
], ],
projectId: 'PRJ-2026-004', projectId: 'PRJ-2026-004',
paymentStatus: 'Unpaid', paymentStatus: 'Partially Paid',
paymentRequestedAt: '2026-05-16T17:30:00Z', paymentRequestedAt: '2026-05-16T17:30:00Z',
amountPaid: 350,
paidAt: '2026-05-20T11:00:00Z',
createdAt: '2026-05-15T09:12:00Z', createdAt: '2026-05-15T09:12:00Z',
updatedAt: '2026-05-16T17:30:00Z', updatedAt: '2026-05-16T17:30:00Z',
}, },
@@ -7484,6 +7582,7 @@ const MOCK_SUBCONTRACTOR_TASKS = [
projectId: 'PRJ-2026-003', projectId: 'PRJ-2026-003',
paymentStatus: 'Unpaid', paymentStatus: 'Unpaid',
paymentRequestedAt: '2026-05-12T17:00:00Z', paymentRequestedAt: '2026-05-12T17:00:00Z',
amountPaid: 0,
createdAt: '2026-05-10T15:40:00Z', createdAt: '2026-05-10T15:40:00Z',
updatedAt: '2026-05-12T17:00:00Z', updatedAt: '2026-05-12T17:00:00Z',
}, },
@@ -7561,6 +7660,7 @@ const MOCK_SUBCONTRACTOR_TASKS = [
projectId: 'PRJ-2026-006', projectId: 'PRJ-2026-006',
paymentStatus: 'Paid', paymentStatus: 'Paid',
paymentRequestedAt: '2026-05-14T17:35:00Z', paymentRequestedAt: '2026-05-14T17:35:00Z',
amountPaid: 232,
paidAt: '2026-05-18T10:15:00Z', paidAt: '2026-05-18T10:15:00Z',
createdAt: '2026-05-05T11:00:00Z', createdAt: '2026-05-05T11:00:00Z',
updatedAt: '2026-05-14T17:30:00Z', updatedAt: '2026-05-14T17:30:00Z',
@@ -7603,140 +7703,11 @@ const MOCK_SUBCONTRACTOR_TASKS = [
projectId: 'PRJ-2026-001', projectId: 'PRJ-2026-001',
paymentStatus: 'Unpaid', paymentStatus: 'Unpaid',
paymentRequestedAt: null, paymentRequestedAt: null,
amountPaid: 0,
createdAt: '2026-05-18T22:40:00Z', createdAt: '2026-05-18T22:40:00Z',
updatedAt: '2026-05-18T23:10: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 // sct_007 Maya (sub_002) completed earlier this month
// ------------------------------------------------------------------- // -------------------------------------------------------------------
@@ -7812,6 +7783,7 @@ const MOCK_SUBCONTRACTOR_TASKS = [
projectId: 'PRJ-2026-001', projectId: 'PRJ-2026-001',
paymentStatus: 'Paid', paymentStatus: 'Paid',
paymentRequestedAt: '2026-05-07T16:50:00Z', paymentRequestedAt: '2026-05-07T16:50:00Z',
amountPaid: 1089,
paidAt: '2026-05-12T14:00:00Z', paidAt: '2026-05-12T14:00:00Z',
createdAt: '2026-05-01T09:00:00Z', createdAt: '2026-05-01T09:00:00Z',
updatedAt: '2026-05-07T17:00:00Z', updatedAt: '2026-05-07T17:00:00Z',
@@ -7908,6 +7880,7 @@ const MOCK_SUBCONTRACTOR_TASKS = [
projectId: 'PRJ-2026-004', projectId: 'PRJ-2026-004',
paymentStatus: 'Unpaid', paymentStatus: 'Unpaid',
paymentRequestedAt: '2026-05-19T12:35:00Z', paymentRequestedAt: '2026-05-19T12:35:00Z',
amountPaid: 0,
createdAt: '2026-05-16T13:00:00Z', createdAt: '2026-05-16T13:00:00Z',
updatedAt: '2026-05-19T18:00:00Z', updatedAt: '2026-05-19T18:00:00Z',
}, },
@@ -7996,61 +7969,268 @@ const MOCK_SUBCONTRACTOR_TASKS = [
projectId: 'PRJ-2026-006', projectId: 'PRJ-2026-006',
paymentStatus: 'Unpaid', paymentStatus: 'Unpaid',
paymentRequestedAt: null, paymentRequestedAt: null,
amountPaid: 0,
createdAt: '2026-05-20T09:00:00Z', createdAt: '2026-05-20T09:00:00Z',
updatedAt: '2026-05-27T11:05: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', companyId: 'lynkeduppro',
companyName: 'LynkedUp Pro Roofing', companyName: 'LynkedUp Pro Roofing',
subcontractorId: 'sub_004', subcontractorId: 'sub_004',
subcontractorName: 'Jennifer Castillo', subcontractorName: 'Jennifer Castillo',
assignedBy: 'own_001', assignedBy: 'own_001',
assignedByName: 'Justin Johnson', assignedByName: 'Justin Johnson',
title: 'Rough-in plumbing for ADU bathroom', title: 'Re-pipe master bath supply lines',
location: '6112 Roundrock Dr, Plano, TX 75025', location: '2612 Dunwick Dr, Plano, TX 75023',
description: 'New ADU build — bathroom rough-in. Coordinate with framing inspection.', description: 'Replace old galvanized supply lines in master bath with 1/2" PEX. Two shut-offs, one shower valve, one sink valve.',
dueDate: '2026-06-15', dueDate: '2026-04-30',
priority: 'medium', priority: 'medium',
category: 'Plumbing', category: 'Plumbing',
assignedDate: '2026-05-09T10:00:00Z', assignedDate: '2026-04-22T08:00:00Z',
status: 'Cancelled', status: 'Completed',
photos: [], photos: [],
statusHistory: [ 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_014_h1', status: 'Assigned', actorId: 'own_001', actorName: 'Justin Johnson', comment: 'Assigned.', at: '2026-04-22T08: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_h2', status: 'Completed', actorId: 'sub_004', actorName: 'Jennifer Castillo', comment: 'Complete.', at: '2026-04-28T15:00:00Z' },
], ],
thread: [ 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' }, expenses: [
{ 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 }, { 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: [
fees: [], { id: 'sct_014_f1', description: 'Labour — master bath re-pipe', type: 'Labour / Service Fee', amount: 740, createdAt: '2026-04-28T15:10:00Z' },
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',
},
], ],
projectId: null, activities: [],
paymentStatus: 'Unpaid', projectId: 'PRJ-2026-004',
paymentRequestedAt: null, paymentStatus: 'Paid',
createdAt: '2026-05-09T10:00:00Z', paymentRequestedAt: '2026-04-28T16:00:00Z',
updatedAt: '2026-05-20T15:30: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', taskId: 'sct_004', fromCompanyId: 'lynkeduppro', fromCompanyName: 'LynkedUp Pro Roofing',
isRead: false, createdAt: '2026-05-18T22:40:00Z', 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 -------------------------- // -- Message Received --------------------------
{ {
id: 'notif_005', recipientUserId: 'sub_002', recipientRole: 'SUBCONTRACTOR', id: 'notif_005', recipientUserId: 'sub_002', recipientRole: 'SUBCONTRACTOR',
@@ -8102,13 +8268,6 @@ const MOCK_NOTIFICATIONS = [
taskId: 'sct_004', fromCompanyId: 'lynkeduppro', fromCompanyName: 'LynkedUp Pro Roofing', taskId: 'sct_004', fromCompanyId: 'lynkeduppro', fromCompanyName: 'LynkedUp Pro Roofing',
isRead: false, createdAt: '2026-05-18T22:42:00Z', 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 ------------------------------ // -- Task Updated ------------------------------
{ {
id: 'notif_008', recipientUserId: 'sub_002', recipientRole: 'SUBCONTRACTOR', id: 'notif_008', recipientUserId: 'sub_002', recipientRole: 'SUBCONTRACTOR',
@@ -8139,13 +8298,6 @@ const MOCK_NOTIFICATIONS = [
taskId: 'sct_001', fromCompanyId: 'lynkeduppro', fromCompanyName: 'LynkedUp Pro Roofing', taskId: 'sct_001', fromCompanyId: 'lynkeduppro', fromCompanyName: 'LynkedUp Pro Roofing',
isRead: true, createdAt: '2026-05-16T08:30:00Z', 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', id: 'notif_013', recipientUserId: 'sub_002', recipientRole: 'SUBCONTRACTOR',
type: 'status_changed', type: 'status_changed',
@@ -10143,9 +10295,9 @@ export const LEAD_FORM_OPTIONS = {
'HOA / Community', 'HOA / Community',
], ],
leadSources: [ leadSources: [
'Door Knock', 'Canvassers',
'Referral', 'Referral',
'Storm Chase', 'Storm Chase/Self-Gen',
'Online / Web', 'Online / Web',
'Mailer / Postcard', 'Mailer / Postcard',
'Sign Call', 'Sign Call',
@@ -10274,51 +10426,243 @@ export const INITIAL_STORM_ZONES = [
export const MOCK_STORM_ATTRIBUTION_LEADS = [ export const MOCK_STORM_ATTRIBUTION_LEADS = [
// SE-001 · Apr 28 2026 · Severe · NE Plano // 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-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: '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-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: '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-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: '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-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: '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-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: '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-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: '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-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: '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-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 // 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-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: '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-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: '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-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: '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-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: '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-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 // 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-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: '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-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: '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-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: '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-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: '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-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: '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-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 // 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-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: '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-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: '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-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 // 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-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: '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-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 // 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-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: '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-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: '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-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: '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-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: '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-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: '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-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: '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-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 // 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-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: '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-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: '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-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: '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-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 [];
};
+3 -3
View File
@@ -140,7 +140,7 @@ export default function CreateLeadPage() {
...INITIAL_FORM, ...INITIAL_FORM,
city: 'Plano', city: 'Plano',
state: 'TX', state: 'TX',
leadSource: 'Door Knock', leadSource: 'Canvassers',
urgency, urgency,
notes: `Storm zone canvass — ${stormSource.areaName}.${stormSource.maxHailSize ? ` ${stormSource.maxHailSize}" hail` : ''}${stormDate ? ` on ${stormDate}` : ''}.`, notes: `Storm zone canvass — ${stormSource.areaName}.${stormSource.maxHailSize ? ` ${stormSource.maxHailSize}" hail` : ''}${stormDate ? ` on ${stormDate}` : ''}.`,
stormSource, stormSource,
@@ -186,7 +186,7 @@ export default function CreateLeadPage() {
setFormData(prev => { setFormData(prev => {
const next = { ...prev, [field]: value }; const next = { ...prev, [field]: value };
if (field === 'leadSource') { if (field === 'leadSource') {
if (value !== 'Door Knock') { next.canvasserId = ''; next.canvasserName = ''; } if (value !== 'Canvassers') { next.canvasserId = ''; next.canvasserName = ''; }
if (value !== 'Referral') { next.referralNote = ''; } if (value !== 'Referral') { next.referralNote = ''; }
} }
return next; return next;
@@ -215,7 +215,7 @@ export default function CreateLeadPage() {
if (!formData.address) missingFields.push('Street Address'); if (!formData.address) missingFields.push('Street Address');
if (!formData.city) missingFields.push('City'); if (!formData.city) missingFields.push('City');
if (!formData.leadSource) missingFields.push('Lead Source'); 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) { if (missingFields.length > 0) {
setValidationError(`Required: ${missingFields.join(', ')}`); setValidationError(`Required: ${missingFields.join(', ')}`);
+317 -81
View File
@@ -1,11 +1,11 @@
import React, { useState, useMemo, useRef, useEffect } from 'react'; 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 { 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 { LIFECYCLE_STAGES, REWORK_STAGE, nextStage, prevStage, stageToPct } from '../../data/lifecycle';
import { SpotlightCard } from '../../components/SpotlightCard'; import { SpotlightCard } from '../../components/SpotlightCard';
import { AnimatedCounter } from '../../components/AnimatedCounter'; import { AnimatedCounter } from '../../components/AnimatedCounter';
import { motion } from 'framer-motion'; import { motion, AnimatePresence } from 'framer-motion';
import { import {
BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer
} from 'recharts'; } from 'recharts';
@@ -20,6 +20,7 @@ import InvoiceDetailModal from '../../components/owner/InvoiceDetailModal';
import EstimateDetailModal from '../../components/estimates/EstimateDetailModal'; import EstimateDetailModal from '../../components/estimates/EstimateDetailModal';
import CreateItemModal from '../../components/owner/CreateItemModal'; import CreateItemModal from '../../components/owner/CreateItemModal';
import CommissionSettingsModal from '../../components/owner/CommissionSettingsModal'; import CommissionSettingsModal from '../../components/owner/CommissionSettingsModal';
import AddTeamCostModal from '../../components/owner/AddTeamCostModal';
import TeamManagementPanel from '../../components/owner/TeamManagementPanel'; import TeamManagementPanel from '../../components/owner/TeamManagementPanel';
import { usePermissions } from '../../hooks/usePermissions'; import { usePermissions } from '../../hooks/usePermissions';
import { resolveCommission, findAssignedUserRoleKey } from '../../utils/commissionResolver'; import { resolveCommission, findAssignedUserRoleKey } from '../../utils/commissionResolver';
@@ -114,7 +115,12 @@ function seedDocsMock(projectId) {
return all.slice(0, 4 + seed); 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 = [ 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_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' }, { 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_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' }, { 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; const seed = projectId.charCodeAt(projectId.length - 1) % 3;
return all.slice(0, 3 + seed); return all.slice(0, 3 + seed);
} }
@@ -133,13 +140,24 @@ const PAYMENTS_PER_PAGE = 5;
const EXPENSE_TYPE_CONFIG = { 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' }, 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' }, 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' }, 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' }, other: { label: 'Other', icon: DollarSign, color: 'text-zinc-600 dark:text-zinc-400', bg: 'bg-zinc-100 dark:bg-white/5', border: 'border-zinc-200 dark:border-white/10' },
}; };
const SUBCONTRACTOR_WORK_TYPES = ['Plumbing', 'Electrical', 'Painting', 'Roofing', 'Civil work', 'HVAC', 'Carpentry', 'Other']; const 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 inferExpenseType = (category = '') => {
const c = (category || '').toLowerCase(); const c = (category || '').toLowerCase();
if (c.includes('material')) return 'material'; if (c.includes('material')) return 'material';
@@ -150,27 +168,56 @@ const inferExpenseType = (category = '') => {
}; };
function seedExpenseRowsMock(project) { function seedExpenseRowsMock(project) {
const baseRows = (project?.budgetBreakdown || []).map((b, i) => ({ // Prefer per-project expenses data when defined on the project (even if empty)
id: `exp_base_${i}`, if (Array.isArray(project?.expenses)) {
type: inferExpenseType(b.category), return project.expenses.map((e, i) => {
name: b.name || '—', const allocated = Number(e.allocated) || 0;
category: b.category || '', const actual = Number(e.actual) || 0;
allocated: Number(b.allocated) || 0, return {
actual: Number(b.actual) || 0, id: e.id || `exp_${i}`,
paid: Number(b.actual) > 0 && Number(b.actual) >= Number(b.allocated) * 0.9, 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 seed = (project?.id || '').charCodeAt((project?.id || 'x').length - 1) % 3;
const teamRows = [ const teamRows = [
{ id: 'exp_team_1', type: 'team', name: 'Jesus Gonzales', category: 'Sales Rep', allocated: 4200, actual: 4200, paid: true }, { 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', allocated: 1500, actual: 1500, paid: false }, { 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', allocated: 1200, actual: 0, 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); ].slice(0, 2 + seed).map(row => ({
...row,
paymentRequests: generateMockPaymentRequests(row.id, row.actual, row.paid, row.allocated)
}));
const subRows = [ const subRows = [
{ id: 'exp_sub_1', type: 'subcontractor', name: 'PlumbPro Services', category: 'Plumbing', allocated: 1800, actual: 1800, paid: true }, { 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_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_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 }, { 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]; return [...baseRows, ...teamRows, ...subRows];
} }
@@ -199,14 +246,23 @@ const OwnerProjectDetail = () => {
} = useMockStore(); } = useMockStore();
const navigate = useNavigate(); const navigate = useNavigate();
const { can } = usePermissions(); 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 [selectedCO, setSelectedCO] = useState(null);
const [selectedInvoice, setSelectedInvoice] = useState(null); const [selectedInvoice, setSelectedInvoice] = useState(null);
const [selectedVersion, setSelectedVersion] = useState(null); const [selectedVersion, setSelectedVersion] = useState(null);
const [createModalConfig, setCreateModalConfig] = useState(null); const [createModalConfig, setCreateModalConfig] = useState(null);
// ── Payments state ───────────────────────────────────────────────────────── // ── 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 [paymentSort, setPaymentSort] = useState({ field: 'date', dir: 'desc' });
const [paymentPage, setPaymentPage] = useState(0); const [paymentPage, setPaymentPage] = useState(0);
@@ -214,6 +270,79 @@ const OwnerProjectDetail = () => {
const [expenseRows, setExpenseRows] = useState([]); const [expenseRows, setExpenseRows] = useState([]);
const [expensesSeeded, setExpensesSeeded] = useState(false); 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); const [openTooltip, setOpenTooltip] = useState(null);
// ── Commission config state ───────────────────────────────────────────── // ── Commission config state ─────────────────────────────────────────────
@@ -313,6 +442,7 @@ const OwnerProjectDetail = () => {
useEffect(() => { useEffect(() => {
if (project && !expensesSeeded) { if (project && !expensesSeeded) {
setExpenseRows(seedExpenseRowsMock(project)); setExpenseRows(seedExpenseRowsMock(project));
setPayments(seedPaymentsMock(project));
setExpensesSeeded(true); setExpensesSeeded(true);
} }
}, [project, expensesSeeded]); }, [project, expensesSeeded]);
@@ -328,9 +458,11 @@ const OwnerProjectDetail = () => {
type: type || data.type || 'other', type: type || data.type || 'other',
name: data.name || '', name: data.name || '',
category: data.category || '', category: data.category || '',
costType: data.costType || '',
allocated: amt, allocated: amt,
actual: amt, actual: amt,
paid: false, paid: false,
paymentRequests: [],
}; };
setExpenseRows(prev => [newRow, ...prev]); setExpenseRows(prev => [newRow, ...prev]);
}; };
@@ -371,11 +503,11 @@ const OwnerProjectDetail = () => {
const activeCommissionConfig = resolvedCommission; const activeCommissionConfig = resolvedCommission;
const expenseTotals = useMemo(() => { const expenseTotals = useMemo(() => {
const total = expenseRows.reduce((s, r) => s + (Number(r.actual) || Number(r.allocated) || 0), 0); const total = processedExpenseRows.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 paid = processedExpenseRows.reduce((s, r) => s + (r.paid ? (Number(r.actual) || Number(r.allocated) || 0) : 0), 0);
const outstanding = Math.max(0, total - paid); const outstanding = Math.max(0, total - paid);
return { total, paid, outstanding }; return { total, paid, outstanding };
}, [expenseRows]); }, [processedExpenseRows]);
const profitMetrics = useMemo(() => { 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 }; 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 ( return (
<button <button
key={tab.id} key={tab.id}
onClick={() => setActiveTab(tab.id)} onClick={() => {
setActiveTab(tab.id);
setSearchParams({ tab: tab.id });
}}
className={`flex items-center gap-1.5 sm:gap-2 px-2.5 sm:px-4 py-3 text-xs sm:text-sm font-semibold whitespace-nowrap border-b-2 transition-colors ${isActive className={`flex items-center gap-1.5 sm:gap-2 px-2.5 sm:px-4 py-3 text-xs sm:text-sm font-semibold whitespace-nowrap border-b-2 transition-colors ${isActive
? 'border-amber-500 text-amber-600 dark:text-amber-400' ? 'border-amber-500 text-amber-600 dark:text-amber-400'
: 'border-transparent text-zinc-500 hover:text-zinc-900 dark:hover:text-white' : 'border-transparent text-zinc-500 hover:text-zinc-900 dark:hover:text-white'
@@ -659,18 +794,36 @@ const OwnerProjectDetail = () => {
<div className="flex flex-wrap items-center gap-2 mb-5"> <div className="flex flex-wrap items-center gap-2 mb-5">
{!isAtFinalStage && ( {!isAtFinalStage && (
<button <button
onClick={() => setCreateModalConfig({ onClick={() => {
title: `Advance to "${nextStageName}"`, // Gate: a project can't move to "Work In Progress" without any team members assigned
icon: ChevronRight, const hasTeamMembers = (project.teamMembers || []).length > 0;
iconColor: 'text-sky-500 dark:text-[#00f0ff]', if (nextStageName === 'Work In Progress' && !hasTeamMembers) {
iconBg: 'bg-sky-500/10', setCreateModalConfig({
submitLabel: `Advance Stage`, title: 'Add Team Members First',
submitColor: 'bg-sky-500/20 text-sky-600 dark:text-[#00f0ff] border-sky-500/30', icon: Users,
fields: [ iconColor: 'text-amber-500 dark:text-amber-400',
{ name: 'note', label: 'Note (optional)', type: 'textarea', required: false, placeholder: 'Add a note about this stage transition…' }, iconBg: 'bg-amber-500/10',
], description: 'This project has no team members assigned yet. Add at least one team member in the Team tab before moving the project to "Work In Progress".',
onSubmit: (d) => advanceLifecycleStage(project.id, nextStageName, d.note || '', user?.name || user?.id || 'Owner'), submitLabel: 'Go to Team Tab',
})} submitColor: 'bg-purple-500/20 text-purple-600 dark:text-purple-400 border-purple-500/30',
fields: [],
onSubmit: () => setActiveTab('team'),
});
return;
}
setCreateModalConfig({
title: `Advance to "${nextStageName}"`,
icon: ChevronRight,
iconColor: 'text-sky-500 dark:text-[#00f0ff]',
iconBg: 'bg-sky-500/10',
submitLabel: `Advance Stage`,
submitColor: 'bg-sky-500/20 text-sky-600 dark:text-[#00f0ff] border-sky-500/30',
fields: [
{ name: 'note', label: 'Note (optional)', type: 'textarea', required: false, placeholder: 'Add a note about this stage transition…' },
],
onSubmit: (d) => advanceLifecycleStage(project.id, nextStageName, d.note || '', user?.name || user?.id || 'Owner'),
});
}}
className="flex items-center gap-2 px-4 py-2 rounded-xl bg-sky-500/15 hover:bg-sky-500/25 text-sky-600 dark:text-[#00f0ff] border border-sky-500/30 dark:border-[#00f0ff]/30 text-xs font-bold transition shadow-sm" className="flex items-center gap-2 px-4 py-2 rounded-xl bg-sky-500/15 hover:bg-sky-500/25 text-sky-600 dark:text-[#00f0ff] border border-sky-500/30 dark:border-[#00f0ff]/30 text-xs font-bold transition shadow-sm"
> >
<ChevronRight size={14} /> <ChevronRight size={14} />
@@ -1006,16 +1159,7 @@ const OwnerProjectDetail = () => {
</div> </div>
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<button <button
onClick={() => setCreateModalConfig({ onClick={() => setAddTeamCostOpen(true)}
title: 'Add Team Commission Cost', icon: UserCheck, iconColor: 'text-purple-500 dark:text-purple-400', iconBg: 'bg-purple-500/10',
submitLabel: 'Save Team Cost', submitColor: 'bg-purple-500/20 text-purple-500 dark:text-purple-400 border-purple-500/30',
fields: [
{ name: 'name', label: 'Team Member Name', type: 'text', required: true, placeholder: 'e.g. Jesus Gonzales' },
{ name: 'category', label: 'Role / Category', type: 'text', required: true, placeholder: 'e.g. Sales Rep' },
{ name: 'amount', label: 'Commission Amount ($)', type: 'number', required: true },
],
onSubmit: (d) => handleAddExpense(d, 'team'),
})}
className="bg-purple-500/10 hover:bg-purple-500/20 text-purple-600 dark:text-purple-400 border border-purple-500/30 px-3 py-1.5 rounded-lg text-xs font-bold transition flex items-center gap-2" className="bg-purple-500/10 hover:bg-purple-500/20 text-purple-600 dark:text-purple-400 border border-purple-500/30 px-3 py-1.5 rounded-lg text-xs font-bold transition flex items-center gap-2"
> >
<UserCheck size={14} /> Add Team Cost <UserCheck size={14} /> Add Team Cost
@@ -1061,47 +1205,124 @@ const OwnerProjectDetail = () => {
<th className="px-5 py-4 text-xs font-bold uppercase tracking-wider text-zinc-500 dark:text-zinc-400">Category / Role</th> <th className="px-5 py-4 text-xs font-bold uppercase tracking-wider text-zinc-500 dark:text-zinc-400">Category / Role</th>
<th className="px-5 py-4 text-xs font-bold uppercase tracking-wider text-zinc-500 dark:text-zinc-400 text-right">Allocated</th> <th className="px-5 py-4 text-xs font-bold uppercase tracking-wider text-zinc-500 dark:text-zinc-400 text-right">Allocated</th>
<th className="px-5 py-4 text-xs font-bold uppercase tracking-wider text-zinc-500 dark:text-zinc-400 text-right">Actual</th> <th className="px-5 py-4 text-xs font-bold uppercase tracking-wider text-zinc-500 dark:text-zinc-400 text-right">Actual</th>
<th className="px-5 py-4 text-xs font-bold uppercase tracking-wider text-zinc-500 dark:text-zinc-400 text-center">Paid</th> <th className="px-5 py-4 text-xs font-bold uppercase tracking-wider text-zinc-500 dark:text-zinc-400 text-right">Remaining</th>
<th className="px-5 py-4 text-xs font-bold uppercase tracking-wider text-zinc-500 dark:text-zinc-400 text-center">Status</th>
<th className="w-10"></th>
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-zinc-100 dark:divide-white/5"> <tbody className="divide-y divide-zinc-100 dark:divide-white/5">
{expenseRows.map((row) => { {processedExpenseRows.map((row) => {
const cfg = EXPENSE_TYPE_CONFIG[row.type] || EXPENSE_TYPE_CONFIG.other; const cfg = EXPENSE_TYPE_CONFIG[row.type] || EXPENSE_TYPE_CONFIG.other;
const TypeIcon = cfg.icon; const TypeIcon = cfg.icon;
const isExpanded = expandedRowId === row.id;
return ( return (
<tr key={row.id} className="hover:bg-zinc-50 dark:hover:bg-white/5 transition-colors"> <React.Fragment key={row.id}>
<td className="px-5 py-4"> <tr
<span className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-[10px] font-bold uppercase tracking-widest border ${cfg.color} ${cfg.bg} ${cfg.border}`}> onClick={() => toggleRowExpanded(row.id)}
<TypeIcon size={11} /> className={`transition-colors cursor-pointer border-l-2 ${
{cfg.label} isExpanded
</span> ? 'bg-amber-50/60 dark:bg-amber-500/[0.06] border-l-amber-500 dark:border-l-[#fda913]'
</td> : 'hover:bg-zinc-50 dark:hover:bg-white/5 border-l-transparent'
<td className="px-5 py-4 font-bold text-sm text-zinc-900 dark:text-white">{row.name || '—'}</td> }`}
<td className="px-5 py-4 text-sm text-zinc-500 dark:text-zinc-400">{row.category || '—'}</td> >
<td className="px-5 py-4 text-right font-mono text-sm text-zinc-600 dark:text-zinc-300">{formatCurrency(row.allocated)}</td> <td className="px-5 py-4">
<td className="px-5 py-4 text-right font-mono text-sm text-[#00f0ff]">{formatCurrency(row.actual)}</td> <span className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-[10px] font-bold uppercase tracking-widest border ${cfg.color} ${cfg.bg} ${cfg.border}`}>
<td className="px-5 py-4 text-center"> <TypeIcon size={11} />
<label className="inline-flex items-center justify-center cursor-pointer"> {cfg.label}{row.type === 'team' && row.costType ? ` · ${COST_TYPE_ABBR[row.costType] || row.costType}` : ''}
<input
type="checkbox"
checked={!!row.paid}
onChange={() => togglePaid(row.id)}
className="sr-only peer"
aria-label={`Mark ${row.name || 'expense'} paid`}
/>
<span className={`w-5 h-5 rounded-md border flex items-center justify-center transition-colors ${row.paid
? 'bg-emerald-500 border-emerald-500 dark:bg-[#39ff14] dark:border-[#39ff14]'
: 'bg-white dark:bg-[#18181b] border-zinc-300 dark:border-white/20 hover:border-emerald-400'}`}>
{row.paid && <CheckCircle size={12} className="text-white dark:text-black" strokeWidth={3} />}
</span> </span>
</label> </td>
</td> <td className="px-5 py-4 font-bold text-sm text-zinc-900 dark:text-white">{row.name || '—'}</td>
</tr> <td className="px-5 py-4 text-sm text-zinc-500 dark:text-zinc-400">{row.category || '—'}</td>
<td className="px-5 py-4 text-right font-mono text-sm text-zinc-600 dark:text-zinc-300">{formatCurrency(row.allocated)}</td>
<td className="px-5 py-4 text-right font-mono text-sm text-[#00f0ff]">{formatCurrency(row.actual)}</td>
<td className="px-5 py-4 text-right font-mono text-sm text-zinc-600 dark:text-zinc-300">{formatCurrency(row.remaining)}</td>
<td className="px-5 py-4 text-center">
<span className={`inline-flex items-center px-2.5 py-1 rounded-full text-[10px] font-bold uppercase tracking-widest border ${
row.status === 'Paid'
? 'text-green-600 dark:text-[#39ff14] bg-green-50 dark:bg-green-500/10 border-green-200 dark:border-green-500/20'
: row.status === 'Partially Paid'
? 'text-amber-600 dark:text-[#fda913] bg-amber-50 dark:bg-[#fda913]/10 border-amber-200 dark:border-[#fda913]/20'
: 'text-red-600 dark:text-[#ff003c] bg-red-50 dark:bg-[#ff003c]/10 border-red-200 dark:border-[#ff003c]/20'
}`}>
{row.status}
</span>
</td>
<td className="px-5 py-4 text-center">
<ChevronDownIcon
size={14}
className={`shrink-0 text-zinc-400 transition-transform duration-200 ${isExpanded ? 'rotate-180' : ''}`}
/>
</td>
</tr>
<AnimatePresence initial={false}>
{isExpanded && (
<tr className="bg-zinc-50/50 dark:bg-white/[0.02]">
<td colSpan={8} className="p-0 border-t border-zinc-200 dark:border-white/5">
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.2, ease: "easeInOut" }}
className="overflow-hidden"
>
<div className="p-5 bg-zinc-50/40 dark:bg-zinc-950/20">
<h4 className="text-xs font-bold uppercase tracking-widest text-zinc-400 dark:text-zinc-500 mb-3">Payment Request History</h4>
{row.paymentRequests && row.paymentRequests.length > 0 ? (
<div className="overflow-x-auto rounded-xl border border-zinc-200 dark:border-white/5 shadow-sm bg-white dark:bg-[#121214]">
<table className="w-full text-left border-collapse text-xs">
<thead className="bg-zinc-50 dark:bg-zinc-900/50 border-b border-zinc-200 dark:border-white/5">
<tr>
<th className="px-4 py-2.5 font-bold text-zinc-500 dark:text-zinc-400">Request Date</th>
<th className="px-4 py-2.5 font-bold text-zinc-500 dark:text-zinc-400 text-right">Requested Amount</th>
<th className="px-4 py-2.5 font-bold text-zinc-500 dark:text-zinc-400 text-right">Amount Paid</th>
<th className="px-4 py-2.5 font-bold text-zinc-500 dark:text-zinc-400 text-right">Remaining</th>
<th className="px-4 py-2.5 font-bold text-zinc-500 dark:text-zinc-400">Payment Date</th>
<th className="px-4 py-2.5 font-bold text-zinc-500 dark:text-zinc-400 text-center">Status</th>
</tr>
</thead>
<tbody className="divide-y divide-zinc-100 dark:divide-white/5">
{row.paymentRequests.map((req) => {
const reqRemaining = getRequestRemaining(req);
const reqStatus = getRequestStatus(req);
return (
<tr key={req.id} className="hover:bg-zinc-50/50 dark:hover:bg-white/[0.01] transition-colors">
<td className="px-4 py-3 text-zinc-600 dark:text-zinc-300 font-mono">{req.requestDate || '—'}</td>
<td className="px-4 py-3 text-right font-mono text-zinc-600 dark:text-zinc-300">{formatCurrency(req.requestedAmount)}</td>
<td className="px-4 py-3 text-right font-mono text-[#39ff14]">{formatCurrency(req.amountPaid)}</td>
<td className="px-4 py-3 text-right font-mono text-zinc-500">{formatCurrency(reqRemaining)}</td>
<td className="px-4 py-3 text-zinc-600 dark:text-zinc-300 font-mono">{req.paymentDate || '—'}</td>
<td className="px-4 py-3 text-center">
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-[9px] font-bold uppercase tracking-widest border ${
reqStatus === 'Paid'
? 'text-green-600 dark:text-[#39ff14] bg-green-50 dark:bg-green-500/10 border-green-200 dark:border-green-500/20'
: reqStatus === 'Partially Paid'
? 'text-amber-600 dark:text-[#fda913] bg-amber-50 dark:bg-[#fda913]/10 border-amber-200 dark:border-[#fda913]/20'
: 'text-red-600 dark:text-[#ff003c] bg-red-50 dark:bg-[#ff003c]/10 border-red-200 dark:border-[#ff003c]/20'
}`}>
{reqStatus}
</span>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
) : (
<p className="text-zinc-500 dark:text-zinc-400 text-xs italic pl-1">No payment requests recorded for this expense.</p>
)}
</div>
</motion.div>
</td>
</tr>
)}
</AnimatePresence>
</React.Fragment>
); );
})} })}
{expenseRows.length === 0 && ( {processedExpenseRows.length === 0 && (
<tr> <tr>
<td colSpan={6} className="px-5 py-10 text-center text-sm text-zinc-500 dark:text-zinc-400"> <td colSpan={8} className="px-5 py-10 text-center text-sm text-zinc-500 dark:text-zinc-400">
No expenses recorded yet. No expenses recorded yet.
</td> </td>
</tr> </tr>
@@ -1113,13 +1334,16 @@ const OwnerProjectDetail = () => {
<span className="text-xs font-bold uppercase tracking-widest text-zinc-500 dark:text-zinc-400">Totals</span> <span className="text-xs font-bold uppercase tracking-widest text-zinc-500 dark:text-zinc-400">Totals</span>
</td> </td>
<td className="px-5 py-4 text-right font-mono text-sm font-bold text-zinc-600 dark:text-zinc-300"> <td className="px-5 py-4 text-right font-mono text-sm font-bold text-zinc-600 dark:text-zinc-300">
{formatCurrency(expenseRows.reduce((s, r) => s + (Number(r.allocated) || 0), 0))} {formatCurrency(processedExpenseRows.reduce((s, r) => s + (Number(r.allocated) || 0), 0))}
</td> </td>
<td className="px-5 py-4 text-right font-mono text-sm font-bold text-[#00f0ff]"> <td className="px-5 py-4 text-right font-mono text-sm font-bold text-[#00f0ff]">
{formatCurrency(profitMetrics.totalExpenses)} {formatCurrency(profitMetrics.totalExpenses)}
</td> </td>
<td className="px-5 py-4 text-center text-[10px] font-mono font-bold text-zinc-500 dark:text-zinc-400 uppercase tracking-widest"> <td className="px-5 py-4 text-right font-mono text-sm font-bold text-zinc-600 dark:text-zinc-300">
{expenseRows.filter(r => r.paid).length}/{expenseRows.length} {formatCurrency(processedExpenseRows.reduce((s, r) => s + r.remaining, 0))}
</td>
<td className="px-5 py-4 text-center text-[10px] font-mono font-bold text-zinc-500 dark:text-zinc-400 uppercase tracking-widest" colSpan={2}>
{processedExpenseRows.filter(r => r.paid).length}/{processedExpenseRows.length}
</td> </td>
</tr> </tr>
</tfoot> </tfoot>
@@ -2538,6 +2762,18 @@ const OwnerProjectDetail = () => {
<span className="attribution-ghost">igotsar.matyas | LynkedUpPro - Turns out roofing CRMs don't build themselves. Who knew?</span> <span className="attribution-ghost">igotsar.matyas | LynkedUpPro - Turns out roofing CRMs don't build themselves. Who knew?</span>
{/* Add Team Cost Modal */}
<AddTeamCostModal
isOpen={addTeamCostOpen}
onClose={() => setAddTeamCostOpen(false)}
onSubmit={(data) => {
handleAddExpense(
{ name: data.name, category: data.category, costType: data.costType, amount: data.amount },
'team'
);
}}
/>
{/* Commission Settings Modal */} {/* Commission Settings Modal */}
<CommissionSettingsModal <CommissionSettingsModal
isOpen={commissionSettingsOpen} isOpen={commissionSettingsOpen}
+1 -1
View File
@@ -602,7 +602,7 @@ const OwnerSnapshot = () => {
<SubcontractorPaymentsPanel <SubcontractorPaymentsPanel
subcontractorTasks={subcontractorTasks} subcontractorTasks={subcontractorTasks}
subcontractors={subcontractors} subcontractors={subcontractors}
projects={projects} projects={ownerProjects}
/> />
</SpotlightCard> </SpotlightCard>
</section> </section>