redesign: rename Project→Client, finance single-card + filters, mobile tasks, sticky headers

- Rename "Project" → "Client" across UI (labels + /projects→/clients routes with redirect); normalize company-meaning labels to "Company"
- Finance tabs: merge dual cards into one per tab, add header column filters (expenses/subs/invoices), overview as 3-section card, move +Expense/+Invoice to card right edge
- Tasks: responsive mobile stats grid, progressive column hiding (min Status+Name+Assigned), sidebar mobile footer (profile/theme/signout)
- Hide Company column for single-company users; +Task shows disabled Company field instead of hiding
- Sticky table headers site-wide with opaque card background
- Pin dev server to port 5173

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Krao Hasanee
2026-07-02 09:39:54 -04:00
parent c91e292066
commit 91045a1897
19 changed files with 432 additions and 278 deletions
+3 -3
View File
@@ -208,9 +208,9 @@ export default function CreateSubcontractorPO() {
</select>
</div>
<div className="form-group" style={{ marginBottom: 0 }}>
<label>Project</label>
<label>Client</label>
<select value={form.project_id} onChange={e => handleProjectChange(e.target.value)}>
<option value="">No project</option>
<option value="">No client</option>
{projects.map(project => (
<option key={project.id} value={project.id}>
{project.name}{project.company?.name ? ` · ${project.company.name}` : ''}
@@ -224,7 +224,7 @@ export default function CreateSubcontractorPO() {
{form.project_id && (
<div className="card" style={{ marginBottom: 24 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 12 }}>
<div className="card-title" style={{ marginBottom: 0 }}>Project Tasks</div>
<div className="card-title" style={{ marginBottom: 0 }}>Client Tasks</div>
{availableTasks.length > 0 && (
<button className="btn btn-outline btn-sm" onClick={() => availableTasks.forEach(task => addTaskAsLineItem(task))}>
+ Add All
+4 -4
View File
@@ -312,7 +312,7 @@ function TasksInProgressCard({ tasks = [] }) {
{rows.length === 0 ? (
<div style={{ minHeight: 120, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 13, color: 'var(--text-muted)' }}>No tasks in progress</div>
) : (
<table style={{ width: '100%', borderCollapse: 'collapse', tableLayout: 'fixed' }}>
<table className="table-sticky-head" style={{ width: '100%', borderCollapse: 'collapse', tableLayout: 'fixed' }}>
<colgroup>
<col style={{ width: '55%' }} />
<col style={{ width: '45%' }} />
@@ -402,7 +402,7 @@ function HotItemsCard({ submissions, tasks, isClient = false, isExternal = false
</div>
) : (
<div className="table-scroll-hidden" style={{ flex: 1, minHeight: 0, overflowY: 'auto' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', tableLayout: 'fixed' }}>
<table className="table-sticky-head" style={{ width: '100%', borderCollapse: 'collapse', tableLayout: 'fixed' }}>
{isExternal ? (
<>
<colgroup>
@@ -414,7 +414,7 @@ function HotItemsCard({ submissions, tasks, isClient = false, isExternal = false
<thead style={{ background: 'transparent' }}>
<tr style={{ background: 'transparent' }}>
<SortTh col="task" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ fontSize: 10, fontWeight: 500, color: 'var(--text-muted)', textAlign: 'left', letterSpacing: 0.6, textTransform: 'uppercase', padding: '0 0 12px 5px', border: 'none', background: 'transparent', verticalAlign: 'top' }}>Task</SortTh>
<SortTh col="project" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ fontSize: 10, fontWeight: 500, color: 'var(--text-muted)', textAlign: 'left', letterSpacing: 0.6, textTransform: 'uppercase', padding: '0 0 12px 0', border: 'none', background: 'transparent', verticalAlign: 'top' }}>Project</SortTh>
<SortTh col="project" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ fontSize: 10, fontWeight: 500, color: 'var(--text-muted)', textAlign: 'left', letterSpacing: 0.6, textTransform: 'uppercase', padding: '0 0 12px 0', border: 'none', background: 'transparent', verticalAlign: 'top' }}>Client</SortTh>
<SortTh col="status" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ fontSize: 10, fontWeight: 500, color: 'var(--text-muted)', textAlign: 'center', letterSpacing: 0.6, textTransform: 'uppercase', padding: '0 0 12px 0', border: 'none', background: 'transparent', verticalAlign: 'top' }}>Status</SortTh>
<SortTh col="deadline" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ fontSize: 10, fontWeight: 500, color: 'var(--text-muted)', textAlign: 'center', letterSpacing: 0.6, textTransform: 'uppercase', padding: '0 0 12px 0', border: 'none', background: 'transparent', verticalAlign: 'top' }}>Due By</SortTh>
</tr>
@@ -854,7 +854,7 @@ export default function TeamDashboard() {
<Layout>
<div className="dash-stat-grid" style={{ marginBottom: 0 }}>
<DashStatCard label="Open Tasks" value={activeTasks.length} sub="not complete" iconBg="color-mix(in srgb, var(--violet) 15%, transparent)" iconColor="var(--violet)" iconPath={DASH_ICONS.tasks} />
<DashStatCard label="Active Projects" value={activeProjects.length} sub={`${projects.length} total`} iconBg="color-mix(in srgb, var(--info) 15%, transparent)" iconColor="var(--info)" iconPath={DASH_ICONS.projects} />
<DashStatCard label="Active Clients" value={activeProjects.length} sub={`${projects.length} total`} iconBg="color-mix(in srgb, var(--info) 15%, transparent)" iconColor="var(--info)" iconPath={DASH_ICONS.projects} />
<DashStatCard label={card3.label} value={card3.value} sub={card3.sub} iconBg={card3.iconBg} iconColor={card3.iconColor} iconPath={card3.iconPath} />
<DashStatCard label={card4.label} value={card4.value} sub={card4.sub} iconBg={card4.iconBg} iconColor={card4.iconColor} iconPath={card4.iconPath} chartData={card4.chartData} />
</div>
+1 -1
View File
@@ -574,7 +574,7 @@ export function TeamInvoiceDetailPanel({
<div className="card" style={{ marginBottom: 24 }}>
<div className="card-title">Line Items</div>
<div className="table-wrapper" style={{ border: 'none' }}>
<table>
<table className="table-sticky-head">
<thead>
<tr>
<SortTh col="type" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ width: 100 }}>Type</SortTh>
+122 -104
View File
@@ -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> : (
+5 -5
View File
@@ -274,7 +274,7 @@ export default function TeamReports() {
autoTable(doc, {
startY: 78,
head: [['Company', 'Project', 'Task', 'Version', 'Type', 'Version Status', 'Client Billing', 'Sub Billing']],
head: [['Company', 'Client', 'Task', 'Version', 'Type', 'Version Status', 'Client Billing', 'Sub Billing']],
body: sortedRows.map((row) => [
row.company_name,
row.project_name,
@@ -325,9 +325,9 @@ export default function TeamReports() {
</select>
</div>
<div>
<div style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase', marginBottom: 4 }}>Project</div>
<div style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase', marginBottom: 4 }}>Client</div>
<select value={projectFilter} onChange={(event) => setProjectFilter(event.target.value)}>
<option value="all">All Projects</option>
<option value="all">All Clients</option>
{filteredProjects.map((project) => (
<option key={project.id} value={project.id}>{project.name}</option>
))}
@@ -367,7 +367,7 @@ export default function TeamReports() {
) : sortedRows.length === 0 ? (
<div style={{ padding: 24, fontSize: 13, color: 'var(--text-muted)', textAlign: 'center' }}>No report rows for the selected filters.</div>
) : (
<table style={{ width: '100%', borderCollapse: 'collapse', tableLayout: 'fixed' }}>
<table className="table-sticky-head" style={{ width: '100%', borderCollapse: 'collapse', tableLayout: 'fixed' }}>
<colgroup>
<col style={{ width: '15%' }} />
<col style={{ width: '15%' }} />
@@ -381,7 +381,7 @@ export default function TeamReports() {
<thead>
<tr>
<SortTh col="company_name" sortKey={sortKey} sortDir={sortDir} onSort={toggle}>Company</SortTh>
<SortTh col="project_name" sortKey={sortKey} sortDir={sortDir} onSort={toggle}>Project</SortTh>
<SortTh col="project_name" sortKey={sortKey} sortDir={sortDir} onSort={toggle}>Client</SortTh>
<SortTh col="task_title" sortKey={sortKey} sortDir={sortDir} onSort={toggle}>Task Name</SortTh>
<SortTh col="version_number" sortKey={sortKey} sortDir={sortDir} onSort={toggle}>R#</SortTh>
<SortTh col="version_type" sortKey={sortKey} sortDir={sortDir} onSort={toggle}>Type</SortTh>
+5 -5
View File
@@ -158,8 +158,8 @@ export default function SubcontractorPODetail() {
<div className="detail-item"><label>PO Date</label><p>{new Date(po.date).toLocaleDateString()}</p></div>
<div className="detail-item"><label>Due Date</label><p>{po.due_date ? new Date(po.due_date).toLocaleDateString() : '—'}</p></div>
<div className="detail-item"><label>Terms</label><p>{po.terms || 'Net 15'}</p></div>
<div className="detail-item"><label>Project</label><p>{po.project?.name || 'No project'}</p></div>
<div className="detail-item"><label>Client</label><p>{po.project?.company?.name || '—'}</p></div>
<div className="detail-item"><label>Client</label><p>{po.project?.name || 'No client'}</p></div>
<div className="detail-item"><label>Company</label><p>{po.project?.company?.name || '—'}</p></div>
<div className="detail-item"><label>Total</label><p style={{ fontSize: 18, fontWeight: 400, color: 'var(--accent)' }}>${Number(po.amount).toFixed(2)}</p></div>
{po.paid_at && <div className="detail-item"><label>Paid On</label><p>{new Date(po.paid_at).toLocaleDateString()}</p></div>}
</div>
@@ -169,10 +169,10 @@ export default function SubcontractorPODetail() {
<div className="card" style={{ marginBottom: 24 }}>
<div className="card-title">Line Items</div>
<div className="table-wrapper" style={{ border: 'none' }}>
<table>
<table className="table-sticky-head">
<thead>
<tr>
<SortTh col="project" sortKey={sortKey} sortDir={sortDir} onSort={toggle}>Project</SortTh>
<SortTh col="project" sortKey={sortKey} sortDir={sortDir} onSort={toggle}>Client</SortTh>
<SortTh col="task" sortKey={sortKey} sortDir={sortDir} onSort={toggle}>Task</SortTh>
<SortTh col="description" sortKey={sortKey} sortDir={sortDir} onSort={toggle}>Description</SortTh>
<SortTh col="amount" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ textAlign: 'right' }}>Amount</SortTh>
@@ -181,7 +181,7 @@ export default function SubcontractorPODetail() {
<tbody>
{tableItems.map(item => (
<tr key={item.id}>
<td>{po.project?.name || 'No project'}</td>
<td>{po.project?.name || 'No client'}</td>
<td>{item.task?.title || '—'}</td>
<td>{item.description}</td>
<td style={{ textAlign: 'right', fontWeight: 400 }}>${Number(item.amount).toFixed(2)}</td>