import { useState, useEffect, useMemo, useRef } from 'react'; import { AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer, PieChart, Pie, Cell } from 'recharts'; import { DashboardBanner } from '../../lib/dashboardBanner'; import { useLocation, useNavigate } from 'react-router-dom'; import Layout from '../../components/Layout'; import SortTh from '../../components/SortTh'; import StatusBadge from '../../components/StatusBadge'; import { useSortable } from '../../hooks/useSortable'; import { supabase } from '../../lib/supabase'; import { readPageCache, writePageCache } from '../../lib/pageCache'; import { exportCPAPackage, generateSubcontractorPOPDF } from '../../lib/invoice'; import { blobToEmailAttachment, sendEmail } from '../../lib/email'; import { popupOverlayStyle, popupSurfaceStyle } from '../../lib/popupStyles'; const CATEGORIES = ['Software', 'Contractor', 'Advertising', 'Office', 'Travel', 'Meals', 'Equipment', 'Other']; const statusColor = { draft: 'not_started', sent: 'in_progress', paid: 'client_approved' }; const poStatusColor = { draft: 'not_started', sent: 'in_progress', approved: 'client_approved', ready_to_pay: 'in_progress', paid: 'client_approved', cancelled: 'needs_revision', }; const poStatusLabel = { draft: 'Draft', sent: 'Sent', approved: 'Approved', ready_to_pay: 'Ready to Pay', paid: 'Paid', cancelled: 'Cancelled', }; const invoiceStatusBadgeLabel = (status) => { if (status === 'sent') return 'Invoiced'; return status ? status.charAt(0).toUpperCase() + status.slice(1) : '—'; }; const RECEIPT_BUCKET = 'expense-receipts'; const FIELD_LABEL_STYLE = { fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', textTransform: 'uppercase', letterSpacing: 0.8, display: 'block', marginBottom: 4 }; const FIELD_INPUT_STYLE = { minHeight: 42, margin: 0 }; const blankExpense = () => ({ date: new Date().toISOString().slice(0, 10), description: '', category: 'Other', amount: '', notes: '', receipt: null, receipt_path: null, receipt_name: null, removeReceipt: false, }); function getFileExt(name = '') { const clean = String(name).split('.').pop()?.toLowerCase(); return clean && clean !== name ? clean.replace(/[^a-z0-9]/g, '') : 'bin'; } const MONTHS = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec']; function FinancesChartTooltip({ active, payload, label, year }) { if (!active || !payload?.length) return null; return (
{label} {year}
{payload.map(p => (
{p.name}: ${p.value.toLocaleString('en-US', { minimumFractionDigits: 2 })}
))}
); } export default function Invoices() { const navigate = useNavigate(); const location = useLocation(); const cached = readPageCache('team_invoices'); const [invoices, setInvoices] = useState(() => cached?.invoices || []); const [loading, setLoading] = useState(() => !cached); const [activeTab, setActiveTab] = useState(() => location.state?.tab || 'invoices'); const [financeTab, setFinanceTab] = useState('overview'); const [filter, setFilter] = useState('all'); const { sortKey: invSortKey, sortDir: invSortDir, toggle: invToggle, sort: invSort } = useSortable('invoice_date'); const { sortKey: expSortKey, sortDir: expSortDir, toggle: expToggle, sort: expSort } = useSortable('date'); const { sortKey: subSortKey, sortDir: subSortDir, toggle: subToggle, sort: subSort } = useSortable('submitted_at'); const { sortKey: ovExpKey, sortDir: ovExpDir, toggle: ovExpToggle } = useSortable('date'); const { sortKey: ovSubKey, sortDir: ovSubDir, toggle: ovSubToggle } = useSortable('date'); const { sortKey: ovInvKey, sortDir: ovInvDir, toggle: ovInvToggle } = useSortable('invoice_date'); const [filterCompany, setFilterCompany] = useState(''); const initMonth = () => { const n = new Date(); return { month: n.getMonth(), year: n.getFullYear() }; }; const [ovMonth1, setOvMonth1] = useState(initMonth); const [ovMonth2, setOvMonth2] = useState(initMonth); const [ovMonth3, setOvMonth3] = useState(initMonth); const [exportYear, setExportYear] = useState(new Date().getFullYear()); const [exporting, setExporting] = useState(false); const [expenses, setExpenses] = useState([]); const [expensesLoading, setExpensesLoading] = useState(true); const [expensesError, setExpensesError] = useState(''); const [newExpense, setNewExpense] = useState(blankExpense()); const [addingExpense, setAddingExpense] = useState(false); const [editingExpenseId, setEditingExpenseId] = useState(''); const [showExpenseForm, setShowExpenseForm] = useState(false); const [expenseFilter, setExpenseFilter] = useState('all'); const [subcontractorPOs, setSubcontractorPOs] = useState([]); const [subcontractorLoading, setSubcontractorLoading] = useState(true); const [subcontractorError, setSubcontractorError] = useState(''); const [selectedSubcontractorPOId, setSelectedSubcontractorPOId] = useState(''); const [subInvoices, setSubInvoices] = useState([]); const [subInvoicesLoading, setSubInvoicesLoading] = useState(true); const [subInvoicesError, setSubInvoicesError] = useState(''); const [markingPaid, setMarkingPaid] = useState(''); const [expandedSubInvoiceId, setExpandedSubInvoiceId] = useState(null); useEffect(() => { async function load() { const { data } = await supabase .from('invoices') .select('*, company:companies(name)') .order('created_at', { ascending: false }); setInvoices(data || []); writePageCache('team_invoices', { invoices: data || [] }); setLoading(false); } load(); }, []); useEffect(() => { async function loadExpenses() { const [{ data, error }, { data: purchaseOrders, error: purchaseOrdersError }] = await Promise.all([ supabase.from('expenses').select('*').order('date', { ascending: false }), supabase .from('subcontractor_payments') .select('*, profile:profiles!subcontractor_payments_profile_id_fkey(id, name, email), project:projects(id, name, company:companies(name)), items:subcontractor_po_items(*, task:tasks(id, title))') .order('date', { ascending: false }), ]); if (error) { console.error('Failed to load expenses:', error); setExpensesError(error.message || 'Failed to load expenses.'); setExpenses([]); } else { setExpenses(data || []); setExpensesError(''); } if (purchaseOrdersError) { console.error('Failed to load subcontractor POs:', purchaseOrdersError); setSubcontractorError(purchaseOrdersError.message || 'Failed to load subcontractor POs.'); setSubcontractorPOs([]); } else { setSubcontractorPOs(purchaseOrders || []); setSubcontractorError(''); } setExpensesLoading(false); setSubcontractorLoading(false); } loadExpenses(); }, []); useEffect(() => { async function loadSubInvoices() { const { data, error: err } = await supabase .from('subcontractor_invoices') .select('*, profile:profiles!subcontractor_invoices_profile_id_fkey(id, name, email), items:subcontractor_invoice_items(*)') .order('created_at', { ascending: false }); if (err) { setSubInvoicesError(err.message); setSubInvoicesLoading(false); return; } setSubInvoices(data || []); setSubInvoicesLoading(false); } loadSubInvoices(); }, []); const startEditExpense = (expense) => { setEditingExpenseId(expense.id); setNewExpense({ date: expense.date, description: expense.description || '', category: expense.category || 'Other', amount: String(expense.amount ?? ''), notes: expense.notes || '', receipt: null, receipt_path: expense.receipt_path || null, receipt_name: expense.receipt_name || null, removeReceipt: false, }); setExpensesError(''); setShowExpenseForm(true); }; const cancelExpenseEdit = () => { setEditingExpenseId(''); setNewExpense(blankExpense()); setExpensesError(''); setShowExpenseForm(false); }; const handleAddExpense = async (e) => { e.preventDefault(); if (!newExpense.description.trim() || !newExpense.amount) return; setAddingExpense(true); setExpensesError(''); try { const existingExpense = editingExpenseId ? expenses.find(expense => expense.id === editingExpenseId) : null; let receiptPath = existingExpense?.receipt_path || newExpense.receipt_path || null; let receiptName = existingExpense?.receipt_name || newExpense.receipt_name || null; if (newExpense.removeReceipt && existingExpense?.receipt_path) { await supabase.storage.from(RECEIPT_BUCKET).remove([existingExpense.receipt_path]); receiptPath = null; receiptName = null; } if (newExpense.receipt) { const ext = getFileExt(newExpense.receipt.name); receiptName = newExpense.receipt.name; receiptPath = `${newExpense.date}/${Date.now()}-${crypto.randomUUID()}.${ext}`; const { error: uploadError } = await supabase.storage.from(RECEIPT_BUCKET).upload(receiptPath, newExpense.receipt, { upsert: false }); if (uploadError) throw uploadError; if (existingExpense?.receipt_path) { await supabase.storage.from(RECEIPT_BUCKET).remove([existingExpense.receipt_path]); } } const payload = { date: newExpense.date, description: newExpense.description.trim(), category: newExpense.category, amount: Number(newExpense.amount), notes: newExpense.notes.trim(), receipt_path: receiptPath, receipt_name: receiptName, }; const query = editingExpenseId ? supabase.from('expenses').update(payload).eq('id', editingExpenseId) : supabase.from('expenses').insert(payload); const { data, error } = await query.select().single(); if (error) { if (newExpense.receipt && receiptPath) { await supabase.storage.from(RECEIPT_BUCKET).remove([receiptPath]); } throw error; } if (data) { setExpenses(prev => editingExpenseId ? prev.map(expense => expense.id === editingExpenseId ? data : expense) : [data, ...prev] ); setNewExpense(blankExpense()); setEditingExpenseId(''); setShowExpenseForm(false); } } catch (error) { console.error(`Failed to ${editingExpenseId ? 'update' : 'add'} expense:`, error); setExpensesError(error.message || `Failed to ${editingExpenseId ? 'update' : 'add'} expense.`); alert(`Failed to ${editingExpenseId ? 'update' : 'add'} expense: ${error.message || 'unknown error'}`); } setAddingExpense(false); }; const handleDeleteExpense = async (id) => { if (!window.confirm('Delete this expense?')) return; const expense = expenses.find(e => e.id === id); if (expense?.receipt_path) { await supabase.storage.from(RECEIPT_BUCKET).remove([expense.receipt_path]); } await supabase.from('expenses').delete().eq('id', id); setExpenses(prev => prev.filter(e => e.id !== id)); }; const handleViewReceipt = async (expense) => { if (!expense.receipt_path) return; const { data, error } = await supabase.storage.from(RECEIPT_BUCKET).createSignedUrl(expense.receipt_path, 3600, { download: expense.receipt_name || undefined, }); if (error || !data?.signedUrl) { const message = error?.message || 'Failed to open receipt.'; setExpensesError(message); alert(message); return; } window.open(data.signedUrl, '_blank', 'noopener,noreferrer'); }; const updateSubcontractorPO = async (po, updates, errorLabel = 'Failed to update PO') => { const { data, error } = await supabase .from('subcontractor_payments') .update(updates) .eq('id', po.id) .select('*, profile:profiles!subcontractor_payments_profile_id_fkey(id, name, email), project:projects(id, name, company:companies(name)), items:subcontractor_po_items(*, task:tasks(id, title))') .single(); if (error) { alert(`${errorLabel}: ${error.message}`); return null; } if (data) { setSubcontractorPOs(prev => prev.map(row => row.id === po.id ? data : row)); setSelectedSubcontractorPOId(current => current === po.id ? data.id : current); } return data; }; const handleSendSubcontractorPO = async (po) => { const sentPO = await updateSubcontractorPO(po, { status: 'sent', sent_at: new Date().toISOString() }, 'Failed to send PO'); if (!sentPO?.profile?.email) return; try { await sendEmail('subcontractor_po_sent', sentPO.profile.email, { poNumber: sentPO.po_number || 'Purchase Order', subcontractorName: sentPO.profile?.name || 'there', projectName: sentPO.project?.name || 'Subcontractor Work', companyName: sentPO.project?.company?.name || 'Fourge Branding', amount: `$${Number(sentPO.amount).toFixed(2)}`, dueDate: sentPO.due_date ? new Date(sentPO.due_date).toLocaleDateString() : 'Not set', terms: sentPO.terms || 'Net 15', scope: sentPO.items?.length ? sentPO.items.map(item => `${item.description} — $${Number(item.amount).toFixed(2)}`).join('\n') : sentPO.description, portalUrl: 'https://portal.fourgebranding.com/my-purchase-orders', }); } catch (emailError) { console.error('Failed to email subcontractor PO:', emailError); alert(`PO was marked sent, but the email failed: ${emailError.message || 'unknown error'}`); } }; const handleReadyToPaySubcontractorPO = (po) => { updateSubcontractorPO(po, { status: 'ready_to_pay' }, 'Failed to mark ready to pay'); }; const handleMarkSubcontractorPaid = async (po) => { const paidAt = new Date().toISOString().slice(0, 10); updateSubcontractorPO(po, { status: 'paid', paid_at: paidAt }, 'Failed to mark as paid'); }; const handleMarkSubInvoicePaid = async (invoice) => { setMarkingPaid(invoice.id); try { const paidAt = new Date().toISOString(); const { error } = await supabase.from('subcontractor_invoices').update({ status: 'paid', paid_at: paidAt }).eq('id', invoice.id); if (error) throw error; const items = invoice.items || []; const total = items.reduce((s, i) => s + Number(i.unit_price || 0) * Number(i.quantity || 1), 0); let pdfBlob = null; try { pdfBlob = await generateSubcontractorPOPDF({ po_number: invoice.invoice_number, status: 'paid', profile: invoice.profile, project: { name: 'Subcontractor Invoice', company: { name: 'Fourge Branding' } }, date: invoice.created_at?.split('T')[0], due_date: paidAt.split('T')[0], amount: total, description: 'Payment for completed subcontractor work.', notes: invoice.notes, items: items.map((item, idx) => ({ description: item.description, amount: Number(item.unit_price) * Number(item.quantity || 1), sort_order: item.sort_order ?? idx, })), }, { output: 'blob' }); } catch (pdfErr) { console.error('PDF generation failed:', pdfErr); } if (invoice.profile?.email) { try { const attachments = pdfBlob ? [await blobToEmailAttachment(pdfBlob, `${invoice.invoice_number}-receipt.pdf`)] : []; await sendEmail('receipt_sent', invoice.profile.email, { invoiceNumber: invoice.invoice_number, billTo: invoice.profile.name || invoice.profile.email, total: total.toFixed(2), paidDate: new Date(paidAt).toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' }), }, attachments); } catch (emailErr) { console.error('Email failed:', emailErr); alert(`Marked paid, but email failed: ${emailErr.message || 'unknown error'}`); } } setSubInvoices(prev => prev.map(i => i.id === invoice.id ? { ...i, status: 'paid', paid_at: paidAt } : i)); } catch (err) { alert(`Failed to mark as paid: ${err.message}`); } setMarkingPaid(''); }; const handleReopenSubcontractorPO = async (po) => { updateSubcontractorPO(po, { status: 'draft', paid_at: null, cancelled_at: null }, 'Failed to reopen PO'); }; const handleCancelSubcontractorPO = async (po) => { if (!window.confirm(`Cancel ${po.po_number || 'this PO'}?`)) return; updateSubcontractorPO(po, { status: 'cancelled', cancelled_at: new Date().toISOString() }, 'Failed to cancel PO'); }; const handleDeleteSubcontractorPO = async (id) => { if (!window.confirm('Delete this subcontractor PO?')) return; await supabase.from('subcontractor_payments').delete().eq('id', id); setSubcontractorPOs(prev => prev.filter(po => po.id !== id)); setSelectedSubcontractorPOId(current => current === id ? '' : current); }; const handleDownloadSubcontractorPO = async (po) => { try { await generateSubcontractorPOPDF(po); } catch (error) { console.error('Failed to download subcontractor PO:', error); alert(`Failed to download PO: ${error.message || 'unknown error'}`); } }; const handleCPAExport = async () => { setExporting(true); try { const yearInvoices = invoices.filter(inv => inv.status === 'paid' && new Date(inv.invoice_date).getFullYear() === exportYear ); const yearExpenses = expenses.filter(exp => new Date(exp.date).getFullYear() === exportYear ); const yearSubcontractorExpenses = subcontractorPOs .filter(po => po.status === 'paid' && new Date(po.paid_at || po.date).getFullYear() === exportYear) .map(po => ({ date: po.paid_at || po.date, category: 'Contractor', description: `Subcontractor: ${po.profile?.name || 'External'} — ${po.items?.length ? po.items.map(item => item.description).join('; ') : po.description}`, amount: po.amount, notes: [po.po_number, po.project?.name, po.notes || po.profile?.email || ''].filter(Boolean).join(' · '), })); const exportExpenses = [...yearExpenses, ...yearSubcontractorExpenses]; if (!yearInvoices.length && !exportExpenses.length) { alert(`No data found for ${exportYear}.`); return; } const { data: allItems } = yearInvoices.length ? await supabase .from('invoice_items') .select('invoice_id, description') .in('invoice_id', yearInvoices.map(i => i.id)) : { data: [] }; const itemsByInvoice = (allItems || []).reduce((acc, item) => { (acc[item.invoice_id] = acc[item.invoice_id] || []).push(item); return acc; }, {}); await exportCPAPackage(yearInvoices, itemsByInvoice, exportExpenses, exportYear); } finally { setExporting(false); } }; const paidYears = [...new Set( invoices.filter(i => i.status === 'paid').map(i => new Date(i.invoice_date).getFullYear()) )].sort((a, b) => b - a); const companyNames = [...new Set(invoices.map(inv => inv.company?.name).filter(Boolean))].sort(); const filtered = invoices.filter(inv => { if (filter !== 'all' && inv.status !== filter) return false; if (filterCompany && inv.company?.name !== filterCompany) return false; return true; }); const totals = { all: invoices.reduce((s, i) => s + Number(i.total), 0), draft: invoices.filter(i => i.status === 'draft').reduce((s, i) => s + Number(i.total), 0), sent: invoices.filter(i => i.status === 'sent').reduce((s, i) => s + Number(i.total), 0), paid: invoices.filter(i => i.status === 'paid').reduce((s, i) => s + Number(i.total), 0), netReceived: invoices.filter(i => i.status === 'paid').reduce((s, i) => s + Number(i.total) - Number(i.stripe_fee || 0), 0), }; const filteredExpenses = expenseFilter === 'all' ? expenses : expenses.filter(e => e.category === expenseFilter); const paidSubcontractorPOs = subcontractorPOs.filter(po => po.status === 'paid'); const payableSubcontractorPOs = subcontractorPOs.filter(po => ['approved', 'ready_to_pay'].includes(po.status)); const selectedSubcontractorPO = subcontractorPOs.find(po => po.id === selectedSubcontractorPOId); const subInvoiceItemTotal = (inv) => (inv.items || []).reduce((a, x) => a + Number(x.unit_price || 0) * Number(x.quantity || 1), 0); const paidSubInvoices = subInvoices.filter(i => i.status === 'paid'); const totalPaidSubInvoices = paidSubInvoices.reduce((s, i) => s + subInvoiceItemTotal(i), 0); const totalPaidSubcontractors = paidSubcontractorPOs.reduce((s, po) => s + Number(po.amount), 0) + totalPaidSubInvoices; const totalPayableSubcontractorPOs = payableSubcontractorPOs.reduce((s, po) => s + Number(po.amount), 0); const totalPayableSubInvoices = subInvoices.filter(i => i.status === 'submitted').reduce((s, i) => s + subInvoiceItemTotal(i), 0); const totalPayableSubcontractors = totalPayableSubcontractorPOs + totalPayableSubInvoices; const payableSubcontractorCount = payableSubcontractorPOs.length + subInvoices.filter(i => i.status === 'submitted').length; const totalExpenses = filteredExpenses.reduce((s, e) => s + Number(e.amount), 0); const currentYear = new Date().getFullYear(); const yearExpenses = expenses.filter(e => new Date(e.date).getFullYear() === currentYear); const currentYearExpenseTotal = yearExpenses.reduce((s, e) => s + Number(e.amount), 0); const currentYearPaidSubcontractorPOs = paidSubcontractorPOs .filter(po => new Date(po.paid_at || po.date).getFullYear() === currentYear) .reduce((s, po) => s + Number(po.amount), 0); const currentYearPaidSubInvoices = paidSubInvoices .filter(i => new Date(i.paid_at).getFullYear() === currentYear) .reduce((s, i) => s + subInvoiceItemTotal(i), 0); const currentYearTotalExpenses = currentYearExpenseTotal + currentYearPaidSubcontractorPOs + currentYearPaidSubInvoices; const revenue = totals.paid; const profit = totals.netReceived - expenses.reduce((s, e) => s + Number(e.amount), 0) - totalPaidSubcontractors; const chartYear = exportYear; const chartData = useMemo(() => MONTHS.map((month, mi) => { const paid = invoices .filter(inv => inv.status === 'paid' && new Date(inv.invoice_date).getFullYear() === chartYear && new Date(inv.invoice_date).getMonth() === mi) .reduce((s, inv) => s + Number(inv.total || 0), 0); const outstanding = invoices .filter(inv => inv.status === 'sent' && new Date(inv.invoice_date).getFullYear() === chartYear && new Date(inv.invoice_date).getMonth() === mi) .reduce((s, inv) => s + Number(inv.total || 0), 0); const exp = expenses .filter(e => new Date(e.date).getFullYear() === chartYear && new Date(e.date).getMonth() === mi) .reduce((s, e) => s + Number(e.amount || 0), 0); const subExp = subInvoices .filter(i => i.status === 'paid' && new Date(i.created_at).getFullYear() === chartYear && new Date(i.created_at).getMonth() === mi) .reduce((s, i) => s + (i.items || []).reduce((a, x) => a + Number(x.unit_price || 0) * Number(x.quantity || 1), 0), 0); const totalExp = exp + subExp; return { month, Revenue: +paid.toFixed(2), Outstanding: +outstanding.toFixed(2), Expenses: +totalExp.toFixed(2), Profit: +(paid - totalExp).toFixed(2) }; }), [invoices, expenses, subInvoices, chartYear]); return (
{/* Top row: 60% chart + 40% stat cards */} {(() => { const yr = chartData.reduce((s, m) => s + m.Revenue, 0); const yp = chartData.reduce((s, m) => s + m.Profit, 0); const ye = chartData.reduce((s, m) => s + m.Expenses, 0); const yo = chartData.reduce((s, m) => s + m.Outstanding, 0); const fmt = v => v >= 100000 ? `$${(v/1000).toFixed(0)}k` : `$${v.toFixed(2)}`; const CARD = { background: 'var(--card-bg)', border: '1px solid var(--border)', borderRadius: 8, padding: '18px 21px', backdropFilter: 'blur(12px)', WebkitBackdropFilter: 'blur(12px)' }; const StatCard = ({ label, value, sub, iconBg, iconColor, iconSvg }) => (
{label}
{value}
{sub &&
{sub}
}
{iconSvg}
); return (
Revenue Overview
`$${v >= 1000 ? (v/1000).toFixed(0)+'k' : v}`} width={45} /> } />
} /> } /> } /> } />
); })()}
{[ { id: 'overview', label: 'Overview' }, { id: 'expenses', label: 'Expenses' }, { id: 'subcontractors', label: 'Subcontractors' }, { id: 'invoices', label: 'Invoices' }, { id: 'legacy', label: 'Legacy' }, ].map(t => ( ))} {financeTab === 'expenses' && (
)} {financeTab === 'invoices' && (
)}
{financeTab === 'overview' && (() => { const CARD = { background: 'var(--card-bg)', border: '1px solid var(--border)', borderRadius: 8, padding: '18px 21px', backdropFilter: 'blur(12px)', WebkitBackdropFilter: 'blur(12px)' }; const TH = { fontSize: 10, fontWeight: 500, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: 0.6, padding: '0 0 12px 0', border: 'none', background: 'transparent', textAlign: 'left' }; const TD = { fontSize: 13, color: 'var(--text-primary)', padding: '5px 0', border: 'none', background: 'transparent', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }; const sortRows = (arr, key, dir, getter) => [...arr].sort((a, b) => { const av = getter(a, key), bv = getter(b, key); if (av < bv) return dir === 'asc' ? -1 : 1; if (av > bv) return dir === 'asc' ? 1 : -1; return 0; }); const fmtDate = d => d ? new Date(d).toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) : '—'; const fmtAmt = v => `$${Number(v).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`; const now = new Date(); const curM = now.getMonth(), curY = now.getFullYear(); const stepMonth = (p, dir) => dir === -1 ? (p.month === 0 ? { month: 11, year: p.year - 1 } : { month: p.month - 1, year: p.year }) : (p.month === 11 ? { month: 0, year: p.year + 1 } : { month: p.month + 1, year: p.year }); const MonthNav = ({ m, setter }) => { const atCurrent = m.month === curM && m.year === curY; return (
); }; // Card 1: Expenses const m1 = ovMonth1.month, y1 = ovMonth1.year; const thisMonthExp = expenses.filter(e => { const d = new Date(e.date); return d.getFullYear() === y1 && d.getMonth() === m1; }); const thisMonthTotal = thisMonthExp.reduce((s, e) => s + Number(e.amount || 0), 0); const thisCatTotals = CATEGORIES.map(cat => ({ cat, total: thisMonthExp.filter(e => e.category === cat).reduce((s, e) => s + Number(e.amount || 0), 0) })).filter(c => c.total > 0).sort((a, b) => b.total - a.total).slice(0, 4); // Card 2: Sub payments this month (paid sub invoices) const ovInvTotal = inv => (inv.items || []).reduce((s, i) => s + Number(i.unit_price || 0) * Number(i.quantity || 1), 0); const m2 = ovMonth2.month, y2 = ovMonth2.year; const pendingSubs = [...subInvoices.filter(i => { const d = new Date(i.paid_at || i.submitted_at); return d.getFullYear() === y2 && d.getMonth() === m2; })].sort((a, b) => new Date(b.paid_at || b.submitted_at) - new Date(a.paid_at || a.submitted_at)); const pendingSubsTotal = pendingSubs.reduce((s, i) => s + ovInvTotal(i), 0); // Card 3: All invoices issued this month const m3 = ovMonth3.month, y3 = ovMonth3.year; const monthInvoices = [...invoices.filter(i => { const d = new Date(i.invoice_date); return d.getFullYear() === y3 && d.getMonth() === m3; })]; const outstandingTotal = monthInvoices.reduce((s, i) => s + Number(i.total || 0), 0); const PIE_COLORS = ['#F5A523', '#4ade80', '#60a5fa', '#c084fc', '#f472b6', '#fb923c', '#34d399', '#a78bfa']; // pie by subcontractor const subPieData = Object.values(pendingSubs.reduce((acc, inv) => { const name = inv.profile?.name || 'Unknown'; if (!acc[name]) acc[name] = { name, value: 0 }; acc[name].value += ovInvTotal(inv); return acc; }, {})).sort((a, b) => b.value - a.value); // pie by company (outstanding invoices) const invPieData = Object.values(monthInvoices.reduce((acc, inv) => { const name = inv.company?.name || inv.bill_to || 'Unknown'; if (!acc[name]) acc[name] = { name, value: 0 }; acc[name].value += Number(inv.total || 0); return acc; }, {})).sort((a, b) => b.value - a.value); const PieTooltipContent = ({ active, payload }) => { if (!active || !payload?.length) return null; return (
{payload[0].name}
{fmtAmt(payload[0].value)}
); }; const PieLegend = ({ data, colors }) => (
{data.map((d, i) => (
{d.name}
{fmtAmt(d.value)}
))}
); return (
{/* Card 1: This Month Expenses by category */}
{MONTHS[m1]} {y1 !== curY ? y1 : ''} Expenses
{fmtAmt(thisMonthTotal)}
{thisCatTotals.length ? thisCatTotals.map((_, i) => ) : } {thisCatTotals.length > 0 && { if (!active || !payload?.length) return null; return
{payload[0].name}
{fmtAmt(payload[0].value)}
; }} />}
{thisCatTotals.length > 0 && ({ name: c.cat, value: c.total }))} colors={PIE_COLORS} />}
{/* Card 2: Pending Sub Payments by subcontractor */}
{MONTHS[m2]} {y2 !== curY ? y2 : ''} Sub Payments
{fmtAmt(pendingSubsTotal)}
{subPieData.length ? subPieData.map((_, i) => ) : } {subPieData.length > 0 && { if (!active || !payload?.length) return null; return
{payload[0].name}
{fmtAmt(payload[0].value)}
; }} />}
{subPieData.length > 0 && }
{/* Card 3: Invoiced this month by company */}
{MONTHS[m3]} {y3 !== curY ? y3 : ''} Invoiced
{fmtAmt(outstandingTotal)}
{invPieData.length ? invPieData.map((_, i) => ) : } {invPieData.length > 0 && { if (!active || !payload?.length) return null; return
{payload[0].name}
{fmtAmt(payload[0].value)}
; }} />}
{invPieData.length > 0 && }
); })()} {financeTab === 'expenses' && (() => { const CARD = { background: 'var(--card-bg)', border: '1px solid var(--border)', borderRadius: 8, padding: '18px 21px', backdropFilter: 'blur(12px)', WebkitBackdropFilter: 'blur(12px)' }; const TH = { fontSize: 10, fontWeight: 500, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: 0.6, padding: '0 0 12px 0', border: 'none', background: 'transparent', textAlign: 'left' }; const TD = { fontSize: 13, color: 'var(--text-primary)', padding: '5px 0', border: 'none', background: 'transparent', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }; const fmtDate = d => d ? new Date(d).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) : '—'; const fmtAmt = v => `$${Number(v).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`; const sortedExpenses = expSort(expenses, (exp, key) => { if (key === 'amount') return Number(exp.amount) || 0; if (key === 'date') return exp.date || ''; return exp[key] || ''; }); const categoryTotals = CATEGORIES .map(cat => ({ cat, total: expenses.filter(e => e.category === cat).reduce((s, e) => s + Number(e.amount || 0), 0) })) .sort((a, b) => b.total - a.total); const grandTotal = expenses.reduce((s, e) => s + Number(e.amount || 0), 0); return (
{/* Left: expense list */}
{expenses.length === 0 ?
No expenses
: (
DateDescriptionCategoryNotesAmount{sortedExpenses.map(exp => ( ))}
{fmtDate(exp.date)} {exp.category || '—'} {exp.notes || '—'} {fmtAmt(exp.amount)}
)}
{/* Right: category breakdown */}
Category
{fmtAmt(grandTotal)}
{categoryTotals.length === 0 ?
No expenses
: (
{categoryTotals.map(({ cat, total }) => { const pct = grandTotal > 0 ? (total / grandTotal) * 100 : 0; return (
{cat} {fmtAmt(total)}
); })}
)}
); })()} {financeTab === 'subcontractors' && (() => { const CARD = { background: 'var(--card-bg)', border: '1px solid var(--border)', borderRadius: 8, padding: '18px 21px', backdropFilter: 'blur(12px)', WebkitBackdropFilter: 'blur(12px)' }; const TH = { fontSize: 10, fontWeight: 500, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: 0.6, padding: '0 0 12px 0', border: 'none', background: 'transparent', textAlign: 'left' }; const TD = { fontSize: 13, color: 'var(--text-primary)', padding: '5px 0', border: 'none', background: 'transparent', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }; const fmtDate = d => d ? new Date(d).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) : '—'; const fmtAmt = v => `$${Number(v).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`; const subInvoiceStatusColor = { draft: 'not_started', submitted: 'in_progress', paid: 'client_approved' }; const invTotal = inv => (inv.items || []).reduce((s, i) => s + Number(i.unit_price || 0) * Number(i.quantity || 1), 0); const yearFiltered = subInvoices; const sortedInvoices = subSort(yearFiltered, (inv, key) => { if (key === 'total') return invTotal(inv); if (key === 'subcontractor') return inv.profile?.name || ''; if (key === 'submitted_at') return inv.submitted_at ? new Date(inv.submitted_at).getTime() : 0; if (key === 'paid_at') return inv.paid_at ? new Date(inv.paid_at).getTime() : 0; return inv[key] || ''; }); const bySubcontractor = Object.values(yearFiltered.reduce((acc, inv) => { const key = inv.profile?.id || 'unknown'; if (!acc[key]) acc[key] = { name: inv.profile?.name || 'Unknown', invoices: [] }; acc[key].invoices.push(inv); return acc; }, {})).map(g => ({ ...g, total: g.invoices.reduce((s, i) => s + invTotal(i), 0), paid: g.invoices.filter(i => i.status === 'paid').reduce((s, i) => s + invTotal(i), 0), pending: g.invoices.filter(i => i.status === 'submitted').reduce((s, i) => s + invTotal(i), 0), })).sort((a, b) => b.total - a.total); const grandTotal = bySubcontractor.reduce((s, g) => s + g.total, 0); return (
{/* Left: invoice list */}
{subInvoicesLoading ? (
Loading…
) : subInvoicesError ? (
{subInvoicesError}
) : yearFiltered.length === 0 ? (
No invoices
) : (
Invoice #SubcontractorSubmittedPaidStatusAmount{sortedInvoices.map(inv => { const total = invTotal(inv); return ( ); })}
{inv.profile?.name || '—'} {fmtDate(inv.submitted_at)} {fmtDate(inv.paid_at)} {fmtAmt(total)}
)}
{/* Right: by subcontractor */}
Subcontractors
{fmtAmt(grandTotal)}
{bySubcontractor.length === 0 ?
No data
: (
{bySubcontractor.map(g => { const pct = grandTotal > 0 ? (g.total / grandTotal) * 100 : 0; return (
{g.name} {fmtAmt(g.total)}
0 ? '#F5A523' : 'var(--text-muted)' }}>{fmtAmt(g.pending)} pending · {fmtAmt(g.paid)} paid
); })}
)}
); })()} {financeTab === 'invoices' && (() => { const CARD = { background: 'var(--card-bg)', border: '1px solid var(--border)', borderRadius: 8, padding: '18px 21px', backdropFilter: 'blur(12px)', WebkitBackdropFilter: 'blur(12px)' }; const TH = { fontSize: 10, fontWeight: 500, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: 0.6, padding: '0 0 12px 0', border: 'none', background: 'transparent', textAlign: 'left' }; const TD = { fontSize: 13, color: 'var(--text-primary)', padding: '5px 0', border: 'none', background: 'transparent', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }; const fmtDate = d => d ? new Date(d).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) : '—'; const fmtAmt = v => `$${Number(v).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`; const statusColor = { draft: 'not_started', sent: 'in_progress', paid: 'client_approved' }; const sorted = invSort(invoices, (inv, key) => { if (key === 'total') return Number(inv.total) || 0; if (key === 'invoice_date' || key === 'due_date') return new Date(inv[key]).getTime(); if (key === 'company') return inv.company?.name || ''; return inv[key] || ''; }); const byCompany = Object.values(invoices.reduce((acc, inv) => { const name = inv.company?.name || inv.bill_to || 'Unknown'; if (!acc[name]) acc[name] = { name, total: 0, paid: 0, outstanding: 0 }; acc[name].total += Number(inv.total) || 0; if (inv.status === 'paid') acc[name].paid += Number(inv.total) || 0; else if (inv.status === 'sent') acc[name].outstanding += Number(inv.total) || 0; return acc; }, {})).sort((a, b) => b.total - a.total); const grandTotal = byCompany.reduce((s, g) => s + g.total, 0); return (
{loading ? (
Loading…
) : invoices.length === 0 ? (
No invoices
) : (
Invoice #CompanyDateDueStatusTotal{sorted.map(inv => ( ))}
{fmtDate(inv.invoice_date)} {fmtDate(inv.due_date)} {fmtAmt(inv.total)}
)}
Companies
{fmtAmt(grandTotal)}
{byCompany.length === 0 ?
No data
: (
{byCompany.map(g => { const pct = grandTotal > 0 ? (g.total / grandTotal) * 100 : 0; return (
{g.name} {fmtAmt(g.total)}
0 ? '#F5A523' : 'var(--text-muted)' }}>{fmtAmt(g.outstanding)} outstanding · {fmtAmt(g.paid)} paid
); })}
)}
); })()} {financeTab === 'legacy' &&
{[ { id: 'invoices', label: 'INVOICES' }, { id: 'expenses', label: 'EXPENSES' }, { id: 'sub-invoices', label: 'SUBCONTRACTOR INVOICES' }, ].map((t, i, arr) => ( {i < arr.length - 1 && |} ))}
{companyNames.length > 0 && activeTab === 'invoices' && ( )}
{activeTab === 'invoices' && (
)} {activeTab === 'expenses' && (
)}
{activeTab === 'invoices' && (
{/* ── Invoices ── */}
{loading ? (

Loading...

) : filtered.length === 0 ? (

No invoices.

) : (
Invoice #Bill ToDateDueStatusTotal {invSort(filtered, (inv, key) => { if (key === 'total') return Number(inv.total) || 0; if (key === 'invoice_date' || key === 'due_date') return new Date(inv[key]).getTime(); return inv[key] || inv.company?.name || ''; }).map(inv => ( navigate(`/invoices/${inv.id}`)} style={{ cursor: 'pointer' }}> ))}
{inv.invoice_number} {inv.bill_to || inv.company?.name} {new Date(inv.invoice_date).toLocaleDateString()} {new Date(inv.due_date).toLocaleDateString()} ${Number(inv.total).toFixed(2)}
)}
)} {activeTab === 'expenses' && (
{expensesLoading ? (

Loading...

) : expensesError ? (

{expensesError}

) : filteredExpenses.length === 0 ? (

No expenses yet.

) : (
DateDescriptionCategoryNotesAmount {expSort(filteredExpenses, (exp, key) => { if (key === 'amount') return Number(exp.amount) || 0; if (key === 'date') return exp.date || ''; return exp[key] || ''; }).map(exp => ( startEditExpense(exp)}> ))}
Action
{new Date(exp.date).toLocaleDateString()} {exp.description} {exp.category} {exp.notes || exp.receipt_path ? (
{exp.notes || '—'} {exp.receipt_path && ( )}
) : '—'}
${Number(exp.amount).toFixed(2)}
)}
)} {activeTab === 'sub-invoices' && (
{subInvoicesLoading ? (

Loading...

) : subInvoicesError ? (

{subInvoicesError}

) : subInvoices.length === 0 ? (

No sub invoices yet.

) : (
Invoice #SubcontractorSubmittedStatusTotal {subSort(subInvoices, (inv, key) => { if (key === 'total') return (inv.items || []).reduce((s, i) => s + Number(i.unit_price || 0) * Number(i.quantity || 1), 0); if (key === 'subcontractor') return inv.profile?.name || ''; if (key === 'submitted_at') return inv.submitted_at ? new Date(inv.submitted_at).getTime() : 0; return inv[key] || ''; }).map(inv => { const total = (inv.items || []).reduce((s, i) => s + Number(i.unit_price || 0) * Number(i.quantity || 1), 0); const subInvoiceStatusColor = { draft: 'not_started', submitted: 'in_progress', paid: 'client_approved' }; return ( navigate(`/sub-invoices/${inv.id}`)}> ); })}
Actions
{inv.invoice_number}
{inv.profile?.name || 'External'}
{inv.profile?.email || '—'}
{inv.submitted_at ? new Date(inv.submitted_at).toLocaleDateString() : '—'} ${total.toFixed(2)}
)}
)}
}{/* end legacy wrapper */} {showExpenseForm && (
e.stopPropagation()}>
{editingExpenseId ? 'Edit Expense' : 'New Expense'}
Date *
setNewExpense(p => ({ ...p, date: e.target.value }))} style={{ fontSize: 13, color: 'var(--text-primary)', background: 'var(--card-bg-2)', border: '1px solid var(--border)', borderRadius: 6, padding: '6px 10px', width: '100%', fontFamily: 'inherit', boxSizing: 'border-box' }} />
Amount (USD) *
setNewExpense(p => ({ ...p, amount: e.target.value }))} style={{ fontSize: 13, color: 'var(--text-primary)', background: 'var(--card-bg-2)', border: '1px solid var(--border)', borderRadius: 6, padding: '6px 10px', width: '100%', fontFamily: 'inherit', boxSizing: 'border-box' }} />
Description *
setNewExpense(p => ({ ...p, description: e.target.value }))} style={{ fontSize: 13, color: 'var(--text-primary)', background: 'var(--card-bg-2)', border: '1px solid var(--border)', borderRadius: 6, padding: '6px 10px', width: '100%', fontFamily: 'inherit', boxSizing: 'border-box' }} />
Category
Notes
setNewExpense(p => ({ ...p, notes: e.target.value }))} style={{ fontSize: 13, color: 'var(--text-primary)', background: 'var(--card-bg-2)', border: '1px solid var(--border)', borderRadius: 6, padding: '6px 10px', width: '100%', fontFamily: 'inherit', boxSizing: 'border-box' }} />
Receipt / Photo
setNewExpense(p => ({ ...p, receipt: e.target.files?.[0] || null, removeReceipt: false }))} style={{ fontSize: 13, color: 'var(--text-primary)', background: 'var(--card-bg-2)', border: '1px solid var(--border)', borderRadius: 6, padding: '6px 10px', width: '100%', fontFamily: 'inherit', boxSizing: 'border-box' }} /> {!newExpense.receipt && newExpense.receipt_path && (
{newExpense.receipt_name || 'Existing receipt attached'}
)} {newExpense.receipt &&
{newExpense.receipt.name}
}
{expensesError &&
{expensesError}
}
)}
{/* end flex column wrapper */} ); }