subcontractor payments table added in owners box
This commit is contained in:
@@ -0,0 +1,483 @@
|
||||
import React, { useMemo, useState, useRef, useEffect } from 'react';
|
||||
import { Wallet, Users, Search, ChevronDown, X, Check, FilterX } from 'lucide-react';
|
||||
|
||||
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',
|
||||
'Painting': 'text-purple-500 dark:text-purple-400 bg-purple-500/10 border-purple-500/20',
|
||||
'Plumbing': 'text-blue-500 dark:text-blue-400 bg-blue-500/10 border-blue-500/20',
|
||||
'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',
|
||||
};
|
||||
|
||||
function taskTotal(task) {
|
||||
const expenses = (task.expenses || []).reduce((s, e) => s + (Number(e.amount) || 0), 0);
|
||||
const fees = (task.fees || []).reduce((s, f) => s + (Number(f.amount) || 0), 0);
|
||||
return expenses + fees;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
};
|
||||
|
||||
// Dark-mode-aware dropdown — replaces native <select> so the option list
|
||||
// doesn't render with the OS default white background in dark mode.
|
||||
function FilterPopover({ id, value, onChange, options, allLabel, ariaLabel }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const boxRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onClick = (e) => {
|
||||
if (boxRef.current && !boxRef.current.contains(e.target)) setOpen(false);
|
||||
};
|
||||
const onKey = (e) => { if (e.key === 'Escape') setOpen(false); };
|
||||
document.addEventListener('mousedown', onClick);
|
||||
document.addEventListener('keydown', onKey);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', onClick);
|
||||
document.removeEventListener('keydown', onKey);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
const display = value === 'all' ? allLabel : value;
|
||||
|
||||
return (
|
||||
<div className="relative" ref={boxRef}>
|
||||
<button
|
||||
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"
|
||||
>
|
||||
<span className="truncate text-left">{display}</span>
|
||||
<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 className="max-h-56 overflow-y-auto custom-scrollbar py-1">
|
||||
<button
|
||||
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'}`}
|
||||
>
|
||||
<span>{allLabel}</span>
|
||||
{value === 'all' && <Check size={12} strokeWidth={2.5} className="shrink-0 opacity-70" />}
|
||||
</button>
|
||||
{options.map(opt => {
|
||||
const isSelected = opt === value;
|
||||
return (
|
||||
<button
|
||||
key={opt}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={isSelected}
|
||||
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'}`}
|
||||
>
|
||||
<span className="truncate">{opt}</span>
|
||||
{isSelected && <Check size={12} strokeWidth={2.5} className="shrink-0 opacity-70" />}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const SubcontractorPaymentsPanel = ({
|
||||
subcontractorTasks = [],
|
||||
subcontractors = [],
|
||||
projects = [],
|
||||
}) => {
|
||||
const subById = useMemo(() => {
|
||||
const m = new Map();
|
||||
for (const s of subcontractors) m.set(s.id, s);
|
||||
return m;
|
||||
}, [subcontractors]);
|
||||
|
||||
const projectById = useMemo(() => {
|
||||
const m = new Map();
|
||||
for (const p of projects) m.set(p.id, p);
|
||||
return m;
|
||||
}, [projects]);
|
||||
|
||||
const rows = useMemo(() => {
|
||||
const entries = [];
|
||||
for (const task of subcontractorTasks) {
|
||||
const amount = taskTotal(task);
|
||||
if (amount <= 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,
|
||||
companyName: sub?.companyName || '',
|
||||
projectName: project ? (project.projectType || project.address) : '—',
|
||||
projectAddress: project?.address || '',
|
||||
projectId: project?.id || null,
|
||||
workCategory: sub?.tradeType || '—',
|
||||
amount,
|
||||
requestedAt: task.paymentRequestedAt || null,
|
||||
paidAt: task.paidAt || null,
|
||||
paymentStatus: task.paymentStatus || 'Unpaid',
|
||||
});
|
||||
}
|
||||
return entries;
|
||||
}, [subcontractorTasks, subById, projectById]);
|
||||
|
||||
// ── 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);
|
||||
|
||||
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(() => {
|
||||
const map = new Map();
|
||||
for (const r of rows) {
|
||||
const key = r.projectName || '—';
|
||||
if (!map.has(key)) map.set(key, { name: key });
|
||||
}
|
||||
return Array.from(map.values()).sort((a, b) => a.name.localeCompare(b.name));
|
||||
}, [rows]);
|
||||
|
||||
const categoryOptions = useMemo(() => {
|
||||
const set = new Set(rows.map(r => r.workCategory).filter(Boolean));
|
||||
return Array.from(set).sort();
|
||||
}, [rows]);
|
||||
|
||||
const statusOptions = useMemo(() => {
|
||||
const set = new Set(rows.map(r => r.paymentStatus).filter(Boolean));
|
||||
return Array.from(set).sort();
|
||||
}, [rows]);
|
||||
|
||||
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(() => {
|
||||
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 true;
|
||||
});
|
||||
}, [rows, nameQuery, projectFilter, 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 hasActiveFilters =
|
||||
nameQuery.trim() !== '' || projectFilter !== 'all' || categoryFilter !== 'all' || statusFilter !== 'all';
|
||||
|
||||
const clearFilters = () => {
|
||||
setNameQuery('');
|
||||
setProjectFilter('all');
|
||||
setCategoryFilter('all');
|
||||
setStatusFilter('all');
|
||||
setProjectQuery('');
|
||||
};
|
||||
|
||||
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} />
|
||||
<h3 className="text-sm font-mono font-bold text-zinc-900 dark:text-white tracking-widest uppercase">
|
||||
Subcontractor Payments
|
||||
</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'}`
|
||||
}
|
||||
</span>
|
||||
</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 */}
|
||||
<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"
|
||||
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"
|
||||
>
|
||||
<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 */}
|
||||
<FilterPopover
|
||||
id="payments-category-filter"
|
||||
ariaLabel="Filter by work category"
|
||||
value={categoryFilter}
|
||||
onChange={setCategoryFilter}
|
||||
options={categoryOptions}
|
||||
allLabel="All Work Categories"
|
||||
/>
|
||||
|
||||
{/* Payment Status filter */}
|
||||
<FilterPopover
|
||||
id="payments-status-filter"
|
||||
ariaLabel="Filter by payment status"
|
||||
value={statusFilter}
|
||||
onChange={setStatusFilter}
|
||||
options={statusOptions}
|
||||
allLabel="All Payment Statuses"
|
||||
/>
|
||||
|
||||
{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"
|
||||
>
|
||||
<FilterX size={12} /> Clear Filters
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{rows.length === 0 ? (
|
||||
<div className="py-10 text-center">
|
||||
<Users size={28} className="mx-auto mb-3 text-zinc-400" />
|
||||
<p className="text-sm text-zinc-500">No subcontractor payments yet.</p>
|
||||
<p className="text-xs text-zinc-400 mt-1">Tasks with logged fees or expenses will appear here.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto rounded-xl border border-zinc-200 dark:border-white/5">
|
||||
<table className="w-full text-left min-w-[860px]">
|
||||
<thead>
|
||||
<tr className="border-b border-zinc-200 dark:border-white/5 bg-zinc-50 dark:bg-white/[0.02]">
|
||||
<th className="px-4 py-2.5 text-[10px] font-mono font-bold text-zinc-500 uppercase tracking-widest">Subcontractor</th>
|
||||
<th className="px-4 py-2.5 text-[10px] font-mono font-bold text-zinc-500 uppercase tracking-widest">Project</th>
|
||||
<th className="px-4 py-2.5 text-[10px] font-mono font-bold text-zinc-500 uppercase tracking-widest">Work Category</th>
|
||||
<th className="px-4 py-2.5 text-[10px] font-mono font-bold text-zinc-500 uppercase tracking-widest text-right">Amount to Pay</th>
|
||||
<th className="px-4 py-2.5 text-[10px] font-mono font-bold text-zinc-500 uppercase tracking-widest">Requested</th>
|
||||
<th className="px-4 py-2.5 text-[10px] font-mono font-bold text-zinc-500 uppercase tracking-widest">Paid Date</th>
|
||||
<th className="px-4 py-2.5 text-[10px] font-mono font-bold text-zinc-500 uppercase tracking-widest">Payment Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredRows.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={7} className="px-4 py-10 text-center">
|
||||
<Users size={22} className="mx-auto mb-2 text-zinc-400" />
|
||||
<p className="text-sm text-zinc-500">No payments match the current filters.</p>
|
||||
<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="min-w-0">
|
||||
<div className="text-xs font-semibold text-zinc-900 dark:text-white truncate max-w-[180px]">{row.name}</div>
|
||||
{row.companyName && (
|
||||
<div className="text-[10px] text-zinc-500 dark:text-zinc-400 truncate max-w-[180px]">{row.companyName}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="min-w-0">
|
||||
<div className="text-xs text-zinc-700 dark:text-zinc-300 truncate max-w-[200px]" title={row.projectName}>
|
||||
{row.projectName}
|
||||
</div>
|
||||
{row.projectAddress && (
|
||||
<div className="text-[10px] text-zinc-500 dark:text-zinc-400 truncate max-w-[200px]" title={row.projectAddress}>
|
||||
{row.projectAddress}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className={`text-[11px] font-bold rounded-md px-1.5 py-0.5 border whitespace-nowrap ${CATEGORY_COLORS[row.workCategory] || 'text-zinc-400 bg-zinc-500/10 border-zinc-500/20'}`}>
|
||||
{row.workCategory}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
<span className="font-mono text-xs font-bold text-amber-600 dark:text-amber-400">
|
||||
{fmt(row.amount)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className="text-[11px] font-mono text-zinc-500 dark:text-zinc-400 whitespace-nowrap">
|
||||
{fmtDate(row.requestedAt)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className={`text-[11px] font-mono whitespace-nowrap ${row.paidAt ? 'text-emerald-600 dark:text-emerald-400 font-semibold' : 'text-zinc-400 dark:text-zinc-500'}`}>
|
||||
{fmtDate(row.paidAt)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className={`text-[11px] font-bold rounded-md px-1.5 py-0.5 border whitespace-nowrap ${PAYMENT_STATUS_COLORS[row.paymentStatus] || 'text-zinc-400 bg-zinc-500/10 border-zinc-500/20'}`}>
|
||||
{row.paymentStatus}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
<tfoot className="border-t-2 border-zinc-300 dark:border-white/10">
|
||||
<tr className="bg-zinc-50 dark:bg-white/[0.03]">
|
||||
<td colSpan={3} className="px-4 py-3 text-[10px] font-mono font-bold text-zinc-500 uppercase tracking-widest">
|
||||
{hasActiveFilters ? 'Filtered Total' : 'Total Amount'}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
<span className="font-mono text-sm font-extrabold text-amber-600 dark:text-amber-400">
|
||||
{fmt(total)}
|
||||
</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>
|
||||
);
|
||||
};
|
||||
|
||||
export default SubcontractorPaymentsPanel;
|
||||
@@ -5152,6 +5152,9 @@ const MOCK_SUBCONTRACTOR_TASKS = [
|
||||
{ id: 'sct_001_f1', description: 'Labour — Ridge Cap Replacement', type: 'Labour / Service Fee', amount: 350, createdAt: '2026-05-16T13:20:00Z' },
|
||||
{ id: 'sct_001_f2', description: 'Mobilization to site', type: 'Mobilization Fee', amount: 75, createdAt: '2026-05-16T13:22:00Z' },
|
||||
],
|
||||
projectId: 'proj_004',
|
||||
paymentStatus: 'Unpaid',
|
||||
paymentRequestedAt: '2026-05-16T17:30:00Z',
|
||||
createdAt: '2026-05-15T09:12:00Z',
|
||||
updatedAt: '2026-05-16T17:30:00Z',
|
||||
},
|
||||
@@ -5194,6 +5197,9 @@ const MOCK_SUBCONTRACTOR_TASKS = [
|
||||
fees: [
|
||||
{ id: 'sct_002_f1', description: 'Labour — Day 1 disconnect / prep', type: 'Labour / Service Fee', amount: 425, createdAt: '2026-05-12T17:00:00Z' },
|
||||
],
|
||||
projectId: 'proj_003',
|
||||
paymentStatus: 'Unpaid',
|
||||
paymentRequestedAt: '2026-05-12T17:00:00Z',
|
||||
createdAt: '2026-05-10T15:40:00Z',
|
||||
updatedAt: '2026-05-12T17:00:00Z',
|
||||
},
|
||||
@@ -5232,6 +5238,10 @@ const MOCK_SUBCONTRACTOR_TASKS = [
|
||||
fees: [
|
||||
{ id: 'sct_003_f1', description: 'Touch-up paint labour', type: 'Labour / Service Fee', amount: 180, createdAt: '2026-05-14T17:35:00Z' },
|
||||
],
|
||||
projectId: 'proj_006',
|
||||
paymentStatus: 'Paid',
|
||||
paymentRequestedAt: '2026-05-14T17:35:00Z',
|
||||
paidAt: '2026-05-18T10:15:00Z',
|
||||
createdAt: '2026-05-05T11:00:00Z',
|
||||
updatedAt: '2026-05-14T17:30:00Z',
|
||||
},
|
||||
@@ -5267,6 +5277,9 @@ const MOCK_SUBCONTRACTOR_TASKS = [
|
||||
],
|
||||
expenses: [],
|
||||
fees: [],
|
||||
projectId: 'proj_003',
|
||||
paymentStatus: 'Unpaid',
|
||||
paymentRequestedAt: null,
|
||||
createdAt: '2026-05-18T22:40:00Z',
|
||||
updatedAt: '2026-05-18T23:10:00Z',
|
||||
},
|
||||
@@ -5306,6 +5319,9 @@ const MOCK_SUBCONTRACTOR_TASKS = [
|
||||
fees: [
|
||||
{ id: 'sct_005_f1', description: 'Inspection — initial visit', type: 'Inspection Fee', amount: 125, createdAt: '2026-05-14T13:00:00Z' },
|
||||
],
|
||||
projectId: 'proj_012',
|
||||
paymentStatus: 'Unpaid',
|
||||
paymentRequestedAt: '2026-05-14T13:00:00Z',
|
||||
createdAt: '2026-05-12T10:00:00Z',
|
||||
updatedAt: '2026-05-15T16:20:00Z',
|
||||
},
|
||||
@@ -5334,6 +5350,9 @@ const MOCK_SUBCONTRACTOR_TASKS = [
|
||||
thread: [],
|
||||
expenses: [],
|
||||
fees: [],
|
||||
projectId: 'proj_011',
|
||||
paymentStatus: 'Unpaid',
|
||||
paymentRequestedAt: null,
|
||||
createdAt: '2026-05-17T14:00:00Z',
|
||||
updatedAt: '2026-05-17T14:00:00Z',
|
||||
},
|
||||
@@ -5374,6 +5393,10 @@ const MOCK_SUBCONTRACTOR_TASKS = [
|
||||
fees: [
|
||||
{ id: 'sct_007_f1', description: 'Labour — deck repair (2 days)', type: 'Labour / Service Fee', amount: 920, createdAt: '2026-05-07T16:50:00Z' },
|
||||
],
|
||||
projectId: 'proj_011',
|
||||
paymentStatus: 'Paid',
|
||||
paymentRequestedAt: '2026-05-07T16:50:00Z',
|
||||
paidAt: '2026-05-12T14:00:00Z',
|
||||
createdAt: '2026-05-01T09:00:00Z',
|
||||
updatedAt: '2026-05-07T17:00:00Z',
|
||||
},
|
||||
@@ -5426,6 +5449,9 @@ const MOCK_SUBCONTRACTOR_TASKS = [
|
||||
{ id: 'sct_008_f1', description: 'Labour — Kitchen GFCI install (2 outlets + island)', type: 'Labour / Service Fee', amount: 220, createdAt: '2026-05-19T12:35:00Z' },
|
||||
{ id: 'sct_008_f2', description: 'Mobilization to site', type: 'Mobilization Fee', amount: 75, createdAt: '2026-05-19T08:00:00Z' },
|
||||
],
|
||||
projectId: 'proj_011',
|
||||
paymentStatus: 'Unpaid',
|
||||
paymentRequestedAt: '2026-05-19T12:35:00Z',
|
||||
createdAt: '2026-05-16T13:00:00Z',
|
||||
updatedAt: '2026-05-19T18:00:00Z',
|
||||
},
|
||||
@@ -5455,6 +5481,9 @@ const MOCK_SUBCONTRACTOR_TASKS = [
|
||||
thread: [],
|
||||
expenses: [],
|
||||
fees: [],
|
||||
projectId: null,
|
||||
paymentStatus: 'Unpaid',
|
||||
paymentRequestedAt: null,
|
||||
createdAt: '2026-05-09T10:00:00Z',
|
||||
updatedAt: '2026-05-13T12:00:00Z',
|
||||
},
|
||||
|
||||
@@ -8,6 +8,7 @@ import FinancialDetailsModal from '../../components/owner/FinancialDetailsModal'
|
||||
import ActionCenterModal from '../../components/owner/ActionCenterModal';
|
||||
import TeamManagementPanel from '../../components/owner/TeamManagementPanel';
|
||||
import CommissionDistributionPanel from '../../components/owner/CommissionDistributionPanel';
|
||||
import SubcontractorPaymentsPanel from '../../components/owner/SubcontractorPaymentsPanel';
|
||||
import { SpotlightCard } from '../../components/SpotlightCard';
|
||||
import {
|
||||
BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, PieChart, Pie, Cell, Legend
|
||||
@@ -24,6 +25,7 @@ const OwnerSnapshot = () => {
|
||||
orgCommissionDefaults, userCommissionOverrides,
|
||||
roleCommissionOverrides, orgMembers,
|
||||
addJobTeamMember, removeJobTeamMember, updateJobTeamMember,
|
||||
subcontractorTasks, subcontractors,
|
||||
} = useMockStore();
|
||||
|
||||
// Find the current owner profile
|
||||
@@ -404,6 +406,23 @@ const OwnerSnapshot = () => {
|
||||
</SpotlightCard>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* ── Owner's Box — Subcontractor Payments ── */}
|
||||
{subcontractorTasks?.length > 0 && (
|
||||
<section>
|
||||
<h2 className="text-xl font-bold text-zinc-900 dark:text-white/90 mb-4 flex items-center">
|
||||
<span className="w-2 h-8 bg-amber-500 rounded-full mr-3"></span>
|
||||
Subcontractor Payments
|
||||
</h2>
|
||||
<SpotlightCard className="p-5 lg:p-6" spotlightColor="rgba(245, 158, 11, 0.06)">
|
||||
<SubcontractorPaymentsPanel
|
||||
subcontractorTasks={subcontractorTasks}
|
||||
subcontractors={subcontractors}
|
||||
projects={projects}
|
||||
/>
|
||||
</SpotlightCard>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Interactive Modals */}
|
||||
|
||||
Reference in New Issue
Block a user