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 (
{label}
{value}
{sub}
); } 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="color-mix(in srgb, var(--positive) 15%, transparent)" iconColor="var(--positive)" iconPath={STAT_ICONS.paid} />
{invoices.length === 0 ? ( <>
No invoices
) : ( <>
Invoice #CreatedSubmittedStatusItemsTotal {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 && (
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} 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}
Total
${total.toFixed(2)}
: 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 }
); })()}
); }