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
+67 -119
View File
@@ -8,8 +8,9 @@ import { useAuth } from '../../context/AuthContext';
import { readPageCache, writePageCache } from '../../lib/pageCache';
import { useSortable } from '../../hooks/useSortable';
import { isCompletedVersionEligible } from '../../lib/invoiceVersionRules';
import { popupOverlayStyle, popupSurfaceStyle } from '../../lib/popupStyles';
import SubcontractorInvoiceForm from '../../components/SubcontractorInvoiceForm';
import InvoiceDetailPopup from '../../components/InvoiceDetailPopup';
import SubcontractorInvoiceDetailView from '../../components/SubcontractorInvoiceDetailView';
import { generateSubcontractorPOPDF } from '../../lib/invoice';
const STATUS_BADGE = { draft: 'not_started', submitted: 'in_progress', paid: 'client_approved' };
@@ -443,126 +444,73 @@ export default function MyInvoices() {
</div>
)}
{(viewingInvoice || detailLoading || detailError) && (
<div style={popupOverlayStyle} onClick={() => { if (!submittingInvoice && !downloadingInvoice && !downloadingReceipt) { setViewingInvoice(null); setDetailError(''); } }}>
<div
style={{ ...popupSurfaceStyle, background: 'var(--card-bg)', width: '80vw', height: '80vh', display: 'flex', flexDirection: 'column', gap: 0 }}
onClick={(e) => e.stopPropagation()}
{(viewingInvoice || detailLoading || detailError) && (() => {
const inv = viewingInvoice;
const total = inv ? invoiceTotal(inv.items) : 0;
const baseItems = inv ? [...(inv.items || [])].sort((a, b) => (a.sort_order ?? 0) - (b.sort_order ?? 0)) : [];
const invoiceStatus = inv?.status ? inv.status.charAt(0).toUpperCase() + inv.status.slice(1) : '—';
const busy = submittingInvoice || downloadingInvoice || downloadingReceipt;
const tableItems = sortDetailItems(baseItems.map(item => ({
...item,
_inferredType: { label: itemWorkLabel(item), badgeClass: itemWorkLabel(item) === 'New' ? 'badge-initial' : 'badge-client_revision' },
description: cleanItemDescription(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 (
<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 ? (
<>
<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>}
{inv.status === 'paid' && <LoadingButton className="btn btn-outline" loading={downloadingReceipt} loadingText="Generating…" disabled={busy} onClick={handleDownloadReceipt}>Download Receipt</LoadingButton>}
<button type="button" className="btn btn-outline" onClick={() => { setViewingInvoice(null); setDetailError(''); }} disabled={busy}>Close</button>
</>
) : null}
>
{detailLoading && !viewingInvoice ? (
<div className="card-empty-center" style={{ flex: 1, minHeight: 0 }}>Loading invoice</div>
) : viewingInvoice ? (() => {
const total = invoiceTotal(viewingInvoice.items);
const sortedItems = [...(viewingInvoice.items || [])].sort((a, b) => (a.sort_order ?? 0) - (b.sort_order ?? 0));
const invoiceStatus = viewingInvoice.status ? viewingInvoice.status.charAt(0).toUpperCase() + viewingInvoice.status.slice(1) : '—';
return (
<>
{detailError ? <div className="notification notification-info" style={{ marginBottom: 16, flexShrink: 0 }}>{detailError}</div> : null}
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 16, paddingBottom: 18, borderBottom: '1px solid var(--border)' }}>
<div>
<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} />
{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>
<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>
</>
);
})() : (
<div className="card-empty-center" style={{ flex: 1, minHeight: 0 }}>{detailError || 'Invoice not found.'}</div>
)}
</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>
);
})()}
</Layout>
);
}