feat: finance page overhaul — pie charts, month nav, tab improvements

- Overview tab: 3 donut pie charts (expenses by category, sub payments, invoiced) each with independent month navigation arrows
- Expenses tab: big red total in category sidebar, left-aligned category column, white text
- Subcontractors tab: big gold total in sidebar, pending/paid breakdown with gold highlight
- Invoices tab: big green total in companies sidebar, dashboard-inline-link for invoice# and company, StatusBadge with Invoiced/Paid labels, column widths tuned
- StatusBadge: add optional label prop for custom display text
- Layout/style polish across tabs

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Krao Hasanee
2026-06-01 20:14:14 -04:00
parent ac3f08122d
commit 986d831186
13 changed files with 604 additions and 305 deletions
+5 -3
View File
@@ -3,6 +3,7 @@ import { useParams, useNavigate, useLocation, Link } from 'react-router-dom';
import Layout from '../../components/Layout';
import LoadingButton from '../../components/LoadingButton';
import SortTh from '../../components/SortTh';
import StatusBadge from '../../components/StatusBadge';
import { supabase } from '../../lib/supabase';
import { generateInvoicePDF, generateReceiptPDF } from '../../lib/invoice';
import { blobToEmailAttachment, sendEmail } from '../../lib/email';
@@ -337,9 +338,10 @@ export default function InvoiceDetail() {
</div>
</div>
<div className="action-buttons">
<span className={`badge badge-${statusColor[invoice.status]}`} style={{ fontSize: 13, padding: '6px 14px', textTransform: 'capitalize' }}>
{invoice.status}{isOverdue ? ' · Overdue' : ''}
</span>
<StatusBadge
status={statusColor[invoice.status] || 'not_started'}
label={`${invoice.status ? invoice.status.charAt(0).toUpperCase() + invoice.status.slice(1) : '—'}${isOverdue ? ' · Overdue' : ''}`}
/>
<LoadingButton className="btn btn-primary" loading={generating === 'invoice'} disabled={Boolean(generating)} loadingText="Generating... PDF" onClick={handleDownload}>Download Invoice</LoadingButton>
{invoice.status === 'sent' && (
<button className="btn btn-outline" onClick={handleResendInvoice} disabled={saving}>Resend Invoice</button>
+462 -240
View File
@@ -1,9 +1,10 @@
import { useState, useEffect, useMemo } from 'react';
import { AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from 'recharts';
import { useState, useEffect, useMemo, useRef } from 'react';
import { AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer, PieChart, Pie, Cell } from 'recharts';
import { DashboardBanner } from '../../lib/dashboardBanner';
import { useLocation, useNavigate } from 'react-router-dom';
import Layout from '../../components/Layout';
import SortTh from '../../components/SortTh';
import StatusBadge from '../../components/StatusBadge';
import { useSortable } from '../../hooks/useSortable';
import { supabase } from '../../lib/supabase';
import { readPageCache, writePageCache } from '../../lib/pageCache';
@@ -29,6 +30,10 @@ const poStatusLabel = {
paid: 'Paid',
cancelled: 'Cancelled',
};
const invoiceStatusBadgeLabel = (status) => {
if (status === 'sent') return 'Invoiced';
return status ? status.charAt(0).toUpperCase() + status.slice(1) : '—';
};
const RECEIPT_BUCKET = 'expense-receipts';
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 };
@@ -80,6 +85,10 @@ export default function Invoices() {
const { sortKey: ovSubKey, sortDir: ovSubDir, toggle: ovSubToggle } = useSortable('date');
const { sortKey: ovInvKey, sortDir: ovInvDir, toggle: ovInvToggle } = useSortable('invoice_date');
const [filterCompany, setFilterCompany] = useState('');
const initMonth = () => { const n = new Date(); return { month: n.getMonth(), year: n.getFullYear() }; };
const [ovMonth1, setOvMonth1] = useState(initMonth);
const [ovMonth2, setOvMonth2] = useState(initMonth);
const [ovMonth3, setOvMonth3] = useState(initMonth);
const [exportYear, setExportYear] = useState(new Date().getFullYear());
const [exporting, setExporting] = useState(false);
@@ -557,17 +566,17 @@ export default function Invoices() {
</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"/></>} />
<StatCard label="Revenue" value={fmt(yr)} sub={`${chartYear} · paid invoices`} 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={`${chartYear} · sent & unpaid`} 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={`${chartYear} · 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={`${chartYear} · 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 }}>
<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' },
@@ -579,16 +588,7 @@ export default function Invoices() {
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',
}}
className={`section-tab-btn${financeTab === t.id ? ' is-active' : ''}`}
>{t.label}</button>
))}
{financeTab === 'expenses' && (
@@ -596,6 +596,11 @@ export default function Invoices() {
<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={() => navigate('/invoices/new')}>+ Invoice</button>
</div>
)}
</div>
<div />
</div>
@@ -610,95 +615,146 @@ export default function Invoices() {
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 })}`;
const now = new Date();
const curM = now.getMonth(), curY = now.getFullYear();
const stepMonth = (p, dir) => dir === -1
? (p.month === 0 ? { month: 11, year: p.year - 1 } : { month: p.month - 1, year: p.year })
: (p.month === 11 ? { month: 0, year: p.year + 1 } : { month: p.month + 1, year: p.year });
const MonthNav = ({ m, setter }) => {
const atCurrent = m.month === curM && m.year === curY;
return (
<div style={{ display: 'flex', alignItems: 'center', gap: 0, flexShrink: 0 }}>
<button type="button" onClick={() => setter(p => stepMonth(p, -1))} style={{ background: 'none', border: 'none', padding: '0 6px', cursor: 'pointer', color: 'var(--text-secondary)', fontSize: 18, lineHeight: 1 }}></button>
<button type="button" onClick={() => setter(p => stepMonth(p, 1))} disabled={atCurrent} style={{ background: 'none', border: 'none', padding: '0 6px', cursor: atCurrent ? 'default' : 'pointer', color: 'var(--text-secondary)', fontSize: 18, lineHeight: 1, opacity: atCurrent ? 0.25 : 1 }}></button>
</div>
);
};
// Card 1: Expenses
const m1 = ovMonth1.month, y1 = ovMonth1.year;
const thisMonthExp = expenses.filter(e => { const d = new Date(e.date); return d.getFullYear() === y1 && d.getMonth() === m1; });
const thisMonthTotal = thisMonthExp.reduce((s, e) => s + Number(e.amount || 0), 0);
const thisCatTotals = CATEGORIES.map(cat => ({ cat, total: thisMonthExp.filter(e => e.category === cat).reduce((s, e) => s + Number(e.amount || 0), 0) })).filter(c => c.total > 0).sort((a, b) => b.total - a.total).slice(0, 4);
// Card 2: Sub payments this month (paid sub invoices)
const ovInvTotal = inv => (inv.items || []).reduce((s, i) => s + Number(i.unit_price || 0) * Number(i.quantity || 1), 0);
const m2 = ovMonth2.month, y2 = ovMonth2.year;
const pendingSubs = [...subInvoices.filter(i => { const d = new Date(i.paid_at || i.submitted_at); return d.getFullYear() === y2 && d.getMonth() === m2; })].sort((a, b) => new Date(b.paid_at || b.submitted_at) - new Date(a.paid_at || a.submitted_at));
const pendingSubsTotal = pendingSubs.reduce((s, i) => s + ovInvTotal(i), 0);
// Card 3: All invoices issued this month
const m3 = ovMonth3.month, y3 = ovMonth3.year;
const monthInvoices = [...invoices.filter(i => { const d = new Date(i.invoice_date); return d.getFullYear() === y3 && d.getMonth() === m3; })];
const outstandingTotal = monthInvoices.reduce((s, i) => s + Number(i.total || 0), 0);
const PIE_COLORS = ['#F5A523', '#4ade80', '#60a5fa', '#c084fc', '#f472b6', '#fb923c', '#34d399', '#a78bfa'];
// pie by subcontractor
const subPieData = Object.values(pendingSubs.reduce((acc, inv) => {
const name = inv.profile?.name || 'Unknown';
if (!acc[name]) acc[name] = { name, value: 0 };
acc[name].value += ovInvTotal(inv);
return acc;
}, {})).sort((a, b) => b.value - a.value);
// pie by company (outstanding invoices)
const invPieData = Object.values(monthInvoices.reduce((acc, inv) => {
const name = inv.company?.name || inv.bill_to || 'Unknown';
if (!acc[name]) acc[name] = { name, value: 0 };
acc[name].value += Number(inv.total || 0);
return acc;
}, {})).sort((a, b) => b.value - a.value);
const PieTooltipContent = ({ active, payload }) => {
if (!active || !payload?.length) return null;
return (
<div style={{ background: 'var(--card-bg)', border: '1px solid var(--border)', borderRadius: 4, padding: '8px 12px', fontSize: 12 }}>
<div style={{ color: payload[0].payload.fill, fontWeight: 500 }}>{payload[0].name}</div>
<div style={{ color: 'var(--text-primary)' }}>{fmtAmt(payload[0].value)}</div>
</div>
);
};
const PieLegend = ({ data, colors }) => (
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, marginTop: 14 }}>
{data.map((d, i) => (
<div key={d.name} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, minWidth: 0 }}>
<div style={{ width: 8, height: 8, borderRadius: '50%', background: colors[i % colors.length], flexShrink: 0 }} />
<span style={{ fontSize: 12, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{d.name}</span>
</div>
<span style={{ fontSize: 12, color: 'var(--text-primary)', fontVariantNumeric: 'tabular-nums', flexShrink: 0 }}>{fmtAmt(d.value)}</span>
</div>
))}
</div>
);
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>
)}
{/* Card 1: This Month Expenses by category */}
<div style={{ ...CARD, display: 'flex', flexDirection: 'column', minHeight: 0, overflowY: 'auto' }}>
<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} />
</div>
<div style={{ fontSize: 30, fontWeight: 400, letterSpacing: -0.5, lineHeight: 1.1, color: '#ef4444', fontVariantNumeric: 'tabular-nums', marginBottom: 14, flexShrink: 0 }}>{fmtAmt(thisMonthTotal)}</div>
<ResponsiveContainer width="100%" height={160}>
<PieChart>
<Pie data={thisCatTotals.length ? thisCatTotals : [{ cat: 'None', total: 1 }]} dataKey="total" nameKey="cat" cx="50%" cy="50%" innerRadius={45} outerRadius={72} strokeWidth={0}>
{thisCatTotals.length ? thisCatTotals.map((_, i) => <Cell key={i} fill={PIE_COLORS[i % PIE_COLORS.length]} />) : <Cell fill="var(--border)" />}
</Pie>
{thisCatTotals.length > 0 && <Tooltip content={({ active, payload }) => {
if (!active || !payload?.length) return null;
return <div style={{ background: 'var(--card-bg)', border: '1px solid var(--border)', borderRadius: 4, padding: '8px 12px', fontSize: 12 }}><div style={{ color: payload[0].payload.fill, fontWeight: 500 }}>{payload[0].name}</div><div style={{ color: 'var(--text-primary)' }}>{fmtAmt(payload[0].value)}</div></div>;
}} />}
</PieChart>
</ResponsiveContainer>
{thisCatTotals.length > 0 && <PieLegend data={thisCatTotals.map(c => ({ name: c.cat, value: c.total }))} colors={PIE_COLORS} />}
</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>
)}
{/* Card 2: Pending Sub Payments by subcontractor */}
<div style={{ ...CARD, display: 'flex', flexDirection: 'column', minHeight: 0, overflowY: 'auto' }}>
<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} />
</div>
<div style={{ fontSize: 30, fontWeight: 400, letterSpacing: -0.5, lineHeight: 1.1, color: '#F5A523', fontVariantNumeric: 'tabular-nums', marginBottom: 14, flexShrink: 0 }}>{fmtAmt(pendingSubsTotal)}</div>
<ResponsiveContainer width="100%" height={160}>
<PieChart>
<Pie data={subPieData.length ? subPieData : [{ name: 'None', value: 1 }]} dataKey="value" nameKey="name" cx="50%" cy="50%" innerRadius={45} outerRadius={72} strokeWidth={0}>
{subPieData.length ? subPieData.map((_, i) => <Cell key={i} fill={PIE_COLORS[i % PIE_COLORS.length]} />) : <Cell fill="var(--border)" />}
</Pie>
{subPieData.length > 0 && <Tooltip content={({ active, payload }) => {
if (!active || !payload?.length) return null;
return <div style={{ background: 'var(--card-bg)', border: '1px solid var(--border)', borderRadius: 4, padding: '8px 12px', fontSize: 12 }}><div style={{ color: payload[0].payload.fill, fontWeight: 500 }}>{payload[0].name}</div><div style={{ color: 'var(--text-primary)' }}>{fmtAmt(payload[0].value)}</div></div>;
}} />}
</PieChart>
</ResponsiveContainer>
{subPieData.length > 0 && <PieLegend data={subPieData} colors={PIE_COLORS} />}
</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>
)}
{/* Card 3: Invoiced this month by company */}
<div style={{ ...CARD, display: 'flex', flexDirection: 'column', minHeight: 0, overflowY: 'auto' }}>
<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} />
</div>
<div style={{ fontSize: 30, fontWeight: 400, letterSpacing: -0.5, lineHeight: 1.1, color: '#4ade80', fontVariantNumeric: 'tabular-nums', marginBottom: 14, flexShrink: 0 }}>{fmtAmt(outstandingTotal)}</div>
<ResponsiveContainer width="100%" height={160}>
<PieChart>
<Pie data={invPieData.length ? invPieData : [{ name: 'None', value: 1 }]} dataKey="value" nameKey="name" cx="50%" cy="50%" innerRadius={45} outerRadius={72} strokeWidth={0}>
{invPieData.length ? invPieData.map((_, i) => <Cell key={i} fill={PIE_COLORS[i % PIE_COLORS.length]} />) : <Cell fill="var(--border)" />}
</Pie>
{invPieData.length > 0 && <Tooltip content={({ active, payload }) => {
if (!active || !payload?.length) return null;
return <div style={{ background: 'var(--card-bg)', border: '1px solid var(--border)', borderRadius: 4, padding: '8px 12px', fontSize: 12 }}><div style={{ color: payload[0].payload.fill, fontWeight: 500 }}>{payload[0].name}</div><div style={{ color: 'var(--text-primary)' }}>{fmtAmt(payload[0].value)}</div></div>;
}} />}
</PieChart>
</ResponsiveContainer>
{invPieData.length > 0 && <PieLegend data={invPieData} colors={PIE_COLORS} />}
</div>
</div>
);
@@ -717,7 +773,6 @@ export default function Invoices() {
});
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 (
@@ -725,7 +780,6 @@ export default function Invoices() {
<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' }}>
@@ -739,16 +793,20 @@ export default function Invoices() {
<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="category" sortKey={expSortKey} sortDir={expSortDir} onSort={expToggle} style={TH}>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>
<tr key={exp.id}>
<td style={{ ...TD, fontSize: 12 }}>{fmtDate(exp.date)}</td>
<td style={{ ...TD }}>
<button type="button" className="dashboard-inline-link" onClick={() => startEditExpense(exp)} style={{ fontSize: 13, fontWeight: 400 }}>
{exp.description || '—'}
</button>
</td>
<td style={{ ...TD, fontSize: 12 }}>{exp.category || '—'}</td>
<td style={{ ...TD, fontSize: 12 }}>{exp.notes || '—'}</td>
<td style={{ ...TD, textAlign: 'right', color: '#ef4444' }}>{fmtAmt(exp.amount)}</td>
</tr>
))}</tbody>
@@ -758,7 +816,8 @@ export default function Invoices() {
</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>
<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: '#ef4444', fontVariantNumeric: 'tabular-nums', marginBottom: 14, flexShrink: 0 }}>{fmtAmt(grandTotal)}</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 }}>
@@ -776,10 +835,6 @@ export default function Invoices() {
</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>
)}
@@ -789,6 +844,236 @@ export default function Invoices() {
);
})()}
{financeTab === 'subcontractors' && (() => {
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 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 sortedInvoices = subSort(yearFiltered, (inv, key) => {
if (key === 'total') return invTotal(inv);
if (key === 'subcontractor') return inv.profile?.name || '';
if (key === 'submitted_at') return inv.submitted_at ? new Date(inv.submitted_at).getTime() : 0;
if (key === 'paid_at') return inv.paid_at ? new Date(inv.paid_at).getTime() : 0;
return inv[key] || '';
});
const bySubcontractor = Object.values(yearFiltered.reduce((acc, inv) => {
const key = inv.profile?.id || 'unknown';
if (!acc[key]) acc[key] = { name: inv.profile?.name || 'Unknown', invoices: [] };
acc[key].invoices.push(inv);
return acc;
}, {})).map(g => ({
...g,
total: g.invoices.reduce((s, i) => s + invTotal(i), 0),
paid: g.invoices.filter(i => i.status === 'paid').reduce((s, i) => s + invTotal(i), 0),
pending: g.invoices.filter(i => i.status === 'submitted').reduce((s, i) => s + invTotal(i), 0),
})).sort((a, b) => b.total - a.total);
const grandTotal = bySubcontractor.reduce((s, g) => s + g.total, 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: invoice list */}
<div style={{ ...CARD, display: 'flex', flexDirection: 'column', minHeight: 0 }}>
{subInvoicesLoading ? (
<div style={{ fontSize: 13, color: 'var(--text-muted)' }}>Loading</div>
) : subInvoicesError ? (
<div style={{ fontSize: 13, color: 'var(--danger)' }}>{subInvoicesError}</div>
) : yearFiltered.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: '14%' }} />
<col style={{ width: '26%' }} />
<col style={{ width: '16%' }} />
<col style={{ width: '16%' }} />
<col style={{ width: '14%' }} />
<col style={{ width: '14%' }} />
</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>
<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>
<SortTh col="total" sortKey={subSortKey} sortDir={subSortDir} onSort={subToggle} style={{ ...TH, textAlign: 'right' }}>Amount</SortTh>
</tr></thead>
<tbody>{sortedInvoices.map(inv => {
const total = invTotal(inv);
return (
<tr key={inv.id}>
<td style={{ ...TD }}>
<button type="button" className="table-link" onClick={() => navigate(`/sub-invoices/${inv.id}`)}>
{inv.invoice_number || '—'}
</button>
</td>
<td style={{ ...TD }}>{inv.profile?.name || '—'}</td>
<td style={{ ...TD, fontSize: 12, color: 'var(--text-muted)' }}>{fmtDate(inv.submitted_at)}</td>
<td style={{ ...TD, fontSize: 12, color: 'var(--text-muted)' }}>{fmtDate(inv.paid_at)}</td>
<td style={{ ...TD }}>
<StatusBadge
status={subInvoiceStatusColor[inv.status] || 'not_started'}
label={inv.status ? inv.status.charAt(0).toUpperCase() + inv.status.slice(1) : '—'}
/>
</td>
<td style={{ ...TD, textAlign: 'right', color: inv.status === 'paid' ? '#4ade80' : inv.status === 'submitted' ? '#F5A523' : 'var(--text-primary)' }}>
{fmtAmt(total)}
</td>
</tr>
);
})}</tbody>
</table>
</div>
)}
</div>
{/* Right: by subcontractor */}
<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: 5, flexShrink: 0 }}>Subcontractors</div>
<div style={{ fontSize: 30, fontWeight: 400, letterSpacing: -0.5, lineHeight: 1.1, color: '#F5A523', fontVariantNumeric: 'tabular-nums', marginBottom: 14, flexShrink: 0 }}>{fmtAmt(grandTotal)}</div>
{bySubcontractor.length === 0 ? <div className="card-empty-center">No data</div> : (
<div className="scrollbar-thin-theme table-scroll-hidden" style={{ flex: 1, minHeight: 0, overflowY: 'auto' }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
{bySubcontractor.map(g => {
const pct = grandTotal > 0 ? (g.total / grandTotal) * 100 : 0;
return (
<div key={g.name}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 5 }}>
<span style={{ fontSize: 12, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', maxWidth: '65%' }}>{g.name}</span>
<span style={{ fontSize: 12, color: '#F5A523', fontVariantNumeric: 'tabular-nums', flexShrink: 0 }}>{fmtAmt(g.total)}</span>
</div>
<div style={{ height: 4, borderRadius: 2, background: 'var(--border)', overflow: 'hidden' }}>
<div style={{ height: '100%', width: `${pct}%`, background: '#F5A523', borderRadius: 2, opacity: 0.7, transition: 'width 400ms ease' }} />
</div>
<div style={{ fontSize: 11, color: 'var(--text-muted)', marginTop: 3 }}><span style={{ color: g.pending > 0 ? '#F5A523' : 'var(--text-muted)' }}>{fmtAmt(g.pending)} pending</span> · {fmtAmt(g.paid)} paid</div>
</div>
);
})}
</div>
</div>
)}
</div>
</div>
</div>
);
})()}
{financeTab === 'invoices' && (() => {
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 statusColor = { draft: 'not_started', sent: 'in_progress', paid: 'client_approved' };
const sorted = invSort(invoices, (inv, key) => {
if (key === 'total') return Number(inv.total) || 0;
if (key === 'invoice_date' || key === 'due_date') return new Date(inv[key]).getTime();
if (key === 'company') return inv.company?.name || '';
return inv[key] || '';
});
const byCompany = Object.values(invoices.reduce((acc, inv) => {
const name = inv.company?.name || inv.bill_to || 'Unknown';
if (!acc[name]) acc[name] = { name, total: 0, paid: 0, outstanding: 0 };
acc[name].total += Number(inv.total) || 0;
if (inv.status === 'paid') acc[name].paid += Number(inv.total) || 0;
else if (inv.status === 'sent') acc[name].outstanding += Number(inv.total) || 0;
return acc;
}, {})).sort((a, b) => b.total - a.total);
const grandTotal = byCompany.reduce((s, g) => s + g.total, 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 }}>
<div style={{ ...CARD, display: 'flex', flexDirection: 'column', minHeight: 0 }}>
{loading ? (
<div style={{ fontSize: 13, color: 'var(--text-muted)' }}>Loading</div>
) : invoices.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: '20%' }} />
<col style={{ width: '27%' }} />
<col style={{ width: '15%' }} />
<col style={{ width: '15%' }} />
<col style={{ width: '13%' }} />
<col style={{ width: '10%' }} />
</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>
<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="total" sortKey={invSortKey} sortDir={invSortDir} onSort={invToggle} style={{ ...TH, textAlign: 'right' }}>Total</SortTh>
</tr></thead>
<tbody>{sorted.map(inv => (
<tr key={inv.id}>
<td style={{ ...TD }}>
<button type="button" className="dashboard-inline-link" onClick={() => navigate(`/invoices/${inv.id}`)}>{inv.invoice_number}</button>
</td>
<td style={{ ...TD }}>
<button type="button" className="dashboard-inline-link" onClick={() => inv.company?.id && navigate(`/company/${inv.company.id}`)}>{inv.company?.name || inv.bill_to || '—'}</button>
</td>
<td style={{ ...TD, fontSize: 12 }}>{fmtDate(inv.invoice_date)}</td>
<td style={{ ...TD, fontSize: 12, color: inv.status !== 'paid' && new Date(inv.due_date) < new Date() ? 'var(--danger)' : 'var(--text-primary)' }}>{fmtDate(inv.due_date)}</td>
<td style={{ ...TD, textAlign: 'center' }}>
<StatusBadge
status={statusColor[inv.status] || 'not_started'}
label={invoiceStatusBadgeLabel(inv.status)}
/>
</td>
<td style={{ ...TD, textAlign: 'right', color: inv.status === 'paid' ? '#4ade80' : inv.status === 'sent' ? '#F5A523' : 'var(--text-primary)' }}>
{fmtAmt(inv.total)}
</td>
</tr>
))}</tbody>
</table>
</div>
)}
</div>
<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: 5, flexShrink: 0 }}>Companies</div>
<div style={{ fontSize: 30, fontWeight: 400, letterSpacing: -0.5, lineHeight: 1.1, color: '#4ade80', fontVariantNumeric: 'tabular-nums', marginBottom: 14, flexShrink: 0 }}>{fmtAmt(grandTotal)}</div>
{byCompany.length === 0 ? <div className="card-empty-center">No data</div> : (
<div className="scrollbar-thin-theme table-scroll-hidden" style={{ flex: 1, minHeight: 0, overflowY: 'auto' }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
{byCompany.map(g => {
const pct = grandTotal > 0 ? (g.total / grandTotal) * 100 : 0;
return (
<div key={g.name}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 5 }}>
<span style={{ fontSize: 12, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', maxWidth: '65%' }}>{g.name}</span>
<span style={{ fontSize: 12, color: '#4ade80', fontVariantNumeric: 'tabular-nums', flexShrink: 0 }}>{fmtAmt(g.total)}</span>
</div>
<div style={{ height: 4, borderRadius: 2, background: 'var(--border)', overflow: 'hidden' }}>
<div style={{ height: '100%', width: `${pct}%`, background: '#4ade80', borderRadius: 2, opacity: 0.7, transition: 'width 400ms ease' }} />
</div>
<div style={{ fontSize: 11, color: 'var(--text-muted)', marginTop: 3 }}><span style={{ color: g.outstanding > 0 ? '#F5A523' : 'var(--text-muted)' }}>{fmtAmt(g.outstanding)} outstanding</span> · {fmtAmt(g.paid)} paid</div>
</div>
);
})}
</div>
</div>
)}
</div>
</div>
</div>
);
})()}
{financeTab === 'legacy' && <div>
<div style={{ display: 'flex', alignItems: 'center', gap: 0, marginBottom: 16 }}>
{[
@@ -877,7 +1162,12 @@ export default function Invoices() {
{new Date(inv.due_date).toLocaleDateString()}
</span>
</td>
<td><span className={`badge badge-${statusColor[inv.status]}`} style={{ textTransform: 'capitalize' }}>{inv.status}</span></td>
<td style={{ textAlign: 'center' }}>
<StatusBadge
status={statusColor[inv.status] || 'not_started'}
label={invoiceStatusBadgeLabel(inv.status)}
/>
</td>
<td style={{ fontWeight: 400, color: 'var(--accent)' }}>${Number(inv.total).toFixed(2)}</td>
</tr>
))}
@@ -959,130 +1249,6 @@ export default function Invoices() {
</div>
{showExpenseForm && (
<div style={popupOverlayStyle} onClick={cancelExpenseEdit}>
<div style={{ ...popupSurfaceStyle, padding: 28, width: '100%', maxWidth: 520, maxHeight: '90vh', overflowY: 'auto' }} onClick={e => e.stopPropagation()}>
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', marginBottom: 20 }}>
<div>
<div style={{ fontSize: 11, fontWeight: 400, textTransform: 'uppercase', letterSpacing: '0.5px', color: 'var(--text-muted)', marginBottom: 4 }}>{editingExpenseId ? 'Edit Expense' : 'New Expense'}</div>
<div style={{ fontSize: 22, color: 'var(--text-primary)' }}>{editingExpenseId ? 'Edit expense' : 'Add an expense'}</div>
</div>
<button onClick={cancelExpenseEdit} style={{ background: 'none', border: 'none', color: 'var(--text-muted)', fontSize: 20, cursor: 'pointer', lineHeight: 1, padding: 4 }}>×</button>
</div>
<form onSubmit={handleAddExpense} style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 8 }}>
<div>
<label style={FIELD_LABEL_STYLE}>Date</label>
<input
type="date"
className="input"
value={newExpense.date}
onChange={e => setNewExpense(p => ({ ...p, date: e.target.value }))}
style={FIELD_INPUT_STYLE}
required
/>
</div>
<div>
<label style={FIELD_LABEL_STYLE}>Amount (USD)</label>
<input
type="number"
step="0.01"
min="0"
className="input"
placeholder="0.00"
value={newExpense.amount}
onChange={e => setNewExpense(p => ({ ...p, amount: e.target.value }))}
style={{ ...FIELD_INPUT_STYLE, minHeight: 38, borderRadius: 4, paddingLeft: 10 }}
required
/>
</div>
</div>
<div>
<label style={FIELD_LABEL_STYLE}>Description</label>
<input
type="text"
className="input"
placeholder="What was this for?"
value={newExpense.description}
onChange={e => setNewExpense(p => ({ ...p, description: e.target.value }))}
required
/>
</div>
<div>
<label style={FIELD_LABEL_STYLE}>Category</label>
<select
className="input"
value={newExpense.category}
onChange={e => setNewExpense(p => ({ ...p, category: e.target.value }))}
style={FIELD_INPUT_STYLE}
>
{CATEGORIES.map(c => <option key={c} value={c}>{c}</option>)}
</select>
</div>
<div>
<label style={FIELD_LABEL_STYLE}>Notes (optional)</label>
<input
type="text"
className="input"
placeholder="Any extra details"
value={newExpense.notes}
onChange={e => setNewExpense(p => ({ ...p, notes: e.target.value }))}
/>
</div>
<div>
<label style={FIELD_LABEL_STYLE}>Receipt / Photo (optional)</label>
<input
type="file"
className="input"
accept="image/*,.pdf"
style={{ ...FIELD_INPUT_STYLE, padding: '9px 12px' }}
onChange={e => setNewExpense(p => ({ ...p, receipt: e.target.files?.[0] || null, removeReceipt: false }))}
/>
{!newExpense.receipt && newExpense.receipt_path && (
<div style={{ marginTop: 6, display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
<span style={{ fontSize: 12, color: 'var(--text-muted)' }}>{newExpense.receipt_name || 'Existing receipt attached'}</span>
<button
type="button"
className="btn btn-outline btn-sm"
onClick={() => handleViewReceipt(newExpense)}
>
View Receipt
</button>
<button
type="button"
className="btn btn-outline btn-sm"
style={{ color: 'var(--danger)' }}
onClick={() => setNewExpense(p => ({ ...p, removeReceipt: true, receipt_path: null, receipt_name: null }))}
>
Remove Receipt
</button>
</div>
)}
{newExpense.receipt && (
<div style={{ marginTop: 6, fontSize: 12, color: 'var(--text-muted)' }}>
{newExpense.receipt.name}
</div>
)}
</div>
<div className="action-buttons" style={{ marginTop: 4 }}>
<button className="btn btn-primary btn-sm" type="submit" disabled={addingExpense}>
{addingExpense ? (editingExpenseId ? 'Saving…' : 'Adding…') : (editingExpenseId ? 'Save Changes' : 'Add Expense')}
</button>
{editingExpenseId && (
<button className="btn btn-outline btn-sm" type="button" onClick={cancelExpenseEdit}>
Cancel
</button>
)}
</div>
{expensesError && (
<div style={{ fontSize: 12, color: 'var(--danger)' }}>
{expensesError}
</div>
)}
</form>
</div>
</div>
)}
</div>
)}
@@ -1125,7 +1291,12 @@ export default function Invoices() {
<div style={{ color: 'var(--text-muted)', fontSize: 12 }}>{inv.profile?.email || '—'}</div>
</td>
<td style={{ color: 'var(--text-muted)' }}>{inv.submitted_at ? new Date(inv.submitted_at).toLocaleDateString() : '—'}</td>
<td><span className={`badge badge-${subInvoiceStatusColor[inv.status] || 'not_started'}`} style={{ textTransform: 'capitalize' }}>{inv.status}</span></td>
<td>
<StatusBadge
status={subInvoiceStatusColor[inv.status] || 'not_started'}
label={inv.status ? inv.status.charAt(0).toUpperCase() + inv.status.slice(1) : '—'}
/>
</td>
<td style={{ textAlign: 'right', fontWeight: 400, color: 'var(--accent)' }}>${total.toFixed(2)}</td>
<td />
</tr>
@@ -1140,6 +1311,57 @@ export default function Invoices() {
)}
</div>}{/* end legacy wrapper */}
{showExpenseForm && (
<div style={popupOverlayStyle} onClick={cancelExpenseEdit}>
<div style={{ ...popupSurfaceStyle, background: 'var(--card-bg)', width: 'min(520px, 96vw)', maxHeight: 'calc(100vh - 48px)', overflowY: 'auto', display: 'flex', flexDirection: 'column', gap: 16 }} onClick={e => e.stopPropagation()}>
<div style={{ fontSize: 11, fontWeight: 500, textTransform: 'uppercase', letterSpacing: 0.8, color: 'var(--text-secondary)' }}>{editingExpenseId ? 'Edit Expense' : 'New Expense'}</div>
<form onSubmit={handleAddExpense} style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 14 }}>
<div>
<div style={FIELD_LABEL_STYLE}>Date *</div>
<input type="date" required value={newExpense.date} onChange={e => setNewExpense(p => ({ ...p, date: e.target.value }))} style={{ fontSize: 13, color: 'var(--text-primary)', background: 'var(--card-bg-2)', border: '1px solid var(--border)', borderRadius: 6, padding: '6px 10px', width: '100%', fontFamily: 'inherit', boxSizing: 'border-box' }} />
</div>
<div>
<div style={FIELD_LABEL_STYLE}>Amount (USD) *</div>
<input type="number" step="0.01" min="0" required placeholder="0.00" value={newExpense.amount} onChange={e => setNewExpense(p => ({ ...p, amount: e.target.value }))} style={{ fontSize: 13, color: 'var(--text-primary)', background: 'var(--card-bg-2)', border: '1px solid var(--border)', borderRadius: 6, padding: '6px 10px', width: '100%', fontFamily: 'inherit', boxSizing: 'border-box' }} />
</div>
</div>
<div>
<div style={FIELD_LABEL_STYLE}>Description *</div>
<input type="text" required placeholder="What was this for?" value={newExpense.description} onChange={e => setNewExpense(p => ({ ...p, description: e.target.value }))} style={{ fontSize: 13, color: 'var(--text-primary)', background: 'var(--card-bg-2)', border: '1px solid var(--border)', borderRadius: 6, padding: '6px 10px', width: '100%', fontFamily: 'inherit', boxSizing: 'border-box' }} />
</div>
<div>
<div style={FIELD_LABEL_STYLE}>Category</div>
<select value={newExpense.category} onChange={e => setNewExpense(p => ({ ...p, category: e.target.value }))} style={{ fontSize: 13, color: 'var(--text-primary)', background: 'var(--card-bg-2)', border: '1px solid var(--border)', borderRadius: 6, padding: '6px 10px', width: '100%', fontFamily: 'inherit', boxSizing: 'border-box' }}>
{CATEGORIES.map(c => <option key={c} value={c}>{c}</option>)}
</select>
</div>
<div>
<div style={FIELD_LABEL_STYLE}>Notes</div>
<input type="text" placeholder="Any extra details" value={newExpense.notes} onChange={e => setNewExpense(p => ({ ...p, notes: e.target.value }))} style={{ fontSize: 13, color: 'var(--text-primary)', background: 'var(--card-bg-2)', border: '1px solid var(--border)', borderRadius: 6, padding: '6px 10px', width: '100%', fontFamily: 'inherit', boxSizing: 'border-box' }} />
</div>
<div>
<div style={FIELD_LABEL_STYLE}>Receipt / Photo</div>
<input type="file" accept="image/*,.pdf" onChange={e => setNewExpense(p => ({ ...p, receipt: e.target.files?.[0] || null, removeReceipt: false }))} style={{ fontSize: 13, color: 'var(--text-primary)', background: 'var(--card-bg-2)', border: '1px solid var(--border)', borderRadius: 6, padding: '6px 10px', width: '100%', fontFamily: 'inherit', boxSizing: 'border-box' }} />
{!newExpense.receipt && newExpense.receipt_path && (
<div style={{ marginTop: 6, display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
<span style={{ fontSize: 12, color: 'var(--text-muted)' }}>{newExpense.receipt_name || 'Existing receipt attached'}</span>
<button type="button" className="btn btn-outline" onClick={() => handleViewReceipt(newExpense)}>View</button>
<button type="button" className="btn btn-outline" style={{ color: 'var(--danger)' }} onClick={() => setNewExpense(p => ({ ...p, removeReceipt: true, receipt_path: null, receipt_name: null }))}>Remove</button>
</div>
)}
{newExpense.receipt && <div style={{ marginTop: 6, fontSize: 12, color: 'var(--text-muted)' }}>{newExpense.receipt.name}</div>}
</div>
{expensesError && <div style={{ fontSize: 12, color: 'var(--danger)' }}>{expensesError}</div>}
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end', paddingTop: 4 }}>
<button type="submit" className="btn btn-outline" disabled={addingExpense}>{addingExpense ? 'Saving…' : 'Save'}</button>
<button type="button" className="btn btn-outline" onClick={cancelExpenseEdit}>Cancel</button>
</div>
</form>
</div>
</div>
)}
</div>{/* end flex column wrapper */}
</Layout>
);
+5 -3
View File
@@ -2,6 +2,7 @@ import { useState, useEffect } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import Layout from '../../components/Layout';
import SortTh from '../../components/SortTh';
import StatusBadge from '../../components/StatusBadge';
import { supabase } from '../../lib/supabase';
import { generateSubcontractorPOPDF } from '../../lib/invoice';
import { blobToEmailAttachment, sendEmail } from '../../lib/email';
@@ -121,9 +122,10 @@ export default function SubInvoiceDetail() {
{invoice.profile?.name || 'External'} · {invoice.profile?.email || '—'}
</div>
</div>
<span className={`badge badge-${statusColor[invoice.status] || 'not_started'}`} style={{ fontSize: 13, padding: '6px 14px', textTransform: 'capitalize' }}>
{invoice.status}
</span>
<StatusBadge
status={statusColor[invoice.status] || 'not_started'}
label={invoice.status ? invoice.status.charAt(0).toUpperCase() + invoice.status.slice(1) : '—'}
/>
</div>
<div className="grid-2" style={{ marginBottom: 24 }}>
+5 -3
View File
@@ -3,6 +3,7 @@ import { useNavigate, useParams } from 'react-router-dom';
import Layout from '../../components/Layout';
import LoadingButton from '../../components/LoadingButton';
import SortTh from '../../components/SortTh';
import StatusBadge from '../../components/StatusBadge';
import { supabase } from '../../lib/supabase';
import { sendEmail } from '../../lib/email';
import { generateSubcontractorPOPDF } from '../../lib/invoice';
@@ -169,9 +170,10 @@ export default function SubcontractorPODetail() {
<div className="page-title">{po.po_number || 'Purchase Order'}</div>
</div>
<div className="action-buttons">
<span className={`badge badge-${poStatusColor[po.status] || 'not_started'}`} style={{ fontSize: 13, padding: '6px 14px' }}>
{poStatusLabel[po.status] || po.status}
</span>
<StatusBadge
status={poStatusColor[po.status] || 'not_started'}
label={poStatusLabel[po.status] || po.status}
/>
<LoadingButton className="btn btn-primary" loading={generating} loadingText="Generating..." onClick={handleDownload}>Download PO</LoadingButton>
{po.status !== 'draft' && po.status !== 'cancelled' && (
<button className="btn btn-outline" onClick={handleResend} disabled={saving}>Resend PO</button>