feat: unify all invoice popups to shared InvoiceDetailPopup shell

All invoice popup modals (team invoice, sub-invoice team, client invoice,
external sub-invoice) now share InvoiceDetailPopup + SubcontractorInvoiceDetailView.
Same overlay, header, scroll body, footer action row — just different data per role.

- New InvoiceDetailPopup component (overlay shell, header, scrollable body, footer)
- SubcontractorInvoiceDetailView: respects item._inferredType if pre-set
- ClientInvoiceModal: uses shared components, adds sortable headers
- TeamInvoiceDetailPanel embedded: uses shared components, keeps edit-dates/email/company
- TeamInvoices sub-invoice popup: uses shared components, cleans descriptions
- ExternalMyInvoices popup: uses shared components, fixes detailSort vars (was unused)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Krao Hasanee
2026-06-08 13:46:08 -04:00
parent 04e0911e9f
commit 6e4e99c33c
7 changed files with 347 additions and 379 deletions
+57 -118
View File
@@ -10,7 +10,8 @@ import { supabase } from '../../lib/supabase';
import { generateInvoicePDF, generateReceiptPDF } from '../../lib/invoice';
import { useAuth } from '../../context/AuthContext';
import { useSortable } from '../../hooks/useSortable';
import { popupOverlayStyle, popupSurfaceStyle } from '../../lib/popupStyles';
import InvoiceDetailPopup from '../../components/InvoiceDetailPopup';
import SubcontractorInvoiceDetailView from '../../components/SubcontractorInvoiceDetailView';
const statusColor = { draft: 'not_started', sent: 'in_progress', paid: 'client_approved' };
const invoiceStatusLabel = (status) => {
@@ -48,11 +49,13 @@ function ClientFinanceTooltip({ active, payload, label, year }) {
function ClientInvoiceModal({ invoice, onClose }) {
const navigate = useNavigate();
const [downloading, setDownloading] = useState('');
const { sortKey, sortDir, toggle, sort } = useSortable('description');
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 total = Number(invoice.total || 0);
const handleDownloadInvoice = async () => {
if (downloading) return;
@@ -79,128 +82,64 @@ function ClientInvoiceModal({ invoice, 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;
const baseItems = [...items].sort((a, b) => (a.sort_order ?? 0) - (b.sort_order ?? 0));
const tableItems = sort(baseItems.map(item => ({
...item,
_inferredType: item.submission_id
? { label: 'Revision', badgeClass: 'badge-client_revision' }
: { label: 'New', badgeClass: 'badge-initial' },
})), (item, key) => {
if (key === 'type') return item._inferredType?.label || '';
if (key === 'description') return item.description || '';
if (key === 'quantity') return Number(item.quantity || 0);
if (key === 'unit_price') return Number(item.unit_price || 0);
if (key === 'line_total') return Number(item.quantity || 0) * Number(item.unit_price || 0);
return '';
});
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'
)}
<InvoiceDetailPopup
title={invoice.invoice_number}
subtitle={company?.name || invoice.bill_to}
headerRight={<StatusBadge status={statusColor[invoice.status] || 'not_started'} label={`${invoiceStatusLabel(invoice.status)}${isOverdue ? ' · Overdue' : ''}`} />}
onClose={onClose}
blockClose={Boolean(downloading)}
footerActions={<>
{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>
</>}
>
<SubcontractorInvoiceDetailView
leftCardTitle="Bill To"
leftCardBody={<>
<div style={{ fontSize: 15, fontWeight: 400 }}>{invoice.bill_to || company?.name || '—'}</div>
{company?.id && (
<div style={{ marginTop: 12 }}>
<Link to={`/company/${company.id}`} className="btn btn-outline btn-sm" onClick={onClose}>View Company</Link>
</div>
)}
</>}
rightCardTitle="Invoice Details"
rightCardBody={
<div className="invoice-detail-meta-grid">
<div className="invoice-detail-meta-item"><label>Invoice Date</label><p>{invoice.invoice_date ? new Date(invoice.invoice_date).toLocaleDateString() : '—'}</p></div>
<div className="invoice-detail-meta-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="invoice-detail-meta-item"><label>Terms</label><p>Net 30</p></div>
<div className="invoice-detail-meta-item"><label>Status</label><p><StatusBadge status={statusColor[invoice.status] || 'not_started'} label={invoiceStatusLabel(invoice.status)} /></p></div>
<div className="invoice-detail-meta-item"><label>Total</label><p style={{ fontSize: 18, fontWeight: 400, color: 'var(--accent)' }}>${total.toFixed(2)}</p></div>
{invoice.paid_at && <div className="invoice-detail-meta-item"><label>Paid On</label><p style={{ color: 'var(--success, #16a34a)' }}>{new Date(invoice.paid_at).toLocaleDateString()}</p></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>
}
sortKey={sortKey}
sortDir={sortDir}
onSort={toggle}
items={tableItems}
notes={invoice.notes}
total={total}
/>
</InvoiceDetailPopup>
);
}