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,14 +1,13 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import Layout from '../../components/Layout';
|
||||
import SortTh from '../../components/SortTh';
|
||||
import StatusBadge from '../../components/StatusBadge';
|
||||
import PageLoader from '../../components/PageLoader';
|
||||
import LoadingButton from '../../components/LoadingButton';
|
||||
import { supabase } from '../../lib/supabase';
|
||||
import { generateSubcontractorPOPDF } from '../../lib/invoice';
|
||||
import { blobToEmailAttachment, sendEmail } from '../../lib/email';
|
||||
import { useSortable } from '../../hooks/useSortable';
|
||||
|
||||
const statusColor = { draft: 'not_started', submitted: 'in_progress', paid: 'client_approved' };
|
||||
import SubcontractorInvoiceDetailView from '../../components/SubcontractorInvoiceDetailView';
|
||||
|
||||
export default function SubInvoiceDetail() {
|
||||
const { id } = useParams();
|
||||
@@ -17,6 +16,7 @@ export default function SubInvoiceDetail() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [generating, setGenerating] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const { sortKey, sortDir, toggle, sort } = useSortable('description');
|
||||
|
||||
useEffect(() => {
|
||||
@@ -25,18 +25,24 @@ export default function SubInvoiceDetail() {
|
||||
.select('*, profile:profiles!subcontractor_invoices_profile_id_fkey(id, name, email), items:subcontractor_invoice_items(*)')
|
||||
.eq('id', id)
|
||||
.single()
|
||||
.then(({ data }) => {
|
||||
setInvoice(data);
|
||||
.then(({ data, error: err }) => {
|
||||
if (err || !data) setError('Invoice not found.');
|
||||
setInvoice(data || null);
|
||||
setLoading(false);
|
||||
});
|
||||
}, [id]);
|
||||
|
||||
const total = (invoice?.items || []).reduce((s, i) => s + Number(i.unit_price || 0) * Number(i.quantity || 1), 0);
|
||||
const total = (invoice?.items || []).reduce((sum, item) => sum + Number(item.unit_price || 0) * Number(item.quantity || 1), 0);
|
||||
const sortedItems = [...(invoice?.items || [])].sort((a, b) => (a.sort_order ?? 0) - (b.sort_order ?? 0));
|
||||
const tableItems = sort(sortedItems, (item, key) => {
|
||||
const typeSort = /\bR(\d{2})\b/i.test(String(item.description || ''))
|
||||
? Number(String(item.description).match(/\bR(\d{2})\b/i)?.[1] || 0)
|
||||
: item.task_id ? 0 : 999;
|
||||
if (key === 'type') return typeSort;
|
||||
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 === 'amount') return Number(item.unit_price || 0) * Number(item.quantity || 1);
|
||||
if (key === 'line_total') return Number(item.unit_price || 0) * Number(item.quantity || 1);
|
||||
return '';
|
||||
});
|
||||
|
||||
@@ -58,15 +64,18 @@ export default function SubInvoiceDetail() {
|
||||
});
|
||||
|
||||
const handleMarkPaid = async () => {
|
||||
if (!invoice) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
const paidAt = new Date().toISOString();
|
||||
const { error } = await supabase.from('subcontractor_invoices').update({ status: 'paid', paid_at: paidAt }).eq('id', id);
|
||||
if (error) throw error;
|
||||
const { error: updateError } = await supabase.from('subcontractor_invoices').update({ status: 'paid', paid_at: paidAt }).eq('id', id);
|
||||
if (updateError) throw updateError;
|
||||
const updated = { ...invoice, status: 'paid', paid_at: paidAt };
|
||||
setInvoice(updated);
|
||||
let pdfBlob = null;
|
||||
try { pdfBlob = await generateSubcontractorPOPDF(buildPDFArgs(updated), { output: 'blob' }); } catch {}
|
||||
try {
|
||||
pdfBlob = await generateSubcontractorPOPDF(buildPDFArgs(updated), { output: 'blob' });
|
||||
} catch { /* PDF generation failed — continue without attachment */ }
|
||||
if (invoice.profile?.email) {
|
||||
try {
|
||||
const attachments = pdfBlob ? [await blobToEmailAttachment(pdfBlob, `${invoice.invoice_number}-receipt.pdf`)] : [];
|
||||
@@ -77,136 +86,90 @@ export default function SubInvoiceDetail() {
|
||||
paidDate: new Date(paidAt).toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' }),
|
||||
}, attachments);
|
||||
} catch (emailErr) {
|
||||
alert(`Marked paid, but email failed: ${emailErr.message || 'unknown error'}`);
|
||||
setError(`Marked paid, but email failed: ${emailErr.message || 'unknown error'}`);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
alert(`Failed to mark as paid: ${err.message}`);
|
||||
setError(`Failed to mark as paid: ${err.message}`);
|
||||
}
|
||||
setSaving(false);
|
||||
};
|
||||
|
||||
const handleReceipt = async () => {
|
||||
if (!invoice) return;
|
||||
setGenerating(true);
|
||||
try {
|
||||
await generateSubcontractorPOPDF(buildPDFArgs(invoice));
|
||||
} catch (err) {
|
||||
alert(err.message);
|
||||
setError(err.message);
|
||||
}
|
||||
setGenerating(false);
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!invoice) return;
|
||||
if (!window.confirm(`Delete invoice ${invoice.invoice_number}? This cannot be undone.`)) return;
|
||||
setSaving(true);
|
||||
const { error } = await supabase.from('subcontractor_invoices').delete().eq('id', id);
|
||||
if (error) { alert('Failed to delete: ' + error.message); setSaving(false); return; }
|
||||
navigate('/invoices', { state: { tab: 'sub-invoices' } });
|
||||
const { error: deleteError } = await supabase.from('subcontractor_invoices').delete().eq('id', id);
|
||||
if (deleteError) {
|
||||
setError(`Failed to delete: ${deleteError.message}`);
|
||||
setSaving(false);
|
||||
return;
|
||||
}
|
||||
navigate('/finances', { state: { tab: 'sub-invoices' } });
|
||||
};
|
||||
|
||||
if (loading) return <Layout><p style={{ padding: 24, color: 'var(--text-muted)' }}>Loading...</p></Layout>;
|
||||
if (!invoice) return <Layout><p style={{ padding: 24 }}>Invoice not found.</p></Layout>;
|
||||
if (loading) return <Layout><PageLoader /></Layout>;
|
||||
if (!invoice) return <Layout><p style={{ padding: 24 }}>{error || 'Invoice not found.'}</p></Layout>;
|
||||
|
||||
const sortedItems = [...(invoice.items || [])].sort((a, b) => (a.sort_order ?? 0) - (b.sort_order ?? 0));
|
||||
const headerActions = (
|
||||
<>
|
||||
{invoice.status === 'submitted' ? (
|
||||
<LoadingButton className="btn btn-outline" loading={saving} loadingText="Processing…" disabled={saving} onClick={handleMarkPaid}>
|
||||
Mark as Paid
|
||||
</LoadingButton>
|
||||
) : null}
|
||||
{invoice.status === 'paid' ? (
|
||||
<LoadingButton className="btn btn-outline" loading={generating} loadingText="Generating…" disabled={generating} onClick={handleReceipt}>
|
||||
Download Receipt
|
||||
</LoadingButton>
|
||||
) : null}
|
||||
<button className="btn btn-outline" style={{ color: 'var(--danger)', borderColor: 'var(--danger)' }} onClick={handleDelete} disabled={saving}>
|
||||
Delete
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<button className="back-link" onClick={() => navigate('/invoices', { state: { tab: 'sub-invoices' } })}>
|
||||
← Back to Subcontractor Invoices
|
||||
</button>
|
||||
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<div className="page-title">{invoice.invoice_number}</div>
|
||||
<div className="page-subtitle">
|
||||
{invoice.profile?.name || 'External'} · {invoice.profile?.email || '—'}
|
||||
</div>
|
||||
</div>
|
||||
<StatusBadge
|
||||
status={statusColor[invoice.status] || 'not_started'}
|
||||
label={invoice.status ? invoice.status.charAt(0).toUpperCase() + invoice.status.slice(1) : '—'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid-2" style={{ marginBottom: 24 }}>
|
||||
<div className="card">
|
||||
<div className="card-title">Invoice Info</div>
|
||||
<div className="detail-grid" style={{ marginBottom: 0 }}>
|
||||
<div className="detail-item"><label>Invoice #</label><p style={{ fontWeight: 400 }}>{invoice.invoice_number}</p></div>
|
||||
<div className="detail-item"><label>Status</label><p style={{ textTransform: 'capitalize' }}>{invoice.status}</p></div>
|
||||
<div className="detail-item"><label>Submitted</label><p>{invoice.submitted_at ? new Date(invoice.submitted_at).toLocaleDateString() : '—'}</p></div>
|
||||
{invoice.paid_at && (
|
||||
<div className="detail-item"><label>Paid On</label><p style={{ color: 'var(--success)', fontWeight: 400 }}>{new Date(invoice.paid_at).toLocaleDateString()}</p></div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="card">
|
||||
<div className="card-title">Summary</div>
|
||||
<div className="detail-grid" style={{ marginBottom: 0 }}>
|
||||
<div className="detail-item"><label>Line Items</label><p>{sortedItems.length}</p></div>
|
||||
<div className="detail-item"><label>Total</label><p style={{ fontWeight: 400, fontSize: 18 }}>${total.toFixed(2)}</p></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card" style={{ marginBottom: 24 }}>
|
||||
<div className="card-title">Line Items</div>
|
||||
{sortedItems.length === 0 ? (
|
||||
<p style={{ color: 'var(--text-muted)', fontSize: 13 }}>No line items.</p>
|
||||
) : (
|
||||
<div className="table-wrapper" style={{ border: 'none' }}>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<SortTh col="description" sortKey={sortKey} sortDir={sortDir} onSort={toggle}>Description</SortTh>
|
||||
<SortTh col="quantity" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ textAlign: 'right' }}>Qty</SortTh>
|
||||
<SortTh col="unit_price" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ textAlign: 'right' }}>Unit Price</SortTh>
|
||||
<SortTh col="amount" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ textAlign: 'right' }}>Amount</SortTh>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{tableItems.map(item => (
|
||||
<tr key={item.id}>
|
||||
<td>{item.description}</td>
|
||||
<td style={{ textAlign: 'right' }}>{item.quantity}</td>
|
||||
<td style={{ textAlign: 'right' }}>${Number(item.unit_price).toFixed(2)}</td>
|
||||
<td style={{ textAlign: 'right', fontWeight: 400 }}>${(Number(item.unit_price) * Number(item.quantity || 1)).toFixed(2)}</td>
|
||||
</tr>
|
||||
))}
|
||||
<tr>
|
||||
<td colSpan={3} style={{ textAlign: 'right', fontWeight: 400, borderTop: '1px solid var(--border)', paddingTop: 10 }}>Total</td>
|
||||
<td style={{ textAlign: 'right', fontWeight: 400, fontSize: 16, borderTop: '1px solid var(--border)', paddingTop: 10 }}>${total.toFixed(2)}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<SubcontractorInvoiceDetailView
|
||||
error={error}
|
||||
headerActions={headerActions}
|
||||
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>{invoice.status ? invoice.status.charAt(0).toUpperCase() + invoice.status.slice(1) : '—'}</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> : null}
|
||||
<div className="invoice-detail-meta-item"><label>Line Items</label><p>{sortedItems.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>
|
||||
)}
|
||||
{invoice.notes && (
|
||||
<div style={{ marginTop: 16, padding: '12px 14px', background: 'var(--bg)', borderRadius: 4, border: '1px solid var(--border)' }}>
|
||||
<div style={{ fontSize: 11, fontWeight: 400, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: 0.5, marginBottom: 6 }}>Notes</div>
|
||||
<p style={{ fontSize: 13, color: 'var(--text-secondary)', whiteSpace: 'pre-wrap', margin: 0 }}>{invoice.notes}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<div className="card-title">Actions</div>
|
||||
<div className="action-buttons">
|
||||
{invoice.status === 'submitted' && (
|
||||
<button className="btn btn-success" onClick={handleMarkPaid} disabled={saving}>
|
||||
{saving ? 'Processing…' : 'Mark as Paid'}
|
||||
</button>
|
||||
)}
|
||||
{invoice.status === 'paid' && (
|
||||
<button className="btn btn-outline" onClick={handleReceipt} disabled={generating}>
|
||||
{generating ? 'Generating…' : 'Download Receipt'}
|
||||
</button>
|
||||
)}
|
||||
<button className="btn-icon btn-icon-danger" title="Delete Invoice" onClick={handleDelete} disabled={saving}>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
sortKey={sortKey}
|
||||
sortDir={sortDir}
|
||||
onSort={toggle}
|
||||
items={tableItems}
|
||||
notes={invoice.notes}
|
||||
total={total}
|
||||
/>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user