Add sub invoice detail page with delete/mark paid/receipt; fix payable stat

This commit is contained in:
Krao Hasanee
2026-05-14 19:35:44 -04:00
parent 13bb0f7914
commit 6b5f5df547
3 changed files with 218 additions and 76 deletions
+2
View File
@@ -21,6 +21,7 @@ const CreateInvoice = lazy(() => import('./pages/team/CreateInvoice'));
const CreateSubcontractorPO = lazy(() => import('./pages/team/CreateSubcontractorPO'));
const InvoiceDetail = lazy(() => import('./pages/team/InvoiceDetail'));
const SubcontractorPODetail = lazy(() => import('./pages/team/SubcontractorPODetail'));
const SubInvoiceDetail = lazy(() => import('./pages/team/SubInvoiceDetail'));
const SurveyMaker = lazy(() => import('./pages/team/SurveyMaker'));
const BrandBook = lazy(() => import('./pages/team/BrandBook'));
const Converters = lazy(() => import('./pages/team/Converters'));
@@ -64,6 +65,7 @@ export default function App() {
<Route path="/subcontractor-pos/new" element={<ProtectedRoute role="team"><CreateSubcontractorPO /></ProtectedRoute>} />
<Route path="/invoices/:id" element={<ProtectedRoute role="team"><InvoiceDetail /></ProtectedRoute>} />
<Route path="/subcontractor-pos/:id" element={<ProtectedRoute role="team"><SubcontractorPODetail /></ProtectedRoute>} />
<Route path="/sub-invoices/:id" element={<ProtectedRoute role="team"><SubInvoiceDetail /></ProtectedRoute>} />
<Route path="/survey-maker" element={<ProtectedRoute role={['team', 'external']}><SurveyMaker /></ProtectedRoute>} />
<Route path="/brand-book" element={<ProtectedRoute role={['team', 'external']}><BrandBook /></ProtectedRoute>} />
<Route path="/converters" element={<ProtectedRoute role={['team', 'external']}><Converters /></ProtectedRoute>} />
+16 -76
View File
@@ -430,7 +430,10 @@ export default function Invoices() {
const payableSubcontractorPOs = subcontractorPOs.filter(po => ['approved', 'ready_to_pay'].includes(po.status));
const selectedSubcontractorPO = subcontractorPOs.find(po => po.id === selectedSubcontractorPOId);
const totalPaidSubcontractors = paidSubcontractorPOs.reduce((s, po) => s + Number(po.amount), 0);
const totalPayableSubcontractors = payableSubcontractorPOs.reduce((s, po) => s + Number(po.amount), 0);
const totalPayableSubcontractorPOs = payableSubcontractorPOs.reduce((s, po) => s + Number(po.amount), 0);
const totalPayableSubInvoices = subInvoices.filter(i => i.status === 'submitted').reduce((s, i) => s + (i.items || []).reduce((a, x) => a + Number(x.unit_price || 0) * Number(x.quantity || 1), 0), 0);
const totalPayableSubcontractors = totalPayableSubcontractorPOs + totalPayableSubInvoices;
const payableSubcontractorCount = payableSubcontractorPOs.length + subInvoices.filter(i => i.status === 'submitted').length;
const totalExpenses = filteredExpenses.reduce((s, e) => s + Number(e.amount), 0);
const currentYear = new Date().getFullYear();
const yearExpenses = expenses.filter(e => new Date(e.date).getFullYear() === currentYear);
@@ -494,7 +497,7 @@ export default function Invoices() {
</div>
<div className="stat-card">
<div className="stat-value" style={{ fontSize: 22 }}>${totalPayableSubcontractors.toFixed(2)}</div>
<div className="stat-label">Subcontractors Payable · {payableSubcontractorPOs.length} approved/ready</div>
<div className="stat-label">Subcontractors Payable · {payableSubcontractorCount} pending</div>
</div>
</div>
@@ -860,82 +863,19 @@ export default function Invoices() {
<tbody>
{subInvoices.map(inv => {
const total = (inv.items || []).reduce((s, i) => s + Number(i.unit_price || 0) * Number(i.quantity || 1), 0);
const isExpanded = expandedSubInvoiceId === inv.id;
const subInvoiceStatusColor = { draft: 'not_started', submitted: 'in_progress', paid: 'client_approved' };
return (
<>
<tr key={inv.id} style={{ cursor: 'pointer' }} onClick={() => setExpandedSubInvoiceId(isExpanded ? null : inv.id)}>
<td style={{ fontWeight: 700 }}>{inv.invoice_number}</td>
<td>
<div style={{ fontWeight: 600 }}>{inv.profile?.name || 'External'}</div>
<div style={{ color: 'var(--text-muted)', fontSize: 12 }}>{inv.profile?.email || '—'}</div>
</td>
<td>{inv.submitted_at ? new Date(inv.submitted_at).toLocaleDateString() : <span style={{ color: 'var(--text-muted)' }}></span>}</td>
<td><span className={`badge badge-${subInvoiceStatusColor[inv.status] || 'not_started'}`} style={{ textTransform: 'capitalize' }}>{inv.status}</span></td>
<td style={{ textAlign: 'right', fontWeight: 700 }}>${total.toFixed(2)}</td>
<td style={{ textAlign: 'right' }}>
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 6 }} onClick={e => e.stopPropagation()}>
{inv.status === 'submitted' && (
<button
className="btn btn-success btn-sm"
disabled={markingPaid === inv.id}
onClick={() => handleMarkSubInvoicePaid(inv)}
>
{markingPaid === inv.id ? 'Processing…' : 'Mark Paid'}
</button>
)}
{inv.status === 'paid' && (
<button
className="btn btn-outline btn-sm"
onClick={() => generateSubcontractorPOPDF({
po_number: inv.invoice_number, status: 'paid',
profile: inv.profile,
project: { name: 'Subcontractor Invoice', company: { name: 'Fourge Branding' } },
date: inv.created_at?.split('T')[0],
due_date: inv.paid_at?.split('T')[0],
amount: total,
description: 'Payment for completed subcontractor work.',
notes: inv.notes,
items: (inv.items || []).map((item, idx) => ({ description: item.description, amount: Number(item.unit_price) * Number(item.quantity || 1), sort_order: item.sort_order ?? idx })),
}).catch(err => alert(err.message))}
>
Receipt
</button>
)}
</div>
</td>
</tr>
{isExpanded && (
<tr key={`${inv.id}-detail`}>
<td colSpan={6} style={{ background: 'var(--bg)', padding: '12px 16px' }}>
{inv.items?.length > 0 ? (
<table style={{ width: '100%', fontSize: 13 }}>
<thead>
<tr>
<th style={{ textAlign: 'left', fontWeight: 600, paddingBottom: 6 }}>Description</th>
<th style={{ textAlign: 'right', fontWeight: 600, paddingBottom: 6 }}>Qty</th>
<th style={{ textAlign: 'right', fontWeight: 600, paddingBottom: 6 }}>Unit Price</th>
<th style={{ textAlign: 'right', fontWeight: 600, paddingBottom: 6 }}>Amount</th>
</tr>
</thead>
<tbody>
{(inv.items || []).slice().sort((a, b) => (a.sort_order ?? 0) - (b.sort_order ?? 0)).map(item => (
<tr key={item.id}>
<td style={{ paddingBottom: 4 }}>{item.description}</td>
<td style={{ textAlign: 'right', paddingBottom: 4 }}>{item.quantity}</td>
<td style={{ textAlign: 'right', paddingBottom: 4 }}>${Number(item.unit_price).toFixed(2)}</td>
<td style={{ textAlign: 'right', fontWeight: 700, paddingBottom: 4 }}>${(Number(item.unit_price) * Number(item.quantity || 1)).toFixed(2)}</td>
</tr>
))}
</tbody>
</table>
) : <span style={{ color: 'var(--text-muted)', fontSize: 13 }}>No line items.</span>}
{inv.notes && <p style={{ marginTop: 8, fontSize: 13, color: 'var(--text-secondary)', whiteSpace: 'pre-wrap' }}>{inv.notes}</p>}
{inv.paid_at && <p style={{ marginTop: 4, fontSize: 12, color: 'var(--text-muted)' }}>Paid {new Date(inv.paid_at).toLocaleDateString()}</p>}
</td>
</tr>
)}
</>
<tr key={inv.id} style={{ cursor: 'pointer' }} onClick={() => navigate(`/sub-invoices/${inv.id}`)}>
<td style={{ fontWeight: 700 }}>{inv.invoice_number}</td>
<td>
<div style={{ fontWeight: 600 }}>{inv.profile?.name || 'External'}</div>
<div style={{ color: 'var(--text-muted)', fontSize: 12 }}>{inv.profile?.email || ''}</div>
</td>
<td>{inv.submitted_at ? new Date(inv.submitted_at).toLocaleDateString() : <span style={{ color: 'var(--text-muted)' }}></span>}</td>
<td><span className={`badge badge-${subInvoiceStatusColor[inv.status] || 'not_started'}`} style={{ textTransform: 'capitalize' }}>{inv.status}</span></td>
<td style={{ textAlign: 'right', fontWeight: 700 }}>${total.toFixed(2)}</td>
<td />
</tr>
);
})}
</tbody>
+200
View File
@@ -0,0 +1,200 @@
import { useState, useEffect } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import Layout from '../../components/Layout';
import { supabase } from '../../lib/supabase';
import { generateSubcontractorPOPDF } from '../../lib/invoice';
import { blobToEmailAttachment, sendEmail } from '../../lib/email';
const statusColor = { draft: 'not_started', submitted: 'in_progress', paid: 'client_approved' };
export default function SubInvoiceDetail() {
const { id } = useParams();
const navigate = useNavigate();
const [invoice, setInvoice] = useState(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [generating, setGenerating] = useState(false);
useEffect(() => {
supabase
.from('subcontractor_invoices')
.select('*, profile:profiles!subcontractor_invoices_profile_id_fkey(id, name, email), items:subcontractor_invoice_items(*)')
.eq('id', id)
.single()
.then(({ data }) => {
setInvoice(data);
setLoading(false);
});
}, [id]);
const total = (invoice?.items || []).reduce((s, i) => s + Number(i.unit_price || 0) * Number(i.quantity || 1), 0);
const buildPDFArgs = (inv) => ({
po_number: inv.invoice_number,
status: 'paid',
profile: inv.profile,
project: { name: 'Subcontractor Invoice', company: { name: 'Fourge Branding' } },
date: inv.created_at?.split('T')[0],
due_date: (inv.paid_at || new Date().toISOString()).split('T')[0],
amount: total,
description: 'Payment for completed subcontractor work.',
notes: inv.notes,
items: (inv.items || []).map((item, idx) => ({
description: item.description,
amount: Number(item.unit_price) * Number(item.quantity || 1),
sort_order: item.sort_order ?? idx,
})),
});
const handleMarkPaid = async () => {
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 updated = { ...invoice, status: 'paid', paid_at: paidAt };
setInvoice(updated);
let pdfBlob = null;
try { pdfBlob = await generateSubcontractorPOPDF(buildPDFArgs(updated), { output: 'blob' }); } catch {}
if (invoice.profile?.email) {
try {
const attachments = pdfBlob ? [await blobToEmailAttachment(pdfBlob, `${invoice.invoice_number}-receipt.pdf`)] : [];
await sendEmail('receipt_sent', invoice.profile.email, {
invoiceNumber: invoice.invoice_number,
billTo: invoice.profile.name || invoice.profile.email,
total: total.toFixed(2),
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'}`);
}
}
} catch (err) {
alert(`Failed to mark as paid: ${err.message}`);
}
setSaving(false);
};
const handleReceipt = async () => {
setGenerating(true);
try {
await generateSubcontractorPOPDF(buildPDFArgs(invoice));
} catch (err) {
alert(err.message);
}
setGenerating(false);
};
const handleDelete = async () => {
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' } });
};
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>;
const sortedItems = [...(invoice.items || [])].sort((a, b) => (a.sort_order ?? 0) - (b.sort_order ?? 0));
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>
<span className={`badge badge-${statusColor[invoice.status] || 'not_started'}`} style={{ fontSize: 13, padding: '6px 14px', textTransform: 'capitalize' }}>
{invoice.status}
</span>
</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: 700 }}>{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: 600 }}>{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: 700, 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>
<th>Description</th>
<th style={{ textAlign: 'right' }}>Qty</th>
<th style={{ textAlign: 'right' }}>Unit Price</th>
<th style={{ textAlign: 'right' }}>Amount</th>
</tr>
</thead>
<tbody>
{sortedItems.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: 700 }}>${(Number(item.unit_price) * Number(item.quantity || 1)).toFixed(2)}</td>
</tr>
))}
<tr>
<td colSpan={3} style={{ textAlign: 'right', fontWeight: 700, borderTop: '1px solid var(--border)', paddingTop: 10 }}>Total</td>
<td style={{ textAlign: 'right', fontWeight: 700, fontSize: 16, borderTop: '1px solid var(--border)', paddingTop: 10 }}>${total.toFixed(2)}</td>
</tr>
</tbody>
</table>
</div>
)}
{invoice.notes && (
<div style={{ marginTop: 16, padding: '12px 14px', background: 'var(--bg)', borderRadius: 8, border: '1px solid var(--border)' }}>
<div style={{ fontSize: 11, fontWeight: 600, 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 btn-danger" onClick={handleDelete} disabled={saving}>
Delete Invoice
</button>
</div>
</div>
</Layout>
);
}