Owners box: subcontractor table restrutcured name wise and display projects of each subcon with payment statuses

This commit is contained in:
Mayur Shinde
2026-06-04 14:53:35 +05:30
parent d420deaa0b
commit 96381d4e0a
2 changed files with 881 additions and 297 deletions
@@ -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
View File
@@ -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 = [