1369 lines
84 KiB
React
1369 lines
84 KiB
React
import { useState, useEffect, useMemo, useRef } from 'react';
|
||
import { AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer, PieChart, Pie, Cell } from 'recharts';
|
||
import { DashboardBanner } from '../../lib/dashboardBanner';
|
||
import { useLocation, useNavigate } from 'react-router-dom';
|
||
import Layout from '../../components/Layout';
|
||
import SortTh from '../../components/SortTh';
|
||
import StatusBadge from '../../components/StatusBadge';
|
||
import { useSortable } from '../../hooks/useSortable';
|
||
import { supabase } from '../../lib/supabase';
|
||
import { readPageCache, writePageCache } from '../../lib/pageCache';
|
||
import { exportCPAPackage, generateSubcontractorPOPDF } from '../../lib/invoice';
|
||
import { blobToEmailAttachment, sendEmail } from '../../lib/email';
|
||
import { popupOverlayStyle, popupSurfaceStyle } from '../../lib/popupStyles';
|
||
|
||
const CATEGORIES = ['Software', 'Contractor', 'Advertising', 'Office', 'Travel', 'Meals', 'Equipment', 'Other'];
|
||
const statusColor = { draft: 'not_started', sent: 'in_progress', paid: 'client_approved' };
|
||
const poStatusColor = {
|
||
draft: 'not_started',
|
||
sent: 'in_progress',
|
||
approved: 'client_approved',
|
||
ready_to_pay: 'in_progress',
|
||
paid: 'client_approved',
|
||
cancelled: 'needs_revision',
|
||
};
|
||
const poStatusLabel = {
|
||
draft: 'Draft',
|
||
sent: 'Sent',
|
||
approved: 'Approved',
|
||
ready_to_pay: 'Ready to Pay',
|
||
paid: 'Paid',
|
||
cancelled: 'Cancelled',
|
||
};
|
||
const invoiceStatusBadgeLabel = (status) => {
|
||
if (status === 'sent') return 'Invoiced';
|
||
return status ? status.charAt(0).toUpperCase() + status.slice(1) : '—';
|
||
};
|
||
const RECEIPT_BUCKET = 'expense-receipts';
|
||
const FIELD_LABEL_STYLE = { fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', textTransform: 'uppercase', letterSpacing: 0.8, display: 'block', marginBottom: 4 };
|
||
const FIELD_INPUT_STYLE = { minHeight: 42, margin: 0 };
|
||
|
||
const blankExpense = () => ({
|
||
date: new Date().toISOString().slice(0, 10),
|
||
description: '',
|
||
category: 'Other',
|
||
amount: '',
|
||
notes: '',
|
||
receipt: null,
|
||
receipt_path: null,
|
||
receipt_name: null,
|
||
removeReceipt: false,
|
||
});
|
||
|
||
function getFileExt(name = '') {
|
||
const clean = String(name).split('.').pop()?.toLowerCase();
|
||
return clean && clean !== name ? clean.replace(/[^a-z0-9]/g, '') : 'bin';
|
||
}
|
||
|
||
const MONTHS = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
|
||
|
||
function FinancesChartTooltip({ active, payload, label, year }) {
|
||
if (!active || !payload?.length) return null;
|
||
return (
|
||
<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 [financeTab, setFinanceTab] = useState('overview');
|
||
const [filter, setFilter] = useState('all');
|
||
const { sortKey: invSortKey, sortDir: invSortDir, toggle: invToggle, sort: invSort } = useSortable('invoice_date');
|
||
const { sortKey: expSortKey, sortDir: expSortDir, toggle: expToggle, sort: expSort } = useSortable('date');
|
||
const { sortKey: subSortKey, sortDir: subSortDir, toggle: subToggle, sort: subSort } = useSortable('submitted_at');
|
||
const { sortKey: ovExpKey, sortDir: ovExpDir, toggle: ovExpToggle } = useSortable('date');
|
||
const { sortKey: ovSubKey, sortDir: ovSubDir, toggle: ovSubToggle } = useSortable('date');
|
||
const { sortKey: ovInvKey, sortDir: ovInvDir, toggle: ovInvToggle } = useSortable('invoice_date');
|
||
const [filterCompany, setFilterCompany] = useState('');
|
||
const initMonth = () => { const n = new Date(); return { month: n.getMonth(), year: n.getFullYear() }; };
|
||
const [ovMonth1, setOvMonth1] = useState(initMonth);
|
||
const [ovMonth2, setOvMonth2] = useState(initMonth);
|
||
const [ovMonth3, setOvMonth3] = useState(initMonth);
|
||
const [exportYear, setExportYear] = useState(new Date().getFullYear());
|
||
const [exporting, setExporting] = useState(false);
|
||
|
||
const [expenses, setExpenses] = useState([]);
|
||
const [expensesLoading, setExpensesLoading] = useState(true);
|
||
const [expensesError, setExpensesError] = useState('');
|
||
const [newExpense, setNewExpense] = useState(blankExpense());
|
||
const [addingExpense, setAddingExpense] = useState(false);
|
||
const [editingExpenseId, setEditingExpenseId] = useState('');
|
||
const [showExpenseForm, setShowExpenseForm] = useState(false);
|
||
const [expenseFilter, setExpenseFilter] = useState('all');
|
||
const [subcontractorPOs, setSubcontractorPOs] = useState([]);
|
||
const [subcontractorLoading, setSubcontractorLoading] = useState(true);
|
||
const [subcontractorError, setSubcontractorError] = useState('');
|
||
const [selectedSubcontractorPOId, setSelectedSubcontractorPOId] = useState('');
|
||
const [subInvoices, setSubInvoices] = useState([]);
|
||
const [subInvoicesLoading, setSubInvoicesLoading] = useState(true);
|
||
const [subInvoicesError, setSubInvoicesError] = useState('');
|
||
const [markingPaid, setMarkingPaid] = useState('');
|
||
const [expandedSubInvoiceId, setExpandedSubInvoiceId] = useState(null);
|
||
|
||
useEffect(() => {
|
||
async function load() {
|
||
const { data } = await supabase
|
||
.from('invoices')
|
||
.select('*, company:companies(name)')
|
||
.order('created_at', { ascending: false });
|
||
setInvoices(data || []);
|
||
writePageCache('team_invoices', { invoices: data || [] });
|
||
setLoading(false);
|
||
}
|
||
load();
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
async function loadExpenses() {
|
||
const [{ data, error }, { data: purchaseOrders, error: purchaseOrdersError }] = await Promise.all([
|
||
supabase.from('expenses').select('*').order('date', { ascending: false }),
|
||
supabase
|
||
.from('subcontractor_payments')
|
||
.select('*, profile:profiles!subcontractor_payments_profile_id_fkey(id, name, email), project:projects(id, name, company:companies(name)), items:subcontractor_po_items(*, task:tasks(id, title))')
|
||
.order('date', { ascending: false }),
|
||
]);
|
||
if (error) {
|
||
console.error('Failed to load expenses:', error);
|
||
setExpensesError(error.message || 'Failed to load expenses.');
|
||
setExpenses([]);
|
||
} else {
|
||
setExpenses(data || []);
|
||
setExpensesError('');
|
||
}
|
||
if (purchaseOrdersError) {
|
||
console.error('Failed to load subcontractor POs:', purchaseOrdersError);
|
||
setSubcontractorError(purchaseOrdersError.message || 'Failed to load subcontractor POs.');
|
||
setSubcontractorPOs([]);
|
||
} else {
|
||
setSubcontractorPOs(purchaseOrders || []);
|
||
setSubcontractorError('');
|
||
}
|
||
setExpensesLoading(false);
|
||
setSubcontractorLoading(false);
|
||
}
|
||
loadExpenses();
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
async function loadSubInvoices() {
|
||
const { data, error: err } = await supabase
|
||
.from('subcontractor_invoices')
|
||
.select('*, profile:profiles!subcontractor_invoices_profile_id_fkey(id, name, email), items:subcontractor_invoice_items(*)')
|
||
.order('created_at', { ascending: false });
|
||
if (err) { setSubInvoicesError(err.message); setSubInvoicesLoading(false); return; }
|
||
setSubInvoices(data || []);
|
||
setSubInvoicesLoading(false);
|
||
}
|
||
loadSubInvoices();
|
||
}, []);
|
||
|
||
const startEditExpense = (expense) => {
|
||
setEditingExpenseId(expense.id);
|
||
setNewExpense({
|
||
date: expense.date,
|
||
description: expense.description || '',
|
||
category: expense.category || 'Other',
|
||
amount: String(expense.amount ?? ''),
|
||
notes: expense.notes || '',
|
||
receipt: null,
|
||
receipt_path: expense.receipt_path || null,
|
||
receipt_name: expense.receipt_name || null,
|
||
removeReceipt: false,
|
||
});
|
||
setExpensesError('');
|
||
setShowExpenseForm(true);
|
||
};
|
||
|
||
const cancelExpenseEdit = () => {
|
||
setEditingExpenseId('');
|
||
setNewExpense(blankExpense());
|
||
setExpensesError('');
|
||
setShowExpenseForm(false);
|
||
};
|
||
|
||
const handleAddExpense = async (e) => {
|
||
e.preventDefault();
|
||
if (!newExpense.description.trim() || !newExpense.amount) return;
|
||
setAddingExpense(true);
|
||
setExpensesError('');
|
||
try {
|
||
const existingExpense = editingExpenseId ? expenses.find(expense => expense.id === editingExpenseId) : null;
|
||
let receiptPath = existingExpense?.receipt_path || newExpense.receipt_path || null;
|
||
let receiptName = existingExpense?.receipt_name || newExpense.receipt_name || null;
|
||
|
||
if (newExpense.removeReceipt && existingExpense?.receipt_path) {
|
||
await supabase.storage.from(RECEIPT_BUCKET).remove([existingExpense.receipt_path]);
|
||
receiptPath = null;
|
||
receiptName = null;
|
||
}
|
||
|
||
if (newExpense.receipt) {
|
||
const ext = getFileExt(newExpense.receipt.name);
|
||
receiptName = newExpense.receipt.name;
|
||
receiptPath = `${newExpense.date}/${Date.now()}-${crypto.randomUUID()}.${ext}`;
|
||
const { error: uploadError } = await supabase.storage.from(RECEIPT_BUCKET).upload(receiptPath, newExpense.receipt, { upsert: false });
|
||
if (uploadError) throw uploadError;
|
||
if (existingExpense?.receipt_path) {
|
||
await supabase.storage.from(RECEIPT_BUCKET).remove([existingExpense.receipt_path]);
|
||
}
|
||
}
|
||
|
||
const payload = {
|
||
date: newExpense.date,
|
||
description: newExpense.description.trim(),
|
||
category: newExpense.category,
|
||
amount: Number(newExpense.amount),
|
||
notes: newExpense.notes.trim(),
|
||
receipt_path: receiptPath,
|
||
receipt_name: receiptName,
|
||
};
|
||
|
||
const query = editingExpenseId
|
||
? supabase.from('expenses').update(payload).eq('id', editingExpenseId)
|
||
: supabase.from('expenses').insert(payload);
|
||
const { data, error } = await query.select().single();
|
||
|
||
if (error) {
|
||
if (newExpense.receipt && receiptPath) {
|
||
await supabase.storage.from(RECEIPT_BUCKET).remove([receiptPath]);
|
||
}
|
||
throw error;
|
||
}
|
||
|
||
if (data) {
|
||
setExpenses(prev => editingExpenseId
|
||
? prev.map(expense => expense.id === editingExpenseId ? data : expense)
|
||
: [data, ...prev]
|
||
);
|
||
setNewExpense(blankExpense());
|
||
setEditingExpenseId('');
|
||
setShowExpenseForm(false);
|
||
}
|
||
} catch (error) {
|
||
console.error(`Failed to ${editingExpenseId ? 'update' : 'add'} expense:`, error);
|
||
setExpensesError(error.message || `Failed to ${editingExpenseId ? 'update' : 'add'} expense.`);
|
||
alert(`Failed to ${editingExpenseId ? 'update' : 'add'} expense: ${error.message || 'unknown error'}`);
|
||
}
|
||
setAddingExpense(false);
|
||
};
|
||
|
||
const handleDeleteExpense = async (id) => {
|
||
if (!window.confirm('Delete this expense?')) return;
|
||
const expense = expenses.find(e => e.id === id);
|
||
if (expense?.receipt_path) {
|
||
await supabase.storage.from(RECEIPT_BUCKET).remove([expense.receipt_path]);
|
||
}
|
||
await supabase.from('expenses').delete().eq('id', id);
|
||
setExpenses(prev => prev.filter(e => e.id !== id));
|
||
};
|
||
|
||
const handleViewReceipt = async (expense) => {
|
||
if (!expense.receipt_path) return;
|
||
const { data, error } = await supabase.storage.from(RECEIPT_BUCKET).createSignedUrl(expense.receipt_path, 3600, {
|
||
download: expense.receipt_name || undefined,
|
||
});
|
||
if (error || !data?.signedUrl) {
|
||
const message = error?.message || 'Failed to open receipt.';
|
||
setExpensesError(message);
|
||
alert(message);
|
||
return;
|
||
}
|
||
window.open(data.signedUrl, '_blank', 'noopener,noreferrer');
|
||
};
|
||
|
||
const updateSubcontractorPO = async (po, updates, errorLabel = 'Failed to update PO') => {
|
||
const { data, error } = await supabase
|
||
.from('subcontractor_payments')
|
||
.update(updates)
|
||
.eq('id', po.id)
|
||
.select('*, profile:profiles!subcontractor_payments_profile_id_fkey(id, name, email), project:projects(id, name, company:companies(name)), items:subcontractor_po_items(*, task:tasks(id, title))')
|
||
.single();
|
||
if (error) {
|
||
alert(`${errorLabel}: ${error.message}`);
|
||
return null;
|
||
}
|
||
if (data) {
|
||
setSubcontractorPOs(prev => prev.map(row => row.id === po.id ? data : row));
|
||
setSelectedSubcontractorPOId(current => current === po.id ? data.id : current);
|
||
}
|
||
return data;
|
||
};
|
||
|
||
const handleSendSubcontractorPO = async (po) => {
|
||
const sentPO = await updateSubcontractorPO(po, { status: 'sent', sent_at: new Date().toISOString() }, 'Failed to send PO');
|
||
if (!sentPO?.profile?.email) return;
|
||
try {
|
||
await sendEmail('subcontractor_po_sent', sentPO.profile.email, {
|
||
poNumber: sentPO.po_number || 'Purchase Order',
|
||
subcontractorName: sentPO.profile?.name || 'there',
|
||
projectName: sentPO.project?.name || 'Subcontractor Work',
|
||
companyName: sentPO.project?.company?.name || 'Fourge Branding',
|
||
amount: `$${Number(sentPO.amount).toFixed(2)}`,
|
||
dueDate: sentPO.due_date ? new Date(sentPO.due_date).toLocaleDateString() : 'Not set',
|
||
terms: sentPO.terms || 'Net 15',
|
||
scope: sentPO.items?.length
|
||
? sentPO.items.map(item => `${item.description} — $${Number(item.amount).toFixed(2)}`).join('\n')
|
||
: sentPO.description,
|
||
portalUrl: 'https://portal.fourgebranding.com/my-purchase-orders',
|
||
});
|
||
} catch (emailError) {
|
||
console.error('Failed to email subcontractor PO:', emailError);
|
||
alert(`PO was marked sent, but the email failed: ${emailError.message || 'unknown error'}`);
|
||
}
|
||
};
|
||
|
||
const handleReadyToPaySubcontractorPO = (po) => {
|
||
updateSubcontractorPO(po, { status: 'ready_to_pay' }, 'Failed to mark ready to pay');
|
||
};
|
||
|
||
const handleMarkSubcontractorPaid = async (po) => {
|
||
const paidAt = new Date().toISOString().slice(0, 10);
|
||
updateSubcontractorPO(po, { status: 'paid', paid_at: paidAt }, 'Failed to mark as paid');
|
||
};
|
||
|
||
const handleMarkSubInvoicePaid = async (invoice) => {
|
||
setMarkingPaid(invoice.id);
|
||
try {
|
||
const paidAt = new Date().toISOString();
|
||
const { error } = await supabase.from('subcontractor_invoices').update({ status: 'paid', paid_at: paidAt }).eq('id', invoice.id);
|
||
if (error) throw error;
|
||
const items = invoice.items || [];
|
||
const total = items.reduce((s, i) => s + Number(i.unit_price || 0) * Number(i.quantity || 1), 0);
|
||
let pdfBlob = null;
|
||
try {
|
||
pdfBlob = await generateSubcontractorPOPDF({
|
||
po_number: invoice.invoice_number,
|
||
status: 'paid',
|
||
profile: invoice.profile,
|
||
project: { name: 'Subcontractor Invoice', company: { name: 'Fourge Branding' } },
|
||
date: invoice.created_at?.split('T')[0],
|
||
due_date: paidAt.split('T')[0],
|
||
amount: total,
|
||
description: 'Payment for completed subcontractor work.',
|
||
notes: invoice.notes,
|
||
items: items.map((item, idx) => ({
|
||
description: item.description,
|
||
amount: Number(item.unit_price) * Number(item.quantity || 1),
|
||
sort_order: item.sort_order ?? idx,
|
||
})),
|
||
}, { output: 'blob' });
|
||
} catch (pdfErr) {
|
||
console.error('PDF generation failed:', pdfErr);
|
||
}
|
||
if (invoice.profile?.email) {
|
||
try {
|
||
const attachments = pdfBlob ? [await blobToEmailAttachment(pdfBlob, `${invoice.invoice_number}-receipt.pdf`)] : [];
|
||
await sendEmail('receipt_sent', invoice.profile.email, {
|
||
invoiceNumber: invoice.invoice_number,
|
||
billTo: invoice.profile.name || invoice.profile.email,
|
||
total: total.toFixed(2),
|
||
paidDate: new Date(paidAt).toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' }),
|
||
}, attachments);
|
||
} catch (emailErr) {
|
||
console.error('Email failed:', emailErr);
|
||
alert(`Marked paid, but email failed: ${emailErr.message || 'unknown error'}`);
|
||
}
|
||
}
|
||
setSubInvoices(prev => prev.map(i => i.id === invoice.id ? { ...i, status: 'paid', paid_at: paidAt } : i));
|
||
} catch (err) {
|
||
alert(`Failed to mark as paid: ${err.message}`);
|
||
}
|
||
setMarkingPaid('');
|
||
};
|
||
|
||
const handleReopenSubcontractorPO = async (po) => {
|
||
updateSubcontractorPO(po, { status: 'draft', paid_at: null, cancelled_at: null }, 'Failed to reopen PO');
|
||
};
|
||
|
||
const handleCancelSubcontractorPO = async (po) => {
|
||
if (!window.confirm(`Cancel ${po.po_number || 'this PO'}?`)) return;
|
||
updateSubcontractorPO(po, { status: 'cancelled', cancelled_at: new Date().toISOString() }, 'Failed to cancel PO');
|
||
};
|
||
|
||
const handleDeleteSubcontractorPO = async (id) => {
|
||
if (!window.confirm('Delete this subcontractor PO?')) return;
|
||
await supabase.from('subcontractor_payments').delete().eq('id', id);
|
||
setSubcontractorPOs(prev => prev.filter(po => po.id !== id));
|
||
setSelectedSubcontractorPOId(current => current === id ? '' : current);
|
||
};
|
||
|
||
const handleDownloadSubcontractorPO = async (po) => {
|
||
try {
|
||
await generateSubcontractorPOPDF(po);
|
||
} catch (error) {
|
||
console.error('Failed to download subcontractor PO:', error);
|
||
alert(`Failed to download PO: ${error.message || 'unknown error'}`);
|
||
}
|
||
};
|
||
|
||
const handleCPAExport = async () => {
|
||
setExporting(true);
|
||
try {
|
||
const yearInvoices = invoices.filter(inv =>
|
||
inv.status === 'paid' && new Date(inv.invoice_date).getFullYear() === exportYear
|
||
);
|
||
const yearExpenses = expenses.filter(exp =>
|
||
new Date(exp.date).getFullYear() === exportYear
|
||
);
|
||
const yearSubcontractorExpenses = subcontractorPOs
|
||
.filter(po => po.status === 'paid' && new Date(po.paid_at || po.date).getFullYear() === exportYear)
|
||
.map(po => ({
|
||
date: po.paid_at || po.date,
|
||
category: 'Contractor',
|
||
description: `Subcontractor: ${po.profile?.name || 'External'} — ${po.items?.length ? po.items.map(item => item.description).join('; ') : po.description}`,
|
||
amount: po.amount,
|
||
notes: [po.po_number, po.project?.name, po.notes || po.profile?.email || ''].filter(Boolean).join(' · '),
|
||
}));
|
||
const exportExpenses = [...yearExpenses, ...yearSubcontractorExpenses];
|
||
if (!yearInvoices.length && !exportExpenses.length) {
|
||
alert(`No data found for ${exportYear}.`);
|
||
return;
|
||
}
|
||
const { data: allItems } = yearInvoices.length ? await supabase
|
||
.from('invoice_items')
|
||
.select('invoice_id, description')
|
||
.in('invoice_id', yearInvoices.map(i => i.id)) : { data: [] };
|
||
const itemsByInvoice = (allItems || []).reduce((acc, item) => {
|
||
(acc[item.invoice_id] = acc[item.invoice_id] || []).push(item);
|
||
return acc;
|
||
}, {});
|
||
await exportCPAPackage(yearInvoices, itemsByInvoice, exportExpenses, exportYear);
|
||
} finally {
|
||
setExporting(false);
|
||
}
|
||
};
|
||
|
||
const paidYears = [...new Set(
|
||
invoices.filter(i => i.status === 'paid').map(i => new Date(i.invoice_date).getFullYear())
|
||
)].sort((a, b) => b - a);
|
||
|
||
const companyNames = [...new Set(invoices.map(inv => inv.company?.name).filter(Boolean))].sort();
|
||
const filtered = invoices.filter(inv => {
|
||
if (filter !== 'all' && inv.status !== filter) return false;
|
||
if (filterCompany && inv.company?.name !== filterCompany) return false;
|
||
return true;
|
||
});
|
||
|
||
const totals = {
|
||
all: invoices.reduce((s, i) => s + Number(i.total), 0),
|
||
draft: invoices.filter(i => i.status === 'draft').reduce((s, i) => s + Number(i.total), 0),
|
||
sent: invoices.filter(i => i.status === 'sent').reduce((s, i) => s + Number(i.total), 0),
|
||
paid: invoices.filter(i => i.status === 'paid').reduce((s, i) => s + Number(i.total), 0),
|
||
netReceived: invoices.filter(i => i.status === 'paid').reduce((s, i) => s + Number(i.total) - Number(i.stripe_fee || 0), 0),
|
||
};
|
||
|
||
const filteredExpenses = expenseFilter === 'all'
|
||
? expenses
|
||
: expenses.filter(e => e.category === expenseFilter);
|
||
const paidSubcontractorPOs = subcontractorPOs.filter(po => po.status === 'paid');
|
||
const payableSubcontractorPOs = subcontractorPOs.filter(po => ['approved', 'ready_to_pay'].includes(po.status));
|
||
const selectedSubcontractorPO = subcontractorPOs.find(po => po.id === selectedSubcontractorPOId);
|
||
const subInvoiceItemTotal = (inv) => (inv.items || []).reduce((a, x) => a + Number(x.unit_price || 0) * Number(x.quantity || 1), 0);
|
||
const paidSubInvoices = subInvoices.filter(i => i.status === 'paid');
|
||
const totalPaidSubInvoices = paidSubInvoices.reduce((s, i) => s + subInvoiceItemTotal(i), 0);
|
||
const totalPaidSubcontractors = paidSubcontractorPOs.reduce((s, po) => s + Number(po.amount), 0) + totalPaidSubInvoices;
|
||
const totalPayableSubcontractorPOs = payableSubcontractorPOs.reduce((s, po) => s + Number(po.amount), 0);
|
||
const totalPayableSubInvoices = subInvoices.filter(i => i.status === 'submitted').reduce((s, i) => s + subInvoiceItemTotal(i), 0);
|
||
const totalPayableSubcontractors = totalPayableSubcontractorPOs + totalPayableSubInvoices;
|
||
const payableSubcontractorCount = payableSubcontractorPOs.length + subInvoices.filter(i => i.status === 'submitted').length;
|
||
const totalExpenses = filteredExpenses.reduce((s, e) => s + Number(e.amount), 0);
|
||
const currentYear = new Date().getFullYear();
|
||
const yearExpenses = expenses.filter(e => new Date(e.date).getFullYear() === currentYear);
|
||
const currentYearExpenseTotal = yearExpenses.reduce((s, e) => s + Number(e.amount), 0);
|
||
const currentYearPaidSubcontractorPOs = paidSubcontractorPOs
|
||
.filter(po => new Date(po.paid_at || po.date).getFullYear() === currentYear)
|
||
.reduce((s, po) => s + Number(po.amount), 0);
|
||
const currentYearPaidSubInvoices = paidSubInvoices
|
||
.filter(i => new Date(i.paid_at).getFullYear() === currentYear)
|
||
.reduce((s, i) => s + subInvoiceItemTotal(i), 0);
|
||
const currentYearTotalExpenses = currentYearExpenseTotal + currentYearPaidSubcontractorPOs + currentYearPaidSubInvoices;
|
||
const revenue = totals.paid;
|
||
const profit = totals.netReceived - expenses.reduce((s, e) => s + Number(e.amount), 0) - totalPaidSubcontractors;
|
||
|
||
const chartYear = exportYear;
|
||
const chartData = useMemo(() => MONTHS.map((month, mi) => {
|
||
const paid = invoices
|
||
.filter(inv => inv.status === 'paid' && new Date(inv.invoice_date).getFullYear() === chartYear && new Date(inv.invoice_date).getMonth() === mi)
|
||
.reduce((s, inv) => s + Number(inv.total || 0), 0);
|
||
const outstanding = invoices
|
||
.filter(inv => inv.status === 'sent' && new Date(inv.invoice_date).getFullYear() === chartYear && new Date(inv.invoice_date).getMonth() === mi)
|
||
.reduce((s, inv) => s + Number(inv.total || 0), 0);
|
||
const exp = expenses
|
||
.filter(e => new Date(e.date).getFullYear() === chartYear && new Date(e.date).getMonth() === mi)
|
||
.reduce((s, e) => s + Number(e.amount || 0), 0);
|
||
const subExp = subInvoices
|
||
.filter(i => i.status === 'paid' && new Date(i.created_at).getFullYear() === chartYear && new Date(i.created_at).getMonth() === mi)
|
||
.reduce((s, i) => s + (i.items || []).reduce((a, x) => a + Number(x.unit_price || 0) * Number(x.quantity || 1), 0), 0);
|
||
const totalExp = exp + subExp;
|
||
return { month, Revenue: +paid.toFixed(2), Outstanding: +outstanding.toFixed(2), Expenses: +totalExp.toFixed(2), Profit: +(paid - totalExp).toFixed(2) };
|
||
}), [invoices, expenses, subInvoices, chartYear]);
|
||
|
||
|
||
return (
|
||
<Layout>
|
||
<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).toFixed(0)}k` : `$${v.toFixed(2)}`;
|
||
const CARD = { background: 'var(--card-bg)', border: '1px solid var(--border)', borderRadius: 8, padding: '18px 21px', backdropFilter: 'blur(12px)', WebkitBackdropFilter: 'blur(12px)' };
|
||
const StatCard = ({ label, value, sub, iconBg, iconColor, iconSvg }) => (
|
||
<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 => setChartYear(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' },
|
||
{ id: 'legacy', label: 'Legacy' },
|
||
].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>
|
||
)}
|
||
{financeTab === 'invoices' && (
|
||
<div style={{ marginLeft: 'auto', display: 'flex', alignItems: 'center', gap: 8 }}>
|
||
<button className="btn btn-outline" onClick={() => navigate('/invoices/new')}>+ Invoice</button>
|
||
</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 => { const d = new Date(e.date); return d.getFullYear() === y1 && d.getMonth() === m1; });
|
||
const thisMonthTotal = thisMonthExp.reduce((s, e) => s + Number(e.amount || 0), 0);
|
||
const thisCatTotals = CATEGORIES.map(cat => ({ cat, total: thisMonthExp.filter(e => e.category === cat).reduce((s, e) => s + Number(e.amount || 0), 0) })).filter(c => c.total > 0).sort((a, b) => b.total - a.total).slice(0, 4);
|
||
|
||
// Card 2: Sub payments this month (paid sub invoices)
|
||
const ovInvTotal = inv => (inv.items || []).reduce((s, i) => s + Number(i.unit_price || 0) * Number(i.quantity || 1), 0);
|
||
const m2 = ovMonth2.month, y2 = ovMonth2.year;
|
||
const pendingSubs = [...subInvoices.filter(i => { const d = new Date(i.paid_at || i.submitted_at); return d.getFullYear() === y2 && d.getMonth() === m2; })].sort((a, b) => new Date(b.paid_at || b.submitted_at) - new Date(a.paid_at || a.submitted_at));
|
||
const pendingSubsTotal = pendingSubs.reduce((s, i) => s + ovInvTotal(i), 0);
|
||
|
||
// Card 3: All invoices issued this month
|
||
const m3 = ovMonth3.month, y3 = ovMonth3.year;
|
||
const monthInvoices = [...invoices.filter(i => { const d = new Date(i.invoice_date); return d.getFullYear() === y3 && d.getMonth() === m3; })];
|
||
const outstandingTotal = monthInvoices.reduce((s, i) => s + Number(i.total || 0), 0);
|
||
|
||
const PIE_COLORS = ['#F5A523', '#4ade80', '#60a5fa', '#c084fc', '#f472b6', '#fb923c', '#34d399', '#a78bfa'];
|
||
|
||
// pie by subcontractor
|
||
const subPieData = Object.values(pendingSubs.reduce((acc, inv) => {
|
||
const name = inv.profile?.name || 'Unknown';
|
||
if (!acc[name]) acc[name] = { name, value: 0 };
|
||
acc[name].value += ovInvTotal(inv);
|
||
return acc;
|
||
}, {})).sort((a, b) => b.value - a.value);
|
||
|
||
// pie by company (outstanding invoices)
|
||
const invPieData = Object.values(monthInvoices.reduce((acc, inv) => {
|
||
const name = inv.company?.name || inv.bill_to || 'Unknown';
|
||
if (!acc[name]) acc[name] = { name, value: 0 };
|
||
acc[name].value += Number(inv.total || 0);
|
||
return acc;
|
||
}, {})).sort((a, b) => b.value - a.value);
|
||
|
||
const PieTooltipContent = ({ active, payload }) => {
|
||
if (!active || !payload?.length) return null;
|
||
return (
|
||
<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>
|
||
<ResponsiveContainer width="100%" height={160}>
|
||
<PieChart>
|
||
<Pie data={thisCatTotals.length ? thisCatTotals : [{ cat: 'None', total: 1 }]} dataKey="total" nameKey="cat" cx="50%" cy="50%" innerRadius={45} outerRadius={72} 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>
|
||
{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>
|
||
<ResponsiveContainer width="100%" height={160}>
|
||
<PieChart>
|
||
<Pie data={subPieData.length ? subPieData : [{ name: 'None', value: 1 }]} dataKey="value" nameKey="name" cx="50%" cy="50%" innerRadius={45} outerRadius={72} 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>
|
||
{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>
|
||
<ResponsiveContainer width="100%" height={160}>
|
||
<PieChart>
|
||
<Pie data={invPieData.length ? invPieData : [{ name: 'None', value: 1 }]} dataKey="value" nameKey="name" cx="50%" cy="50%" innerRadius={45} outerRadius={72} 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>
|
||
{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).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) : '—';
|
||
const fmtAmt = v => `$${Number(v).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
|
||
const sortedExpenses = expSort(expenses, (exp, key) => {
|
||
if (key === 'amount') return Number(exp.amount) || 0;
|
||
if (key === 'date') return exp.date || '';
|
||
return exp[key] || '';
|
||
});
|
||
const categoryTotals = CATEGORIES
|
||
.map(cat => ({ cat, total: expenses.filter(e => e.category === cat).reduce((s, e) => s + Number(e.amount || 0), 0) }))
|
||
.sort((a, b) => b.total - a.total);
|
||
const grandTotal = expenses.reduce((s, e) => s + Number(e.amount || 0), 0);
|
||
return (
|
||
<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={() => startEditExpense(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={() => navigate(`/sub-invoices/${inv.id}`)}>
|
||
{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 sorted = invSort(invoices, (inv, key) => {
|
||
if (key === 'total') return Number(inv.total) || 0;
|
||
if (key === 'invoice_date' || key === 'due_date') return new Date(inv[key]).getTime();
|
||
if (key === 'company') return inv.company?.name || '';
|
||
return inv[key] || '';
|
||
});
|
||
|
||
const byCompany = Object.values(invoices.reduce((acc, inv) => {
|
||
const name = inv.company?.name || inv.bill_to || 'Unknown';
|
||
if (!acc[name]) acc[name] = { name, total: 0, paid: 0, outstanding: 0 };
|
||
acc[name].total += Number(inv.total) || 0;
|
||
if (inv.status === 'paid') acc[name].paid += Number(inv.total) || 0;
|
||
else if (inv.status === 'sent') acc[name].outstanding += Number(inv.total) || 0;
|
||
return acc;
|
||
}, {})).sort((a, b) => b.total - a.total);
|
||
|
||
const grandTotal = byCompany.reduce((s, g) => s + g.total, 0);
|
||
|
||
return (
|
||
<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: '20%' }} />
|
||
<col style={{ width: '27%' }} />
|
||
<col style={{ width: '15%' }} />
|
||
<col style={{ width: '15%' }} />
|
||
<col style={{ width: '13%' }} />
|
||
<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="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={() => navigate(`/invoices/${inv.id}`)}>{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', 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</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
})()}
|
||
|
||
{financeTab === 'legacy' && <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 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: 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>
|
||
|
||
</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>
|
||
<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 */}
|
||
|
||
{showExpenseForm && (
|
||
<div style={popupOverlayStyle} onClick={cancelExpenseEdit}>
|
||
<div style={{ ...popupSurfaceStyle, background: 'var(--card-bg)', width: 'min(520px, 96vw)', maxHeight: 'calc(100vh - 48px)', overflowY: 'auto', display: 'flex', flexDirection: 'column', gap: 16 }} onClick={e => e.stopPropagation()}>
|
||
<div style={{ fontSize: 11, fontWeight: 500, textTransform: 'uppercase', letterSpacing: 0.8, color: 'var(--text-secondary)' }}>{editingExpenseId ? 'Edit Expense' : 'New Expense'}</div>
|
||
<form onSubmit={handleAddExpense} style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
|
||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 14 }}>
|
||
<div>
|
||
<div style={FIELD_LABEL_STYLE}>Date *</div>
|
||
<input type="date" required value={newExpense.date} onChange={e => setNewExpense(p => ({ ...p, date: e.target.value }))} style={{ fontSize: 13, color: 'var(--text-primary)', background: 'var(--card-bg-2)', border: '1px solid var(--border)', borderRadius: 6, padding: '6px 10px', width: '100%', fontFamily: 'inherit', boxSizing: 'border-box' }} />
|
||
</div>
|
||
<div>
|
||
<div style={FIELD_LABEL_STYLE}>Amount (USD) *</div>
|
||
<input type="number" step="0.01" min="0" required placeholder="0.00" value={newExpense.amount} onChange={e => setNewExpense(p => ({ ...p, amount: e.target.value }))} style={{ fontSize: 13, color: 'var(--text-primary)', background: 'var(--card-bg-2)', border: '1px solid var(--border)', borderRadius: 6, padding: '6px 10px', width: '100%', fontFamily: 'inherit', boxSizing: 'border-box' }} />
|
||
</div>
|
||
</div>
|
||
<div>
|
||
<div style={FIELD_LABEL_STYLE}>Description *</div>
|
||
<input type="text" required placeholder="What was this for?" value={newExpense.description} onChange={e => setNewExpense(p => ({ ...p, description: e.target.value }))} style={{ fontSize: 13, color: 'var(--text-primary)', background: 'var(--card-bg-2)', border: '1px solid var(--border)', borderRadius: 6, padding: '6px 10px', width: '100%', fontFamily: 'inherit', boxSizing: 'border-box' }} />
|
||
</div>
|
||
<div>
|
||
<div style={FIELD_LABEL_STYLE}>Category</div>
|
||
<select value={newExpense.category} onChange={e => setNewExpense(p => ({ ...p, category: e.target.value }))} style={{ fontSize: 13, color: 'var(--text-primary)', background: 'var(--card-bg-2)', border: '1px solid var(--border)', borderRadius: 6, padding: '6px 10px', width: '100%', fontFamily: 'inherit', boxSizing: 'border-box' }}>
|
||
{CATEGORIES.map(c => <option key={c} value={c}>{c}</option>)}
|
||
</select>
|
||
</div>
|
||
<div>
|
||
<div style={FIELD_LABEL_STYLE}>Notes</div>
|
||
<input type="text" placeholder="Any extra details" value={newExpense.notes} onChange={e => setNewExpense(p => ({ ...p, notes: e.target.value }))} style={{ fontSize: 13, color: 'var(--text-primary)', background: 'var(--card-bg-2)', border: '1px solid var(--border)', borderRadius: 6, padding: '6px 10px', width: '100%', fontFamily: 'inherit', boxSizing: 'border-box' }} />
|
||
</div>
|
||
<div>
|
||
<div style={FIELD_LABEL_STYLE}>Receipt / Photo</div>
|
||
<input type="file" accept="image/*,.pdf" onChange={e => setNewExpense(p => ({ ...p, receipt: e.target.files?.[0] || null, removeReceipt: false }))} style={{ fontSize: 13, color: 'var(--text-primary)', background: 'var(--card-bg-2)', border: '1px solid var(--border)', borderRadius: 6, padding: '6px 10px', width: '100%', fontFamily: 'inherit', boxSizing: 'border-box' }} />
|
||
{!newExpense.receipt && newExpense.receipt_path && (
|
||
<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" onClick={() => handleViewReceipt(newExpense)}>View</button>
|
||
<button type="button" className="btn btn-outline" style={{ color: 'var(--danger)' }} onClick={() => setNewExpense(p => ({ ...p, removeReceipt: true, receipt_path: null, receipt_name: null }))}>Remove</button>
|
||
</div>
|
||
)}
|
||
{newExpense.receipt && <div style={{ marginTop: 6, fontSize: 12, color: 'var(--text-muted)' }}>{newExpense.receipt.name}</div>}
|
||
</div>
|
||
{expensesError && <div style={{ fontSize: 12, color: 'var(--danger)' }}>{expensesError}</div>}
|
||
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end', paddingTop: 4 }}>
|
||
<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 */}
|
||
</Layout>
|
||
);
|
||
}
|