Files
fourge-portal/src/pages/team/TeamInvoices.jsx
T
Krao Hasanee da0e5d5c39 fix: guard task status updates on invoice lifecycle transitions
Prevent overwriting task status when task has moved to a new revision
cycle after being invoiced. Now uses .eq('status', guardStatus) to only
update tasks still at the expected prior state:
- invoice creation: only update tasks at client_approved → invoiced
- mark sent: only client_approved → invoiced
- mark paid: only invoiced → paid
- reopen: only paid → client_approved

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 22:22:18 -05:00

2080 lines
130 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { 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 } 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 (
<div style={FINANCE_MODAL_AMOUNT_FIELD_STYLE}>
<span style={FINANCE_MODAL_AMOUNT_PREFIX_STYLE}>$</span>
<input
type="number"
min={min}
step={step}
required={required}
placeholder={placeholder}
value={value}
onChange={onChange}
style={FINANCE_MODAL_AMOUNT_INPUT_STYLE}
/>
</div>
);
}
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 (
<div style={{ background: 'var(--card-bg)', border: '1px solid var(--border)', borderRadius: 4, padding: '10px 14px', fontSize: 12 }}>
<div style={{ fontWeight: 600, marginBottom: 6, color: 'var(--text-primary)' }}>{label} {year}</div>
{payload.map(p => (
<div key={p.dataKey} style={{ color: p.color, marginBottom: 2 }}>{p.name}: <strong>${p.value.toLocaleString('en-US', { minimumFractionDigits: 2 })}</strong></div>
))}
</div>
);
}
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 => {
const ini = (t.submissions || []).find(s => s.type === 'initial') || t.submissions?.[0];
return { ...t, service_type: ini?.service_type || 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 ini = (rev.task?.submissions || []).find(s => s.type === 'initial') || rev.task?.submissions?.[0];
const svcType = ini?.service_type || 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, status: 'invoiced' }).in('id', taskIds).eq('status', 'client_approved');
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 (
<Layout loading={pageLoading}>
<div style={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
{/* 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 }) => (
<div style={{ ...CARD, display: 'flex', alignItems: 'stretch', gap: 21, minHeight: 120 }}>
<div style={{ flex: 1, display: 'flex', flexDirection: 'column' }}>
<div style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase', marginBottom: 5 }}>{label}</div>
<div style={{ flex: 1, display: 'flex', alignItems: 'center' }}>
<div style={{ fontSize: 30, fontWeight: 400, color: 'var(--text-primary)', letterSpacing: -0.5, lineHeight: 1.1 }}>{value}</div>
</div>
{sub && <div style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: 5 }}>{sub}</div>}
</div>
<div style={{ flexShrink: 0, display: 'flex', flexDirection: 'column', alignItems: 'flex-end', justifyContent: 'flex-start' }}>
<div style={{ width: 27, height: 27, borderRadius: '50%', background: iconBg, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke={iconColor} strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">{iconSvg}</svg>
</div>
</div>
</div>
);
return (
<div style={{ display: 'grid', gridTemplateColumns: '3fr 2fr', gap: 24, flexShrink: 0 }}>
<div style={{ ...CARD }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 16 }}>
<span style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase' }}>Revenue Overview</span>
<select className="filter-select" value={chartYear} onChange={e => setExportYear(Number(e.target.value))} style={{ width: 90, borderRadius: 8, fontSize: 11, fontWeight: 500 }}>
{(paidYears.length > 0 ? paidYears : [new Date().getFullYear()]).map(y => <option key={y} value={y}>{y}</option>)}
</select>
</div>
<ResponsiveContainer width="100%" height={160}>
<AreaChart data={chartData} margin={{ top: 4, right: 8, left: 0, bottom: 0 }}>
<defs>
<linearGradient id="gc2Revenue" x1="0" y1="0" x2="0" y2="1"><stop offset="5%" stopColor="#F5A523" stopOpacity={0.25}/><stop offset="95%" stopColor="#F5A523" stopOpacity={0}/></linearGradient>
<linearGradient id="gc2Profit" x1="0" y1="0" x2="0" y2="1"><stop offset="5%" stopColor="#4ade80" stopOpacity={0.25}/><stop offset="95%" stopColor="#4ade80" stopOpacity={0}/></linearGradient>
<linearGradient id="gc2Expenses" x1="0" y1="0" x2="0" y2="1"><stop offset="5%" stopColor="#ef4444" stopOpacity={0.2}/><stop offset="95%" stopColor="#ef4444" stopOpacity={0}/></linearGradient>
<linearGradient id="gc2Outstanding" x1="0" y1="0" x2="0" y2="1"><stop offset="5%" stopColor="#60a5fa" stopOpacity={0.2}/><stop offset="95%" stopColor="#60a5fa" stopOpacity={0}/></linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" stroke="var(--border)" vertical={false} />
<XAxis dataKey="month" tick={{ fontSize: 11, fill: 'var(--text-muted)' }} axisLine={false} tickLine={false} />
<YAxis tick={{ fontSize: 11, fill: 'var(--text-muted)' }} axisLine={false} tickLine={false} tickFormatter={v => `$${v >= 1000 ? (v/1000).toFixed(0)+'k' : v}`} width={45} />
<Tooltip content={<FinancesChartTooltip year={chartYear} />} />
<Legend wrapperStyle={{ fontSize: 11, paddingTop: 8 }} />
<Area type="monotone" dataKey="Revenue" stroke="#F5A523" strokeWidth={2} fill="url(#gc2Revenue)" dot={false} activeDot={{ r: 4 }} />
<Area type="monotone" dataKey="Profit" stroke="#4ade80" strokeWidth={2} fill="url(#gc2Profit)" dot={false} activeDot={{ r: 4 }} />
<Area type="monotone" dataKey="Expenses" stroke="#ef4444" strokeWidth={2} fill="url(#gc2Expenses)" dot={false} activeDot={{ r: 4 }} />
<Area type="monotone" dataKey="Outstanding" stroke="#60a5fa" strokeWidth={2} fill="url(#gc2Outstanding)" dot={false} activeDot={{ r: 4 }} />
</AreaChart>
</ResponsiveContainer>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 24, alignContent: 'start' }}>
<StatCard label="Revenue" value={fmt(yr)} sub={`${chartYear} · paid invoices`} iconBg="rgba(245,165,35,0.15)" iconColor="#F5A523" iconSvg={<><line x1="12" y1="19" x2="12" y2="5"/><polyline points="5,12 12,5 19,12"/></>} />
<StatCard label="Outstanding" value={fmt(yo)} sub={`${chartYear} · sent & unpaid`} iconBg="rgba(96,165,250,0.15)" iconColor="#60a5fa" iconSvg={<><circle cx="12" cy="12" r="8"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></>} />
<StatCard label="Expenses" value={fmt(ye)} sub={`${chartYear} · total spend`} iconBg="rgba(239,68,68,0.15)" iconColor="#ef4444" iconSvg={<><line x1="12" y1="5" x2="12" y2="19"/><polyline points="19,12 12,19 5,12"/></>} />
<StatCard label="Net Profit" value={fmt(yp)} sub={`${chartYear} · after expenses`} iconBg="rgba(74,222,128,0.15)" iconColor="#4ade80" iconSvg={<><polyline points="4,13 9,18 20,7"/></>} />
</div>
</div>
);
})()}
<div style={{ display: 'grid', gridTemplateColumns: '7fr 3fr', gap: 24, marginTop: 24, marginBottom: 10, flexShrink: 0, alignItems: 'center' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 4, minHeight: 'var(--btn-height)' }}>
{[
{ id: 'overview', label: 'Overview' },
{ id: 'expenses', label: 'Expenses' },
{ id: 'subcontractors', label: 'Subcontractors' },
{ id: 'invoices', label: 'Invoices' },
].map(t => (
<button
key={t.id}
type="button"
onClick={() => setFinanceTab(t.id)}
className={`section-tab-btn${financeTab === t.id ? ' is-active' : ''}`}
>{t.label}</button>
))}
{financeTab === 'expenses' && (
<div style={{ marginLeft: 'auto', display: 'flex', alignItems: 'center', gap: 8 }}>
<button className="btn btn-outline" onClick={() => { setEditingExpenseId(''); setNewExpense(blankExpense()); setExpensesError(''); setShowExpenseForm(true); }}>+ Expense</button>
<div style={{ position: 'relative', display: 'flex', alignItems: 'center' }} ref={expYearMenuRef}>
<button className="btn btn-outline" onClick={() => setExpYearMenuOpen(o => !o)} style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }} title="Filter by year">
<svg viewBox="0 0 16 16" width="13" height="13" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M2 4h12M4 8h8M6 12h4" /></svg>
</button>
{expYearMenuOpen && (
<div className="site-header-avatar-menu" style={{ position: 'absolute', top: 'calc(100% + 4px)', right: 0, zIndex: 220, minWidth: 130 }}>
<button onClick={() => { setExpYearFilter('all'); setExpYearMenuOpen(false); }} className="site-header-avatar-item" style={{ background: expYearFilter === 'all' ? 'rgba(245,165,35,0.08)' : 'transparent', color: expYearFilter === 'all' ? 'var(--accent)' : 'var(--text-primary)' }}>All Years</button>
{expenseYears.map(y => (
<button key={y} onClick={() => { setExpYearFilter(y); setExpYearMenuOpen(false); }} className="site-header-avatar-item" style={{ background: expYearFilter === y ? 'rgba(245,165,35,0.08)' : 'transparent', color: expYearFilter === y ? 'var(--accent)' : 'var(--text-primary)' }}>{y}</button>
))}
</div>
)}
</div>
</div>
)}
{financeTab === 'invoices' && (
<div style={{ marginLeft: 'auto', display: 'flex', alignItems: 'center', gap: 8 }}>
<button className="btn btn-outline" onClick={() => setShowInvoiceForm(true)}>+ Invoice</button>
<div style={{ position: 'relative', display: 'flex', alignItems: 'center' }} ref={invYearMenuRef}>
<button className="btn btn-outline" onClick={() => setInvYearMenuOpen(o => !o)} style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }} title="Filter by year">
<svg viewBox="0 0 16 16" width="13" height="13" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M2 4h12M4 8h8M6 12h4" /></svg>
</button>
{invYearMenuOpen && (
<div className="site-header-avatar-menu" style={{ position: 'absolute', top: 'calc(100% + 4px)', right: 0, zIndex: 220, minWidth: 150 }}>
<div style={{ fontSize: 10, fontWeight: 500, textTransform: 'uppercase', letterSpacing: 0.8, color: 'var(--text-muted)', padding: '6px 14px 4px' }}>Year</div>
<button onClick={() => setInvYearFilter('all')} className="site-header-avatar-item" style={{ background: invYearFilter === 'all' ? 'rgba(245,165,35,0.08)' : 'transparent', color: invYearFilter === 'all' ? 'var(--accent)' : 'var(--text-primary)' }}>All Years</button>
{[...new Set(invoices.map(i => i.invoice_date?.slice(0,4)).filter(Boolean))].sort((a,b) => b-a).map(y => (
<button key={y} onClick={() => setInvYearFilter(y)} className="site-header-avatar-item" style={{ background: invYearFilter === y ? 'rgba(245,165,35,0.08)' : 'transparent', color: invYearFilter === y ? 'var(--accent)' : 'var(--text-primary)' }}>{y}</button>
))}
<div style={{ borderTop: '1px solid var(--border)', margin: '4px 0' }} />
<div style={{ fontSize: 10, fontWeight: 500, textTransform: 'uppercase', letterSpacing: 0.8, color: 'var(--text-muted)', padding: '4px 14px 4px' }}>Company</div>
<button onClick={() => setInvCompanyFilter('all')} className="site-header-avatar-item" style={{ background: invCompanyFilter === 'all' ? 'rgba(245,165,35,0.08)' : 'transparent', color: invCompanyFilter === 'all' ? 'var(--accent)' : 'var(--text-primary)' }}>All Companies</button>
{[...new Set(invoices.map(i => i.company?.name || i.bill_to).filter(Boolean))].sort().map(c => (
<button key={c} onClick={() => setInvCompanyFilter(c)} className="site-header-avatar-item" style={{ background: invCompanyFilter === c ? 'rgba(245,165,35,0.08)' : 'transparent', color: invCompanyFilter === c ? 'var(--accent)' : 'var(--text-primary)' }}>{c}</button>
))}
</div>
)}
</div>
</div>
)}
</div>
<div />
</div>
{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 (
<div style={{ display: 'flex', alignItems: 'center', gap: 0, flexShrink: 0 }}>
<button type="button" onClick={() => setter(p => stepMonth(p, -1))} style={{ background: 'none', border: 'none', padding: '0 6px', cursor: 'pointer', color: 'var(--text-secondary)', fontSize: 18, lineHeight: 1 }}></button>
<button type="button" onClick={() => setter(p => stepMonth(p, 1))} disabled={atCurrent} style={{ background: 'none', border: 'none', padding: '0 6px', cursor: atCurrent ? 'default' : 'pointer', color: 'var(--text-secondary)', fontSize: 18, lineHeight: 1, opacity: atCurrent ? 0.25 : 1 }}></button>
</div>
);
};
// 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 (
<div style={{ background: 'var(--card-bg)', border: '1px solid var(--border)', borderRadius: 4, padding: '8px 12px', fontSize: 12 }}>
<div style={{ color: payload[0].payload.fill, fontWeight: 500 }}>{payload[0].name}</div>
<div style={{ color: 'var(--text-primary)' }}>{fmtAmt(payload[0].value)}</div>
</div>
);
};
const PieLegend = ({ data, colors }) => (
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, marginTop: 14 }}>
{data.map((d, i) => (
<div key={d.name} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, minWidth: 0 }}>
<div style={{ width: 8, height: 8, borderRadius: '50%', background: colors[i % colors.length], flexShrink: 0 }} />
<span style={{ fontSize: 12, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{d.name}</span>
</div>
<span style={{ fontSize: 12, color: 'var(--text-primary)', fontVariantNumeric: 'tabular-nums', flexShrink: 0 }}>{fmtAmt(d.value)}</span>
</div>
))}
</div>
);
return (
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 24, flex: 1, minHeight: 0 }}>
{/* Card 1: This Month Expenses by category */}
<div style={{ ...CARD, display: 'flex', flexDirection: 'column', minHeight: 0, overflowY: 'auto' }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 5, flexShrink: 0 }}>
<span style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase' }}>{MONTHS[m1]} {y1 !== curY ? y1 : ''} Expenses</span>
<MonthNav m={ovMonth1} setter={setOvMonth1} />
</div>
<div style={{ fontSize: 30, fontWeight: 400, letterSpacing: -0.5, lineHeight: 1.1, color: '#ef4444', fontVariantNumeric: 'tabular-nums', marginBottom: 14, flexShrink: 0 }}>{fmtAmt(thisMonthTotal)}</div>
<div style={{ position: 'relative' }}>
<ResponsiveContainer width="100%" height={260}>
<PieChart>
<Pie data={thisCatTotals.length ? thisCatTotals : [{ cat: 'None', total: 1 }]} dataKey="total" nameKey="cat" cx="50%" cy="50%" innerRadius={72} outerRadius={115} strokeWidth={0}>
{thisCatTotals.length ? thisCatTotals.map((_, i) => <Cell key={i} fill={PIE_COLORS[i % PIE_COLORS.length]} />) : <Cell fill="var(--border)" />}
</Pie>
{thisCatTotals.length > 0 && <Tooltip content={({ active, payload }) => {
if (!active || !payload?.length) return null;
return <div style={{ background: 'var(--card-bg)', border: '1px solid var(--border)', borderRadius: 4, padding: '8px 12px', fontSize: 12 }}><div style={{ color: payload[0].payload.fill, fontWeight: 500 }}>{payload[0].name}</div><div style={{ color: 'var(--text-primary)' }}>{fmtAmt(payload[0].value)}</div></div>;
}} />}
</PieChart>
</ResponsiveContainer>
<div style={{ position: 'absolute', top: '50%', left: '50%', transform: 'translate(-50%, -50%)', textAlign: 'center', pointerEvents: 'none' }}>
<div style={{ fontSize: 22, fontWeight: 500, color: 'var(--text-primary)', lineHeight: 1.1 }}>{thisMonthExp.length}</div>
<div style={{ fontSize: 10, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: 0.6 }}>items</div>
</div>
</div>
{thisCatTotals.length > 0 && <PieLegend data={thisCatTotals.map(c => ({ name: c.cat, value: c.total }))} colors={PIE_COLORS} />}
</div>
{/* Card 2: Pending Sub Payments by subcontractor */}
<div style={{ ...CARD, display: 'flex', flexDirection: 'column', minHeight: 0, overflowY: 'auto' }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 5, flexShrink: 0 }}>
<span style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase' }}>{MONTHS[m2]} {y2 !== curY ? y2 : ''} Sub Payments</span>
<MonthNav m={ovMonth2} setter={setOvMonth2} />
</div>
<div style={{ fontSize: 30, fontWeight: 400, letterSpacing: -0.5, lineHeight: 1.1, color: '#F5A523', fontVariantNumeric: 'tabular-nums', marginBottom: 14, flexShrink: 0 }}>{fmtAmt(pendingSubsTotal)}</div>
<div style={{ position: 'relative' }}>
<ResponsiveContainer width="100%" height={260}>
<PieChart>
<Pie data={subPieData.length ? subPieData : [{ name: 'None', value: 1 }]} dataKey="value" nameKey="name" cx="50%" cy="50%" innerRadius={72} outerRadius={115} strokeWidth={0}>
{subPieData.length ? subPieData.map((_, i) => <Cell key={i} fill={PIE_COLORS[i % PIE_COLORS.length]} />) : <Cell fill="var(--border)" />}
</Pie>
{subPieData.length > 0 && <Tooltip content={({ active, payload }) => {
if (!active || !payload?.length) return null;
return <div style={{ background: 'var(--card-bg)', border: '1px solid var(--border)', borderRadius: 4, padding: '8px 12px', fontSize: 12 }}><div style={{ color: payload[0].payload.fill, fontWeight: 500 }}>{payload[0].name}</div><div style={{ color: 'var(--text-primary)' }}>{fmtAmt(payload[0].value)}</div></div>;
}} />}
</PieChart>
</ResponsiveContainer>
<div style={{ position: 'absolute', top: '50%', left: '50%', transform: 'translate(-50%, -50%)', textAlign: 'center', pointerEvents: 'none' }}>
<div style={{ fontSize: 22, fontWeight: 500, color: 'var(--text-primary)', lineHeight: 1.1 }}>{pendingSubs.length}</div>
<div style={{ fontSize: 10, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: 0.6 }}>invoices</div>
</div>
</div>
{subPieData.length > 0 && <PieLegend data={subPieData} colors={PIE_COLORS} />}
</div>
{/* Card 3: Invoiced this month by company */}
<div style={{ ...CARD, display: 'flex', flexDirection: 'column', minHeight: 0, overflowY: 'auto' }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 5, flexShrink: 0 }}>
<span style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase' }}>{MONTHS[m3]} {y3 !== curY ? y3 : ''} Invoiced</span>
<MonthNav m={ovMonth3} setter={setOvMonth3} />
</div>
<div style={{ fontSize: 30, fontWeight: 400, letterSpacing: -0.5, lineHeight: 1.1, color: '#4ade80', fontVariantNumeric: 'tabular-nums', marginBottom: 14, flexShrink: 0 }}>{fmtAmt(outstandingTotal)}</div>
<div style={{ position: 'relative' }}>
<ResponsiveContainer width="100%" height={260}>
<PieChart>
<Pie data={invPieData.length ? invPieData : [{ name: 'None', value: 1 }]} dataKey="value" nameKey="name" cx="50%" cy="50%" innerRadius={72} outerRadius={115} strokeWidth={0}>
{invPieData.length ? invPieData.map((_, i) => <Cell key={i} fill={PIE_COLORS[i % PIE_COLORS.length]} />) : <Cell fill="var(--border)" />}
</Pie>
{invPieData.length > 0 && <Tooltip content={({ active, payload }) => {
if (!active || !payload?.length) return null;
return <div style={{ background: 'var(--card-bg)', border: '1px solid var(--border)', borderRadius: 4, padding: '8px 12px', fontSize: 12 }}><div style={{ color: payload[0].payload.fill, fontWeight: 500 }}>{payload[0].name}</div><div style={{ color: 'var(--text-primary)' }}>{fmtAmt(payload[0].value)}</div></div>;
}} />}
</PieChart>
</ResponsiveContainer>
<div style={{ position: 'absolute', top: '50%', left: '50%', transform: 'translate(-50%, -50%)', textAlign: 'center', pointerEvents: 'none' }}>
<div style={{ fontSize: 22, fontWeight: 500, color: 'var(--text-primary)', lineHeight: 1.1 }}>{monthInvoices.length}</div>
<div style={{ fontSize: 10, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: 0.6 }}>invoices</div>
</div>
</div>
{invPieData.length > 0 && <PieLegend data={invPieData} colors={PIE_COLORS} />}
</div>
</div>
);
})()}
{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 (
<div style={{ display: 'flex', flexDirection: 'column', gap: 0, flex: 1, minHeight: 0 }}>
<div style={{ display: 'grid', gridTemplateColumns: '7fr 3fr', gap: 24, flex: 1, minHeight: 0 }}>
{/* Left: expense list */}
<div style={{ ...CARD, display: 'flex', flexDirection: 'column', minHeight: 0 }}>
{expenses.length === 0 ? <div className="card-empty-center">No expenses</div> : (
<div className="scrollbar-thin-theme table-scroll-hidden" style={{ flex: 1, minHeight: 0, overflowY: 'auto' }}>
<table className="table-sticky-head" style={{ width: '100%', borderCollapse: 'collapse', tableLayout: 'fixed' }}>
<colgroup>
<col style={{ width: '14%' }} />
<col style={{ width: '34%' }} />
<col style={{ width: '16%' }} />
<col style={{ width: '24%' }} />
<col style={{ width: '12%' }} />
</colgroup>
<thead><tr>
<SortTh col="date" sortKey={expSortKey} sortDir={expSortDir} onSort={expToggle} style={TH}>Date</SortTh>
<SortTh col="description" sortKey={expSortKey} sortDir={expSortDir} onSort={expToggle} style={TH}>Description</SortTh>
<SortTh col="category" sortKey={expSortKey} sortDir={expSortDir} onSort={expToggle} style={TH}>Category</SortTh>
<SortTh col="notes" sortKey={expSortKey} sortDir={expSortDir} onSort={expToggle} style={TH}>Notes</SortTh>
<SortTh col="amount" sortKey={expSortKey} sortDir={expSortDir} onSort={expToggle} style={{ ...TH, textAlign: 'right' }}>Amount</SortTh>
</tr></thead>
<tbody>{sortedExpenses.map(exp => (
<tr key={exp.id}>
<td style={{ ...TD, fontSize: 12 }}>{fmtDate(exp.date)}</td>
<td style={{ ...TD }}>
<button type="button" className="dashboard-inline-link" onClick={() => setViewingExpense(exp)} style={{ fontSize: 13, fontWeight: 400 }}>
{exp.description || '—'}
</button>
</td>
<td style={{ ...TD, fontSize: 12 }}>{exp.category || '—'}</td>
<td style={{ ...TD, fontSize: 12 }}>{exp.notes || '—'}</td>
<td style={{ ...TD, textAlign: 'right', color: '#ef4444' }}>{fmtAmt(exp.amount)}</td>
</tr>
))}</tbody>
</table>
</div>
)}
</div>
{/* Right: category breakdown */}
<div style={{ ...CARD, display: 'flex', flexDirection: 'column', minHeight: 0 }}>
<div style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase', marginBottom: 5, flexShrink: 0 }}>Category</div>
<div style={{ fontSize: 30, fontWeight: 400, letterSpacing: -0.5, lineHeight: 1.1, color: '#ef4444', fontVariantNumeric: 'tabular-nums', marginBottom: 14, flexShrink: 0 }}>{fmtAmt(grandTotal)}</div>
{categoryTotals.length === 0 ? <div className="card-empty-center">No expenses</div> : (
<div className="scrollbar-thin-theme table-scroll-hidden" style={{ flex: 1, minHeight: 0, overflowY: 'auto' }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
{categoryTotals.map(({ cat, total }) => {
const pct = grandTotal > 0 ? (total / grandTotal) * 100 : 0;
return (
<div key={cat}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 5 }}>
<span style={{ fontSize: 12, color: 'var(--text-primary)' }}>{cat}</span>
<span style={{ fontSize: 12, color: '#ef4444', fontVariantNumeric: 'tabular-nums' }}>{fmtAmt(total)}</span>
</div>
<div style={{ height: 4, borderRadius: 2, background: 'var(--border)', overflow: 'hidden' }}>
<div style={{ height: '100%', width: `${pct}%`, background: '#ef4444', borderRadius: 2, opacity: 0.7, transition: 'width 400ms ease' }} />
</div>
</div>
);
})}
</div>
</div>
)}
</div>
</div>
</div>
);
})()}
{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 (
<div style={{ display: 'flex', flexDirection: 'column', gap: 0, flex: 1, minHeight: 0 }}>
<div style={{ display: 'grid', gridTemplateColumns: '7fr 3fr', gap: 24, flex: 1, minHeight: 0 }}>
{/* Left: invoice list */}
<div style={{ ...CARD, display: 'flex', flexDirection: 'column', minHeight: 0 }}>
{subInvoicesLoading ? (
<div style={{ fontSize: 13, color: 'var(--text-muted)' }}>Loading</div>
) : subInvoicesError ? (
<div style={{ fontSize: 13, color: 'var(--danger)' }}>{subInvoicesError}</div>
) : yearFiltered.length === 0 ? (
<div className="card-empty-center">No invoices</div>
) : (
<div className="scrollbar-thin-theme table-scroll-hidden" style={{ flex: 1, minHeight: 0, overflowY: 'auto' }}>
<table className="table-sticky-head" style={{ width: '100%', borderCollapse: 'collapse', tableLayout: 'fixed' }}>
<colgroup>
<col style={{ width: '14%' }} />
<col style={{ width: '26%' }} />
<col style={{ width: '16%' }} />
<col style={{ width: '16%' }} />
<col style={{ width: '14%' }} />
<col style={{ width: '14%' }} />
</colgroup>
<thead><tr>
<SortTh col="invoice_number" sortKey={subSortKey} sortDir={subSortDir} onSort={subToggle} style={TH}>Invoice #</SortTh>
<SortTh col="subcontractor" sortKey={subSortKey} sortDir={subSortDir} onSort={subToggle} style={TH}>Subcontractor</SortTh>
<SortTh col="submitted_at" sortKey={subSortKey} sortDir={subSortDir} onSort={subToggle} style={TH}>Submitted</SortTh>
<SortTh col="paid_at" sortKey={subSortKey} sortDir={subSortDir} onSort={subToggle} style={TH}>Paid</SortTh>
<SortTh col="status" sortKey={subSortKey} sortDir={subSortDir} onSort={subToggle} style={TH}>Status</SortTh>
<SortTh col="total" sortKey={subSortKey} sortDir={subSortDir} onSort={subToggle} style={{ ...TH, textAlign: 'right' }}>Amount</SortTh>
</tr></thead>
<tbody>{sortedInvoices.map(inv => {
const total = invTotal(inv);
return (
<tr key={inv.id}>
<td style={{ ...TD }}>
<button type="button" className="table-link" onClick={() => setViewingSubInvoice(inv)}>
{inv.invoice_number || '—'}
</button>
</td>
<td style={{ ...TD }}>{inv.profile?.name || '—'}</td>
<td style={{ ...TD, fontSize: 12, color: 'var(--text-muted)' }}>{fmtDate(inv.submitted_at)}</td>
<td style={{ ...TD, fontSize: 12, color: 'var(--text-muted)' }}>{fmtDate(inv.paid_at)}</td>
<td style={{ ...TD }}>
<StatusBadge
status={subInvoiceStatusColor[inv.status] || 'not_started'}
label={inv.status ? inv.status.charAt(0).toUpperCase() + inv.status.slice(1) : '—'}
/>
</td>
<td style={{ ...TD, textAlign: 'right', color: inv.status === 'paid' ? '#4ade80' : inv.status === 'submitted' ? '#F5A523' : 'var(--text-primary)' }}>
{fmtAmt(total)}
</td>
</tr>
);
})}</tbody>
</table>
</div>
)}
</div>
{/* Right: by subcontractor */}
<div style={{ ...CARD, display: 'flex', flexDirection: 'column', minHeight: 0 }}>
<div style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase', marginBottom: 5, flexShrink: 0 }}>Subcontractors</div>
<div style={{ fontSize: 30, fontWeight: 400, letterSpacing: -0.5, lineHeight: 1.1, color: '#F5A523', fontVariantNumeric: 'tabular-nums', marginBottom: 14, flexShrink: 0 }}>{fmtAmt(grandTotal)}</div>
{bySubcontractor.length === 0 ? <div className="card-empty-center">No data</div> : (
<div className="scrollbar-thin-theme table-scroll-hidden" style={{ flex: 1, minHeight: 0, overflowY: 'auto' }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
{bySubcontractor.map(g => {
const pct = grandTotal > 0 ? (g.total / grandTotal) * 100 : 0;
return (
<div key={g.name}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 5 }}>
<span style={{ fontSize: 12, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', maxWidth: '65%' }}>{g.name}</span>
<span style={{ fontSize: 12, color: '#F5A523', fontVariantNumeric: 'tabular-nums', flexShrink: 0 }}>{fmtAmt(g.total)}</span>
</div>
<div style={{ height: 4, borderRadius: 2, background: 'var(--border)', overflow: 'hidden' }}>
<div style={{ height: '100%', width: `${pct}%`, background: '#F5A523', borderRadius: 2, opacity: 0.7, transition: 'width 400ms ease' }} />
</div>
<div style={{ fontSize: 11, color: 'var(--text-muted)', marginTop: 3 }}><span style={{ color: g.pending > 0 ? '#F5A523' : 'var(--text-muted)' }}>{fmtAmt(g.pending)} pending</span> · {fmtAmt(g.paid)} paid</div>
</div>
);
})}
</div>
</div>
)}
</div>
</div>
</div>
);
})()}
{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 (
<div style={{ display: 'flex', flexDirection: 'column', gap: 0, flex: 1, minHeight: 0 }}>
<div style={{ display: 'grid', gridTemplateColumns: '7fr 3fr', gap: 24, flex: 1, minHeight: 0 }}>
<div style={{ ...CARD, display: 'flex', flexDirection: 'column', minHeight: 0 }}>
{loading ? (
<div style={{ fontSize: 13, color: 'var(--text-muted)' }}>Loading</div>
) : invoices.length === 0 ? (
<div className="card-empty-center">No invoices</div>
) : (
<div className="scrollbar-thin-theme table-scroll-hidden" style={{ flex: 1, minHeight: 0, overflowY: 'auto' }}>
<table className="table-sticky-head" style={{ width: '100%', borderCollapse: 'collapse', tableLayout: 'fixed' }}>
<colgroup>
<col style={{ width: '18%' }} />
<col style={{ width: '24%' }} />
<col style={{ width: '13%' }} />
<col style={{ width: '13%' }} />
<col style={{ width: '12%' }} />
<col style={{ width: '10%' }} />
<col style={{ width: '10%' }} />
</colgroup>
<thead><tr>
<SortTh col="invoice_number" sortKey={invSortKey} sortDir={invSortDir} onSort={invToggle} style={TH}>Invoice #</SortTh>
<SortTh col="company" sortKey={invSortKey} sortDir={invSortDir} onSort={invToggle} style={TH}>Company</SortTh>
<SortTh col="invoice_date" sortKey={invSortKey} sortDir={invSortDir} onSort={invToggle} style={TH}>Date</SortTh>
<SortTh col="due_date" sortKey={invSortKey} sortDir={invSortDir} onSort={invToggle} style={TH}>Due</SortTh>
<SortTh col="status" sortKey={invSortKey} sortDir={invSortDir} onSort={invToggle} style={{ ...TH, textAlign: 'center' }}>Status</SortTh>
<SortTh col="stripe_fee" sortKey={invSortKey} sortDir={invSortDir} onSort={invToggle} style={{ ...TH, textAlign: 'right' }}>Fees</SortTh>
<SortTh col="total" sortKey={invSortKey} sortDir={invSortDir} onSort={invToggle} style={{ ...TH, textAlign: 'right' }}>Total</SortTh>
</tr></thead>
<tbody>{sorted.map(inv => (
<tr key={inv.id}>
<td style={{ ...TD }}>
<button type="button" className="dashboard-inline-link" onClick={() => setViewingInvoice(inv)}>{inv.invoice_number}</button>
</td>
<td style={{ ...TD }}>
<button type="button" className="dashboard-inline-link" onClick={() => inv.company?.id && navigate(`/company/${inv.company.id}`)}>{inv.company?.name || inv.bill_to || '—'}</button>
</td>
<td style={{ ...TD, fontSize: 12 }}>{fmtDate(inv.invoice_date)}</td>
<td style={{ ...TD, fontSize: 12, color: inv.status !== 'paid' && new Date(inv.due_date) < new Date() ? 'var(--danger)' : 'var(--text-primary)' }}>{fmtDate(inv.due_date)}</td>
<td style={{ ...TD, textAlign: 'center' }}>
<StatusBadge status={statusColor[inv.status] || 'not_started'} label={invoiceStatusBadgeLabel(inv.status)} />
</td>
<td style={{ ...TD, textAlign: 'right', fontSize: 12, color: Number(inv.stripe_fee) > 0 ? '#ef4444' : 'var(--text-muted)' }}>
{Number(inv.stripe_fee) > 0 ? `-${fmtAmt(inv.stripe_fee)}` : '$0.00'}
</td>
<td style={{ ...TD, textAlign: 'right', color: inv.status === 'paid' ? '#4ade80' : inv.status === 'sent' ? '#F5A523' : 'var(--text-primary)' }}>
{fmtAmt(inv.total)}
</td>
</tr>
))}</tbody>
</table>
</div>
)}
</div>
<div style={{ ...CARD, display: 'flex', flexDirection: 'column', minHeight: 0 }}>
<div style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase', marginBottom: 5, flexShrink: 0 }}>Companies</div>
<div style={{ fontSize: 30, fontWeight: 400, letterSpacing: -0.5, lineHeight: 1.1, color: '#4ade80', fontVariantNumeric: 'tabular-nums', marginBottom: 14, flexShrink: 0 }}>{fmtAmt(grandTotal)}</div>
{byCompany.length === 0 ? <div className="card-empty-center">No data</div> : (
<div className="scrollbar-thin-theme table-scroll-hidden" style={{ flex: 1, minHeight: 0, overflowY: 'auto' }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
{byCompany.map(g => {
const pct = grandTotal > 0 ? (g.total / grandTotal) * 100 : 0;
return (
<div key={g.name}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 5 }}>
<span style={{ fontSize: 12, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', maxWidth: '65%' }}>{g.name}</span>
<span style={{ fontSize: 12, color: '#4ade80', fontVariantNumeric: 'tabular-nums', flexShrink: 0 }}>{fmtAmt(g.total)}</span>
</div>
<div style={{ height: 4, borderRadius: 2, background: 'var(--border)', overflow: 'hidden' }}>
<div style={{ height: '100%', width: `${pct}%`, background: '#4ade80', borderRadius: 2, opacity: 0.7, transition: 'width 400ms ease' }} />
</div>
<div style={{ fontSize: 11, color: 'var(--text-muted)', marginTop: 3 }}>
<span style={{ color: g.outstanding > 0 ? '#F5A523' : 'var(--text-muted)' }}>{fmtAmt(g.outstanding)} outstanding</span>
{' · '}{fmtAmt(g.paid)} paid
{g.fees > 0 && <span style={{ color: '#ef4444' }}> · -{fmtAmt(g.fees)} fees</span>}
</div>
</div>
);
})}
</div>
</div>
)}
</div>
</div>
</div>
);
})()}
{financeTab === 'legacy' && <div>
<div style={{ display: 'flex', alignItems: 'center', gap: 4, marginBottom: 10, minHeight: 'var(--btn-height)' }}>
{[
{ id: 'invoices', label: 'INVOICES' },
{ id: 'expenses', label: 'EXPENSES' },
{ id: 'sub-invoices', label: 'SUBCONTRACTOR INVOICES' },
].map((t, i, arr) => (
<button
key={t.id}
type="button"
onClick={() => setActiveTab(t.id)}
className={`section-tab-btn${activeTab === t.id ? ' is-active' : ''}`}
>{t.label}</button>
))}
</div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, marginBottom: 10, minHeight: 'var(--btn-height)', flexWrap: 'wrap' }}>
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
{companyNames.length > 0 && activeTab === 'invoices' && (
<select className="filter-select" style={{ width: 120 }} value={filterCompany} onChange={e => setFilterCompany(e.target.value)}>
<option value="">All Companies</option>
{companyNames.map(name => <option key={name} value={name}>{name}</option>)}
</select>
)}
</div>
{activeTab === 'invoices' && (
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<select className="filter-select" style={{ width: 120 }} value={filter} onChange={e => setFilter(e.target.value)}>
<option value="all">All</option>
<option value="draft">Draft</option>
<option value="sent">Sent</option>
<option value="paid">Paid</option>
</select>
<button className="btn btn-primary btn-sm" onClick={() => setShowInvoiceForm(true)}>+ New Invoice</button>
</div>
)}
{activeTab === 'expenses' && (
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<select className="filter-select" style={{ width: 120 }} value={expenseFilter} onChange={e => setExpenseFilter(e.target.value)}>
<option value="all">All</option>
{CATEGORIES.filter(c => expenses.some(ex => ex.category === c)).map(c => (
<option key={c} value={c}>{c}</option>
))}
</select>
<button className="btn btn-primary btn-sm" onClick={() => { setEditingExpenseId(''); setNewExpense(blankExpense()); setExpensesError(''); setShowExpenseForm(true); }}>+ Add Expense</button>
</div>
)}
</div>
{activeTab === 'invoices' && (
<div>
{/* ── Invoices ── */}
<div>
{loading ? (
<p style={{ color: 'var(--text-muted)' }}>Loading...</p>
) : filtered.length === 0 ? (
<div className="card card-empty-center">No invoices</div>
) : (
<div className="table-wrapper">
<table className="table-sticky-head">
<thead>
<tr>
<SortTh col="invoice_number" sortKey={invSortKey} sortDir={invSortDir} onSort={invToggle}>Invoice #</SortTh>
<SortTh col="bill_to" sortKey={invSortKey} sortDir={invSortDir} onSort={invToggle}>Bill To</SortTh>
<SortTh col="invoice_date" sortKey={invSortKey} sortDir={invSortDir} onSort={invToggle}>Date</SortTh>
<SortTh col="due_date" sortKey={invSortKey} sortDir={invSortDir} onSort={invToggle}>Due</SortTh>
<SortTh col="status" sortKey={invSortKey} sortDir={invSortDir} onSort={invToggle}>Status</SortTh>
<SortTh col="total" sortKey={invSortKey} sortDir={invSortDir} onSort={invToggle}>Total</SortTh>
</tr>
</thead>
<tbody>
{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 => (
<tr key={inv.id} onClick={() => setViewingInvoice(inv)} style={{ cursor: 'pointer' }}>
<td style={{ fontWeight: 400 }}>{inv.invoice_number}</td>
<td style={{ fontWeight: 400 }}>{inv.bill_to || inv.company?.name}</td>
<td style={{ color: 'var(--text-muted)' }}>{new Date(inv.invoice_date).toLocaleDateString()}</td>
<td>
<span style={{ color: inv.status !== 'paid' && new Date(inv.due_date) < new Date() ? 'var(--danger)' : 'var(--text-muted)' }}>
{new Date(inv.due_date).toLocaleDateString()}
</span>
</td>
<td style={{ textAlign: 'center' }}>
<StatusBadge
status={statusColor[inv.status] || 'not_started'}
label={invoiceStatusBadgeLabel(inv.status)}
/>
</td>
<td style={{ fontWeight: 400, color: 'var(--accent)' }}>${Number(inv.total).toFixed(2)}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
)}
{activeTab === 'expenses' && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 24 }}>
<div>
<div style={{ marginBottom: 10 }}>
{expensesLoading ? (
<p style={{ color: 'var(--text-muted)', fontSize: 13 }}>Loading...</p>
) : expensesError ? (
<p style={{ color: 'var(--danger)', fontSize: 13 }}>{expensesError}</p>
) : filteredExpenses.length === 0 ? (
<div className="card card-empty-center">No expenses</div>
) : (
<div className="table-wrapper">
<table className="table-sticky-head">
<thead>
<tr>
<SortTh col="date" sortKey={expSortKey} sortDir={expSortDir} onSort={expToggle}>Date</SortTh>
<SortTh col="description" sortKey={expSortKey} sortDir={expSortDir} onSort={expToggle}>Description</SortTh>
<SortTh col="category" sortKey={expSortKey} sortDir={expSortDir} onSort={expToggle}>Category</SortTh>
<SortTh col="notes" sortKey={expSortKey} sortDir={expSortDir} onSort={expToggle}>Notes</SortTh>
<SortTh col="amount" sortKey={expSortKey} sortDir={expSortDir} onSort={expToggle} style={{ textAlign: 'right' }}>Amount</SortTh>
<th style={{ width: 140, textAlign: 'right' }}>Action</th>
</tr>
</thead>
<tbody>
{expSort(filteredExpenses, (exp, key) => {
if (key === 'amount') return Number(exp.amount) || 0;
if (key === 'date') return exp.date || '';
return exp[key] || '';
}).map(exp => (
<tr key={exp.id} style={{ cursor: 'pointer' }} onClick={() => setViewingExpense(exp)}>
<td>{new Date(exp.date + 'T12:00:00').toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })}</td>
<td style={{ fontWeight: 400 }}>{exp.description}</td>
<td>{exp.category}</td>
<td style={{ color: 'var(--text-muted)' }}>
{exp.notes || exp.receipt_path ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
<span>{exp.notes || '—'}</span>
{exp.receipt_path && (
<button
className="btn btn-outline btn-sm"
style={{ width: 'fit-content' }}
onClick={e => { e.stopPropagation(); handleViewReceipt(exp); }}
>
View Receipt
</button>
)}
</div>
) : '—'}
</td>
<td style={{ textAlign: 'right', fontWeight: 400 }}>${Number(exp.amount).toFixed(2)}</td>
<td style={{ textAlign: 'right' }}>
<button
className="btn-icon btn-icon-danger"
title="Delete"
onClick={e => { e.stopPropagation(); handleDeleteExpense(exp.id); }}
>
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
</div>
)}
{activeTab === 'sub-invoices' && (
<div>
<div style={{ marginBottom: 10 }}>
{subInvoicesLoading ? (
<p style={{ color: 'var(--text-muted)', fontSize: 13 }}>Loading...</p>
) : subInvoicesError ? (
<p style={{ color: 'var(--danger)', fontSize: 13 }}>{subInvoicesError}</p>
) : subInvoices.length === 0 ? (
<div className="card card-empty-center">No sub invoices</div>
) : (
<div className="table-wrapper">
<table className="table-sticky-head">
<thead>
<tr>
<SortTh col="invoice_number" sortKey={subSortKey} sortDir={subSortDir} onSort={subToggle}>Invoice #</SortTh>
<SortTh col="subcontractor" sortKey={subSortKey} sortDir={subSortDir} onSort={subToggle}>Subcontractor</SortTh>
<SortTh col="submitted_at" sortKey={subSortKey} sortDir={subSortDir} onSort={subToggle}>Submitted</SortTh>
<SortTh col="status" sortKey={subSortKey} sortDir={subSortDir} onSort={subToggle}>Status</SortTh>
<SortTh col="total" sortKey={subSortKey} sortDir={subSortDir} onSort={subToggle} style={{ textAlign: 'right' }}>Total</SortTh>
<th style={{ textAlign: 'right' }}>Actions</th>
</tr>
</thead>
<tbody>
{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 (
<tr key={inv.id} style={{ cursor: 'pointer' }} onClick={() => setViewingSubInvoice(inv)}>
<td style={{ fontWeight: 400 }}>{inv.invoice_number}</td>
<td>
<div style={{ fontWeight: 400 }}>{inv.profile?.name || 'External'}</div>
<div style={{ color: 'var(--text-muted)', fontSize: 12 }}>{inv.profile?.email || '—'}</div>
</td>
<td style={{ color: 'var(--text-muted)' }}>{inv.submitted_at ? new Date(inv.submitted_at).toLocaleDateString() : '—'}</td>
<td>
<StatusBadge
status={subInvoiceStatusColor[inv.status] || 'not_started'}
label={inv.status ? inv.status.charAt(0).toUpperCase() + inv.status.slice(1) : '—'}
/>
</td>
<td style={{ textAlign: 'right', fontWeight: 400, color: 'var(--accent)' }}>${total.toFixed(2)}</td>
<td />
</tr>
);
})}
</tbody>
</table>
</div>
)}
</div>
</div>
)}
</div>}{/* 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 (
<InvoiceDetailPopup
title={invoice.invoice_number || 'Subcontractor Invoice'}
subtitle={`${invoice.profile?.name || 'External'}${invoice.profile?.email ? ` · ${invoice.profile.email}` : ''}`}
headerRight={<StatusBadge status={subInvoiceStatusColor[invoice.status] || 'not_started'} label={invoiceStatus} />}
onClose={() => { if (!markingPaid) setViewingSubInvoice(null); }}
blockClose={Boolean(markingPaid)}
metaContent={<>
<div><div style={F}>Submitted</div><div style={{ fontSize: 13, color: 'var(--text-primary)' }}>{invoice.submitted_at ? new Date(invoice.submitted_at).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) : '—'}</div></div>
<div><div style={F}>Paid</div><div style={{ fontSize: 13, color: 'var(--text-primary)' }}>{invoice.paid_at ? new Date(invoice.paid_at).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) : '—'}</div></div>
<div><div style={F}>Line Items</div><div style={{ fontSize: 13, color: 'var(--text-primary)' }}>{baseItems.length}</div></div>
<div><div style={F}>Total</div><div style={{ fontSize: 18, fontWeight: 500, color: 'var(--accent)', lineHeight: 1.1 }}>${total.toFixed(2)}</div></div>
</>}
footerActions={<>
<button type="button" className="btn btn-outline" onClick={() => handleDownloadSubInvoice(invoice)}>Download Invoice</button>
{invoice.status === 'submitted' && (
<LoadingButton className="btn btn-outline" onClick={async () => {
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</LoadingButton>
)}
{invoice.status === 'paid' && (
<button type="button" className="btn btn-outline" onClick={() => handleDownloadSubInvoiceReceipt(invoice)}>Download Receipt</button>
)}
<button type="button" className="btn btn-outline" onClick={() => setViewingSubInvoice(null)}>Close</button>
</>}
>
<InvoicePopupTable sortKey={subPopupKey} sortDir={subPopupDir} onSort={subPopupToggle} items={tableItems} notes={invoice.notes} />
</InvoiceDetailPopup>
);
})()}
{viewingInvoice && (
<TeamInvoiceDetailPanel
invoiceId={viewingInvoice.id}
initialInvoice={viewingInvoice}
embedded
onClose={() => 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 (
<div style={popupOverlayStyle} onClick={() => { setViewingExpense(null); setExpenseDetailEditing(false); }}>
<div style={{ ...popupSurfaceStyle, background: 'var(--card-bg)', width: '80vw', height: '80vh', display: 'flex', flexDirection: 'column', gap: 0 }} onClick={e => e.stopPropagation()}>
{/* Main content row */}
<div style={{ display: 'flex', gap: 24, alignItems: 'stretch', flex: 1, minHeight: 0 }}>
{/* Left: details (editable in-place) */}
<div style={{ display: 'flex', flexDirection: 'column', gap: 14, flex: '0 0 260px', overflowY: 'auto' }}>
<div style={LABEL}>Expense Detail</div>
{expenseDetailEditing ? (
<CurrencyInput
value={newExpense.amount}
required
onChange={e => setNewExpense(p => ({ ...p, amount: e.target.value }))}
/>
) : (
<div style={{ ...FINANCE_MODAL_TOTAL_VALUE_STYLE, color: 'var(--danger)' }}>
${Number(viewingExpense.amount).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
</div>
)}
<div>
<div style={LABEL}>Description</div>
{expenseDetailEditing
? <input type="text" required style={INPUT} value={newExpense.description} onChange={e => setNewExpense(p => ({ ...p, description: e.target.value }))} />
: <div style={{ fontSize: 13 }}>{viewingExpense.description || '—'}</div>}
</div>
<div>
<div style={LABEL}>Date</div>
{expenseDetailEditing
? <input type="date" required style={INPUT} value={newExpense.date} onChange={e => setNewExpense(p => ({ ...p, date: e.target.value }))} />
: <div style={{ fontSize: 13 }}>{viewingExpense.date ? new Date(viewingExpense.date + 'T12:00:00').toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' }) : '—'}</div>}
</div>
<div>
<div style={LABEL}>Category</div>
{expenseDetailEditing
? <select style={INPUT} value={newExpense.category} onChange={e => setNewExpense(p => ({ ...p, category: e.target.value }))}>{CATEGORIES.map(c => <option key={c} value={c}>{c}</option>)}</select>
: <div style={{ fontSize: 13 }}>{viewingExpense.category || '—'}</div>}
</div>
<div>
<div style={LABEL}>Notes</div>
{expenseDetailEditing
? <input type="text" style={INPUT} value={newExpense.notes} onChange={e => setNewExpense(p => ({ ...p, notes: e.target.value }))} />
: <div style={{ fontSize: 13 }}>{viewingExpense.notes || '—'}</div>}
</div>
{expenseDetailEditing && (
<div>
<div style={LABEL}>Receipt / Photo</div>
<input type="file" accept="image/*,.pdf" style={{ ...INPUT, padding: '4px 10px' }} onChange={e => setNewExpense(p => ({ ...p, receipt: e.target.files?.[0] || null, removeReceipt: false }))} />
{!newExpense.receipt && newExpense.receipt_path && (
<div style={{ marginTop: 6, display: 'flex', alignItems: 'center', gap: 8 }}>
<span style={{ fontSize: 12, color: 'var(--text-muted)' }}>{newExpense.receipt_name || 'Existing receipt'}</span>
<button type="button" className="btn btn-outline" style={{ fontSize: 11, color: 'var(--danger)' }} onClick={() => setNewExpense(p => ({ ...p, removeReceipt: true, receipt_path: null, receipt_name: null }))}>Remove</button>
</div>
)}
</div>
)}
</div>
{/* Right: receipt preview — fixed to left column height, item scales inside */}
{viewingExpense.receipt_path && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, flex: 1, minHeight: 0, alignSelf: 'stretch' }}>
<div style={LABEL}>Receipt</div>
{expensePreviewUrl ? (
isPdf
? <embed src={`${expensePreviewUrl}#toolbar=0&navpanes=0&scrollbar=0`} type="application/pdf" style={{ flex: 1, width: '100%', minHeight: 0, border: '1px solid var(--border)', borderRadius: 6 }} />
: <img src={expensePreviewUrl} alt="Receipt" style={{ flex: 1, width: '100%', minHeight: 0, height: 0, objectFit: 'contain', objectPosition: 'top center', display: 'block', border: '1px solid var(--border)', borderRadius: 6, background: 'var(--card-bg-2)' }} />
) : (
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>Loading preview</div>
)}
</div>
)}
</div>
{/* Footer: buttons bottom-right */}
<div className="modal-action-row" style={{ paddingTop: 14, marginTop: 14, flexShrink: 0 }}>
{expenseDetailEditing ? (
<>
<button className="btn btn-outline" disabled={!isDirty || addingExpense} onClick={async () => { await saveExpense(); setExpenseDetailEditing(false); setViewingExpense(null); }}>{addingExpense ? 'Saving…' : 'Save'}</button>
<button className="btn btn-outline" onClick={() => setExpenseDetailEditing(false)}>Cancel</button>
</>
) : (
<>
{expensePreviewUrl && (
<a href={expensePreviewUrl} download={viewingExpense.receipt_name || 'receipt'} className="btn btn-outline" style={{ display: 'inline-flex' }}>Download Receipt</a>
)}
<button type="button" className="btn btn-outline" onClick={() => { setEditingExpenseId(viewingExpense.id); setNewExpense({ date: viewingExpense.date, description: viewingExpense.description || '', category: viewingExpense.category || 'Other', amount: String(viewingExpense.amount ?? ''), notes: viewingExpense.notes || '', receipt: null, receipt_path: viewingExpense.receipt_path || null, receipt_name: viewingExpense.receipt_name || null }); setExpensesError(''); setExpenseDetailEditing(true); }}>Edit</button>
<button type="button" className="btn btn-outline" onClick={() => setViewingExpense(null)}>Cancel</button>
</>
)}
</div>
</div>
</div>
);
})()}
{showExpenseForm && (() => {
const LABEL = { ...FIELD_LABEL_STYLE, marginBottom: 3 };
const INPUT = FINANCE_MODAL_INPUT_STYLE;
return (
<div style={popupOverlayStyle} onClick={cancelExpenseEdit}>
<div style={{ ...popupSurfaceStyle, background: 'var(--card-bg)', width: '80vw', height: '80vh', display: 'flex', flexDirection: 'column', gap: 0 }} onClick={e => e.stopPropagation()}>
<form onSubmit={handleAddExpense} style={{ display: 'flex', flexDirection: 'column', flex: 1, minHeight: 0, gap: 0 }}>
{/* Main content row */}
<div style={{ display: 'flex', gap: 24, alignItems: 'stretch', flex: 1, minHeight: 0 }}>
{/* Left: fields */}
<div style={{ display: 'flex', flexDirection: 'column', gap: 14, flex: '0 0 260px', overflowY: 'auto' }}>
<div style={LABEL}>New Expense</div>
<CurrencyInput
value={newExpense.amount}
required
onChange={e => setNewExpense(p => ({ ...p, amount: e.target.value }))}
/>
<div>
<div style={LABEL}>Description</div>
<input type="text" required placeholder="What was this for?" style={INPUT} value={newExpense.description} onChange={e => setNewExpense(p => ({ ...p, description: e.target.value }))} />
</div>
<div>
<div style={LABEL}>Date</div>
<input type="date" required style={INPUT} value={newExpense.date} onChange={e => setNewExpense(p => ({ ...p, date: e.target.value }))} />
</div>
<div>
<div style={LABEL}>Category</div>
<select style={INPUT} value={newExpense.category} onChange={e => setNewExpense(p => ({ ...p, category: e.target.value }))}>{CATEGORIES.map(c => <option key={c} value={c}>{c}</option>)}</select>
</div>
<div>
<div style={LABEL}>Notes</div>
<input type="text" placeholder="Any extra details" style={INPUT} value={newExpense.notes} onChange={e => setNewExpense(p => ({ ...p, notes: e.target.value }))} />
</div>
{expensesError && <div style={{ fontSize: 12, color: 'var(--danger)' }}>{expensesError}</div>}
</div>
{/* Right: file attach area */}
<div style={{ display: 'flex', flexDirection: 'column', flex: 1, minHeight: 0 }}>
<FileAttachment
files={newExpense.receipt ? [newExpense.receipt] : []}
onChange={files => setNewExpense(p => ({ ...p, receipt: files.length > 0 ? files[files.length - 1] : null }))}
/>
</div>
</div>
{/* Footer */}
<div className="modal-action-row" style={{ paddingTop: 14, marginTop: 14, flexShrink: 0 }}>
<button type="submit" className="btn btn-outline" disabled={addingExpense}>{addingExpense ? 'Saving…' : 'Save'}</button>
<button type="button" className="btn btn-outline" onClick={cancelExpenseEdit}>Cancel</button>
</div>
</form>
</div>
</div>
);
})()}
</div>{/* end flex column wrapper */}
{showInvoiceForm && (() => {
const LABEL = { ...FIELD_LABEL_STYLE, marginBottom: 3 };
const INPUT = FINANCE_MODAL_INPUT_STYLE;
return (
<div style={popupOverlayStyle} onClick={() => { if (!invSaving) invClose(); }}>
<div style={{ ...popupSurfaceStyle, background: 'var(--card-bg)', width: '80vw', height: '80vh', display: 'flex', flexDirection: 'column', gap: 0 }} onClick={e => e.stopPropagation()}>
<div style={{ display: 'flex', gap: 24, alignItems: 'stretch', flex: 1, minHeight: 0 }}>
{/* Left: meta fields */}
<div style={{ display: 'flex', flexDirection: 'column', gap: 14, flex: '0 0 260px', overflowY: 'auto' }}>
<div style={LABEL}>New Invoice</div>
<div>
<div style={LABEL}>Company *</div>
<select style={INPUT} value={invCompanyId} onChange={e => setInvCompanyId(e.target.value)}>
<option value="">Choose a company</option>
{invCompanies.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
</select>
</div>
{invSelectedCompany && <>
<div>
<div style={LABEL}>Bill To</div>
<input style={INPUT} type="text" value={invBillTo} onChange={e => setInvBillTo(e.target.value)} placeholder={invSelectedCompany.name} />
</div>
<div>
<div style={LABEL}>Email To</div>
<input style={INPUT} type="email" list="inv-recipients" value={invEmail} onChange={e => setInvEmail(e.target.value)} placeholder="client@example.com" />
<datalist id="inv-recipients">{invRecipients.map(r => <option key={r.id} value={r.email}>{r.name} ({r.role})</option>)}</datalist>
</div>
</>}
<div>
<div style={LABEL}>Notes</div>
<textarea style={FINANCE_MODAL_TEXTAREA_STYLE} value={invNotes} onChange={e => setInvNotes(e.target.value)} placeholder="Payment terms, notes…" />
</div>
<div style={{ marginTop: 'auto', paddingTop: 8 }}>
<div style={LABEL}>Total</div>
<div style={FINANCE_MODAL_TOTAL_VALUE_STYLE}>${invTotal.toFixed(2)}</div>
</div>
</div>
{/* Right: unbilled tasks + line items */}
<div style={{ display: 'flex', flexDirection: 'column', gap: 14, flex: 1, minHeight: 0, overflowY: 'auto' }}>
{/* Unbilled tasks */}
{invSelectedCompany && (
<div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 6 }}>
<div style={LABEL}>Unbilled Requests</div>
{invUnbilledTasks.length > 0 && <button type="button" className="btn btn-outline" style={{ fontSize: 11 }} onClick={() => invUnbilledTasks.forEach(t => { if (!invItems.some(i => i.task_id === t.id && !i.submission_id)) invAddTask(t); })}>+ All</button>}
</div>
{invLoadingTasks ? <div style={{ fontSize: 12, color: 'var(--text-muted)' }}>Loading</div>
: invUnbilledTasks.length === 0 ? <div style={{ fontSize: 12, color: 'var(--text-muted)' }}>None</div>
: invUnbilledTasks.map(t => {
const price = invPriceList.find(p => p.service_type === t.service_type && p.price_type === 'new');
const added = invItems.some(i => i.task_id === t.id && !i.submission_id);
return (
<div key={t.id} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '6px 10px', background: 'var(--card-bg-2)', borderRadius: 4, border: '1px solid var(--border)', marginBottom: 4 }}>
<div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
<span className="badge badge-initial">New</span>
<div style={{ fontSize: 12 }}>{t.project?.name} {t.title || t.service_type}</div>
</div>
<div style={{ fontSize: 11, color: 'var(--text-muted)' }}>{price ? `$${Number(price.price).toFixed(2)}` : 'No price'}</div>
</div>
<button type="button" className="btn btn-outline" style={{ fontSize: 11 }} disabled={added} onClick={() => invAddTask(t)}>{added ? 'Added' : '+ Add'}</button>
</div>
);
})}
</div>
)}
{/* Unbilled revisions */}
{invSelectedCompany && invUnbilledRevisions.length > 0 && (
<div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 6 }}>
<div style={LABEL}>Unbilled Revisions</div>
<button type="button" className="btn btn-outline" style={{ fontSize: 11 }} onClick={() => invUnbilledRevisions.forEach(r => { if (!invItems.some(i => i.submission_id === r.id)) invAddRevision(r); })}>+ All</button>
</div>
{invUnbilledRevisions.map(r => {
const added = invItems.some(i => i.submission_id === r.id);
const revisionQty = getRevisionChargeQuantity(r?.version_number);
const initial = (r.task?.submissions || []).find(s => s.type === 'initial') || r.task?.submissions?.[0];
const revisionServiceType = initial?.service_type || r.service_type || r.task?.title || 'Revision';
const revisionPrice = invPriceList.find(p => p.service_type === revisionServiceType && p.price_type === 'revision');
const revisionLabel = revisionQty > 0 ? (revisionPrice ? `$${Number(revisionPrice.price).toFixed(2)}` : 'No price') : 'Free';
return (
<div key={r.id} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '6px 10px', background: 'var(--card-bg-2)', borderRadius: 4, border: '1px solid var(--border)', marginBottom: 4 }}>
<div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
<span className="badge badge-client_revision">Revision</span>
<div style={{ fontSize: 12 }}>{r.task?.project?.name} {r.task?.title} R{String(r.version_number || 0).padStart(2, '0')}</div>
</div>
<div style={{ fontSize: 11, color: 'var(--text-muted)' }}>{revisionLabel}</div>
</div>
<button type="button" className="btn btn-outline" style={{ fontSize: 11 }} disabled={added} onClick={() => invAddRevision(r)}>{added ? 'Added' : '+ Add'}</button>
</div>
);
})}
</div>
)}
{/* Line items */}
<div style={{ flex: 1 }}>
<div style={LABEL}>Line Items</div>
<div style={{ display: 'grid', gridTemplateColumns: '20px 90px 1fr 60px 100px 90px 28px', gap: 6, marginBottom: 6 }}>
{['', 'Type', 'Description', 'Qty', 'Unit Price', 'Total', ''].map((h, i) => <div key={i} style={{ fontSize: 10, fontWeight: 500, textTransform: 'uppercase', letterSpacing: 0.5, color: 'var(--text-muted)', textAlign: i > 3 ? 'right' : 'left' }}>{h}</div>)}
</div>
{invItems.map((item, idx) => (
<div key={item.id} onDragOver={e => e.preventDefault()} onDrop={() => invHandleDrop(idx)} style={{ display: 'grid', gridTemplateColumns: '20px 90px 1fr 60px 100px 90px 28px', gap: 6, alignItems: 'center', marginBottom: 4 }}>
<div draggable onDragStart={() => { invDragItem.current = idx; }} style={{ cursor: 'grab', color: 'var(--text-muted)', fontSize: 12, textAlign: 'center', userSelect: 'none' }}></div>
<span className={`badge ${item.submission_id ? 'badge-client_revision' : 'badge-initial'}`}>
{item.submission_id ? 'Revision' : 'New'}
</span>
<input style={FINANCE_MODAL_LINE_ITEM_INPUT_STYLE} type="text" placeholder="Description…" value={item.description} onChange={e => invUpdateItem(item.id, 'description', e.target.value)} />
<input style={{ ...FINANCE_MODAL_NUMBER_INPUT_STYLE, textAlign: 'center' }} type="number" min="1" value={item.quantity} onChange={e => invUpdateItem(item.id, 'quantity', e.target.value)} />
<input style={{ ...FINANCE_MODAL_NUMBER_INPUT_STYLE, textAlign: 'right' }} type="number" min="0" step="0.01" placeholder="0.00" value={item.unit_price} onChange={e => invUpdateItem(item.id, 'unit_price', e.target.value)} />
<div style={{ minHeight: 32, display: 'flex', alignItems: 'center', justifyContent: 'flex-end', textAlign: 'right', fontSize: 13, color: 'var(--text-primary)', paddingRight: 2, fontVariantNumeric: 'tabular-nums' }}>${((Number(item.quantity) || 0) * (Number(item.unit_price) || 0)).toFixed(2)}</div>
<button type="button" onClick={() => invRemoveItem(item.id)} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--danger)', fontSize: 14, padding: 2 }}></button>
</div>
))}
<button type="button" className="btn btn-outline" style={{ marginTop: 8, fontSize: 12 }} onClick={() => setInvItems(prev => [...prev, invNewItem()])}>+ Line Item</button>
</div>
</div>
</div>
{/* Footer */}
<div className="modal-action-row" style={{ paddingTop: 14, marginTop: 14, flexShrink: 0 }}>
<button type="button" className="btn btn-outline" disabled={invSaving} onClick={() => invHandleSave('draft')}>{invSaving ? 'Saving…' : 'Save Draft'}</button>
<LoadingButton className="btn btn-outline" style={{ color: 'var(--accent)', borderColor: 'var(--accent)' }} onClick={() => invHandleSave('sent')} loading={invSaving} loadingText="Sending…">Finalise & Send</LoadingButton>
<button type="button" className="btn btn-outline" disabled={invSaving} onClick={invClose}>Cancel</button>
</div>
</div>
</div>
);
})()}
</Layout>
);
}