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 { useAuth } from '../../context/AuthContext'; import { readPageCache, writePageCache } from '../../lib/pageCache'; import { exportCPAPackage, generateSubcontractorPOPDF, generateInvoicePDF } from '../../lib/invoice'; import { withTimeout } from '../../lib/withTimeout'; import LoadingButton from '../../components/LoadingButton'; import { blobToEmailAttachment, sendEmail } from '../../lib/email'; import { popupOverlayStyle, popupSurfaceStyle } from '../../lib/popupStyles'; import FileAttachment from '../../components/FileAttachment'; import { isReviewShadowDescription, pickInitialServiceType } from '../../lib/taskVersions'; import { getRevisionChargeQuantity, isCompletedVersionEligible, isInitialVersionEligible } from '../../lib/invoiceVersionRules'; import { TeamInvoiceDetailPanel } from './TeamInvoiceDetail'; import InvoiceDetailPopup, { POPUP_FIELD_LABEL } from '../../components/InvoiceDetailPopup'; import InvoicePopupTable from '../../components/InvoicePopupTable'; 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 FINANCE_MODAL_INPUT_STYLE = { ...FIELD_INPUT_STYLE, width: '100%', padding: '0 10px', fontSize: 13, color: 'var(--text-primary)', background: 'var(--card-bg-2)', border: '1px solid var(--border)', borderRadius: 6, fontFamily: 'inherit', boxSizing: 'border-box', }; const FINANCE_MODAL_TEXTAREA_STYLE = { ...FINANCE_MODAL_INPUT_STYLE, minHeight: 72, padding: '10px', resize: 'vertical', }; const FINANCE_MODAL_LINE_ITEM_INPUT_STYLE = { ...FINANCE_MODAL_INPUT_STYLE, minHeight: 32, padding: '0 8px', }; const FINANCE_MODAL_NUMBER_INPUT_STYLE = { ...FINANCE_MODAL_LINE_ITEM_INPUT_STYLE, fontVariantNumeric: 'tabular-nums', }; const FINANCE_MODAL_AMOUNT_FIELD_STYLE = { display: 'flex', alignItems: 'center', gap: 8, minHeight: 42, width: '100%', padding: '0 12px', background: 'var(--card-bg-2)', border: '1px solid var(--border)', borderRadius: 6, boxSizing: 'border-box', }; const FINANCE_MODAL_AMOUNT_PREFIX_STYLE = { flexShrink: 0, fontSize: 16, fontWeight: 500, lineHeight: 1, color: 'var(--text-secondary)', }; const FINANCE_MODAL_AMOUNT_INPUT_STYLE = { flex: 1, minWidth: 0, margin: 0, padding: 0, border: 'none', outline: 'none', background: 'transparent', boxShadow: 'none', fontFamily: 'inherit', fontSize: 22, fontWeight: 500, lineHeight: 1.1, textAlign: 'right', color: 'var(--text-primary)', fontVariantNumeric: 'tabular-nums', appearance: 'textfield', }; const FINANCE_MODAL_TOTAL_VALUE_STYLE = { fontSize: 24, fontWeight: 500, lineHeight: 1.1, color: 'var(--accent)', fontVariantNumeric: 'tabular-nums', }; function parseSubInvoiceItemVersionNumber(item) { if (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 subInvoiceItemVersionLabel(item) { return `R${String(parseSubInvoiceItemVersionNumber(item)).padStart(2, '0')}`; } function subInvoiceItemWorkLabel(item) { return parseSubInvoiceItemVersionNumber(item) > 0 ? 'Revision' : 'New'; } function cleanSubInvoiceItemDescription(item) { return String(item?.description || '—').replace(/\s*[–-]\s*R\d{2}\b/i, '').trim() || '—'; } function asArray(value) { if (Array.isArray(value)) return value; if (value == null) return []; return typeof value === 'object' ? [value] : []; } async function fetchSubInvoiceTaskTypeMap(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 CurrencyInput({ value, onChange, placeholder = '0.00', required = false, min = '0', step = '0.01' }) { return (
$
); } const INV_TODAY = new Date().toISOString().split('T')[0]; const INV_NET30 = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString().split('T')[0]; const invNewItem = (description = '', unit_price = '', quantity = 1, task_id = null, submission_id = null) => ({ id: crypto.randomUUID(), description, unit_price, quantity, task_id, submission_id }); const invBuildNewItemDescription = (task) => { const projectName = task.project?.name || 'No Project'; const taskTitle = task.title || task.service_type || 'Untitled'; return `${projectName} • ${taskTitle}`; }; const invBuildRevisionItemDescription = (revision) => { const projectName = revision.task?.project?.name || 'No Project'; const taskTitle = revision.task?.title || revision.service_type || 'Revision'; const versionLabel = 'R' + String(revision.version_number || 0).padStart(2, '0'); return `${projectName} • ${taskTitle} • Revision ${versionLabel}`; }; 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 = ['January','February','March','April','May','June','July','August','September','October','November','December']; 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 { currentUser } = useAuth(); 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', 'desc'); const { sortKey: expSortKey, sortDir: expSortDir, toggle: expToggle, sort: expSort } = useSortable('date', 'desc'); 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 { sortKey: subPopupKey, sortDir: subPopupDir, toggle: subPopupToggle, sort: subPopupSort } = useSortable('description'); 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 [viewingExpense, setViewingExpense] = useState(null); const [expensePreviewUrl, setExpensePreviewUrl] = useState(null); const [expenseDetailEditing, setExpenseDetailEditing] = useState(false); const [expYearFilter, setExpYearFilter] = useState(String(new Date().getFullYear())); const [expYearMenuOpen, setExpYearMenuOpen] = useState(false); const expYearMenuRef = useRef(null); const [invYearFilter, setInvYearFilter] = useState(String(new Date().getFullYear())); const [invCompanyFilter, setInvCompanyFilter] = useState('all'); const [invYearMenuOpen, setInvYearMenuOpen] = useState(false); const invYearMenuRef = useRef(null); 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 [viewingSubInvoice, setViewingSubInvoice] = useState(null); const [viewingSubInvoiceTaskTypeMap, setViewingSubInvoiceTaskTypeMap] = useState({}); const [viewingInvoice, setViewingInvoice] = useState(null); const [expandedSubInvoiceId, setExpandedSubInvoiceId] = useState(null); // ── New Invoice form ────────────────────────────────────────────────────── const [showInvoiceForm, setShowInvoiceForm] = useState(false); const [invCompanies, setInvCompanies] = useState([]); const [invCompanyId, setInvCompanyId] = useState(''); const [invBillTo, setInvBillTo] = useState(''); const [invEmail, setInvEmail] = useState(''); const [invRecipients, setInvRecipients] = useState([]); const [invUnbilledTasks, setInvUnbilledTasks] = useState([]); const [invUnbilledRevisions, setInvUnbilledRevisions] = useState([]); const [invPriceList, setInvPriceList] = useState([]); const [invItems, setInvItems] = useState([invNewItem()]); const [invNotes, setInvNotes] = useState(''); const [invSaving, setInvSaving] = useState(false); const [invLoadingTasks, setInvLoadingTasks] = useState(false); const invDragItem = useRef(null); const pageLoading = loading || expensesLoading || subcontractorLoading || subInvoicesLoading; useEffect(() => { supabase.from('companies').select('id, name, contact_email').order('name').then(({ data }) => setInvCompanies(data || [])); }, []); useEffect(() => { let cancelled = false; async function loadSubInvoiceTaskTypes() { if (!viewingSubInvoice?.items?.length) { setViewingSubInvoiceTaskTypeMap({}); return; } try { const map = await fetchSubInvoiceTaskTypeMap(asArray(viewingSubInvoice.items).map((item) => item.task_id)); if (!cancelled) setViewingSubInvoiceTaskTypeMap(map); } catch { if (!cancelled) setViewingSubInvoiceTaskTypeMap({}); } } loadSubInvoiceTaskTypes(); return () => { cancelled = true; }; }, [viewingSubInvoice]); useEffect(() => { if (!invCompanyId) { setInvUnbilledTasks([]); setInvUnbilledRevisions([]); setInvPriceList([]); setInvItems([invNewItem()]); setInvBillTo(''); setInvEmail(''); setInvRecipients([]); return; } const co = invCompanies.find(c => c.id === invCompanyId); setInvBillTo(co?.name || ''); setInvLoadingTasks(true); Promise.all([ supabase.from('projects').select('id').eq('company_id', invCompanyId), supabase.from('company_prices').select('*').eq('company_id', invCompanyId), supabase.from('profiles').select('id, name, email, role').eq('company_id', invCompanyId).in('role', ['client', 'external']).order('name'), ]).then(async ([{ data: projects }, { data: prices }, { data: users }]) => { setInvPriceList(prices || []); setInvRecipients(users || []); setInvEmail((users || [])[0]?.email || co?.contact_email || ''); if (projects?.length > 0) { const pids = projects.map(p => p.id); const { data: companyTasks } = await supabase .from('tasks') .select('*, project:projects(name), submissions(service_type, type, version_number)') .in('project_id', pids) .in('status', ['not_started', 'in_progress', 'on_hold', 'client_review', 'client_approved', 'invoiced', 'paid']); const revisionTaskIds = (companyTasks || []).map(t => t.id).filter(Boolean); const { data: revisions } = revisionTaskIds.length > 0 ? await supabase .from('submissions') .select('*, task:tasks(id, title, status, current_version, project:projects(name), submissions(service_type, type))') .eq('type', 'revision') .eq('invoiced', false) .in('task_id', revisionTaskIds) .order('submitted_at', { ascending: false }) : { data: [] }; const uninvoicedTasks = (companyTasks || []).filter(isInitialVersionEligible); setInvUnbilledTasks(uninvoicedTasks.map(t => { return { ...t, service_type: pickInitialServiceType(t.submissions, t.title) }; })); const revMap = new Map(); for (const r of (revisions || []).filter(r => !isReviewShadowDescription(r.description))) { if (!isCompletedVersionEligible(r.task, r.version_number)) continue; const k = `${r.task_id}:${r.version_number}`; if (!revMap.has(k)) revMap.set(k, r); } setInvUnbilledRevisions([...revMap.values()]); } else { setInvUnbilledTasks([]); setInvUnbilledRevisions([]); } setInvLoadingTasks(false); }); }, [invCompanyId, invCompanies]); const invAddTask = (task) => { const price = invPriceList.find(p => p.service_type === task.service_type && p.price_type === 'new'); const desc = invBuildNewItemDescription(task); setInvItems(prev => prev.length === 1 && !prev[0].description && !prev[0].unit_price ? [invNewItem(desc, price?.price || '', 1, task.id)] : [...prev, invNewItem(desc, price?.price || '', 1, task.id)]); }; const invAddRevision = (rev) => { const svcType = pickInitialServiceType(rev.task?.submissions, rev.service_type || rev.task?.title || 'Revision'); const price = invPriceList.find(p => p.service_type === svcType && p.price_type === 'revision'); const revisionChargeQty = getRevisionChargeQuantity(rev?.version_number); const qty = revisionChargeQty > 0 ? revisionChargeQty : 1; const unitPrice = revisionChargeQty > 0 ? (price?.price || '') : 0; const desc = invBuildRevisionItemDescription(rev); setInvItems(prev => prev.length === 1 && !prev[0].description && !prev[0].unit_price ? [invNewItem(desc, unitPrice, qty, rev.task_id, rev.id)] : [...prev, invNewItem(desc, unitPrice, qty, rev.task_id, rev.id)]); }; const invUpdateItem = (id, field, val) => setInvItems(prev => prev.map(it => it.id === id ? { ...it, [field]: val } : it)); const invRemoveItem = (id) => setInvItems(prev => prev.filter(it => it.id !== id)); const invHandleDrop = (targetIdx) => { if (invDragItem.current === null || invDragItem.current === targetIdx) { invDragItem.current = null; return; } setInvItems(prev => { const next = [...prev]; const [moved] = next.splice(invDragItem.current, 1); next.splice(targetIdx, 0, moved); return next; }); invDragItem.current = null; }; const invTotal = invItems.reduce((s, it) => s + (Number(it.quantity) || 0) * (Number(it.unit_price) || 0), 0); const invSelectedCompany = invCompanies.find(c => c.id === invCompanyId); const invClose = () => { setShowInvoiceForm(false); setInvCompanyId(''); setInvBillTo(''); setInvEmail(''); setInvRecipients([]); setInvUnbilledTasks([]); setInvUnbilledRevisions([]); setInvPriceList([]); setInvItems([invNewItem()]); setInvNotes(''); }; const invHandleSave = async (status) => { if (!invCompanyId) return alert('Select a company.'); if (invItems.every(i => !i.description)) return alert('Add at least one line item.'); if (status === 'sent' && !invEmail.trim()) return alert('Enter an email recipient before sending.'); setInvSaving(true); try { const year = new Date().getFullYear(); const { count } = await supabase.from('invoices').select('*', { count: 'exact', head: true }).gte('created_at', `${year}-01-01`); const invoiceNumber = `INV-${year}-${String((count || 0) + 1).padStart(3, '0')}`; const { data: invoice, error } = await supabase.from('invoices').insert({ company_id: invCompanyId, invoice_number: invoiceNumber, invoice_date: INV_TODAY, due_date: INV_NET30, status: status === 'sent' ? 'draft' : status, bill_to: invBillTo || null, invoice_email: invEmail.trim() || null, notes: invNotes || null, total: invTotal, created_by: currentUser?.id }).select().single(); if (error || !invoice) throw error || new Error('Invoice not created.'); const validItems = invItems.filter(i => i.description); if (validItems.length > 0) await supabase.from('invoice_items').insert(validItems.map(it => ({ invoice_id: invoice.id, task_id: it.task_id || null, submission_id: it.submission_id || null, description: it.description, quantity: Number(it.quantity) || 1, unit_price: Number(it.unit_price) || 0 }))); const taskIds = [...new Set(validItems.filter(i => i.task_id && !i.submission_id).map(i => i.task_id))]; if (taskIds.length > 0) await supabase.from('tasks').update({ invoiced: true }).in('id', taskIds); const subIds = [...new Set(validItems.filter(i => i.submission_id).map(i => i.submission_id))]; if (subIds.length > 0) await supabase.from('submissions').update({ invoiced: true }).in('id', subIds); if (status === 'sent') { try { const dueDate = new Date(INV_NET30).toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' }); const payUrl = `https://portal.fourgebranding.com/pay/${encodeURIComponent(invoiceNumber)}`; const pdfItems = validItems.map(it => ({ description: it.description, quantity: Number(it.quantity) || 1, unit_price: Number(it.unit_price) || 0 })); let attachments = []; try { const pdf = await withTimeout(generateInvoicePDF({ ...invoice, status: 'sent' }, invSelectedCompany, pdfItems, { save: false }), 8000, 'PDF'); attachments = [await withTimeout(blobToEmailAttachment(pdf, `${invoiceNumber}.pdf`), 5000, 'Attachment')]; } catch { /* PDF attach failed — send email without attachment */ } await withTimeout(sendEmail('invoice_sent', invEmail.trim(), { invoiceNumber, billTo: invBillTo || invSelectedCompany?.name, total: `$${invTotal.toFixed(2)}`, dueDate, payUrl, notes: invNotes || '' }, attachments), 12000, 'Email'); await supabase.from('invoices').update({ status: 'sent' }).eq('id', invoice.id); } catch (e) { alert(`Invoice saved as draft — email failed: ${e.message}`); } } const createdInvoice = { ...invoice, company: invSelectedCompany ? { id: invSelectedCompany.id, name: invSelectedCompany.name } : null, }; setInvoices(prev => [createdInvoice, ...prev]); invClose(); setViewingInvoice(createdInvoice); } catch (e) { alert(`Failed to save invoice: ${e.message || 'Unknown error'}`); } setInvSaving(false); }; 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(() => { if (!expYearMenuOpen) return; const handler = e => { if (expYearMenuRef.current && !expYearMenuRef.current.contains(e.target)) setExpYearMenuOpen(false); }; document.addEventListener('mousedown', handler); return () => document.removeEventListener('mousedown', handler); }, [expYearMenuOpen]); useEffect(() => { if (!invYearMenuOpen) return; const handler = e => { if (invYearMenuRef.current && !invYearMenuRef.current.contains(e.target)) setInvYearMenuOpen(false); }; document.addEventListener('mousedown', handler); return () => document.removeEventListener('mousedown', handler); }, [invYearMenuOpen]); useEffect(() => { setExpensePreviewUrl(null); setExpenseDetailEditing(false); if (!viewingExpense?.receipt_path) return; supabase.storage.from(RECEIPT_BUCKET).createSignedUrl(viewingExpense.receipt_path, 3600) .then(({ data }) => { if (data?.signedUrl) setExpensePreviewUrl(data.signedUrl); }); }, [viewingExpense]); 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 cancelExpenseEdit = () => { setEditingExpenseId(''); setNewExpense(blankExpense()); setExpensesError(''); setShowExpenseForm(false); }; const saveExpense = async () => { 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 handleAddExpense = async (e) => { e.preventDefault(); await saveExpense(); }; 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 win = window.open('', '_blank', 'noopener,noreferrer'); const { data, error } = await supabase.storage.from(RECEIPT_BUCKET).createSignedUrl(expense.receipt_path, 3600); if (error || !data?.signedUrl) { win?.close(); const message = error?.message || 'Failed to open receipt.'; setExpensesError(message); alert(message); return; } win.location.href = data.signedUrl; }; 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) => { await updateSubcontractorPO(po, { status: 'sent', sent_at: new Date().toISOString() }, 'Failed to send PO'); }; 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 handleDownloadSubInvoiceReceipt = async (invoice) => { const items = invoice.items || []; const total = items.reduce((s, i) => s + Number(i.unit_price || 0) * Number(i.quantity || 1), 0); try { 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: (invoice.paid_at || new Date().toISOString()).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, })), }); } catch (err) { alert(`Failed to download receipt: ${err.message || 'unknown error'}`); } }; const handleDownloadSubInvoice = async (invoice) => { const items = invoice.items || []; const total = items.reduce((s, i) => s + Number(i.unit_price || 0) * Number(i.quantity || 1), 0); try { await generateSubcontractorPOPDF({ po_number: invoice.invoice_number, status: invoice.status || 'draft', profile: invoice.profile, project: { name: 'Subcontractor Invoice', company: { name: 'Fourge Branding' } }, date: invoice.created_at?.split('T')[0], due_date: (invoice.paid_at || invoice.submitted_at || invoice.created_at || new Date().toISOString()).split('T')[0], amount: total, description: invoice.status === 'paid' ? 'Payment for completed subcontractor work.' : 'Subcontractor invoice for completed work.', notes: invoice.notes, items: items.map((item, idx) => ({ description: item.description, amount: Number(item.unit_price || 0) * Number(item.quantity || 1), sort_order: item.sort_order ?? idx, })), }); } catch (err) { alert(`Failed to download invoice: ${err.message || 'unknown error'}`); } }; 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 expenseYears = [...new Set(expenses.map(e => e.date?.slice(0, 4)).filter(Boolean))].sort((a, b) => b - a); const filteredExpenses = expenses.filter(e => { if (expYearFilter !== 'all' && e.date?.slice(0, 4) !== expYearFilter) return false; if (expenseFilter !== 'all' && e.category !== expenseFilter) return false; return true; }); 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 paidInvs = invoices.filter(inv => inv.status === 'paid' && new Date(inv.invoice_date).getFullYear() === chartYear && new Date(inv.invoice_date).getMonth() === mi); const paid = paidInvs.reduce((s, inv) => s + Number(inv.total || 0), 0); const netPaid = paidInvs.reduce((s, inv) => s + Number(inv.total || 0) - Number(inv.stripe_fee || 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: +(netPaid - 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).toLocaleString('en-US')}k` : `$${v.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 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' }, ].map(t => ( ))} {financeTab === 'expenses' && (
{expYearMenuOpen && (
{expenseYears.map(y => ( ))}
)}
)} {financeTab === 'invoices' && (
{invYearMenuOpen && (
Year
{[...new Set(invoices.map(i => i.invoice_date?.slice(0,4)).filter(Boolean))].sort((a,b) => b-a).map(y => ( ))}
Company
{[...new Set(invoices.map(i => i.company?.name || i.bill_to).filter(Boolean))].sort().map(c => ( ))}
)}
)}
{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 => e.date && parseInt(e.date.slice(0,4)) === y1 && parseInt(e.date.slice(5,7)) - 1 === 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 => i.invoice_date && parseInt(i.invoice_date.slice(0,4)) === y3 && parseInt(i.invoice_date.slice(5,7)) - 1 === 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)}
; }} />}
{thisMonthExp.length}
items
{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)}
; }} />}
{pendingSubs.length}
invoices
{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)}
; }} />}
{monthInvoices.length}
invoices
{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 + 'T12:00:00').toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) : '—'; const fmtAmt = v => `$${Number(v).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`; const sortedExpenses = expSort(filteredExpenses, (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: filteredExpenses.filter(e => e.category === cat).reduce((s, e) => s + Number(e.amount || 0), 0) })) .sort((a, b) => b.total - a.total); const grandTotal = filteredExpenses.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 filteredInvoices = invoices.filter(i => { if (invYearFilter !== 'all' && i.invoice_date?.slice(0,4) !== invYearFilter) return false; if (invCompanyFilter !== 'all' && (i.company?.name || i.bill_to) !== invCompanyFilter) return false; return true; }); const sorted = invSort(filteredInvoices, (inv, key) => { if (key === 'total' || key === 'stripe_fee') return Number(inv[key]) || 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(filteredInvoices.reduce((acc, inv) => { const name = inv.company?.name || inv.bill_to || 'Unknown'; if (!acc[name]) acc[name] = { name, total: 0, paid: 0, outstanding: 0, fees: 0 }; acc[name].total += Number(inv.total) || 0; if (inv.status === 'paid') { acc[name].paid += Number(inv.total) || 0; acc[name].fees += Number(inv.stripe_fee) || 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 #CompanyDateDueStatusFeesTotal{sorted.map(inv => ( ))}
{fmtDate(inv.invoice_date)} {fmtDate(inv.due_date)} 0 ? '#ef4444' : 'var(--text-muted)' }}> {Number(inv.stripe_fee) > 0 ? `-${fmtAmt(inv.stripe_fee)}` : '$0.00'} {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 {g.fees > 0 && · -{fmtAmt(g.fees)} fees}
); })}
)}
); })()} {financeTab === 'legacy' &&
{[ { id: 'invoices', label: 'INVOICES' }, { id: 'expenses', label: 'EXPENSES' }, { id: 'sub-invoices', label: 'SUBCONTRACTOR INVOICES' }, ].map((t, i, arr) => ( ))}
{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 => ( setViewingInvoice(inv)} 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
) : (
DateDescriptionCategoryNotesAmount {expSort(filteredExpenses, (exp, key) => { if (key === 'amount') return Number(exp.amount) || 0; if (key === 'date') return exp.date || ''; return exp[key] || ''; }).map(exp => ( setViewingExpense(exp)}> ))}
Action
{new Date(exp.date + 'T12:00:00').toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })} {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
) : (
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 ( setViewingSubInvoice(inv)}> ); })}
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 */} {viewingSubInvoice && (() => { const invoice = viewingSubInvoice; const baseItems = [...(invoice.items || [])].sort((a, b) => (a.sort_order ?? 0) - (b.sort_order ?? 0)); const total = baseItems.reduce((s, item) => s + Number(item.unit_price || 0) * Number(item.quantity || 1), 0); const invoiceStatus = invoice.status ? invoice.status.charAt(0).toUpperCase() + invoice.status.slice(1) : '—'; const subInvoiceStatusColor = { draft: 'not_started', submitted: 'in_progress', paid: 'client_approved' }; const tableItems = subPopupSort(baseItems.map(item => ({ ...item, _inferredType: { label: subInvoiceItemWorkLabel(item), badgeClass: subInvoiceItemWorkLabel(item) === 'New' ? 'badge-initial' : 'badge-client_revision' }, description: cleanSubInvoiceItemDescription(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 ( } onClose={() => { if (!markingPaid) setViewingSubInvoice(null); }} blockClose={Boolean(markingPaid)} metaContent={<>
Submitted
{invoice.submitted_at ? new Date(invoice.submitted_at).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) : '—'}
Paid
{invoice.paid_at ? new Date(invoice.paid_at).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) : '—'}
Line Items
{baseItems.length}
Total
${total.toFixed(2)}
} footerActions={<> {invoice.status === 'submitted' && ( { await handleMarkSubInvoicePaid(invoice); setViewingSubInvoice(current => current?.id === invoice.id ? { ...current, status: 'paid', paid_at: new Date().toISOString() } : current); }} loading={markingPaid === invoice.id} loadingText="Processing…">Mark as Paid )} {invoice.status === 'paid' && ( )} } >
); })()} {viewingInvoice && ( setViewingInvoice(null)} onInvoiceUpdated={(updatedInvoice) => { setViewingInvoice(updatedInvoice); setInvoices((current) => current.map((invoice) => (invoice.id === updatedInvoice.id ? { ...invoice, ...updatedInvoice } : invoice))); }} onInvoiceDeleted={(deletedId) => { setInvoices((current) => current.filter((invoice) => invoice.id !== deletedId)); setViewingInvoice((current) => (current?.id === deletedId ? null : current)); }} /> )} {viewingExpense && (() => { const LABEL = { ...FIELD_LABEL_STYLE, marginBottom: 3 }; const INPUT = FINANCE_MODAL_INPUT_STYLE; const isPdf = viewingExpense.receipt_name?.toLowerCase().endsWith('.pdf'); const isDirty = expenseDetailEditing && ( newExpense.amount !== String(viewingExpense.amount ?? '') || newExpense.description !== (viewingExpense.description || '') || newExpense.date !== (viewingExpense.date || '') || newExpense.category !== (viewingExpense.category || 'Other') || newExpense.notes !== (viewingExpense.notes || '') || newExpense.receipt != null || newExpense.removeReceipt === true ); return (
{ setViewingExpense(null); setExpenseDetailEditing(false); }}>
e.stopPropagation()}> {/* Main content row */}
{/* Left: details (editable in-place) */}
Expense Detail
{expenseDetailEditing ? ( setNewExpense(p => ({ ...p, amount: e.target.value }))} /> ) : (
${Number(viewingExpense.amount).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
)}
Description
{expenseDetailEditing ? setNewExpense(p => ({ ...p, description: e.target.value }))} /> :
{viewingExpense.description || '—'}
}
Date
{expenseDetailEditing ? setNewExpense(p => ({ ...p, date: e.target.value }))} /> :
{viewingExpense.date ? new Date(viewingExpense.date + 'T12:00:00').toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' }) : '—'}
}
Category
{expenseDetailEditing ? :
{viewingExpense.category || '—'}
}
Notes
{expenseDetailEditing ? setNewExpense(p => ({ ...p, notes: e.target.value }))} /> :
{viewingExpense.notes || '—'}
}
{expenseDetailEditing && (
Receipt / Photo
setNewExpense(p => ({ ...p, receipt: e.target.files?.[0] || null, removeReceipt: false }))} /> {!newExpense.receipt && newExpense.receipt_path && (
{newExpense.receipt_name || 'Existing receipt'}
)}
)}
{/* Right: receipt preview — fixed to left column height, item scales inside */} {viewingExpense.receipt_path && (
Receipt
{expensePreviewUrl ? ( isPdf ? : Receipt ) : (
Loading preview…
)}
)}
{/* Footer: buttons bottom-right */}
{expenseDetailEditing ? ( <> ) : ( <> {expensePreviewUrl && ( Download Receipt )} )}
); })()} {showExpenseForm && (() => { const LABEL = { ...FIELD_LABEL_STYLE, marginBottom: 3 }; const INPUT = FINANCE_MODAL_INPUT_STYLE; return (
e.stopPropagation()}>
{/* Main content row */}
{/* Left: fields */}
New Expense
setNewExpense(p => ({ ...p, amount: e.target.value }))} />
Description
setNewExpense(p => ({ ...p, description: e.target.value }))} />
Date
setNewExpense(p => ({ ...p, date: e.target.value }))} />
Category
Notes
setNewExpense(p => ({ ...p, notes: e.target.value }))} />
{expensesError &&
{expensesError}
}
{/* Right: file attach area */}
setNewExpense(p => ({ ...p, receipt: files.length > 0 ? files[files.length - 1] : null }))} />
{/* Footer */}
); })()}
{/* end flex column wrapper */} {showInvoiceForm && (() => { const LABEL = { ...FIELD_LABEL_STYLE, marginBottom: 3 }; const INPUT = FINANCE_MODAL_INPUT_STYLE; return (
{ if (!invSaving) invClose(); }}>
e.stopPropagation()}>
{/* Left: meta fields */}
New Invoice
Company *
{invSelectedCompany && <>
Bill To
setInvBillTo(e.target.value)} placeholder={invSelectedCompany.name} />
Email To
setInvEmail(e.target.value)} placeholder="client@example.com" /> {invRecipients.map(r => )}
}
Notes