Session 2026-05-30: tasks/projects unification, code consolidation, file renames

- Unified Tasks page: 3 role render blocks → 1, normalized row shape, single renderRow/sort/tabs
- Fixed role scoping: external = all tasks in member projects, client = all tasks in company projects
- Fixed client bug: was scoped by submitted_by, now company→project→tasks
- Aligned dashboard client scope to match tasks page
- Hot tasks dashboard: now filters to active statuses only (not completed/invoiced/paid)
- Removed Projects.jsx (dead), fixed /project/ → /projects/ links
- Renamed all page files to match folder path (Team*, External*, Client* prefixes)
- Renamed: RequestsPage→Tasks, Settings→Profile, CompaniesPage→Companies, ProjectDetailPage→ProjectDetail
- Dropped dead fetches from Tasks page (invoices, invoice_items, subcontractor_invoice_items)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Krao Hasanee
2026-05-30 10:19:23 -04:00
parent 6fe7ab1059
commit 13ef1f4ded
30 changed files with 1177 additions and 1258 deletions
+892
View File
@@ -0,0 +1,892 @@
import { useState, useEffect, useMemo } from 'react';
import { AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } 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 { useSortable } from '../../hooks/useSortable';
import { supabase } from '../../lib/supabase';
import { readPageCache, writePageCache } from '../../lib/pageCache';
import { exportCPAPackage, generateSubcontractorPOPDF } from '../../lib/invoice';
import { blobToEmailAttachment, sendEmail } from '../../lib/email';
import { popupOverlayStyle, popupSurfaceStyle } from '../../lib/popupStyles';
const CATEGORIES = ['Software', 'Contractor', 'Advertising', 'Office', 'Travel', 'Meals', 'Equipment', 'Other'];
const statusColor = { draft: 'not_started', sent: 'in_progress', paid: 'client_approved' };
const poStatusColor = {
draft: 'not_started',
sent: 'in_progress',
approved: 'client_approved',
ready_to_pay: 'in_progress',
paid: 'client_approved',
cancelled: 'needs_revision',
};
const poStatusLabel = {
draft: 'Draft',
sent: 'Sent',
approved: 'Approved',
ready_to_pay: 'Ready to Pay',
paid: 'Paid',
cancelled: 'Cancelled',
};
const RECEIPT_BUCKET = 'expense-receipts';
const FIELD_LABEL_STYLE = { fontSize: 11, fontWeight: 400, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: 0.5, display: 'block', marginBottom: 4 };
const FIELD_INPUT_STYLE = { minHeight: 42, margin: 0 };
const blankExpense = () => ({
date: new Date().toISOString().slice(0, 10),
description: '',
category: 'Other',
amount: '',
notes: '',
receipt: null,
receipt_path: null,
receipt_name: null,
removeReceipt: false,
});
function getFileExt(name = '') {
const clean = String(name).split('.').pop()?.toLowerCase();
return clean && clean !== name ? clean.replace(/[^a-z0-9]/g, '') : 'bin';
}
const MONTHS = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
function FinancesChartTooltip({ active, payload, label, year }) {
if (!active || !payload?.length) return null;
return (
<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 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 [filter, setFilter] = useState('all');
const { sortKey: invSortKey, sortDir: invSortDir, toggle: invToggle, sort: invSort } = useSortable('invoice_date');
const { sortKey: expSortKey, sortDir: expSortDir, toggle: expToggle, sort: expSort } = useSortable('date');
const { sortKey: subSortKey, sortDir: subSortDir, toggle: subToggle, sort: subSort } = useSortable('submitted_at');
const [filterCompany, setFilterCompany] = useState('');
const [exportYear, setExportYear] = useState(new Date().getFullYear());
const [exporting, setExporting] = useState(false);
const [expenses, setExpenses] = useState([]);
const [expensesLoading, setExpensesLoading] = useState(true);
const [expensesError, setExpensesError] = useState('');
const [newExpense, setNewExpense] = useState(blankExpense());
const [addingExpense, setAddingExpense] = useState(false);
const [editingExpenseId, setEditingExpenseId] = useState('');
const [showExpenseForm, setShowExpenseForm] = useState(false);
const [expenseFilter, setExpenseFilter] = useState('all');
const [subcontractorPOs, setSubcontractorPOs] = useState([]);
const [subcontractorLoading, setSubcontractorLoading] = useState(true);
const [subcontractorError, setSubcontractorError] = useState('');
const [selectedSubcontractorPOId, setSelectedSubcontractorPOId] = useState('');
const [subInvoices, setSubInvoices] = useState([]);
const [subInvoicesLoading, setSubInvoicesLoading] = useState(true);
const [subInvoicesError, setSubInvoicesError] = useState('');
const [markingPaid, setMarkingPaid] = useState('');
const [expandedSubInvoiceId, setExpandedSubInvoiceId] = useState(null);
useEffect(() => {
async function load() {
const { data } = await supabase
.from('invoices')
.select('*, company:companies(name)')
.order('created_at', { ascending: false });
setInvoices(data || []);
writePageCache('team_invoices', { invoices: data || [] });
setLoading(false);
}
load();
}, []);
useEffect(() => {
async function loadExpenses() {
const [{ data, error }, { data: purchaseOrders, error: purchaseOrdersError }] = await Promise.all([
supabase.from('expenses').select('*').order('date', { ascending: false }),
supabase
.from('subcontractor_payments')
.select('*, profile:profiles!subcontractor_payments_profile_id_fkey(id, name, email), project:projects(id, name, company:companies(name)), items:subcontractor_po_items(*, task:tasks(id, title))')
.order('date', { ascending: false }),
]);
if (error) {
console.error('Failed to load expenses:', error);
setExpensesError(error.message || 'Failed to load expenses.');
setExpenses([]);
} else {
setExpenses(data || []);
setExpensesError('');
}
if (purchaseOrdersError) {
console.error('Failed to load subcontractor POs:', purchaseOrdersError);
setSubcontractorError(purchaseOrdersError.message || 'Failed to load subcontractor POs.');
setSubcontractorPOs([]);
} else {
setSubcontractorPOs(purchaseOrders || []);
setSubcontractorError('');
}
setExpensesLoading(false);
setSubcontractorLoading(false);
}
loadExpenses();
}, []);
useEffect(() => {
async function loadSubInvoices() {
const { data, error: err } = await supabase
.from('subcontractor_invoices')
.select('*, profile:profiles!subcontractor_invoices_profile_id_fkey(id, name, email), items:subcontractor_invoice_items(*)')
.order('created_at', { ascending: false });
if (err) { setSubInvoicesError(err.message); setSubInvoicesLoading(false); return; }
setSubInvoices(data || []);
setSubInvoicesLoading(false);
}
loadSubInvoices();
}, []);
const startEditExpense = (expense) => {
setEditingExpenseId(expense.id);
setNewExpense({
date: expense.date,
description: expense.description || '',
category: expense.category || 'Other',
amount: String(expense.amount ?? ''),
notes: expense.notes || '',
receipt: null,
receipt_path: expense.receipt_path || null,
receipt_name: expense.receipt_name || null,
removeReceipt: false,
});
setExpensesError('');
setShowExpenseForm(true);
};
const cancelExpenseEdit = () => {
setEditingExpenseId('');
setNewExpense(blankExpense());
setExpensesError('');
setShowExpenseForm(false);
};
const handleAddExpense = async (e) => {
e.preventDefault();
if (!newExpense.description.trim() || !newExpense.amount) return;
setAddingExpense(true);
setExpensesError('');
try {
const existingExpense = editingExpenseId ? expenses.find(expense => expense.id === editingExpenseId) : null;
let receiptPath = existingExpense?.receipt_path || newExpense.receipt_path || null;
let receiptName = existingExpense?.receipt_name || newExpense.receipt_name || null;
if (newExpense.removeReceipt && existingExpense?.receipt_path) {
await supabase.storage.from(RECEIPT_BUCKET).remove([existingExpense.receipt_path]);
receiptPath = null;
receiptName = null;
}
if (newExpense.receipt) {
const ext = getFileExt(newExpense.receipt.name);
receiptName = newExpense.receipt.name;
receiptPath = `${newExpense.date}/${Date.now()}-${crypto.randomUUID()}.${ext}`;
const { error: uploadError } = await supabase.storage.from(RECEIPT_BUCKET).upload(receiptPath, newExpense.receipt, { upsert: false });
if (uploadError) throw uploadError;
if (existingExpense?.receipt_path) {
await supabase.storage.from(RECEIPT_BUCKET).remove([existingExpense.receipt_path]);
}
}
const payload = {
date: newExpense.date,
description: newExpense.description.trim(),
category: newExpense.category,
amount: Number(newExpense.amount),
notes: newExpense.notes.trim(),
receipt_path: receiptPath,
receipt_name: receiptName,
};
const query = editingExpenseId
? supabase.from('expenses').update(payload).eq('id', editingExpenseId)
: supabase.from('expenses').insert(payload);
const { data, error } = await query.select().single();
if (error) {
if (newExpense.receipt && receiptPath) {
await supabase.storage.from(RECEIPT_BUCKET).remove([receiptPath]);
}
throw error;
}
if (data) {
setExpenses(prev => editingExpenseId
? prev.map(expense => expense.id === editingExpenseId ? data : expense)
: [data, ...prev]
);
setNewExpense(blankExpense());
setEditingExpenseId('');
setShowExpenseForm(false);
}
} catch (error) {
console.error(`Failed to ${editingExpenseId ? 'update' : 'add'} expense:`, error);
setExpensesError(error.message || `Failed to ${editingExpenseId ? 'update' : 'add'} expense.`);
alert(`Failed to ${editingExpenseId ? 'update' : 'add'} expense: ${error.message || 'unknown error'}`);
}
setAddingExpense(false);
};
const handleDeleteExpense = async (id) => {
if (!window.confirm('Delete this expense?')) return;
const expense = expenses.find(e => e.id === id);
if (expense?.receipt_path) {
await supabase.storage.from(RECEIPT_BUCKET).remove([expense.receipt_path]);
}
await supabase.from('expenses').delete().eq('id', id);
setExpenses(prev => prev.filter(e => e.id !== id));
};
const handleViewReceipt = async (expense) => {
if (!expense.receipt_path) return;
const { data, error } = await supabase.storage.from(RECEIPT_BUCKET).createSignedUrl(expense.receipt_path, 3600, {
download: expense.receipt_name || undefined,
});
if (error || !data?.signedUrl) {
const message = error?.message || 'Failed to open receipt.';
setExpensesError(message);
alert(message);
return;
}
window.open(data.signedUrl, '_blank', 'noopener,noreferrer');
};
const updateSubcontractorPO = async (po, updates, errorLabel = 'Failed to update PO') => {
const { data, error } = await supabase
.from('subcontractor_payments')
.update(updates)
.eq('id', po.id)
.select('*, profile:profiles!subcontractor_payments_profile_id_fkey(id, name, email), project:projects(id, name, company:companies(name)), items:subcontractor_po_items(*, task:tasks(id, title))')
.single();
if (error) {
alert(`${errorLabel}: ${error.message}`);
return null;
}
if (data) {
setSubcontractorPOs(prev => prev.map(row => row.id === po.id ? data : row));
setSelectedSubcontractorPOId(current => current === po.id ? data.id : current);
}
return data;
};
const handleSendSubcontractorPO = async (po) => {
const sentPO = await updateSubcontractorPO(po, { status: 'sent', sent_at: new Date().toISOString() }, 'Failed to send PO');
if (!sentPO?.profile?.email) return;
try {
await sendEmail('subcontractor_po_sent', sentPO.profile.email, {
poNumber: sentPO.po_number || 'Purchase Order',
subcontractorName: sentPO.profile?.name || 'there',
projectName: sentPO.project?.name || 'Subcontractor Work',
companyName: sentPO.project?.company?.name || 'Fourge Branding',
amount: `$${Number(sentPO.amount).toFixed(2)}`,
dueDate: sentPO.due_date ? new Date(sentPO.due_date).toLocaleDateString() : 'Not set',
terms: sentPO.terms || 'Net 15',
scope: sentPO.items?.length
? sentPO.items.map(item => `${item.description}$${Number(item.amount).toFixed(2)}`).join('\n')
: sentPO.description,
portalUrl: 'https://portal.fourgebranding.com/my-purchase-orders',
});
} catch (emailError) {
console.error('Failed to email subcontractor PO:', emailError);
alert(`PO was marked sent, but the email failed: ${emailError.message || 'unknown error'}`);
}
};
const handleReadyToPaySubcontractorPO = (po) => {
updateSubcontractorPO(po, { status: 'ready_to_pay' }, 'Failed to mark ready to pay');
};
const handleMarkSubcontractorPaid = async (po) => {
const paidAt = new Date().toISOString().slice(0, 10);
updateSubcontractorPO(po, { status: 'paid', paid_at: paidAt }, 'Failed to mark as paid');
};
const handleMarkSubInvoicePaid = async (invoice) => {
setMarkingPaid(invoice.id);
try {
const paidAt = new Date().toISOString();
const { error } = await supabase.from('subcontractor_invoices').update({ status: 'paid', paid_at: paidAt }).eq('id', invoice.id);
if (error) throw error;
const items = invoice.items || [];
const total = items.reduce((s, i) => s + Number(i.unit_price || 0) * Number(i.quantity || 1), 0);
let pdfBlob = null;
try {
pdfBlob = await generateSubcontractorPOPDF({
po_number: invoice.invoice_number,
status: 'paid',
profile: invoice.profile,
project: { name: 'Subcontractor Invoice', company: { name: 'Fourge Branding' } },
date: invoice.created_at?.split('T')[0],
due_date: paidAt.split('T')[0],
amount: total,
description: 'Payment for completed subcontractor work.',
notes: invoice.notes,
items: items.map((item, idx) => ({
description: item.description,
amount: Number(item.unit_price) * Number(item.quantity || 1),
sort_order: item.sort_order ?? idx,
})),
}, { output: 'blob' });
} catch (pdfErr) {
console.error('PDF generation failed:', pdfErr);
}
if (invoice.profile?.email) {
try {
const attachments = pdfBlob ? [await blobToEmailAttachment(pdfBlob, `${invoice.invoice_number}-receipt.pdf`)] : [];
await sendEmail('receipt_sent', invoice.profile.email, {
invoiceNumber: invoice.invoice_number,
billTo: invoice.profile.name || invoice.profile.email,
total: total.toFixed(2),
paidDate: new Date(paidAt).toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' }),
}, attachments);
} catch (emailErr) {
console.error('Email failed:', emailErr);
alert(`Marked paid, but email failed: ${emailErr.message || 'unknown error'}`);
}
}
setSubInvoices(prev => prev.map(i => i.id === invoice.id ? { ...i, status: 'paid', paid_at: paidAt } : i));
} catch (err) {
alert(`Failed to mark as paid: ${err.message}`);
}
setMarkingPaid('');
};
const handleReopenSubcontractorPO = async (po) => {
updateSubcontractorPO(po, { status: 'draft', paid_at: null, cancelled_at: null }, 'Failed to reopen PO');
};
const handleCancelSubcontractorPO = async (po) => {
if (!window.confirm(`Cancel ${po.po_number || 'this PO'}?`)) return;
updateSubcontractorPO(po, { status: 'cancelled', cancelled_at: new Date().toISOString() }, 'Failed to cancel PO');
};
const handleDeleteSubcontractorPO = async (id) => {
if (!window.confirm('Delete this subcontractor PO?')) return;
await supabase.from('subcontractor_payments').delete().eq('id', id);
setSubcontractorPOs(prev => prev.filter(po => po.id !== id));
setSelectedSubcontractorPOId(current => current === id ? '' : current);
};
const handleDownloadSubcontractorPO = async (po) => {
try {
await generateSubcontractorPOPDF(po);
} catch (error) {
console.error('Failed to download subcontractor PO:', error);
alert(`Failed to download PO: ${error.message || 'unknown error'}`);
}
};
const handleCPAExport = async () => {
setExporting(true);
try {
const yearInvoices = invoices.filter(inv =>
inv.status === 'paid' && new Date(inv.invoice_date).getFullYear() === exportYear
);
const yearExpenses = expenses.filter(exp =>
new Date(exp.date).getFullYear() === exportYear
);
const yearSubcontractorExpenses = subcontractorPOs
.filter(po => po.status === 'paid' && new Date(po.paid_at || po.date).getFullYear() === exportYear)
.map(po => ({
date: po.paid_at || po.date,
category: 'Contractor',
description: `Subcontractor: ${po.profile?.name || 'External'}${po.items?.length ? po.items.map(item => item.description).join('; ') : po.description}`,
amount: po.amount,
notes: [po.po_number, po.project?.name, po.notes || po.profile?.email || ''].filter(Boolean).join(' · '),
}));
const exportExpenses = [...yearExpenses, ...yearSubcontractorExpenses];
if (!yearInvoices.length && !exportExpenses.length) {
alert(`No data found for ${exportYear}.`);
return;
}
const { data: allItems } = yearInvoices.length ? await supabase
.from('invoice_items')
.select('invoice_id, description')
.in('invoice_id', yearInvoices.map(i => i.id)) : { data: [] };
const itemsByInvoice = (allItems || []).reduce((acc, item) => {
(acc[item.invoice_id] = acc[item.invoice_id] || []).push(item);
return acc;
}, {});
await exportCPAPackage(yearInvoices, itemsByInvoice, exportExpenses, exportYear);
} finally {
setExporting(false);
}
};
const paidYears = [...new Set(
invoices.filter(i => i.status === 'paid').map(i => new Date(i.invoice_date).getFullYear())
)].sort((a, b) => b - a);
const companyNames = [...new Set(invoices.map(inv => inv.company?.name).filter(Boolean))].sort();
const filtered = invoices.filter(inv => {
if (filter !== 'all' && inv.status !== filter) return false;
if (filterCompany && inv.company?.name !== filterCompany) return false;
return true;
});
const totals = {
all: invoices.reduce((s, i) => s + Number(i.total), 0),
draft: invoices.filter(i => i.status === 'draft').reduce((s, i) => s + Number(i.total), 0),
sent: invoices.filter(i => i.status === 'sent').reduce((s, i) => s + Number(i.total), 0),
paid: invoices.filter(i => i.status === 'paid').reduce((s, i) => s + Number(i.total), 0),
netReceived: invoices.filter(i => i.status === 'paid').reduce((s, i) => s + Number(i.total) - Number(i.stripe_fee || 0), 0),
};
const filteredExpenses = expenseFilter === 'all'
? expenses
: expenses.filter(e => e.category === expenseFilter);
const paidSubcontractorPOs = subcontractorPOs.filter(po => po.status === 'paid');
const payableSubcontractorPOs = subcontractorPOs.filter(po => ['approved', 'ready_to_pay'].includes(po.status));
const selectedSubcontractorPO = subcontractorPOs.find(po => po.id === selectedSubcontractorPOId);
const subInvoiceItemTotal = (inv) => (inv.items || []).reduce((a, x) => a + Number(x.unit_price || 0) * Number(x.quantity || 1), 0);
const paidSubInvoices = subInvoices.filter(i => i.status === 'paid');
const totalPaidSubInvoices = paidSubInvoices.reduce((s, i) => s + subInvoiceItemTotal(i), 0);
const totalPaidSubcontractors = paidSubcontractorPOs.reduce((s, po) => s + Number(po.amount), 0) + totalPaidSubInvoices;
const totalPayableSubcontractorPOs = payableSubcontractorPOs.reduce((s, po) => s + Number(po.amount), 0);
const totalPayableSubInvoices = subInvoices.filter(i => i.status === 'submitted').reduce((s, i) => s + subInvoiceItemTotal(i), 0);
const totalPayableSubcontractors = totalPayableSubcontractorPOs + totalPayableSubInvoices;
const payableSubcontractorCount = payableSubcontractorPOs.length + subInvoices.filter(i => i.status === 'submitted').length;
const totalExpenses = filteredExpenses.reduce((s, e) => s + Number(e.amount), 0);
const currentYear = new Date().getFullYear();
const yearExpenses = expenses.filter(e => new Date(e.date).getFullYear() === currentYear);
const currentYearExpenseTotal = yearExpenses.reduce((s, e) => s + Number(e.amount), 0);
const currentYearPaidSubcontractorPOs = paidSubcontractorPOs
.filter(po => new Date(po.paid_at || po.date).getFullYear() === currentYear)
.reduce((s, po) => s + Number(po.amount), 0);
const currentYearPaidSubInvoices = paidSubInvoices
.filter(i => new Date(i.paid_at).getFullYear() === currentYear)
.reduce((s, i) => s + subInvoiceItemTotal(i), 0);
const currentYearTotalExpenses = currentYearExpenseTotal + currentYearPaidSubcontractorPOs + currentYearPaidSubInvoices;
const revenue = totals.paid;
const profit = totals.netReceived - expenses.reduce((s, e) => s + Number(e.amount), 0) - totalPaidSubcontractors;
const chartYear = exportYear;
const chartData = useMemo(() => MONTHS.map((month, mi) => {
const paid = invoices
.filter(inv => inv.status === 'paid' && new Date(inv.invoice_date).getFullYear() === chartYear && new Date(inv.invoice_date).getMonth() === mi)
.reduce((s, inv) => s + Number(inv.total || 0), 0);
const outstanding = invoices
.filter(inv => inv.status === 'sent' && new Date(inv.invoice_date).getFullYear() === chartYear && new Date(inv.invoice_date).getMonth() === mi)
.reduce((s, inv) => s + Number(inv.total || 0), 0);
const exp = expenses
.filter(e => new Date(e.date).getFullYear() === chartYear && new Date(e.date).getMonth() === mi)
.reduce((s, e) => s + Number(e.amount || 0), 0);
const subExp = subInvoices
.filter(i => i.status === 'paid' && new Date(i.created_at).getFullYear() === chartYear && new Date(i.created_at).getMonth() === mi)
.reduce((s, i) => s + (i.items || []).reduce((a, x) => a + Number(x.unit_price || 0) * Number(x.quantity || 1), 0), 0);
const totalExp = exp + subExp;
return { month, Revenue: +paid.toFixed(2), Outstanding: +outstanding.toFixed(2), Expenses: +totalExp.toFixed(2), Profit: +(paid - totalExp).toFixed(2) };
}), [invoices, expenses, subInvoices, chartYear]);
return (
<Layout>
<div className="page-header" style={{ flexShrink: 0 }}>
<div className="page-header-left">
<DashboardBanner />
<div className="page-title dashboard-greeting">Finances</div>
<div className="page-subtitle">{invoices.length} invoice{invoices.length !== 1 ? 's' : ''} · {expenses.length} expense{expenses.length !== 1 ? 's' : ''} · {subcontractorPOs.length} subcontractor PO{subcontractorPOs.length !== 1 ? 's' : ''}</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
{paidYears.length > 0 && (
<select className="filter-select" value={exportYear} onChange={e => setExportYear(Number(e.target.value))} style={{ width: 120 }}>
{paidYears.map(y => <option key={y} value={y}>{y}</option>)}
</select>
)}
<button className="btn btn-primary btn-sm" onClick={handleCPAExport} disabled={exporting}>
{exporting ? 'Exporting…' : 'Export for CPA'}
</button>
</div>
</div>
<div style={{ background: 'var(--card-bg)', border: '1px solid var(--border)', borderRadius: 4, padding: '16px 16px 8px', marginBottom: 18 }}>
<ResponsiveContainer width="100%" height={200}>
<AreaChart data={chartData} margin={{ top: 4, right: 8, left: 0, bottom: 0 }}>
<defs>
<linearGradient id="gradRevenue" 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="gradProfit" 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="gradExpenses" 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="gradOutstanding" 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(#gradRevenue)" dot={false} activeDot={{ r: 4 }} />
<Area type="monotone" dataKey="Profit" stroke="#4ade80" strokeWidth={2} fill="url(#gradProfit)" dot={false} activeDot={{ r: 4 }} />
<Area type="monotone" dataKey="Expenses" stroke="#ef4444" strokeWidth={2} fill="url(#gradExpenses)" dot={false} activeDot={{ r: 4 }} />
<Area type="monotone" dataKey="Outstanding" stroke="#60a5fa" strokeWidth={2} fill="url(#gradOutstanding)" dot={false} activeDot={{ r: 4 }} />
</AreaChart>
</ResponsiveContainer>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 0, marginBottom: 16 }}>
{[
{ id: 'invoices', label: 'INVOICES' },
{ id: 'expenses', label: 'EXPENSES' },
{ id: 'sub-invoices', label: 'SUBCONTRACTOR INVOICES' },
].map((t, i, arr) => (
<span key={t.id} style={{ display: 'flex', alignItems: 'center' }}>
<button
type="button"
onClick={() => setActiveTab(t.id)}
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 0, fontSize: 13, letterSpacing: 0.5, fontWeight: activeTab === t.id ? 600 : 400, color: activeTab === t.id ? 'var(--text-primary)' : 'var(--text-muted)', fontFamily: 'inherit' }}
>{t.label}</button>
{i < arr.length - 1 && <span style={{ margin: '0 10px', color: 'var(--border)', userSelect: 'none' }}>|</span>}
</span>
))}
</div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, marginBottom: 16, 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={() => navigate('/invoices/new')}>+ 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 ? (
<p style={{ color: 'var(--text-muted)', fontSize: 13 }}>No invoices.</p>
) : (
<div style={{ background: 'var(--card-bg)', borderRadius: 4, border: '1px solid var(--border)' }}>
<table>
<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={() => navigate(`/invoices/${inv.id}`)} 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><span className={`badge badge-${statusColor[inv.status]}`} style={{ textTransform: 'capitalize' }}>{inv.status}</span></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: 16 }}>
{expensesLoading ? (
<p style={{ color: 'var(--text-muted)', fontSize: 13 }}>Loading...</p>
) : expensesError ? (
<p style={{ color: 'var(--danger)', fontSize: 13 }}>{expensesError}</p>
) : filteredExpenses.length === 0 ? (
<p style={{ color: 'var(--text-muted)', fontSize: 13 }}>No expenses yet.</p>
) : (
<div style={{ background: 'var(--card-bg)', borderRadius: 4, border: '1px solid var(--border)' }}>
<table>
<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={() => startEditExpense(exp)}>
<td>{new Date(exp.date).toLocaleDateString()}</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>
{showExpenseForm && (
<div style={popupOverlayStyle} onClick={cancelExpenseEdit}>
<div style={{ ...popupSurfaceStyle, padding: 28, width: '100%', maxWidth: 520, maxHeight: '90vh', overflowY: 'auto' }} onClick={e => e.stopPropagation()}>
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', marginBottom: 20 }}>
<div>
<div style={{ fontSize: 11, fontWeight: 400, textTransform: 'uppercase', letterSpacing: '0.5px', color: 'var(--text-muted)', marginBottom: 4 }}>{editingExpenseId ? 'Edit Expense' : 'New Expense'}</div>
<div style={{ fontSize: 22, color: 'var(--text-primary)' }}>{editingExpenseId ? 'Edit expense' : 'Add an expense'}</div>
</div>
<button onClick={cancelExpenseEdit} style={{ background: 'none', border: 'none', color: 'var(--text-muted)', fontSize: 20, cursor: 'pointer', lineHeight: 1, padding: 4 }}>×</button>
</div>
<form onSubmit={handleAddExpense} style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 8 }}>
<div>
<label style={FIELD_LABEL_STYLE}>Date</label>
<input
type="date"
className="input"
value={newExpense.date}
onChange={e => setNewExpense(p => ({ ...p, date: e.target.value }))}
style={FIELD_INPUT_STYLE}
required
/>
</div>
<div>
<label style={FIELD_LABEL_STYLE}>Amount (USD)</label>
<input
type="number"
step="0.01"
min="0"
className="input"
placeholder="0.00"
value={newExpense.amount}
onChange={e => setNewExpense(p => ({ ...p, amount: e.target.value }))}
style={{ ...FIELD_INPUT_STYLE, minHeight: 38, borderRadius: 4, paddingLeft: 10 }}
required
/>
</div>
</div>
<div>
<label style={FIELD_LABEL_STYLE}>Description</label>
<input
type="text"
className="input"
placeholder="What was this for?"
value={newExpense.description}
onChange={e => setNewExpense(p => ({ ...p, description: e.target.value }))}
required
/>
</div>
<div>
<label style={FIELD_LABEL_STYLE}>Category</label>
<select
className="input"
value={newExpense.category}
onChange={e => setNewExpense(p => ({ ...p, category: e.target.value }))}
style={FIELD_INPUT_STYLE}
>
{CATEGORIES.map(c => <option key={c} value={c}>{c}</option>)}
</select>
</div>
<div>
<label style={FIELD_LABEL_STYLE}>Notes (optional)</label>
<input
type="text"
className="input"
placeholder="Any extra details"
value={newExpense.notes}
onChange={e => setNewExpense(p => ({ ...p, notes: e.target.value }))}
/>
</div>
<div>
<label style={FIELD_LABEL_STYLE}>Receipt / Photo (optional)</label>
<input
type="file"
className="input"
accept="image/*,.pdf"
style={{ ...FIELD_INPUT_STYLE, padding: '9px 12px' }}
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, flexWrap: 'wrap' }}>
<span style={{ fontSize: 12, color: 'var(--text-muted)' }}>{newExpense.receipt_name || 'Existing receipt attached'}</span>
<button
type="button"
className="btn btn-outline btn-sm"
onClick={() => handleViewReceipt(newExpense)}
>
View Receipt
</button>
<button
type="button"
className="btn btn-outline btn-sm"
style={{ color: 'var(--danger)' }}
onClick={() => setNewExpense(p => ({ ...p, removeReceipt: true, receipt_path: null, receipt_name: null }))}
>
Remove Receipt
</button>
</div>
)}
{newExpense.receipt && (
<div style={{ marginTop: 6, fontSize: 12, color: 'var(--text-muted)' }}>
{newExpense.receipt.name}
</div>
)}
</div>
<div className="action-buttons" style={{ marginTop: 4 }}>
<button className="btn btn-primary btn-sm" type="submit" disabled={addingExpense}>
{addingExpense ? (editingExpenseId ? 'Saving…' : 'Adding…') : (editingExpenseId ? 'Save Changes' : 'Add Expense')}
</button>
{editingExpenseId && (
<button className="btn btn-outline btn-sm" type="button" onClick={cancelExpenseEdit}>
Cancel
</button>
)}
</div>
{expensesError && (
<div style={{ fontSize: 12, color: 'var(--danger)' }}>
{expensesError}
</div>
)}
</form>
</div>
</div>
)}
</div>
)}
{activeTab === 'sub-invoices' && (
<div>
<div style={{ marginBottom: 16 }}>
{subInvoicesLoading ? (
<p style={{ color: 'var(--text-muted)', fontSize: 13 }}>Loading...</p>
) : subInvoicesError ? (
<p style={{ color: 'var(--danger)', fontSize: 13 }}>{subInvoicesError}</p>
) : subInvoices.length === 0 ? (
<p style={{ color: 'var(--text-muted)', fontSize: 13 }}>No sub invoices yet.</p>
) : (
<div style={{ background: 'var(--card-bg)', borderRadius: 4, border: '1px solid var(--border)' }}>
<table>
<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={() => navigate(`/sub-invoices/${inv.id}`)}>
<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><span className={`badge badge-${subInvoiceStatusColor[inv.status] || 'not_started'}`} style={{ textTransform: 'capitalize' }}>{inv.status}</span></td>
<td style={{ textAlign: 'right', fontWeight: 400, color: 'var(--accent)' }}>${total.toFixed(2)}</td>
<td />
</tr>
);
})}
</tbody>
</table>
</div>
)}
</div>
</div>
)}
</Layout>
);
}