feat: finance page overhaul, file access fix for clients and subcontractors
- Finance (TeamInvoices): chart + stat cards, Overview/Expenses/Subcontractors/Invoices/Legacy tabs - Expenses tab: 70/30 card split with expense list + category breakdown, + Expense button - Overview tab: 3 cards dynamic height filling window, .md-compliant table headers and sticky heads - TaskDetail FileBrowser: role-based virtual paths so clients and subcontractors no longer get 403 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -76,10 +76,9 @@ function ActionIcon({ actionKey, size = 27 }) {
|
||||
}
|
||||
|
||||
function Avatar({ name, size = 27 }) {
|
||||
const tone = iconTone(name);
|
||||
return (
|
||||
<div style={{ width: size, height: size, borderRadius: '50%', background: tone.bg, flexShrink: 0, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill={tone.color}>
|
||||
<div style={{ width: size, height: size, borderRadius: '50%', background: 'rgba(245,165,35,0.15)', flexShrink: 0, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="#F5A523">
|
||||
<circle cx="12" cy="8" r="4" />
|
||||
<path d="M4 20c0-4.4 3.6-7 8-7s8 2.6 8 7" />
|
||||
</svg>
|
||||
@@ -88,10 +87,9 @@ function Avatar({ name, size = 27 }) {
|
||||
}
|
||||
|
||||
function InitialPortrait({ name }) {
|
||||
const tone = iconTone(name);
|
||||
return (
|
||||
<div style={{ width: 27, height: 27, borderRadius: '50%', background: tone.bg, display: 'inline-flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
|
||||
<span style={{ fontSize: 12, fontWeight: 500, color: tone.color, lineHeight: 1 }}>{(name || '?')[0].toUpperCase()}</span>
|
||||
<div style={{ width: 27, height: 27, borderRadius: '50%', background: 'var(--accent)', display: 'inline-flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
|
||||
<span style={{ fontSize: 12, fontWeight: 600, color: '#000', lineHeight: 1 }}>{(name || '?')[0].toUpperCase()}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -592,18 +590,17 @@ function TeamPerformanceCard({ tasks, profiles }) {
|
||||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column' }}>
|
||||
{people.slice(0, 5).map((p, i) => {
|
||||
const pct = totalDone > 0 ? Math.round((p.done / totalDone) * 100) : 0;
|
||||
const tone = iconTone(p.name);
|
||||
return (
|
||||
<div key={p.name} style={{ display: 'flex', alignItems: 'center', gap: 10, marginTop: i > 0 ? 10 : 0 }}>
|
||||
{(() => { const prof = p.id ? profileMap.get(p.id) : null; const tone = iconTone(p.name); return prof?.avatar_url ? <div style={{ width: 27, height: 27, borderRadius: '50%', flexShrink: 0, overflow: 'hidden' }}><img src={prof.avatar_url} alt={p.name} style={{ width: '100%', height: '100%', objectFit: 'cover' }} /></div> : <Avatar name={p.name} />; })()}
|
||||
{(() => { const prof = p.id ? profileMap.get(p.id) : null; return prof?.avatar_url ? <div style={{ width: 27, height: 27, borderRadius: '50%', flexShrink: 0, overflow: 'hidden' }}><img src={prof.avatar_url} alt={p.name} style={{ width: '100%', height: '100%', objectFit: 'cover' }} /></div> : <Avatar name={p.name} />; })()}
|
||||
<div style={{ flex: 1, minWidth: 0, display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
{p.id ? <button type="button" className="dashboard-inline-link" style={{ fontSize: 13, fontWeight: 400, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }} onClick={() => navigate(`/profile/${p.id}`)}>{p.name}</button> : <span style={{ fontSize: 13, color: 'var(--text-primary)', fontWeight: 400, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{p.name}</span>}
|
||||
<span style={{ fontSize: 11, color: 'var(--text-muted)', flexShrink: 0, marginLeft: 8 }}>{p.newCount} new · {p.revCount} revision</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<div style={{ flex: 1, height: 4, borderRadius: 2, background: tone.bg, overflow: 'hidden' }}>
|
||||
<div style={{ height: '100%', borderRadius: 2, background: tone.color, width: `${pct}%`, transition: 'width 0.3s ease' }} />
|
||||
<div style={{ flex: 1, height: 4, borderRadius: 2, background: 'rgba(245,165,35,0.15)', overflow: 'hidden' }}>
|
||||
<div style={{ height: '100%', borderRadius: 2, background: 'var(--accent)', width: `${pct}%`, transition: 'width 0.3s ease' }} />
|
||||
</div>
|
||||
<span style={{ fontSize: 11, color: 'var(--text-muted)', minWidth: 28, textAlign: 'right' }}>{pct}%</span>
|
||||
</div>
|
||||
|
||||
+290
-36
@@ -30,7 +30,7 @@ const poStatusLabel = {
|
||||
cancelled: 'Cancelled',
|
||||
};
|
||||
const RECEIPT_BUCKET = 'expense-receipts';
|
||||
const FIELD_LABEL_STYLE = { fontSize: 11, fontWeight: 400, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: 0.5, display: 'block', marginBottom: 4 };
|
||||
const FIELD_LABEL_STYLE = { fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', textTransform: 'uppercase', letterSpacing: 0.8, display: 'block', marginBottom: 4 };
|
||||
const FIELD_INPUT_STYLE = { minHeight: 42, margin: 0 };
|
||||
|
||||
const blankExpense = () => ({
|
||||
@@ -71,10 +71,14 @@ export default function Invoices() {
|
||||
const [invoices, setInvoices] = useState(() => cached?.invoices || []);
|
||||
const [loading, setLoading] = useState(() => !cached);
|
||||
const [activeTab, setActiveTab] = useState(() => location.state?.tab || 'invoices');
|
||||
const [financeTab, setFinanceTab] = useState('overview');
|
||||
const [filter, setFilter] = useState('all');
|
||||
const { sortKey: invSortKey, sortDir: invSortDir, toggle: invToggle, sort: invSort } = useSortable('invoice_date');
|
||||
const { sortKey: expSortKey, sortDir: expSortDir, toggle: expToggle, sort: expSort } = useSortable('date');
|
||||
const { sortKey: subSortKey, sortDir: subSortDir, toggle: subToggle, sort: subSort } = useSortable('submitted_at');
|
||||
const { sortKey: ovExpKey, sortDir: ovExpDir, toggle: ovExpToggle } = useSortable('date');
|
||||
const { sortKey: ovSubKey, sortDir: ovSubDir, toggle: ovSubToggle } = useSortable('date');
|
||||
const { sortKey: ovInvKey, sortDir: ovInvDir, toggle: ovInvToggle } = useSortable('invoice_date');
|
||||
const [filterCompany, setFilterCompany] = useState('');
|
||||
const [exportYear, setExportYear] = useState(new Date().getFullYear());
|
||||
const [exporting, setExporting] = useState(false);
|
||||
@@ -498,46 +502,294 @@ export default function Invoices() {
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<div className="page-header" style={{ flexShrink: 0 }}>
|
||||
<div className="page-header-left">
|
||||
<DashboardBanner />
|
||||
<div className="page-title dashboard-greeting">Finances</div>
|
||||
<div className="page-subtitle">{invoices.length} invoice{invoices.length !== 1 ? 's' : ''} · {expenses.length} expense{expenses.length !== 1 ? 's' : ''} · {subcontractorPOs.length} subcontractor PO{subcontractorPOs.length !== 1 ? 's' : ''}</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
{paidYears.length > 0 && (
|
||||
<select className="filter-select" value={exportYear} onChange={e => setExportYear(Number(e.target.value))} style={{ width: 120 }}>
|
||||
{paidYears.map(y => <option key={y} value={y}>{y}</option>)}
|
||||
</select>
|
||||
<div style={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
|
||||
{/* Top row: 60% chart + 40% stat cards */}
|
||||
{(() => {
|
||||
const yr = chartData.reduce((s, m) => s + m.Revenue, 0);
|
||||
const yp = chartData.reduce((s, m) => s + m.Profit, 0);
|
||||
const ye = chartData.reduce((s, m) => s + m.Expenses, 0);
|
||||
const yo = chartData.reduce((s, m) => s + m.Outstanding, 0);
|
||||
const fmt = v => v >= 1000 ? `$${(v/1000).toFixed(1)}k` : `$${v.toFixed(0)}`;
|
||||
const CARD = { background: 'var(--card-bg)', border: '1px solid var(--border)', borderRadius: 8, padding: '18px 21px', backdropFilter: 'blur(12px)', WebkitBackdropFilter: 'blur(12px)' };
|
||||
const StatCard = ({ label, value, sub, iconBg, iconColor, iconSvg }) => (
|
||||
<div style={{ ...CARD, display: 'flex', alignItems: 'stretch', gap: 21, minHeight: 120 }}>
|
||||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column' }}>
|
||||
<div style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase', marginBottom: 5 }}>{label}</div>
|
||||
<div style={{ flex: 1, display: 'flex', alignItems: 'center' }}>
|
||||
<div style={{ fontSize: 30, fontWeight: 400, color: 'var(--text-primary)', letterSpacing: -0.5, lineHeight: 1.1 }}>{value}</div>
|
||||
</div>
|
||||
{sub && <div style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: 5 }}>{sub}</div>}
|
||||
</div>
|
||||
<div style={{ flexShrink: 0, display: 'flex', flexDirection: 'column', alignItems: 'flex-end', justifyContent: 'flex-start' }}>
|
||||
<div style={{ width: 27, height: 27, borderRadius: '50%', background: iconBg, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke={iconColor} strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">{iconSvg}</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '3fr 2fr', gap: 24, flexShrink: 0 }}>
|
||||
<div style={{ ...CARD }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 16 }}>
|
||||
<span style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase' }}>Revenue Overview</span>
|
||||
<select className="filter-select" value={chartYear} onChange={e => setChartYear(Number(e.target.value))} style={{ width: 90, borderRadius: 8, fontSize: 11, fontWeight: 500 }}>
|
||||
{(paidYears.length > 0 ? paidYears : [new Date().getFullYear()]).map(y => <option key={y} value={y}>{y}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<ResponsiveContainer width="100%" height={160}>
|
||||
<AreaChart data={chartData} margin={{ top: 4, right: 8, left: 0, bottom: 0 }}>
|
||||
<defs>
|
||||
<linearGradient id="gc2Revenue" x1="0" y1="0" x2="0" y2="1"><stop offset="5%" stopColor="#F5A523" stopOpacity={0.25}/><stop offset="95%" stopColor="#F5A523" stopOpacity={0}/></linearGradient>
|
||||
<linearGradient id="gc2Profit" x1="0" y1="0" x2="0" y2="1"><stop offset="5%" stopColor="#4ade80" stopOpacity={0.25}/><stop offset="95%" stopColor="#4ade80" stopOpacity={0}/></linearGradient>
|
||||
<linearGradient id="gc2Expenses" x1="0" y1="0" x2="0" y2="1"><stop offset="5%" stopColor="#ef4444" stopOpacity={0.2}/><stop offset="95%" stopColor="#ef4444" stopOpacity={0}/></linearGradient>
|
||||
<linearGradient id="gc2Outstanding" x1="0" y1="0" x2="0" y2="1"><stop offset="5%" stopColor="#60a5fa" stopOpacity={0.2}/><stop offset="95%" stopColor="#60a5fa" stopOpacity={0}/></linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="var(--border)" vertical={false} />
|
||||
<XAxis dataKey="month" tick={{ fontSize: 11, fill: 'var(--text-muted)' }} axisLine={false} tickLine={false} />
|
||||
<YAxis tick={{ fontSize: 11, fill: 'var(--text-muted)' }} axisLine={false} tickLine={false} tickFormatter={v => `$${v >= 1000 ? (v/1000).toFixed(0)+'k' : v}`} width={45} />
|
||||
<Tooltip content={<FinancesChartTooltip year={chartYear} />} />
|
||||
<Legend wrapperStyle={{ fontSize: 11, paddingTop: 8 }} />
|
||||
<Area type="monotone" dataKey="Revenue" stroke="#F5A523" strokeWidth={2} fill="url(#gc2Revenue)" dot={false} activeDot={{ r: 4 }} />
|
||||
<Area type="monotone" dataKey="Profit" stroke="#4ade80" strokeWidth={2} fill="url(#gc2Profit)" dot={false} activeDot={{ r: 4 }} />
|
||||
<Area type="monotone" dataKey="Expenses" stroke="#ef4444" strokeWidth={2} fill="url(#gc2Expenses)" dot={false} activeDot={{ r: 4 }} />
|
||||
<Area type="monotone" dataKey="Outstanding" stroke="#60a5fa" strokeWidth={2} fill="url(#gc2Outstanding)" dot={false} activeDot={{ r: 4 }} />
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 24, alignContent: 'start' }}>
|
||||
<StatCard label="Revenue" value={fmt(yr)} sub={`${chartYear} paid`} iconBg="rgba(245,165,35,0.15)" iconColor="#F5A523" iconSvg={<><line x1="12" y1="19" x2="12" y2="5"/><polyline points="5,12 12,5 19,12"/></>} />
|
||||
<StatCard label="Outstanding" value={fmt(yo)} sub="sent invoices" iconBg="rgba(96,165,250,0.15)" iconColor="#60a5fa" iconSvg={<><circle cx="12" cy="12" r="8"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></>} />
|
||||
<StatCard label="Expenses" value={fmt(ye)} sub="total spend" iconBg="rgba(239,68,68,0.15)" iconColor="#ef4444" iconSvg={<><line x1="12" y1="5" x2="12" y2="19"/><polyline points="19,12 12,19 5,12"/></>} />
|
||||
<StatCard label="Net Profit" value={fmt(yp)} sub="after expenses" iconBg="rgba(74,222,128,0.15)" iconColor="#4ade80" iconSvg={<><polyline points="4,13 9,18 20,7"/></>} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '7fr 3fr', gap: 24, marginTop: 24, marginBottom: 10, flexShrink: 0 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||
{[
|
||||
{ id: 'overview', label: 'Overview' },
|
||||
{ id: 'expenses', label: 'Expenses' },
|
||||
{ id: 'subcontractors', label: 'Subcontractors' },
|
||||
{ id: 'invoices', label: 'Invoices' },
|
||||
{ id: 'legacy', label: 'Legacy' },
|
||||
].map(t => (
|
||||
<button
|
||||
key={t.id}
|
||||
type="button"
|
||||
onClick={() => setFinanceTab(t.id)}
|
||||
style={{
|
||||
fontFamily: "Fourge, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif",
|
||||
fontSize: 13, fontWeight: 500, letterSpacing: 0.2,
|
||||
padding: '2px 0 5px', margin: '0 8px',
|
||||
borderRadius: 0, border: 'none',
|
||||
borderBottom: financeTab === t.id ? '2px solid var(--accent)' : '2px solid transparent',
|
||||
cursor: 'pointer', background: 'transparent',
|
||||
color: financeTab === t.id ? 'var(--text-primary)' : 'var(--text-secondary)',
|
||||
transition: 'all 160ms',
|
||||
}}
|
||||
>{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>
|
||||
)}
|
||||
<button className="btn btn-primary btn-sm" onClick={handleCPAExport} disabled={exporting}>
|
||||
{exporting ? 'Exporting…' : 'Export for CPA'}
|
||||
</button>
|
||||
</div>
|
||||
<div />
|
||||
</div>
|
||||
|
||||
<div style={{ background: 'var(--card-bg)', border: '1px solid var(--border)', borderRadius: 4, padding: '16px 16px 8px', marginBottom: 18 }}>
|
||||
<ResponsiveContainer width="100%" height={200}>
|
||||
<AreaChart data={chartData} margin={{ top: 4, right: 8, left: 0, bottom: 0 }}>
|
||||
<defs>
|
||||
<linearGradient id="gradRevenue" x1="0" y1="0" x2="0" y2="1"><stop offset="5%" stopColor="#F5A523" stopOpacity={0.25}/><stop offset="95%" stopColor="#F5A523" stopOpacity={0}/></linearGradient>
|
||||
<linearGradient id="gradProfit" x1="0" y1="0" x2="0" y2="1"><stop offset="5%" stopColor="#4ade80" stopOpacity={0.25}/><stop offset="95%" stopColor="#4ade80" stopOpacity={0}/></linearGradient>
|
||||
<linearGradient id="gradExpenses" x1="0" y1="0" x2="0" y2="1"><stop offset="5%" stopColor="#ef4444" stopOpacity={0.2}/><stop offset="95%" stopColor="#ef4444" stopOpacity={0}/></linearGradient>
|
||||
<linearGradient id="gradOutstanding" x1="0" y1="0" x2="0" y2="1"><stop offset="5%" stopColor="#60a5fa" stopOpacity={0.2}/><stop offset="95%" stopColor="#60a5fa" stopOpacity={0}/></linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="var(--border)" vertical={false} />
|
||||
<XAxis dataKey="month" tick={{ fontSize: 11, fill: 'var(--text-muted)' }} axisLine={false} tickLine={false} />
|
||||
<YAxis tick={{ fontSize: 11, fill: 'var(--text-muted)' }} axisLine={false} tickLine={false} tickFormatter={v => `$${v >= 1000 ? (v/1000).toFixed(0)+'k' : v}`} width={45} />
|
||||
<Tooltip content={<FinancesChartTooltip year={chartYear} />} />
|
||||
<Legend wrapperStyle={{ fontSize: 11, paddingTop: 8 }} />
|
||||
<Area type="monotone" dataKey="Revenue" stroke="#F5A523" strokeWidth={2} fill="url(#gradRevenue)" dot={false} activeDot={{ r: 4 }} />
|
||||
<Area type="monotone" dataKey="Profit" stroke="#4ade80" strokeWidth={2} fill="url(#gradProfit)" dot={false} activeDot={{ r: 4 }} />
|
||||
<Area type="monotone" dataKey="Expenses" stroke="#ef4444" strokeWidth={2} fill="url(#gradExpenses)" dot={false} activeDot={{ r: 4 }} />
|
||||
<Area type="monotone" dataKey="Outstanding" stroke="#60a5fa" strokeWidth={2} fill="url(#gradOutstanding)" dot={false} activeDot={{ r: 4 }} />
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
{financeTab === 'overview' && (() => {
|
||||
const CARD = { background: 'var(--card-bg)', border: '1px solid var(--border)', borderRadius: 8, padding: '18px 21px', backdropFilter: 'blur(12px)', WebkitBackdropFilter: 'blur(12px)' };
|
||||
const TH = { fontSize: 10, fontWeight: 500, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: 0.6, padding: '0 0 12px 0', border: 'none', background: 'transparent', textAlign: 'left' };
|
||||
const TD = { fontSize: 13, color: 'var(--text-primary)', padding: '5px 0', border: 'none', background: 'transparent', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' };
|
||||
const sortRows = (arr, key, dir, getter) => [...arr].sort((a, b) => {
|
||||
const av = getter(a, key), bv = getter(b, key);
|
||||
if (av < bv) return dir === 'asc' ? -1 : 1;
|
||||
if (av > bv) return dir === 'asc' ? 1 : -1;
|
||||
return 0;
|
||||
});
|
||||
const recentExp = sortRows(expenses, ovExpKey, ovExpDir, (e, k) => {
|
||||
if (k === 'description') return (e.description || '').toLowerCase();
|
||||
if (k === 'category') return (e.category || '').toLowerCase();
|
||||
if (k === 'amount') return Number(e.amount) || 0;
|
||||
return e.date || '';
|
||||
}).slice(0, 5);
|
||||
const recentSub = sortRows(subcontractorPOs, ovSubKey, ovSubDir, (po, k) => {
|
||||
if (k === 'name') return (po.profile?.name || '').toLowerCase();
|
||||
if (k === 'project') return (po.project?.name || '').toLowerCase();
|
||||
if (k === 'amount') return Number(po.amount) || 0;
|
||||
return po.paid_at || po.date || '';
|
||||
}).slice(0, 5);
|
||||
const recentInv = sortRows(invoices, ovInvKey, ovInvDir, (inv, k) => {
|
||||
if (k === 'company') return (inv.company?.name || '').toLowerCase();
|
||||
if (k === 'total') return Number(inv.total) || 0;
|
||||
return inv.invoice_date || '';
|
||||
}).slice(0, 5);
|
||||
const fmtDate = d => d ? new Date(d).toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) : '—';
|
||||
const fmtAmt = v => `$${Number(v).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
|
||||
return (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 24, flex: 1, minHeight: 0 }}>
|
||||
{/* Recent Expenses */}
|
||||
<div style={{ ...CARD, display: 'flex', flexDirection: 'column', minHeight: 0 }}>
|
||||
<div style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase', marginBottom: 14, flexShrink: 0 }}>Recent Expenses</div>
|
||||
{recentExp.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' }}>
|
||||
<colgroup><col style={{ width: '45%' }}/><col style={{ width: '30%' }}/><col style={{ width: '25%' }}/></colgroup>
|
||||
<thead><tr>
|
||||
<SortTh col="description" sortKey={ovExpKey} sortDir={ovExpDir} onSort={ovExpToggle} style={TH}>Description</SortTh>
|
||||
<SortTh col="category" sortKey={ovExpKey} sortDir={ovExpDir} onSort={ovExpToggle} style={{ ...TH, textAlign: 'center' }}>Category</SortTh>
|
||||
<SortTh col="amount" sortKey={ovExpKey} sortDir={ovExpDir} onSort={ovExpToggle} style={{ ...TH, textAlign: 'right' }}>Amount</SortTh>
|
||||
</tr></thead>
|
||||
<tbody>{recentExp.map(e => (
|
||||
<tr key={e.id}>
|
||||
<td style={{ ...TD }}>{e.description || '—'}</td>
|
||||
<td style={{ ...TD, fontSize: 12, color: 'var(--text-muted)', textAlign: 'center' }}>{e.category || '—'}</td>
|
||||
<td style={{ ...TD, textAlign: 'right', color: '#ef4444' }}>{fmtAmt(e.amount)}</td>
|
||||
</tr>
|
||||
))}</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* Recent Subcontractor Payments */}
|
||||
<div style={{ ...CARD, display: 'flex', flexDirection: 'column', minHeight: 0 }}>
|
||||
<div style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase', marginBottom: 14, flexShrink: 0 }}>Recent Subcontractor Payments</div>
|
||||
{recentSub.length === 0 ? <div className="card-empty-center">No payments</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' }}>
|
||||
<colgroup><col style={{ width: '35%' }}/><col style={{ width: '35%' }}/><col style={{ width: '30%' }}/></colgroup>
|
||||
<thead><tr>
|
||||
<SortTh col="name" sortKey={ovSubKey} sortDir={ovSubDir} onSort={ovSubToggle} style={TH}>Name</SortTh>
|
||||
<SortTh col="project" sortKey={ovSubKey} sortDir={ovSubDir} onSort={ovSubToggle} style={{ ...TH, textAlign: 'center' }}>Project</SortTh>
|
||||
<SortTh col="amount" sortKey={ovSubKey} sortDir={ovSubDir} onSort={ovSubToggle} style={{ ...TH, textAlign: 'right' }}>Amount</SortTh>
|
||||
</tr></thead>
|
||||
<tbody>{recentSub.map(po => (
|
||||
<tr key={po.id}>
|
||||
<td style={{ ...TD }}>{po.profile?.name || '—'}</td>
|
||||
<td style={{ ...TD, fontSize: 12, color: 'var(--text-muted)', textAlign: 'center' }}>{po.project?.name || '—'}</td>
|
||||
<td style={{ ...TD, textAlign: 'right', color: '#F5A523' }}>{fmtAmt(po.amount)}</td>
|
||||
</tr>
|
||||
))}</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* Recent Invoices */}
|
||||
<div style={{ ...CARD, display: 'flex', flexDirection: 'column', minHeight: 0 }}>
|
||||
<div style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase', marginBottom: 14, flexShrink: 0 }}>Recent Invoices</div>
|
||||
{recentInv.length === 0 ? <div className="card-empty-center">No invoices</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' }}>
|
||||
<colgroup><col style={{ width: '40%' }}/><col style={{ width: '25%' }}/><col style={{ width: '35%' }}/></colgroup>
|
||||
<thead><tr>
|
||||
<SortTh col="company" sortKey={ovInvKey} sortDir={ovInvDir} onSort={ovInvToggle} style={TH}>Company</SortTh>
|
||||
<SortTh col="invoice_date" sortKey={ovInvKey} sortDir={ovInvDir} onSort={ovInvToggle} style={{ ...TH, textAlign: 'center' }}>Date</SortTh>
|
||||
<SortTh col="total" sortKey={ovInvKey} sortDir={ovInvDir} onSort={ovInvToggle} style={{ ...TH, textAlign: 'right' }}>Total</SortTh>
|
||||
</tr></thead>
|
||||
<tbody>{recentInv.map(inv => (
|
||||
<tr key={inv.id}>
|
||||
<td style={{ ...TD }}>{inv.company?.name || '—'}</td>
|
||||
<td style={{ ...TD, fontSize: 12, color: 'var(--text-muted)', textAlign: 'center' }}>{fmtDate(inv.invoice_date)}</td>
|
||||
<td style={{ ...TD, textAlign: 'right', color: '#4ade80' }}>{fmtAmt(inv.total)}</td>
|
||||
</tr>
|
||||
))}</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
{financeTab === 'expenses' && (() => {
|
||||
const CARD = { background: 'var(--card-bg)', border: '1px solid var(--border)', borderRadius: 8, padding: '18px 21px', backdropFilter: 'blur(12px)', WebkitBackdropFilter: 'blur(12px)' };
|
||||
const TH = { fontSize: 10, fontWeight: 500, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: 0.6, padding: '0 0 12px 0', border: 'none', background: 'transparent', textAlign: 'left' };
|
||||
const TD = { fontSize: 13, color: 'var(--text-primary)', padding: '5px 0', border: 'none', background: 'transparent', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' };
|
||||
const fmtDate = d => d ? new Date(d).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) : '—';
|
||||
const fmtAmt = v => `$${Number(v).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
|
||||
const sortedExpenses = expSort(expenses, (exp, key) => {
|
||||
if (key === 'amount') return Number(exp.amount) || 0;
|
||||
if (key === 'date') return exp.date || '';
|
||||
return exp[key] || '';
|
||||
});
|
||||
const categoryTotals = CATEGORIES
|
||||
.map(cat => ({ cat, total: expenses.filter(e => e.category === cat).reduce((s, e) => s + Number(e.amount || 0), 0) }))
|
||||
.filter(x => x.total > 0)
|
||||
.sort((a, b) => b.total - a.total);
|
||||
const grandTotal = expenses.reduce((s, e) => s + Number(e.amount || 0), 0);
|
||||
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 }}>
|
||||
{/* Left: expense list */}
|
||||
<div style={{ ...CARD, display: 'flex', flexDirection: 'column', minHeight: 0 }}>
|
||||
<div style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase', marginBottom: 14, flexShrink: 0 }}>Expenses</div>
|
||||
{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' }}>
|
||||
<colgroup>
|
||||
<col style={{ width: '14%' }} />
|
||||
<col style={{ width: '34%' }} />
|
||||
<col style={{ width: '16%' }} />
|
||||
<col style={{ width: '24%' }} />
|
||||
<col style={{ width: '12%' }} />
|
||||
</colgroup>
|
||||
<thead><tr>
|
||||
<SortTh col="date" sortKey={expSortKey} sortDir={expSortDir} onSort={expToggle} style={TH}>Date</SortTh>
|
||||
<SortTh col="description" sortKey={expSortKey} sortDir={expSortDir} onSort={expToggle} style={TH}>Description</SortTh>
|
||||
<SortTh col="category" sortKey={expSortKey} sortDir={expSortDir} onSort={expToggle} style={{ ...TH, textAlign: 'center' }}>Category</SortTh>
|
||||
<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>
|
||||
<tbody>{sortedExpenses.map(exp => (
|
||||
<tr key={exp.id} style={{ cursor: 'pointer' }} onClick={() => startEditExpense(exp)}>
|
||||
<td style={{ ...TD, fontSize: 12, color: 'var(--text-muted)' }}>{fmtDate(exp.date)}</td>
|
||||
<td style={{ ...TD }}>{exp.description || '—'}</td>
|
||||
<td style={{ ...TD, fontSize: 12, color: 'var(--text-muted)', textAlign: 'center' }}>{exp.category || '—'}</td>
|
||||
<td style={{ ...TD, fontSize: 12, color: 'var(--text-muted)' }}>{exp.notes || '—'}</td>
|
||||
<td style={{ ...TD, textAlign: 'right', color: '#ef4444' }}>{fmtAmt(exp.amount)}</td>
|
||||
</tr>
|
||||
))}</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* Right: category breakdown */}
|
||||
<div style={{ ...CARD, display: 'flex', flexDirection: 'column', minHeight: 0 }}>
|
||||
<div style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase', marginBottom: 14, flexShrink: 0 }}>By Category</div>
|
||||
{categoryTotals.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' }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
{categoryTotals.map(({ cat, total }) => {
|
||||
const pct = grandTotal > 0 ? (total / grandTotal) * 100 : 0;
|
||||
return (
|
||||
<div key={cat}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 5 }}>
|
||||
<span style={{ fontSize: 12, color: 'var(--text-primary)' }}>{cat}</span>
|
||||
<span style={{ fontSize: 12, color: '#ef4444', fontVariantNumeric: 'tabular-nums' }}>{fmtAmt(total)}</span>
|
||||
</div>
|
||||
<div style={{ height: 4, borderRadius: 2, background: 'var(--border)', overflow: 'hidden' }}>
|
||||
<div style={{ height: '100%', width: `${pct}%`, background: '#ef4444', borderRadius: 2, opacity: 0.7, transition: 'width 400ms ease' }} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div style={{ borderTop: '1px solid var(--border)', paddingTop: 12, display: 'flex', justifyContent: 'space-between', marginTop: 4 }}>
|
||||
<span style={{ fontSize: 12, fontWeight: 500, color: 'var(--text-secondary)', textTransform: 'uppercase', letterSpacing: 0.6 }}>Total</span>
|
||||
<span style={{ fontSize: 13, fontWeight: 500, color: '#ef4444', fontVariantNumeric: 'tabular-nums' }}>{fmtAmt(grandTotal)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
{financeTab === 'legacy' && <div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 0, marginBottom: 16 }}>
|
||||
{[
|
||||
{ id: 'invoices', label: 'INVOICES' },
|
||||
@@ -886,7 +1138,9 @@ export default function Invoices() {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>}{/* end legacy wrapper */}
|
||||
|
||||
</div>{/* end flex column wrapper */}
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user