diff --git a/src/pages/client/ClientMyInvoices.jsx b/src/pages/client/ClientMyInvoices.jsx
index 7f6f6a5..e37425f 100644
--- a/src/pages/client/ClientMyInvoices.jsx
+++ b/src/pages/client/ClientMyInvoices.jsx
@@ -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 (
-
- e.stopPropagation()}>
-
-
- {invoice.invoice_number}
-
- {company?.id ? (
-
- {company.name}
-
- ) : (
- company?.name || invoice.bill_to || 'Invoice'
- )}
+ }
+ onClose={onClose}
+ blockClose={Boolean(downloading)}
+ footerActions={<>
+ {invoice.status === 'sent' && }
+ Download Invoice
+ {invoice.status === 'paid' && Download Receipt}
+
+ >}
+ >
+
+ {invoice.bill_to || company?.name || '—'}
+ {company?.id && (
+
+ View Company
+ )}
+ >}
+ rightCardTitle="Invoice Details"
+ rightCardBody={
+
+ {invoice.invoice_date ? new Date(invoice.invoice_date).toLocaleDateString() : '—'}
+ {invoice.due_date ? new Date(invoice.due_date).toLocaleDateString() : '—'}
+
+
+
+ {invoice.paid_at && {new Date(invoice.paid_at).toLocaleDateString()} }
-
-
- {invoice.status === 'sent' && (
-
- )}
-
- Download Invoice
-
- {invoice.status === 'paid' && (
-
- Download Receipt
-
- )}
-
-
-
-
-
-
- Bill To
- {invoice.bill_to || company?.name || '—'}
-
- {company?.id ? (
-
- {company.name}
-
- ) : (
- company?.name || 'Fourge Branding Client Invoice'
- )}
-
-
-
- Invoice Details
-
- {invoice.invoice_date ? new Date(invoice.invoice_date).toLocaleDateString() : '—'}
- {invoice.due_date ? new Date(invoice.due_date).toLocaleDateString() : '—'}
-
-
- ${Number(invoice.total || 0).toFixed(2)}
- {invoice.paid_at && {new Date(invoice.paid_at).toLocaleDateString()} }
-
-
-
-
-
- Line Items
-
-
-
-
-
-
-
-
-
-
-
- | Type |
- Description |
- Qty |
- Unit Price |
- Total |
-
-
-
- {sortedItems.map(item => (
-
- |
-
- {item.submission_id ? 'Revision' : 'New'}
-
- |
- {item.description} |
- {item.quantity} |
- ${Number(item.unit_price || 0).toFixed(2)} |
- ${(Number(item.quantity || 0) * Number(item.unit_price || 0)).toFixed(2)} |
-
- ))}
-
-
-
-
-
- Total
- ${Number(invoice.total || 0).toFixed(2)}
-
-
-
-
- {invoice.notes ? (
-
- Notes
- {invoice.notes}
-
- ) : null}
-
-
+ }
+ sortKey={sortKey}
+ sortDir={sortDir}
+ onSort={toggle}
+ items={tableItems}
+ notes={invoice.notes}
+ total={total}
+ />
+
);
}
diff --git a/src/pages/external/ExternalMyInvoices.jsx b/src/pages/external/ExternalMyInvoices.jsx
index 2d4940c..cc088b8 100644
--- a/src/pages/external/ExternalMyInvoices.jsx
+++ b/src/pages/external/ExternalMyInvoices.jsx
@@ -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() {
)}
- {(viewingInvoice || detailLoading || detailError) && (
- { if (!submittingInvoice && !downloadingInvoice && !downloadingReceipt) { setViewingInvoice(null); setDetailError(''); } }}>
- 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 (
+ : null}
+ onClose={() => { if (!busy) { setViewingInvoice(null); setDetailError(''); } }}
+ blockClose={busy}
+ loading={detailLoading && !inv}
+ footerActions={inv ? (
+ <>
+ Download Invoice
+ {inv.status === 'draft' && Submit to Team}
+ {inv.status === 'paid' && Download Receipt}
+
+ >
+ ) : null}
>
- {detailLoading && !viewingInvoice ? (
- Loading invoice…
- ) : 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 ? {detailError} : null}
-
-
-
- {viewingInvoice.invoice_number || 'Subcontractor Invoice'}
-
- {currentUser?.name || 'Subcontractor'}{currentUser?.email ? ` · ${currentUser.email}` : ''}
-
-
-
+ {detailError && {detailError} }
+ {inv ? (
+
+ {currentUser?.name || 'Subcontractor'}
+ {currentUser?.email || '—'}
+ >}
+ rightCardTitle="Invoice Details"
+ rightCardBody={
+
+ {inv.invoice_number || '—'}
+
+ {inv.created_at ? new Date(inv.created_at).toLocaleDateString() : '—'}
+ {inv.submitted_at ? new Date(inv.submitted_at).toLocaleDateString() : '—'}
+ {baseItems.length}
+
-
-
-
- Created
- {viewingInvoice.created_at ? new Date(viewingInvoice.created_at).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) : '—'}
-
-
- Submitted
- {viewingInvoice.submitted_at ? new Date(viewingInvoice.submitted_at).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) : '—'}
-
-
- Line Items
- {sortedItems.length}
-
-
- Total
- ${total.toFixed(2)}
-
-
-
-
- {sortedItems.length === 0 ? (
- No line items
- ) : (
-
-
-
-
-
-
-
-
-
-
-
-
- | Work |
- R# |
- Description |
- Type |
- Qty |
- Unit Price |
- Amount |
-
-
-
- {sortedItems.map(item => (
-
- | {itemWorkLabel(item)} |
- {itemVersionLabel(item)} |
- {cleanItemDescription(item)} |
- {detailTaskTypeMap[item.task_id] || 'Other'} |
- {item.quantity || 1} |
- ${Number(item.unit_price || 0).toFixed(2)} |
- ${(Number(item.unit_price || 0) * Number(item.quantity || 1)).toFixed(2)} |
-
- ))}
-
-
- )}
-
- {viewingInvoice.notes && (
-
- Notes
- {viewingInvoice.notes}
-
- )}
-
-
-
-
- Download Invoice
-
- {viewingInvoice.status === 'draft' && (
-
- Submit to Team
-
- )}
- {viewingInvoice.status === 'paid' && (
-
- Download Receipt
-
- )}
-
-
- >
- );
- })() : (
- {detailError || 'Invoice not found.'}
- )}
-
-
- )}
+ }
+ sortKey={detailSortKey}
+ sortDir={detailSortDir}
+ onSort={toggleDetailSort}
+ items={tableItems}
+ notes={inv.notes}
+ total={total}
+ />
+ ) : !detailError ? (
+ Invoice not found.
+ ) : null}
+
+ );
+ })()}
);
}
diff --git a/src/pages/team/TeamInvoiceDetail.jsx b/src/pages/team/TeamInvoiceDetail.jsx
index 21b9635..98bbe9b 100644
--- a/src/pages/team/TeamInvoiceDetail.jsx
+++ b/src/pages/team/TeamInvoiceDetail.jsx
@@ -10,7 +10,8 @@ import { generateInvoicePDF, generateReceiptPDF } from '../../lib/invoice';
import { blobToEmailAttachment, sendEmail } from '../../lib/email';
import { withTimeout } from '../../lib/withTimeout';
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' };
@@ -349,36 +350,120 @@ export function TeamInvoiceDetailPanel({
setEmailRecipient(defaultEmail);
};
- if (loading) {
- if (embedded) {
- return (
-
- );
- }
- return ;
- }
- if (!invoice) {
- if (embedded) {
- return (
-
- Invoice not found.
-
- );
- }
- return Invoice not found. ;
- }
- const sortedItems = sort(items, (item, key) => {
+ if (loading && !embedded) return ;
+ if (!invoice && !embedded) return Invoice not found. ;
+
+ const sortedItems = invoice ? sort(items, (item, key) => {
if (key === 'type') return item.submission_id ? 'Revision' : 'New';
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 '';
- });
+ }) : [];
+
+ 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 (
+
+ ) : null}
+ onClose={onClose}
+ blockClose={saving || Boolean(generating)}
+ loading={loading}
+ footerActions={invoice && !loading ? (
+ <>
+ {invoice.status === 'draft' && Finalize & Send}
+ {invoice.status === 'sent' && Resend Invoice}
+ {invoice.status === 'sent' && }
+ {invoice.status === 'paid' && }
+ Download Invoice
+ {invoice.status === 'paid' && <>
+ Download Receipt
+ Send Receipt
+ >}
+ {invoice.status !== 'paid' && }
+
+ >
+ ) : null}
+ >
+ {invoice ? (
+
+ {invoice.bill_to || company?.name}
+
+ View Company
+
+ >}
+ headerActions={!editingDates
+ ?
+ :
+
+
+
+ }
+ rightCardTitle="Invoice Details"
+ rightCardBody={
+
+
+
+ {editingDates
+ ? setDateForm(f => ({ ...f, invoice_date: e.target.value }))} />
+ : {new Date(invoice.invoice_date).toLocaleDateString()} }
+
+
+
+ {editingDates
+ ? setDateForm(f => ({ ...f, due_date: e.target.value }))} />
+ : {new Date(invoice.due_date).toLocaleDateString()} }
+
+
+
+
+
+
+
+
+ setEmailRecipient(e.target.value)} onBlur={handleEmailBlur} placeholder="client@example.com" disabled={saving} />
+
+ ${Number(invoice.total).toFixed(2)}
+ {invoice.paid_at && {new Date(invoice.paid_at).toLocaleDateString()} }
+ {invoice.status === 'paid' && invoice.stripe_fee != null && <>
+ −${Number(invoice.stripe_fee).toFixed(2)}
+ ${(Number(invoice.total) - Number(invoice.stripe_fee)).toFixed(2)}
+ >}
+
+ }
+ sortKey={sortKey}
+ sortDir={sortDir}
+ onSort={toggle}
+ items={tableItems}
+ notes={invoice.notes}
+ total={Number(invoice.total)}
+ />
+ ) : (
+ Invoice not found.
+ )}
+
+ );
+ }
- const isOverdue = invoice.status !== 'paid' && new Date(invoice.due_date) < new Date();
const detailContent = (
<>
{!embedded && }
@@ -571,18 +656,7 @@ export function TeamInvoiceDetailPanel({
>
);
- if (embedded) {
- return (
- { if (!saving && !generating) onClose?.(); }}>
- e.stopPropagation()}
- >
- {detailContent}
-
-
- );
- }
+
return {detailContent};
}
diff --git a/src/pages/team/TeamInvoices.jsx b/src/pages/team/TeamInvoices.jsx
index 05e3036..a2b6150 100644
--- a/src/pages/team/TeamInvoices.jsx
+++ b/src/pages/team/TeamInvoices.jsx
@@ -18,6 +18,8 @@ import FileAttachment from '../../components/FileAttachment';
import { isReviewShadowDescription } from '../../lib/taskVersions';
import { getRevisionChargeQuantity, isCompletedVersionEligible, isInitialVersionEligible } from '../../lib/invoiceVersionRules';
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 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: ovSubKey, sortDir: ovSubDir, toggle: ovSubToggle } = useSortable('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 initMonth = () => { const n = new Date(); return { month: n.getMonth(), year: n.getFullYear() }; };
const [ovMonth1, setOvMonth1] = useState(initMonth);
@@ -1712,114 +1715,68 @@ export default function Invoices() {
{viewingSubInvoice && (() => {
const invoice = viewingSubInvoice;
- const sortedItems = [...(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 baseItems = [...(invoice.items || [])].sort((a, b) => (a.sort_order ?? 0) - (b.sort_order ?? 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 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 (
- { if (!markingPaid) setViewingSubInvoice(null); }}>
- e.stopPropagation()}>
-
-
- {invoice.invoice_number || 'Subcontractor Invoice'}
-
- {invoice.profile?.name || 'External'}{invoice.profile?.email ? ` · ${invoice.profile.email}` : ''}
-
+ }
+ onClose={() => { if (!markingPaid) setViewingSubInvoice(null); }}
+ blockClose={Boolean(markingPaid)}
+ footerActions={<>
+
+ {invoice.status === 'submitted' && (
+ {
+ await handleMarkSubInvoicePaid(invoice);
+ setViewingSubInvoice(current => current?.id === invoice.id ? { ...current, status: 'paid', paid_at: new Date().toISOString() } : current);
+ }} loading={markingPaid === invoice.id} loadingText="Processing…">Mark as Paid
+ )}
+ {invoice.status === 'paid' && (
+
+ )}
+
+ >}
+ >
+
+ {invoice.profile?.name || 'External'}
+ {invoice.profile?.email || '—'}
+ >}
+ rightCardTitle="Invoice Details"
+ rightCardBody={
+
+ {invoice.invoice_number || '—'}
+
+ {invoice.submitted_at ? new Date(invoice.submitted_at).toLocaleDateString() : '—'}
+ {invoice.paid_at && {new Date(invoice.paid_at).toLocaleDateString()} }
+ {baseItems.length}
+
-
-
-
-
-
- Submitted
- {invoice.submitted_at ? new Date(invoice.submitted_at).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) : '—'}
-
-
- Paid
- {invoice.paid_at ? new Date(invoice.paid_at).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) : '—'}
-
-
- Line Items
- {sortedItems.length}
-
-
- Total
- ${total.toFixed(2)}
-
-
-
-
- {sortedItems.length === 0 ? (
- No line items
- ) : (
-
-
-
-
-
-
-
-
-
-
-
-
- | Work |
- R# |
- Description |
- Type |
- Qty |
- Unit Price |
- Amount |
-
-
-
- {sortedItems.map(item => (
-
- | {subInvoiceItemWorkLabel(item)} |
- {subInvoiceItemVersionLabel(item)} |
- {cleanSubInvoiceItemDescription(item)} |
- {viewingSubInvoiceTaskTypeMap[item.task_id] || 'Other'} |
- {item.quantity || 1} |
- ${Number(item.unit_price || 0).toFixed(2)} |
- ${(Number(item.unit_price || 0) * Number(item.quantity || 1)).toFixed(2)} |
-
- ))}
-
-
- )}
-
- {invoice.notes && (
-
- Notes
- {invoice.notes}
-
- )}
-
-
-
-
- {invoice.status === 'submitted' && (
- {
- await handleMarkSubInvoicePaid(invoice);
- setViewingSubInvoice(current => current?.id === invoice.id ? { ...current, status: 'paid', paid_at: new Date().toISOString() } : current);
- }} loading={markingPaid === invoice.id} loadingText="Processing…">
- Mark as Paid
-
- )}
- {invoice.status === 'paid' && (
-
- )}
-
-
-
-
+ }
+ sortKey={subPopupKey}
+ sortDir={subPopupDir}
+ onSort={subPopupToggle}
+ items={tableItems}
+ notes={invoice.notes}
+ total={total}
+ />
+
);
})()}
|