c91e292066
Design system (Layout2): - Solid token-driven theme (no animated grid); single-source tokens for bg, cards (bg/border/radius), popups, fonts (Inter), and full semantic + data-viz color palette. Swept ~168 hardcoded colors to tokens. Dashboard: - Reflow: To Do + Calendar row (fixed right rail) over Activity / In-Progress / Team Performance; CSS height-match (To Do = Calendar); responsive stacking. - 'Tasks Not Started' -> 'To Do' card with R#/Assigned columns; compact money fmt. Tasks page: - Combined tabs into Tasks + Completed with a Status column. - Column-header sort + filter (Status, R#/new-vs-revision, Project, Task Type, Company) via portaled, scrollable FilterDropdown; removed toolbar filters and the projects side panel; headers stay visible when empty; renamed to 'Tasks'. Perf: - FK/filter index migration; removed redundant client/external scope waterfall (rely on RLS); parallel follow-up queries. Mobile: - Responsive grids; mobile topbar = hamburger only, blends with bg, proper offset. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
176 lines
7.5 KiB
React
176 lines
7.5 KiB
React
import { useState, useEffect } from 'react';
|
|
import { useParams, useNavigate } from 'react-router-dom';
|
|
import Layout from '../../components/Layout';
|
|
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';
|
|
import SubcontractorInvoiceDetailView from '../../components/SubcontractorInvoiceDetailView';
|
|
|
|
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);
|
|
const [error, setError] = useState('');
|
|
const { sortKey, sortDir, toggle, sort } = useSortable('description');
|
|
|
|
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, error: err }) => {
|
|
if (err || !data) setError('Invoice not found.');
|
|
setInvoice(data || null);
|
|
setLoading(false);
|
|
});
|
|
}, [id]);
|
|
|
|
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 === 'line_total') return Number(item.unit_price || 0) * Number(item.quantity || 1);
|
|
return '';
|
|
});
|
|
|
|
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 () => {
|
|
if (!invoice) return;
|
|
setSaving(true);
|
|
try {
|
|
const paidAt = new Date().toISOString();
|
|
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 { /* PDF generation failed — continue without attachment */ }
|
|
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) {
|
|
setError(`Marked paid, but email failed: ${emailErr.message || 'unknown error'}`);
|
|
}
|
|
}
|
|
} catch (err) {
|
|
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) {
|
|
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: 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><PageLoader /></Layout>;
|
|
if (!invoice) return <Layout><p style={{ padding: 24 }}>{error || 'Invoice not found.'}</p></Layout>;
|
|
|
|
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>
|
|
<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, var(--success-strong))' }}>{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>
|
|
)}
|
|
sortKey={sortKey}
|
|
sortDir={sortDir}
|
|
onSort={toggle}
|
|
items={tableItems}
|
|
notes={invoice.notes}
|
|
total={total}
|
|
/>
|
|
</Layout>
|
|
);
|
|
}
|