Files
fourge-portal/src/pages/external/ExternalMyInvoices.jsx
T
Krao Hasanee 2c1ff61b29 fix: popups no longer close on backdrop click, only via explicit close/cancel
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>
2026-07-15 16:45:37 -04:00

499 lines
24 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
}