fix: crash bugs in TeamInvoices + faster load
Bug fixes: - TeamInvoices: add useAuth import/currentUser (invoice-create crash) - TeamInvoices: setChartYear -> setExportYear (year-dropdown crash) - TeamDashboard: drop redundant setState-in-effect (cascading renders) - Companies: delete dead _UnusedClientCompanies (illegal hook calls) - annotate intentional empty PDF-fallback catches Load speed: - preconnect/dns-prefetch to Supabase origin - lazy-load heic-to in Converters: page chunk 2737KB -> 9KB - split recharts into its own 'charts' vendor chunk Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1,169 +1,401 @@
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { useState, useEffect, useMemo, useRef } from 'react';
|
||||
import { AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from 'recharts';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useNavigate } 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 } from '../../lib/invoice';
|
||||
import { generateInvoicePDF, generateReceiptPDF } from '../../lib/invoice';
|
||||
import { useAuth } from '../../context/AuthContext';
|
||||
import { useSortable } from '../../hooks/useSortable';
|
||||
import { popupOverlayStyle, popupSurfaceStyle } from '../../lib/popupStyles';
|
||||
|
||||
const statusColor = { draft: 'not_started', sent: 'in_progress', paid: 'client_approved' };
|
||||
const invoiceStatusLabel = (status) => {
|
||||
if (status === 'sent') return 'Invoiced';
|
||||
return status ? status.charAt(0).toUpperCase() + status.slice(1) : '—';
|
||||
};
|
||||
|
||||
function ClientFinanceStatCard({ label, value, sub, iconBg, iconColor, iconPath }) {
|
||||
return (
|
||||
<div style={{ background: 'var(--card-bg)', border: '1px solid var(--border)', borderRadius: 8, padding: '18px 21px', backdropFilter: 'blur(12px)', WebkitBackdropFilter: 'blur(12px)', minHeight: 120 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 10 }}>
|
||||
<div style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase', marginBottom: 5 }}>{label}</div>
|
||||
<div style={{ width: 27, height: 27, borderRadius: '50%', background: iconBg, display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke={iconColor} strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" dangerouslySetInnerHTML={{ __html: iconPath }} />
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ fontSize: 30, fontWeight: 400, color: 'var(--text-primary)', letterSpacing: -0.5, lineHeight: 1.1 }}>{value}</div>
|
||||
{sub ? <div style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: 5 }}>{sub}</div> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ClientFinanceTooltip({ active, payload, label, year }) {
|
||||
if (!active || !payload?.length) return null;
|
||||
return (
|
||||
<div style={{ background: 'var(--card-bg)', border: '1px solid var(--border)', borderRadius: 4, padding: '10px 14px', fontSize: 12 }}>
|
||||
<div style={{ fontWeight: 600, marginBottom: 6, color: 'var(--text-primary)' }}>{label} {year}</div>
|
||||
{payload.map(p => (
|
||||
<div key={p.dataKey} style={{ color: p.color, marginBottom: 2 }}>{p.name}: <strong>${Number(p.value).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}</strong></div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ClientInvoiceModal({ invoice, onClose }) {
|
||||
const navigate = useNavigate();
|
||||
const [downloading, setDownloading] = useState('');
|
||||
if (!invoice) return null;
|
||||
|
||||
const items = invoice.items || [];
|
||||
const company = invoice.company || null;
|
||||
const isOverdue = invoice.status !== 'paid' && invoice.due_date && new Date(invoice.due_date) < new Date();
|
||||
|
||||
const handleDownloadInvoice = async () => {
|
||||
if (downloading) return;
|
||||
setDownloading('invoice');
|
||||
try {
|
||||
await generateInvoicePDF(invoice, company, items);
|
||||
} finally {
|
||||
setDownloading('');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownloadReceipt = async () => {
|
||||
if (downloading) return;
|
||||
setDownloading('receipt');
|
||||
try {
|
||||
await generateReceiptPDF(invoice, company, items);
|
||||
} finally {
|
||||
setDownloading('');
|
||||
}
|
||||
};
|
||||
|
||||
const openPay = () => {
|
||||
onClose?.();
|
||||
navigate(`/pay/${encodeURIComponent(invoice.invoice_number)}`);
|
||||
};
|
||||
|
||||
const sortedItems = [...items].sort((a, b) => {
|
||||
const aOrder = Number(a.sort_order ?? 0);
|
||||
const bOrder = Number(b.sort_order ?? 0);
|
||||
return aOrder - bOrder;
|
||||
});
|
||||
|
||||
return (
|
||||
<div style={popupOverlayStyle} onClick={onClose}>
|
||||
<div style={{ ...popupSurfaceStyle, background: 'var(--card-bg)', width: '80vw', maxWidth: 1180, maxHeight: '86vh', display: 'flex', flexDirection: 'column', gap: 0 }} onClick={e => e.stopPropagation()}>
|
||||
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 16, paddingBottom: 18, marginBottom: 18, borderBottom: '1px solid var(--border)', flexShrink: 0 }}>
|
||||
<div>
|
||||
<div style={{ fontSize: 28, fontWeight: 500, lineHeight: 1.2 }}>{invoice.invoice_number}</div>
|
||||
<div style={{ fontSize: 13, color: 'var(--text-secondary)', marginTop: 4 }}>
|
||||
{company?.id ? (
|
||||
<Link to={`/company/${company.id}`} className="dashboard-inline-link" onClick={onClose}>
|
||||
{company.name}
|
||||
</Link>
|
||||
) : (
|
||||
company?.name || invoice.bill_to || 'Invoice'
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="modal-action-row" style={{ alignItems: 'center' }}>
|
||||
<StatusBadge
|
||||
status={statusColor[invoice.status] || 'not_started'}
|
||||
label={`${invoiceStatusLabel(invoice.status)}${isOverdue ? ' · Overdue' : ''}`}
|
||||
/>
|
||||
{invoice.status === 'sent' && (
|
||||
<button className="btn btn-outline" onClick={openPay}>Pay Invoice</button>
|
||||
)}
|
||||
<LoadingButton className="btn btn-outline" loading={downloading === 'invoice'} disabled={Boolean(downloading)} loadingText="Generating…" onClick={handleDownloadInvoice}>
|
||||
Download Invoice
|
||||
</LoadingButton>
|
||||
{invoice.status === 'paid' && (
|
||||
<LoadingButton className="btn btn-outline" loading={downloading === 'receipt'} disabled={Boolean(downloading)} loadingText="Generating…" onClick={handleDownloadReceipt}>
|
||||
Download Receipt
|
||||
</LoadingButton>
|
||||
)}
|
||||
<button className="btn btn-outline" onClick={onClose}>Close</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'minmax(0, 1fr) minmax(0, 1fr)', gap: 24, marginBottom: 24, flexShrink: 0 }}>
|
||||
<div className="card" style={{ margin: 0 }}>
|
||||
<div className="card-title">Bill To</div>
|
||||
<div style={{ fontSize: 15, fontWeight: 400 }}>{invoice.bill_to || company?.name || '—'}</div>
|
||||
<div style={{ fontSize: 13, color: 'var(--text-muted)', marginTop: 4 }}>
|
||||
{company?.id ? (
|
||||
<Link to={`/company/${company.id}`} className="dashboard-inline-link" onClick={onClose}>
|
||||
{company.name}
|
||||
</Link>
|
||||
) : (
|
||||
company?.name || 'Fourge Branding Client Invoice'
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="card" style={{ margin: 0 }}>
|
||||
<div className="card-title">Invoice Details</div>
|
||||
<div className="detail-grid" style={{ marginBottom: 0 }}>
|
||||
<div className="detail-item"><label>Invoice Date</label><p>{invoice.invoice_date ? new Date(invoice.invoice_date).toLocaleDateString() : '—'}</p></div>
|
||||
<div className="detail-item"><label>Due Date</label><p style={{ color: isOverdue ? 'var(--danger)' : 'inherit' }}>{invoice.due_date ? new Date(invoice.due_date).toLocaleDateString() : '—'}</p></div>
|
||||
<div className="detail-item"><label>Terms</label><p>Net 30</p></div>
|
||||
<div className="detail-item"><label>Status</label><p><StatusBadge status={statusColor[invoice.status] || 'not_started'} label={invoiceStatusLabel(invoice.status)} /></p></div>
|
||||
<div className="detail-item"><label>Total</label><p style={{ fontSize: 18, fontWeight: 400, color: 'var(--accent)' }}>${Number(invoice.total || 0).toFixed(2)}</p></div>
|
||||
{invoice.paid_at && <div className="detail-item"><label>Paid On</label><p style={{ color: 'var(--success, #16a34a)', fontWeight: 400 }}>{new Date(invoice.paid_at).toLocaleDateString()}</p></div>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card" style={{ margin: 0, display: 'flex', flexDirection: 'column', minHeight: 0 }}>
|
||||
<div className="card-title">Line Items</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: '12%' }} />
|
||||
<col style={{ width: '46%' }} />
|
||||
<col style={{ width: '14%' }} />
|
||||
<col style={{ width: '14%' }} />
|
||||
<col style={{ width: '14%' }} />
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Type</th>
|
||||
<th>Description</th>
|
||||
<th style={{ textAlign: 'center' }}>Qty</th>
|
||||
<th style={{ textAlign: 'right' }}>Unit Price</th>
|
||||
<th style={{ textAlign: 'right' }}>Total</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sortedItems.map(item => (
|
||||
<tr key={item.id}>
|
||||
<td style={{ padding: '5px 0' }}>
|
||||
<span className={`badge ${item.submission_id ? 'badge-client_revision' : 'badge-initial'}`}>
|
||||
{item.submission_id ? 'Revision' : 'New'}
|
||||
</span>
|
||||
</td>
|
||||
<td style={{ padding: '5px 0', fontSize: 13 }}>{item.description}</td>
|
||||
<td style={{ padding: '5px 0', fontSize: 13, textAlign: 'center' }}>{item.quantity}</td>
|
||||
<td style={{ padding: '5px 0', fontSize: 13, textAlign: 'right' }}>${Number(item.unit_price || 0).toFixed(2)}</td>
|
||||
<td style={{ padding: '5px 0', fontSize: 13, textAlign: 'right', fontWeight: 400 }}>${(Number(item.quantity || 0) * Number(item.unit_price || 0)).toFixed(2)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', paddingTop: 16, marginTop: 12, borderTop: '1px solid var(--border)', flexShrink: 0 }}>
|
||||
<div style={{ textAlign: 'right' }}>
|
||||
<div style={{ fontSize: 11, fontWeight: 400, textTransform: 'uppercase', letterSpacing: '0.5px', color: 'var(--text-muted)', marginBottom: 4 }}>Total</div>
|
||||
<div style={{ fontSize: 24, fontWeight: 400, color: 'var(--accent)' }}>${Number(invoice.total || 0).toFixed(2)}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{invoice.notes ? (
|
||||
<div className="card" style={{ margin: '24px 0 0 0', flexShrink: 0 }}>
|
||||
<div className="card-title">Notes</div>
|
||||
<p style={{ fontSize: 14, color: 'var(--text-secondary)', lineHeight: 1.6, whiteSpace: 'pre-wrap' }}>{invoice.notes}</p>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function MyInvoices() {
|
||||
const { currentUser } = useAuth();
|
||||
const companies = (currentUser?.companies?.length ? currentUser.companies : (currentUser?.company ? [currentUser.company] : [])).slice().sort((a, b) => a.name.localeCompare(b.name));
|
||||
const [activeCompanyId, setActiveCompanyId] = useState(companies[0]?.id || null);
|
||||
const companyIds = (currentUser?.companies?.length ? currentUser.companies : (currentUser?.company ? [currentUser.company] : []))
|
||||
.map(company => company?.id)
|
||||
.filter(Boolean);
|
||||
|
||||
const [invoices, setInvoices] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [generatingInvoiceId, setGeneratingInvoiceId] = useState('');
|
||||
const { sortKey, sortDir, toggle, sort } = useSortable('invoice_date');
|
||||
const [activeInvoice, setActiveInvoice] = useState(null);
|
||||
const [yearFilter, setYearFilter] = useState(String(new Date().getFullYear()));
|
||||
const [companyFilter, setCompanyFilter] = useState('all');
|
||||
const [filterMenuOpen, setFilterMenuOpen] = useState(false);
|
||||
const filterMenuRef = useRef(null);
|
||||
const { sortKey, sortDir, toggle, sort } = useSortable('invoice_date', 'desc');
|
||||
|
||||
useEffect(() => {
|
||||
async function load() {
|
||||
if (companyIds.length === 0) {
|
||||
setInvoices([]);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
const { data } = await supabase
|
||||
.from('invoices')
|
||||
.select('*, company:companies(name), items:invoice_items(*)')
|
||||
.select('*, company:companies(id, name), items:invoice_items(*)')
|
||||
.in('company_id', companyIds)
|
||||
.order('created_at', { ascending: false });
|
||||
setInvoices((data || []).filter(inv => inv.status !== 'draft'));
|
||||
setLoading(false);
|
||||
}
|
||||
load();
|
||||
}, []);
|
||||
}, [currentUser?.id]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const handleDownload = async (invoice) => {
|
||||
if (generatingInvoiceId) return;
|
||||
setGeneratingInvoiceId(invoice.id);
|
||||
try {
|
||||
await generateInvoicePDF(invoice, invoice.company, invoice.items || []);
|
||||
} finally {
|
||||
setGeneratingInvoiceId('');
|
||||
useEffect(() => {
|
||||
if (!filterMenuOpen) return;
|
||||
function onDocClick(e) {
|
||||
if (filterMenuRef.current && !filterMenuRef.current.contains(e.target)) setFilterMenuOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
const visible = companies.length > 1 && activeCompanyId
|
||||
? invoices.filter(inv => inv.company_id === activeCompanyId)
|
||||
: invoices;
|
||||
|
||||
const outstanding = visible.filter(i => i.status === 'sent').reduce((s, i) => s + Number(i.total), 0);
|
||||
const paid = visible.filter(i => i.status === 'paid').reduce((s, i) => s + Number(i.total), 0);
|
||||
const overdueCount = visible.filter(inv => inv.status !== 'paid' && new Date(inv.due_date) < new Date()).length;
|
||||
document.addEventListener('mousedown', onDocClick);
|
||||
return () => document.removeEventListener('mousedown', onDocClick);
|
||||
}, [filterMenuOpen]);
|
||||
|
||||
const MONTHS = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
|
||||
const chartYear = new Date().getFullYear();
|
||||
const invoiceYears = [...new Set(invoices.map(i => i.invoice_date?.slice(0, 4)).filter(Boolean))].sort((a, b) => b.localeCompare(a));
|
||||
const availableCompanyOptions = (currentUser?.companies?.length ? currentUser.companies : (currentUser?.company ? [currentUser.company] : []))
|
||||
.filter(company => company?.id && invoices.some(inv => inv.company_id === company.id))
|
||||
.sort((a, b) => (a.name || '').localeCompare(b.name || ''));
|
||||
const chartYear = Number(yearFilter) || new Date().getFullYear();
|
||||
const filteredInvoices = invoices.filter(inv => {
|
||||
if (yearFilter !== 'all' && inv.invoice_date?.slice(0, 4) !== yearFilter) return false;
|
||||
if (companyFilter !== 'all' && inv.company_id !== companyFilter) return false;
|
||||
return true;
|
||||
});
|
||||
const chartData = useMemo(() => MONTHS.map((month, mi) => {
|
||||
const paidAmt = visible.filter(i => i.status === 'paid' && new Date(i.invoice_date).getFullYear() === chartYear && new Date(i.invoice_date).getMonth() === mi).reduce((s, i) => s + Number(i.total || 0), 0);
|
||||
const outAmt = visible.filter(i => i.status === 'sent' && new Date(i.invoice_date).getFullYear() === chartYear && new Date(i.invoice_date).getMonth() === mi).reduce((s, i) => s + Number(i.total || 0), 0);
|
||||
const paidAmt = filteredInvoices.filter(i => i.status === 'paid' && new Date(i.invoice_date).getFullYear() === chartYear && new Date(i.invoice_date).getMonth() === mi).reduce((s, i) => s + Number(i.total || 0), 0);
|
||||
const outAmt = filteredInvoices.filter(i => i.status === 'sent' && new Date(i.invoice_date).getFullYear() === chartYear && new Date(i.invoice_date).getMonth() === mi).reduce((s, i) => s + Number(i.total || 0), 0);
|
||||
return { month, Paid: +paidAmt.toFixed(2), Outstanding: +outAmt.toFixed(2) };
|
||||
}), [visible, chartYear]);
|
||||
const hasChartData = chartData.some(d => d.Paid > 0 || d.Outstanding > 0);
|
||||
}), [filteredInvoices, chartYear]);
|
||||
|
||||
const sorted = sort(visible, (inv, key) => {
|
||||
const outstanding = filteredInvoices.filter(i => i.status === 'sent').reduce((s, i) => s + Number(i.total || 0), 0);
|
||||
const paid = filteredInvoices.filter(i => i.status === 'paid').reduce((s, i) => s + Number(i.total || 0), 0);
|
||||
const overdueCount = filteredInvoices.filter(inv => inv.status !== 'paid' && inv.due_date && new Date(inv.due_date) < new Date()).length;
|
||||
const paidCount = filteredInvoices.filter(i => i.status === 'paid').length;
|
||||
|
||||
const sortedInvoices = sort(filteredInvoices, (inv, key) => {
|
||||
if (key === 'invoice_date' || key === 'due_date') return new Date(inv[key] || 0).getTime();
|
||||
if (key === 'total') return Number(inv.total || 0);
|
||||
if (key === 'company') return inv.company?.name || '';
|
||||
return inv[key] || '';
|
||||
});
|
||||
|
||||
const th = { sortKey, sortDir, onSort: toggle };
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<div className="page-title">Invoices</div>
|
||||
<div className="page-subtitle">{visible.length} invoice{visible.length !== 1 ? 's' : ''}</div>
|
||||
<Layout loading={loading}>
|
||||
<div style={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '3fr 2fr', gap: 24, flexShrink: 0 }}>
|
||||
<div style={{ background: 'var(--card-bg)', border: '1px solid var(--border)', borderRadius: 8, padding: '18px 21px', backdropFilter: 'blur(12px)', WebkitBackdropFilter: 'blur(12px)' }}>
|
||||
<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' }}>Invoice Overview</span>
|
||||
<span style={{ fontSize: 11, color: 'var(--text-muted)' }}>{chartYear}</span>
|
||||
</div>
|
||||
<ResponsiveContainer width="100%" height={160}>
|
||||
<AreaChart data={chartData} margin={{ top: 4, right: 8, left: 0, bottom: 0 }}>
|
||||
<defs>
|
||||
<linearGradient id="clientPaidGrad" 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="clientOutGrad" 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={<ClientFinanceTooltip year={chartYear} />} />
|
||||
<Legend wrapperStyle={{ fontSize: 11, paddingTop: 8 }} />
|
||||
<Area type="monotone" dataKey="Paid" stroke="#4ade80" strokeWidth={2} fill="url(#clientPaidGrad)" dot={false} activeDot={{ r: 4 }} />
|
||||
<Area type="monotone" dataKey="Outstanding" stroke="#60a5fa" strokeWidth={2} fill="url(#clientOutGrad)" dot={false} activeDot={{ r: 4 }} />
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 24, alignContent: 'start' }}>
|
||||
<ClientFinanceStatCard label="Paid" value={`$${paid.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`} sub={`${paidCount} settled`} iconBg="rgba(74,222,128,0.15)" iconColor="#4ade80" iconPath={'<polyline points="4,12 9,17 20,6"/>'} />
|
||||
<ClientFinanceStatCard label="Outstanding" value={`$${outstanding.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`} sub="ready to pay" iconBg="rgba(96,165,250,0.15)" iconColor="#60a5fa" iconPath={'<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"/>'} />
|
||||
<ClientFinanceStatCard label="Overdue" value={overdueCount} sub={overdueCount === 1 ? 'needs attention' : 'need attention'} iconBg="rgba(239,68,68,0.15)" iconColor="#ef4444" iconPath={'<path d="M12 9v4"/><path d="M12 16h.01"/><path d="M10.29 3.86l-7.5 13A1 1 0 0 0 3.66 18h16.68a1 1 0 0 0 .87-1.5l-7.5-13a1 1 0 0 0-1.74 0z"/>'} />
|
||||
<ClientFinanceStatCard label="Invoices" value={invoices.length} sub={`${companyIds.length} companies`} iconBg="rgba(245,165,35,0.15)" iconColor="#F5A523" iconPath={'<rect x="3" y="4" width="18" height="16" rx="2"/><line x1="7" y1="9" x2="17" y2="9"/><line x1="7" y1="13" x2="17" y2="13"/>'} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 4, marginTop: 24, marginBottom: 10, flexShrink: 0, minHeight: 'var(--btn-height)' }}>
|
||||
<div style={{ marginLeft: 'auto', position: 'relative', display: 'flex', alignItems: 'center' }} ref={filterMenuRef}>
|
||||
<button className="btn btn-outline" onClick={() => setFilterMenuOpen(o => !o)} style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }} title="Filter invoices">
|
||||
<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>
|
||||
{filterMenuOpen && (
|
||||
<div className="site-header-avatar-menu" style={{ position: 'absolute', top: 'calc(100% + 4px)', right: 0, zIndex: 220, minWidth: 160 }}>
|
||||
<div style={{ fontSize: 10, fontWeight: 500, textTransform: 'uppercase', letterSpacing: 0.8, color: 'var(--text-muted)', padding: '6px 14px 4px' }}>Year</div>
|
||||
<button onClick={() => { setYearFilter('all'); setFilterMenuOpen(false); }} className="site-header-avatar-item" style={{ background: yearFilter === 'all' ? 'rgba(245,165,35,0.08)' : 'transparent', color: yearFilter === 'all' ? 'var(--accent)' : 'var(--text-primary)' }}>All Years</button>
|
||||
{invoiceYears.map(year => (
|
||||
<button key={year} onClick={() => { setYearFilter(year); setFilterMenuOpen(false); }} className="site-header-avatar-item" style={{ background: yearFilter === year ? 'rgba(245,165,35,0.08)' : 'transparent', color: yearFilter === year ? 'var(--accent)' : 'var(--text-primary)' }}>{year}</button>
|
||||
))}
|
||||
{availableCompanyOptions.length > 1 && (
|
||||
<>
|
||||
<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={() => { setCompanyFilter('all'); setFilterMenuOpen(false); }} className="site-header-avatar-item" style={{ background: companyFilter === 'all' ? 'rgba(245,165,35,0.08)' : 'transparent', color: companyFilter === 'all' ? 'var(--accent)' : 'var(--text-primary)' }}>All Companies</button>
|
||||
{availableCompanyOptions.map(company => (
|
||||
<button key={company.id} onClick={() => { setCompanyFilter(company.id); setFilterMenuOpen(false); }} className="site-header-avatar-item" style={{ background: companyFilter === company.id ? 'rgba(245,165,35,0.08)' : 'transparent', color: companyFilter === company.id ? 'var(--accent)' : 'var(--text-primary)' }}>{company.name}</button>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', flex: 1, minHeight: 0 }}>
|
||||
<div style={{ background: 'var(--card-bg)', border: '1px solid var(--border)', borderRadius: 8, padding: '18px 21px', backdropFilter: 'blur(12px)', WebkitBackdropFilter: 'blur(12px)', display: 'flex', flexDirection: 'column', minHeight: 0 }}>
|
||||
{filteredInvoices.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 table-no-row-hover" style={{ width: '100%', borderCollapse: 'collapse', tableLayout: 'fixed' }}>
|
||||
<colgroup>
|
||||
<col style={{ width: '15%' }} />
|
||||
<col style={{ width: '20%' }} />
|
||||
<col style={{ width: '10%' }} />
|
||||
<col style={{ width: '10%' }} />
|
||||
<col style={{ width: '10%' }} />
|
||||
<col style={{ width: '15%' }} />
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr>
|
||||
<SortTh col="invoice_number" sortKey={sortKey} sortDir={sortDir} onSort={toggle}>Invoice #</SortTh>
|
||||
<SortTh col="company" sortKey={sortKey} sortDir={sortDir} onSort={toggle}>Company</SortTh>
|
||||
<SortTh col="invoice_date" sortKey={sortKey} sortDir={sortDir} onSort={toggle}>Issued</SortTh>
|
||||
<SortTh col="due_date" sortKey={sortKey} sortDir={sortDir} onSort={toggle}>Due</SortTh>
|
||||
<SortTh col="status" sortKey={sortKey} sortDir={sortDir} onSort={toggle}>Status</SortTh>
|
||||
<SortTh col="total" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ textAlign: 'right' }}>Total</SortTh>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sortedInvoices.map(inv => {
|
||||
const isOverdue = inv.status !== 'paid' && inv.due_date && new Date(inv.due_date) < new Date();
|
||||
return (
|
||||
<tr key={inv.id}>
|
||||
<td style={{ padding: '5px 0' }}>
|
||||
<button type="button" className="table-link" onClick={() => setActiveInvoice(inv)}>{inv.invoice_number}</button>
|
||||
</td>
|
||||
<td style={{ padding: '5px 0', fontSize: 13 }}>
|
||||
{inv.company?.id ? (
|
||||
<Link to={`/company/${inv.company.id}`} className="table-link">
|
||||
{inv.company?.name || inv.bill_to || '—'}
|
||||
</Link>
|
||||
) : (
|
||||
inv.company?.name || inv.bill_to || '—'
|
||||
)}
|
||||
</td>
|
||||
<td style={{ padding: '5px 0', fontSize: 12, color: 'var(--text-muted)' }}>{inv.invoice_date ? new Date(inv.invoice_date).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) : '—'}</td>
|
||||
<td style={{ padding: '5px 0', fontSize: 12, color: isOverdue ? 'var(--danger)' : 'var(--text-muted)' }}>{inv.due_date ? new Date(inv.due_date).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) : '—'}</td>
|
||||
<td style={{ padding: '5px 0' }}>
|
||||
<StatusBadge status={statusColor[inv.status] || 'not_started'} label={invoiceStatusLabel(inv.status)} />
|
||||
</td>
|
||||
<td style={{ padding: '5px 0', textAlign: 'right', fontSize: 13, color: inv.status === 'paid' ? '#4ade80' : inv.status === 'sent' ? '#F5A523' : 'var(--text-primary)', fontWeight: 400 }}>${Number(inv.total || 0).toFixed(2)}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
{hasChartData && (
|
||||
<div style={{ background: 'var(--card-bg)', border: '1px solid var(--border)', borderRadius: 4, padding: '16px 16px 8px', marginBottom: 18 }}>
|
||||
<ResponsiveContainer width="100%" height={180}>
|
||||
<AreaChart data={chartData} margin={{ top: 4, right: 8, left: 0, bottom: 0 }}>
|
||||
<defs>
|
||||
<linearGradient id="gradPaidC" 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="gradOutC" 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 formatter={(v) => [`$${Number(v).toLocaleString('en-US', { minimumFractionDigits: 2 })}`, undefined]} contentStyle={{ background: 'var(--card-bg)', border: '1px solid var(--border)', borderRadius: 4, fontSize: 12 }} />
|
||||
<Legend wrapperStyle={{ fontSize: 11, paddingTop: 8 }} />
|
||||
<Area type="monotone" dataKey="Paid" stroke="#4ade80" strokeWidth={2} fill="url(#gradPaidC)" dot={false} activeDot={{ r: 4 }} />
|
||||
<Area type="monotone" dataKey="Outstanding" stroke="#60a5fa" strokeWidth={2} fill="url(#gradOutC)" dot={false} activeDot={{ r: 4 }} />
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{companies.length > 1 && (
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<select className="filter-select" value={activeCompanyId || ''} onChange={e => setActiveCompanyId(e.target.value)}>
|
||||
{companies.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<p style={{ color: 'var(--text-muted)' }}>Loading...</p>
|
||||
) : visible.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<h3>No invoices yet</h3>
|
||||
<p>Your invoices will appear here once they are sent.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="card">
|
||||
<div className="table-wrapper">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<SortTh col="invoice_number" {...th}>Invoice #</SortTh>
|
||||
<SortTh col="invoice_date" {...th}>Issued</SortTh>
|
||||
<SortTh col="due_date" {...th}>Due</SortTh>
|
||||
<SortTh col="status" {...th}>Status</SortTh>
|
||||
<SortTh col="total" {...th}>Total</SortTh>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sorted.map(inv => {
|
||||
const isOverdue = inv.status !== 'paid' && new Date(inv.due_date) < new Date();
|
||||
return (
|
||||
<tr key={inv.id}>
|
||||
<td style={{ fontWeight: 400 }}>{inv.invoice_number}</td>
|
||||
<td style={{ color: 'var(--text-muted)' }}>{new Date(inv.invoice_date).toLocaleDateString()}</td>
|
||||
<td style={{ color: isOverdue ? 'var(--danger)' : 'var(--text-muted)' }}>
|
||||
{inv.due_date ? new Date(inv.due_date).toLocaleDateString() : '—'}
|
||||
{isOverdue && <span style={{ marginLeft: 6, fontSize: 11 }}>Overdue</span>}
|
||||
</td>
|
||||
<td>
|
||||
<StatusBadge
|
||||
status={statusColor[inv.status] || 'not_started'}
|
||||
label={inv.status ? inv.status.charAt(0).toUpperCase() + inv.status.slice(1) : '—'}
|
||||
/>
|
||||
</td>
|
||||
<td style={{ fontWeight: 400, color: 'var(--accent)' }}>${Number(inv.total).toFixed(2)}</td>
|
||||
<td>
|
||||
<LoadingButton
|
||||
className="btn btn-outline btn-sm"
|
||||
loading={generatingInvoiceId === inv.id}
|
||||
disabled={Boolean(generatingInvoiceId)}
|
||||
loadingText="Generating..."
|
||||
onClick={() => handleDownload(inv)}
|
||||
>
|
||||
Download PDF
|
||||
</LoadingButton>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<ClientInvoiceModal invoice={activeInvoice} onClose={() => setActiveInvoice(null)} />
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user