|
|
|
@@ -4,6 +4,7 @@ import { DashboardBanner } from '../../lib/dashboardBanner';
|
|
|
|
|
import { useLocation, useNavigate } from 'react-router-dom';
|
|
|
|
|
import Layout from '../../components/Layout';
|
|
|
|
|
import SortTh from '../../components/SortTh';
|
|
|
|
|
import FilterDropdown from '../../components/FilterDropdown';
|
|
|
|
|
import StatusBadge from '../../components/StatusBadge';
|
|
|
|
|
import { useSortable } from '../../hooks/useSortable';
|
|
|
|
|
import { useActionLock } from '../../hooks/useActionLock';
|
|
|
|
@@ -186,13 +187,13 @@ const invNewItem = (description = '', unit_price = '', quantity = 1, task_id = n
|
|
|
|
|
({ id: crypto.randomUUID(), description, unit_price, quantity, task_id, submission_id });
|
|
|
|
|
|
|
|
|
|
const invBuildNewItemDescription = (task) => {
|
|
|
|
|
const projectName = task.project?.name || 'No Project';
|
|
|
|
|
const projectName = task.project?.name || 'No Client';
|
|
|
|
|
const taskTitle = task.title || task.service_type || 'Untitled';
|
|
|
|
|
return `${projectName} • ${taskTitle}`;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const invBuildRevisionItemDescription = (revision) => {
|
|
|
|
|
const projectName = revision.task?.project?.name || 'No Project';
|
|
|
|
|
const projectName = revision.task?.project?.name || 'No Client';
|
|
|
|
|
const taskTitle = revision.task?.title || revision.service_type || 'Revision';
|
|
|
|
|
const versionLabel = 'R' + String(revision.version_number || 0).padStart(2, '0');
|
|
|
|
|
return `${projectName} • ${taskTitle} • Revision ${versionLabel}`;
|
|
|
|
@@ -266,12 +267,11 @@ export default function Invoices() {
|
|
|
|
|
const [expensePreviewUrl, setExpensePreviewUrl] = useState(null);
|
|
|
|
|
const [expenseDetailEditing, setExpenseDetailEditing] = useState(false);
|
|
|
|
|
const [expYearFilter, setExpYearFilter] = useState(String(new Date().getFullYear()));
|
|
|
|
|
const [expYearMenuOpen, setExpYearMenuOpen] = useState(false);
|
|
|
|
|
const expYearMenuRef = useRef(null);
|
|
|
|
|
const [invYearFilter, setInvYearFilter] = useState(String(new Date().getFullYear()));
|
|
|
|
|
const [invCompanyFilter, setInvCompanyFilter] = useState('all');
|
|
|
|
|
const [invYearMenuOpen, setInvYearMenuOpen] = useState(false);
|
|
|
|
|
const invYearMenuRef = useRef(null);
|
|
|
|
|
const [subWhoFilter, setSubWhoFilter] = useState('all');
|
|
|
|
|
const [subStatusFilter, setSubStatusFilter] = useState('all');
|
|
|
|
|
const [subYearFilter, setSubYearFilter] = useState('all');
|
|
|
|
|
const [subcontractorPOs, setSubcontractorPOs] = useState([]);
|
|
|
|
|
const [subcontractorLoading, setSubcontractorLoading] = useState(true);
|
|
|
|
|
const [subcontractorError, setSubcontractorError] = useState('');
|
|
|
|
@@ -485,20 +485,6 @@ export default function Invoices() {
|
|
|
|
|
loadExpenses();
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (!expYearMenuOpen) return;
|
|
|
|
|
const handler = e => { if (expYearMenuRef.current && !expYearMenuRef.current.contains(e.target)) setExpYearMenuOpen(false); };
|
|
|
|
|
document.addEventListener('mousedown', handler);
|
|
|
|
|
return () => document.removeEventListener('mousedown', handler);
|
|
|
|
|
}, [expYearMenuOpen]);
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (!invYearMenuOpen) return;
|
|
|
|
|
const handler = e => { if (invYearMenuRef.current && !invYearMenuRef.current.contains(e.target)) setInvYearMenuOpen(false); };
|
|
|
|
|
document.addEventListener('mousedown', handler);
|
|
|
|
|
return () => document.removeEventListener('mousedown', handler);
|
|
|
|
|
}, [invYearMenuOpen]);
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
setExpensePreviewUrl(null);
|
|
|
|
|
setExpenseDetailEditing(false);
|
|
|
|
@@ -921,66 +907,30 @@ export default function Invoices() {
|
|
|
|
|
);
|
|
|
|
|
})()}
|
|
|
|
|
|
|
|
|
|
<div style={{ display: 'grid', gridTemplateColumns: '7fr 3fr', gap: 24, marginTop: 24, marginBottom: 10, flexShrink: 0, alignItems: 'center' }}>
|
|
|
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 4, minHeight: 'var(--btn-height)' }}>
|
|
|
|
|
{[
|
|
|
|
|
{ id: 'overview', label: 'Overview' },
|
|
|
|
|
{ id: 'expenses', label: 'Expenses' },
|
|
|
|
|
{ id: 'subcontractors', label: 'Subcontractors' },
|
|
|
|
|
{ id: 'invoices', label: 'Invoices' },
|
|
|
|
|
].map(t => (
|
|
|
|
|
<button
|
|
|
|
|
key={t.id}
|
|
|
|
|
type="button"
|
|
|
|
|
onClick={() => setFinanceTab(t.id)}
|
|
|
|
|
className={`section-tab-btn${financeTab === t.id ? ' is-active' : ''}`}
|
|
|
|
|
>{t.label}</button>
|
|
|
|
|
))}
|
|
|
|
|
{financeTab === 'expenses' && (
|
|
|
|
|
<div style={{ marginLeft: 'auto', display: 'flex', alignItems: 'center', gap: 8 }}>
|
|
|
|
|
<button className="btn btn-outline" onClick={() => { setEditingExpenseId(''); setNewExpense(blankExpense()); setExpensesError(''); setShowExpenseForm(true); }}>+ Expense</button>
|
|
|
|
|
<div style={{ position: 'relative', display: 'flex', alignItems: 'center' }} ref={expYearMenuRef}>
|
|
|
|
|
<button className="btn btn-outline" onClick={() => setExpYearMenuOpen(o => !o)} style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }} title="Filter by year">
|
|
|
|
|
<svg viewBox="0 0 16 16" width="13" height="13" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M2 4h12M4 8h8M6 12h4" /></svg>
|
|
|
|
|
</button>
|
|
|
|
|
{expYearMenuOpen && (
|
|
|
|
|
<div className="site-header-avatar-menu" style={{ position: 'absolute', top: 'calc(100% + 4px)', right: 0, zIndex: 220, minWidth: 130 }}>
|
|
|
|
|
<button onClick={() => { setExpYearFilter('all'); setExpYearMenuOpen(false); }} className="site-header-avatar-item" style={{ background: expYearFilter === 'all' ? 'color-mix(in srgb, var(--accent) 8%, transparent)' : 'transparent', color: expYearFilter === 'all' ? 'var(--accent)' : 'var(--text-primary)' }}>All Years</button>
|
|
|
|
|
{expenseYears.map(y => (
|
|
|
|
|
<button key={y} onClick={() => { setExpYearFilter(y); setExpYearMenuOpen(false); }} className="site-header-avatar-item" style={{ background: expYearFilter === y ? 'color-mix(in srgb, var(--accent) 8%, transparent)' : 'transparent', color: expYearFilter === y ? 'var(--accent)' : 'var(--text-primary)' }}>{y}</button>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
{financeTab === 'invoices' && (
|
|
|
|
|
<div style={{ marginLeft: 'auto', display: 'flex', alignItems: 'center', gap: 8 }}>
|
|
|
|
|
<button className="btn btn-outline" onClick={() => setShowInvoiceForm(true)}>+ Invoice</button>
|
|
|
|
|
<div style={{ position: 'relative', display: 'flex', alignItems: 'center' }} ref={invYearMenuRef}>
|
|
|
|
|
<button className="btn btn-outline" onClick={() => setInvYearMenuOpen(o => !o)} style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }} title="Filter by year">
|
|
|
|
|
<svg viewBox="0 0 16 16" width="13" height="13" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M2 4h12M4 8h8M6 12h4" /></svg>
|
|
|
|
|
</button>
|
|
|
|
|
{invYearMenuOpen && (
|
|
|
|
|
<div className="site-header-avatar-menu" style={{ position: 'absolute', top: 'calc(100% + 4px)', right: 0, zIndex: 220, minWidth: 150 }}>
|
|
|
|
|
<div style={{ fontSize: 10, fontWeight: 500, textTransform: 'uppercase', letterSpacing: 0.8, color: 'var(--text-muted)', padding: '6px 14px 4px' }}>Year</div>
|
|
|
|
|
<button onClick={() => setInvYearFilter('all')} className="site-header-avatar-item" style={{ background: invYearFilter === 'all' ? 'color-mix(in srgb, var(--accent) 8%, transparent)' : 'transparent', color: invYearFilter === 'all' ? 'var(--accent)' : 'var(--text-primary)' }}>All Years</button>
|
|
|
|
|
{[...new Set(invoices.map(i => i.invoice_date?.slice(0,4)).filter(Boolean))].sort((a,b) => b-a).map(y => (
|
|
|
|
|
<button key={y} onClick={() => setInvYearFilter(y)} className="site-header-avatar-item" style={{ background: invYearFilter === y ? 'color-mix(in srgb, var(--accent) 8%, transparent)' : 'transparent', color: invYearFilter === y ? 'var(--accent)' : 'var(--text-primary)' }}>{y}</button>
|
|
|
|
|
))}
|
|
|
|
|
<div style={{ borderTop: '1px solid var(--border)', margin: '4px 0' }} />
|
|
|
|
|
<div style={{ fontSize: 10, fontWeight: 500, textTransform: 'uppercase', letterSpacing: 0.8, color: 'var(--text-muted)', padding: '4px 14px 4px' }}>Company</div>
|
|
|
|
|
<button onClick={() => setInvCompanyFilter('all')} className="site-header-avatar-item" style={{ background: invCompanyFilter === 'all' ? 'color-mix(in srgb, var(--accent) 8%, transparent)' : 'transparent', color: invCompanyFilter === 'all' ? 'var(--accent)' : 'var(--text-primary)' }}>All Companies</button>
|
|
|
|
|
{[...new Set(invoices.map(i => i.company?.name || i.bill_to).filter(Boolean))].sort().map(c => (
|
|
|
|
|
<button key={c} onClick={() => setInvCompanyFilter(c)} className="site-header-avatar-item" style={{ background: invCompanyFilter === c ? 'color-mix(in srgb, var(--accent) 8%, transparent)' : 'transparent', color: invCompanyFilter === c ? 'var(--accent)' : 'var(--text-primary)' }}>{c}</button>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
<div />
|
|
|
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 4, marginTop: 24, marginBottom: 10, flexShrink: 0, minHeight: 'var(--btn-height)' }}>
|
|
|
|
|
{[
|
|
|
|
|
{ id: 'overview', label: 'Overview' },
|
|
|
|
|
{ id: 'expenses', label: 'Expenses' },
|
|
|
|
|
{ id: 'subcontractors', label: 'Subcontractors' },
|
|
|
|
|
{ id: 'invoices', label: 'Invoices' },
|
|
|
|
|
].map(t => (
|
|
|
|
|
<button
|
|
|
|
|
key={t.id}
|
|
|
|
|
type="button"
|
|
|
|
|
onClick={() => setFinanceTab(t.id)}
|
|
|
|
|
className={`section-tab-btn${financeTab === t.id ? ' is-active' : ''}`}
|
|
|
|
|
>{t.label}</button>
|
|
|
|
|
))}
|
|
|
|
|
{financeTab === 'expenses' && (
|
|
|
|
|
<div style={{ marginLeft: 'auto', display: 'flex', alignItems: 'center', gap: 8 }}>
|
|
|
|
|
<button className="btn btn-outline" onClick={() => { setEditingExpenseId(''); setNewExpense(blankExpense()); setExpensesError(''); setShowExpenseForm(true); }}>+ Expense</button>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
{financeTab === 'invoices' && (
|
|
|
|
|
<div style={{ marginLeft: 'auto', display: 'flex', alignItems: 'center', gap: 8 }}>
|
|
|
|
|
<button className="btn btn-outline" onClick={() => setShowInvoiceForm(true)}>+ Invoice</button>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{financeTab === 'overview' && (() => {
|
|
|
|
@@ -1071,9 +1021,9 @@ export default function Invoices() {
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 24, flex: 1, minHeight: 0 }}>
|
|
|
|
|
{/* Card 1: This Month Expenses by category */}
|
|
|
|
|
<div style={{ ...CARD, display: 'flex', flexDirection: 'column', minHeight: 0, overflowY: 'auto' }}>
|
|
|
|
|
<div style={{ ...CARD, display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 0, flex: 1, minHeight: 0 }}>
|
|
|
|
|
{/* Section 1: This Month Expenses by category */}
|
|
|
|
|
<div style={{ display: 'flex', flexDirection: 'column', minHeight: 0, overflowY: 'auto', paddingRight: 21 }}>
|
|
|
|
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 5, flexShrink: 0 }}>
|
|
|
|
|
<span style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase' }}>{MONTHS[m1]} {y1 !== curY ? y1 : ''} Expenses</span>
|
|
|
|
|
<MonthNav m={ovMonth1} setter={setOvMonth1} />
|
|
|
|
@@ -1099,8 +1049,8 @@ export default function Invoices() {
|
|
|
|
|
{thisCatTotals.length > 0 && <PieLegend data={thisCatTotals.map(c => ({ name: c.cat, value: c.total }))} colors={PIE_COLORS} />}
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Card 2: Pending Sub Payments by subcontractor */}
|
|
|
|
|
<div style={{ ...CARD, display: 'flex', flexDirection: 'column', minHeight: 0, overflowY: 'auto' }}>
|
|
|
|
|
{/* Section 2: Pending Sub Payments by subcontractor */}
|
|
|
|
|
<div style={{ display: 'flex', flexDirection: 'column', minHeight: 0, overflowY: 'auto', borderLeft: '1px solid var(--border)', paddingLeft: 21, paddingRight: 21 }}>
|
|
|
|
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 5, flexShrink: 0 }}>
|
|
|
|
|
<span style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase' }}>{MONTHS[m2]} {y2 !== curY ? y2 : ''} Sub Payments</span>
|
|
|
|
|
<MonthNav m={ovMonth2} setter={setOvMonth2} />
|
|
|
|
@@ -1126,8 +1076,8 @@ export default function Invoices() {
|
|
|
|
|
{subPieData.length > 0 && <PieLegend data={subPieData} colors={PIE_COLORS} />}
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Card 3: Invoiced this month by company */}
|
|
|
|
|
<div style={{ ...CARD, display: 'flex', flexDirection: 'column', minHeight: 0, overflowY: 'auto' }}>
|
|
|
|
|
{/* Section 3: Invoiced this month by company */}
|
|
|
|
|
<div style={{ display: 'flex', flexDirection: 'column', minHeight: 0, overflowY: 'auto', borderLeft: '1px solid var(--border)', paddingLeft: 21 }}>
|
|
|
|
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 5, flexShrink: 0 }}>
|
|
|
|
|
<span style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase' }}>{MONTHS[m3]} {y3 !== curY ? y3 : ''} Invoiced</span>
|
|
|
|
|
<MonthNav m={ovMonth3} setter={setOvMonth3} />
|
|
|
|
@@ -1171,11 +1121,13 @@ export default function Invoices() {
|
|
|
|
|
.map(cat => ({ cat, total: filteredExpenses.filter(e => e.category === cat).reduce((s, e) => s + Number(e.amount || 0), 0) }))
|
|
|
|
|
.sort((a, b) => b.total - a.total);
|
|
|
|
|
const grandTotal = filteredExpenses.reduce((s, e) => s + Number(e.amount || 0), 0);
|
|
|
|
|
const yearOptions = [{ value: 'all', label: 'All Years' }, ...expenseYears.map(y => ({ value: y, label: y }))];
|
|
|
|
|
const categoryOptions = [{ value: 'all', label: 'All' }, ...CATEGORIES.map(c => ({ value: c, label: c }))];
|
|
|
|
|
return (
|
|
|
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 0, flex: 1, minHeight: 0 }}>
|
|
|
|
|
<div style={{ display: 'grid', gridTemplateColumns: '7fr 3fr', gap: 24, flex: 1, minHeight: 0 }}>
|
|
|
|
|
<div style={{ ...CARD, display: 'grid', gridTemplateColumns: '7fr 3fr', gap: 0, flex: 1, minHeight: 0 }}>
|
|
|
|
|
{/* Left: expense list */}
|
|
|
|
|
<div style={{ ...CARD, display: 'flex', flexDirection: 'column', minHeight: 0 }}>
|
|
|
|
|
<div style={{ display: 'flex', flexDirection: 'column', minHeight: 0, paddingRight: 21 }}>
|
|
|
|
|
{expenses.length === 0 ? <div className="card-empty-center">No expenses</div> : (
|
|
|
|
|
<div className="scrollbar-thin-theme table-scroll-hidden" style={{ flex: 1, minHeight: 0, overflowY: 'auto' }}>
|
|
|
|
|
<table className="table-sticky-head" style={{ width: '100%', borderCollapse: 'collapse', tableLayout: 'fixed' }}>
|
|
|
|
@@ -1187,9 +1139,25 @@ export default function Invoices() {
|
|
|
|
|
<col style={{ width: '12%' }} />
|
|
|
|
|
</colgroup>
|
|
|
|
|
<thead><tr>
|
|
|
|
|
<SortTh col="date" sortKey={expSortKey} sortDir={expSortDir} onSort={expToggle} style={TH}>Date</SortTh>
|
|
|
|
|
<th style={{ ...TH, whiteSpace: 'nowrap' }}>
|
|
|
|
|
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
|
|
|
|
|
<span onClick={() => expToggle('date')} style={{ cursor: 'pointer', userSelect: 'none' }}>
|
|
|
|
|
Date
|
|
|
|
|
<span style={{ marginLeft: 4, opacity: expSortKey === 'date' ? 0.85 : 0.2, fontSize: 9 }}>{expSortKey === 'date' ? (expSortDir === 'asc' ? '▲' : '▼') : '▲▼'}</span>
|
|
|
|
|
</span>
|
|
|
|
|
<FilterDropdown value={expYearFilter} onChange={setExpYearFilter} options={yearOptions} />
|
|
|
|
|
</span>
|
|
|
|
|
</th>
|
|
|
|
|
<SortTh col="description" sortKey={expSortKey} sortDir={expSortDir} onSort={expToggle} style={TH}>Description</SortTh>
|
|
|
|
|
<SortTh col="category" sortKey={expSortKey} sortDir={expSortDir} onSort={expToggle} style={TH}>Category</SortTh>
|
|
|
|
|
<th style={{ ...TH, whiteSpace: 'nowrap' }}>
|
|
|
|
|
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
|
|
|
|
|
<span onClick={() => expToggle('category')} style={{ cursor: 'pointer', userSelect: 'none' }}>
|
|
|
|
|
Category
|
|
|
|
|
<span style={{ marginLeft: 4, opacity: expSortKey === 'category' ? 0.85 : 0.2, fontSize: 9 }}>{expSortKey === 'category' ? (expSortDir === 'asc' ? '▲' : '▼') : '▲▼'}</span>
|
|
|
|
|
</span>
|
|
|
|
|
<FilterDropdown value={expenseFilter} onChange={setExpenseFilter} options={categoryOptions} />
|
|
|
|
|
</span>
|
|
|
|
|
</th>
|
|
|
|
|
<SortTh col="notes" sortKey={expSortKey} sortDir={expSortDir} onSort={expToggle} style={TH}>Notes</SortTh>
|
|
|
|
|
<SortTh col="amount" sortKey={expSortKey} sortDir={expSortDir} onSort={expToggle} style={{ ...TH, textAlign: 'right' }}>Amount</SortTh>
|
|
|
|
|
</tr></thead>
|
|
|
|
@@ -1211,7 +1179,7 @@ export default function Invoices() {
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
{/* Right: category breakdown */}
|
|
|
|
|
<div style={{ ...CARD, display: 'flex', flexDirection: 'column', minHeight: 0 }}>
|
|
|
|
|
<div style={{ display: 'flex', flexDirection: 'column', minHeight: 0, borderLeft: '1px solid var(--border)', paddingLeft: 21 }}>
|
|
|
|
|
<div style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase', marginBottom: 5, flexShrink: 0 }}>Category</div>
|
|
|
|
|
<div style={{ fontSize: 30, fontWeight: 400, letterSpacing: -0.5, lineHeight: 1.1, color: 'var(--danger)', fontVariantNumeric: 'tabular-nums', marginBottom: 14, flexShrink: 0 }}>{fmtAmt(grandTotal)}</div>
|
|
|
|
|
{categoryTotals.length === 0 ? <div className="card-empty-center">No expenses</div> : (
|
|
|
|
@@ -1249,7 +1217,15 @@ export default function Invoices() {
|
|
|
|
|
const subInvoiceStatusColor = { draft: 'not_started', submitted: 'in_progress', paid: 'client_approved' };
|
|
|
|
|
const invTotal = inv => (inv.items || []).reduce((s, i) => s + Number(i.unit_price || 0) * Number(i.quantity || 1), 0);
|
|
|
|
|
|
|
|
|
|
const yearFiltered = subInvoices;
|
|
|
|
|
const yearFiltered = subInvoices.filter(inv => {
|
|
|
|
|
if (subWhoFilter !== 'all' && (inv.profile?.name || 'Unknown') !== subWhoFilter) return false;
|
|
|
|
|
if (subStatusFilter !== 'all' && inv.status !== subStatusFilter) return false;
|
|
|
|
|
if (subYearFilter !== 'all' && (inv.submitted_at ? String(new Date(inv.submitted_at).getFullYear()) : '') !== subYearFilter) return false;
|
|
|
|
|
return true;
|
|
|
|
|
});
|
|
|
|
|
const subWhoOptions = [{ value: 'all', label: 'All' }, ...[...new Set(subInvoices.map(i => i.profile?.name || 'Unknown'))].sort().map(n => ({ value: n, label: n }))];
|
|
|
|
|
const subStatusOptions = [{ value: 'all', label: 'All' }, ...[...new Set(subInvoices.map(i => i.status).filter(Boolean))].sort().map(s => ({ value: s, label: s.charAt(0).toUpperCase() + s.slice(1) }))];
|
|
|
|
|
const subYearOptions = [{ value: 'all', label: 'All Years' }, ...[...new Set(subInvoices.map(i => i.submitted_at ? String(new Date(i.submitted_at).getFullYear()) : null).filter(Boolean))].sort((a,b) => b-a).map(y => ({ value: y, label: y }))];
|
|
|
|
|
|
|
|
|
|
const sortedInvoices = subSort(yearFiltered, (inv, key) => {
|
|
|
|
|
if (key === 'total') return invTotal(inv);
|
|
|
|
@@ -1275,9 +1251,9 @@ export default function Invoices() {
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 0, flex: 1, minHeight: 0 }}>
|
|
|
|
|
<div style={{ display: 'grid', gridTemplateColumns: '7fr 3fr', gap: 24, flex: 1, minHeight: 0 }}>
|
|
|
|
|
<div style={{ ...CARD, display: 'grid', gridTemplateColumns: '7fr 3fr', gap: 0, flex: 1, minHeight: 0 }}>
|
|
|
|
|
{/* Left: invoice list */}
|
|
|
|
|
<div style={{ ...CARD, display: 'flex', flexDirection: 'column', minHeight: 0 }}>
|
|
|
|
|
<div style={{ display: 'flex', flexDirection: 'column', minHeight: 0, paddingRight: 21 }}>
|
|
|
|
|
{subInvoicesLoading ? (
|
|
|
|
|
<div style={{ fontSize: 13, color: 'var(--text-muted)' }}>Loading…</div>
|
|
|
|
|
) : subInvoicesError ? (
|
|
|
|
@@ -1297,10 +1273,34 @@ export default function Invoices() {
|
|
|
|
|
</colgroup>
|
|
|
|
|
<thead><tr>
|
|
|
|
|
<SortTh col="invoice_number" sortKey={subSortKey} sortDir={subSortDir} onSort={subToggle} style={TH}>Invoice #</SortTh>
|
|
|
|
|
<SortTh col="subcontractor" sortKey={subSortKey} sortDir={subSortDir} onSort={subToggle} style={TH}>Subcontractor</SortTh>
|
|
|
|
|
<SortTh col="submitted_at" sortKey={subSortKey} sortDir={subSortDir} onSort={subToggle} style={TH}>Submitted</SortTh>
|
|
|
|
|
<th style={{ ...TH, whiteSpace: 'nowrap' }}>
|
|
|
|
|
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
|
|
|
|
|
<span onClick={() => subToggle('subcontractor')} style={{ cursor: 'pointer', userSelect: 'none' }}>
|
|
|
|
|
Subcontractor
|
|
|
|
|
<span style={{ marginLeft: 4, opacity: subSortKey === 'subcontractor' ? 0.85 : 0.2, fontSize: 9 }}>{subSortKey === 'subcontractor' ? (subSortDir === 'asc' ? '▲' : '▼') : '▲▼'}</span>
|
|
|
|
|
</span>
|
|
|
|
|
<FilterDropdown value={subWhoFilter} onChange={setSubWhoFilter} options={subWhoOptions} />
|
|
|
|
|
</span>
|
|
|
|
|
</th>
|
|
|
|
|
<th style={{ ...TH, whiteSpace: 'nowrap' }}>
|
|
|
|
|
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
|
|
|
|
|
<span onClick={() => subToggle('submitted_at')} style={{ cursor: 'pointer', userSelect: 'none' }}>
|
|
|
|
|
Submitted
|
|
|
|
|
<span style={{ marginLeft: 4, opacity: subSortKey === 'submitted_at' ? 0.85 : 0.2, fontSize: 9 }}>{subSortKey === 'submitted_at' ? (subSortDir === 'asc' ? '▲' : '▼') : '▲▼'}</span>
|
|
|
|
|
</span>
|
|
|
|
|
<FilterDropdown value={subYearFilter} onChange={setSubYearFilter} options={subYearOptions} />
|
|
|
|
|
</span>
|
|
|
|
|
</th>
|
|
|
|
|
<SortTh col="paid_at" sortKey={subSortKey} sortDir={subSortDir} onSort={subToggle} style={TH}>Paid</SortTh>
|
|
|
|
|
<SortTh col="status" sortKey={subSortKey} sortDir={subSortDir} onSort={subToggle} style={TH}>Status</SortTh>
|
|
|
|
|
<th style={{ ...TH, whiteSpace: 'nowrap' }}>
|
|
|
|
|
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
|
|
|
|
|
<span onClick={() => subToggle('status')} style={{ cursor: 'pointer', userSelect: 'none' }}>
|
|
|
|
|
Status
|
|
|
|
|
<span style={{ marginLeft: 4, opacity: subSortKey === 'status' ? 0.85 : 0.2, fontSize: 9 }}>{subSortKey === 'status' ? (subSortDir === 'asc' ? '▲' : '▼') : '▲▼'}</span>
|
|
|
|
|
</span>
|
|
|
|
|
<FilterDropdown value={subStatusFilter} onChange={setSubStatusFilter} options={subStatusOptions} />
|
|
|
|
|
</span>
|
|
|
|
|
</th>
|
|
|
|
|
<SortTh col="total" sortKey={subSortKey} sortDir={subSortDir} onSort={subToggle} style={{ ...TH, textAlign: 'right' }}>Amount</SortTh>
|
|
|
|
|
</tr></thead>
|
|
|
|
|
<tbody>{sortedInvoices.map(inv => {
|
|
|
|
@@ -1332,7 +1332,7 @@ export default function Invoices() {
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
{/* Right: by subcontractor */}
|
|
|
|
|
<div style={{ ...CARD, display: 'flex', flexDirection: 'column', minHeight: 0 }}>
|
|
|
|
|
<div style={{ display: 'flex', flexDirection: 'column', minHeight: 0, borderLeft: '1px solid var(--border)', paddingLeft: 21 }}>
|
|
|
|
|
<div style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase', marginBottom: 5, flexShrink: 0 }}>Subcontractors</div>
|
|
|
|
|
<div style={{ fontSize: 30, fontWeight: 400, letterSpacing: -0.5, lineHeight: 1.1, color: 'var(--accent)', fontVariantNumeric: 'tabular-nums', marginBottom: 14, flexShrink: 0 }}>{fmtAmt(grandTotal)}</div>
|
|
|
|
|
{bySubcontractor.length === 0 ? <div className="card-empty-center">No data</div> : (
|
|
|
|
@@ -1395,11 +1395,13 @@ export default function Invoices() {
|
|
|
|
|
}, {})).sort((a, b) => b.total - a.total);
|
|
|
|
|
|
|
|
|
|
const grandTotal = byCompany.reduce((s, g) => s + g.total, 0);
|
|
|
|
|
const invYearOptions = [{ value: 'all', label: 'All Years' }, ...[...new Set(invoices.map(i => i.invoice_date?.slice(0,4)).filter(Boolean))].sort((a,b) => b-a).map(y => ({ value: y, label: y }))];
|
|
|
|
|
const invCompanyOptions = [{ value: 'all', label: 'All Companies' }, ...[...new Set(invoices.map(i => i.company?.name || i.bill_to).filter(Boolean))].sort().map(c => ({ value: c, label: c }))];
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 0, flex: 1, minHeight: 0 }}>
|
|
|
|
|
<div style={{ display: 'grid', gridTemplateColumns: '7fr 3fr', gap: 24, flex: 1, minHeight: 0 }}>
|
|
|
|
|
<div style={{ ...CARD, display: 'flex', flexDirection: 'column', minHeight: 0 }}>
|
|
|
|
|
<div style={{ ...CARD, display: 'grid', gridTemplateColumns: '7fr 3fr', gap: 0, flex: 1, minHeight: 0 }}>
|
|
|
|
|
<div style={{ display: 'flex', flexDirection: 'column', minHeight: 0, paddingRight: 21 }}>
|
|
|
|
|
{loading ? (
|
|
|
|
|
<div style={{ fontSize: 13, color: 'var(--text-muted)' }}>Loading…</div>
|
|
|
|
|
) : invoices.length === 0 ? (
|
|
|
|
@@ -1418,8 +1420,24 @@ export default function Invoices() {
|
|
|
|
|
</colgroup>
|
|
|
|
|
<thead><tr>
|
|
|
|
|
<SortTh col="invoice_number" sortKey={invSortKey} sortDir={invSortDir} onSort={invToggle} style={TH}>Invoice #</SortTh>
|
|
|
|
|
<SortTh col="company" sortKey={invSortKey} sortDir={invSortDir} onSort={invToggle} style={TH}>Company</SortTh>
|
|
|
|
|
<SortTh col="invoice_date" sortKey={invSortKey} sortDir={invSortDir} onSort={invToggle} style={TH}>Date</SortTh>
|
|
|
|
|
<th style={{ ...TH, whiteSpace: 'nowrap' }}>
|
|
|
|
|
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
|
|
|
|
|
<span onClick={() => invToggle('company')} style={{ cursor: 'pointer', userSelect: 'none' }}>
|
|
|
|
|
Company
|
|
|
|
|
<span style={{ marginLeft: 4, opacity: invSortKey === 'company' ? 0.85 : 0.2, fontSize: 9 }}>{invSortKey === 'company' ? (invSortDir === 'asc' ? '▲' : '▼') : '▲▼'}</span>
|
|
|
|
|
</span>
|
|
|
|
|
<FilterDropdown value={invCompanyFilter} onChange={setInvCompanyFilter} options={invCompanyOptions} />
|
|
|
|
|
</span>
|
|
|
|
|
</th>
|
|
|
|
|
<th style={{ ...TH, whiteSpace: 'nowrap' }}>
|
|
|
|
|
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
|
|
|
|
|
<span onClick={() => invToggle('invoice_date')} style={{ cursor: 'pointer', userSelect: 'none' }}>
|
|
|
|
|
Date
|
|
|
|
|
<span style={{ marginLeft: 4, opacity: invSortKey === 'invoice_date' ? 0.85 : 0.2, fontSize: 9 }}>{invSortKey === 'invoice_date' ? (invSortDir === 'asc' ? '▲' : '▼') : '▲▼'}</span>
|
|
|
|
|
</span>
|
|
|
|
|
<FilterDropdown value={invYearFilter} onChange={setInvYearFilter} options={invYearOptions} />
|
|
|
|
|
</span>
|
|
|
|
|
</th>
|
|
|
|
|
<SortTh col="due_date" sortKey={invSortKey} sortDir={invSortDir} onSort={invToggle} style={TH}>Due</SortTh>
|
|
|
|
|
<SortTh col="status" sortKey={invSortKey} sortDir={invSortDir} onSort={invToggle} style={{ ...TH, textAlign: 'center' }}>Status</SortTh>
|
|
|
|
|
<SortTh col="stripe_fee" sortKey={invSortKey} sortDir={invSortDir} onSort={invToggle} style={{ ...TH, textAlign: 'right' }}>Fees</SortTh>
|
|
|
|
@@ -1450,7 +1468,7 @@ export default function Invoices() {
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
<div style={{ ...CARD, display: 'flex', flexDirection: 'column', minHeight: 0 }}>
|
|
|
|
|
<div style={{ display: 'flex', flexDirection: 'column', minHeight: 0, borderLeft: '1px solid var(--border)', paddingLeft: 21 }}>
|
|
|
|
|
<div style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase', marginBottom: 5, flexShrink: 0 }}>Companies</div>
|
|
|
|
|
<div style={{ fontSize: 30, fontWeight: 400, letterSpacing: -0.5, lineHeight: 1.1, color: 'var(--positive)', fontVariantNumeric: 'tabular-nums', marginBottom: 14, flexShrink: 0 }}>{fmtAmt(grandTotal)}</div>
|
|
|
|
|
{byCompany.length === 0 ? <div className="card-empty-center">No data</div> : (
|
|
|
|
|