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
+5 -1
View File
@@ -10,7 +10,11 @@
"Bash(sed -n '147p' src/pages/Profile.jsx)", "Bash(sed -n '147p' src/pages/Profile.jsx)",
"Bash(awk '{print $5, $9}')", "Bash(awk '{print $5, $9}')",
"Bash(git add *)", "Bash(git add *)",
"Bash(git commit -q -m ' *)" "Bash(git commit -q -m ' *)",
"Bash(git push *)",
"Bash(vercel --prod --yes)",
"Bash(sed -n '1,15p' src/pages/external/ExternalMyInvoices.jsx)",
"Bash(sed -n '1,15p' src/pages/client/ClientMyInvoices.jsx)"
] ]
} }
} }
+46
View File
@@ -0,0 +1,46 @@
import { popupOverlayStyle, popupSurfaceStyle } from '../lib/popupStyles';
import PageLoader from './PageLoader';
export default function InvoiceDetailPopup({
title,
subtitle,
headerRight,
onClose,
blockClose = false,
footerActions,
loading = false,
children,
}) {
return (
<div style={popupOverlayStyle} onClick={() => { if (!blockClose) onClose?.(); }}>
<div
style={{ ...popupSurfaceStyle, background: 'var(--card-bg)', width: '80vw', height: '80vh', 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: 18, fontWeight: 500, color: 'var(--text-primary)', letterSpacing: 0.2, lineHeight: 1.1 }}>{title}</div>
{subtitle && <div style={{ marginTop: 6, fontSize: 13, color: 'var(--text-secondary)' }}>{subtitle}</div>}
</div>
{headerRight && <div style={{ flexShrink: 0 }}>{headerRight}</div>}
</div>
{loading ? (
<div style={{ flex: 1, minHeight: 0, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<PageLoader />
</div>
) : (
<div className="scrollbar-thin-theme table-scroll-hidden" style={{ flex: 1, minHeight: 0, overflowY: 'auto' }}>
{children}
</div>
)}
{footerActions && (
<div className="modal-action-row" style={{ paddingTop: 18, borderTop: '1px solid var(--border)', flexShrink: 0 }}>
{footerActions}
</div>
)}
</div>
</div>
);
}
@@ -73,7 +73,7 @@ export default function SubcontractorInvoiceDetailView({
</thead> </thead>
<tbody> <tbody>
{items.map((item) => { {items.map((item) => {
const itemType = inferItemType(item); const itemType = item._inferredType || inferItemType(item);
return ( return (
<tr key={item.id}> <tr key={item.id}>
<td style={{ padding: '5px 0' }}> <td style={{ padding: '5px 0' }}>
+55 -116
View File
@@ -10,7 +10,8 @@ import { supabase } from '../../lib/supabase';
import { generateInvoicePDF, generateReceiptPDF } from '../../lib/invoice'; import { generateInvoicePDF, generateReceiptPDF } from '../../lib/invoice';
import { useAuth } from '../../context/AuthContext'; import { useAuth } from '../../context/AuthContext';
import { useSortable } from '../../hooks/useSortable'; 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 statusColor = { draft: 'not_started', sent: 'in_progress', paid: 'client_approved' };
const invoiceStatusLabel = (status) => { const invoiceStatusLabel = (status) => {
@@ -48,11 +49,13 @@ function ClientFinanceTooltip({ active, payload, label, year }) {
function ClientInvoiceModal({ invoice, onClose }) { function ClientInvoiceModal({ invoice, onClose }) {
const navigate = useNavigate(); const navigate = useNavigate();
const [downloading, setDownloading] = useState(''); const [downloading, setDownloading] = useState('');
const { sortKey, sortDir, toggle, sort } = useSortable('description');
if (!invoice) return null; if (!invoice) return null;
const items = invoice.items || []; const items = invoice.items || [];
const company = invoice.company || null; const company = invoice.company || null;
const isOverdue = invoice.status !== 'paid' && invoice.due_date && new Date(invoice.due_date) < new Date(); const isOverdue = invoice.status !== 'paid' && invoice.due_date && new Date(invoice.due_date) < new Date();
const total = Number(invoice.total || 0);
const handleDownloadInvoice = async () => { const handleDownloadInvoice = async () => {
if (downloading) return; if (downloading) return;
@@ -79,128 +82,64 @@ function ClientInvoiceModal({ invoice, onClose }) {
navigate(`/pay/${encodeURIComponent(invoice.invoice_number)}`); navigate(`/pay/${encodeURIComponent(invoice.invoice_number)}`);
}; };
const sortedItems = [...items].sort((a, b) => { const baseItems = [...items].sort((a, b) => (a.sort_order ?? 0) - (b.sort_order ?? 0));
const aOrder = Number(a.sort_order ?? 0); const tableItems = sort(baseItems.map(item => ({
const bOrder = Number(b.sort_order ?? 0); ...item,
return aOrder - bOrder; _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 ( return (
<div style={popupOverlayStyle} onClick={onClose}> <InvoiceDetailPopup
<div style={{ ...popupSurfaceStyle, background: 'var(--card-bg)', width: '80vw', maxWidth: 1180, maxHeight: '86vh', display: 'flex', flexDirection: 'column', gap: 0 }} onClick={e => e.stopPropagation()}> title={invoice.invoice_number}
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 16, paddingBottom: 18, marginBottom: 18, borderBottom: '1px solid var(--border)', flexShrink: 0 }}> subtitle={company?.name || invoice.bill_to}
<div> headerRight={<StatusBadge status={statusColor[invoice.status] || 'not_started'} label={`${invoiceStatusLabel(invoice.status)}${isOverdue ? ' · Overdue' : ''}`} />}
<div style={{ fontSize: 28, fontWeight: 500, lineHeight: 1.2 }}>{invoice.invoice_number}</div> onClose={onClose}
<div style={{ fontSize: 13, color: 'var(--text-secondary)', marginTop: 4 }}> blockClose={Boolean(downloading)}
{company?.id ? ( footerActions={<>
<Link to={`/company/${company.id}`} className="dashboard-inline-link" onClick={onClose}> {invoice.status === 'sent' && <button className="btn btn-outline" onClick={openPay}>Pay Invoice</button>}
{company.name} <LoadingButton className="btn btn-outline" loading={downloading === 'invoice'} disabled={Boolean(downloading)} loadingText="Generating…" onClick={handleDownloadInvoice}>Download Invoice</LoadingButton>
</Link> {invoice.status === 'paid' && <LoadingButton className="btn btn-outline" loading={downloading === 'receipt'} disabled={Boolean(downloading)} loadingText="Generating…" onClick={handleDownloadReceipt}>Download Receipt</LoadingButton>}
) : (
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> <button className="btn btn-outline" onClick={onClose}>Close</button>
</div> </>}
</div> >
<SubcontractorInvoiceDetailView
<div style={{ display: 'grid', gridTemplateColumns: 'minmax(0, 1fr) minmax(0, 1fr)', gap: 24, marginBottom: 24, flexShrink: 0 }}> leftCardTitle="Bill To"
<div className="card" style={{ margin: 0 }}> leftCardBody={<>
<div className="card-title">Bill To</div>
<div style={{ fontSize: 15, fontWeight: 400 }}>{invoice.bill_to || company?.name || '—'}</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 && (
{company?.id ? ( <div style={{ marginTop: 12 }}>
<Link to={`/company/${company.id}`} className="dashboard-inline-link" onClick={onClose}> <Link to={`/company/${company.id}`} className="btn btn-outline btn-sm" onClick={onClose}>View Company</Link>
{company.name} </div>
</Link>
) : (
company?.name || 'Fourge Branding Client Invoice'
)} )}
</>}
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>
</div> }
<div className="card" style={{ margin: 0 }}> sortKey={sortKey}
<div className="card-title">Invoice Details</div> sortDir={sortDir}
<div className="detail-grid" style={{ marginBottom: 0 }}> onSort={toggle}
<div className="detail-item"><label>Invoice Date</label><p>{invoice.invoice_date ? new Date(invoice.invoice_date).toLocaleDateString() : '—'}</p></div> items={tableItems}
<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> notes={invoice.notes}
<div className="detail-item"><label>Terms</label><p>Net 30</p></div> total={total}
<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> </InvoiceDetailPopup>
{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>
); );
} }
+65 -117
View File
@@ -8,8 +8,9 @@ import { useAuth } from '../../context/AuthContext';
import { readPageCache, writePageCache } from '../../lib/pageCache'; import { readPageCache, writePageCache } from '../../lib/pageCache';
import { useSortable } from '../../hooks/useSortable'; import { useSortable } from '../../hooks/useSortable';
import { isCompletedVersionEligible } from '../../lib/invoiceVersionRules'; import { isCompletedVersionEligible } from '../../lib/invoiceVersionRules';
import { popupOverlayStyle, popupSurfaceStyle } from '../../lib/popupStyles';
import SubcontractorInvoiceForm from '../../components/SubcontractorInvoiceForm'; import SubcontractorInvoiceForm from '../../components/SubcontractorInvoiceForm';
import InvoiceDetailPopup from '../../components/InvoiceDetailPopup';
import SubcontractorInvoiceDetailView from '../../components/SubcontractorInvoiceDetailView';
import { generateSubcontractorPOPDF } from '../../lib/invoice'; import { generateSubcontractorPOPDF } from '../../lib/invoice';
const STATUS_BADGE = { draft: 'not_started', submitted: 'in_progress', paid: 'client_approved' }; const STATUS_BADGE = { draft: 'not_started', submitted: 'in_progress', paid: 'client_approved' };
@@ -443,126 +444,73 @@ export default function MyInvoices() {
</div> </div>
)} )}
{(viewingInvoice || detailLoading || detailError) && ( {(viewingInvoice || detailLoading || detailError) && (() => {
<div style={popupOverlayStyle} onClick={() => { if (!submittingInvoice && !downloadingInvoice && !downloadingReceipt) { setViewingInvoice(null); setDetailError(''); } }}> const inv = viewingInvoice;
<div const total = inv ? invoiceTotal(inv.items) : 0;
style={{ ...popupSurfaceStyle, background: 'var(--card-bg)', width: '80vw', height: '80vh', display: 'flex', flexDirection: 'column', gap: 0 }} const baseItems = inv ? [...(inv.items || [])].sort((a, b) => (a.sort_order ?? 0) - (b.sort_order ?? 0)) : [];
onClick={(e) => e.stopPropagation()} const invoiceStatus = inv?.status ? inv.status.charAt(0).toUpperCase() + inv.status.slice(1) : '—';
> const busy = submittingInvoice || downloadingInvoice || downloadingReceipt;
{detailLoading && !viewingInvoice ? ( const tableItems = sortDetailItems(baseItems.map(item => ({
<div className="card-empty-center" style={{ flex: 1, minHeight: 0 }}>Loading invoice</div> ...item,
) : viewingInvoice ? (() => { _inferredType: { label: itemWorkLabel(item), badgeClass: itemWorkLabel(item) === 'New' ? 'badge-initial' : 'badge-client_revision' },
const total = invoiceTotal(viewingInvoice.items); description: cleanItemDescription(item),
const sortedItems = [...(viewingInvoice.items || [])].sort((a, b) => (a.sort_order ?? 0) - (b.sort_order ?? 0)); })), (item, key) => {
const invoiceStatus = viewingInvoice.status ? viewingInvoice.status.charAt(0).toUpperCase() + viewingInvoice.status.slice(1) : ''; 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.unit_price || 0) * Number(item.quantity || 1);
return '';
});
return ( return (
<InvoiceDetailPopup
title={inv?.invoice_number || 'Subcontractor Invoice'}
subtitle={`${currentUser?.name || 'Subcontractor'}${currentUser?.email ? ` · ${currentUser.email}` : ''}`}
headerRight={inv ? <StatusBadge status={STATUS_BADGE[inv.status] || 'not_started'} label={invoiceStatus} /> : null}
onClose={() => { if (!busy) { setViewingInvoice(null); setDetailError(''); } }}
blockClose={busy}
loading={detailLoading && !inv}
footerActions={inv ? (
<> <>
{detailError ? <div className="notification notification-info" style={{ marginBottom: 16, flexShrink: 0 }}>{detailError}</div> : null} <LoadingButton className="btn btn-outline" loading={downloadingInvoice} loadingText="Generating…" disabled={busy} onClick={handleDownloadInvoice}>Download Invoice</LoadingButton>
{inv.status === 'draft' && <LoadingButton className="btn btn-outline" loading={submittingInvoice} loadingText="Submitting…" disabled={busy} onClick={handleSubmitInvoice}>Submit to Team</LoadingButton>}
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 16, paddingBottom: 18, borderBottom: '1px solid var(--border)' }}> {inv.status === 'paid' && <LoadingButton className="btn btn-outline" loading={downloadingReceipt} loadingText="Generating…" disabled={busy} onClick={handleDownloadReceipt}>Download Receipt</LoadingButton>}
<div> <button type="button" className="btn btn-outline" onClick={() => { setViewingInvoice(null); setDetailError(''); }} disabled={busy}>Close</button>
<div style={{ fontSize: 18, fontWeight: 500, color: 'var(--text-primary)', letterSpacing: 0.2, lineHeight: 1.1 }}>{viewingInvoice.invoice_number || 'Subcontractor Invoice'}</div>
<div style={{ marginTop: 6, fontSize: 13, color: 'var(--text-secondary)' }}>
{currentUser?.name || 'Subcontractor'}{currentUser?.email ? ` · ${currentUser.email}` : ''}
</div>
</div>
<StatusBadge status={STATUS_BADGE[viewingInvoice.status] || 'not_started'} label={invoiceStatus} />
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr 1fr', gap: 18, padding: '18px 0', borderBottom: '1px solid var(--border)' }}>
<div>
<div style={FIELD_LABEL_STYLE}>Created</div>
<div style={{ fontSize: 13, color: 'var(--text-primary)' }}>{viewingInvoice.created_at ? new Date(viewingInvoice.created_at).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) : '—'}</div>
</div>
<div>
<div style={FIELD_LABEL_STYLE}>Submitted</div>
<div style={{ fontSize: 13, color: 'var(--text-primary)' }}>{viewingInvoice.submitted_at ? new Date(viewingInvoice.submitted_at).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) : '—'}</div>
</div>
<div>
<div style={FIELD_LABEL_STYLE}>Line Items</div>
<div style={{ fontSize: 13, color: 'var(--text-primary)' }}>{sortedItems.length}</div>
</div>
<div>
<div style={FIELD_LABEL_STYLE}>Total</div>
<div style={{ fontSize: 18, fontWeight: 500, color: 'var(--accent)', lineHeight: 1.1 }}>${total.toFixed(2)}</div>
</div>
</div>
<div className="scrollbar-thin-theme table-scroll-hidden" style={{ flex: 1, minHeight: 0, overflowY: 'auto', paddingTop: 18 }}>
{sortedItems.length === 0 ? (
<div className="card-empty-center">No line items</div>
) : (
<table className="table-sticky-head" style={{ width: '100%', borderCollapse: 'collapse', tableLayout: 'fixed' }}>
<colgroup>
<col style={{ width: '8%' }} />
<col style={{ width: '10%' }} />
<col style={{ width: '32%' }} />
<col style={{ width: '16%' }} />
<col style={{ width: '10%' }} />
<col style={{ width: '12%' }} />
<col style={{ width: '12%' }} />
</colgroup>
<thead>
<tr>
<th style={{ textAlign: 'center' }}>Work</th>
<th style={{ textAlign: 'center' }}>R#</th>
<th style={{ textAlign: 'left' }}>Description</th>
<th style={{ textAlign: 'center' }}>Type</th>
<th style={{ textAlign: 'center' }}>Qty</th>
<th style={{ textAlign: 'center' }}>Unit Price</th>
<th style={{ textAlign: 'center' }}>Amount</th>
</tr>
</thead>
<tbody>
{sortedItems.map(item => (
<tr key={item.id}>
<td style={{ padding: '5px 0', fontSize: 13, color: 'var(--text-primary)', textAlign: 'center' }}>{itemWorkLabel(item)}</td>
<td style={{ padding: '5px 0', fontSize: 13, color: 'var(--text-primary)', textAlign: 'center' }}>{itemVersionLabel(item)}</td>
<td style={{ padding: '5px 0', fontSize: 13, color: 'var(--text-primary)' }}>{cleanItemDescription(item)}</td>
<td style={{ padding: '5px 0', fontSize: 13, color: 'var(--text-primary)', textAlign: 'center' }}>{detailTaskTypeMap[item.task_id] || 'Other'}</td>
<td style={{ padding: '5px 0', textAlign: 'center', fontSize: 13, color: 'var(--text-primary)' }}>{item.quantity || 1}</td>
<td style={{ padding: '5px 0', textAlign: 'center', fontSize: 13, color: 'var(--text-primary)' }}>${Number(item.unit_price || 0).toFixed(2)}</td>
<td style={{ padding: '5px 0', textAlign: 'center', fontSize: 13, color: 'var(--text-primary)' }}>${(Number(item.unit_price || 0) * Number(item.quantity || 1)).toFixed(2)}</td>
</tr>
))}
</tbody>
</table>
)}
{viewingInvoice.notes && (
<div style={{ marginTop: 18, padding: '12px 14px', background: 'var(--card-bg-2)', borderRadius: 6, border: '1px solid var(--border)' }}>
<div style={FIELD_LABEL_STYLE}>Notes</div>
<div style={{ fontSize: 13, color: 'var(--text-secondary)', whiteSpace: 'pre-wrap' }}>{viewingInvoice.notes}</div>
</div>
)}
</div>
<div className="modal-action-row" style={{ paddingTop: 18, borderTop: '1px solid var(--border)' }}>
<LoadingButton className="btn btn-outline" loading={downloadingInvoice} loadingText="Generating…" disabled={downloadingInvoice || submittingInvoice || downloadingReceipt} onClick={handleDownloadInvoice}>
Download Invoice
</LoadingButton>
{viewingInvoice.status === 'draft' && (
<LoadingButton className="btn btn-outline" loading={submittingInvoice} loadingText="Submitting…" disabled={submittingInvoice || downloadingInvoice || downloadingReceipt} onClick={handleSubmitInvoice}>
Submit to Team
</LoadingButton>
)}
{viewingInvoice.status === 'paid' && (
<LoadingButton className="btn btn-outline" loading={downloadingReceipt} loadingText="Generating…" disabled={downloadingReceipt || downloadingInvoice || submittingInvoice} onClick={handleDownloadReceipt}>
Download Receipt
</LoadingButton>
)}
<button type="button" className="btn btn-outline" onClick={() => { setViewingInvoice(null); setDetailError(''); }} disabled={submittingInvoice || downloadingInvoice || downloadingReceipt}>
Cancel
</button>
</div>
</> </>
) : null}
>
{detailError && <div className="notification notification-info" style={{ marginBottom: 16 }}>{detailError}</div>}
{inv ? (
<SubcontractorInvoiceDetailView
leftCardTitle="Submitted By"
leftCardBody={<>
<div style={{ fontSize: 15, fontWeight: 400 }}>{currentUser?.name || 'Subcontractor'}</div>
<div style={{ fontSize: 13, color: 'var(--text-muted)', marginTop: 4 }}>{currentUser?.email || '—'}</div>
</>}
rightCardTitle="Invoice Details"
rightCardBody={
<div className="invoice-detail-meta-grid">
<div className="invoice-detail-meta-item"><label>Invoice #</label><p>{inv.invoice_number || '—'}</p></div>
<div className="invoice-detail-meta-item"><label>Status</label><p>{invoiceStatus}</p></div>
<div className="invoice-detail-meta-item"><label>Created</label><p>{inv.created_at ? new Date(inv.created_at).toLocaleDateString() : '—'}</p></div>
<div className="invoice-detail-meta-item"><label>Submitted</label><p>{inv.submitted_at ? new Date(inv.submitted_at).toLocaleDateString() : '—'}</p></div>
<div className="invoice-detail-meta-item"><label>Line Items</label><p>{baseItems.length}</p></div>
<div className="invoice-detail-meta-item"><label>Total</label><p style={{ fontSize: 18, color: 'var(--accent)' }}>${total.toFixed(2)}</p></div>
</div>
}
sortKey={detailSortKey}
sortDir={detailSortDir}
onSort={toggleDetailSort}
items={tableItems}
notes={inv.notes}
total={total}
/>
) : !detailError ? (
<div className="card-empty-center">Invoice not found.</div>
) : null}
</InvoiceDetailPopup>
); );
})() : ( })()}
<div className="card-empty-center" style={{ flex: 1, minHeight: 0 }}>{detailError || 'Invoice not found.'}</div>
)}
</div>
</div>
)}
</Layout> </Layout>
); );
} }
+110 -36
View File
@@ -10,7 +10,8 @@ import { generateInvoicePDF, generateReceiptPDF } from '../../lib/invoice';
import { blobToEmailAttachment, sendEmail } from '../../lib/email'; import { blobToEmailAttachment, sendEmail } from '../../lib/email';
import { withTimeout } from '../../lib/withTimeout'; import { withTimeout } from '../../lib/withTimeout';
import { useSortable } from '../../hooks/useSortable'; 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 statusColor = { draft: 'not_started', sent: 'in_progress', paid: 'client_approved' };
@@ -349,36 +350,120 @@ export function TeamInvoiceDetailPanel({
setEmailRecipient(defaultEmail); setEmailRecipient(defaultEmail);
}; };
if (loading) { if (loading && !embedded) return <Layout><PageLoader /></Layout>;
if (embedded) { if (!invoice && !embedded) return <Layout><p>Invoice not found.</p></Layout>;
return (
<div style={{ ...popupSurfaceStyle, background: 'var(--card-bg)', width: '80vw', height: '80vh', display: 'flex', alignItems: 'center', justifyContent: 'center' }}> const sortedItems = invoice ? sort(items, (item, key) => {
<PageLoader />
</div>
);
}
return <Layout><PageLoader /></Layout>;
}
if (!invoice) {
if (embedded) {
return (
<div style={{ ...popupSurfaceStyle, background: 'var(--card-bg)', width: '80vw', height: '80vh', display: 'flex', alignItems: 'center', justifyContent: 'center', color: 'var(--text-muted)' }}>
Invoice not found.
</div>
);
}
return <Layout><p>Invoice not found.</p></Layout>;
}
const sortedItems = sort(items, (item, key) => {
if (key === 'type') return item.submission_id ? 'Revision' : 'New'; if (key === 'type') return item.submission_id ? 'Revision' : 'New';
if (key === 'description') return item.description || ''; if (key === 'description') return item.description || '';
if (key === 'quantity') return Number(item.quantity || 0); if (key === 'quantity') return Number(item.quantity || 0);
if (key === 'unit_price') return Number(item.unit_price || 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); if (key === 'line_total') return Number(item.quantity || 0) * Number(item.unit_price || 0);
return ''; return '';
}); }) : [];
const isOverdue = invoice && invoice.status !== 'paid' && new Date(invoice.due_date) < new Date();
if (embedded) {
const tableItems = sortedItems.map(item => ({
...item,
_inferredType: item.submission_id
? { label: 'Revision', badgeClass: 'badge-client_revision' }
: { label: 'New', badgeClass: 'badge-initial' },
}));
return (
<InvoiceDetailPopup
title={invoice?.invoice_number || '…'}
subtitle={company?.name}
headerRight={invoice ? (
<StatusBadge
status={statusColor[invoice.status] || 'not_started'}
label={`${invoice.status ? invoice.status.charAt(0).toUpperCase() + invoice.status.slice(1) : '—'}${isOverdue ? ' · Overdue' : ''}`}
/>
) : null}
onClose={onClose}
blockClose={saving || Boolean(generating)}
loading={loading}
footerActions={invoice && !loading ? (
<>
{invoice.status === 'draft' && <LoadingButton className="btn btn-primary" onClick={handleFinalizeSend} loading={saving} loadingText="Finalizing & Sending...">Finalize & Send</LoadingButton>}
{invoice.status === 'sent' && <LoadingButton className="btn btn-outline" onClick={handleResendInvoice} loading={saving} loadingText="Resending...">Resend Invoice</LoadingButton>}
{invoice.status === 'sent' && <button className="btn btn-success" onClick={() => updateStatus('paid')} disabled={saving}>Mark as Paid</button>}
{invoice.status === 'paid' && <button className="btn btn-outline" onClick={() => updateStatus('sent')} disabled={saving}>Reopen</button>}
<LoadingButton className="btn btn-primary" loading={generating === 'invoice'} disabled={Boolean(generating)} loadingText="Generating... PDF" onClick={handleDownload}>Download Invoice</LoadingButton>
{invoice.status === 'paid' && <>
<LoadingButton className="btn btn-success" loading={generating === 'receipt'} disabled={Boolean(generating)} loadingText="Generating... PDF" onClick={handleReceipt}>Download Receipt</LoadingButton>
<LoadingButton className="btn btn-outline" onClick={handleSendReceipt} loading={saving} loadingText="Sending Receipt...">Send Receipt</LoadingButton>
</>}
{invoice.status !== 'paid' && <button className="btn-icon btn-icon-danger" title="Delete Invoice" onClick={handleDelete} disabled={saving}></button>}
<button className="btn btn-outline" onClick={onClose} disabled={saving || Boolean(generating)}>Close</button>
</>
) : null}
>
{invoice ? (
<SubcontractorInvoiceDetailView
leftCardTitle="Bill To"
leftCardBody={<>
<div style={{ fontSize: 15, fontWeight: 400 }}>{invoice.bill_to || company?.name}</div>
<div style={{ marginTop: 12 }}>
<Link to={`/company/${company?.id}`} className="btn btn-outline btn-sm">View Company</Link>
</div>
</>}
headerActions={!editingDates
? <button className="btn btn-outline btn-sm" onClick={handleEditDates}>Edit Dates</button>
: <div style={{ display: 'flex', gap: 8 }}>
<button className="btn btn-primary btn-sm" onClick={handleSaveDates} disabled={saving}>Save</button>
<button className="btn btn-outline btn-sm" onClick={() => setEditingDates(false)} disabled={saving}>Cancel</button>
</div>
}
rightCardTitle="Invoice Details"
rightCardBody={
<div className="invoice-detail-meta-grid">
<div className="invoice-detail-meta-item">
<label>Invoice Date</label>
{editingDates
? <input type="date" className="input" style={{ margin: 0 }} value={dateForm.invoice_date} onChange={e => setDateForm(f => ({ ...f, invoice_date: e.target.value }))} />
: <p>{new Date(invoice.invoice_date).toLocaleDateString()}</p>}
</div>
<div className="invoice-detail-meta-item">
<label>Due Date</label>
{editingDates
? <input type="date" className="input" style={{ margin: 0 }} value={dateForm.due_date} onChange={e => setDateForm(f => ({ ...f, due_date: e.target.value }))} />
: <p style={{ color: isOverdue ? 'var(--danger)' : 'inherit' }}>{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>Company</label>
<select className="input" style={{ margin: 0 }} value={invoice.company_id || ''} onChange={e => handleCompanyChange(e.target.value)} disabled={saving}>
{companies.map(co => <option key={co.id} value={co.id}>{co.name}</option>)}
</select>
</div>
<div className="invoice-detail-meta-item">
<label>Email To</label>
<input type="email" className="input" style={{ margin: 0 }} value={emailRecipient} onChange={e => setEmailRecipient(e.target.value)} onBlur={handleEmailBlur} placeholder="client@example.com" disabled={saving} />
</div>
<div className="invoice-detail-meta-item"><label>Total</label><p style={{ fontSize: 18, fontWeight: 400, color: 'var(--accent)' }}>${Number(invoice.total).toFixed(2)}</p></div>
{invoice.paid_at && <div className="invoice-detail-meta-item"><label>Paid On</label><p style={{ color: 'var(--success, #16a34a)', fontWeight: 400 }}>{new Date(invoice.paid_at).toLocaleDateString()}</p></div>}
{invoice.status === 'paid' && invoice.stripe_fee != null && <>
<div className="invoice-detail-meta-item"><label>Stripe Fee</label><p style={{ color: 'var(--text-secondary)' }}>${Number(invoice.stripe_fee).toFixed(2)}</p></div>
<div className="invoice-detail-meta-item"><label>Net Received</label><p style={{ fontWeight: 400 }}>${(Number(invoice.total) - Number(invoice.stripe_fee)).toFixed(2)}</p></div>
</>}
</div>
}
sortKey={sortKey}
sortDir={sortDir}
onSort={toggle}
items={tableItems}
notes={invoice.notes}
total={Number(invoice.total)}
/>
) : (
<div style={{ padding: 24, color: 'var(--text-muted)' }}>Invoice not found.</div>
)}
</InvoiceDetailPopup>
);
}
const isOverdue = invoice.status !== 'paid' && new Date(invoice.due_date) < new Date();
const detailContent = ( const detailContent = (
<> <>
{!embedded && <button className="back-link" onClick={() => navigate('/finances')}> Back to Invoices</button>} {!embedded && <button className="back-link" onClick={() => navigate('/finances')}> Back to Invoices</button>}
@@ -571,18 +656,7 @@ export function TeamInvoiceDetailPanel({
</> </>
); );
if (embedded) {
return (
<div style={popupOverlayStyle} onClick={() => { if (!saving && !generating) onClose?.(); }}>
<div
style={{ ...popupSurfaceStyle, background: 'var(--card-bg)', width: '80vw', height: '80vh', display: 'flex', flexDirection: 'column', gap: 0, overflowY: 'auto' }}
onClick={(e) => e.stopPropagation()}
>
{detailContent}
</div>
</div>
);
}
return <Layout>{detailContent}</Layout>; return <Layout>{detailContent}</Layout>;
} }
+54 -97
View File
@@ -18,6 +18,8 @@ import FileAttachment from '../../components/FileAttachment';
import { isReviewShadowDescription } from '../../lib/taskVersions'; import { isReviewShadowDescription } from '../../lib/taskVersions';
import { getRevisionChargeQuantity, isCompletedVersionEligible, isInitialVersionEligible } from '../../lib/invoiceVersionRules'; import { getRevisionChargeQuantity, isCompletedVersionEligible, isInitialVersionEligible } from '../../lib/invoiceVersionRules';
import { TeamInvoiceDetailPanel } from './TeamInvoiceDetail'; import { TeamInvoiceDetailPanel } from './TeamInvoiceDetail';
import InvoiceDetailPopup from '../../components/InvoiceDetailPopup';
import SubcontractorInvoiceDetailView from '../../components/SubcontractorInvoiceDetailView';
const CATEGORIES = ['Software', 'Contractor', 'Advertising', 'Office', 'Travel', 'Meals', 'Equipment', 'Other']; const CATEGORIES = ['Software', 'Contractor', 'Advertising', 'Office', 'Travel', 'Meals', 'Equipment', 'Other'];
const statusColor = { draft: 'not_started', sent: 'in_progress', paid: 'client_approved' }; const statusColor = { draft: 'not_started', sent: 'in_progress', paid: 'client_approved' };
@@ -241,6 +243,7 @@ export default function Invoices() {
const { sortKey: ovExpKey, sortDir: ovExpDir, toggle: ovExpToggle } = useSortable('date'); const { sortKey: ovExpKey, sortDir: ovExpDir, toggle: ovExpToggle } = useSortable('date');
const { sortKey: ovSubKey, sortDir: ovSubDir, toggle: ovSubToggle } = useSortable('date'); const { sortKey: ovSubKey, sortDir: ovSubDir, toggle: ovSubToggle } = useSortable('date');
const { sortKey: ovInvKey, sortDir: ovInvDir, toggle: ovInvToggle } = useSortable('invoice_date'); const { sortKey: ovInvKey, sortDir: ovInvDir, toggle: ovInvToggle } = useSortable('invoice_date');
const { sortKey: subPopupKey, sortDir: subPopupDir, toggle: subPopupToggle, sort: subPopupSort } = useSortable('description');
const [filterCompany, setFilterCompany] = useState(''); const [filterCompany, setFilterCompany] = useState('');
const initMonth = () => { const n = new Date(); return { month: n.getMonth(), year: n.getFullYear() }; }; const initMonth = () => { const n = new Date(); return { month: n.getMonth(), year: n.getFullYear() }; };
const [ovMonth1, setOvMonth1] = useState(initMonth); const [ovMonth1, setOvMonth1] = useState(initMonth);
@@ -1712,114 +1715,68 @@ export default function Invoices() {
{viewingSubInvoice && (() => { {viewingSubInvoice && (() => {
const invoice = viewingSubInvoice; const invoice = viewingSubInvoice;
const sortedItems = [...(invoice.items || [])].sort((a, b) => (a.sort_order ?? 0) - (b.sort_order ?? 0)); const baseItems = [...(invoice.items || [])].sort((a, b) => (a.sort_order ?? 0) - (b.sort_order ?? 0));
const total = sortedItems.reduce((s, item) => s + Number(item.unit_price || 0) * Number(item.quantity || 1), 0); const total = baseItems.reduce((s, item) => s + Number(item.unit_price || 0) * Number(item.quantity || 1), 0);
const invoiceStatus = invoice.status ? invoice.status.charAt(0).toUpperCase() + invoice.status.slice(1) : '—'; const invoiceStatus = invoice.status ? invoice.status.charAt(0).toUpperCase() + invoice.status.slice(1) : '—';
const subInvoiceStatusColor = { draft: 'not_started', submitted: 'in_progress', paid: 'client_approved' }; const subInvoiceStatusColor = { draft: 'not_started', submitted: 'in_progress', paid: 'client_approved' };
const tableItems = subPopupSort(baseItems.map(item => ({
...item,
_inferredType: { label: subInvoiceItemWorkLabel(item), badgeClass: subInvoiceItemWorkLabel(item) === 'New' ? 'badge-initial' : 'badge-client_revision' },
description: cleanSubInvoiceItemDescription(item),
})), (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.unit_price || 0) * Number(item.quantity || 1);
return '';
});
return ( return (
<div style={popupOverlayStyle} onClick={() => { if (!markingPaid) setViewingSubInvoice(null); }}> <InvoiceDetailPopup
<div style={{ ...popupSurfaceStyle, background: 'var(--card-bg)', width: '80vw', height: '80vh', display: 'flex', flexDirection: 'column', gap: 0 }} onClick={e => e.stopPropagation()}> title={invoice.invoice_number || 'Subcontractor Invoice'}
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 16, paddingBottom: 18, borderBottom: '1px solid var(--border)' }}> subtitle={`${invoice.profile?.name || 'External'}${invoice.profile?.email ? ` · ${invoice.profile.email}` : ''}`}
<div> headerRight={<StatusBadge status={subInvoiceStatusColor[invoice.status] || 'not_started'} label={invoiceStatus} />}
<div style={{ fontSize: 18, fontWeight: 500, color: 'var(--text-primary)', letterSpacing: 0.2, lineHeight: 1.1 }}>{invoice.invoice_number || 'Subcontractor Invoice'}</div> onClose={() => { if (!markingPaid) setViewingSubInvoice(null); }}
<div style={{ marginTop: 6, fontSize: 13, color: 'var(--text-secondary)' }}> blockClose={Boolean(markingPaid)}
{invoice.profile?.name || 'External'}{invoice.profile?.email ? ` · ${invoice.profile.email}` : ''} footerActions={<>
</div> <button type="button" className="btn btn-outline" onClick={() => handleDownloadSubInvoice(invoice)}>Download Invoice</button>
</div>
<StatusBadge status={subInvoiceStatusColor[invoice.status] || 'not_started'} label={invoiceStatus} />
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr 1fr', gap: 18, padding: '18px 0', borderBottom: '1px solid var(--border)' }}>
<div>
<div style={FIELD_LABEL_STYLE}>Submitted</div>
<div style={{ fontSize: 13, color: 'var(--text-primary)' }}>{invoice.submitted_at ? new Date(invoice.submitted_at).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) : '—'}</div>
</div>
<div>
<div style={FIELD_LABEL_STYLE}>Paid</div>
<div style={{ fontSize: 13, color: 'var(--text-primary)' }}>{invoice.paid_at ? new Date(invoice.paid_at).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) : '—'}</div>
</div>
<div>
<div style={FIELD_LABEL_STYLE}>Line Items</div>
<div style={{ fontSize: 13, color: 'var(--text-primary)' }}>{sortedItems.length}</div>
</div>
<div>
<div style={FIELD_LABEL_STYLE}>Total</div>
<div style={{ fontSize: 18, fontWeight: 500, color: 'var(--accent)', lineHeight: 1.1 }}>${total.toFixed(2)}</div>
</div>
</div>
<div className="scrollbar-thin-theme table-scroll-hidden" style={{ flex: 1, minHeight: 0, overflowY: 'auto', paddingTop: 18 }}>
{sortedItems.length === 0 ? (
<div className="card-empty-center">No line items</div>
) : (
<table className="table-sticky-head" style={{ width: '100%', borderCollapse: 'collapse', tableLayout: 'fixed' }}>
<colgroup>
<col style={{ width: '8%' }} />
<col style={{ width: '10%' }} />
<col style={{ width: '32%' }} />
<col style={{ width: '16%' }} />
<col style={{ width: '10%' }} />
<col style={{ width: '12%' }} />
<col style={{ width: '12%' }} />
</colgroup>
<thead>
<tr>
<th style={{ textAlign: 'center' }}>Work</th>
<th style={{ textAlign: 'center' }}>R#</th>
<th style={{ textAlign: 'left' }}>Description</th>
<th style={{ textAlign: 'center' }}>Type</th>
<th style={{ textAlign: 'center' }}>Qty</th>
<th style={{ textAlign: 'center' }}>Unit Price</th>
<th style={{ textAlign: 'center' }}>Amount</th>
</tr>
</thead>
<tbody>
{sortedItems.map(item => (
<tr key={item.id}>
<td style={{ padding: '5px 0', fontSize: 13, color: 'var(--text-primary)', textAlign: 'center' }}>{subInvoiceItemWorkLabel(item)}</td>
<td style={{ padding: '5px 0', fontSize: 13, color: 'var(--text-primary)', textAlign: 'center' }}>{subInvoiceItemVersionLabel(item)}</td>
<td style={{ padding: '5px 0', fontSize: 13, color: 'var(--text-primary)' }}>{cleanSubInvoiceItemDescription(item)}</td>
<td style={{ padding: '5px 0', fontSize: 13, color: 'var(--text-primary)', textAlign: 'center' }}>{viewingSubInvoiceTaskTypeMap[item.task_id] || 'Other'}</td>
<td style={{ padding: '5px 0', textAlign: 'center', fontSize: 13, color: 'var(--text-primary)' }}>{item.quantity || 1}</td>
<td style={{ padding: '5px 0', textAlign: 'center', fontSize: 13, color: 'var(--text-primary)' }}>${Number(item.unit_price || 0).toFixed(2)}</td>
<td style={{ padding: '5px 0', textAlign: 'center', fontSize: 13, color: 'var(--text-primary)' }}>${(Number(item.unit_price || 0) * Number(item.quantity || 1)).toFixed(2)}</td>
</tr>
))}
</tbody>
</table>
)}
{invoice.notes && (
<div style={{ marginTop: 18, padding: '12px 14px', background: 'var(--card-bg-2)', borderRadius: 6, border: '1px solid var(--border)' }}>
<div style={FIELD_LABEL_STYLE}>Notes</div>
<div style={{ fontSize: 13, color: 'var(--text-secondary)', whiteSpace: 'pre-wrap' }}>{invoice.notes}</div>
</div>
)}
</div>
<div className="modal-action-row" style={{ paddingTop: 18, borderTop: '1px solid var(--border)' }}>
<button type="button" className="btn btn-outline" onClick={() => handleDownloadSubInvoice(invoice)}>
Download Invoice
</button>
{invoice.status === 'submitted' && ( {invoice.status === 'submitted' && (
<LoadingButton className="btn btn-outline" onClick={async () => { <LoadingButton className="btn btn-outline" onClick={async () => {
await handleMarkSubInvoicePaid(invoice); await handleMarkSubInvoicePaid(invoice);
setViewingSubInvoice(current => current?.id === invoice.id ? { ...current, status: 'paid', paid_at: new Date().toISOString() } : current); setViewingSubInvoice(current => current?.id === invoice.id ? { ...current, status: 'paid', paid_at: new Date().toISOString() } : current);
}} loading={markingPaid === invoice.id} loadingText="Processing…"> }} loading={markingPaid === invoice.id} loadingText="Processing…">Mark as Paid</LoadingButton>
Mark as Paid
</LoadingButton>
)} )}
{invoice.status === 'paid' && ( {invoice.status === 'paid' && (
<button type="button" className="btn btn-outline" onClick={() => handleDownloadSubInvoiceReceipt(invoice)}> <button type="button" className="btn btn-outline" onClick={() => handleDownloadSubInvoiceReceipt(invoice)}>Download Receipt</button>
Download Receipt
</button>
)} )}
<button type="button" className="btn btn-outline" onClick={() => setViewingSubInvoice(null)}> <button type="button" className="btn btn-outline" onClick={() => setViewingSubInvoice(null)}>Close</button>
Cancel </>}
</button> >
</div> <SubcontractorInvoiceDetailView
</div> leftCardTitle="Submitted By"
leftCardBody={<>
<div style={{ fontSize: 15, fontWeight: 400 }}>{invoice.profile?.name || 'External'}</div>
<div style={{ fontSize: 13, color: 'var(--text-muted)', marginTop: 4 }}>{invoice.profile?.email || '—'}</div>
</>}
rightCardTitle="Invoice Details"
rightCardBody={
<div className="invoice-detail-meta-grid">
<div className="invoice-detail-meta-item"><label>Invoice #</label><p>{invoice.invoice_number || '—'}</p></div>
<div className="invoice-detail-meta-item"><label>Status</label><p>{invoiceStatus}</p></div>
<div className="invoice-detail-meta-item"><label>Submitted</label><p>{invoice.submitted_at ? new Date(invoice.submitted_at).toLocaleDateString() : '—'}</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 className="invoice-detail-meta-item"><label>Line Items</label><p>{baseItems.length}</p></div>
<div className="invoice-detail-meta-item"><label>Total</label><p style={{ fontSize: 18, color: 'var(--accent)' }}>${total.toFixed(2)}</p></div>
</div> </div>
}
sortKey={subPopupKey}
sortDir={subPopupDir}
onSort={subPopupToggle}
items={tableItems}
notes={invoice.notes}
total={total}
/>
</InvoiceDetailPopup>
); );
})()} })()}