Owners box: subcontractor table restrutcured name wise and display projects of each subcon with payment statuses
This commit is contained in:
@@ -1,6 +1,9 @@
|
||||
import React, { useMemo, useState, useRef, useEffect } from 'react';
|
||||
import { Wallet, Users, Search, ChevronDown, X, Check, FilterX } from 'lucide-react';
|
||||
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,9 +12,18 @@ 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) {
|
||||
@@ -20,36 +32,47 @@ function taskTotal(task) {
|
||||
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 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 +80,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 +89,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 +100,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,6 +119,160 @@ 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 }) {
|
||||
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 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]">
|
||||
<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">{proj.projectName}</span>
|
||||
{proj.projectAddress && (
|
||||
<span className="text-[10px] text-zinc-400 dark:text-zinc-500 truncate max-w-[200px]">{proj.projectAddress}</span>
|
||||
)}
|
||||
</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 = [],
|
||||
@@ -124,22 +290,25 @@ 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 requested = taskTotal(task);
|
||||
if (requested <= 0) continue;
|
||||
const sub = subById.get(task.subcontractorId);
|
||||
const project = task.projectId ? projectById.get(task.projectId) : null;
|
||||
entries.push({
|
||||
key: task.id,
|
||||
name: task.subcontractorName,
|
||||
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 || '',
|
||||
projectId: project?.id || null,
|
||||
workCategory: sub?.tradeType || '—',
|
||||
amount,
|
||||
requested,
|
||||
paid: taskPaid(task),
|
||||
requestedAt: task.paymentRequestedAt || null,
|
||||
paidAt: task.paidAt || null,
|
||||
paymentStatus: task.paymentStatus || 'Unpaid',
|
||||
@@ -148,86 +317,118 @@ const SubcontractorPaymentsPanel = ({
|
||||
return entries;
|
||||
}, [subcontractorTasks, subById, projectById]);
|
||||
|
||||
// ── Filter state ───────────────────────────────────────────────
|
||||
// ── Filter state ─────────────────────────────────────────────────────────
|
||||
const [nameQuery, setNameQuery] = useState('');
|
||||
const [projectFilter, setProjectFilter] = useState('all');
|
||||
const [categoryFilter, setCategoryFilter] = 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 });
|
||||
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,
|
||||
});
|
||||
}
|
||||
return Array.from(map.values()).sort((a, b) => a.name.localeCompare(b.name));
|
||||
}, [rows]);
|
||||
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 +437,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 +491,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 +502,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 +512,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">
|
||||
<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 payments 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"
|
||||
>
|
||||
<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>
|
||||
</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="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={() => 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"
|
||||
>
|
||||
{/* 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 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 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>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
</div>
|
||||
{/* 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 text-zinc-700 dark:text-zinc-300 truncate max-w-[200px]" title={row.projectName}>
|
||||
{row.projectName}
|
||||
<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>
|
||||
{row.projectAddress && (
|
||||
<div className="text-[10px] text-zinc-500 dark:text-zinc-400 truncate max-w-[200px]" title={row.projectAddress}>
|
||||
{row.projectAddress}
|
||||
</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>
|
||||
</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}
|
||||
);
|
||||
})}
|
||||
</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 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)}
|
||||
</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>
|
||||
|
||||
+399
-2
@@ -7388,8 +7388,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 +7486,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 +7564,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,6 +7607,7 @@ 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',
|
||||
},
|
||||
@@ -7672,8 +7677,10 @@ const MOCK_SUBCONTRACTOR_TASKS = [
|
||||
},
|
||||
],
|
||||
projectId: 'PRJ-2026-007',
|
||||
paymentStatus: 'Unpaid',
|
||||
paymentStatus: 'Partially Paid',
|
||||
paymentRequestedAt: '2026-05-14T13:00:00Z',
|
||||
amountPaid: 80,
|
||||
paidAt: '2026-05-18T09:00:00Z',
|
||||
createdAt: '2026-05-12T10:00:00Z',
|
||||
updatedAt: '2026-05-15T16:20:00Z',
|
||||
},
|
||||
@@ -7733,6 +7740,7 @@ const MOCK_SUBCONTRACTOR_TASKS = [
|
||||
projectId: 'PRJ-2026-009',
|
||||
paymentStatus: 'Unpaid',
|
||||
paymentRequestedAt: null,
|
||||
amountPaid: 0,
|
||||
createdAt: '2026-05-17T14:00:00Z',
|
||||
updatedAt: '2026-05-26T11:00:00Z',
|
||||
},
|
||||
@@ -7812,6 +7820,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 +7917,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,6 +8006,7 @@ 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',
|
||||
},
|
||||
@@ -8049,9 +8060,395 @@ const MOCK_SUBCONTRACTOR_TASKS = [
|
||||
projectId: null,
|
||||
paymentStatus: 'Unpaid',
|
||||
paymentRequestedAt: null,
|
||||
amountPaid: 0,
|
||||
createdAt: '2026-05-09T10:00:00Z',
|
||||
updatedAt: '2026-05-20T15:30:00Z',
|
||||
},
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// sct_011 — Maya Patel (sub_002) — full roof replacement PRJ-2026-001, PAID
|
||||
// -------------------------------------------------------------------
|
||||
{
|
||||
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: '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-04-22T08:00:00Z',
|
||||
status: 'Completed',
|
||||
photos: [],
|
||||
statusHistory: [
|
||||
{ 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: [],
|
||||
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' },
|
||||
],
|
||||
fees: [
|
||||
{ id: 'sct_014_f1', description: 'Labour — master bath re-pipe', type: 'Labour / Service Fee', amount: 740, createdAt: '2026-04-28T15:10: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_015 — Jennifer Castillo (sub_004) — PRJ-2026-007, Partially Paid
|
||||
// -------------------------------------------------------------------
|
||||
{
|
||||
id: 'sct_015',
|
||||
companyId: 'lynkeduppro',
|
||||
companyName: 'LynkedUp Pro Roofing',
|
||||
subcontractorId: 'sub_004',
|
||||
subcontractorName: 'Jennifer Castillo',
|
||||
assignedBy: 'a1',
|
||||
assignedByName: 'Wade Hollis',
|
||||
title: 'Hot water heater replacement — PRJ-007',
|
||||
location: '6909 Custer Rd, Plano, TX 75023',
|
||||
description: 'Replace 40-gal gas water heater (unit failed). Install Bradford White 40-gal NG, reconnect supply & drain.',
|
||||
dueDate: '2026-05-20',
|
||||
priority: 'high',
|
||||
category: 'Plumbing',
|
||||
assignedDate: '2026-05-14T08:00:00Z',
|
||||
status: 'Completed',
|
||||
photos: [],
|
||||
statusHistory: [
|
||||
{ id: 'sct_015_h1', status: 'Assigned', actorId: 'a1', actorName: 'Wade Hollis', comment: 'Assigned.', at: '2026-05-14T08:00:00Z' },
|
||||
{ id: 'sct_015_h2', status: 'Completed', actorId: 'sub_004', actorName: 'Jennifer Castillo', comment: 'Complete.', at: '2026-05-16T13:00:00Z' },
|
||||
],
|
||||
thread: [],
|
||||
expenses: [
|
||||
{ id: 'sct_015_e1', description: 'Bradford White 40-gal NG heater', category: 'Materials', amount: 680, notes: '', receiptUrl: '', createdAt: '2026-05-14T10:00:00Z' },
|
||||
{ id: 'sct_015_e2', description: 'Gas flex connector + fittings', category: 'Materials', amount: 48, notes: '', receiptUrl: '', createdAt: '2026-05-14T10:05:00Z' },
|
||||
{ id: 'sct_015_e3', description: 'Expansion tank + pressure relief', category: 'Materials', amount: 95, notes: '', receiptUrl: '', createdAt: '2026-05-14T10:10:00Z' },
|
||||
{ id: 'sct_015_e4', description: 'Haul-away — old unit', category: 'Disposal / Dumpster', amount: 65, notes: '', receiptUrl: '', createdAt: '2026-05-16T14:00:00Z' },
|
||||
],
|
||||
fees: [
|
||||
{ id: 'sct_015_f1', description: 'Labour — WHR install', type: 'Labour / Service Fee', amount: 420, createdAt: '2026-05-16T13:10:00Z' },
|
||||
{ id: 'sct_015_f2', description: 'Mobilization', type: 'Mobilization Fee', amount: 75, createdAt: '2026-05-14T08:00:00Z' },
|
||||
],
|
||||
activities: [],
|
||||
projectId: 'PRJ-2026-007',
|
||||
paymentStatus: 'Partially Paid',
|
||||
paymentRequestedAt: '2026-05-16T14:00:00Z',
|
||||
amountPaid: 700,
|
||||
paidAt: '2026-05-22T10:00:00Z',
|
||||
createdAt: '2026-05-14T08:00:00Z',
|
||||
updatedAt: '2026-05-16T14: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_017 — Dwayne Boudreaux (sub_003) — PRJ-2026-007 ceiling repaint, Unpaid
|
||||
// -------------------------------------------------------------------
|
||||
{
|
||||
id: 'sct_017',
|
||||
companyId: 'lynkeduppro',
|
||||
companyName: 'LynkedUp Pro Roofing',
|
||||
subcontractorId: 'sub_003',
|
||||
subcontractorName: 'Dwayne Boudreaux',
|
||||
assignedBy: 'a1',
|
||||
assignedByName: 'Wade Hollis',
|
||||
title: 'Ceiling repaint after water damage — PRJ-007',
|
||||
location: '6909 Custer Rd, Plano, TX 75023',
|
||||
description: 'Repaint ceilings in master bedroom and en-suite after remediation. Stain block primer + 2 coats flat white.',
|
||||
dueDate: '2026-05-28',
|
||||
priority: 'medium',
|
||||
category: 'Painting',
|
||||
assignedDate: '2026-05-18T10:00:00Z',
|
||||
status: 'Completed',
|
||||
photos: [],
|
||||
statusHistory: [
|
||||
{ id: 'sct_017_h1', status: 'Assigned', actorId: 'a1', actorName: 'Wade Hollis', comment: 'Assigned.', at: '2026-05-18T10:00:00Z' },
|
||||
{ id: 'sct_017_h2', status: 'Completed', actorId: 'sub_003', actorName: 'Dwayne Boudreaux', comment: 'Complete.', at: '2026-05-25T14:00:00Z' },
|
||||
],
|
||||
thread: [],
|
||||
expenses: [
|
||||
{ id: 'sct_017_e1', description: 'Zinsser stain-block primer — 1 gal', category: 'Materials', amount: 52, notes: '', receiptUrl: '', createdAt: '2026-05-19T08:00:00Z' },
|
||||
{ id: 'sct_017_e2', description: 'Ceiling flat white — 1 gal', category: 'Materials', amount: 42, notes: '', receiptUrl: '', createdAt: '2026-05-19T08:05:00Z' },
|
||||
],
|
||||
fees: [
|
||||
{ id: 'sct_017_f1', description: 'Labour — ceiling repaint (1.5 days)', type: 'Labour / Service Fee', amount: 580, createdAt: '2026-05-25T14:10:00Z' },
|
||||
],
|
||||
activities: [],
|
||||
projectId: 'PRJ-2026-007',
|
||||
paymentStatus: 'Unpaid',
|
||||
paymentRequestedAt: '2026-05-25T15:00:00Z',
|
||||
amountPaid: 0,
|
||||
createdAt: '2026-05-18T10:00:00Z',
|
||||
updatedAt: '2026-05-25T15: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',
|
||||
},
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// sct_019 — Greg Alston (sub_005) — window sealing PRJ-2026-009, Unpaid
|
||||
// -------------------------------------------------------------------
|
||||
{
|
||||
id: 'sct_019',
|
||||
companyId: 'lynkeduppro',
|
||||
companyName: 'LynkedUp Pro Roofing',
|
||||
subcontractorId: 'sub_005',
|
||||
subcontractorName: 'Greg Alston',
|
||||
assignedBy: 'a1',
|
||||
assignedByName: 'Wade Hollis',
|
||||
title: 'Re-seal & caulk all windows — PRJ-009',
|
||||
location: '7224 Independence Pkwy, Plano, TX 75025',
|
||||
description: 'Full perimeter re-seal on 8 windows. Remove old silicone, clean frames, apply new NP1 polyurethane sealant.',
|
||||
dueDate: '2026-06-05',
|
||||
priority: 'low',
|
||||
category: 'Windows & Glazing',
|
||||
assignedDate: '2026-05-22T09:00:00Z',
|
||||
status: 'In Progress',
|
||||
photos: [],
|
||||
statusHistory: [
|
||||
{ id: 'sct_019_h1', status: 'Assigned', actorId: 'a1', actorName: 'Wade Hollis', comment: 'Assigned.', at: '2026-05-22T09:00:00Z' },
|
||||
{ id: 'sct_019_h2', status: 'In Progress', actorId: 'sub_005', actorName: 'Greg Alston', comment: 'In progress.', at: '2026-05-28T08:30:00Z' },
|
||||
],
|
||||
thread: [],
|
||||
expenses: [
|
||||
{ id: 'sct_019_e1', description: 'NP1 polyurethane sealant (6 tubes)', category: 'Materials', amount: 78, notes: '', receiptUrl: '', createdAt: '2026-05-23T07:00:00Z' },
|
||||
],
|
||||
fees: [
|
||||
{ id: 'sct_019_f1', description: 'Labour — window sealing (1 day)', type: 'Labour / Service Fee', amount: 380, createdAt: '2026-05-28T12:00:00Z' },
|
||||
],
|
||||
activities: [],
|
||||
projectId: 'PRJ-2026-009',
|
||||
paymentStatus: 'Unpaid',
|
||||
paymentRequestedAt: '2026-05-28T12:00:00Z',
|
||||
amountPaid: 0,
|
||||
createdAt: '2026-05-22T09:00:00Z',
|
||||
updatedAt: '2026-05-28T12:00:00Z',
|
||||
},
|
||||
];
|
||||
|
||||
const MOCK_NOTIFICATIONS = [
|
||||
|
||||
Reference in New Issue
Block a user