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:
+524
-99
@@ -1,16 +1,27 @@
|
||||
import { useEffect, useState, useMemo } from 'react';
|
||||
import { AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from 'recharts';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import Layout from '../../components/Layout';
|
||||
import StatusBadge from '../../components/StatusBadge';
|
||||
import SortTh from '../../components/SortTh';
|
||||
import LoadingButton from '../../components/LoadingButton';
|
||||
import { supabase } from '../../lib/supabase';
|
||||
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 { generateSubcontractorPOPDF } from '../../lib/invoice';
|
||||
|
||||
const STATUS_BADGE = { draft: 'not_started', submitted: 'in_progress', paid: 'client_approved' };
|
||||
const STATUS_LABEL = { draft: 'Draft', submitted: 'Submitted', paid: 'Paid' };
|
||||
const FIELD_LABEL_STYLE = { fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', textTransform: 'uppercase', letterSpacing: 0.8, display: 'block', marginBottom: 4 };
|
||||
|
||||
const STAT_ICONS = {
|
||||
newTasks: '<rect x="3" y="4" width="18" height="16" rx="2"/><line x1="7" y1="9" x2="17" y2="9"/><line x1="7" y1="13" x2="17" y2="13"/>',
|
||||
revisions: '<path d="M4 12a8 8 0 018-8v0a8 8 0 018 8"/><polyline points="18,8 20,12 16,12"/>',
|
||||
invoiced: '<path d="M5 3h11l3 3v15H5z"/><path d="M16 3v4h4"/><line x1="8" y1="11" x2="16" y2="11"/><line x1="8" y1="15" x2="16" y2="15"/>',
|
||||
paid: '<polyline points="4,12 9,17 20,6"/>',
|
||||
};
|
||||
|
||||
function fmt(val) {
|
||||
return `$${Number(val || 0).toFixed(2)}`;
|
||||
@@ -20,121 +31,535 @@ function invoiceTotal(items) {
|
||||
return (items || []).reduce((s, i) => s + Number(i.unit_price || 0) * Number(i.quantity || 1), 0);
|
||||
}
|
||||
|
||||
function TaskStatCard({ label, value, sub, iconBg, iconColor, iconPath }) {
|
||||
return (
|
||||
<div style={{ background: 'var(--card-bg)', backdropFilter: 'blur(12px)', WebkitBackdropFilter: 'blur(12px)', border: '1px solid var(--border)', borderRadius: 8, padding: '18px 21px', minHeight: 120 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 10 }}>
|
||||
<div style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase', marginBottom: 5 }}>{label}</div>
|
||||
<div style={{ width: 27, height: 27, borderRadius: '50%', background: iconBg, display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke={iconColor} strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" dangerouslySetInnerHTML={{ __html: iconPath }} />
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ fontSize: 30, fontWeight: 400, color: 'var(--text-primary)', letterSpacing: -0.5, lineHeight: 1.1 }}>{value}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-secondary)', marginTop: 5 }}>{sub}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function asArray(value) {
|
||||
if (Array.isArray(value)) return value;
|
||||
if (value == null) return [];
|
||||
return typeof value === 'object' ? [value] : [];
|
||||
}
|
||||
|
||||
function parseItemVersionNumber(item) {
|
||||
if (Number.isFinite(Number(item?.version_number))) return Number(item.version_number);
|
||||
const match = String(item?.description || '').match(/\bR(\d{2})\b/i);
|
||||
return match ? Number(match[1]) : 0;
|
||||
}
|
||||
|
||||
function itemVersionLabel(item) {
|
||||
return `R${String(parseItemVersionNumber(item)).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function itemWorkLabel(item) {
|
||||
return parseItemVersionNumber(item) > 0 ? 'Revision' : 'New';
|
||||
}
|
||||
|
||||
function cleanItemDescription(item) {
|
||||
return String(item?.description || '—').replace(/\s*[–-]\s*R\d{2}\b/i, '').trim() || '—';
|
||||
}
|
||||
|
||||
async function fetchTaskTypeMap(taskIds) {
|
||||
const uniqueTaskIds = [...new Set((taskIds || []).filter(Boolean))];
|
||||
if (uniqueTaskIds.length === 0) return {};
|
||||
const { data, error } = await supabase
|
||||
.from('tasks')
|
||||
.select('id, title, submissions(service_type, type)')
|
||||
.in('id', uniqueTaskIds);
|
||||
if (error) throw error;
|
||||
const next = {};
|
||||
for (const task of (data || [])) {
|
||||
const submissions = asArray(task.submissions);
|
||||
const initial = submissions.find((submission) => submission?.type === 'initial' && submission?.service_type);
|
||||
const fallback = submissions.find((submission) => submission?.service_type);
|
||||
next[task.id] = initial?.service_type || fallback?.service_type || task.title || 'Other';
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
function buildPDFArgs(invoice, profile, total, statusOverride) {
|
||||
return {
|
||||
po_number: invoice.invoice_number,
|
||||
status: statusOverride || invoice.status || 'draft',
|
||||
profile,
|
||||
project: { name: 'Subcontractor Invoice', company: { name: 'Fourge Branding' } },
|
||||
date: invoice.created_at?.split('T')[0],
|
||||
due_date: invoice.paid_at?.split('T')[0] || invoice.submitted_at?.split('T')[0] || invoice.created_at?.split('T')[0],
|
||||
amount: total,
|
||||
description: invoice.status === 'paid'
|
||||
? 'Payment received for completed subcontractor work.'
|
||||
: 'Subcontractor invoice for completed work.',
|
||||
notes: invoice.notes,
|
||||
items: (invoice.items || []).map((item, idx) => ({
|
||||
description: item.description,
|
||||
amount: Number(item.unit_price || 0) * Number(item.quantity || 1),
|
||||
sort_order: item.sort_order ?? idx,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export default function MyInvoices() {
|
||||
const { currentUser } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const cacheKey = `ext-invoices:${currentUser?.id}`;
|
||||
const cached = readPageCache(cacheKey, 3 * 60_000);
|
||||
const [invoices, setInvoices] = useState(() => cached || []);
|
||||
const [invoices, setInvoices] = useState(() => cached?.invoices || []);
|
||||
const [stats, setStats] = useState(() => cached?.stats || { completedNewTasks: 0, completedRevisions: 0, invoicedUnits: 0, paidUnits: 0 });
|
||||
const [loading, setLoading] = useState(() => !cached);
|
||||
const [error, setError] = useState('');
|
||||
const { sortKey, sortDir, toggle, sort } = useSortable('created_at');
|
||||
const [showInvoiceForm, setShowInvoiceForm] = useState(false);
|
||||
const { sortKey, sortDir, toggle, sort } = useSortable('created_at', 'desc');
|
||||
const { sortKey: detailSortKey, sortDir: detailSortDir, toggle: toggleDetailSort, sort: sortDetailItems } = useSortable('description');
|
||||
const [viewingInvoice, setViewingInvoice] = useState(null);
|
||||
const [detailLoading, setDetailLoading] = useState(false);
|
||||
const [detailError, setDetailError] = useState('');
|
||||
const [detailTaskTypeMap, setDetailTaskTypeMap] = useState({});
|
||||
const [submittingInvoice, setSubmittingInvoice] = useState(false);
|
||||
const [downloadingInvoice, setDownloadingInvoice] = useState(false);
|
||||
const [downloadingReceipt, setDownloadingReceipt] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!currentUser?.id) { setLoading(false); return; }
|
||||
supabase
|
||||
.from('subcontractor_invoices')
|
||||
.select('*, items:subcontractor_invoice_items(*)')
|
||||
.order('created_at', { ascending: false })
|
||||
.then(({ data, error: err }) => {
|
||||
if (err) setError(err.message);
|
||||
else { setInvoices(data || []); writePageCache(cacheKey, data || []); }
|
||||
async function load() {
|
||||
if (!currentUser?.id) {
|
||||
setInvoices([]);
|
||||
setStats({ completedNewTasks: 0, completedRevisions: 0, invoicedUnits: 0, paidUnits: 0 });
|
||||
setLoading(false);
|
||||
});
|
||||
}, [currentUser?.id]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
return;
|
||||
}
|
||||
|
||||
const MONTHS = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
|
||||
const chartYear = new Date().getFullYear();
|
||||
const chartData = useMemo(() => MONTHS.map((month, mi) => {
|
||||
const submittedAmt = invoices.filter(i => i.status === 'submitted' && new Date(i.created_at).getFullYear() === chartYear && new Date(i.created_at).getMonth() === mi).reduce((s, i) => s + invoiceTotal(i.items), 0);
|
||||
const paidAmt = invoices.filter(i => i.status === 'paid' && new Date(i.created_at).getFullYear() === chartYear && new Date(i.created_at).getMonth() === mi).reduce((s, i) => s + invoiceTotal(i.items), 0);
|
||||
return { month, Submitted: +submittedAmt.toFixed(2), Paid: +paidAmt.toFixed(2) };
|
||||
}), [invoices, chartYear]);
|
||||
const hasChartData = chartData.some(d => d.Submitted > 0 || d.Paid > 0);
|
||||
const totalPaid = invoices.filter(i => i.status === 'paid').reduce((s, i) => s + invoiceTotal(i.items), 0);
|
||||
const totalPending = invoices.filter(i => i.status === 'submitted').reduce((s, i) => s + invoiceTotal(i.items), 0);
|
||||
setLoading(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
const [{ data: invoiceRows, error: invoiceError }, { data: taskRows, error: taskError }] = await Promise.all([
|
||||
supabase
|
||||
.from('subcontractor_invoices')
|
||||
.select('*, items:subcontractor_invoice_items(*)')
|
||||
.order('created_at', { ascending: false }),
|
||||
supabase
|
||||
.from('tasks')
|
||||
.select('id, title, status, current_version, assigned_to')
|
||||
.in('status', ['not_started', 'in_progress', 'on_hold', 'client_review', 'client_approved', 'invoiced', 'paid'])
|
||||
.eq('assigned_to', currentUser.id)
|
||||
.order('title'),
|
||||
]);
|
||||
|
||||
if (invoiceError) throw invoiceError;
|
||||
if (taskError) throw taskError;
|
||||
|
||||
const tasks = taskRows || [];
|
||||
const invoicesData = invoiceRows || [];
|
||||
|
||||
const taskIds = tasks.map((task) => task.id).filter(Boolean);
|
||||
const { data: submissionRows, error: submissionsError } = taskIds.length > 0
|
||||
? await supabase
|
||||
.from('submissions')
|
||||
.select('task_id, deliveries(version_number, sent_by, sent_at)')
|
||||
.in('task_id', taskIds)
|
||||
: { data: [], error: null };
|
||||
if (submissionsError) throw submissionsError;
|
||||
|
||||
const deliveriesByTask = new Map();
|
||||
for (const row of (submissionRows || [])) {
|
||||
const taskId = row.task_id;
|
||||
if (!taskId) continue;
|
||||
const bucket = deliveriesByTask.get(taskId) || new Map();
|
||||
for (const delivery of asArray(row.deliveries)) {
|
||||
if ((delivery.sent_by || '').trim().toLowerCase() !== (currentUser.name || '').trim().toLowerCase()) continue;
|
||||
const version = Number(delivery.version_number || 0);
|
||||
if (!bucket.has(version)) bucket.set(version, delivery);
|
||||
}
|
||||
deliveriesByTask.set(taskId, bucket);
|
||||
}
|
||||
|
||||
let completedNewTasks = 0;
|
||||
let completedRevisions = 0;
|
||||
for (const task of tasks) {
|
||||
const versions = [...(deliveriesByTask.get(task.id)?.keys() || [])];
|
||||
for (const version of versions) {
|
||||
if (!isCompletedVersionEligible(task, version)) continue;
|
||||
if (version <= 0) completedNewTasks += 1;
|
||||
else completedRevisions += 1;
|
||||
}
|
||||
}
|
||||
|
||||
const invoiceUnits = invoicesData.reduce((sum, invoice) => sum + asArray(invoice.items).filter((item) => item.task_id).length, 0);
|
||||
const paidUnits = invoicesData
|
||||
.filter((invoice) => invoice.status === 'paid')
|
||||
.reduce((sum, invoice) => sum + asArray(invoice.items).filter((item) => item.task_id).length, 0);
|
||||
|
||||
const nextStats = {
|
||||
completedNewTasks,
|
||||
completedRevisions,
|
||||
invoicedUnits: invoiceUnits,
|
||||
paidUnits,
|
||||
};
|
||||
|
||||
setInvoices(invoicesData);
|
||||
setStats(nextStats);
|
||||
writePageCache(cacheKey, { invoices: invoicesData, stats: nextStats });
|
||||
} catch (err) {
|
||||
console.error('Failed to load subcontractor invoices:', err);
|
||||
setError(err?.message || 'Failed to load invoices.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
load();
|
||||
}, [cacheKey, currentUser?.id, currentUser?.name]);
|
||||
|
||||
const totalInvoiceAmount = useMemo(
|
||||
() => invoices.reduce((sum, invoice) => sum + invoiceTotal(invoice.items), 0),
|
||||
[invoices]
|
||||
);
|
||||
const paidInvoiceAmount = useMemo(
|
||||
() => invoices.filter((invoice) => invoice.status === 'paid').reduce((sum, invoice) => sum + invoiceTotal(invoice.items), 0),
|
||||
[invoices]
|
||||
);
|
||||
|
||||
const sortedInvoices = sort(invoices, (inv, key) => {
|
||||
if (key === 'total') return invoiceTotal(inv.items);
|
||||
if (key === 'line_count') return asArray(inv.items).length;
|
||||
if (key === 'submitted_at') return inv.submitted_at ? new Date(inv.submitted_at).getTime() : 0;
|
||||
if (key === 'created_at') return inv.created_at ? new Date(inv.created_at).getTime() : 0;
|
||||
return inv[key] || '';
|
||||
});
|
||||
|
||||
async function openInvoicePopup(invoiceId) {
|
||||
if (!invoiceId) return;
|
||||
setDetailLoading(true);
|
||||
setDetailError('');
|
||||
try {
|
||||
const { data, error: err } = await supabase
|
||||
.from('subcontractor_invoices')
|
||||
.select('*, items:subcontractor_invoice_items(*)')
|
||||
.eq('id', invoiceId)
|
||||
.single();
|
||||
if (err || !data) throw err || new Error('Invoice not found.');
|
||||
const taskTypeMap = await fetchTaskTypeMap(asArray(data.items).map((item) => item.task_id));
|
||||
setDetailTaskTypeMap(taskTypeMap);
|
||||
setViewingInvoice(data);
|
||||
} catch (err) {
|
||||
setDetailError(err?.message || 'Failed to load invoice.');
|
||||
} finally {
|
||||
setDetailLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubmitInvoice() {
|
||||
if (!viewingInvoice?.id) return;
|
||||
setSubmittingInvoice(true);
|
||||
setDetailError('');
|
||||
try {
|
||||
const submittedAt = new Date().toISOString();
|
||||
const { error: err } = await supabase
|
||||
.from('subcontractor_invoices')
|
||||
.update({ status: 'submitted', submitted_at: submittedAt })
|
||||
.eq('id', viewingInvoice.id);
|
||||
if (err) throw err;
|
||||
setViewingInvoice((current) => current ? { ...current, status: 'submitted', submitted_at: submittedAt } : current);
|
||||
setInvoices((current) => current.map((invoice) => invoice.id === viewingInvoice.id ? { ...invoice, status: 'submitted', submitted_at: submittedAt } : invoice));
|
||||
} catch (err) {
|
||||
setDetailError(err?.message || 'Failed to submit invoice.');
|
||||
} finally {
|
||||
setSubmittingInvoice(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDownloadReceipt() {
|
||||
if (!viewingInvoice) return;
|
||||
setDownloadingReceipt(true);
|
||||
setDetailError('');
|
||||
try {
|
||||
const total = invoiceTotal(viewingInvoice.items);
|
||||
await generateSubcontractorPOPDF(
|
||||
buildPDFArgs(viewingInvoice, { name: currentUser?.name, email: currentUser?.email }, total, 'paid')
|
||||
);
|
||||
} catch (err) {
|
||||
setDetailError(err?.message || 'Failed to download receipt.');
|
||||
} finally {
|
||||
setDownloadingReceipt(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDownloadInvoice() {
|
||||
if (!viewingInvoice) return;
|
||||
setDownloadingInvoice(true);
|
||||
setDetailError('');
|
||||
try {
|
||||
const total = invoiceTotal(viewingInvoice.items);
|
||||
await generateSubcontractorPOPDF(
|
||||
buildPDFArgs(viewingInvoice, { name: currentUser?.name, email: currentUser?.email }, total)
|
||||
);
|
||||
} catch (err) {
|
||||
setDetailError(err?.message || 'Failed to download invoice.');
|
||||
} finally {
|
||||
setDownloadingInvoice(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<div className="page-title">Invoices</div>
|
||||
<div className="page-subtitle">Submit invoices to Fourge Branding for your completed work.</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: 4 }}>Payment terms: NET 30 from the date Fourge receives payment from the client.</div>
|
||||
<Layout loading={loading}>
|
||||
<div style={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
|
||||
{error && <div className="notification notification-info" style={{ marginBottom: 16 }}>{error}</div>}
|
||||
|
||||
<div className="dash-stat-grid" style={{ gridTemplateColumns: '1fr 1fr 1fr 1fr', flexShrink: 0 }}>
|
||||
<TaskStatCard
|
||||
label="Completed New Tasks"
|
||||
value={stats.completedNewTasks}
|
||||
sub={`${stats.completedNewTasks} completed R00 units`}
|
||||
iconBg="rgba(96,165,250,0.15)"
|
||||
iconColor="#60a5fa"
|
||||
iconPath={STAT_ICONS.newTasks}
|
||||
/>
|
||||
<TaskStatCard
|
||||
label="Completed Revisions"
|
||||
value={stats.completedRevisions}
|
||||
sub={`${stats.completedRevisions} completed revision units`}
|
||||
iconBg="rgba(245,165,35,0.15)"
|
||||
iconColor="#F5A523"
|
||||
iconPath={STAT_ICONS.revisions}
|
||||
/>
|
||||
<TaskStatCard
|
||||
label="Invoiced"
|
||||
value={fmt(totalInvoiceAmount)}
|
||||
sub={`${invoices.length} invoices submitted`}
|
||||
iconBg="rgba(167,139,250,0.15)"
|
||||
iconColor="#a78bfa"
|
||||
iconPath={STAT_ICONS.invoiced}
|
||||
/>
|
||||
<TaskStatCard
|
||||
label="Paid"
|
||||
value={fmt(paidInvoiceAmount)}
|
||||
sub={`${invoices.filter((invoice) => invoice.status === 'paid').length} paid invoices`}
|
||||
iconBg="rgba(74,222,128,0.15)"
|
||||
iconColor="#4ade80"
|
||||
iconPath={STAT_ICONS.paid}
|
||||
/>
|
||||
</div>
|
||||
<button className="btn btn-primary" onClick={() => navigate('/my-invoices-sub/new')} disabled={loading}>
|
||||
+ New Invoice
|
||||
</button>
|
||||
|
||||
{invoices.length === 0 ? (
|
||||
<>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 4, marginBottom: 10, flexShrink: 0, minHeight: 'var(--btn-height)' }}>
|
||||
<div style={{ marginLeft: 'auto', display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<button className="btn btn-outline" onClick={() => setShowInvoiceForm(true)} disabled={loading}>
|
||||
+ Invoice
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="card card-empty-center" style={{ flex: 1, minHeight: 0 }}>No invoices</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 4, marginBottom: 10, flexShrink: 0, minHeight: 'var(--btn-height)' }}>
|
||||
<div style={{ marginLeft: 'auto', display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<button className="btn btn-outline" onClick={() => setShowInvoiceForm(true)} disabled={loading}>
|
||||
+ Invoice
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
background: 'var(--card-bg)',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 8,
|
||||
padding: '18px 21px',
|
||||
backdropFilter: 'blur(12px)',
|
||||
WebkitBackdropFilter: 'blur(12px)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
}}
|
||||
>
|
||||
<div className="scrollbar-thin-theme table-scroll-hidden" style={{ flex: 1, minHeight: 0, overflowY: 'auto' }}>
|
||||
<table className="table-sticky-head" style={{ tableLayout: 'fixed', borderCollapse: 'collapse', width: '100%' }}>
|
||||
<colgroup>
|
||||
<col style={{ width: '28%' }} />
|
||||
<col style={{ width: '15%' }} />
|
||||
<col style={{ width: '15%' }} />
|
||||
<col style={{ width: '15%' }} />
|
||||
<col style={{ width: '12%' }} />
|
||||
<col style={{ width: '15%' }} />
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr>
|
||||
<SortTh col="invoice_number" sortKey={sortKey} sortDir={sortDir} onSort={toggle}>Invoice #</SortTh>
|
||||
<SortTh col="created_at" sortKey={sortKey} sortDir={sortDir} onSort={toggle}>Created</SortTh>
|
||||
<SortTh col="submitted_at" sortKey={sortKey} sortDir={sortDir} onSort={toggle}>Submitted</SortTh>
|
||||
<SortTh col="status" sortKey={sortKey} sortDir={sortDir} onSort={toggle}>Status</SortTh>
|
||||
<SortTh col="line_count" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ textAlign: 'center' }}>Items</SortTh>
|
||||
<SortTh col="total" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ textAlign: 'right' }}>Total</SortTh>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sortedInvoices.map((inv) => {
|
||||
const total = invoiceTotal(inv.items);
|
||||
return (
|
||||
<tr key={inv.id} style={{ cursor: 'pointer' }} onClick={() => openInvoicePopup(inv.id)}>
|
||||
<td style={{ fontWeight: 400 }}>{inv.invoice_number || 'Subcontractor Invoice'}</td>
|
||||
<td style={{ color: 'var(--text-muted)' }}>{inv.created_at ? new Date(inv.created_at).toLocaleDateString() : '—'}</td>
|
||||
<td style={{ color: 'var(--text-muted)' }}>{inv.submitted_at ? new Date(inv.submitted_at).toLocaleDateString() : '—'}</td>
|
||||
<td><StatusBadge status={STATUS_BADGE[inv.status]} label={STATUS_LABEL[inv.status]} /></td>
|
||||
<td style={{ textAlign: 'center', color: 'var(--text-primary)' }}>{asArray(inv.items).length}</td>
|
||||
<td style={{ textAlign: 'right', fontWeight: 400, color: 'var(--accent)' }}>{fmt(total)}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && <div className="notification notification-info" style={{ marginBottom: 16 }}>{error}</div>}
|
||||
|
||||
{!loading && invoices.length > 0 && (
|
||||
<>
|
||||
<div className="stat-bar" style={{ marginBottom: 18 }}>
|
||||
<div className="stat-bar-item"><div className="stat-bar-header"><div className="stat-bar-label">Total Paid</div><div className="stat-bar-dot" style={{ background: '#4ade80' }} /></div><div className="stat-bar-value">{fmt(totalPaid)}</div></div>
|
||||
<div className="stat-bar-item"><div className="stat-bar-header"><div className="stat-bar-label">Pending Payment</div><div className="stat-bar-dot" style={{ background: '#F5A523' }} /></div><div className="stat-bar-value">{fmt(totalPending)}</div></div>
|
||||
<div className="stat-bar-item"><div className="stat-bar-header"><div className="stat-bar-label">Total Invoices</div><div className="stat-bar-dot" style={{ background: '#60a5fa' }} /></div><div className="stat-bar-value">{invoices.length}</div></div>
|
||||
{showInvoiceForm && (
|
||||
<div style={popupOverlayStyle} onClick={() => setShowInvoiceForm(false)}>
|
||||
<div
|
||||
style={{ ...popupSurfaceStyle, background: 'var(--card-bg)', width: '80vw', height: '80vh', display: 'flex', flexDirection: 'column', gap: 0 }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<SubcontractorInvoiceForm
|
||||
embedded
|
||||
onCancel={() => setShowInvoiceForm(false)}
|
||||
onSubmitted={(inv) => {
|
||||
setShowInvoiceForm(false);
|
||||
openInvoicePopup(inv.id);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{hasChartData && (
|
||||
<div style={{ background: 'var(--card-bg)', border: '1px solid var(--border)', borderRadius: 4, padding: '16px 16px 8px', marginBottom: 18 }}>
|
||||
<ResponsiveContainer width="100%" height={180}>
|
||||
<AreaChart data={chartData} margin={{ top: 4, right: 8, left: 0, bottom: 0 }}>
|
||||
<defs>
|
||||
<linearGradient id="gradSubAmt" x1="0" y1="0" x2="0" y2="1"><stop offset="5%" stopColor="#F5A523" stopOpacity={0.25}/><stop offset="95%" stopColor="#F5A523" stopOpacity={0}/></linearGradient>
|
||||
<linearGradient id="gradSubPaid" x1="0" y1="0" x2="0" y2="1"><stop offset="5%" stopColor="#4ade80" stopOpacity={0.25}/><stop offset="95%" stopColor="#4ade80" stopOpacity={0}/></linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="var(--border)" vertical={false} />
|
||||
<XAxis dataKey="month" tick={{ fontSize: 11, fill: 'var(--text-muted)' }} axisLine={false} tickLine={false} />
|
||||
<YAxis tick={{ fontSize: 11, fill: 'var(--text-muted)' }} axisLine={false} tickLine={false} tickFormatter={v => `$${v >= 1000 ? (v/1000).toFixed(0)+'k' : v}`} width={45} />
|
||||
<Tooltip formatter={(v) => [`$${Number(v).toLocaleString('en-US', { minimumFractionDigits: 2 })}`, undefined]} contentStyle={{ background: 'var(--card-bg)', border: '1px solid var(--border)', borderRadius: 4, fontSize: 12 }} />
|
||||
<Legend wrapperStyle={{ fontSize: 11, paddingTop: 8 }} />
|
||||
<Area type="monotone" dataKey="Submitted" stroke="#F5A523" strokeWidth={2} fill="url(#gradSubAmt)" dot={false} activeDot={{ r: 4 }} />
|
||||
<Area type="monotone" dataKey="Paid" stroke="#4ade80" strokeWidth={2} fill="url(#gradSubPaid)" dot={false} activeDot={{ r: 4 }} />
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<div className="empty-state">Loading invoices...</div>
|
||||
) : invoices.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<h3>No invoices yet</h3>
|
||||
<p>Create your first invoice to get paid for your completed work.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="card">
|
||||
<div className="table-wrapper">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<SortTh col="invoice_number" sortKey={sortKey} sortDir={sortDir} onSort={toggle}>Invoice #</SortTh>
|
||||
<SortTh col="submitted_at" sortKey={sortKey} sortDir={sortDir} onSort={toggle}>Submitted</SortTh>
|
||||
<SortTh col="status" sortKey={sortKey} sortDir={sortDir} onSort={toggle}>Status</SortTh>
|
||||
<SortTh col="total" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ textAlign: 'right' }}>Total</SortTh>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sort(invoices, (inv, key) => {
|
||||
if (key === 'total') return invoiceTotal(inv.items);
|
||||
if (key === 'submitted_at') return inv.submitted_at ? new Date(inv.submitted_at).getTime() : 0;
|
||||
return inv[key] || '';
|
||||
}).map(inv => {
|
||||
const total = invoiceTotal(inv.items);
|
||||
return (
|
||||
<tr key={inv.id} style={{ cursor: 'pointer' }} onClick={() => navigate(`/my-invoices-sub/${inv.id}`)}>
|
||||
<td style={{ fontWeight: 400 }}>{inv.invoice_number}</td>
|
||||
<td style={{ color: 'var(--text-muted)' }}>{inv.submitted_at ? new Date(inv.submitted_at).toLocaleDateString() : '—'}</td>
|
||||
<td><StatusBadge status={STATUS_BADGE[inv.status]} label={STATUS_LABEL[inv.status]} /></td>
|
||||
<td style={{ textAlign: 'right', fontWeight: 400, color: 'var(--accent)' }}>{fmt(total)}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
{(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()}
|
||||
>
|
||||
{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} />
|
||||
</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>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user