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:
@@ -62,9 +62,9 @@ export default function LeadJobSection({ formData, updateField, isQuickCapture }
|
||||
accent="blue"
|
||||
/>
|
||||
|
||||
{/* --- Door Knock → Canvasser selector --- */}
|
||||
{/* --- Canvassers → Canvasser selector --- */}
|
||||
<AnimatePresence initial={false}>
|
||||
{formData.leadSource === 'Door Knock' && (
|
||||
{formData.leadSource === 'Canvassers' && (
|
||||
<motion.div
|
||||
key="canvasser"
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
|
||||
@@ -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;
|
||||
@@ -2,7 +2,7 @@ import React, { useEffect, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { X } from 'lucide-react';
|
||||
|
||||
const CreateItemModal = ({ isOpen, onClose, title, icon: Icon, iconColor = "text-[#00f0ff]", iconBg = "bg-blue-500/10", fields, onSubmit, submitLabel = "Submit", submitColor = "bg-blue-500/20 text-[#00f0ff] border-blue-500/30" }) => {
|
||||
const CreateItemModal = ({ isOpen, onClose, title, icon: Icon, iconColor = "text-[#00f0ff]", iconBg = "bg-blue-500/10", description, fields = [], onSubmit, submitLabel = "Submit", submitColor = "bg-blue-500/20 text-[#00f0ff] border-blue-500/30" }) => {
|
||||
const [formData, setFormData] = useState({});
|
||||
|
||||
useEffect(() => {
|
||||
@@ -44,6 +44,9 @@ const CreateItemModal = ({ isOpen, onClose, title, icon: Icon, iconColor = "text
|
||||
{/* Content */}
|
||||
<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">
|
||||
{description && (
|
||||
<p className="text-sm text-zinc-600 dark:text-zinc-300 leading-relaxed">{description}</p>
|
||||
)}
|
||||
{fields.map((field, i) => (
|
||||
<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>
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import React, { useMemo, useState, useRef, useEffect } from 'react';
|
||||
import { Wallet, Users, Search, ChevronDown, X, Check, FilterX } from 'lucide-react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Wallet, Users, Search, ChevronDown, ChevronRight, X, Check, FilterX } from 'lucide-react';
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Helpers
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
const CATEGORY_COLORS = {
|
||||
'Electrical': 'text-cyan-500 dark:text-cyan-400 bg-cyan-500/10 border-cyan-500/20',
|
||||
'Roofing': 'text-orange-500 dark:text-orange-400 bg-orange-500/10 border-orange-500/20',
|
||||
@@ -9,47 +13,67 @@ const CATEGORY_COLORS = {
|
||||
'Windows & Glazing': 'text-teal-500 dark:text-teal-400 bg-teal-500/10 border-teal-500/20',
|
||||
};
|
||||
|
||||
const PAYMENT_STATUS_COLORS = {
|
||||
'Paid': 'text-emerald-600 dark:text-emerald-400 bg-emerald-500/10 border-emerald-500/20',
|
||||
'Unpaid': 'text-amber-600 dark:text-amber-400 bg-amber-500/10 border-amber-500/20',
|
||||
// Contractor-level status: Paid | Partially Paid | Unpaid
|
||||
const CONTRACTOR_STATUS = {
|
||||
Paid: 'text-emerald-700 dark:text-emerald-400 bg-emerald-500/10 border-emerald-500/25',
|
||||
'Partially Paid':'text-amber-700 dark:text-amber-400 bg-amber-500/10 border-amber-500/25',
|
||||
Unpaid: 'text-red-700 dark:text-red-400 bg-red-500/10 border-red-500/25',
|
||||
};
|
||||
|
||||
// Request-level status colours
|
||||
const REQUEST_STATUS = {
|
||||
Paid: 'text-emerald-600 dark:text-emerald-400 bg-emerald-500/10 border-emerald-500/20',
|
||||
'Partially Paid':'text-amber-600 dark:text-amber-400 bg-amber-500/10 border-amber-500/20',
|
||||
Unpaid: 'text-red-600 dark:text-red-400 bg-red-500/10 border-red-500/20',
|
||||
};
|
||||
|
||||
function taskTotal(task) {
|
||||
const expenses = (task.expenses || []).reduce((s, e) => s + (Number(e.amount) || 0), 0);
|
||||
const fees = (task.fees || []).reduce((s, f) => s + (Number(f.amount) || 0), 0);
|
||||
const fees = (task.fees || []).reduce((s, f) => s + (Number(f.amount) || 0), 0);
|
||||
return expenses + fees;
|
||||
}
|
||||
|
||||
function taskPaid(task) {
|
||||
return Number(task.amountPaid || 0);
|
||||
}
|
||||
|
||||
const fmt = (amt) =>
|
||||
new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', maximumFractionDigits: 0 }).format(Number(amt) || 0);
|
||||
|
||||
const fmtDate = (iso) => {
|
||||
if (!iso) return '—';
|
||||
try {
|
||||
return new Date(iso).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' });
|
||||
} catch {
|
||||
return iso;
|
||||
}
|
||||
try { return new Date(iso).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' }); }
|
||||
catch { return iso; }
|
||||
};
|
||||
|
||||
// Dark-mode-aware dropdown — replaces native <select> so the option list
|
||||
// doesn't render with the OS default white background in dark mode.
|
||||
/** Derive contractor-level status from totals */
|
||||
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 }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const boxRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onClick = (e) => {
|
||||
if (boxRef.current && !boxRef.current.contains(e.target)) setOpen(false);
|
||||
};
|
||||
const onKey = (e) => { if (e.key === 'Escape') setOpen(false); };
|
||||
const onClick = (e) => { if (boxRef.current && !boxRef.current.contains(e.target)) setOpen(false); };
|
||||
const onKey = (e) => { if (e.key === 'Escape') setOpen(false); };
|
||||
document.addEventListener('mousedown', onClick);
|
||||
document.addEventListener('keydown', onKey);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', onClick);
|
||||
document.removeEventListener('keydown', onKey);
|
||||
};
|
||||
return () => { document.removeEventListener('mousedown', onClick); document.removeEventListener('keydown', onKey); };
|
||||
}, [open]);
|
||||
|
||||
const display = value === 'all' ? allLabel : value;
|
||||
@@ -57,11 +81,8 @@ function FilterPopover({ id, value, onChange, options, allLabel, ariaLabel }) {
|
||||
return (
|
||||
<div className="relative" ref={boxRef}>
|
||||
<button
|
||||
id={id}
|
||||
type="button"
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={open}
|
||||
aria-label={ariaLabel}
|
||||
id={id} type="button"
|
||||
aria-haspopup="listbox" aria-expanded={open} aria-label={ariaLabel}
|
||||
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"
|
||||
>
|
||||
@@ -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' : ''}`} />
|
||||
</button>
|
||||
{open && (
|
||||
<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"
|
||||
>
|
||||
<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">
|
||||
<div className="max-h-56 overflow-y-auto custom-scrollbar py-1">
|
||||
<button
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={value === 'all'}
|
||||
type="button" role="option" aria-selected={value === 'all'}
|
||||
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'}`}
|
||||
>
|
||||
@@ -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" />}
|
||||
</button>
|
||||
{options.map(opt => {
|
||||
const isSelected = opt === value;
|
||||
const isSel = opt === value;
|
||||
return (
|
||||
<button
|
||||
key={opt}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={isSelected}
|
||||
key={opt} type="button" role="option" aria-selected={isSel}
|
||||
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>
|
||||
{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>
|
||||
);
|
||||
})}
|
||||
@@ -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 = ({
|
||||
subcontractorTasks = [],
|
||||
subcontractors = [],
|
||||
projects = [],
|
||||
subcontractors = [],
|
||||
projects = [],
|
||||
}) => {
|
||||
const subById = useMemo(() => {
|
||||
const m = new Map();
|
||||
@@ -124,110 +299,146 @@ const SubcontractorPaymentsPanel = ({
|
||||
return m;
|
||||
}, [projects]);
|
||||
|
||||
const rows = useMemo(() => {
|
||||
// ── Build flat request rows (same logic as before) ──────────────────────
|
||||
const allRequests = useMemo(() => {
|
||||
const entries = [];
|
||||
for (const task of subcontractorTasks) {
|
||||
const amount = taskTotal(task);
|
||||
if (amount <= 0) continue;
|
||||
const sub = subById.get(task.subcontractorId);
|
||||
const requested = taskTotal(task);
|
||||
if (requested <= 0) continue;
|
||||
const sub = subById.get(task.subcontractorId);
|
||||
const project = task.projectId ? projectById.get(task.projectId) : null;
|
||||
if (!project) continue;
|
||||
entries.push({
|
||||
key: task.id,
|
||||
name: task.subcontractorName,
|
||||
companyName: sub?.companyName || '',
|
||||
projectName: project ? (project.projectType || project.address) : '—',
|
||||
projectAddress: project?.address || '',
|
||||
projectId: project?.id || null,
|
||||
workCategory: sub?.tradeType || '—',
|
||||
amount,
|
||||
requestedAt: task.paymentRequestedAt || null,
|
||||
paidAt: task.paidAt || null,
|
||||
paymentStatus: task.paymentStatus || 'Unpaid',
|
||||
taskId: task.id,
|
||||
subId: task.subcontractorId,
|
||||
subName: task.subcontractorName || '—',
|
||||
companyName: sub?.companyName || '',
|
||||
workCategory: sub?.tradeType || '—',
|
||||
projectId: project?.id || null,
|
||||
projectName: project ? (project.projectType || project.address) : '—',
|
||||
projectAddress: project?.address || '',
|
||||
requested,
|
||||
paid: taskPaid(task),
|
||||
requestedAt: task.paymentRequestedAt || null,
|
||||
paidAt: task.paidAt || null,
|
||||
paymentStatus: task.paymentStatus || 'Unpaid',
|
||||
});
|
||||
}
|
||||
return entries;
|
||||
}, [subcontractorTasks, subById, projectById]);
|
||||
|
||||
// ── Filter state ───────────────────────────────────────────────
|
||||
const [nameQuery, setNameQuery] = useState('');
|
||||
const [projectFilter, setProjectFilter] = useState('all');
|
||||
// ── Filter state ─────────────────────────────────────────────────────────
|
||||
const [nameQuery, setNameQuery] = useState('');
|
||||
const [categoryFilter, setCategoryFilter] = useState('all');
|
||||
const [statusFilter, setStatusFilter] = useState('all');
|
||||
const [statusFilter, setStatusFilter] = useState('all');
|
||||
|
||||
// Searchable project dropdown
|
||||
const [projectOpen, setProjectOpen] = useState(false);
|
||||
const [projectQuery, setProjectQuery] = useState('');
|
||||
const projectBoxRef = useRef(null);
|
||||
const categoryOptions = useMemo(() => Array.from(new Set(allRequests.map(r => r.workCategory).filter(Boolean))).sort(), [allRequests]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!projectOpen) return;
|
||||
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(() => {
|
||||
// Contractor-level status options for filter (computed after grouping)
|
||||
const contractorGroups = useMemo(() => {
|
||||
const map = new Map();
|
||||
for (const r of rows) {
|
||||
const key = r.projectName || '—';
|
||||
if (!map.has(key)) map.set(key, { name: key });
|
||||
}
|
||||
return Array.from(map.values()).sort((a, b) => a.name.localeCompare(b.name));
|
||||
}, [rows]);
|
||||
for (const req of allRequests) {
|
||||
if (!map.has(req.subId)) {
|
||||
map.set(req.subId, {
|
||||
subId: req.subId,
|
||||
subName: req.subName,
|
||||
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 set = new Set(rows.map(r => r.workCategory).filter(Boolean));
|
||||
return Array.from(set).sort();
|
||||
}, [rows]);
|
||||
const projKey = req.projectId || req.projectName;
|
||||
if (!contractor.projects.has(projKey)) {
|
||||
contractor.projects.set(projKey, {
|
||||
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 set = new Set(rows.map(r => r.paymentStatus).filter(Boolean));
|
||||
return Array.from(set).sort();
|
||||
}, [rows]);
|
||||
const s = new Set(contractorGroups.map(c => contractorStatus(c.totalRequested, c.totalPaid)));
|
||||
return Array.from(s).sort();
|
||||
}, [contractorGroups]);
|
||||
|
||||
const filteredProjectOptions = useMemo(() => {
|
||||
const q = projectQuery.trim().toLowerCase();
|
||||
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(() => {
|
||||
// ── Filtered contractor list ─────────────────────────────────────────────
|
||||
const filteredContractors = useMemo(() => {
|
||||
const q = nameQuery.trim().toLowerCase();
|
||||
return rows.filter(r => {
|
||||
if (q && !(r.name || '').toLowerCase().includes(q)) return false;
|
||||
if (projectFilter !== 'all' && r.projectName !== projectFilter) return false;
|
||||
if (categoryFilter !== 'all' && r.workCategory !== categoryFilter) return false;
|
||||
if (statusFilter !== 'all' && r.paymentStatus !== statusFilter) return false;
|
||||
return contractorGroups.filter(c => {
|
||||
// Name search
|
||||
if (q && !(c.subName || '').toLowerCase().includes(q) && !(c.companyName || '').toLowerCase().includes(q)) 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;
|
||||
}).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 outstanding = useMemo(
|
||||
() => filteredRows.filter(r => r.paymentStatus === 'Unpaid').reduce((s, r) => s + r.amount, 0),
|
||||
[filteredRows]
|
||||
);
|
||||
const grandTotalRequested = useMemo(() => filteredContractors.reduce((s, c) => s + c.totalRequested, 0), [filteredContractors]);
|
||||
const grandTotalPaid = useMemo(() => filteredContractors.reduce((s, c) => s + c.totalPaid, 0), [filteredContractors]);
|
||||
const grandTotalRemaining = Math.max(0, grandTotalRequested - grandTotalPaid);
|
||||
|
||||
const hasActiveFilters =
|
||||
nameQuery.trim() !== '' || projectFilter !== 'all' || categoryFilter !== 'all' || statusFilter !== 'all';
|
||||
const hasActiveFilters = nameQuery.trim() !== '' || categoryFilter !== 'all' || statusFilter !== 'all';
|
||||
|
||||
const clearFilters = () => {
|
||||
setNameQuery('');
|
||||
setProjectFilter('all');
|
||||
setCategoryFilter('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 (
|
||||
<div className="flex flex-col gap-3">
|
||||
|
||||
{/* Header */}
|
||||
<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} />
|
||||
@@ -236,95 +447,51 @@ const SubcontractorPaymentsPanel = ({
|
||||
</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">
|
||||
{hasActiveFilters
|
||||
? `${filteredRows.length} of ${rows.length} payment${rows.length === 1 ? '' : 's'}`
|
||||
: `${rows.length} payment${rows.length === 1 ? '' : 's'}`
|
||||
? `${filteredContractors.length} of ${contractorGroups.length} contractor${contractorGroups.length === 1 ? '' : 's'}`
|
||||
: `${contractorGroups.length} contractor${contractorGroups.length === 1 ? '' : 's'}`
|
||||
}
|
||||
</span>
|
||||
</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 */}
|
||||
{rows.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">
|
||||
{/* Search by Name */}
|
||||
{contractorGroups.length > 0 && (
|
||||
<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">
|
||||
{/* Name search */}
|
||||
<div className="relative">
|
||||
<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} />
|
||||
<input
|
||||
id="payments-name-search"
|
||||
type="text"
|
||||
id="payments-name-search" type="text"
|
||||
placeholder="Search by name..."
|
||||
value={nameQuery}
|
||||
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"
|
||||
/>
|
||||
{nameQuery && (
|
||||
<button
|
||||
type="button"
|
||||
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"
|
||||
>
|
||||
<button type="button" 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} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Project searchable dropdown */}
|
||||
<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 */}
|
||||
{/* Category filter */}
|
||||
<FilterPopover
|
||||
id="payments-category-filter"
|
||||
ariaLabel="Filter by work category"
|
||||
@@ -334,7 +501,7 @@ const SubcontractorPaymentsPanel = ({
|
||||
allLabel="All Work Categories"
|
||||
/>
|
||||
|
||||
{/* Payment Status filter */}
|
||||
{/* Contractor-status filter */}
|
||||
<FilterPopover
|
||||
id="payments-status-filter"
|
||||
ariaLabel="Filter by payment status"
|
||||
@@ -345,12 +512,9 @@ const SubcontractorPaymentsPanel = ({
|
||||
/>
|
||||
|
||||
{hasActiveFilters && (
|
||||
<div className="sm:col-span-2 lg:col-span-4 flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
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"
|
||||
>
|
||||
<div className="sm:col-span-3 flex justify-end">
|
||||
<button type="button" 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
|
||||
</button>
|
||||
</div>
|
||||
@@ -358,122 +522,155 @@ const SubcontractorPaymentsPanel = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{rows.length === 0 ? (
|
||||
{/* Empty state */}
|
||||
{contractorGroups.length === 0 ? (
|
||||
<div className="py-10 text-center">
|
||||
<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-xs text-zinc-400 mt-1">Tasks with logged fees or expenses will appear here.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto rounded-xl border border-zinc-200 dark:border-white/5">
|
||||
<table className="w-full text-left min-w-[860px]">
|
||||
<thead>
|
||||
<tr className="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>
|
||||
<th className="px-4 py-2.5 text-[10px] font-mono font-bold text-zinc-500 uppercase tracking-widest">Project</th>
|
||||
<th className="px-4 py-2.5 text-[10px] font-mono font-bold text-zinc-500 uppercase tracking-widest">Work Category</th>
|
||||
<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>
|
||||
<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>
|
||||
<th className="px-4 py-2.5 text-[10px] font-mono font-bold text-zinc-500 uppercase tracking-widest">Payment Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredRows.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={7} className="px-4 py-10 text-center">
|
||||
<Users size={22} className="mx-auto mb-2 text-zinc-400" />
|
||||
<p className="text-sm text-zinc-500">No payments match the current filters.</p>
|
||||
<div className="rounded-xl border border-zinc-200 dark:border-white/5 overflow-hidden">
|
||||
|
||||
{/* ── Parent table header ── */}
|
||||
<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]">
|
||||
{['Contractor Name', 'Payment Requested', 'Amount Left to be Paid', 'Status', ''].map((h, i) => (
|
||||
<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' : ''}`}>
|
||||
{h}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{filteredContractors.length === 0 ? (
|
||||
<div className="py-10 text-center">
|
||||
<Users size={22} className="mx-auto mb-2 text-zinc-400" />
|
||||
<p className="text-sm text-zinc-500">No contractors match the current filters.</p>
|
||||
<button type="button" onClick={clearFilters}
|
||||
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">
|
||||
<FilterX size={12} /> Clear Filters
|
||||
</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
|
||||
type="button"
|
||||
onClick={clearFilters}
|
||||
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"
|
||||
onClick={() => toggle(c.subId)}
|
||||
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
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{filteredRows.map((row) => (
|
||||
<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">
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<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">
|
||||
{row.name?.[0] || '?'}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="text-xs font-semibold text-zinc-900 dark:text-white truncate max-w-[180px]">{row.name}</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}
|
||||
{/* Desktop */}
|
||||
<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">
|
||||
{/* Contractor name */}
|
||||
<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">
|
||||
{c.subName?.[0] || '?'}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="text-xs font-semibold text-zinc-900 dark:text-white truncate">{c.subName}</div>
|
||||
{c.companyName && <div className="text-[10px] text-zinc-500 dark:text-zinc-400 truncate">{c.companyName}</div>}
|
||||
<div className="text-[10px] text-zinc-400 dark:text-zinc-500 mt-0.5">
|
||||
{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>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<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'}`}>
|
||||
{row.workCategory}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
<span className="font-mono text-xs font-bold text-amber-600 dark:text-amber-400">
|
||||
{fmt(row.amount)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className="text-[11px] font-mono text-zinc-500 dark:text-zinc-400 whitespace-nowrap">
|
||||
{fmtDate(row.requestedAt)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<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)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<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'}`}>
|
||||
{row.paymentStatus}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
<tfoot className="border-t-2 border-zinc-300 dark:border-white/10">
|
||||
<tr className="bg-zinc-50 dark:bg-white/[0.03]">
|
||||
<td colSpan={3} className="px-4 py-3 text-[10px] font-mono font-bold text-zinc-500 uppercase tracking-widest">
|
||||
{hasActiveFilters ? 'Filtered Total' : 'Total Amount'}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
<span className="font-mono text-sm font-extrabold text-amber-600 dark:text-amber-400">
|
||||
{fmt(total)}
|
||||
{/* Payment Requested */}
|
||||
<div className="px-4 py-3.5 flex items-center justify-end">
|
||||
<span className="font-mono text-xs font-bold text-amber-600 dark:text-amber-400">{fmt(c.totalRequested)}</span>
|
||||
</div>
|
||||
{/* Amount Left */}
|
||||
<div className="px-4 py-3.5 flex items-center justify-end">
|
||||
<span className={`font-mono text-xs font-bold ${remaining > 0 ? 'text-red-500 dark:text-red-400' : 'text-zinc-400 dark:text-zinc-500'}`}>
|
||||
{remaining > 0 ? fmt(remaining) : '—'}
|
||||
</span>
|
||||
</div>
|
||||
{/* Status */}
|
||||
<div className="px-4 py-3.5 flex items-center">
|
||||
<StatusBadge status={cStatus} colorMap={CONTRACTOR_STATUS} />
|
||||
</div>
|
||||
{/* Chevron */}
|
||||
<div className="px-2 py-3.5 flex items-center justify-center">
|
||||
{isExpanded
|
||||
? <ChevronDown size={15} className="text-amber-500 dark:text-amber-400 transition-transform" />
|
||||
: <ChevronRight size={15} className="text-zinc-400 transition-transform" />
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile card */}
|
||||
<div className="sm:hidden px-4 py-3.5 hover:bg-zinc-50 dark:hover:bg-white/[0.02] transition-colors">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="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">
|
||||
{c.subName?.[0] || '?'}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="text-xs font-semibold text-zinc-900 dark:text-white truncate">{c.subName}</div>
|
||||
{c.companyName && <div className="text-[10px] text-zinc-500 truncate">{c.companyName}</div>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<StatusBadge status={cStatus} colorMap={CONTRACTOR_STATUS} />
|
||||
{isExpanded
|
||||
? <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>
|
||||
</td>
|
||||
<td colSpan={3} />
|
||||
</tr>
|
||||
<tr className="bg-zinc-50 dark:bg-white/[0.03]">
|
||||
<td colSpan={3} className="px-4 py-3 text-[10px] font-mono font-bold text-zinc-500 uppercase tracking-widest">
|
||||
Outstanding (Unpaid)
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
<span className="font-mono text-sm font-extrabold text-amber-600 dark:text-amber-400">
|
||||
{fmt(outstanding)}
|
||||
</span>
|
||||
</td>
|
||||
<td colSpan={3} />
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
<div className="px-4 py-3 col-span-2" />
|
||||
</div>
|
||||
{/* Mobile totals */}
|
||||
<div className="sm:hidden px-4 py-3 flex items-center justify-between gap-4 flex-wrap">
|
||||
<span className="text-[10px] font-mono font-bold text-zinc-500 uppercase tracking-widest">{hasActiveFilters ? 'Filtered Total' : 'Grand Total'}</span>
|
||||
<div className="flex gap-4 text-xs font-mono font-bold">
|
||||
<span className="text-amber-600 dark:text-amber-400">{fmt(grandTotalRequested)} requested</span>
|
||||
{grandTotalRemaining > 0 && <span className="text-red-500 dark:text-red-400">{fmt(grandTotalRemaining)} outstanding</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
+575
-231
@@ -2158,6 +2158,43 @@ const MOCK_PROJECTS = [
|
||||
{ name: 'City of Plano', category: 'Permits & Inspection', allocated: 2500, committed: 2000, actual: 2000 },
|
||||
{ name: 'Junk King DFW', category: 'Cleanup & Haul-Away', allocated: 4000, committed: 1500, actual: 0 }
|
||||
],
|
||||
// Cost breakdown / expenses for the table (requested amounts never exceed actual)
|
||||
expenses: [
|
||||
{ id: 'exp_001_1', type: 'material', name: 'ABC Supply Co.', category: 'Materials - Shingles', allocated: 15000, actual: 7000, paid: false, paymentRequests: [
|
||||
{ id: 'req_001_1_1', requestDate: '2026-01-26', requestedAmount: 5000, amountPaid: 5000, paymentDate: '2026-01-28' },
|
||||
{ id: 'req_001_1_2', requestDate: '2026-02-10', requestedAmount: 2000, amountPaid: 1000, paymentDate: '2026-02-12' }
|
||||
] },
|
||||
{ id: 'exp_001_2', type: 'material', name: 'ABC Supply Co.', category: 'Materials - Underlayment', allocated: 3500, actual: 2500, paid: true, paymentRequests: [
|
||||
{ id: 'req_001_2_1', requestDate: '2026-01-27', requestedAmount: 2500, amountPaid: 2500, paymentDate: '2026-01-30' }
|
||||
] },
|
||||
{ id: 'exp_001_3', type: 'labor', name: 'Texas Roofing Crew A', category: 'Labor - Tear Off', allocated: 8000, actual: 5000, paid: true, paymentRequests: [
|
||||
{ id: 'req_001_3_1', requestDate: '2026-01-29', requestedAmount: 3000, amountPaid: 3000, paymentDate: '2026-02-01' },
|
||||
{ id: 'req_001_3_2', requestDate: '2026-02-03', requestedAmount: 2000, amountPaid: 2000, paymentDate: '2026-02-05' }
|
||||
] },
|
||||
{ id: 'exp_001_4', type: 'labor', name: 'Texas Roofing Crew A', category: 'Labor - Install', allocated: 12000, actual: 0, paid: false, paymentRequests: [] },
|
||||
{ id: 'exp_001_5', type: 'other', name: 'City of Plano', category: 'Permits & Inspection', allocated: 2500, actual: 2000, paid: true, paymentRequests: [
|
||||
{ id: 'req_001_5_1', requestDate: '2026-01-18', requestedAmount: 2000, amountPaid: 2000, paymentDate: '2026-01-20' }
|
||||
] },
|
||||
{ id: 'exp_001_6', type: 'other', name: 'Junk King DFW', category: 'Cleanup & Haul-Away', allocated: 4000, actual: 0, paid: false, paymentRequests: [] },
|
||||
{ id: 'exp_001_7', type: 'team', name: 'Jesus Gonzales', category: 'Sales Rep', costType: 'Commission', allocated: 4200, actual: 4200, paid: true, paymentRequests: [
|
||||
{ id: 'req_001_7_1', requestDate: '2026-02-06', requestedAmount: 4200, amountPaid: 4200, paymentDate: '2026-02-08' }
|
||||
] },
|
||||
{ id: 'exp_001_8', type: 'team', name: 'Cody Tatum', category: 'Canvasser', costType: 'Hourly Wage', allocated: 1500, actual: 1500, paid: false, paymentRequests: [
|
||||
{ id: 'req_001_8_1', requestDate: '2026-02-09', requestedAmount: 1500, amountPaid: 750, paymentDate: '2026-02-11' }
|
||||
] },
|
||||
{ id: 'exp_001_9', type: 'subcontractor', name: 'Carlos Mendoza', category: 'Electrical', allocated: 1800, actual: 1800, paid: true, paymentRequests: [
|
||||
{ id: 'req_001_9_1', requestDate: '2026-02-04', requestedAmount: 1800, amountPaid: 1800, paymentDate: '2026-02-06' }
|
||||
] },
|
||||
{ id: 'exp_001_10', type: 'subcontractor', name: 'Maya Patel', category: 'Roofing', allocated: 2400, actual: 2400, paid: false, paymentRequests: [
|
||||
{ id: 'req_001_10_1', requestDate: '2026-02-12', requestedAmount: 1200, amountPaid: 1200, paymentDate: '2026-02-14' },
|
||||
{ id: 'req_001_10_2', requestDate: '2026-02-20', requestedAmount: 1200, amountPaid: 0, paymentDate: null }
|
||||
] }
|
||||
],
|
||||
paymentsReceived: [
|
||||
{ id: 'rcv_001_1', from: 'State Farm Insurance', method: 'ACH', amount: 18500, date: '2026-01-28', refNumber: 'ACH-2026-4411', memo: 'Initial claim payout — roof replacement' },
|
||||
{ id: 'rcv_001_2', from: 'Justin Johnson', method: 'Check', amount: 2500, date: '2026-02-05', refNumber: 'CHK-8832', memo: 'Homeowner deductible payment' },
|
||||
{ id: 'rcv_001_3', from: 'State Farm Insurance', method: 'ACH', amount: 6200, date: '2026-02-18', refNumber: 'ACH-2026-4499', memo: 'Supplement approval — additional scope' }
|
||||
],
|
||||
changeOrders: [],
|
||||
rfis: [],
|
||||
riskLog: [
|
||||
@@ -2214,6 +2251,65 @@ const MOCK_PROJECTS = [
|
||||
],
|
||||
},
|
||||
|
||||
// TEST PROJECT: Contract Signed with NO team members — used to verify the
|
||||
// "Add Team Members First" guard when advancing to "Work In Progress".
|
||||
{
|
||||
id: 'PRJ-2026-TEST',
|
||||
ownerId: 'own_001',
|
||||
propertyId: 'P-2602',
|
||||
address: 'TEST — 100 Demo Street, Plano, TX 75023',
|
||||
contractorId: 'con_001',
|
||||
subcontractorIds: [],
|
||||
projectType: 'Roof Replacement',
|
||||
status: 'active',
|
||||
phase: 'Pre-Construction',
|
||||
budget: 32000,
|
||||
approvedBudget: 32000,
|
||||
committedCost: 12000,
|
||||
actualCost: 0,
|
||||
spent: 0,
|
||||
variancePercent: 0,
|
||||
margin: 0.30,
|
||||
commissionType: 'percent_gross',
|
||||
commissionRate: 10,
|
||||
commission: 3200,
|
||||
startDate: '2026-06-01',
|
||||
endDate: '2026-07-15',
|
||||
completionPercentage: 40,
|
||||
healthScore: 78,
|
||||
openRFIs: 0,
|
||||
changeOrderCount: 0,
|
||||
pendingInvoiceCount: 0,
|
||||
budgetBreakdown: [],
|
||||
// Cost breakdown / expenses for the table (Contract Signed — allocated only, nothing spent yet)
|
||||
expenses: [],
|
||||
// No funds received yet at Contract Signed
|
||||
paymentsReceived: [],
|
||||
changeOrders: [],
|
||||
rfis: [],
|
||||
riskLog: [],
|
||||
issueLog: [],
|
||||
paymentSchedule: [
|
||||
{ id: 'ps_test_1', milestone: 'Mobilization (20%)', amount: 6400, dueDate: '2026-06-05', status: 'pending' }
|
||||
],
|
||||
activityTimeline: [
|
||||
{ date: '2026-06-01', action: 'Contract Signed', user: 'Justin Johnson', details: 'Contract executed; awaiting team assignment.' }
|
||||
],
|
||||
milestones: [],
|
||||
invoices: [],
|
||||
documents: [
|
||||
{ name: 'Scope of Work - Roof Replacement', url: '#', type: 'contract' }
|
||||
],
|
||||
teamMembers: [],
|
||||
lifecycleStage: 'Contract Signed',
|
||||
stageHistory: [
|
||||
{ stage: 'Lead', date: '2026-05-15', by: 'Justin Johnson', note: 'Storm damage lead identified — TEST project.' },
|
||||
{ stage: 'Damage Verified', date: '2026-05-20', by: 'Justin Johnson', note: 'Adjuster confirmed hail damage; claim approved.' },
|
||||
{ stage: 'Scope Approved', date: '2026-05-25', by: 'Justin Johnson', note: 'Full roof replacement scope signed off.' },
|
||||
{ stage: 'Contract Signed', date: '2026-06-01', by: 'Justin Johnson', note: 'Contract executed; mobilization deposit pending.' },
|
||||
],
|
||||
},
|
||||
|
||||
// Project 2: Completed, zero variance (perfectly on budget)
|
||||
{
|
||||
id: 'PRJ-2026-002',
|
||||
@@ -7388,8 +7484,10 @@ const MOCK_SUBCONTRACTOR_TASKS = [
|
||||
},
|
||||
],
|
||||
projectId: 'PRJ-2026-004',
|
||||
paymentStatus: 'Unpaid',
|
||||
paymentStatus: 'Partially Paid',
|
||||
paymentRequestedAt: '2026-05-16T17:30:00Z',
|
||||
amountPaid: 350,
|
||||
paidAt: '2026-05-20T11:00:00Z',
|
||||
createdAt: '2026-05-15T09:12:00Z',
|
||||
updatedAt: '2026-05-16T17:30:00Z',
|
||||
},
|
||||
@@ -7484,6 +7582,7 @@ const MOCK_SUBCONTRACTOR_TASKS = [
|
||||
projectId: 'PRJ-2026-003',
|
||||
paymentStatus: 'Unpaid',
|
||||
paymentRequestedAt: '2026-05-12T17:00:00Z',
|
||||
amountPaid: 0,
|
||||
createdAt: '2026-05-10T15:40:00Z',
|
||||
updatedAt: '2026-05-12T17:00:00Z',
|
||||
},
|
||||
@@ -7561,6 +7660,7 @@ const MOCK_SUBCONTRACTOR_TASKS = [
|
||||
projectId: 'PRJ-2026-006',
|
||||
paymentStatus: 'Paid',
|
||||
paymentRequestedAt: '2026-05-14T17:35:00Z',
|
||||
amountPaid: 232,
|
||||
paidAt: '2026-05-18T10:15:00Z',
|
||||
createdAt: '2026-05-05T11:00:00Z',
|
||||
updatedAt: '2026-05-14T17:30:00Z',
|
||||
@@ -7603,140 +7703,11 @@ const MOCK_SUBCONTRACTOR_TASKS = [
|
||||
projectId: 'PRJ-2026-001',
|
||||
paymentStatus: 'Unpaid',
|
||||
paymentRequestedAt: null,
|
||||
amountPaid: 0,
|
||||
createdAt: '2026-05-18T22:40:00Z',
|
||||
updatedAt: '2026-05-18T23:10:00Z',
|
||||
},
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// sct_005 — Maya (sub_002) — Attic ventilation inspection, On Hold
|
||||
// -------------------------------------------------------------------
|
||||
{
|
||||
id: 'sct_005',
|
||||
companyId: 'lynkeduppro',
|
||||
companyName: 'LynkedUp Pro Roofing',
|
||||
subcontractorId: 'sub_002',
|
||||
subcontractorName: 'Maya Patel',
|
||||
assignedBy: 'a1',
|
||||
assignedByName: 'Wade Hollis',
|
||||
title: 'Inspect attic ventilation',
|
||||
location: '6909 Custer Rd, Plano, TX 75023',
|
||||
description: 'Customer reports unusually high attic temps. Check ridge vent, soffit airflow, and intake/exhaust balance. Recommend remediation.',
|
||||
dueDate: '2026-05-30',
|
||||
priority: 'medium',
|
||||
category: 'Inspection',
|
||||
assignedDate: '2026-05-12T10:00:00Z',
|
||||
status: 'On Hold',
|
||||
photos: [
|
||||
{ id: 'sct_005_p1', name: 'Attic_View.jpg', url: '/assets/images/properties/Roof_Inspection_Angle.jpg', annotation: 'Access from south gable' },
|
||||
],
|
||||
statusHistory: [
|
||||
{ id: 'sct_005_h1', status: 'Assigned', actorId: 'a1', actorName: 'Wade Hollis', comment: 'Task created.', at: '2026-05-12T10:00:00Z' },
|
||||
{ id: 'sct_005_h2', status: 'In Progress', actorId: 'sub_002', actorName: 'Maya Patel', comment: 'Initial pass done; baffles look blocked.', at: '2026-05-14T11:00:00Z' },
|
||||
{ id: 'sct_005_h3', status: 'On Hold', actorId: 'sub_002', actorName: 'Maya Patel', comment: 'Waiting on homeowner to clear stored boxes from access path.', at: '2026-05-15T16:00:00Z' },
|
||||
],
|
||||
thread: [
|
||||
{ id: 'sct_005_t1', channel: 'group', senderId: 'sub_002', senderName: 'Maya Patel', senderRole: 'Subcontractor', body: 'Access is blocked — homeowner needs to move some storage before I can complete the inspection.', at: '2026-05-15T15:55:00Z', mine: true },
|
||||
{ id: 'sct_005_t2', channel: 'direct', senderId: 'a1', senderName: 'Wade Hollis', senderRole: 'Admin', body: 'I will reach out to the homeowner and reschedule.', at: '2026-05-15T16:20:00Z' },
|
||||
],
|
||||
expenses: [
|
||||
{ id: 'sct_005_e1', description: 'Infrared thermometer (rental)', category: 'Equipment Rental', amount: 35.0, notes: '', receiptUrl: '', createdAt: '2026-05-14T10:30:00Z' },
|
||||
],
|
||||
fees: [
|
||||
{ id: 'sct_005_f1', description: 'Inspection — initial visit', type: 'Inspection Fee', amount: 125, createdAt: '2026-05-14T13:00:00Z' },
|
||||
],
|
||||
activities: [
|
||||
{
|
||||
id: 'sct_005_a1', type: 'status_change', status: 'Assigned',
|
||||
note: 'Task created. Customer reports unusually high attic temps.',
|
||||
photos: [],
|
||||
actorId: 'a1', actorName: 'Wade Hollis', actorRole: 'Admin',
|
||||
at: '2026-05-12T10:00:00Z',
|
||||
},
|
||||
{
|
||||
id: 'sct_005_a2', type: 'status_change', status: 'In Progress',
|
||||
note: 'Initial inspection pass — soffit baffles appear blocked. Need attic access for confirmation.',
|
||||
photos: [
|
||||
{ id: 'sct_005_a2_p1', url: '/assets/images/properties/Roof_Inspection_Angle.jpg', name: 'south-gable.jpg' },
|
||||
],
|
||||
actorId: 'sub_002', actorName: 'Maya Patel', actorRole: 'Subcontractor',
|
||||
at: '2026-05-14T11:00:00Z',
|
||||
},
|
||||
{
|
||||
id: 'sct_005_a3', type: 'status_change', status: 'On Hold',
|
||||
note: 'Work stopped — homeowner needs to clear stored boxes blocking attic access before I can finish.',
|
||||
photos: [
|
||||
{ id: 'sct_005_a3_p1', url: '/assets/images/properties/Beige_Two_Story_House.jpg', name: 'access-blocked.jpg' },
|
||||
],
|
||||
actorId: 'sub_002', actorName: 'Maya Patel', actorRole: 'Subcontractor',
|
||||
at: '2026-05-15T16:00:00Z',
|
||||
},
|
||||
],
|
||||
projectId: 'PRJ-2026-007',
|
||||
paymentStatus: 'Unpaid',
|
||||
paymentRequestedAt: '2026-05-14T13:00:00Z',
|
||||
createdAt: '2026-05-12T10:00:00Z',
|
||||
updatedAt: '2026-05-15T16:20:00Z',
|
||||
},
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// sct_006 — Maya (sub_002) — Gutter replacement, Pre-Work Inspection
|
||||
// -------------------------------------------------------------------
|
||||
{
|
||||
id: 'sct_006',
|
||||
companyId: 'lynkeduppro',
|
||||
companyName: 'LynkedUp Pro Roofing',
|
||||
subcontractorId: 'sub_002',
|
||||
subcontractorName: 'Maya Patel',
|
||||
assignedBy: 'own_001',
|
||||
assignedByName: 'Justin Johnson',
|
||||
title: 'Replace gutter on south elevation',
|
||||
location: '7224 Independence Pkwy, Plano, TX 75025',
|
||||
description: '30 ft section of K-style gutter — pulling away from fascia after storm. Replace with new 6" oversized gutter, match existing bronze color.',
|
||||
dueDate: '2026-06-02',
|
||||
priority: 'low',
|
||||
category: 'Gutters',
|
||||
assignedDate: '2026-05-17T14:00:00Z',
|
||||
status: 'Pre-Work Inspection',
|
||||
photos: [
|
||||
{ id: 'sct_006_p1', name: 'Gutter_Damage.jpg', url: '/assets/images/properties/Broken_Roof_Surface.jpg', annotation: 'Gutter pulling away from fascia — south elevation' },
|
||||
],
|
||||
statusHistory: [
|
||||
{ id: 'sct_006_h1', status: 'Assigned', actorId: 'own_001', actorName: 'Justin Johnson', comment: 'Task created and assigned.', at: '2026-05-17T14:00:00Z' },
|
||||
{ id: 'sct_006_h2', status: 'Pre-Work Inspection', actorId: 'sub_002', actorName: 'Maya Patel', comment: 'Site walk done — materials staged, access confirmed. Measured 32 ft run, bronze 6" gutter ordered.', at: '2026-05-26T10:00:00Z' },
|
||||
],
|
||||
thread: [
|
||||
{ id: 'sct_006_t1', channel: 'group', senderId: 'sub_002', senderName: 'Maya Patel', senderRole: 'Subcontractor', body: 'On site for pre-work walkthrough. Gutter is pulling at four hangers — 32 ft run needs full replacement. Ordering materials now.', at: '2026-05-26T10:05:00Z', mine: true },
|
||||
{ id: 'sct_006_t2', channel: 'group', senderId: 'own_001', senderName: 'Justin Johnson', senderRole: 'Owner', body: 'Perfect. Confirm when materials arrive and I will coordinate access with the homeowner.', at: '2026-05-26T10:30:00Z' },
|
||||
],
|
||||
expenses: [
|
||||
{ id: 'sct_006_e1', description: '6" K-style gutter bronze (32 ft)', category: 'Materials', amount: 118.0, notes: 'Special order — delivery expected May 28.', receiptUrl: '', createdAt: '2026-05-26T11:00:00Z' },
|
||||
],
|
||||
fees: [],
|
||||
activities: [
|
||||
{
|
||||
id: 'sct_006_a1', type: 'status_change', status: 'Assigned',
|
||||
note: 'Task created and assigned. 30 ft gutter section pulling away from fascia after storm damage.',
|
||||
photos: [],
|
||||
actorId: 'own_001', actorName: 'Justin Johnson', actorRole: 'Owner',
|
||||
at: '2026-05-17T14:00:00Z',
|
||||
},
|
||||
{
|
||||
id: 'sct_006_a2', type: 'status_change', status: 'Pre-Work Inspection',
|
||||
note: 'Site walk done — measured 32 ft run, confirmed bronze colour match, four failed hangers identified. Materials ordered, work scheduled for May 29.',
|
||||
photos: [
|
||||
{ id: 'sct_006_a2_p1', url: '/assets/images/properties/Broken_Roof_Surface.jpg', name: 'gutter-inspection.jpg' },
|
||||
],
|
||||
actorId: 'sub_002', actorName: 'Maya Patel', actorRole: 'Subcontractor',
|
||||
at: '2026-05-26T10:00:00Z',
|
||||
},
|
||||
],
|
||||
projectId: 'PRJ-2026-009',
|
||||
paymentStatus: 'Unpaid',
|
||||
paymentRequestedAt: null,
|
||||
createdAt: '2026-05-17T14:00:00Z',
|
||||
updatedAt: '2026-05-26T11:00:00Z',
|
||||
},
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// sct_007 — Maya (sub_002) — completed earlier this month
|
||||
// -------------------------------------------------------------------
|
||||
@@ -7812,6 +7783,7 @@ const MOCK_SUBCONTRACTOR_TASKS = [
|
||||
projectId: 'PRJ-2026-001',
|
||||
paymentStatus: 'Paid',
|
||||
paymentRequestedAt: '2026-05-07T16:50:00Z',
|
||||
amountPaid: 1089,
|
||||
paidAt: '2026-05-12T14:00:00Z',
|
||||
createdAt: '2026-05-01T09:00:00Z',
|
||||
updatedAt: '2026-05-07T17:00:00Z',
|
||||
@@ -7908,6 +7880,7 @@ const MOCK_SUBCONTRACTOR_TASKS = [
|
||||
projectId: 'PRJ-2026-004',
|
||||
paymentStatus: 'Unpaid',
|
||||
paymentRequestedAt: '2026-05-19T12:35:00Z',
|
||||
amountPaid: 0,
|
||||
createdAt: '2026-05-16T13:00:00Z',
|
||||
updatedAt: '2026-05-19T18:00:00Z',
|
||||
},
|
||||
@@ -7996,61 +7969,268 @@ const MOCK_SUBCONTRACTOR_TASKS = [
|
||||
projectId: 'PRJ-2026-006',
|
||||
paymentStatus: 'Unpaid',
|
||||
paymentRequestedAt: null,
|
||||
amountPaid: 0,
|
||||
createdAt: '2026-05-20T09:00:00Z',
|
||||
updatedAt: '2026-05-27T11:05:00Z',
|
||||
},
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// sct_009 — Jennifer Castillo (sub_004) — Cancelled plumbing
|
||||
// sct_011 — Maya Patel (sub_002) — full roof replacement PRJ-2026-001, PAID
|
||||
// -------------------------------------------------------------------
|
||||
{
|
||||
id: 'sct_009',
|
||||
id: 'sct_011',
|
||||
companyId: 'lynkeduppro',
|
||||
companyName: 'LynkedUp Pro Roofing',
|
||||
subcontractorId: 'sub_002',
|
||||
subcontractorName: 'Maya Patel',
|
||||
assignedBy: 'own_001',
|
||||
assignedByName: 'Justin Johnson',
|
||||
title: 'Full roof replacement — PRJ-001',
|
||||
location: '2604 Dunwick Dr, Plano, TX 75023',
|
||||
description: 'Insurance-approved full tear-off and replacement. 28 sq IKO Cambridge Charcoal. Includes ice & water shield at eaves.',
|
||||
dueDate: '2026-04-20',
|
||||
priority: 'high',
|
||||
category: 'Roofing',
|
||||
assignedDate: '2026-04-10T09:00:00Z',
|
||||
status: 'Completed',
|
||||
photos: [],
|
||||
statusHistory: [
|
||||
{ id: 'sct_011_h1', status: 'Assigned', actorId: 'own_001', actorName: 'Justin Johnson', comment: 'Task assigned.', at: '2026-04-10T09:00:00Z' },
|
||||
{ id: 'sct_011_h2', status: 'Completed', actorId: 'sub_002', actorName: 'Maya Patel', comment: 'Roof complete.', at: '2026-04-18T15:00:00Z' },
|
||||
],
|
||||
thread: [],
|
||||
expenses: [
|
||||
{ id: 'sct_011_e1', description: 'IKO Cambridge Charcoal — 28 sq', category: 'Materials', amount: 2940, notes: '', receiptUrl: '', createdAt: '2026-04-11T08:00:00Z' },
|
||||
{ id: 'sct_011_e2', description: 'Ice & water shield — 4 rolls', category: 'Materials', amount: 320, notes: '', receiptUrl: '', createdAt: '2026-04-11T08:10:00Z' },
|
||||
{ id: 'sct_011_e3', description: 'Dumpster — 3 days', category: 'Disposal / Dumpster', amount: 420, notes: '', receiptUrl: '', createdAt: '2026-04-18T16:00:00Z' },
|
||||
{ id: 'sct_011_e4', description: 'Fuel & crew transport', category: 'Travel / Fuel', amount: 140, notes: '', receiptUrl: '', createdAt: '2026-04-18T17:00:00Z' },
|
||||
],
|
||||
fees: [
|
||||
{ id: 'sct_011_f1', description: 'Labour — full replacement (3-day crew)', type: 'Labour / Service Fee', amount: 4200, createdAt: '2026-04-18T15:30:00Z' },
|
||||
{ id: 'sct_011_f2', description: 'Mobilization', type: 'Mobilization Fee', amount: 200, createdAt: '2026-04-10T09:00:00Z' },
|
||||
],
|
||||
activities: [],
|
||||
projectId: 'PRJ-2026-001',
|
||||
paymentStatus: 'Paid',
|
||||
paymentRequestedAt: '2026-04-18T16:00:00Z',
|
||||
amountPaid: 8220,
|
||||
paidAt: '2026-04-25T10:00:00Z',
|
||||
createdAt: '2026-04-10T09:00:00Z',
|
||||
updatedAt: '2026-04-18T17:00:00Z',
|
||||
},
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// sct_012 — Maya Patel (sub_002) — shingle repair PRJ-2026-003, Partially Paid
|
||||
// -------------------------------------------------------------------
|
||||
{
|
||||
id: 'sct_012',
|
||||
companyId: 'lynkeduppro',
|
||||
companyName: 'LynkedUp Pro Roofing',
|
||||
subcontractorId: 'sub_002',
|
||||
subcontractorName: 'Maya Patel',
|
||||
assignedBy: 'a1',
|
||||
assignedByName: 'Wade Hollis',
|
||||
title: 'Hail damage shingle repair — PRJ-003',
|
||||
location: '6613 Phoenix Pl, Plano, TX 75023',
|
||||
description: 'Replace 6 damaged shingle sections on west slope identified during adjuster inspection.',
|
||||
dueDate: '2026-05-10',
|
||||
priority: 'medium',
|
||||
category: 'Roofing',
|
||||
assignedDate: '2026-05-02T09:00:00Z',
|
||||
status: 'Completed',
|
||||
photos: [],
|
||||
statusHistory: [
|
||||
{ id: 'sct_012_h1', status: 'Assigned', actorId: 'a1', actorName: 'Wade Hollis', comment: 'Assigned.', at: '2026-05-02T09:00:00Z' },
|
||||
{ id: 'sct_012_h2', status: 'Completed', actorId: 'sub_002', actorName: 'Maya Patel', comment: 'Complete.', at: '2026-05-09T14:00:00Z' },
|
||||
],
|
||||
thread: [],
|
||||
expenses: [
|
||||
{ id: 'sct_012_e1', description: 'Materials — premium shingles, underlayment & sealant', category: 'Materials', amount: 1200, notes: '', receiptUrl: '', createdAt: '2026-05-03T07:30:00Z' },
|
||||
],
|
||||
fees: [
|
||||
{ id: 'sct_012_f1', description: 'Labour — shingle repair and roof patch', type: 'Labour / Service Fee', amount: 3800, createdAt: '2026-05-09T14:10:00Z' },
|
||||
],
|
||||
activities: [],
|
||||
projectId: 'PRJ-2026-003',
|
||||
paymentStatus: 'Partially Paid',
|
||||
paymentRequestedAt: '2026-05-09T15:00:00Z',
|
||||
amountPaid: 4000,
|
||||
paidAt: '2026-05-15T09:30:00Z',
|
||||
createdAt: '2026-05-02T09:00:00Z',
|
||||
updatedAt: '2026-05-09T15:00:00Z',
|
||||
},
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// sct_013 — Carlos Mendoza (sub_001) — electrical rough-in PRJ-2026-001, PAID
|
||||
// -------------------------------------------------------------------
|
||||
{
|
||||
id: 'sct_013',
|
||||
companyId: 'lynkeduppro',
|
||||
companyName: 'LynkedUp Pro Roofing',
|
||||
subcontractorId: 'sub_001',
|
||||
subcontractorName: 'Carlos Mendoza',
|
||||
assignedBy: 'own_001',
|
||||
assignedByName: 'Justin Johnson',
|
||||
title: 'Electrical rough-in — PRJ-001 addition',
|
||||
location: '2604 Dunwick Dr, Plano, TX 75023',
|
||||
description: 'Rough-in wiring for garage addition: 2 circuits, 4 outlets, overhead LED fixture prewire.',
|
||||
dueDate: '2026-04-15',
|
||||
priority: 'medium',
|
||||
category: 'Electrical',
|
||||
assignedDate: '2026-04-08T10:00:00Z',
|
||||
status: 'Completed',
|
||||
photos: [],
|
||||
statusHistory: [
|
||||
{ id: 'sct_013_h1', status: 'Assigned', actorId: 'own_001', actorName: 'Justin Johnson', comment: 'Assigned.', at: '2026-04-08T10:00:00Z' },
|
||||
{ id: 'sct_013_h2', status: 'Completed', actorId: 'sub_001', actorName: 'Carlos Mendoza', comment: 'Complete.', at: '2026-04-14T16:00:00Z' },
|
||||
],
|
||||
thread: [],
|
||||
expenses: [
|
||||
{ id: 'sct_013_e1', description: '14/2 NM cable — 100 ft', category: 'Materials', amount: 68, notes: '', receiptUrl: '', createdAt: '2026-04-09T07:00:00Z' },
|
||||
{ id: 'sct_013_e2', description: 'Outlet boxes + covers', category: 'Materials', amount: 32, notes: '', receiptUrl: '', createdAt: '2026-04-09T07:05:00Z' },
|
||||
{ id: 'sct_013_e3', description: 'Conduit + fittings', category: 'Materials', amount: 41, notes: '', receiptUrl: '', createdAt: '2026-04-09T07:10:00Z' },
|
||||
],
|
||||
fees: [
|
||||
{ id: 'sct_013_f1', description: 'Labour — rough-in (1.5 days)', type: 'Labour / Service Fee', amount: 640, createdAt: '2026-04-14T16:10:00Z' },
|
||||
{ id: 'sct_013_f2', description: 'Mobilization', type: 'Mobilization Fee', amount: 75, createdAt: '2026-04-08T10:00:00Z' },
|
||||
],
|
||||
activities: [],
|
||||
projectId: 'PRJ-2026-001',
|
||||
paymentStatus: 'Paid',
|
||||
paymentRequestedAt: '2026-04-14T17:00:00Z',
|
||||
amountPaid: 856,
|
||||
paidAt: '2026-04-20T10:00:00Z',
|
||||
createdAt: '2026-04-08T10:00:00Z',
|
||||
updatedAt: '2026-04-14T17:00:00Z',
|
||||
},
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// sct_014 — Jennifer Castillo (sub_004) — plumbing PRJ-2026-004, Paid
|
||||
// -------------------------------------------------------------------
|
||||
{
|
||||
id: 'sct_014',
|
||||
companyId: 'lynkeduppro',
|
||||
companyName: 'LynkedUp Pro Roofing',
|
||||
subcontractorId: 'sub_004',
|
||||
subcontractorName: 'Jennifer Castillo',
|
||||
assignedBy: 'own_001',
|
||||
assignedByName: 'Justin Johnson',
|
||||
title: 'Rough-in plumbing for ADU bathroom',
|
||||
location: '6112 Roundrock Dr, Plano, TX 75025',
|
||||
description: 'New ADU build — bathroom rough-in. Coordinate with framing inspection.',
|
||||
dueDate: '2026-06-15',
|
||||
title: 'Re-pipe master bath supply lines',
|
||||
location: '2612 Dunwick Dr, Plano, TX 75023',
|
||||
description: 'Replace old galvanized supply lines in master bath with 1/2" PEX. Two shut-offs, one shower valve, one sink valve.',
|
||||
dueDate: '2026-04-30',
|
||||
priority: 'medium',
|
||||
category: 'Plumbing',
|
||||
assignedDate: '2026-05-09T10:00:00Z',
|
||||
status: 'Cancelled',
|
||||
assignedDate: '2026-04-22T08:00:00Z',
|
||||
status: 'Completed',
|
||||
photos: [],
|
||||
statusHistory: [
|
||||
{ id: 'sct_009_h1', status: 'Assigned', actorId: 'own_001', actorName: 'Justin Johnson', comment: 'Task created and assigned.', at: '2026-05-09T10:00:00Z' },
|
||||
{ id: 'sct_009_h2', status: 'Cancelled', actorId: 'own_001', actorName: 'Justin Johnson', comment: 'Homeowner cancelled the ADU project — entire scope suspended. Task voided, no further work required.', at: '2026-05-20T15:00:00Z' },
|
||||
{ id: 'sct_014_h1', status: 'Assigned', actorId: 'own_001', actorName: 'Justin Johnson', comment: 'Assigned.', at: '2026-04-22T08:00:00Z' },
|
||||
{ id: 'sct_014_h2', status: 'Completed', actorId: 'sub_004', actorName: 'Jennifer Castillo', comment: 'Complete.', at: '2026-04-28T15:00:00Z' },
|
||||
],
|
||||
thread: [
|
||||
{ id: 'sct_009_t1', channel: 'group', senderId: 'own_001', senderName: 'Justin Johnson', senderRole: 'Owner', body: 'Jennifer — unfortunately the homeowner has decided to cancel the ADU build entirely. No further work needed on this task. Apologies for the inconvenience.', at: '2026-05-20T15:02:00Z' },
|
||||
{ id: 'sct_009_t2', channel: 'group', senderId: 'sub_004', senderName: 'Jennifer Castillo', senderRole: 'Subcontractor', body: 'Understood — no work had been started so no impact on our end. Thanks for the heads-up.', at: '2026-05-20T15:30:00Z', mine: true },
|
||||
thread: [],
|
||||
expenses: [
|
||||
{ id: 'sct_014_e1', description: '1/2" PEX — 50 ft', category: 'Materials', amount: 62, notes: '', receiptUrl: '', createdAt: '2026-04-23T07:30:00Z' },
|
||||
{ id: 'sct_014_e2', description: 'PEX fittings + crimp rings', category: 'Materials', amount: 44, notes: '', receiptUrl: '', createdAt: '2026-04-23T07:35:00Z' },
|
||||
{ id: 'sct_014_e3', description: 'Shower valve (Moen 2520)', category: 'Materials', amount: 128, notes: '', receiptUrl: '', createdAt: '2026-04-23T07:40:00Z' },
|
||||
],
|
||||
expenses: [],
|
||||
fees: [],
|
||||
activities: [
|
||||
{
|
||||
id: 'sct_009_a1', type: 'status_change', status: 'Assigned',
|
||||
note: 'Task created. New ADU build — bathroom rough-in to coordinate with framing inspection.',
|
||||
photos: [],
|
||||
actorId: 'own_001', actorName: 'Justin Johnson', actorRole: 'Owner',
|
||||
at: '2026-05-09T10:00:00Z',
|
||||
},
|
||||
{
|
||||
id: 'sct_009_a2', type: 'status_change', status: 'Cancelled',
|
||||
note: 'Task cancelled — homeowner suspended the ADU project. No work was started.',
|
||||
photos: [],
|
||||
actorId: 'own_001', actorName: 'Justin Johnson', actorRole: 'Owner',
|
||||
at: '2026-05-20T15:00:00Z',
|
||||
},
|
||||
fees: [
|
||||
{ id: 'sct_014_f1', description: 'Labour — master bath re-pipe', type: 'Labour / Service Fee', amount: 740, createdAt: '2026-04-28T15:10:00Z' },
|
||||
],
|
||||
projectId: null,
|
||||
paymentStatus: 'Unpaid',
|
||||
paymentRequestedAt: null,
|
||||
createdAt: '2026-05-09T10:00:00Z',
|
||||
updatedAt: '2026-05-20T15:30:00Z',
|
||||
activities: [],
|
||||
projectId: 'PRJ-2026-004',
|
||||
paymentStatus: 'Paid',
|
||||
paymentRequestedAt: '2026-04-28T16:00:00Z',
|
||||
amountPaid: 974,
|
||||
paidAt: '2026-05-05T10:00:00Z',
|
||||
createdAt: '2026-04-22T08:00:00Z',
|
||||
updatedAt: '2026-04-28T16:00:00Z',
|
||||
},
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// sct_016 — Dwayne Boudreaux (sub_003) — interior repaint PRJ-2026-004, Paid
|
||||
// -------------------------------------------------------------------
|
||||
{
|
||||
id: 'sct_016',
|
||||
companyId: 'lynkeduppro',
|
||||
companyName: 'LynkedUp Pro Roofing',
|
||||
subcontractorId: 'sub_003',
|
||||
subcontractorName: 'Dwayne Boudreaux',
|
||||
assignedBy: 'own_001',
|
||||
assignedByName: 'Justin Johnson',
|
||||
title: 'Interior repaint — living & dining',
|
||||
location: '2612 Dunwick Dr, Plano, TX 75023',
|
||||
description: 'Repaint living room and dining area after drywall repair. 2 coats SW Accessible Beige. Mask trim.',
|
||||
dueDate: '2026-05-05',
|
||||
priority: 'low',
|
||||
category: 'Painting',
|
||||
assignedDate: '2026-04-28T11:00:00Z',
|
||||
status: 'Completed',
|
||||
photos: [],
|
||||
statusHistory: [
|
||||
{ id: 'sct_016_h1', status: 'Assigned', actorId: 'own_001', actorName: 'Justin Johnson', comment: 'Assigned.', at: '2026-04-28T11:00:00Z' },
|
||||
{ id: 'sct_016_h2', status: 'Completed', actorId: 'sub_003', actorName: 'Dwayne Boudreaux', comment: 'Complete.', at: '2026-05-03T15:00:00Z' },
|
||||
],
|
||||
thread: [],
|
||||
expenses: [
|
||||
{ id: 'sct_016_e1', description: 'SW Accessible Beige — 2 gal', category: 'Materials', amount: 104, notes: '', receiptUrl: '', createdAt: '2026-04-29T08:00:00Z' },
|
||||
{ id: 'sct_016_e2', description: 'Tape, drop cloths, roller kit', category: 'Materials', amount: 38, notes: '', receiptUrl: '', createdAt: '2026-04-29T08:05:00Z' },
|
||||
],
|
||||
fees: [
|
||||
{ id: 'sct_016_f1', description: 'Labour — 2-day interior repaint', type: 'Labour / Service Fee', amount: 760, createdAt: '2026-05-03T15:10:00Z' },
|
||||
],
|
||||
activities: [],
|
||||
projectId: 'PRJ-2026-004',
|
||||
paymentStatus: 'Paid',
|
||||
paymentRequestedAt: '2026-05-03T16:00:00Z',
|
||||
amountPaid: 902,
|
||||
paidAt: '2026-05-10T09:00:00Z',
|
||||
createdAt: '2026-04-28T11:00:00Z',
|
||||
updatedAt: '2026-05-03T16:00:00Z',
|
||||
},
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// sct_018 — Greg Alston (sub_005) — window replacement PRJ-2026-003, Partially Paid
|
||||
// -------------------------------------------------------------------
|
||||
{
|
||||
id: 'sct_018',
|
||||
companyId: 'lynkeduppro',
|
||||
companyName: 'LynkedUp Pro Roofing',
|
||||
subcontractorId: 'sub_005',
|
||||
subcontractorName: 'Greg Alston',
|
||||
assignedBy: 'own_001',
|
||||
assignedByName: 'Justin Johnson',
|
||||
title: 'Replace 3 hail-damaged windows — PRJ-003',
|
||||
location: '6613 Phoenix Pl, Plano, TX 75023',
|
||||
description: 'Insurance scope: replace 3 double-hung windows (front elevation). Source Andersen 400 series double-pane. Include disposal of broken units.',
|
||||
dueDate: '2026-05-15',
|
||||
priority: 'high',
|
||||
category: 'Windows & Glazing',
|
||||
assignedDate: '2026-05-03T09:00:00Z',
|
||||
status: 'Completed',
|
||||
photos: [],
|
||||
statusHistory: [
|
||||
{ id: 'sct_018_h1', status: 'Assigned', actorId: 'own_001', actorName: 'Justin Johnson', comment: 'Assigned.', at: '2026-05-03T09:00:00Z' },
|
||||
{ id: 'sct_018_h2', status: 'Completed', actorId: 'sub_005', actorName: 'Greg Alston', comment: 'Complete.', at: '2026-05-13T15:00:00Z' },
|
||||
],
|
||||
thread: [],
|
||||
expenses: [
|
||||
{ id: 'sct_018_e1', description: 'Andersen 400 DH windows (qty 3)', category: 'Materials', amount: 1470, notes: '', receiptUrl: '', createdAt: '2026-05-04T08:00:00Z' },
|
||||
{ id: 'sct_018_e2', description: 'Weatherstrip + sealant', category: 'Materials', amount: 55, notes: '', receiptUrl: '', createdAt: '2026-05-04T08:05:00Z' },
|
||||
{ id: 'sct_018_e3', description: 'Disposal — broken units', category: 'Disposal / Dumpster', amount: 90, notes: '', receiptUrl: '', createdAt: '2026-05-13T16:00:00Z' },
|
||||
],
|
||||
fees: [
|
||||
{ id: 'sct_018_f1', description: 'Labour — window replacement (2 days)', type: 'Labour / Service Fee', amount: 960, createdAt: '2026-05-13T15:10:00Z' },
|
||||
{ id: 'sct_018_f2', description: 'Mobilization', type: 'Mobilization Fee', amount: 100, createdAt: '2026-05-03T09:00:00Z' },
|
||||
],
|
||||
activities: [],
|
||||
projectId: 'PRJ-2026-003',
|
||||
paymentStatus: 'Partially Paid',
|
||||
paymentRequestedAt: '2026-05-13T16:00:00Z',
|
||||
amountPaid: 1500,
|
||||
paidAt: '2026-05-20T10:00:00Z',
|
||||
createdAt: '2026-05-03T09:00:00Z',
|
||||
updatedAt: '2026-05-13T16:00:00Z',
|
||||
},
|
||||
];
|
||||
|
||||
@@ -8073,20 +8253,6 @@ const MOCK_NOTIFICATIONS = [
|
||||
taskId: 'sct_004', fromCompanyId: 'lynkeduppro', fromCompanyName: 'LynkedUp Pro Roofing',
|
||||
isRead: false, createdAt: '2026-05-18T22:40:00Z',
|
||||
},
|
||||
{
|
||||
id: 'notif_003', recipientUserId: 'sub_002', recipientRole: 'SUBCONTRACTOR',
|
||||
type: 'task_assigned',
|
||||
message: 'You have been assigned a task by LynkedUp Pro Roofing — "Replace gutter on south elevation"',
|
||||
taskId: 'sct_006', fromCompanyId: 'lynkeduppro', fromCompanyName: 'LynkedUp Pro Roofing',
|
||||
isRead: false, createdAt: '2026-05-17T14:00:00Z',
|
||||
},
|
||||
{
|
||||
id: 'notif_004', recipientUserId: 'sub_002', recipientRole: 'SUBCONTRACTOR',
|
||||
type: 'task_assigned',
|
||||
message: 'You have been assigned a task by LynkedUp Pro Roofing — "Inspect attic ventilation"',
|
||||
taskId: 'sct_005', fromCompanyId: 'lynkeduppro', fromCompanyName: 'LynkedUp Pro Roofing',
|
||||
isRead: true, createdAt: '2026-05-12T10:00:00Z',
|
||||
},
|
||||
// -- Message Received --------------------------
|
||||
{
|
||||
id: 'notif_005', recipientUserId: 'sub_002', recipientRole: 'SUBCONTRACTOR',
|
||||
@@ -8102,13 +8268,6 @@ const MOCK_NOTIFICATIONS = [
|
||||
taskId: 'sct_004', fromCompanyId: 'lynkeduppro', fromCompanyName: 'LynkedUp Pro Roofing',
|
||||
isRead: false, createdAt: '2026-05-18T22:42:00Z',
|
||||
},
|
||||
{
|
||||
id: 'notif_007', recipientUserId: 'sub_002', recipientRole: 'SUBCONTRACTOR',
|
||||
type: 'message_received',
|
||||
message: 'Wade Hollis added an internal note on sct_005 — "I will reach out to the homeowner…"',
|
||||
taskId: 'sct_005', fromCompanyId: 'lynkeduppro', fromCompanyName: 'LynkedUp Pro Roofing',
|
||||
isRead: true, createdAt: '2026-05-15T16:20:00Z',
|
||||
},
|
||||
// -- Task Updated ------------------------------
|
||||
{
|
||||
id: 'notif_008', recipientUserId: 'sub_002', recipientRole: 'SUBCONTRACTOR',
|
||||
@@ -8139,13 +8298,6 @@ const MOCK_NOTIFICATIONS = [
|
||||
taskId: 'sct_001', fromCompanyId: 'lynkeduppro', fromCompanyName: 'LynkedUp Pro Roofing',
|
||||
isRead: true, createdAt: '2026-05-16T08:30:00Z',
|
||||
},
|
||||
{
|
||||
id: 'notif_012', recipientUserId: 'sub_002', recipientRole: 'SUBCONTRACTOR',
|
||||
type: 'status_changed',
|
||||
message: 'Status for sct_005 changed to On Hold by Maya Patel.',
|
||||
taskId: 'sct_005', fromCompanyId: 'lynkeduppro', fromCompanyName: 'LynkedUp Pro Roofing',
|
||||
isRead: true, createdAt: '2026-05-15T16:00:00Z',
|
||||
},
|
||||
{
|
||||
id: 'notif_013', recipientUserId: 'sub_002', recipientRole: 'SUBCONTRACTOR',
|
||||
type: 'status_changed',
|
||||
@@ -10143,9 +10295,9 @@ export const LEAD_FORM_OPTIONS = {
|
||||
'HOA / Community',
|
||||
],
|
||||
leadSources: [
|
||||
'Door Knock',
|
||||
'Canvassers',
|
||||
'Referral',
|
||||
'Storm Chase',
|
||||
'Storm Chase/Self-Gen',
|
||||
'Online / Web',
|
||||
'Mailer / Postcard',
|
||||
'Sign Call',
|
||||
@@ -10274,51 +10426,243 @@ export const INITIAL_STORM_ZONES = [
|
||||
|
||||
export const MOCK_STORM_ATTRIBUTION_LEADS = [
|
||||
// ── SE-001 · Apr 28 2026 · Severe · NE Plano ──────────────────────────────
|
||||
{ id: 'SAL-001', firstName: 'John', lastName: 'Martinez', address: '4821 Spring Creek Pkwy', city: 'Plano', state: 'TX', urgency: 'high', leadSource: 'Door Knock', status: 'Contacted', contractValue: null, createdAt: '2026-04-29T10:14:00Z', stormSource: { id: 'SE-001', areaName: 'NE Plano / Spring Creek Pkwy', date: '2026-04-28', maxHailSize: 2.5, severity: 'severe' } },
|
||||
{ id: 'SAL-002', firstName: 'Sarah', lastName: 'Kim', address: '4905 Custer Rd', city: 'Plano', state: 'TX', urgency: 'high', leadSource: 'Door Knock', status: 'Appointed', contractValue: null, createdAt: '2026-04-29T14:32:00Z', stormSource: { id: 'SE-001', areaName: 'NE Plano / Spring Creek Pkwy', date: '2026-04-28', maxHailSize: 2.5, severity: 'severe' } },
|
||||
{ id: 'SAL-003', firstName: 'Robert', lastName: 'Chen', address: '5012 Independence Pkwy', city: 'Plano', state: 'TX', urgency: 'high', leadSource: 'Door Knock', status: 'Closed', contractValue: 18500, createdAt: '2026-04-30T09:05:00Z', stormSource: { id: 'SE-001', areaName: 'NE Plano / Spring Creek Pkwy', date: '2026-04-28', maxHailSize: 2.5, severity: 'severe' } },
|
||||
{ id: 'SAL-004', firstName: 'Maria', lastName: 'Garcia', address: '4720 Alma Dr', city: 'Plano', state: 'TX', urgency: 'high', leadSource: 'Door Knock', status: 'Closed', contractValue: 24200, createdAt: '2026-04-30T11:48:00Z', stormSource: { id: 'SE-001', areaName: 'NE Plano / Spring Creek Pkwy', date: '2026-04-28', maxHailSize: 2.5, severity: 'severe' } },
|
||||
{ id: 'SAL-005', firstName: 'David', lastName: 'Thompson', address: '5130 Spring Creek Pkwy', city: 'Plano', state: 'TX', urgency: 'high', leadSource: 'Door Knock', status: 'Appointed', contractValue: null, createdAt: '2026-05-01T08:22:00Z', stormSource: { id: 'SE-001', areaName: 'NE Plano / Spring Creek Pkwy', date: '2026-04-28', maxHailSize: 2.5, severity: 'severe' } },
|
||||
{ id: 'SAL-006', firstName: 'Lisa', lastName: 'Anderson', address: '4845 Custer Rd', city: 'Plano', state: 'TX', urgency: 'standard', leadSource: 'Door Knock', status: 'Contacted', contractValue: null, createdAt: '2026-05-01T13:10:00Z', stormSource: { id: 'SE-001', areaName: 'NE Plano / Spring Creek Pkwy', date: '2026-04-28', maxHailSize: 2.5, severity: 'severe' } },
|
||||
{ id: 'SAL-007', firstName: 'James', lastName: 'Wilson', address: '5020 Alma Dr', city: 'Plano', state: 'TX', urgency: 'standard', leadSource: 'Door Knock', status: 'New', contractValue: null, createdAt: '2026-05-02T10:00:00Z', stormSource: { id: 'SE-001', areaName: 'NE Plano / Spring Creek Pkwy', date: '2026-04-28', maxHailSize: 2.5, severity: 'severe' } },
|
||||
{ id: 'SAL-008', firstName: 'Emily', lastName: 'Davis', address: '4980 Spring Creek Pkwy', city: 'Plano', state: 'TX', urgency: 'standard', leadSource: 'Door Knock', status: 'Contacted', contractValue: null, createdAt: '2026-05-03T09:45:00Z', stormSource: { id: 'SE-001', areaName: 'NE Plano / Spring Creek Pkwy', date: '2026-04-28', maxHailSize: 2.5, severity: 'severe' } },
|
||||
{ id: 'SAL-001', firstName: 'John', lastName: 'Martinez', address: '4821 Spring Creek Pkwy', city: 'Plano', state: 'TX', urgency: 'high', leadSource: 'Canvassers', status: 'Contacted', contractValue: null, createdAt: '2026-04-29T10:14:00Z', stormSource: { id: 'SE-001', areaName: 'NE Plano / Spring Creek Pkwy', date: '2026-04-28', maxHailSize: 2.5, severity: 'severe' } },
|
||||
{ id: 'SAL-002', firstName: 'Sarah', lastName: 'Kim', address: '4905 Custer Rd', city: 'Plano', state: 'TX', urgency: 'high', leadSource: 'Canvassers', status: 'Appointed', contractValue: null, createdAt: '2026-04-29T14:32:00Z', stormSource: { id: 'SE-001', areaName: 'NE Plano / Spring Creek Pkwy', date: '2026-04-28', maxHailSize: 2.5, severity: 'severe' } },
|
||||
{ id: 'SAL-003', firstName: 'Robert', lastName: 'Chen', address: '5012 Independence Pkwy', city: 'Plano', state: 'TX', urgency: 'high', leadSource: 'Canvassers', status: 'Closed', contractValue: 18500, createdAt: '2026-04-30T09:05:00Z', stormSource: { id: 'SE-001', areaName: 'NE Plano / Spring Creek Pkwy', date: '2026-04-28', maxHailSize: 2.5, severity: 'severe' } },
|
||||
{ id: 'SAL-004', firstName: 'Maria', lastName: 'Garcia', address: '4720 Alma Dr', city: 'Plano', state: 'TX', urgency: 'high', leadSource: 'Canvassers', status: 'Closed', contractValue: 24200, createdAt: '2026-04-30T11:48:00Z', stormSource: { id: 'SE-001', areaName: 'NE Plano / Spring Creek Pkwy', date: '2026-04-28', maxHailSize: 2.5, severity: 'severe' } },
|
||||
{ id: 'SAL-005', firstName: 'David', lastName: 'Thompson', address: '5130 Spring Creek Pkwy', city: 'Plano', state: 'TX', urgency: 'high', leadSource: 'Canvassers', status: 'Appointed', contractValue: null, createdAt: '2026-05-01T08:22:00Z', stormSource: { id: 'SE-001', areaName: 'NE Plano / Spring Creek Pkwy', date: '2026-04-28', maxHailSize: 2.5, severity: 'severe' } },
|
||||
{ id: 'SAL-006', firstName: 'Lisa', lastName: 'Anderson', address: '4845 Custer Rd', city: 'Plano', state: 'TX', urgency: 'standard', leadSource: 'Canvassers', status: 'Contacted', contractValue: null, createdAt: '2026-05-01T13:10:00Z', stormSource: { id: 'SE-001', areaName: 'NE Plano / Spring Creek Pkwy', date: '2026-04-28', maxHailSize: 2.5, severity: 'severe' } },
|
||||
{ id: 'SAL-007', firstName: 'James', lastName: 'Wilson', address: '5020 Alma Dr', city: 'Plano', state: 'TX', urgency: 'standard', leadSource: 'Canvassers', status: 'New', contractValue: null, createdAt: '2026-05-02T10:00:00Z', stormSource: { id: 'SE-001', areaName: 'NE Plano / Spring Creek Pkwy', date: '2026-04-28', maxHailSize: 2.5, severity: 'severe' } },
|
||||
{ id: 'SAL-008', firstName: 'Emily', lastName: 'Davis', address: '4980 Spring Creek Pkwy', city: 'Plano', state: 'TX', urgency: 'standard', leadSource: 'Canvassers', status: 'Contacted', contractValue: null, createdAt: '2026-05-03T09:45:00Z', stormSource: { id: 'SE-001', areaName: 'NE Plano / Spring Creek Pkwy', date: '2026-04-28', maxHailSize: 2.5, severity: 'severe' } },
|
||||
|
||||
// ── SE-002 · Mar 15 2026 · Moderate · Central Plano ──────────────────────
|
||||
{ id: 'SAL-009', firstName: 'Michael', lastName: 'Brown', address: '1520 W 14th St', city: 'Plano', state: 'TX', urgency: 'standard', leadSource: 'Door Knock', status: 'Contacted', contractValue: null, createdAt: '2026-03-16T10:20:00Z', stormSource: { id: 'SE-002', areaName: 'Central Plano / W 15th St', date: '2026-03-15', maxHailSize: 1.2, severity: 'moderate' } },
|
||||
{ id: 'SAL-010', firstName: 'Jennifer', lastName: 'Taylor', address: '1608 W 15th St', city: 'Plano', state: 'TX', urgency: 'standard', leadSource: 'Door Knock', status: 'Appointed', contractValue: null, createdAt: '2026-03-17T11:05:00Z', stormSource: { id: 'SE-002', areaName: 'Central Plano / W 15th St', date: '2026-03-15', maxHailSize: 1.2, severity: 'moderate' } },
|
||||
{ id: 'SAL-011', firstName: 'Christopher',lastName: 'Moore', address: '1542 Ave K', city: 'Plano', state: 'TX', urgency: 'standard', leadSource: 'Door Knock', status: 'Closed', contractValue: 16800, createdAt: '2026-03-18T09:30:00Z', stormSource: { id: 'SE-002', areaName: 'Central Plano / W 15th St', date: '2026-03-15', maxHailSize: 1.2, severity: 'moderate' } },
|
||||
{ id: 'SAL-012', firstName: 'Amanda', lastName: 'Jackson', address: '1615 W 15th St', city: 'Plano', state: 'TX', urgency: 'standard', leadSource: 'Door Knock', status: 'Contacted', contractValue: null, createdAt: '2026-03-19T14:00:00Z', stormSource: { id: 'SE-002', areaName: 'Central Plano / W 15th St', date: '2026-03-15', maxHailSize: 1.2, severity: 'moderate' } },
|
||||
{ id: 'SAL-013', firstName: 'Daniel', lastName: 'White', address: '1480 W 14th St', city: 'Plano', state: 'TX', urgency: 'standard', leadSource: 'Door Knock', status: 'New', contractValue: null, createdAt: '2026-03-20T10:15:00Z', stormSource: { id: 'SE-002', areaName: 'Central Plano / W 15th St', date: '2026-03-15', maxHailSize: 1.2, severity: 'moderate' } },
|
||||
{ id: 'SAL-009', firstName: 'Michael', lastName: 'Brown', address: '1520 W 14th St', city: 'Plano', state: 'TX', urgency: 'standard', leadSource: 'Canvassers', status: 'Contacted', contractValue: null, createdAt: '2026-03-16T10:20:00Z', stormSource: { id: 'SE-002', areaName: 'Central Plano / W 15th St', date: '2026-03-15', maxHailSize: 1.2, severity: 'moderate' } },
|
||||
{ id: 'SAL-010', firstName: 'Jennifer', lastName: 'Taylor', address: '1608 W 15th St', city: 'Plano', state: 'TX', urgency: 'standard', leadSource: 'Canvassers', status: 'Appointed', contractValue: null, createdAt: '2026-03-17T11:05:00Z', stormSource: { id: 'SE-002', areaName: 'Central Plano / W 15th St', date: '2026-03-15', maxHailSize: 1.2, severity: 'moderate' } },
|
||||
{ id: 'SAL-011', firstName: 'Christopher',lastName: 'Moore', address: '1542 Ave K', city: 'Plano', state: 'TX', urgency: 'standard', leadSource: 'Canvassers', status: 'Closed', contractValue: 16800, createdAt: '2026-03-18T09:30:00Z', stormSource: { id: 'SE-002', areaName: 'Central Plano / W 15th St', date: '2026-03-15', maxHailSize: 1.2, severity: 'moderate' } },
|
||||
{ id: 'SAL-012', firstName: 'Amanda', lastName: 'Jackson', address: '1615 W 15th St', city: 'Plano', state: 'TX', urgency: 'standard', leadSource: 'Canvassers', status: 'Contacted', contractValue: null, createdAt: '2026-03-19T14:00:00Z', stormSource: { id: 'SE-002', areaName: 'Central Plano / W 15th St', date: '2026-03-15', maxHailSize: 1.2, severity: 'moderate' } },
|
||||
{ id: 'SAL-013', firstName: 'Daniel', lastName: 'White', address: '1480 W 14th St', city: 'Plano', state: 'TX', urgency: 'standard', leadSource: 'Canvassers', status: 'New', contractValue: null, createdAt: '2026-03-20T10:15:00Z', stormSource: { id: 'SE-002', areaName: 'Central Plano / W 15th St', date: '2026-03-15', maxHailSize: 1.2, severity: 'moderate' } },
|
||||
|
||||
// ── SE-003 · Feb 3 2026 · Significant · SE Plano ─────────────────────────
|
||||
{ id: 'SAL-014', firstName: 'Jessica', lastName: 'Harris', address: '3201 Parker Rd', city: 'Plano', state: 'TX', urgency: 'high', leadSource: 'Door Knock', status: 'Appointed', contractValue: null, createdAt: '2026-02-04T09:00:00Z', stormSource: { id: 'SE-003', areaName: 'SE Plano / Parker Rd Corridor', date: '2026-02-03', maxHailSize: 1.75, severity: 'significant' } },
|
||||
{ id: 'SAL-015', firstName: 'Ryan', lastName: 'Martinez', address: '3150 Parker Rd', city: 'Plano', state: 'TX', urgency: 'high', leadSource: 'Door Knock', status: 'Closed', contractValue: 21400, createdAt: '2026-02-05T11:30:00Z', stormSource: { id: 'SE-003', areaName: 'SE Plano / Parker Rd Corridor', date: '2026-02-03', maxHailSize: 1.75, severity: 'significant' } },
|
||||
{ id: 'SAL-016', firstName: 'Ashley', lastName: 'Thompson', address: '3280 Heritage Pkwy', city: 'Plano', state: 'TX', urgency: 'high', leadSource: 'Door Knock', status: 'Closed', contractValue: 19900, createdAt: '2026-02-05T14:20:00Z', stormSource: { id: 'SE-003', areaName: 'SE Plano / Parker Rd Corridor', date: '2026-02-03', maxHailSize: 1.75, severity: 'significant' } },
|
||||
{ id: 'SAL-017', firstName: 'Kevin', lastName: 'Johnson', address: '3190 Parker Rd', city: 'Plano', state: 'TX', urgency: 'standard', leadSource: 'Door Knock', status: 'Contacted', contractValue: null, createdAt: '2026-02-06T10:00:00Z', stormSource: { id: 'SE-003', areaName: 'SE Plano / Parker Rd Corridor', date: '2026-02-03', maxHailSize: 1.75, severity: 'significant' } },
|
||||
{ id: 'SAL-018', firstName: 'Stephanie', lastName: 'Davis', address: '3220 Heritage Pkwy', city: 'Plano', state: 'TX', urgency: 'standard', leadSource: 'Door Knock', status: 'Appointed', contractValue: null, createdAt: '2026-02-07T09:30:00Z', stormSource: { id: 'SE-003', areaName: 'SE Plano / Parker Rd Corridor', date: '2026-02-03', maxHailSize: 1.75, severity: 'significant' } },
|
||||
{ id: 'SAL-019', firstName: 'Brian', lastName: 'Wilson', address: '3160 Parker Rd', city: 'Plano', state: 'TX', urgency: 'standard', leadSource: 'Door Knock', status: 'Contacted', contractValue: null, createdAt: '2026-02-08T11:00:00Z', stormSource: { id: 'SE-003', areaName: 'SE Plano / Parker Rd Corridor', date: '2026-02-03', maxHailSize: 1.75, severity: 'significant' } },
|
||||
{ id: 'SAL-014', firstName: 'Jessica', lastName: 'Harris', address: '3201 Parker Rd', city: 'Plano', state: 'TX', urgency: 'high', leadSource: 'Canvassers', status: 'Appointed', contractValue: null, createdAt: '2026-02-04T09:00:00Z', stormSource: { id: 'SE-003', areaName: 'SE Plano / Parker Rd Corridor', date: '2026-02-03', maxHailSize: 1.75, severity: 'significant' } },
|
||||
{ id: 'SAL-015', firstName: 'Ryan', lastName: 'Martinez', address: '3150 Parker Rd', city: 'Plano', state: 'TX', urgency: 'high', leadSource: 'Canvassers', status: 'Closed', contractValue: 21400, createdAt: '2026-02-05T11:30:00Z', stormSource: { id: 'SE-003', areaName: 'SE Plano / Parker Rd Corridor', date: '2026-02-03', maxHailSize: 1.75, severity: 'significant' } },
|
||||
{ id: 'SAL-016', firstName: 'Ashley', lastName: 'Thompson', address: '3280 Heritage Pkwy', city: 'Plano', state: 'TX', urgency: 'high', leadSource: 'Canvassers', status: 'Closed', contractValue: 19900, createdAt: '2026-02-05T14:20:00Z', stormSource: { id: 'SE-003', areaName: 'SE Plano / Parker Rd Corridor', date: '2026-02-03', maxHailSize: 1.75, severity: 'significant' } },
|
||||
{ id: 'SAL-017', firstName: 'Kevin', lastName: 'Johnson', address: '3190 Parker Rd', city: 'Plano', state: 'TX', urgency: 'standard', leadSource: 'Canvassers', status: 'Contacted', contractValue: null, createdAt: '2026-02-06T10:00:00Z', stormSource: { id: 'SE-003', areaName: 'SE Plano / Parker Rd Corridor', date: '2026-02-03', maxHailSize: 1.75, severity: 'significant' } },
|
||||
{ id: 'SAL-018', firstName: 'Stephanie', lastName: 'Davis', address: '3220 Heritage Pkwy', city: 'Plano', state: 'TX', urgency: 'standard', leadSource: 'Canvassers', status: 'Appointed', contractValue: null, createdAt: '2026-02-07T09:30:00Z', stormSource: { id: 'SE-003', areaName: 'SE Plano / Parker Rd Corridor', date: '2026-02-03', maxHailSize: 1.75, severity: 'significant' } },
|
||||
{ id: 'SAL-019', firstName: 'Brian', lastName: 'Wilson', address: '3160 Parker Rd', city: 'Plano', state: 'TX', urgency: 'standard', leadSource: 'Canvassers', status: 'Contacted', contractValue: null, createdAt: '2026-02-08T11:00:00Z', stormSource: { id: 'SE-003', areaName: 'SE Plano / Parker Rd Corridor', date: '2026-02-03', maxHailSize: 1.75, severity: 'significant' } },
|
||||
|
||||
// ── SE-004 · Jan 20 2026 · Moderate · W Plano ────────────────────────────
|
||||
{ id: 'SAL-020', firstName: 'Nicole', lastName: 'Garcia', address: '7200 Legacy Dr', city: 'Plano', state: 'TX', urgency: 'standard', leadSource: 'Door Knock', status: 'Contacted', contractValue: null, createdAt: '2026-01-21T10:00:00Z', stormSource: { id: 'SE-004', areaName: 'W Plano / Legacy West', date: '2026-01-20', maxHailSize: 1.0, severity: 'moderate' } },
|
||||
{ id: 'SAL-021', firstName: 'Andrew', lastName: 'Kim', address: '7150 Legacy Dr', city: 'Plano', state: 'TX', urgency: 'standard', leadSource: 'Door Knock', status: 'Appointed', contractValue: null, createdAt: '2026-01-22T13:45:00Z', stormSource: { id: 'SE-004', areaName: 'W Plano / Legacy West', date: '2026-01-20', maxHailSize: 1.0, severity: 'moderate' } },
|
||||
{ id: 'SAL-022', firstName: 'Michelle', lastName: 'Lee', address: '7210 Legacy Dr', city: 'Plano', state: 'TX', urgency: 'standard', leadSource: 'Door Knock', status: 'New', contractValue: null, createdAt: '2026-01-23T09:15:00Z', stormSource: { id: 'SE-004', areaName: 'W Plano / Legacy West', date: '2026-01-20', maxHailSize: 1.0, severity: 'moderate' } },
|
||||
{ id: 'SAL-020', firstName: 'Nicole', lastName: 'Garcia', address: '7200 Legacy Dr', city: 'Plano', state: 'TX', urgency: 'standard', leadSource: 'Canvassers', status: 'Contacted', contractValue: null, createdAt: '2026-01-21T10:00:00Z', stormSource: { id: 'SE-004', areaName: 'W Plano / Legacy West', date: '2026-01-20', maxHailSize: 1.0, severity: 'moderate' } },
|
||||
{ id: 'SAL-021', firstName: 'Andrew', lastName: 'Kim', address: '7150 Legacy Dr', city: 'Plano', state: 'TX', urgency: 'standard', leadSource: 'Canvassers', status: 'Appointed', contractValue: null, createdAt: '2026-01-22T13:45:00Z', stormSource: { id: 'SE-004', areaName: 'W Plano / Legacy West', date: '2026-01-20', maxHailSize: 1.0, severity: 'moderate' } },
|
||||
{ id: 'SAL-022', firstName: 'Michelle', lastName: 'Lee', address: '7210 Legacy Dr', city: 'Plano', state: 'TX', urgency: 'standard', leadSource: 'Canvassers', status: 'New', contractValue: null, createdAt: '2026-01-23T09:15:00Z', stormSource: { id: 'SE-004', areaName: 'W Plano / Legacy West', date: '2026-01-20', maxHailSize: 1.0, severity: 'moderate' } },
|
||||
|
||||
// ── SE-005 · Dec 10 2025 · Trace · Allen / N Plano ───────────────────────
|
||||
{ id: 'SAL-023', firstName: 'Steven', lastName: 'Clark', address: '405 N Greenville Ave', city: 'Allen', state: 'TX', urgency: 'standard', leadSource: 'Door Knock', status: 'Closed', contractValue: 12400, createdAt: '2025-12-11T10:30:00Z', stormSource: { id: 'SE-005', areaName: 'Allen / N Plano Border', date: '2025-12-10', maxHailSize: 0.8, severity: 'trace' } },
|
||||
{ id: 'SAL-024', firstName: 'Melissa', lastName: 'Robinson', address: '415 N Greenville Ave', city: 'Allen', state: 'TX', urgency: 'standard', leadSource: 'Door Knock', status: 'Contacted', contractValue: null, createdAt: '2025-12-12T11:00:00Z', stormSource: { id: 'SE-005', areaName: 'Allen / N Plano Border', date: '2025-12-10', maxHailSize: 0.8, severity: 'trace' } },
|
||||
{ id: 'SAL-023', firstName: 'Steven', lastName: 'Clark', address: '405 N Greenville Ave', city: 'Allen', state: 'TX', urgency: 'standard', leadSource: 'Canvassers', status: 'Closed', contractValue: 12400, createdAt: '2025-12-11T10:30:00Z', stormSource: { id: 'SE-005', areaName: 'Allen / N Plano Border', date: '2025-12-10', maxHailSize: 0.8, severity: 'trace' } },
|
||||
{ id: 'SAL-024', firstName: 'Melissa', lastName: 'Robinson', address: '415 N Greenville Ave', city: 'Allen', state: 'TX', urgency: 'standard', leadSource: 'Canvassers', status: 'Contacted', contractValue: null, createdAt: '2025-12-12T11:00:00Z', stormSource: { id: 'SE-005', areaName: 'Allen / N Plano Border', date: '2025-12-10', maxHailSize: 0.8, severity: 'trace' } },
|
||||
|
||||
// ── SE-006 · Nov 5 2025 · Significant · S Plano ──────────────────────────
|
||||
{ id: 'SAL-025', firstName: 'Timothy', lastName: 'Walker', address: '1801 K Ave', city: 'Plano', state: 'TX', urgency: 'high', leadSource: 'Door Knock', status: 'Closed', contractValue: 31200, createdAt: '2025-11-06T09:00:00Z', stormSource: { id: 'SE-006', areaName: 'S Plano / Haggard Park Area', date: '2025-11-05', maxHailSize: 1.6, severity: 'significant' } },
|
||||
{ id: 'SAL-026', firstName: 'Kimberly', lastName: 'Hall', address: '1750 J Ave', city: 'Plano', state: 'TX', urgency: 'high', leadSource: 'Door Knock', status: 'Closed', contractValue: 18700, createdAt: '2025-11-07T10:15:00Z', stormSource: { id: 'SE-006', areaName: 'S Plano / Haggard Park Area', date: '2025-11-05', maxHailSize: 1.6, severity: 'significant' } },
|
||||
{ id: 'SAL-027', firstName: 'Joshua', lastName: 'Allen', address: '1820 K Ave', city: 'Plano', state: 'TX', urgency: 'high', leadSource: 'Door Knock', status: 'Closed', contractValue: 22900, createdAt: '2025-11-08T11:30:00Z', stormSource: { id: 'SE-006', areaName: 'S Plano / Haggard Park Area', date: '2025-11-05', maxHailSize: 1.6, severity: 'significant' } },
|
||||
{ id: 'SAL-028', firstName: 'Amy', lastName: 'Young', address: '1780 J Ave', city: 'Plano', state: 'TX', urgency: 'standard', leadSource: 'Door Knock', status: 'Appointed', contractValue: null, createdAt: '2025-11-09T09:45:00Z', stormSource: { id: 'SE-006', areaName: 'S Plano / Haggard Park Area', date: '2025-11-05', maxHailSize: 1.6, severity: 'significant' } },
|
||||
{ id: 'SAL-029', firstName: 'Brandon', lastName: 'King', address: '1810 K Ave', city: 'Plano', state: 'TX', urgency: 'standard', leadSource: 'Door Knock', status: 'Contacted', contractValue: null, createdAt: '2025-11-10T10:00:00Z', stormSource: { id: 'SE-006', areaName: 'S Plano / Haggard Park Area', date: '2025-11-05', maxHailSize: 1.6, severity: 'significant' } },
|
||||
{ id: 'SAL-030', firstName: 'Heather', lastName: 'Wright', address: '1760 J Ave', city: 'Plano', state: 'TX', urgency: 'standard', leadSource: 'Door Knock', status: 'Contacted', contractValue: null, createdAt: '2025-11-11T13:20:00Z', stormSource: { id: 'SE-006', areaName: 'S Plano / Haggard Park Area', date: '2025-11-05', maxHailSize: 1.6, severity: 'significant' } },
|
||||
{ id: 'SAL-031', firstName: 'Tyler', lastName: 'Scott', address: '1795 K Ave', city: 'Plano', state: 'TX', urgency: 'standard', leadSource: 'Door Knock', status: 'Contacted', contractValue: null, createdAt: '2025-11-12T09:00:00Z', stormSource: { id: 'SE-006', areaName: 'S Plano / Haggard Park Area', date: '2025-11-05', maxHailSize: 1.6, severity: 'significant' } },
|
||||
{ id: 'SAL-025', firstName: 'Timothy', lastName: 'Walker', address: '1801 K Ave', city: 'Plano', state: 'TX', urgency: 'high', leadSource: 'Canvassers', status: 'Closed', contractValue: 31200, createdAt: '2025-11-06T09:00:00Z', stormSource: { id: 'SE-006', areaName: 'S Plano / Haggard Park Area', date: '2025-11-05', maxHailSize: 1.6, severity: 'significant' } },
|
||||
{ id: 'SAL-026', firstName: 'Kimberly', lastName: 'Hall', address: '1750 J Ave', city: 'Plano', state: 'TX', urgency: 'high', leadSource: 'Canvassers', status: 'Closed', contractValue: 18700, createdAt: '2025-11-07T10:15:00Z', stormSource: { id: 'SE-006', areaName: 'S Plano / Haggard Park Area', date: '2025-11-05', maxHailSize: 1.6, severity: 'significant' } },
|
||||
{ id: 'SAL-027', firstName: 'Joshua', lastName: 'Allen', address: '1820 K Ave', city: 'Plano', state: 'TX', urgency: 'high', leadSource: 'Canvassers', status: 'Closed', contractValue: 22900, createdAt: '2025-11-08T11:30:00Z', stormSource: { id: 'SE-006', areaName: 'S Plano / Haggard Park Area', date: '2025-11-05', maxHailSize: 1.6, severity: 'significant' } },
|
||||
{ id: 'SAL-028', firstName: 'Amy', lastName: 'Young', address: '1780 J Ave', city: 'Plano', state: 'TX', urgency: 'standard', leadSource: 'Canvassers', status: 'Appointed', contractValue: null, createdAt: '2025-11-09T09:45:00Z', stormSource: { id: 'SE-006', areaName: 'S Plano / Haggard Park Area', date: '2025-11-05', maxHailSize: 1.6, severity: 'significant' } },
|
||||
{ id: 'SAL-029', firstName: 'Brandon', lastName: 'King', address: '1810 K Ave', city: 'Plano', state: 'TX', urgency: 'standard', leadSource: 'Canvassers', status: 'Contacted', contractValue: null, createdAt: '2025-11-10T10:00:00Z', stormSource: { id: 'SE-006', areaName: 'S Plano / Haggard Park Area', date: '2025-11-05', maxHailSize: 1.6, severity: 'significant' } },
|
||||
{ id: 'SAL-030', firstName: 'Heather', lastName: 'Wright', address: '1760 J Ave', city: 'Plano', state: 'TX', urgency: 'standard', leadSource: 'Canvassers', status: 'Contacted', contractValue: null, createdAt: '2025-11-11T13:20:00Z', stormSource: { id: 'SE-006', areaName: 'S Plano / Haggard Park Area', date: '2025-11-05', maxHailSize: 1.6, severity: 'significant' } },
|
||||
{ id: 'SAL-031', firstName: 'Tyler', lastName: 'Scott', address: '1795 K Ave', city: 'Plano', state: 'TX', urgency: 'standard', leadSource: 'Canvassers', status: 'Contacted', contractValue: null, createdAt: '2025-11-12T09:00:00Z', stormSource: { id: 'SE-006', areaName: 'S Plano / Haggard Park Area', date: '2025-11-05', maxHailSize: 1.6, severity: 'significant' } },
|
||||
|
||||
// ── SE-007 · Sep 18 2025 · Moderate · Frisco / N Plano ───────────────────
|
||||
{ id: 'SAL-032', firstName: 'Amber', lastName: 'Green', address: '12001 Coit Rd', city: 'Plano', state: 'TX', urgency: 'standard', leadSource: 'Door Knock', status: 'Closed', contractValue: 17600, createdAt: '2025-09-19T10:00:00Z', stormSource: { id: 'SE-007', areaName: 'Frisco / N Plano', date: '2025-09-18', maxHailSize: 1.1, severity: 'moderate' } },
|
||||
{ id: 'SAL-033', firstName: 'Nathan', lastName: 'Adams', address: '12050 Coit Rd', city: 'Plano', state: 'TX', urgency: 'standard', leadSource: 'Door Knock', status: 'Closed', contractValue: 14300, createdAt: '2025-09-20T11:15:00Z', stormSource: { id: 'SE-007', areaName: 'Frisco / N Plano', date: '2025-09-18', maxHailSize: 1.1, severity: 'moderate' } },
|
||||
{ id: 'SAL-034', firstName: 'Brittany', lastName: 'Baker', address: '12020 Coit Rd', city: 'Plano', state: 'TX', urgency: 'standard', leadSource: 'Door Knock', status: 'Contacted', contractValue: null, createdAt: '2025-09-21T09:30:00Z', stormSource: { id: 'SE-007', areaName: 'Frisco / N Plano', date: '2025-09-18', maxHailSize: 1.1, severity: 'moderate' } },
|
||||
{ id: 'SAL-035', firstName: 'Dylan', lastName: 'Nelson', address: '12080 Coit Rd', city: 'Plano', state: 'TX', urgency: 'standard', leadSource: 'Door Knock', status: 'New', contractValue: null, createdAt: '2025-09-22T10:45:00Z', stormSource: { id: 'SE-007', areaName: 'Frisco / N Plano', date: '2025-09-18', maxHailSize: 1.1, severity: 'moderate' } },
|
||||
{ id: 'SAL-032', firstName: 'Amber', lastName: 'Green', address: '12001 Coit Rd', city: 'Plano', state: 'TX', urgency: 'standard', leadSource: 'Canvassers', status: 'Closed', contractValue: 17600, createdAt: '2025-09-19T10:00:00Z', stormSource: { id: 'SE-007', areaName: 'Frisco / N Plano', date: '2025-09-18', maxHailSize: 1.1, severity: 'moderate' } },
|
||||
{ id: 'SAL-033', firstName: 'Nathan', lastName: 'Adams', address: '12050 Coit Rd', city: 'Plano', state: 'TX', urgency: 'standard', leadSource: 'Canvassers', status: 'Closed', contractValue: 14300, createdAt: '2025-09-20T11:15:00Z', stormSource: { id: 'SE-007', areaName: 'Frisco / N Plano', date: '2025-09-18', maxHailSize: 1.1, severity: 'moderate' } },
|
||||
{ id: 'SAL-034', firstName: 'Brittany', lastName: 'Baker', address: '12020 Coit Rd', city: 'Plano', state: 'TX', urgency: 'standard', leadSource: 'Canvassers', status: 'Contacted', contractValue: null, createdAt: '2025-09-21T09:30:00Z', stormSource: { id: 'SE-007', areaName: 'Frisco / N Plano', date: '2025-09-18', maxHailSize: 1.1, severity: 'moderate' } },
|
||||
{ id: 'SAL-035', firstName: 'Dylan', lastName: 'Nelson', address: '12080 Coit Rd', city: 'Plano', state: 'TX', urgency: 'standard', leadSource: 'Canvassers', status: 'New', contractValue: null, createdAt: '2025-09-22T10:45:00Z', stormSource: { id: 'SE-007', areaName: 'Frisco / N Plano', date: '2025-09-18', maxHailSize: 1.1, severity: 'moderate' } },
|
||||
];
|
||||
|
||||
export const generateMockPaymentRequests = (rowId, actual, paid, allocated = 0) => {
|
||||
// Extract a numeric seed from the rowId (e.g. 'exp_base_0' -> 0, 'exp_team_1' -> 1)
|
||||
const seedNum = parseInt(rowId.replace(/[^0-9]/g, '')) || 0;
|
||||
const seed = seedNum % 3;
|
||||
|
||||
// ── Case: actual > 0 and fully paid ──────────────────────────────────────
|
||||
if (actual > 0 && paid) {
|
||||
if (actual <= 1000) {
|
||||
return [
|
||||
{
|
||||
id: `req_${rowId}_1`,
|
||||
requestDate: '2026-04-28',
|
||||
requestedAmount: actual,
|
||||
amountPaid: actual,
|
||||
paymentDate: '2026-05-02',
|
||||
}
|
||||
];
|
||||
} else {
|
||||
const part1 = Math.round(actual * 0.6);
|
||||
const part2 = actual - part1;
|
||||
return [
|
||||
{
|
||||
id: `req_${rowId}_1`,
|
||||
requestDate: '2026-04-15',
|
||||
requestedAmount: part1,
|
||||
amountPaid: part1,
|
||||
paymentDate: '2026-04-18',
|
||||
},
|
||||
{
|
||||
id: `req_${rowId}_2`,
|
||||
requestDate: '2026-05-01',
|
||||
requestedAmount: part2,
|
||||
amountPaid: part2,
|
||||
paymentDate: '2026-05-04',
|
||||
}
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// ── Case: actual > 0, not fully paid ─────────────────────────────────────
|
||||
if (actual > 0 && !paid) {
|
||||
if (actual > 2000) {
|
||||
const part1 = Math.round(actual * 0.5);
|
||||
const part2 = actual - part1;
|
||||
|
||||
if (seed === 0) {
|
||||
// One paid + one partially paid
|
||||
const part2Paid = Math.round(part2 * 0.4);
|
||||
return [
|
||||
{
|
||||
id: `req_${rowId}_1`,
|
||||
requestDate: '2026-05-05',
|
||||
requestedAmount: part1,
|
||||
amountPaid: part1,
|
||||
paymentDate: '2026-05-08',
|
||||
},
|
||||
{
|
||||
id: `req_${rowId}_2`,
|
||||
requestDate: '2026-05-18',
|
||||
requestedAmount: part2,
|
||||
amountPaid: part2Paid,
|
||||
paymentDate: '2026-05-22',
|
||||
}
|
||||
];
|
||||
} else if (seed === 1) {
|
||||
// One paid + one fully unpaid
|
||||
return [
|
||||
{
|
||||
id: `req_${rowId}_1`,
|
||||
requestDate: '2026-05-08',
|
||||
requestedAmount: part1,
|
||||
amountPaid: part1,
|
||||
paymentDate: '2026-05-10',
|
||||
},
|
||||
{
|
||||
id: `req_${rowId}_2`,
|
||||
requestDate: '2026-05-22',
|
||||
requestedAmount: part2,
|
||||
amountPaid: 0,
|
||||
paymentDate: null,
|
||||
}
|
||||
];
|
||||
} else {
|
||||
// Three splits: paid, partial, unpaid
|
||||
const p1 = Math.round(actual * 0.4);
|
||||
const p2 = Math.round(actual * 0.35);
|
||||
const p3 = actual - p1 - p2;
|
||||
const p2Paid = Math.round(p2 * 0.6);
|
||||
return [
|
||||
{
|
||||
id: `req_${rowId}_1`,
|
||||
requestDate: '2026-04-20',
|
||||
requestedAmount: p1,
|
||||
amountPaid: p1,
|
||||
paymentDate: '2026-04-23',
|
||||
},
|
||||
{
|
||||
id: `req_${rowId}_2`,
|
||||
requestDate: '2026-05-05',
|
||||
requestedAmount: p2,
|
||||
amountPaid: p2Paid,
|
||||
paymentDate: '2026-05-09',
|
||||
},
|
||||
{
|
||||
id: `req_${rowId}_3`,
|
||||
requestDate: '2026-05-20',
|
||||
requestedAmount: p3,
|
||||
amountPaid: 0,
|
||||
paymentDate: null,
|
||||
}
|
||||
];
|
||||
}
|
||||
} else {
|
||||
if (seed === 1) {
|
||||
// Single partially paid request
|
||||
const partPaid = Math.round(actual * 0.5);
|
||||
return [
|
||||
{
|
||||
id: `req_${rowId}_1`,
|
||||
requestDate: '2026-05-12',
|
||||
requestedAmount: actual,
|
||||
amountPaid: partPaid,
|
||||
paymentDate: '2026-05-15',
|
||||
}
|
||||
];
|
||||
} else {
|
||||
// Single unpaid request
|
||||
return [
|
||||
{
|
||||
id: `req_${rowId}_1`,
|
||||
requestDate: '2026-05-14',
|
||||
requestedAmount: actual,
|
||||
amountPaid: 0,
|
||||
paymentDate: null,
|
||||
}
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Case: actual === 0 but allocated > 0 — pending / not-started ─────────
|
||||
// Show a pending payment request for the allocated amount (requested but not yet started)
|
||||
if (actual <= 0 && allocated > 0) {
|
||||
if (seed === 0) {
|
||||
// Fully pending (submitted, no payment yet)
|
||||
return [
|
||||
{
|
||||
id: `req_${rowId}_1`,
|
||||
requestDate: '2026-05-25',
|
||||
requestedAmount: allocated,
|
||||
amountPaid: 0,
|
||||
paymentDate: null,
|
||||
}
|
||||
];
|
||||
} else if (seed === 1) {
|
||||
// Two-part pending — initial deposit requested, balance pending
|
||||
const deposit = Math.round(allocated * 0.3);
|
||||
const balance = allocated - deposit;
|
||||
return [
|
||||
{
|
||||
id: `req_${rowId}_1`,
|
||||
requestDate: '2026-05-20',
|
||||
requestedAmount: deposit,
|
||||
amountPaid: 0,
|
||||
paymentDate: null,
|
||||
},
|
||||
{
|
||||
id: `req_${rowId}_2`,
|
||||
requestDate: '2026-05-28',
|
||||
requestedAmount: balance,
|
||||
amountPaid: 0,
|
||||
paymentDate: null,
|
||||
}
|
||||
];
|
||||
} else {
|
||||
// Single pending with partial deposit paid upfront
|
||||
const deposit = Math.round(allocated * 0.25);
|
||||
return [
|
||||
{
|
||||
id: `req_${rowId}_1`,
|
||||
requestDate: '2026-05-22',
|
||||
requestedAmount: allocated,
|
||||
amountPaid: deposit,
|
||||
paymentDate: '2026-05-24',
|
||||
}
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return [];
|
||||
};
|
||||
|
||||
@@ -140,7 +140,7 @@ export default function CreateLeadPage() {
|
||||
...INITIAL_FORM,
|
||||
city: 'Plano',
|
||||
state: 'TX',
|
||||
leadSource: 'Door Knock',
|
||||
leadSource: 'Canvassers',
|
||||
urgency,
|
||||
notes: `Storm zone canvass — ${stormSource.areaName}.${stormSource.maxHailSize ? ` ${stormSource.maxHailSize}" hail` : ''}${stormDate ? ` on ${stormDate}` : ''}.`,
|
||||
stormSource,
|
||||
@@ -186,7 +186,7 @@ export default function CreateLeadPage() {
|
||||
setFormData(prev => {
|
||||
const next = { ...prev, [field]: value };
|
||||
if (field === 'leadSource') {
|
||||
if (value !== 'Door Knock') { next.canvasserId = ''; next.canvasserName = ''; }
|
||||
if (value !== 'Canvassers') { next.canvasserId = ''; next.canvasserName = ''; }
|
||||
if (value !== 'Referral') { next.referralNote = ''; }
|
||||
}
|
||||
return next;
|
||||
@@ -215,7 +215,7 @@ export default function CreateLeadPage() {
|
||||
if (!formData.address) missingFields.push('Street Address');
|
||||
if (!formData.city) missingFields.push('City');
|
||||
if (!formData.leadSource) missingFields.push('Lead Source');
|
||||
if (formData.leadSource === 'Door Knock' && !formData.canvasserId) missingFields.push('Canvasser');
|
||||
if (formData.leadSource === 'Canvassers' && !formData.canvasserId) missingFields.push('Canvasser');
|
||||
|
||||
if (missingFields.length > 0) {
|
||||
setValidationError(`Required: ${missingFields.join(', ')}`);
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import React, { useState, useMemo, useRef, useEffect } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { useParams, useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { useAuth } from '../../context/AuthContext';
|
||||
import { useMockStore } from '../../data/mockStore';
|
||||
import { useMockStore, generateMockPaymentRequests } from '../../data/mockStore';
|
||||
import { LIFECYCLE_STAGES, REWORK_STAGE, nextStage, prevStage, stageToPct } from '../../data/lifecycle';
|
||||
import { SpotlightCard } from '../../components/SpotlightCard';
|
||||
import { AnimatedCounter } from '../../components/AnimatedCounter';
|
||||
import { motion } from 'framer-motion';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import {
|
||||
BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer
|
||||
} from 'recharts';
|
||||
@@ -20,6 +20,7 @@ import InvoiceDetailModal from '../../components/owner/InvoiceDetailModal';
|
||||
import EstimateDetailModal from '../../components/estimates/EstimateDetailModal';
|
||||
import CreateItemModal from '../../components/owner/CreateItemModal';
|
||||
import CommissionSettingsModal from '../../components/owner/CommissionSettingsModal';
|
||||
import AddTeamCostModal from '../../components/owner/AddTeamCostModal';
|
||||
import TeamManagementPanel from '../../components/owner/TeamManagementPanel';
|
||||
import { usePermissions } from '../../hooks/usePermissions';
|
||||
import { resolveCommission, findAssignedUserRoleKey } from '../../utils/commissionResolver';
|
||||
@@ -114,7 +115,12 @@ function seedDocsMock(projectId) {
|
||||
return all.slice(0, 4 + seed);
|
||||
}
|
||||
|
||||
function seedPaymentsMock(projectId) {
|
||||
function seedPaymentsMock(project) {
|
||||
// Prefer per-project payments-received data when defined on the project (even if empty)
|
||||
if (Array.isArray(project?.paymentsReceived)) return project.paymentsReceived;
|
||||
|
||||
// Fallback (legacy) for any project not yet migrated — generic pool sliced by id
|
||||
const projectId = project?.id || '';
|
||||
const all = [
|
||||
{ id: 'pay_001', from: 'State Farm Insurance', method: 'ACH', amount: 18500, date: '2026-01-28', refNumber: 'ACH-2026-4411', memo: 'Initial claim payout — roof replacement' },
|
||||
{ id: 'pay_002', from: 'Justin Johnson', method: 'Check', amount: 2500, date: '2026-02-05', refNumber: 'CHK-8832', memo: 'Homeowner deductible payment' },
|
||||
@@ -123,6 +129,7 @@ function seedPaymentsMock(projectId) {
|
||||
{ id: 'pay_005', from: 'State Farm Insurance', method: 'Check', amount: 4100, date: '2026-03-12', refNumber: 'CHK-7761', memo: 'Final supplement payout' },
|
||||
{ id: 'pay_006', from: 'Justin Johnson', method: 'Cash', amount: 500, date: '2026-03-20', refNumber: '', memo: 'Gutter guard add-on' },
|
||||
];
|
||||
if (!projectId) return all.slice(0, 3);
|
||||
const seed = projectId.charCodeAt(projectId.length - 1) % 3;
|
||||
return all.slice(0, 3 + seed);
|
||||
}
|
||||
@@ -133,13 +140,24 @@ const PAYMENTS_PER_PAGE = 5;
|
||||
const EXPENSE_TYPE_CONFIG = {
|
||||
material: { label: 'Material', icon: Wrench, color: 'text-sky-600 dark:text-[#00f0ff]', bg: 'bg-sky-50 dark:bg-blue-500/10', border: 'border-sky-200 dark:border-blue-500/20' },
|
||||
labor: { label: 'Labor', icon: HardHat, color: 'text-emerald-600 dark:text-emerald-400', bg: 'bg-emerald-50 dark:bg-emerald-500/10', border: 'border-emerald-200 dark:border-emerald-500/20' },
|
||||
team: { label: 'Team Commission', icon: UserCheck, color: 'text-purple-600 dark:text-purple-400', bg: 'bg-purple-50 dark:bg-purple-500/10', border: 'border-purple-200 dark:border-purple-500/20' },
|
||||
team: { label: 'Team Cost', icon: UserCheck, color: 'text-purple-600 dark:text-purple-400', bg: 'bg-purple-50 dark:bg-purple-500/10', border: 'border-purple-200 dark:border-purple-500/20' },
|
||||
subcontractor: { label: 'Subcontractor', icon: HardHat, color: 'text-amber-600 dark:text-[#fda913]', bg: 'bg-amber-50 dark:bg-[#fda913]/10', border: 'border-amber-200 dark:border-[#fda913]/20' },
|
||||
other: { label: 'Other', icon: DollarSign, color: 'text-zinc-600 dark:text-zinc-400', bg: 'bg-zinc-100 dark:bg-white/5', border: 'border-zinc-200 dark:border-white/10' },
|
||||
};
|
||||
|
||||
const SUBCONTRACTOR_WORK_TYPES = ['Plumbing', 'Electrical', 'Painting', 'Roofing', 'Civil work', 'HVAC', 'Carpentry', 'Other'];
|
||||
|
||||
// Short labels for team cost types — shown inline in the Type badge (e.g. "Team Cost · Comm")
|
||||
const COST_TYPE_ABBR = {
|
||||
'Commission': 'Comm',
|
||||
'Salary': 'Salary',
|
||||
'Bonus': 'Bonus',
|
||||
'Hourly Wage': 'Hourly',
|
||||
'Per Diem': 'Per Diem',
|
||||
'Referral Fee': 'Referral',
|
||||
'Overtime': 'OT',
|
||||
};
|
||||
|
||||
const inferExpenseType = (category = '') => {
|
||||
const c = (category || '').toLowerCase();
|
||||
if (c.includes('material')) return 'material';
|
||||
@@ -150,27 +168,56 @@ const inferExpenseType = (category = '') => {
|
||||
};
|
||||
|
||||
function seedExpenseRowsMock(project) {
|
||||
const baseRows = (project?.budgetBreakdown || []).map((b, i) => ({
|
||||
id: `exp_base_${i}`,
|
||||
type: inferExpenseType(b.category),
|
||||
name: b.name || '—',
|
||||
category: b.category || '',
|
||||
allocated: Number(b.allocated) || 0,
|
||||
actual: Number(b.actual) || 0,
|
||||
paid: Number(b.actual) > 0 && Number(b.actual) >= Number(b.allocated) * 0.9,
|
||||
}));
|
||||
// Prefer per-project expenses data when defined on the project (even if empty)
|
||||
if (Array.isArray(project?.expenses)) {
|
||||
return project.expenses.map((e, i) => {
|
||||
const allocated = Number(e.allocated) || 0;
|
||||
const actual = Number(e.actual) || 0;
|
||||
return {
|
||||
id: e.id || `exp_${i}`,
|
||||
type: e.type || inferExpenseType(e.category),
|
||||
name: e.name || '—',
|
||||
category: e.category || '',
|
||||
costType: e.costType || '',
|
||||
allocated,
|
||||
actual,
|
||||
paid: e.paid ?? (actual > 0 && actual >= allocated * 0.9),
|
||||
paymentRequests: e.paymentRequests || [],
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
const baseRows = (project?.budgetBreakdown || []).map((b, i) => {
|
||||
const id = `exp_base_${i}`;
|
||||
const type = inferExpenseType(b.category);
|
||||
const name = b.name || '—';
|
||||
const category = b.category || '';
|
||||
const allocated = Number(b.allocated) || 0;
|
||||
const actual = Number(b.actual) || 0;
|
||||
const paid = Number(b.actual) > 0 && Number(b.actual) >= Number(b.allocated) * 0.9;
|
||||
return {
|
||||
id, type, name, category, allocated, actual, paid,
|
||||
paymentRequests: generateMockPaymentRequests(id, actual, paid, allocated)
|
||||
};
|
||||
});
|
||||
const seed = (project?.id || '').charCodeAt((project?.id || 'x').length - 1) % 3;
|
||||
const teamRows = [
|
||||
{ id: 'exp_team_1', type: 'team', name: 'Jesus Gonzales', category: 'Sales Rep', allocated: 4200, actual: 4200, paid: true },
|
||||
{ id: 'exp_team_2', type: 'team', name: 'Cody Tatum', category: 'Canvasser', allocated: 1500, actual: 1500, paid: false },
|
||||
{ id: 'exp_team_3', type: 'team', name: 'Sarah Calloway', category: 'Estimator', allocated: 1200, actual: 0, paid: false },
|
||||
].slice(0, 2 + seed);
|
||||
{ id: 'exp_team_1', type: 'team', name: 'Jesus Gonzales', category: 'Sales Rep', costType: 'Commission', allocated: 4200, actual: 4200, paid: true },
|
||||
{ id: 'exp_team_2', type: 'team', name: 'Cody Tatum', category: 'Canvasser', costType: 'Hourly Wage', allocated: 1500, actual: 1500, paid: false },
|
||||
{ id: 'exp_team_3', type: 'team', name: 'Sarah Calloway', category: 'Estimator', costType: 'Salary', allocated: 1200, actual: 0, paid: false },
|
||||
].slice(0, 2 + seed).map(row => ({
|
||||
...row,
|
||||
paymentRequests: generateMockPaymentRequests(row.id, row.actual, row.paid, row.allocated)
|
||||
}));
|
||||
const subRows = [
|
||||
{ id: 'exp_sub_1', type: 'subcontractor', name: 'PlumbPro Services', category: 'Plumbing', allocated: 1800, actual: 1800, paid: true },
|
||||
{ id: 'exp_sub_2', type: 'subcontractor', name: 'Bright Electric LLC', category: 'Electrical', allocated: 2400, actual: 2150, paid: false },
|
||||
{ id: 'exp_sub_3', type: 'subcontractor', name: 'Premium Painters', category: 'Painting', allocated: 950, actual: 950, paid: true },
|
||||
{ id: 'exp_sub_4', type: 'subcontractor', name: 'Solid Civil Works', category: 'Civil work', allocated: 3200, actual: 0, paid: false },
|
||||
].slice(0, 3 + seed);
|
||||
].slice(0, 3 + seed).map(row => ({
|
||||
...row,
|
||||
paymentRequests: generateMockPaymentRequests(row.id, row.actual, row.paid, row.allocated)
|
||||
}));
|
||||
return [...baseRows, ...teamRows, ...subRows];
|
||||
}
|
||||
|
||||
@@ -199,14 +246,23 @@ const OwnerProjectDetail = () => {
|
||||
} = useMockStore();
|
||||
const navigate = useNavigate();
|
||||
const { can } = usePermissions();
|
||||
const [activeTab, setActiveTab] = useState('overview');
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const [activeTab, setActiveTab] = useState(() => searchParams.get('tab') || 'overview');
|
||||
|
||||
useEffect(() => {
|
||||
const tab = searchParams.get('tab');
|
||||
if (tab && tab !== activeTab) {
|
||||
setActiveTab(tab);
|
||||
}
|
||||
}, [searchParams, activeTab]);
|
||||
const [selectedCO, setSelectedCO] = useState(null);
|
||||
const [selectedInvoice, setSelectedInvoice] = useState(null);
|
||||
const [selectedVersion, setSelectedVersion] = useState(null);
|
||||
const [createModalConfig, setCreateModalConfig] = useState(null);
|
||||
|
||||
// ── Payments state ─────────────────────────────────────────────────────────
|
||||
const [payments, setPayments] = useState(() => seedPaymentsMock(projectId));
|
||||
// Seeded from the resolved project (see effect below) so each project shows its own payments
|
||||
const [payments, setPayments] = useState([]);
|
||||
const [paymentSort, setPaymentSort] = useState({ field: 'date', dir: 'desc' });
|
||||
const [paymentPage, setPaymentPage] = useState(0);
|
||||
|
||||
@@ -214,6 +270,79 @@ const OwnerProjectDetail = () => {
|
||||
const [expenseRows, setExpenseRows] = useState([]);
|
||||
const [expensesSeeded, setExpensesSeeded] = useState(false);
|
||||
|
||||
// ── Row Expansion state for Cost Breakdown (accordion — one open at a time) ──
|
||||
const [expandedRowId, setExpandedRowId] = useState(null);
|
||||
const toggleRowExpanded = (rowId) => {
|
||||
setExpandedRowId(prev => (prev === rowId ? null : rowId));
|
||||
};
|
||||
|
||||
// ── Add Team Cost modal ───────────────────────────────────────────────────
|
||||
const [addTeamCostOpen, setAddTeamCostOpen] = useState(false);
|
||||
|
||||
// Helper functions for payment requests & status calculations
|
||||
const getRequestRemaining = (req) => (Number(req.requestedAmount) || 0) - (Number(req.amountPaid) || 0);
|
||||
const getRequestStatus = (req) => {
|
||||
const remaining = getRequestRemaining(req);
|
||||
const paid = Number(req.amountPaid) || 0;
|
||||
if (remaining <= 0) return 'Paid';
|
||||
if (paid > 0) return 'Partially Paid';
|
||||
return 'Unpaid';
|
||||
};
|
||||
|
||||
const getRowTotalRequested = (row) => {
|
||||
const reqs = row.paymentRequests || [];
|
||||
return reqs.reduce((sum, r) => sum + (Number(r.requestedAmount) || 0), 0);
|
||||
};
|
||||
const getRowTotalPaid = (row) => {
|
||||
const reqs = row.paymentRequests || [];
|
||||
return reqs.reduce((sum, r) => sum + (Number(r.amountPaid) || 0), 0);
|
||||
};
|
||||
const getRowRemaining = (row) => {
|
||||
const actual = Number(row.actual) || 0;
|
||||
const totalPaid = getRowTotalPaid(row);
|
||||
if (actual > 0) {
|
||||
return Math.max(0, actual - totalPaid);
|
||||
}
|
||||
// For zero-actual rows, use total requested - total paid from payment requests
|
||||
const totalRequested = getRowTotalRequested(row);
|
||||
return Math.max(0, totalRequested - totalPaid);
|
||||
};
|
||||
const getRowStatus = (row) => {
|
||||
const actual = Number(row.actual) || 0;
|
||||
const totalPaid = getRowTotalPaid(row);
|
||||
const remaining = getRowRemaining(row);
|
||||
const totalRequested = getRowTotalRequested(row);
|
||||
|
||||
if (actual === 0 && totalRequested === 0) {
|
||||
return 'Unpaid';
|
||||
}
|
||||
if (remaining <= 0) {
|
||||
return 'Paid';
|
||||
}
|
||||
if (totalPaid > 0) {
|
||||
return 'Partially Paid';
|
||||
}
|
||||
// Has payment requests but nothing paid
|
||||
if (totalRequested > 0) {
|
||||
return 'Unpaid';
|
||||
}
|
||||
return 'Unpaid';
|
||||
};
|
||||
|
||||
const processedExpenseRows = useMemo(() => {
|
||||
return expenseRows.map(row => {
|
||||
const remaining = getRowRemaining(row);
|
||||
const status = getRowStatus(row);
|
||||
const paid = Number(row.actual) > 0 && remaining === 0;
|
||||
return {
|
||||
...row,
|
||||
remaining,
|
||||
status,
|
||||
paid
|
||||
};
|
||||
});
|
||||
}, [expenseRows]);
|
||||
|
||||
const [openTooltip, setOpenTooltip] = useState(null);
|
||||
|
||||
// ── Commission config state ─────────────────────────────────────────────
|
||||
@@ -313,6 +442,7 @@ const OwnerProjectDetail = () => {
|
||||
useEffect(() => {
|
||||
if (project && !expensesSeeded) {
|
||||
setExpenseRows(seedExpenseRowsMock(project));
|
||||
setPayments(seedPaymentsMock(project));
|
||||
setExpensesSeeded(true);
|
||||
}
|
||||
}, [project, expensesSeeded]);
|
||||
@@ -328,9 +458,11 @@ const OwnerProjectDetail = () => {
|
||||
type: type || data.type || 'other',
|
||||
name: data.name || '',
|
||||
category: data.category || '',
|
||||
costType: data.costType || '',
|
||||
allocated: amt,
|
||||
actual: amt,
|
||||
paid: false,
|
||||
paymentRequests: [],
|
||||
};
|
||||
setExpenseRows(prev => [newRow, ...prev]);
|
||||
};
|
||||
@@ -371,11 +503,11 @@ const OwnerProjectDetail = () => {
|
||||
const activeCommissionConfig = resolvedCommission;
|
||||
|
||||
const expenseTotals = useMemo(() => {
|
||||
const total = expenseRows.reduce((s, r) => s + (Number(r.actual) || Number(r.allocated) || 0), 0);
|
||||
const paid = expenseRows.reduce((s, r) => s + (r.paid ? (Number(r.actual) || Number(r.allocated) || 0) : 0), 0);
|
||||
const total = processedExpenseRows.reduce((s, r) => s + (Number(r.actual) || Number(r.allocated) || 0), 0);
|
||||
const paid = processedExpenseRows.reduce((s, r) => s + (r.paid ? (Number(r.actual) || Number(r.allocated) || 0) : 0), 0);
|
||||
const outstanding = Math.max(0, total - paid);
|
||||
return { total, paid, outstanding };
|
||||
}, [expenseRows]);
|
||||
}, [processedExpenseRows]);
|
||||
|
||||
const profitMetrics = useMemo(() => {
|
||||
if (!project) return { totalCosts: 0, totalExpenses: 0, paidExpenses: 0, outstandingExpenses: 0, totalBudget: 0, commission: 0, commissionFormula: '', grossProfit: 0, netProfit: 0, grossMargin: 0, netMargin: 0 };
|
||||
@@ -554,7 +686,10 @@ const OwnerProjectDetail = () => {
|
||||
return (
|
||||
<button
|
||||
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
|
||||
? 'border-amber-500 text-amber-600 dark:text-amber-400'
|
||||
: '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">
|
||||
{!isAtFinalStage && (
|
||||
<button
|
||||
onClick={() => 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'),
|
||||
})}
|
||||
onClick={() => {
|
||||
// Gate: a project can't move to "Work In Progress" without any team members assigned
|
||||
const hasTeamMembers = (project.teamMembers || []).length > 0;
|
||||
if (nextStageName === 'Work In Progress' && !hasTeamMembers) {
|
||||
setCreateModalConfig({
|
||||
title: 'Add Team Members First',
|
||||
icon: Users,
|
||||
iconColor: 'text-amber-500 dark:text-amber-400',
|
||||
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".',
|
||||
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"
|
||||
>
|
||||
<ChevronRight size={14} />
|
||||
@@ -1006,16 +1159,7 @@ const OwnerProjectDetail = () => {
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<button
|
||||
onClick={() => setCreateModalConfig({
|
||||
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'),
|
||||
})}
|
||||
onClick={() => setAddTeamCostOpen(true)}
|
||||
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
|
||||
@@ -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 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-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>
|
||||
</thead>
|
||||
<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 TypeIcon = cfg.icon;
|
||||
const isExpanded = expandedRowId === row.id;
|
||||
return (
|
||||
<tr key={row.id} className="hover:bg-zinc-50 dark:hover:bg-white/5 transition-colors">
|
||||
<td className="px-5 py-4">
|
||||
<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}`}>
|
||||
<TypeIcon size={11} />
|
||||
{cfg.label}
|
||||
</span>
|
||||
</td>
|
||||
<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 text-right font-mono text-sm text-[#00f0ff]">{formatCurrency(row.actual)}</td>
|
||||
<td className="px-5 py-4 text-center">
|
||||
<label className="inline-flex items-center justify-center cursor-pointer">
|
||||
<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} />}
|
||||
<React.Fragment key={row.id}>
|
||||
<tr
|
||||
onClick={() => toggleRowExpanded(row.id)}
|
||||
className={`transition-colors cursor-pointer border-l-2 ${
|
||||
isExpanded
|
||||
? 'bg-amber-50/60 dark:bg-amber-500/[0.06] border-l-amber-500 dark:border-l-[#fda913]'
|
||||
: 'hover:bg-zinc-50 dark:hover:bg-white/5 border-l-transparent'
|
||||
}`}
|
||||
>
|
||||
<td className="px-5 py-4">
|
||||
<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}`}>
|
||||
<TypeIcon size={11} />
|
||||
{cfg.label}{row.type === 'team' && row.costType ? ` · ${COST_TYPE_ABBR[row.costType] || row.costType}` : ''}
|
||||
</span>
|
||||
</label>
|
||||
</td>
|
||||
</tr>
|
||||
</td>
|
||||
<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 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>
|
||||
<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.
|
||||
</td>
|
||||
</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>
|
||||
</td>
|
||||
<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 className="px-5 py-4 text-right font-mono text-sm font-bold text-[#00f0ff]">
|
||||
{formatCurrency(profitMetrics.totalExpenses)}
|
||||
</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">
|
||||
{expenseRows.filter(r => r.paid).length}/{expenseRows.length}
|
||||
<td className="px-5 py-4 text-right font-mono text-sm font-bold text-zinc-600 dark:text-zinc-300">
|
||||
{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>
|
||||
</tr>
|
||||
</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>
|
||||
|
||||
{/* 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 */}
|
||||
<CommissionSettingsModal
|
||||
isOpen={commissionSettingsOpen}
|
||||
|
||||
@@ -602,7 +602,7 @@ const OwnerSnapshot = () => {
|
||||
<SubcontractorPaymentsPanel
|
||||
subcontractorTasks={subcontractorTasks}
|
||||
subcontractors={subcontractors}
|
||||
projects={projects}
|
||||
projects={ownerProjects}
|
||||
/>
|
||||
</SpotlightCard>
|
||||
</section>
|
||||
|
||||
Reference in New Issue
Block a user