2c1ff61b29
Clicking the dimmed area outside a modal (+Task, +Invoice, expense, review, etc.) accidentally dismissed it and lost in-progress input. Every popup already has an explicit Cancel/Close/✕ button, so the backdrop click handler is removed; also drops the now-dead onClose/blockClose prop plumbing that only existed to support it. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
499 lines
24 KiB
React
499 lines
24 KiB
React
import { useEffect, useState, useMemo } from 'react';
|
||
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 SubcontractorInvoiceForm from '../../components/SubcontractorInvoiceForm';
|
||
import { popupOverlayStyle, popupSurfaceStyle } from '../../lib/popupStyles';
|
||
import InvoiceDetailPopup, { POPUP_FIELD_LABEL } from '../../components/InvoiceDetailPopup';
|
||
import InvoicePopupTable from '../../components/InvoicePopupTable';
|
||
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)}`;
|
||
}
|
||
|
||
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: 'var(--card-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 (item?.version_number != null && 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 cacheKey = `ext-invoices:${currentUser?.id}`;
|
||
const cached = readPageCache(cacheKey, 3 * 60_000);
|
||
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 [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(() => {
|
||
async function load() {
|
||
if (!currentUser?.id) {
|
||
setInvoices([]);
|
||
setStats({ completedNewTasks: 0, completedRevisions: 0, invoicedUnits: 0, paidUnits: 0 });
|
||
setLoading(false);
|
||
return;
|
||
}
|
||
|
||
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 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="color-mix(in srgb, var(--info) 15%, transparent)"
|
||
iconColor="var(--info)"
|
||
iconPath={STAT_ICONS.newTasks}
|
||
/>
|
||
<TaskStatCard
|
||
label="Completed Revisions"
|
||
value={stats.completedRevisions}
|
||
sub={`${stats.completedRevisions} completed revision units`}
|
||
iconBg="color-mix(in srgb, var(--accent) 15%, transparent)"
|
||
iconColor="var(--accent)"
|
||
iconPath={STAT_ICONS.revisions}
|
||
/>
|
||
<TaskStatCard
|
||
label="Invoiced"
|
||
value={fmt(totalInvoiceAmount)}
|
||
sub={`${invoices.length} invoices submitted`}
|
||
iconBg="color-mix(in srgb, var(--violet) 15%, transparent)"
|
||
iconColor="var(--violet)"
|
||
iconPath={STAT_ICONS.invoiced}
|
||
/>
|
||
<TaskStatCard
|
||
label="Paid"
|
||
value={fmt(paidInvoiceAmount)}
|
||
sub={`${invoices.filter((invoice) => invoice.status === 'paid').length} paid invoices`}
|
||
iconBg="color-mix(in srgb, var(--positive) 15%, transparent)"
|
||
iconColor="var(--positive)"
|
||
iconPath={STAT_ICONS.paid}
|
||
/>
|
||
</div>
|
||
|
||
{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>
|
||
|
||
{showInvoiceForm && (
|
||
<div style={popupOverlayStyle}>
|
||
<div
|
||
style={{ ...popupSurfaceStyle, 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>
|
||
</div>
|
||
)}
|
||
|
||
{(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 '';
|
||
});
|
||
const F = POPUP_FIELD_LABEL;
|
||
return (
|
||
<InvoiceDetailPopup
|
||
title={inv?.invoice_number || 'Subcontractor Invoice'}
|
||
subtitle={`${currentUser?.name || 'Subcontractor'}${currentUser?.email ? ` · ${currentUser.email}` : ''}`}
|
||
headerRight={inv ? <StatusBadge status={STATUS_BADGE[inv.status] || 'not_started'} label={invoiceStatus} /> : null}
|
||
loading={detailLoading && !inv}
|
||
metaContent={inv ? <>
|
||
<div><div style={F}>Created</div><div style={{ fontSize: 13, color: 'var(--text-primary)' }}>{inv.created_at ? new Date(inv.created_at).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) : '—'}</div></div>
|
||
<div><div style={F}>Submitted</div><div style={{ fontSize: 13, color: 'var(--text-primary)' }}>{inv.submitted_at ? new Date(inv.submitted_at).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) : '—'}</div></div>
|
||
<div><div style={F}>Line Items</div><div style={{ fontSize: 13, color: 'var(--text-primary)' }}>{baseItems.length}</div></div>
|
||
<div><div style={F}>Total</div><div style={{ fontSize: 18, fontWeight: 500, color: 'var(--accent)', lineHeight: 1.1 }}>${total.toFixed(2)}</div></div>
|
||
</> : null}
|
||
footerActions={inv ? (
|
||
<>
|
||
<LoadingButton className="btn btn-outline" loading={downloadingInvoice} loadingText="Generating…" disabled={busy} onClick={handleDownloadInvoice}>Download Invoice</LoadingButton>
|
||
{inv.status === 'draft' && <LoadingButton className="btn btn-outline" loading={submittingInvoice} loadingText="Submitting…" disabled={busy} onClick={handleSubmitInvoice}>Submit to Team</LoadingButton>}
|
||
{inv.status === 'paid' && <LoadingButton className="btn btn-outline" loading={downloadingReceipt} loadingText="Generating…" disabled={busy} onClick={handleDownloadReceipt}>Download Receipt</LoadingButton>}
|
||
<button type="button" className="btn btn-outline" onClick={() => { setViewingInvoice(null); setDetailError(''); }} disabled={busy}>Close</button>
|
||
</>
|
||
) : null}
|
||
>
|
||
{detailError && <div className="notification notification-info" style={{ marginBottom: 16 }}>{detailError}</div>}
|
||
{inv
|
||
? <InvoicePopupTable sortKey={detailSortKey} sortDir={detailSortDir} onSort={toggleDetailSort} items={tableItems} notes={inv.notes} />
|
||
: !detailError ? <div className="card-empty-center">Invoice not found.</div> : null
|
||
}
|
||
</InvoiceDetailPopup>
|
||
);
|
||
})()}
|
||
</Layout>
|
||
);
|
||
}
|