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:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user