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: '',
revisions: '',
invoiced: '',
paid: '',
};
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 (
);
}
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 (
{error &&
{error}
}
invoice.status === 'paid').length} paid invoices`}
iconBg="rgba(74,222,128,0.15)"
iconColor="#4ade80"
iconPath={STAT_ICONS.paid}
/>
{invoices.length === 0 ? (
<>
No invoices
>
) : (
<>
Invoice #
Created
Submitted
Status
Items
Total
{sortedInvoices.map((inv) => {
const total = invoiceTotal(inv.items);
return (
openInvoicePopup(inv.id)}>
| {inv.invoice_number || 'Subcontractor Invoice'} |
{inv.created_at ? new Date(inv.created_at).toLocaleDateString() : '—'} |
{inv.submitted_at ? new Date(inv.submitted_at).toLocaleDateString() : '—'} |
|
{asArray(inv.items).length} |
{fmt(total)} |
);
})}
>
)}
{showInvoiceForm && (
setShowInvoiceForm(false)}>
e.stopPropagation()}
>
setShowInvoiceForm(false)}
onSubmitted={(inv) => {
setShowInvoiceForm(false);
openInvoicePopup(inv.id);
}}
/>
)}
{(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 (
: null}
onClose={() => { if (!busy) { setViewingInvoice(null); setDetailError(''); } }}
blockClose={busy}
loading={detailLoading && !inv}
metaContent={inv ? <>
Created
{inv.created_at ? new Date(inv.created_at).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) : '—'}
Submitted
{inv.submitted_at ? new Date(inv.submitted_at).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) : '—'}
Line Items
{baseItems.length}
> : null}
footerActions={inv ? (
<>
Download Invoice
{inv.status === 'draft' && Submit to Team}
{inv.status === 'paid' && Download Receipt}
>
) : null}
>
{detailError && {detailError}
}
{inv
?
: !detailError ? Invoice not found.
: null
}
);
})()}
);
}