0945b548c1
Replace SubcontractorInvoiceDetailView (card-based) with flat layout matching the original sub-invoice popup: meta strip + raw table + notes. - InvoiceDetailPopup: add metaContent/metaActions/metaCols props + export POPUP_FIELD_LABEL - New InvoicePopupTable: flat sortable 5-col table, no card wrapper - All 4 popups (client, team invoice, team sub-invoice, external): use flat meta strip (4-col grid, borderBottom) and InvoicePopupTable - Sub-invoice + external: Submitted | Paid/Created | Items | Total - Client invoice: Date | Due | Status | Total - Team invoice: Date | Due | Company | Email | Total (+ stripe if paid) with Edit Dates button in metaActions slot Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2080 lines
130 KiB
React
2080 lines
130 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 { useAuth } from '../../context/AuthContext';
|
||
import { readPageCache, writePageCache } from '../../lib/pageCache';
|
||
import { exportCPAPackage, generateSubcontractorPOPDF, generateInvoicePDF } from '../../lib/invoice';
|
||
import { withTimeout } from '../../lib/withTimeout';
|
||
import LoadingButton from '../../components/LoadingButton';
|
||
import { blobToEmailAttachment, sendEmail } from '../../lib/email';
|
||
import { popupOverlayStyle, popupSurfaceStyle } from '../../lib/popupStyles';
|
||
import FileAttachment from '../../components/FileAttachment';
|
||
import { isReviewShadowDescription } from '../../lib/taskVersions';
|
||
import { getRevisionChargeQuantity, isCompletedVersionEligible, isInitialVersionEligible } from '../../lib/invoiceVersionRules';
|
||
import { TeamInvoiceDetailPanel } from './TeamInvoiceDetail';
|
||
import InvoiceDetailPopup, { POPUP_FIELD_LABEL } from '../../components/InvoiceDetailPopup';
|
||
import InvoicePopupTable from '../../components/InvoicePopupTable';
|
||
|
||
const CATEGORIES = ['Software', 'Contractor', 'Advertising', 'Office', 'Travel', 'Meals', 'Equipment', 'Other'];
|
||
const statusColor = { draft: 'not_started', sent: 'in_progress', paid: 'client_approved' };
|
||
const poStatusColor = {
|
||
draft: 'not_started',
|
||
sent: 'in_progress',
|
||
approved: 'client_approved',
|
||
ready_to_pay: 'in_progress',
|
||
paid: 'client_approved',
|
||
cancelled: 'needs_revision',
|
||
};
|
||
const poStatusLabel = {
|
||
draft: 'Draft',
|
||
sent: 'Sent',
|
||
approved: 'Approved',
|
||
ready_to_pay: 'Ready to Pay',
|
||
paid: 'Paid',
|
||
cancelled: 'Cancelled',
|
||
};
|
||
const invoiceStatusBadgeLabel = (status) => {
|
||
if (status === 'sent') return 'Invoiced';
|
||
return status ? status.charAt(0).toUpperCase() + status.slice(1) : '—';
|
||
};
|
||
const RECEIPT_BUCKET = 'expense-receipts';
|
||
const FIELD_LABEL_STYLE = { fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', textTransform: 'uppercase', letterSpacing: 0.8, display: 'block', marginBottom: 4 };
|
||
const FIELD_INPUT_STYLE = { minHeight: 42, margin: 0 };
|
||
const FINANCE_MODAL_INPUT_STYLE = {
|
||
...FIELD_INPUT_STYLE,
|
||
width: '100%',
|
||
padding: '0 10px',
|
||
fontSize: 13,
|
||
color: 'var(--text-primary)',
|
||
background: 'var(--card-bg-2)',
|
||
border: '1px solid var(--border)',
|
||
borderRadius: 6,
|
||
fontFamily: 'inherit',
|
||
boxSizing: 'border-box',
|
||
};
|
||
const FINANCE_MODAL_TEXTAREA_STYLE = {
|
||
...FINANCE_MODAL_INPUT_STYLE,
|
||
minHeight: 72,
|
||
padding: '10px',
|
||
resize: 'vertical',
|
||
};
|
||
const FINANCE_MODAL_LINE_ITEM_INPUT_STYLE = {
|
||
...FINANCE_MODAL_INPUT_STYLE,
|
||
minHeight: 32,
|
||
padding: '0 8px',
|
||
};
|
||
const FINANCE_MODAL_NUMBER_INPUT_STYLE = {
|
||
...FINANCE_MODAL_LINE_ITEM_INPUT_STYLE,
|
||
fontVariantNumeric: 'tabular-nums',
|
||
};
|
||
const FINANCE_MODAL_AMOUNT_FIELD_STYLE = {
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
gap: 8,
|
||
minHeight: 42,
|
||
width: '100%',
|
||
padding: '0 12px',
|
||
background: 'var(--card-bg-2)',
|
||
border: '1px solid var(--border)',
|
||
borderRadius: 6,
|
||
boxSizing: 'border-box',
|
||
};
|
||
const FINANCE_MODAL_AMOUNT_PREFIX_STYLE = {
|
||
flexShrink: 0,
|
||
fontSize: 16,
|
||
fontWeight: 500,
|
||
lineHeight: 1,
|
||
color: 'var(--text-secondary)',
|
||
};
|
||
const FINANCE_MODAL_AMOUNT_INPUT_STYLE = {
|
||
flex: 1,
|
||
minWidth: 0,
|
||
margin: 0,
|
||
padding: 0,
|
||
border: 'none',
|
||
outline: 'none',
|
||
background: 'transparent',
|
||
boxShadow: 'none',
|
||
fontFamily: 'inherit',
|
||
fontSize: 22,
|
||
fontWeight: 500,
|
||
lineHeight: 1.1,
|
||
textAlign: 'right',
|
||
color: 'var(--text-primary)',
|
||
fontVariantNumeric: 'tabular-nums',
|
||
appearance: 'textfield',
|
||
};
|
||
const FINANCE_MODAL_TOTAL_VALUE_STYLE = {
|
||
fontSize: 24,
|
||
fontWeight: 500,
|
||
lineHeight: 1.1,
|
||
color: 'var(--accent)',
|
||
fontVariantNumeric: 'tabular-nums',
|
||
};
|
||
|
||
function parseSubInvoiceItemVersionNumber(item) {
|
||
if (Number.isFinite(Number(item?.version_number))) return Number(item.version_number);
|
||
const match = String(item?.description || '').match(/\bR(\d{2})\b/i);
|
||
return match ? Number(match[1]) : 0;
|
||
}
|
||
|
||
function subInvoiceItemVersionLabel(item) {
|
||
return `R${String(parseSubInvoiceItemVersionNumber(item)).padStart(2, '0')}`;
|
||
}
|
||
|
||
function subInvoiceItemWorkLabel(item) {
|
||
return parseSubInvoiceItemVersionNumber(item) > 0 ? 'Revision' : 'New';
|
||
}
|
||
|
||
function cleanSubInvoiceItemDescription(item) {
|
||
return String(item?.description || '—').replace(/\s*[–-]\s*R\d{2}\b/i, '').trim() || '—';
|
||
}
|
||
|
||
function asArray(value) {
|
||
if (Array.isArray(value)) return value;
|
||
if (value == null) return [];
|
||
return typeof value === 'object' ? [value] : [];
|
||
}
|
||
|
||
async function fetchSubInvoiceTaskTypeMap(taskIds) {
|
||
const uniqueTaskIds = [...new Set((taskIds || []).filter(Boolean))];
|
||
if (uniqueTaskIds.length === 0) return {};
|
||
const { data, error } = await supabase
|
||
.from('tasks')
|
||
.select('id, title, submissions(service_type, type)')
|
||
.in('id', uniqueTaskIds);
|
||
if (error) throw error;
|
||
const next = {};
|
||
for (const task of (data || [])) {
|
||
const submissions = asArray(task.submissions);
|
||
const initial = submissions.find((submission) => submission?.type === 'initial' && submission?.service_type);
|
||
const fallback = submissions.find((submission) => submission?.service_type);
|
||
next[task.id] = initial?.service_type || fallback?.service_type || task.title || 'Other';
|
||
}
|
||
return next;
|
||
}
|
||
|
||
function CurrencyInput({ value, onChange, placeholder = '0.00', required = false, min = '0', step = '0.01' }) {
|
||
return (
|
||
<div style={FINANCE_MODAL_AMOUNT_FIELD_STYLE}>
|
||
<span style={FINANCE_MODAL_AMOUNT_PREFIX_STYLE}>$</span>
|
||
<input
|
||
type="number"
|
||
min={min}
|
||
step={step}
|
||
required={required}
|
||
placeholder={placeholder}
|
||
value={value}
|
||
onChange={onChange}
|
||
style={FINANCE_MODAL_AMOUNT_INPUT_STYLE}
|
||
/>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
const INV_TODAY = new Date().toISOString().split('T')[0];
|
||
const INV_NET30 = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString().split('T')[0];
|
||
const invNewItem = (description = '', unit_price = '', quantity = 1, task_id = null, submission_id = null) =>
|
||
({ id: crypto.randomUUID(), description, unit_price, quantity, task_id, submission_id });
|
||
|
||
const invBuildNewItemDescription = (task) => {
|
||
const projectName = task.project?.name || 'No Project';
|
||
const taskTitle = task.title || task.service_type || 'Untitled';
|
||
return `${projectName} • ${taskTitle}`;
|
||
};
|
||
|
||
const invBuildRevisionItemDescription = (revision) => {
|
||
const projectName = revision.task?.project?.name || 'No Project';
|
||
const taskTitle = revision.task?.title || revision.service_type || 'Revision';
|
||
const versionLabel = 'R' + String(revision.version_number || 0).padStart(2, '0');
|
||
return `${projectName} • ${taskTitle} • Revision ${versionLabel}`;
|
||
};
|
||
|
||
const blankExpense = () => ({
|
||
date: new Date().toISOString().slice(0, 10),
|
||
description: '',
|
||
category: 'Other',
|
||
amount: '',
|
||
notes: '',
|
||
receipt: null,
|
||
receipt_path: null,
|
||
receipt_name: null,
|
||
removeReceipt: false,
|
||
});
|
||
|
||
function getFileExt(name = '') {
|
||
const clean = String(name).split('.').pop()?.toLowerCase();
|
||
return clean && clean !== name ? clean.replace(/[^a-z0-9]/g, '') : 'bin';
|
||
}
|
||
|
||
const MONTHS = ['January','February','March','April','May','June','July','August','September','October','November','December'];
|
||
|
||
function FinancesChartTooltip({ active, payload, label, year }) {
|
||
if (!active || !payload?.length) return null;
|
||
return (
|
||
<div style={{ background: 'var(--card-bg)', border: '1px solid var(--border)', borderRadius: 4, padding: '10px 14px', fontSize: 12 }}>
|
||
<div style={{ fontWeight: 600, marginBottom: 6, color: 'var(--text-primary)' }}>{label} {year}</div>
|
||
{payload.map(p => (
|
||
<div key={p.dataKey} style={{ color: p.color, marginBottom: 2 }}>{p.name}: <strong>${p.value.toLocaleString('en-US', { minimumFractionDigits: 2 })}</strong></div>
|
||
))}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export default function Invoices() {
|
||
const { currentUser } = useAuth();
|
||
const navigate = useNavigate();
|
||
const location = useLocation();
|
||
const cached = readPageCache('team_invoices');
|
||
const [invoices, setInvoices] = useState(() => cached?.invoices || []);
|
||
const [loading, setLoading] = useState(() => !cached);
|
||
const [activeTab, setActiveTab] = useState(() => location.state?.tab || 'invoices');
|
||
const [financeTab, setFinanceTab] = useState('overview');
|
||
const [filter, setFilter] = useState('all');
|
||
const { sortKey: invSortKey, sortDir: invSortDir, toggle: invToggle, sort: invSort } = useSortable('invoice_date', 'desc');
|
||
const { sortKey: expSortKey, sortDir: expSortDir, toggle: expToggle, sort: expSort } = useSortable('date', 'desc');
|
||
const { sortKey: subSortKey, sortDir: subSortDir, toggle: subToggle, sort: subSort } = useSortable('submitted_at');
|
||
const { sortKey: ovExpKey, sortDir: ovExpDir, toggle: ovExpToggle } = useSortable('date');
|
||
const { sortKey: ovSubKey, sortDir: ovSubDir, toggle: ovSubToggle } = useSortable('date');
|
||
const { sortKey: ovInvKey, sortDir: ovInvDir, toggle: ovInvToggle } = useSortable('invoice_date');
|
||
const { sortKey: subPopupKey, sortDir: subPopupDir, toggle: subPopupToggle, sort: subPopupSort } = useSortable('description');
|
||
const [filterCompany, setFilterCompany] = useState('');
|
||
const initMonth = () => { const n = new Date(); return { month: n.getMonth(), year: n.getFullYear() }; };
|
||
const [ovMonth1, setOvMonth1] = useState(initMonth);
|
||
const [ovMonth2, setOvMonth2] = useState(initMonth);
|
||
const [ovMonth3, setOvMonth3] = useState(initMonth);
|
||
const [exportYear, setExportYear] = useState(new Date().getFullYear());
|
||
const [exporting, setExporting] = useState(false);
|
||
|
||
const [expenses, setExpenses] = useState([]);
|
||
const [expensesLoading, setExpensesLoading] = useState(true);
|
||
const [expensesError, setExpensesError] = useState('');
|
||
const [newExpense, setNewExpense] = useState(blankExpense());
|
||
const [addingExpense, setAddingExpense] = useState(false);
|
||
const [editingExpenseId, setEditingExpenseId] = useState('');
|
||
const [showExpenseForm, setShowExpenseForm] = useState(false);
|
||
const [expenseFilter, setExpenseFilter] = useState('all');
|
||
const [viewingExpense, setViewingExpense] = useState(null);
|
||
const [expensePreviewUrl, setExpensePreviewUrl] = useState(null);
|
||
const [expenseDetailEditing, setExpenseDetailEditing] = useState(false);
|
||
const [expYearFilter, setExpYearFilter] = useState(String(new Date().getFullYear()));
|
||
const [expYearMenuOpen, setExpYearMenuOpen] = useState(false);
|
||
const expYearMenuRef = useRef(null);
|
||
const [invYearFilter, setInvYearFilter] = useState(String(new Date().getFullYear()));
|
||
const [invCompanyFilter, setInvCompanyFilter] = useState('all');
|
||
const [invYearMenuOpen, setInvYearMenuOpen] = useState(false);
|
||
const invYearMenuRef = useRef(null);
|
||
const [subcontractorPOs, setSubcontractorPOs] = useState([]);
|
||
const [subcontractorLoading, setSubcontractorLoading] = useState(true);
|
||
const [subcontractorError, setSubcontractorError] = useState('');
|
||
const [selectedSubcontractorPOId, setSelectedSubcontractorPOId] = useState('');
|
||
const [subInvoices, setSubInvoices] = useState([]);
|
||
const [subInvoicesLoading, setSubInvoicesLoading] = useState(true);
|
||
const [subInvoicesError, setSubInvoicesError] = useState('');
|
||
const [markingPaid, setMarkingPaid] = useState('');
|
||
const [viewingSubInvoice, setViewingSubInvoice] = useState(null);
|
||
const [viewingSubInvoiceTaskTypeMap, setViewingSubInvoiceTaskTypeMap] = useState({});
|
||
const [viewingInvoice, setViewingInvoice] = useState(null);
|
||
const [expandedSubInvoiceId, setExpandedSubInvoiceId] = useState(null);
|
||
|
||
// ── New Invoice form ──────────────────────────────────────────────────────
|
||
const [showInvoiceForm, setShowInvoiceForm] = useState(false);
|
||
const [invCompanies, setInvCompanies] = useState([]);
|
||
const [invCompanyId, setInvCompanyId] = useState('');
|
||
const [invBillTo, setInvBillTo] = useState('');
|
||
const [invEmail, setInvEmail] = useState('');
|
||
const [invRecipients, setInvRecipients] = useState([]);
|
||
const [invUnbilledTasks, setInvUnbilledTasks] = useState([]);
|
||
const [invUnbilledRevisions, setInvUnbilledRevisions] = useState([]);
|
||
const [invPriceList, setInvPriceList] = useState([]);
|
||
const [invItems, setInvItems] = useState([invNewItem()]);
|
||
const [invNotes, setInvNotes] = useState('');
|
||
const [invSaving, setInvSaving] = useState(false);
|
||
const [invLoadingTasks, setInvLoadingTasks] = useState(false);
|
||
const invDragItem = useRef(null);
|
||
const pageLoading = loading || expensesLoading || subcontractorLoading || subInvoicesLoading;
|
||
|
||
useEffect(() => {
|
||
supabase.from('companies').select('id, name, contact_email').order('name').then(({ data }) => setInvCompanies(data || []));
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
let cancelled = false;
|
||
async function loadSubInvoiceTaskTypes() {
|
||
if (!viewingSubInvoice?.items?.length) {
|
||
setViewingSubInvoiceTaskTypeMap({});
|
||
return;
|
||
}
|
||
try {
|
||
const map = await fetchSubInvoiceTaskTypeMap(asArray(viewingSubInvoice.items).map((item) => item.task_id));
|
||
if (!cancelled) setViewingSubInvoiceTaskTypeMap(map);
|
||
} catch {
|
||
if (!cancelled) setViewingSubInvoiceTaskTypeMap({});
|
||
}
|
||
}
|
||
loadSubInvoiceTaskTypes();
|
||
return () => { cancelled = true; };
|
||
}, [viewingSubInvoice]);
|
||
|
||
useEffect(() => {
|
||
if (!invCompanyId) { setInvUnbilledTasks([]); setInvUnbilledRevisions([]); setInvPriceList([]); setInvItems([invNewItem()]); setInvBillTo(''); setInvEmail(''); setInvRecipients([]); return; }
|
||
const co = invCompanies.find(c => c.id === invCompanyId);
|
||
setInvBillTo(co?.name || '');
|
||
setInvLoadingTasks(true);
|
||
Promise.all([
|
||
supabase.from('projects').select('id').eq('company_id', invCompanyId),
|
||
supabase.from('company_prices').select('*').eq('company_id', invCompanyId),
|
||
supabase.from('profiles').select('id, name, email, role').eq('company_id', invCompanyId).in('role', ['client', 'external']).order('name'),
|
||
]).then(async ([{ data: projects }, { data: prices }, { data: users }]) => {
|
||
setInvPriceList(prices || []);
|
||
setInvRecipients(users || []);
|
||
setInvEmail((users || [])[0]?.email || co?.contact_email || '');
|
||
if (projects?.length > 0) {
|
||
const pids = projects.map(p => p.id);
|
||
const { data: companyTasks } = await supabase
|
||
.from('tasks')
|
||
.select('*, project:projects(name), submissions(service_type, type, version_number)')
|
||
.in('project_id', pids)
|
||
.in('status', ['not_started', 'in_progress', 'on_hold', 'client_review', 'client_approved', 'invoiced', 'paid']);
|
||
const revisionTaskIds = (companyTasks || []).map(t => t.id).filter(Boolean);
|
||
const { data: revisions } = revisionTaskIds.length > 0
|
||
? await supabase
|
||
.from('submissions')
|
||
.select('*, task:tasks(id, title, status, current_version, project:projects(name), submissions(service_type, type))')
|
||
.eq('type', 'revision')
|
||
.eq('invoiced', false)
|
||
.in('task_id', revisionTaskIds)
|
||
.order('submitted_at', { ascending: false })
|
||
: { data: [] };
|
||
const uninvoicedTasks = (companyTasks || []).filter(isInitialVersionEligible);
|
||
setInvUnbilledTasks(uninvoicedTasks.map(t => {
|
||
const ini = (t.submissions || []).find(s => s.type === 'initial') || t.submissions?.[0];
|
||
return { ...t, service_type: ini?.service_type || t.title };
|
||
}));
|
||
const revMap = new Map();
|
||
for (const r of (revisions || []).filter(r => !isReviewShadowDescription(r.description))) {
|
||
if (!isCompletedVersionEligible(r.task, r.version_number)) continue;
|
||
const k = `${r.task_id}:${r.version_number}`;
|
||
if (!revMap.has(k)) revMap.set(k, r);
|
||
}
|
||
setInvUnbilledRevisions([...revMap.values()]);
|
||
} else { setInvUnbilledTasks([]); setInvUnbilledRevisions([]); }
|
||
setInvLoadingTasks(false);
|
||
});
|
||
}, [invCompanyId, invCompanies]);
|
||
|
||
const invAddTask = (task) => {
|
||
const price = invPriceList.find(p => p.service_type === task.service_type && p.price_type === 'new');
|
||
const desc = invBuildNewItemDescription(task);
|
||
setInvItems(prev => prev.length === 1 && !prev[0].description && !prev[0].unit_price ? [invNewItem(desc, price?.price || '', 1, task.id)] : [...prev, invNewItem(desc, price?.price || '', 1, task.id)]);
|
||
};
|
||
const invAddRevision = (rev) => {
|
||
const ini = (rev.task?.submissions || []).find(s => s.type === 'initial') || rev.task?.submissions?.[0];
|
||
const svcType = ini?.service_type || rev.service_type || rev.task?.title || 'Revision';
|
||
const price = invPriceList.find(p => p.service_type === svcType && p.price_type === 'revision');
|
||
const revisionChargeQty = getRevisionChargeQuantity(rev?.version_number);
|
||
const qty = revisionChargeQty > 0 ? revisionChargeQty : 1;
|
||
const unitPrice = revisionChargeQty > 0 ? (price?.price || '') : 0;
|
||
const desc = invBuildRevisionItemDescription(rev);
|
||
setInvItems(prev => prev.length === 1 && !prev[0].description && !prev[0].unit_price ? [invNewItem(desc, unitPrice, qty, rev.task_id, rev.id)] : [...prev, invNewItem(desc, unitPrice, qty, rev.task_id, rev.id)]);
|
||
};
|
||
const invUpdateItem = (id, field, val) => setInvItems(prev => prev.map(it => it.id === id ? { ...it, [field]: val } : it));
|
||
const invRemoveItem = (id) => setInvItems(prev => prev.filter(it => it.id !== id));
|
||
const invHandleDrop = (targetIdx) => {
|
||
if (invDragItem.current === null || invDragItem.current === targetIdx) { invDragItem.current = null; return; }
|
||
setInvItems(prev => { const next = [...prev]; const [moved] = next.splice(invDragItem.current, 1); next.splice(targetIdx, 0, moved); return next; });
|
||
invDragItem.current = null;
|
||
};
|
||
const invTotal = invItems.reduce((s, it) => s + (Number(it.quantity) || 0) * (Number(it.unit_price) || 0), 0);
|
||
const invSelectedCompany = invCompanies.find(c => c.id === invCompanyId);
|
||
|
||
const invClose = () => { setShowInvoiceForm(false); setInvCompanyId(''); setInvBillTo(''); setInvEmail(''); setInvRecipients([]); setInvUnbilledTasks([]); setInvUnbilledRevisions([]); setInvPriceList([]); setInvItems([invNewItem()]); setInvNotes(''); };
|
||
|
||
const invHandleSave = async (status) => {
|
||
if (!invCompanyId) return alert('Select a company.');
|
||
if (invItems.every(i => !i.description)) return alert('Add at least one line item.');
|
||
if (status === 'sent' && !invEmail.trim()) return alert('Enter an email recipient before sending.');
|
||
setInvSaving(true);
|
||
try {
|
||
const year = new Date().getFullYear();
|
||
const { count } = await supabase.from('invoices').select('*', { count: 'exact', head: true }).gte('created_at', `${year}-01-01`);
|
||
const invoiceNumber = `INV-${year}-${String((count || 0) + 1).padStart(3, '0')}`;
|
||
const { data: invoice, error } = await supabase.from('invoices').insert({ company_id: invCompanyId, invoice_number: invoiceNumber, invoice_date: INV_TODAY, due_date: INV_NET30, status: status === 'sent' ? 'draft' : status, bill_to: invBillTo || null, invoice_email: invEmail.trim() || null, notes: invNotes || null, total: invTotal, created_by: currentUser?.id }).select().single();
|
||
if (error || !invoice) throw error || new Error('Invoice not created.');
|
||
const validItems = invItems.filter(i => i.description);
|
||
if (validItems.length > 0) await supabase.from('invoice_items').insert(validItems.map(it => ({ invoice_id: invoice.id, task_id: it.task_id || null, submission_id: it.submission_id || null, description: it.description, quantity: Number(it.quantity) || 1, unit_price: Number(it.unit_price) || 0 })));
|
||
const taskIds = [...new Set(validItems.filter(i => i.task_id && !i.submission_id).map(i => i.task_id))];
|
||
if (taskIds.length > 0) await supabase.from('tasks').update({ invoiced: true, status: 'invoiced' }).in('id', taskIds);
|
||
const subIds = [...new Set(validItems.filter(i => i.submission_id).map(i => i.submission_id))];
|
||
if (subIds.length > 0) await supabase.from('submissions').update({ invoiced: true }).in('id', subIds);
|
||
if (status === 'sent') {
|
||
try {
|
||
const dueDate = new Date(INV_NET30).toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' });
|
||
const payUrl = `https://portal.fourgebranding.com/pay/${encodeURIComponent(invoiceNumber)}`;
|
||
const pdfItems = validItems.map(it => ({ description: it.description, quantity: Number(it.quantity) || 1, unit_price: Number(it.unit_price) || 0 }));
|
||
let attachments = [];
|
||
try { const pdf = await withTimeout(generateInvoicePDF({ ...invoice, status: 'sent' }, invSelectedCompany, pdfItems, { save: false }), 8000, 'PDF'); attachments = [await withTimeout(blobToEmailAttachment(pdf, `${invoiceNumber}.pdf`), 5000, 'Attachment')]; } catch { /* PDF attach failed — send email without attachment */ }
|
||
await withTimeout(sendEmail('invoice_sent', invEmail.trim(), { invoiceNumber, billTo: invBillTo || invSelectedCompany?.name, total: `$${invTotal.toFixed(2)}`, dueDate, payUrl, notes: invNotes || '' }, attachments), 12000, 'Email');
|
||
await supabase.from('invoices').update({ status: 'sent' }).eq('id', invoice.id);
|
||
} catch (e) { alert(`Invoice saved as draft — email failed: ${e.message}`); }
|
||
}
|
||
const createdInvoice = {
|
||
...invoice,
|
||
company: invSelectedCompany ? { id: invSelectedCompany.id, name: invSelectedCompany.name } : null,
|
||
};
|
||
setInvoices(prev => [createdInvoice, ...prev]);
|
||
invClose();
|
||
setViewingInvoice(createdInvoice);
|
||
} catch (e) { alert(`Failed to save invoice: ${e.message || 'Unknown error'}`); }
|
||
setInvSaving(false);
|
||
};
|
||
|
||
useEffect(() => {
|
||
async function load() {
|
||
const { data } = await supabase
|
||
.from('invoices')
|
||
.select('*, company:companies(name)')
|
||
.order('created_at', { ascending: false });
|
||
setInvoices(data || []);
|
||
writePageCache('team_invoices', { invoices: data || [] });
|
||
setLoading(false);
|
||
}
|
||
load();
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
async function loadExpenses() {
|
||
const [{ data, error }, { data: purchaseOrders, error: purchaseOrdersError }] = await Promise.all([
|
||
supabase.from('expenses').select('*').order('date', { ascending: false }),
|
||
supabase
|
||
.from('subcontractor_payments')
|
||
.select('*, profile:profiles!subcontractor_payments_profile_id_fkey(id, name, email), project:projects(id, name, company:companies(name)), items:subcontractor_po_items(*, task:tasks(id, title))')
|
||
.order('date', { ascending: false }),
|
||
]);
|
||
if (error) {
|
||
console.error('Failed to load expenses:', error);
|
||
setExpensesError(error.message || 'Failed to load expenses.');
|
||
setExpenses([]);
|
||
} else {
|
||
setExpenses(data || []);
|
||
setExpensesError('');
|
||
}
|
||
if (purchaseOrdersError) {
|
||
console.error('Failed to load subcontractor POs:', purchaseOrdersError);
|
||
setSubcontractorError(purchaseOrdersError.message || 'Failed to load subcontractor POs.');
|
||
setSubcontractorPOs([]);
|
||
} else {
|
||
setSubcontractorPOs(purchaseOrders || []);
|
||
setSubcontractorError('');
|
||
}
|
||
setExpensesLoading(false);
|
||
setSubcontractorLoading(false);
|
||
}
|
||
loadExpenses();
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
if (!expYearMenuOpen) return;
|
||
const handler = e => { if (expYearMenuRef.current && !expYearMenuRef.current.contains(e.target)) setExpYearMenuOpen(false); };
|
||
document.addEventListener('mousedown', handler);
|
||
return () => document.removeEventListener('mousedown', handler);
|
||
}, [expYearMenuOpen]);
|
||
|
||
useEffect(() => {
|
||
if (!invYearMenuOpen) return;
|
||
const handler = e => { if (invYearMenuRef.current && !invYearMenuRef.current.contains(e.target)) setInvYearMenuOpen(false); };
|
||
document.addEventListener('mousedown', handler);
|
||
return () => document.removeEventListener('mousedown', handler);
|
||
}, [invYearMenuOpen]);
|
||
|
||
useEffect(() => {
|
||
setExpensePreviewUrl(null);
|
||
setExpenseDetailEditing(false);
|
||
if (!viewingExpense?.receipt_path) return;
|
||
supabase.storage.from(RECEIPT_BUCKET).createSignedUrl(viewingExpense.receipt_path, 3600)
|
||
.then(({ data }) => { if (data?.signedUrl) setExpensePreviewUrl(data.signedUrl); });
|
||
}, [viewingExpense]);
|
||
|
||
useEffect(() => {
|
||
async function loadSubInvoices() {
|
||
const { data, error: err } = await supabase
|
||
.from('subcontractor_invoices')
|
||
.select('*, profile:profiles!subcontractor_invoices_profile_id_fkey(id, name, email), items:subcontractor_invoice_items(*)')
|
||
.order('created_at', { ascending: false });
|
||
if (err) { setSubInvoicesError(err.message); setSubInvoicesLoading(false); return; }
|
||
setSubInvoices(data || []);
|
||
setSubInvoicesLoading(false);
|
||
}
|
||
loadSubInvoices();
|
||
}, []);
|
||
|
||
|
||
const cancelExpenseEdit = () => {
|
||
setEditingExpenseId('');
|
||
setNewExpense(blankExpense());
|
||
setExpensesError('');
|
||
setShowExpenseForm(false);
|
||
};
|
||
|
||
const saveExpense = async () => {
|
||
if (!newExpense.description.trim() || !newExpense.amount) return;
|
||
setAddingExpense(true);
|
||
setExpensesError('');
|
||
try {
|
||
const existingExpense = editingExpenseId ? expenses.find(expense => expense.id === editingExpenseId) : null;
|
||
let receiptPath = existingExpense?.receipt_path || newExpense.receipt_path || null;
|
||
let receiptName = existingExpense?.receipt_name || newExpense.receipt_name || null;
|
||
if (newExpense.removeReceipt && existingExpense?.receipt_path) {
|
||
await supabase.storage.from(RECEIPT_BUCKET).remove([existingExpense.receipt_path]);
|
||
receiptPath = null; receiptName = null;
|
||
}
|
||
if (newExpense.receipt) {
|
||
const ext = getFileExt(newExpense.receipt.name);
|
||
receiptName = newExpense.receipt.name;
|
||
receiptPath = `${newExpense.date}/${Date.now()}-${crypto.randomUUID()}.${ext}`;
|
||
const { error: uploadError } = await supabase.storage.from(RECEIPT_BUCKET).upload(receiptPath, newExpense.receipt, { upsert: false });
|
||
if (uploadError) throw uploadError;
|
||
if (existingExpense?.receipt_path) await supabase.storage.from(RECEIPT_BUCKET).remove([existingExpense.receipt_path]);
|
||
}
|
||
const payload = { date: newExpense.date, description: newExpense.description.trim(), category: newExpense.category, amount: Number(newExpense.amount), notes: newExpense.notes.trim(), receipt_path: receiptPath, receipt_name: receiptName };
|
||
const query = editingExpenseId ? supabase.from('expenses').update(payload).eq('id', editingExpenseId) : supabase.from('expenses').insert(payload);
|
||
const { data, error } = await query.select().single();
|
||
if (error) { if (newExpense.receipt && receiptPath) await supabase.storage.from(RECEIPT_BUCKET).remove([receiptPath]); throw error; }
|
||
if (data) {
|
||
setExpenses(prev => editingExpenseId ? prev.map(expense => expense.id === editingExpenseId ? data : expense) : [data, ...prev]);
|
||
setNewExpense(blankExpense()); setEditingExpenseId(''); setShowExpenseForm(false);
|
||
}
|
||
} catch (error) {
|
||
console.error(`Failed to ${editingExpenseId ? 'update' : 'add'} expense:`, error);
|
||
setExpensesError(error.message || `Failed to ${editingExpenseId ? 'update' : 'add'} expense.`);
|
||
alert(`Failed to ${editingExpenseId ? 'update' : 'add'} expense: ${error.message || 'unknown error'}`);
|
||
}
|
||
setAddingExpense(false);
|
||
};
|
||
|
||
const handleAddExpense = async (e) => {
|
||
e.preventDefault();
|
||
await saveExpense();
|
||
};
|
||
|
||
const handleDeleteExpense = async (id) => {
|
||
if (!window.confirm('Delete this expense?')) return;
|
||
const expense = expenses.find(e => e.id === id);
|
||
if (expense?.receipt_path) {
|
||
await supabase.storage.from(RECEIPT_BUCKET).remove([expense.receipt_path]);
|
||
}
|
||
await supabase.from('expenses').delete().eq('id', id);
|
||
setExpenses(prev => prev.filter(e => e.id !== id));
|
||
};
|
||
|
||
const handleViewReceipt = async (expense) => {
|
||
if (!expense.receipt_path) return;
|
||
const win = window.open('', '_blank', 'noopener,noreferrer');
|
||
const { data, error } = await supabase.storage.from(RECEIPT_BUCKET).createSignedUrl(expense.receipt_path, 3600);
|
||
if (error || !data?.signedUrl) {
|
||
win?.close();
|
||
const message = error?.message || 'Failed to open receipt.';
|
||
setExpensesError(message);
|
||
alert(message);
|
||
return;
|
||
}
|
||
win.location.href = data.signedUrl;
|
||
};
|
||
|
||
const updateSubcontractorPO = async (po, updates, errorLabel = 'Failed to update PO') => {
|
||
const { data, error } = await supabase
|
||
.from('subcontractor_payments')
|
||
.update(updates)
|
||
.eq('id', po.id)
|
||
.select('*, profile:profiles!subcontractor_payments_profile_id_fkey(id, name, email), project:projects(id, name, company:companies(name)), items:subcontractor_po_items(*, task:tasks(id, title))')
|
||
.single();
|
||
if (error) {
|
||
alert(`${errorLabel}: ${error.message}`);
|
||
return null;
|
||
}
|
||
if (data) {
|
||
setSubcontractorPOs(prev => prev.map(row => row.id === po.id ? data : row));
|
||
setSelectedSubcontractorPOId(current => current === po.id ? data.id : current);
|
||
}
|
||
return data;
|
||
};
|
||
|
||
const handleSendSubcontractorPO = async (po) => {
|
||
await updateSubcontractorPO(po, { status: 'sent', sent_at: new Date().toISOString() }, 'Failed to send PO');
|
||
};
|
||
|
||
const handleReadyToPaySubcontractorPO = (po) => {
|
||
updateSubcontractorPO(po, { status: 'ready_to_pay' }, 'Failed to mark ready to pay');
|
||
};
|
||
|
||
const handleMarkSubcontractorPaid = async (po) => {
|
||
const paidAt = new Date().toISOString().slice(0, 10);
|
||
updateSubcontractorPO(po, { status: 'paid', paid_at: paidAt }, 'Failed to mark as paid');
|
||
};
|
||
|
||
const handleMarkSubInvoicePaid = async (invoice) => {
|
||
setMarkingPaid(invoice.id);
|
||
try {
|
||
const paidAt = new Date().toISOString();
|
||
const { error } = await supabase.from('subcontractor_invoices').update({ status: 'paid', paid_at: paidAt }).eq('id', invoice.id);
|
||
if (error) throw error;
|
||
const items = invoice.items || [];
|
||
const total = items.reduce((s, i) => s + Number(i.unit_price || 0) * Number(i.quantity || 1), 0);
|
||
let pdfBlob = null;
|
||
try {
|
||
pdfBlob = await generateSubcontractorPOPDF({
|
||
po_number: invoice.invoice_number,
|
||
status: 'paid',
|
||
profile: invoice.profile,
|
||
project: { name: 'Subcontractor Invoice', company: { name: 'Fourge Branding' } },
|
||
date: invoice.created_at?.split('T')[0],
|
||
due_date: paidAt.split('T')[0],
|
||
amount: total,
|
||
description: 'Payment for completed subcontractor work.',
|
||
notes: invoice.notes,
|
||
items: items.map((item, idx) => ({
|
||
description: item.description,
|
||
amount: Number(item.unit_price) * Number(item.quantity || 1),
|
||
sort_order: item.sort_order ?? idx,
|
||
})),
|
||
}, { output: 'blob' });
|
||
} catch (pdfErr) {
|
||
console.error('PDF generation failed:', pdfErr);
|
||
}
|
||
if (invoice.profile?.email) {
|
||
try {
|
||
const attachments = pdfBlob ? [await blobToEmailAttachment(pdfBlob, `${invoice.invoice_number}-receipt.pdf`)] : [];
|
||
await sendEmail('receipt_sent', invoice.profile.email, {
|
||
invoiceNumber: invoice.invoice_number,
|
||
billTo: invoice.profile.name || invoice.profile.email,
|
||
total: total.toFixed(2),
|
||
paidDate: new Date(paidAt).toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' }),
|
||
}, attachments);
|
||
} catch (emailErr) {
|
||
console.error('Email failed:', emailErr);
|
||
alert(`Marked paid, but email failed: ${emailErr.message || 'unknown error'}`);
|
||
}
|
||
}
|
||
setSubInvoices(prev => prev.map(i => i.id === invoice.id ? { ...i, status: 'paid', paid_at: paidAt } : i));
|
||
} catch (err) {
|
||
alert(`Failed to mark as paid: ${err.message}`);
|
||
}
|
||
setMarkingPaid('');
|
||
};
|
||
|
||
const handleDownloadSubInvoiceReceipt = async (invoice) => {
|
||
const items = invoice.items || [];
|
||
const total = items.reduce((s, i) => s + Number(i.unit_price || 0) * Number(i.quantity || 1), 0);
|
||
try {
|
||
await generateSubcontractorPOPDF({
|
||
po_number: invoice.invoice_number,
|
||
status: 'paid',
|
||
profile: invoice.profile,
|
||
project: { name: 'Subcontractor Invoice', company: { name: 'Fourge Branding' } },
|
||
date: invoice.created_at?.split('T')[0],
|
||
due_date: (invoice.paid_at || new Date().toISOString()).split('T')[0],
|
||
amount: total,
|
||
description: 'Payment for completed subcontractor work.',
|
||
notes: invoice.notes,
|
||
items: items.map((item, idx) => ({
|
||
description: item.description,
|
||
amount: Number(item.unit_price) * Number(item.quantity || 1),
|
||
sort_order: item.sort_order ?? idx,
|
||
})),
|
||
});
|
||
} catch (err) {
|
||
alert(`Failed to download receipt: ${err.message || 'unknown error'}`);
|
||
}
|
||
};
|
||
|
||
const handleDownloadSubInvoice = async (invoice) => {
|
||
const items = invoice.items || [];
|
||
const total = items.reduce((s, i) => s + Number(i.unit_price || 0) * Number(i.quantity || 1), 0);
|
||
try {
|
||
await generateSubcontractorPOPDF({
|
||
po_number: invoice.invoice_number,
|
||
status: invoice.status || 'draft',
|
||
profile: invoice.profile,
|
||
project: { name: 'Subcontractor Invoice', company: { name: 'Fourge Branding' } },
|
||
date: invoice.created_at?.split('T')[0],
|
||
due_date: (invoice.paid_at || invoice.submitted_at || invoice.created_at || new Date().toISOString()).split('T')[0],
|
||
amount: total,
|
||
description: invoice.status === 'paid' ? 'Payment for completed subcontractor work.' : 'Subcontractor invoice for completed work.',
|
||
notes: invoice.notes,
|
||
items: items.map((item, idx) => ({
|
||
description: item.description,
|
||
amount: Number(item.unit_price || 0) * Number(item.quantity || 1),
|
||
sort_order: item.sort_order ?? idx,
|
||
})),
|
||
});
|
||
} catch (err) {
|
||
alert(`Failed to download invoice: ${err.message || 'unknown error'}`);
|
||
}
|
||
};
|
||
|
||
const handleReopenSubcontractorPO = async (po) => {
|
||
updateSubcontractorPO(po, { status: 'draft', paid_at: null, cancelled_at: null }, 'Failed to reopen PO');
|
||
};
|
||
|
||
const handleCancelSubcontractorPO = async (po) => {
|
||
if (!window.confirm(`Cancel ${po.po_number || 'this PO'}?`)) return;
|
||
updateSubcontractorPO(po, { status: 'cancelled', cancelled_at: new Date().toISOString() }, 'Failed to cancel PO');
|
||
};
|
||
|
||
const handleDeleteSubcontractorPO = async (id) => {
|
||
if (!window.confirm('Delete this subcontractor PO?')) return;
|
||
await supabase.from('subcontractor_payments').delete().eq('id', id);
|
||
setSubcontractorPOs(prev => prev.filter(po => po.id !== id));
|
||
setSelectedSubcontractorPOId(current => current === id ? '' : current);
|
||
};
|
||
|
||
const handleDownloadSubcontractorPO = async (po) => {
|
||
try {
|
||
await generateSubcontractorPOPDF(po);
|
||
} catch (error) {
|
||
console.error('Failed to download subcontractor PO:', error);
|
||
alert(`Failed to download PO: ${error.message || 'unknown error'}`);
|
||
}
|
||
};
|
||
|
||
const handleCPAExport = async () => {
|
||
setExporting(true);
|
||
try {
|
||
const yearInvoices = invoices.filter(inv =>
|
||
inv.status === 'paid' && new Date(inv.invoice_date).getFullYear() === exportYear
|
||
);
|
||
const yearExpenses = expenses.filter(exp =>
|
||
new Date(exp.date).getFullYear() === exportYear
|
||
);
|
||
const yearSubcontractorExpenses = subcontractorPOs
|
||
.filter(po => po.status === 'paid' && new Date(po.paid_at || po.date).getFullYear() === exportYear)
|
||
.map(po => ({
|
||
date: po.paid_at || po.date,
|
||
category: 'Contractor',
|
||
description: `Subcontractor: ${po.profile?.name || 'External'} — ${po.items?.length ? po.items.map(item => item.description).join('; ') : po.description}`,
|
||
amount: po.amount,
|
||
notes: [po.po_number, po.project?.name, po.notes || po.profile?.email || ''].filter(Boolean).join(' · '),
|
||
}));
|
||
const exportExpenses = [...yearExpenses, ...yearSubcontractorExpenses];
|
||
if (!yearInvoices.length && !exportExpenses.length) {
|
||
alert(`No data found for ${exportYear}.`);
|
||
return;
|
||
}
|
||
const { data: allItems } = yearInvoices.length ? await supabase
|
||
.from('invoice_items')
|
||
.select('invoice_id, description')
|
||
.in('invoice_id', yearInvoices.map(i => i.id)) : { data: [] };
|
||
const itemsByInvoice = (allItems || []).reduce((acc, item) => {
|
||
(acc[item.invoice_id] = acc[item.invoice_id] || []).push(item);
|
||
return acc;
|
||
}, {});
|
||
await exportCPAPackage(yearInvoices, itemsByInvoice, exportExpenses, exportYear);
|
||
} finally {
|
||
setExporting(false);
|
||
}
|
||
};
|
||
|
||
const paidYears = [...new Set(
|
||
invoices.filter(i => i.status === 'paid').map(i => new Date(i.invoice_date).getFullYear())
|
||
)].sort((a, b) => b - a);
|
||
|
||
const companyNames = [...new Set(invoices.map(inv => inv.company?.name).filter(Boolean))].sort();
|
||
const filtered = invoices.filter(inv => {
|
||
if (filter !== 'all' && inv.status !== filter) return false;
|
||
if (filterCompany && inv.company?.name !== filterCompany) return false;
|
||
return true;
|
||
});
|
||
|
||
const totals = {
|
||
all: invoices.reduce((s, i) => s + Number(i.total), 0),
|
||
draft: invoices.filter(i => i.status === 'draft').reduce((s, i) => s + Number(i.total), 0),
|
||
sent: invoices.filter(i => i.status === 'sent').reduce((s, i) => s + Number(i.total), 0),
|
||
paid: invoices.filter(i => i.status === 'paid').reduce((s, i) => s + Number(i.total), 0),
|
||
netReceived: invoices.filter(i => i.status === 'paid').reduce((s, i) => s + Number(i.total) - Number(i.stripe_fee || 0), 0),
|
||
};
|
||
|
||
const expenseYears = [...new Set(expenses.map(e => e.date?.slice(0, 4)).filter(Boolean))].sort((a, b) => b - a);
|
||
const filteredExpenses = expenses.filter(e => {
|
||
if (expYearFilter !== 'all' && e.date?.slice(0, 4) !== expYearFilter) return false;
|
||
if (expenseFilter !== 'all' && e.category !== expenseFilter) return false;
|
||
return true;
|
||
});
|
||
const paidSubcontractorPOs = subcontractorPOs.filter(po => po.status === 'paid');
|
||
const payableSubcontractorPOs = subcontractorPOs.filter(po => ['approved', 'ready_to_pay'].includes(po.status));
|
||
const selectedSubcontractorPO = subcontractorPOs.find(po => po.id === selectedSubcontractorPOId);
|
||
const subInvoiceItemTotal = (inv) => (inv.items || []).reduce((a, x) => a + Number(x.unit_price || 0) * Number(x.quantity || 1), 0);
|
||
const paidSubInvoices = subInvoices.filter(i => i.status === 'paid');
|
||
const totalPaidSubInvoices = paidSubInvoices.reduce((s, i) => s + subInvoiceItemTotal(i), 0);
|
||
const totalPaidSubcontractors = paidSubcontractorPOs.reduce((s, po) => s + Number(po.amount), 0) + totalPaidSubInvoices;
|
||
const totalPayableSubcontractorPOs = payableSubcontractorPOs.reduce((s, po) => s + Number(po.amount), 0);
|
||
const totalPayableSubInvoices = subInvoices.filter(i => i.status === 'submitted').reduce((s, i) => s + subInvoiceItemTotal(i), 0);
|
||
const totalPayableSubcontractors = totalPayableSubcontractorPOs + totalPayableSubInvoices;
|
||
const payableSubcontractorCount = payableSubcontractorPOs.length + subInvoices.filter(i => i.status === 'submitted').length;
|
||
const totalExpenses = filteredExpenses.reduce((s, e) => s + Number(e.amount), 0);
|
||
const currentYear = new Date().getFullYear();
|
||
const yearExpenses = expenses.filter(e => new Date(e.date).getFullYear() === currentYear);
|
||
const currentYearExpenseTotal = yearExpenses.reduce((s, e) => s + Number(e.amount), 0);
|
||
const currentYearPaidSubcontractorPOs = paidSubcontractorPOs
|
||
.filter(po => new Date(po.paid_at || po.date).getFullYear() === currentYear)
|
||
.reduce((s, po) => s + Number(po.amount), 0);
|
||
const currentYearPaidSubInvoices = paidSubInvoices
|
||
.filter(i => new Date(i.paid_at).getFullYear() === currentYear)
|
||
.reduce((s, i) => s + subInvoiceItemTotal(i), 0);
|
||
const currentYearTotalExpenses = currentYearExpenseTotal + currentYearPaidSubcontractorPOs + currentYearPaidSubInvoices;
|
||
const revenue = totals.paid;
|
||
const profit = totals.netReceived - expenses.reduce((s, e) => s + Number(e.amount), 0) - totalPaidSubcontractors;
|
||
|
||
const chartYear = exportYear;
|
||
const chartData = useMemo(() => MONTHS.map((month, mi) => {
|
||
const paidInvs = invoices.filter(inv => inv.status === 'paid' && new Date(inv.invoice_date).getFullYear() === chartYear && new Date(inv.invoice_date).getMonth() === mi);
|
||
const paid = paidInvs.reduce((s, inv) => s + Number(inv.total || 0), 0);
|
||
const netPaid = paidInvs.reduce((s, inv) => s + Number(inv.total || 0) - Number(inv.stripe_fee || 0), 0);
|
||
const outstanding = invoices
|
||
.filter(inv => inv.status === 'sent' && new Date(inv.invoice_date).getFullYear() === chartYear && new Date(inv.invoice_date).getMonth() === mi)
|
||
.reduce((s, inv) => s + Number(inv.total || 0), 0);
|
||
const exp = expenses
|
||
.filter(e => new Date(e.date).getFullYear() === chartYear && new Date(e.date).getMonth() === mi)
|
||
.reduce((s, e) => s + Number(e.amount || 0), 0);
|
||
const subExp = subInvoices
|
||
.filter(i => i.status === 'paid' && new Date(i.created_at).getFullYear() === chartYear && new Date(i.created_at).getMonth() === mi)
|
||
.reduce((s, i) => s + (i.items || []).reduce((a, x) => a + Number(x.unit_price || 0) * Number(x.quantity || 1), 0), 0);
|
||
const totalExp = exp + subExp;
|
||
return { month, Revenue: +paid.toFixed(2), Outstanding: +outstanding.toFixed(2), Expenses: +totalExp.toFixed(2), Profit: +(netPaid - totalExp).toFixed(2) };
|
||
}), [invoices, expenses, subInvoices, chartYear]);
|
||
|
||
|
||
return (
|
||
<Layout loading={pageLoading}>
|
||
<div style={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
|
||
{/* Top row: 60% chart + 40% stat cards */}
|
||
{(() => {
|
||
const yr = chartData.reduce((s, m) => s + m.Revenue, 0);
|
||
const yp = chartData.reduce((s, m) => s + m.Profit, 0);
|
||
const ye = chartData.reduce((s, m) => s + m.Expenses, 0);
|
||
const yo = chartData.reduce((s, m) => s + m.Outstanding, 0);
|
||
const fmt = v => v >= 100000 ? `$${(v/1000).toLocaleString('en-US')}k` : `$${v.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
|
||
const CARD = { background: 'var(--card-bg)', border: '1px solid var(--border)', borderRadius: 8, padding: '18px 21px', backdropFilter: 'blur(12px)', WebkitBackdropFilter: 'blur(12px)' };
|
||
const StatCard = ({ label, value, sub, iconBg, iconColor, iconSvg }) => (
|
||
<div style={{ ...CARD, display: 'flex', alignItems: 'stretch', gap: 21, minHeight: 120 }}>
|
||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column' }}>
|
||
<div style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase', marginBottom: 5 }}>{label}</div>
|
||
<div style={{ flex: 1, display: 'flex', alignItems: 'center' }}>
|
||
<div style={{ fontSize: 30, fontWeight: 400, color: 'var(--text-primary)', letterSpacing: -0.5, lineHeight: 1.1 }}>{value}</div>
|
||
</div>
|
||
{sub && <div style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: 5 }}>{sub}</div>}
|
||
</div>
|
||
<div style={{ flexShrink: 0, display: 'flex', flexDirection: 'column', alignItems: 'flex-end', justifyContent: 'flex-start' }}>
|
||
<div style={{ width: 27, height: 27, borderRadius: '50%', background: iconBg, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke={iconColor} strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">{iconSvg}</svg>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
return (
|
||
<div style={{ display: 'grid', gridTemplateColumns: '3fr 2fr', gap: 24, flexShrink: 0 }}>
|
||
<div style={{ ...CARD }}>
|
||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 16 }}>
|
||
<span style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase' }}>Revenue Overview</span>
|
||
<select className="filter-select" value={chartYear} onChange={e => setExportYear(Number(e.target.value))} style={{ width: 90, borderRadius: 8, fontSize: 11, fontWeight: 500 }}>
|
||
{(paidYears.length > 0 ? paidYears : [new Date().getFullYear()]).map(y => <option key={y} value={y}>{y}</option>)}
|
||
</select>
|
||
</div>
|
||
<ResponsiveContainer width="100%" height={160}>
|
||
<AreaChart data={chartData} margin={{ top: 4, right: 8, left: 0, bottom: 0 }}>
|
||
<defs>
|
||
<linearGradient id="gc2Revenue" x1="0" y1="0" x2="0" y2="1"><stop offset="5%" stopColor="#F5A523" stopOpacity={0.25}/><stop offset="95%" stopColor="#F5A523" stopOpacity={0}/></linearGradient>
|
||
<linearGradient id="gc2Profit" x1="0" y1="0" x2="0" y2="1"><stop offset="5%" stopColor="#4ade80" stopOpacity={0.25}/><stop offset="95%" stopColor="#4ade80" stopOpacity={0}/></linearGradient>
|
||
<linearGradient id="gc2Expenses" x1="0" y1="0" x2="0" y2="1"><stop offset="5%" stopColor="#ef4444" stopOpacity={0.2}/><stop offset="95%" stopColor="#ef4444" stopOpacity={0}/></linearGradient>
|
||
<linearGradient id="gc2Outstanding" x1="0" y1="0" x2="0" y2="1"><stop offset="5%" stopColor="#60a5fa" stopOpacity={0.2}/><stop offset="95%" stopColor="#60a5fa" stopOpacity={0}/></linearGradient>
|
||
</defs>
|
||
<CartesianGrid strokeDasharray="3 3" stroke="var(--border)" vertical={false} />
|
||
<XAxis dataKey="month" tick={{ fontSize: 11, fill: 'var(--text-muted)' }} axisLine={false} tickLine={false} />
|
||
<YAxis tick={{ fontSize: 11, fill: 'var(--text-muted)' }} axisLine={false} tickLine={false} tickFormatter={v => `$${v >= 1000 ? (v/1000).toFixed(0)+'k' : v}`} width={45} />
|
||
<Tooltip content={<FinancesChartTooltip year={chartYear} />} />
|
||
<Legend wrapperStyle={{ fontSize: 11, paddingTop: 8 }} />
|
||
<Area type="monotone" dataKey="Revenue" stroke="#F5A523" strokeWidth={2} fill="url(#gc2Revenue)" dot={false} activeDot={{ r: 4 }} />
|
||
<Area type="monotone" dataKey="Profit" stroke="#4ade80" strokeWidth={2} fill="url(#gc2Profit)" dot={false} activeDot={{ r: 4 }} />
|
||
<Area type="monotone" dataKey="Expenses" stroke="#ef4444" strokeWidth={2} fill="url(#gc2Expenses)" dot={false} activeDot={{ r: 4 }} />
|
||
<Area type="monotone" dataKey="Outstanding" stroke="#60a5fa" strokeWidth={2} fill="url(#gc2Outstanding)" dot={false} activeDot={{ r: 4 }} />
|
||
</AreaChart>
|
||
</ResponsiveContainer>
|
||
</div>
|
||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 24, alignContent: 'start' }}>
|
||
<StatCard label="Revenue" value={fmt(yr)} sub={`${chartYear} · paid invoices`} iconBg="rgba(245,165,35,0.15)" iconColor="#F5A523" iconSvg={<><line x1="12" y1="19" x2="12" y2="5"/><polyline points="5,12 12,5 19,12"/></>} />
|
||
<StatCard label="Outstanding" value={fmt(yo)} sub={`${chartYear} · sent & unpaid`} iconBg="rgba(96,165,250,0.15)" iconColor="#60a5fa" iconSvg={<><circle cx="12" cy="12" r="8"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></>} />
|
||
<StatCard label="Expenses" value={fmt(ye)} sub={`${chartYear} · total spend`} iconBg="rgba(239,68,68,0.15)" iconColor="#ef4444" iconSvg={<><line x1="12" y1="5" x2="12" y2="19"/><polyline points="19,12 12,19 5,12"/></>} />
|
||
<StatCard label="Net Profit" value={fmt(yp)} sub={`${chartYear} · after expenses`} iconBg="rgba(74,222,128,0.15)" iconColor="#4ade80" iconSvg={<><polyline points="4,13 9,18 20,7"/></>} />
|
||
</div>
|
||
</div>
|
||
);
|
||
})()}
|
||
|
||
<div style={{ display: 'grid', gridTemplateColumns: '7fr 3fr', gap: 24, marginTop: 24, marginBottom: 10, flexShrink: 0, alignItems: 'center' }}>
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: 4, minHeight: 'var(--btn-height)' }}>
|
||
{[
|
||
{ id: 'overview', label: 'Overview' },
|
||
{ id: 'expenses', label: 'Expenses' },
|
||
{ id: 'subcontractors', label: 'Subcontractors' },
|
||
{ id: 'invoices', label: 'Invoices' },
|
||
].map(t => (
|
||
<button
|
||
key={t.id}
|
||
type="button"
|
||
onClick={() => setFinanceTab(t.id)}
|
||
className={`section-tab-btn${financeTab === t.id ? ' is-active' : ''}`}
|
||
>{t.label}</button>
|
||
))}
|
||
{financeTab === 'expenses' && (
|
||
<div style={{ marginLeft: 'auto', display: 'flex', alignItems: 'center', gap: 8 }}>
|
||
<button className="btn btn-outline" onClick={() => { setEditingExpenseId(''); setNewExpense(blankExpense()); setExpensesError(''); setShowExpenseForm(true); }}>+ Expense</button>
|
||
<div style={{ position: 'relative', display: 'flex', alignItems: 'center' }} ref={expYearMenuRef}>
|
||
<button className="btn btn-outline" onClick={() => setExpYearMenuOpen(o => !o)} style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }} title="Filter by year">
|
||
<svg viewBox="0 0 16 16" width="13" height="13" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M2 4h12M4 8h8M6 12h4" /></svg>
|
||
</button>
|
||
{expYearMenuOpen && (
|
||
<div className="site-header-avatar-menu" style={{ position: 'absolute', top: 'calc(100% + 4px)', right: 0, zIndex: 220, minWidth: 130 }}>
|
||
<button onClick={() => { setExpYearFilter('all'); setExpYearMenuOpen(false); }} className="site-header-avatar-item" style={{ background: expYearFilter === 'all' ? 'rgba(245,165,35,0.08)' : 'transparent', color: expYearFilter === 'all' ? 'var(--accent)' : 'var(--text-primary)' }}>All Years</button>
|
||
{expenseYears.map(y => (
|
||
<button key={y} onClick={() => { setExpYearFilter(y); setExpYearMenuOpen(false); }} className="site-header-avatar-item" style={{ background: expYearFilter === y ? 'rgba(245,165,35,0.08)' : 'transparent', color: expYearFilter === y ? 'var(--accent)' : 'var(--text-primary)' }}>{y}</button>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
{financeTab === 'invoices' && (
|
||
<div style={{ marginLeft: 'auto', display: 'flex', alignItems: 'center', gap: 8 }}>
|
||
<button className="btn btn-outline" onClick={() => setShowInvoiceForm(true)}>+ Invoice</button>
|
||
<div style={{ position: 'relative', display: 'flex', alignItems: 'center' }} ref={invYearMenuRef}>
|
||
<button className="btn btn-outline" onClick={() => setInvYearMenuOpen(o => !o)} style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }} title="Filter by year">
|
||
<svg viewBox="0 0 16 16" width="13" height="13" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M2 4h12M4 8h8M6 12h4" /></svg>
|
||
</button>
|
||
{invYearMenuOpen && (
|
||
<div className="site-header-avatar-menu" style={{ position: 'absolute', top: 'calc(100% + 4px)', right: 0, zIndex: 220, minWidth: 150 }}>
|
||
<div style={{ fontSize: 10, fontWeight: 500, textTransform: 'uppercase', letterSpacing: 0.8, color: 'var(--text-muted)', padding: '6px 14px 4px' }}>Year</div>
|
||
<button onClick={() => setInvYearFilter('all')} className="site-header-avatar-item" style={{ background: invYearFilter === 'all' ? 'rgba(245,165,35,0.08)' : 'transparent', color: invYearFilter === 'all' ? 'var(--accent)' : 'var(--text-primary)' }}>All Years</button>
|
||
{[...new Set(invoices.map(i => i.invoice_date?.slice(0,4)).filter(Boolean))].sort((a,b) => b-a).map(y => (
|
||
<button key={y} onClick={() => setInvYearFilter(y)} className="site-header-avatar-item" style={{ background: invYearFilter === y ? 'rgba(245,165,35,0.08)' : 'transparent', color: invYearFilter === y ? 'var(--accent)' : 'var(--text-primary)' }}>{y}</button>
|
||
))}
|
||
<div style={{ borderTop: '1px solid var(--border)', margin: '4px 0' }} />
|
||
<div style={{ fontSize: 10, fontWeight: 500, textTransform: 'uppercase', letterSpacing: 0.8, color: 'var(--text-muted)', padding: '4px 14px 4px' }}>Company</div>
|
||
<button onClick={() => setInvCompanyFilter('all')} className="site-header-avatar-item" style={{ background: invCompanyFilter === 'all' ? 'rgba(245,165,35,0.08)' : 'transparent', color: invCompanyFilter === 'all' ? 'var(--accent)' : 'var(--text-primary)' }}>All Companies</button>
|
||
{[...new Set(invoices.map(i => i.company?.name || i.bill_to).filter(Boolean))].sort().map(c => (
|
||
<button key={c} onClick={() => setInvCompanyFilter(c)} className="site-header-avatar-item" style={{ background: invCompanyFilter === c ? 'rgba(245,165,35,0.08)' : 'transparent', color: invCompanyFilter === c ? 'var(--accent)' : 'var(--text-primary)' }}>{c}</button>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
<div />
|
||
</div>
|
||
|
||
{financeTab === 'overview' && (() => {
|
||
const CARD = { background: 'var(--card-bg)', border: '1px solid var(--border)', borderRadius: 8, padding: '18px 21px', backdropFilter: 'blur(12px)', WebkitBackdropFilter: 'blur(12px)' };
|
||
const TH = { fontSize: 10, fontWeight: 500, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: 0.6, padding: '0 0 12px 0', border: 'none', background: 'transparent', textAlign: 'left' };
|
||
const TD = { fontSize: 13, color: 'var(--text-primary)', padding: '5px 0', border: 'none', background: 'transparent', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' };
|
||
const sortRows = (arr, key, dir, getter) => [...arr].sort((a, b) => {
|
||
const av = getter(a, key), bv = getter(b, key);
|
||
if (av < bv) return dir === 'asc' ? -1 : 1;
|
||
if (av > bv) return dir === 'asc' ? 1 : -1;
|
||
return 0;
|
||
});
|
||
const fmtDate = d => d ? new Date(d).toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) : '—';
|
||
const fmtAmt = v => `$${Number(v).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
|
||
|
||
const now = new Date();
|
||
const curM = now.getMonth(), curY = now.getFullYear();
|
||
const stepMonth = (p, dir) => dir === -1
|
||
? (p.month === 0 ? { month: 11, year: p.year - 1 } : { month: p.month - 1, year: p.year })
|
||
: (p.month === 11 ? { month: 0, year: p.year + 1 } : { month: p.month + 1, year: p.year });
|
||
const MonthNav = ({ m, setter }) => {
|
||
const atCurrent = m.month === curM && m.year === curY;
|
||
return (
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: 0, flexShrink: 0 }}>
|
||
<button type="button" onClick={() => setter(p => stepMonth(p, -1))} style={{ background: 'none', border: 'none', padding: '0 6px', cursor: 'pointer', color: 'var(--text-secondary)', fontSize: 18, lineHeight: 1 }}>‹</button>
|
||
<button type="button" onClick={() => setter(p => stepMonth(p, 1))} disabled={atCurrent} style={{ background: 'none', border: 'none', padding: '0 6px', cursor: atCurrent ? 'default' : 'pointer', color: 'var(--text-secondary)', fontSize: 18, lineHeight: 1, opacity: atCurrent ? 0.25 : 1 }}>›</button>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
// Card 1: Expenses
|
||
const m1 = ovMonth1.month, y1 = ovMonth1.year;
|
||
const thisMonthExp = expenses.filter(e => e.date && parseInt(e.date.slice(0,4)) === y1 && parseInt(e.date.slice(5,7)) - 1 === m1);
|
||
const thisMonthTotal = thisMonthExp.reduce((s, e) => s + Number(e.amount || 0), 0);
|
||
const thisCatTotals = CATEGORIES.map(cat => ({ cat, total: thisMonthExp.filter(e => e.category === cat).reduce((s, e) => s + Number(e.amount || 0), 0) })).filter(c => c.total > 0).sort((a, b) => b.total - a.total).slice(0, 4);
|
||
|
||
// Card 2: Sub payments this month (paid sub invoices)
|
||
const ovInvTotal = inv => (inv.items || []).reduce((s, i) => s + Number(i.unit_price || 0) * Number(i.quantity || 1), 0);
|
||
const m2 = ovMonth2.month, y2 = ovMonth2.year;
|
||
const pendingSubs = [...subInvoices.filter(i => { const d = new Date(i.paid_at || i.submitted_at); return d.getFullYear() === y2 && d.getMonth() === m2; })].sort((a, b) => new Date(b.paid_at || b.submitted_at) - new Date(a.paid_at || a.submitted_at));
|
||
const pendingSubsTotal = pendingSubs.reduce((s, i) => s + ovInvTotal(i), 0);
|
||
|
||
// Card 3: All invoices issued this month
|
||
const m3 = ovMonth3.month, y3 = ovMonth3.year;
|
||
const monthInvoices = [...invoices.filter(i => i.invoice_date && parseInt(i.invoice_date.slice(0,4)) === y3 && parseInt(i.invoice_date.slice(5,7)) - 1 === m3)];
|
||
const outstandingTotal = monthInvoices.reduce((s, i) => s + Number(i.total || 0), 0);
|
||
|
||
const PIE_COLORS = ['#F5A523', '#4ade80', '#60a5fa', '#c084fc', '#f472b6', '#fb923c', '#34d399', '#a78bfa'];
|
||
|
||
// pie by subcontractor
|
||
const subPieData = Object.values(pendingSubs.reduce((acc, inv) => {
|
||
const name = inv.profile?.name || 'Unknown';
|
||
if (!acc[name]) acc[name] = { name, value: 0 };
|
||
acc[name].value += ovInvTotal(inv);
|
||
return acc;
|
||
}, {})).sort((a, b) => b.value - a.value);
|
||
|
||
// pie by company (outstanding invoices)
|
||
const invPieData = Object.values(monthInvoices.reduce((acc, inv) => {
|
||
const name = inv.company?.name || inv.bill_to || 'Unknown';
|
||
if (!acc[name]) acc[name] = { name, value: 0 };
|
||
acc[name].value += Number(inv.total || 0);
|
||
return acc;
|
||
}, {})).sort((a, b) => b.value - a.value);
|
||
|
||
const PieTooltipContent = ({ active, payload }) => {
|
||
if (!active || !payload?.length) return null;
|
||
return (
|
||
<div style={{ background: 'var(--card-bg)', border: '1px solid var(--border)', borderRadius: 4, padding: '8px 12px', fontSize: 12 }}>
|
||
<div style={{ color: payload[0].payload.fill, fontWeight: 500 }}>{payload[0].name}</div>
|
||
<div style={{ color: 'var(--text-primary)' }}>{fmtAmt(payload[0].value)}</div>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
const PieLegend = ({ data, colors }) => (
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, marginTop: 14 }}>
|
||
{data.map((d, i) => (
|
||
<div key={d.name} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8 }}>
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, minWidth: 0 }}>
|
||
<div style={{ width: 8, height: 8, borderRadius: '50%', background: colors[i % colors.length], flexShrink: 0 }} />
|
||
<span style={{ fontSize: 12, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{d.name}</span>
|
||
</div>
|
||
<span style={{ fontSize: 12, color: 'var(--text-primary)', fontVariantNumeric: 'tabular-nums', flexShrink: 0 }}>{fmtAmt(d.value)}</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
);
|
||
|
||
return (
|
||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 24, flex: 1, minHeight: 0 }}>
|
||
{/* Card 1: This Month Expenses by category */}
|
||
<div style={{ ...CARD, display: 'flex', flexDirection: 'column', minHeight: 0, overflowY: 'auto' }}>
|
||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 5, flexShrink: 0 }}>
|
||
<span style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase' }}>{MONTHS[m1]} {y1 !== curY ? y1 : ''} Expenses</span>
|
||
<MonthNav m={ovMonth1} setter={setOvMonth1} />
|
||
</div>
|
||
<div style={{ fontSize: 30, fontWeight: 400, letterSpacing: -0.5, lineHeight: 1.1, color: '#ef4444', fontVariantNumeric: 'tabular-nums', marginBottom: 14, flexShrink: 0 }}>{fmtAmt(thisMonthTotal)}</div>
|
||
<div style={{ position: 'relative' }}>
|
||
<ResponsiveContainer width="100%" height={260}>
|
||
<PieChart>
|
||
<Pie data={thisCatTotals.length ? thisCatTotals : [{ cat: 'None', total: 1 }]} dataKey="total" nameKey="cat" cx="50%" cy="50%" innerRadius={72} outerRadius={115} strokeWidth={0}>
|
||
{thisCatTotals.length ? thisCatTotals.map((_, i) => <Cell key={i} fill={PIE_COLORS[i % PIE_COLORS.length]} />) : <Cell fill="var(--border)" />}
|
||
</Pie>
|
||
{thisCatTotals.length > 0 && <Tooltip content={({ active, payload }) => {
|
||
if (!active || !payload?.length) return null;
|
||
return <div style={{ background: 'var(--card-bg)', border: '1px solid var(--border)', borderRadius: 4, padding: '8px 12px', fontSize: 12 }}><div style={{ color: payload[0].payload.fill, fontWeight: 500 }}>{payload[0].name}</div><div style={{ color: 'var(--text-primary)' }}>{fmtAmt(payload[0].value)}</div></div>;
|
||
}} />}
|
||
</PieChart>
|
||
</ResponsiveContainer>
|
||
<div style={{ position: 'absolute', top: '50%', left: '50%', transform: 'translate(-50%, -50%)', textAlign: 'center', pointerEvents: 'none' }}>
|
||
<div style={{ fontSize: 22, fontWeight: 500, color: 'var(--text-primary)', lineHeight: 1.1 }}>{thisMonthExp.length}</div>
|
||
<div style={{ fontSize: 10, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: 0.6 }}>items</div>
|
||
</div>
|
||
</div>
|
||
{thisCatTotals.length > 0 && <PieLegend data={thisCatTotals.map(c => ({ name: c.cat, value: c.total }))} colors={PIE_COLORS} />}
|
||
</div>
|
||
|
||
{/* Card 2: Pending Sub Payments by subcontractor */}
|
||
<div style={{ ...CARD, display: 'flex', flexDirection: 'column', minHeight: 0, overflowY: 'auto' }}>
|
||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 5, flexShrink: 0 }}>
|
||
<span style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase' }}>{MONTHS[m2]} {y2 !== curY ? y2 : ''} Sub Payments</span>
|
||
<MonthNav m={ovMonth2} setter={setOvMonth2} />
|
||
</div>
|
||
<div style={{ fontSize: 30, fontWeight: 400, letterSpacing: -0.5, lineHeight: 1.1, color: '#F5A523', fontVariantNumeric: 'tabular-nums', marginBottom: 14, flexShrink: 0 }}>{fmtAmt(pendingSubsTotal)}</div>
|
||
<div style={{ position: 'relative' }}>
|
||
<ResponsiveContainer width="100%" height={260}>
|
||
<PieChart>
|
||
<Pie data={subPieData.length ? subPieData : [{ name: 'None', value: 1 }]} dataKey="value" nameKey="name" cx="50%" cy="50%" innerRadius={72} outerRadius={115} strokeWidth={0}>
|
||
{subPieData.length ? subPieData.map((_, i) => <Cell key={i} fill={PIE_COLORS[i % PIE_COLORS.length]} />) : <Cell fill="var(--border)" />}
|
||
</Pie>
|
||
{subPieData.length > 0 && <Tooltip content={({ active, payload }) => {
|
||
if (!active || !payload?.length) return null;
|
||
return <div style={{ background: 'var(--card-bg)', border: '1px solid var(--border)', borderRadius: 4, padding: '8px 12px', fontSize: 12 }}><div style={{ color: payload[0].payload.fill, fontWeight: 500 }}>{payload[0].name}</div><div style={{ color: 'var(--text-primary)' }}>{fmtAmt(payload[0].value)}</div></div>;
|
||
}} />}
|
||
</PieChart>
|
||
</ResponsiveContainer>
|
||
<div style={{ position: 'absolute', top: '50%', left: '50%', transform: 'translate(-50%, -50%)', textAlign: 'center', pointerEvents: 'none' }}>
|
||
<div style={{ fontSize: 22, fontWeight: 500, color: 'var(--text-primary)', lineHeight: 1.1 }}>{pendingSubs.length}</div>
|
||
<div style={{ fontSize: 10, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: 0.6 }}>invoices</div>
|
||
</div>
|
||
</div>
|
||
{subPieData.length > 0 && <PieLegend data={subPieData} colors={PIE_COLORS} />}
|
||
</div>
|
||
|
||
{/* Card 3: Invoiced this month by company */}
|
||
<div style={{ ...CARD, display: 'flex', flexDirection: 'column', minHeight: 0, overflowY: 'auto' }}>
|
||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 5, flexShrink: 0 }}>
|
||
<span style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase' }}>{MONTHS[m3]} {y3 !== curY ? y3 : ''} Invoiced</span>
|
||
<MonthNav m={ovMonth3} setter={setOvMonth3} />
|
||
</div>
|
||
<div style={{ fontSize: 30, fontWeight: 400, letterSpacing: -0.5, lineHeight: 1.1, color: '#4ade80', fontVariantNumeric: 'tabular-nums', marginBottom: 14, flexShrink: 0 }}>{fmtAmt(outstandingTotal)}</div>
|
||
<div style={{ position: 'relative' }}>
|
||
<ResponsiveContainer width="100%" height={260}>
|
||
<PieChart>
|
||
<Pie data={invPieData.length ? invPieData : [{ name: 'None', value: 1 }]} dataKey="value" nameKey="name" cx="50%" cy="50%" innerRadius={72} outerRadius={115} strokeWidth={0}>
|
||
{invPieData.length ? invPieData.map((_, i) => <Cell key={i} fill={PIE_COLORS[i % PIE_COLORS.length]} />) : <Cell fill="var(--border)" />}
|
||
</Pie>
|
||
{invPieData.length > 0 && <Tooltip content={({ active, payload }) => {
|
||
if (!active || !payload?.length) return null;
|
||
return <div style={{ background: 'var(--card-bg)', border: '1px solid var(--border)', borderRadius: 4, padding: '8px 12px', fontSize: 12 }}><div style={{ color: payload[0].payload.fill, fontWeight: 500 }}>{payload[0].name}</div><div style={{ color: 'var(--text-primary)' }}>{fmtAmt(payload[0].value)}</div></div>;
|
||
}} />}
|
||
</PieChart>
|
||
</ResponsiveContainer>
|
||
<div style={{ position: 'absolute', top: '50%', left: '50%', transform: 'translate(-50%, -50%)', textAlign: 'center', pointerEvents: 'none' }}>
|
||
<div style={{ fontSize: 22, fontWeight: 500, color: 'var(--text-primary)', lineHeight: 1.1 }}>{monthInvoices.length}</div>
|
||
<div style={{ fontSize: 10, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: 0.6 }}>invoices</div>
|
||
</div>
|
||
</div>
|
||
{invPieData.length > 0 && <PieLegend data={invPieData} colors={PIE_COLORS} />}
|
||
</div>
|
||
</div>
|
||
);
|
||
})()}
|
||
|
||
{financeTab === 'expenses' && (() => {
|
||
const CARD = { background: 'var(--card-bg)', border: '1px solid var(--border)', borderRadius: 8, padding: '18px 21px', backdropFilter: 'blur(12px)', WebkitBackdropFilter: 'blur(12px)' };
|
||
const TH = { fontSize: 10, fontWeight: 500, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: 0.6, padding: '0 0 12px 0', border: 'none', background: 'transparent', textAlign: 'left' };
|
||
const TD = { fontSize: 13, color: 'var(--text-primary)', padding: '5px 0', border: 'none', background: 'transparent', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' };
|
||
const fmtDate = d => d ? new Date(d + 'T12:00:00').toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) : '—';
|
||
const fmtAmt = v => `$${Number(v).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
|
||
const sortedExpenses = expSort(filteredExpenses, (exp, key) => {
|
||
if (key === 'amount') return Number(exp.amount) || 0;
|
||
if (key === 'date') return exp.date || '';
|
||
return exp[key] || '';
|
||
});
|
||
const categoryTotals = CATEGORIES
|
||
.map(cat => ({ cat, total: filteredExpenses.filter(e => e.category === cat).reduce((s, e) => s + Number(e.amount || 0), 0) }))
|
||
.sort((a, b) => b.total - a.total);
|
||
const grandTotal = filteredExpenses.reduce((s, e) => s + Number(e.amount || 0), 0);
|
||
return (
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: 0, flex: 1, minHeight: 0 }}>
|
||
<div style={{ display: 'grid', gridTemplateColumns: '7fr 3fr', gap: 24, flex: 1, minHeight: 0 }}>
|
||
{/* Left: expense list */}
|
||
<div style={{ ...CARD, display: 'flex', flexDirection: 'column', minHeight: 0 }}>
|
||
{expenses.length === 0 ? <div className="card-empty-center">No expenses</div> : (
|
||
<div className="scrollbar-thin-theme table-scroll-hidden" style={{ flex: 1, minHeight: 0, overflowY: 'auto' }}>
|
||
<table className="table-sticky-head" style={{ width: '100%', borderCollapse: 'collapse', tableLayout: 'fixed' }}>
|
||
<colgroup>
|
||
<col style={{ width: '14%' }} />
|
||
<col style={{ width: '34%' }} />
|
||
<col style={{ width: '16%' }} />
|
||
<col style={{ width: '24%' }} />
|
||
<col style={{ width: '12%' }} />
|
||
</colgroup>
|
||
<thead><tr>
|
||
<SortTh col="date" sortKey={expSortKey} sortDir={expSortDir} onSort={expToggle} style={TH}>Date</SortTh>
|
||
<SortTh col="description" sortKey={expSortKey} sortDir={expSortDir} onSort={expToggle} style={TH}>Description</SortTh>
|
||
<SortTh col="category" sortKey={expSortKey} sortDir={expSortDir} onSort={expToggle} style={TH}>Category</SortTh>
|
||
<SortTh col="notes" sortKey={expSortKey} sortDir={expSortDir} onSort={expToggle} style={TH}>Notes</SortTh>
|
||
<SortTh col="amount" sortKey={expSortKey} sortDir={expSortDir} onSort={expToggle} style={{ ...TH, textAlign: 'right' }}>Amount</SortTh>
|
||
</tr></thead>
|
||
<tbody>{sortedExpenses.map(exp => (
|
||
<tr key={exp.id}>
|
||
<td style={{ ...TD, fontSize: 12 }}>{fmtDate(exp.date)}</td>
|
||
<td style={{ ...TD }}>
|
||
<button type="button" className="dashboard-inline-link" onClick={() => setViewingExpense(exp)} style={{ fontSize: 13, fontWeight: 400 }}>
|
||
{exp.description || '—'}
|
||
</button>
|
||
</td>
|
||
<td style={{ ...TD, fontSize: 12 }}>{exp.category || '—'}</td>
|
||
<td style={{ ...TD, fontSize: 12 }}>{exp.notes || '—'}</td>
|
||
<td style={{ ...TD, textAlign: 'right', color: '#ef4444' }}>{fmtAmt(exp.amount)}</td>
|
||
</tr>
|
||
))}</tbody>
|
||
</table>
|
||
</div>
|
||
)}
|
||
</div>
|
||
{/* Right: category breakdown */}
|
||
<div style={{ ...CARD, display: 'flex', flexDirection: 'column', minHeight: 0 }}>
|
||
<div style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase', marginBottom: 5, flexShrink: 0 }}>Category</div>
|
||
<div style={{ fontSize: 30, fontWeight: 400, letterSpacing: -0.5, lineHeight: 1.1, color: '#ef4444', fontVariantNumeric: 'tabular-nums', marginBottom: 14, flexShrink: 0 }}>{fmtAmt(grandTotal)}</div>
|
||
{categoryTotals.length === 0 ? <div className="card-empty-center">No expenses</div> : (
|
||
<div className="scrollbar-thin-theme table-scroll-hidden" style={{ flex: 1, minHeight: 0, overflowY: 'auto' }}>
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||
{categoryTotals.map(({ cat, total }) => {
|
||
const pct = grandTotal > 0 ? (total / grandTotal) * 100 : 0;
|
||
return (
|
||
<div key={cat}>
|
||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 5 }}>
|
||
<span style={{ fontSize: 12, color: 'var(--text-primary)' }}>{cat}</span>
|
||
<span style={{ fontSize: 12, color: '#ef4444', fontVariantNumeric: 'tabular-nums' }}>{fmtAmt(total)}</span>
|
||
</div>
|
||
<div style={{ height: 4, borderRadius: 2, background: 'var(--border)', overflow: 'hidden' }}>
|
||
<div style={{ height: '100%', width: `${pct}%`, background: '#ef4444', borderRadius: 2, opacity: 0.7, transition: 'width 400ms ease' }} />
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
})()}
|
||
|
||
{financeTab === 'subcontractors' && (() => {
|
||
const CARD = { background: 'var(--card-bg)', border: '1px solid var(--border)', borderRadius: 8, padding: '18px 21px', backdropFilter: 'blur(12px)', WebkitBackdropFilter: 'blur(12px)' };
|
||
const TH = { fontSize: 10, fontWeight: 500, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: 0.6, padding: '0 0 12px 0', border: 'none', background: 'transparent', textAlign: 'left' };
|
||
const TD = { fontSize: 13, color: 'var(--text-primary)', padding: '5px 0', border: 'none', background: 'transparent', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' };
|
||
const fmtDate = d => d ? new Date(d).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) : '—';
|
||
const fmtAmt = v => `$${Number(v).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
|
||
const subInvoiceStatusColor = { draft: 'not_started', submitted: 'in_progress', paid: 'client_approved' };
|
||
const invTotal = inv => (inv.items || []).reduce((s, i) => s + Number(i.unit_price || 0) * Number(i.quantity || 1), 0);
|
||
|
||
const yearFiltered = subInvoices;
|
||
|
||
const sortedInvoices = subSort(yearFiltered, (inv, key) => {
|
||
if (key === 'total') return invTotal(inv);
|
||
if (key === 'subcontractor') return inv.profile?.name || '';
|
||
if (key === 'submitted_at') return inv.submitted_at ? new Date(inv.submitted_at).getTime() : 0;
|
||
if (key === 'paid_at') return inv.paid_at ? new Date(inv.paid_at).getTime() : 0;
|
||
return inv[key] || '';
|
||
});
|
||
|
||
const bySubcontractor = Object.values(yearFiltered.reduce((acc, inv) => {
|
||
const key = inv.profile?.id || 'unknown';
|
||
if (!acc[key]) acc[key] = { name: inv.profile?.name || 'Unknown', invoices: [] };
|
||
acc[key].invoices.push(inv);
|
||
return acc;
|
||
}, {})).map(g => ({
|
||
...g,
|
||
total: g.invoices.reduce((s, i) => s + invTotal(i), 0),
|
||
paid: g.invoices.filter(i => i.status === 'paid').reduce((s, i) => s + invTotal(i), 0),
|
||
pending: g.invoices.filter(i => i.status === 'submitted').reduce((s, i) => s + invTotal(i), 0),
|
||
})).sort((a, b) => b.total - a.total);
|
||
|
||
const grandTotal = bySubcontractor.reduce((s, g) => s + g.total, 0);
|
||
|
||
return (
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: 0, flex: 1, minHeight: 0 }}>
|
||
<div style={{ display: 'grid', gridTemplateColumns: '7fr 3fr', gap: 24, flex: 1, minHeight: 0 }}>
|
||
{/* Left: invoice list */}
|
||
<div style={{ ...CARD, display: 'flex', flexDirection: 'column', minHeight: 0 }}>
|
||
{subInvoicesLoading ? (
|
||
<div style={{ fontSize: 13, color: 'var(--text-muted)' }}>Loading…</div>
|
||
) : subInvoicesError ? (
|
||
<div style={{ fontSize: 13, color: 'var(--danger)' }}>{subInvoicesError}</div>
|
||
) : yearFiltered.length === 0 ? (
|
||
<div className="card-empty-center">No invoices</div>
|
||
) : (
|
||
<div className="scrollbar-thin-theme table-scroll-hidden" style={{ flex: 1, minHeight: 0, overflowY: 'auto' }}>
|
||
<table className="table-sticky-head" style={{ width: '100%', borderCollapse: 'collapse', tableLayout: 'fixed' }}>
|
||
<colgroup>
|
||
<col style={{ width: '14%' }} />
|
||
<col style={{ width: '26%' }} />
|
||
<col style={{ width: '16%' }} />
|
||
<col style={{ width: '16%' }} />
|
||
<col style={{ width: '14%' }} />
|
||
<col style={{ width: '14%' }} />
|
||
</colgroup>
|
||
<thead><tr>
|
||
<SortTh col="invoice_number" sortKey={subSortKey} sortDir={subSortDir} onSort={subToggle} style={TH}>Invoice #</SortTh>
|
||
<SortTh col="subcontractor" sortKey={subSortKey} sortDir={subSortDir} onSort={subToggle} style={TH}>Subcontractor</SortTh>
|
||
<SortTh col="submitted_at" sortKey={subSortKey} sortDir={subSortDir} onSort={subToggle} style={TH}>Submitted</SortTh>
|
||
<SortTh col="paid_at" sortKey={subSortKey} sortDir={subSortDir} onSort={subToggle} style={TH}>Paid</SortTh>
|
||
<SortTh col="status" sortKey={subSortKey} sortDir={subSortDir} onSort={subToggle} style={TH}>Status</SortTh>
|
||
<SortTh col="total" sortKey={subSortKey} sortDir={subSortDir} onSort={subToggle} style={{ ...TH, textAlign: 'right' }}>Amount</SortTh>
|
||
</tr></thead>
|
||
<tbody>{sortedInvoices.map(inv => {
|
||
const total = invTotal(inv);
|
||
return (
|
||
<tr key={inv.id}>
|
||
<td style={{ ...TD }}>
|
||
<button type="button" className="table-link" onClick={() => setViewingSubInvoice(inv)}>
|
||
{inv.invoice_number || '—'}
|
||
</button>
|
||
</td>
|
||
<td style={{ ...TD }}>{inv.profile?.name || '—'}</td>
|
||
<td style={{ ...TD, fontSize: 12, color: 'var(--text-muted)' }}>{fmtDate(inv.submitted_at)}</td>
|
||
<td style={{ ...TD, fontSize: 12, color: 'var(--text-muted)' }}>{fmtDate(inv.paid_at)}</td>
|
||
<td style={{ ...TD }}>
|
||
<StatusBadge
|
||
status={subInvoiceStatusColor[inv.status] || 'not_started'}
|
||
label={inv.status ? inv.status.charAt(0).toUpperCase() + inv.status.slice(1) : '—'}
|
||
/>
|
||
</td>
|
||
<td style={{ ...TD, textAlign: 'right', color: inv.status === 'paid' ? '#4ade80' : inv.status === 'submitted' ? '#F5A523' : 'var(--text-primary)' }}>
|
||
{fmtAmt(total)}
|
||
</td>
|
||
</tr>
|
||
);
|
||
})}</tbody>
|
||
</table>
|
||
</div>
|
||
)}
|
||
</div>
|
||
{/* Right: by subcontractor */}
|
||
<div style={{ ...CARD, display: 'flex', flexDirection: 'column', minHeight: 0 }}>
|
||
<div style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase', marginBottom: 5, flexShrink: 0 }}>Subcontractors</div>
|
||
<div style={{ fontSize: 30, fontWeight: 400, letterSpacing: -0.5, lineHeight: 1.1, color: '#F5A523', fontVariantNumeric: 'tabular-nums', marginBottom: 14, flexShrink: 0 }}>{fmtAmt(grandTotal)}</div>
|
||
{bySubcontractor.length === 0 ? <div className="card-empty-center">No data</div> : (
|
||
<div className="scrollbar-thin-theme table-scroll-hidden" style={{ flex: 1, minHeight: 0, overflowY: 'auto' }}>
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||
{bySubcontractor.map(g => {
|
||
const pct = grandTotal > 0 ? (g.total / grandTotal) * 100 : 0;
|
||
return (
|
||
<div key={g.name}>
|
||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 5 }}>
|
||
<span style={{ fontSize: 12, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', maxWidth: '65%' }}>{g.name}</span>
|
||
<span style={{ fontSize: 12, color: '#F5A523', fontVariantNumeric: 'tabular-nums', flexShrink: 0 }}>{fmtAmt(g.total)}</span>
|
||
</div>
|
||
<div style={{ height: 4, borderRadius: 2, background: 'var(--border)', overflow: 'hidden' }}>
|
||
<div style={{ height: '100%', width: `${pct}%`, background: '#F5A523', borderRadius: 2, opacity: 0.7, transition: 'width 400ms ease' }} />
|
||
</div>
|
||
<div style={{ fontSize: 11, color: 'var(--text-muted)', marginTop: 3 }}><span style={{ color: g.pending > 0 ? '#F5A523' : 'var(--text-muted)' }}>{fmtAmt(g.pending)} pending</span> · {fmtAmt(g.paid)} paid</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
})()}
|
||
|
||
{financeTab === 'invoices' && (() => {
|
||
const CARD = { background: 'var(--card-bg)', border: '1px solid var(--border)', borderRadius: 8, padding: '18px 21px', backdropFilter: 'blur(12px)', WebkitBackdropFilter: 'blur(12px)' };
|
||
const TH = { fontSize: 10, fontWeight: 500, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: 0.6, padding: '0 0 12px 0', border: 'none', background: 'transparent', textAlign: 'left' };
|
||
const TD = { fontSize: 13, color: 'var(--text-primary)', padding: '5px 0', border: 'none', background: 'transparent', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' };
|
||
const fmtDate = d => d ? new Date(d).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) : '—';
|
||
const fmtAmt = v => `$${Number(v).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
|
||
const statusColor = { draft: 'not_started', sent: 'in_progress', paid: 'client_approved' };
|
||
|
||
const filteredInvoices = invoices.filter(i => {
|
||
if (invYearFilter !== 'all' && i.invoice_date?.slice(0,4) !== invYearFilter) return false;
|
||
if (invCompanyFilter !== 'all' && (i.company?.name || i.bill_to) !== invCompanyFilter) return false;
|
||
return true;
|
||
});
|
||
|
||
const sorted = invSort(filteredInvoices, (inv, key) => {
|
||
if (key === 'total' || key === 'stripe_fee') return Number(inv[key]) || 0;
|
||
if (key === 'invoice_date' || key === 'due_date') return new Date(inv[key]).getTime();
|
||
if (key === 'company') return inv.company?.name || '';
|
||
return inv[key] || '';
|
||
});
|
||
|
||
const byCompany = Object.values(filteredInvoices.reduce((acc, inv) => {
|
||
const name = inv.company?.name || inv.bill_to || 'Unknown';
|
||
if (!acc[name]) acc[name] = { name, total: 0, paid: 0, outstanding: 0, fees: 0 };
|
||
acc[name].total += Number(inv.total) || 0;
|
||
if (inv.status === 'paid') {
|
||
acc[name].paid += Number(inv.total) || 0;
|
||
acc[name].fees += Number(inv.stripe_fee) || 0;
|
||
} else if (inv.status === 'sent') acc[name].outstanding += Number(inv.total) || 0;
|
||
return acc;
|
||
}, {})).sort((a, b) => b.total - a.total);
|
||
|
||
const grandTotal = byCompany.reduce((s, g) => s + g.total, 0);
|
||
|
||
return (
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: 0, flex: 1, minHeight: 0 }}>
|
||
<div style={{ display: 'grid', gridTemplateColumns: '7fr 3fr', gap: 24, flex: 1, minHeight: 0 }}>
|
||
<div style={{ ...CARD, display: 'flex', flexDirection: 'column', minHeight: 0 }}>
|
||
{loading ? (
|
||
<div style={{ fontSize: 13, color: 'var(--text-muted)' }}>Loading…</div>
|
||
) : invoices.length === 0 ? (
|
||
<div className="card-empty-center">No invoices</div>
|
||
) : (
|
||
<div className="scrollbar-thin-theme table-scroll-hidden" style={{ flex: 1, minHeight: 0, overflowY: 'auto' }}>
|
||
<table className="table-sticky-head" style={{ width: '100%', borderCollapse: 'collapse', tableLayout: 'fixed' }}>
|
||
<colgroup>
|
||
<col style={{ width: '18%' }} />
|
||
<col style={{ width: '24%' }} />
|
||
<col style={{ width: '13%' }} />
|
||
<col style={{ width: '13%' }} />
|
||
<col style={{ width: '12%' }} />
|
||
<col style={{ width: '10%' }} />
|
||
<col style={{ width: '10%' }} />
|
||
</colgroup>
|
||
<thead><tr>
|
||
<SortTh col="invoice_number" sortKey={invSortKey} sortDir={invSortDir} onSort={invToggle} style={TH}>Invoice #</SortTh>
|
||
<SortTh col="company" sortKey={invSortKey} sortDir={invSortDir} onSort={invToggle} style={TH}>Company</SortTh>
|
||
<SortTh col="invoice_date" sortKey={invSortKey} sortDir={invSortDir} onSort={invToggle} style={TH}>Date</SortTh>
|
||
<SortTh col="due_date" sortKey={invSortKey} sortDir={invSortDir} onSort={invToggle} style={TH}>Due</SortTh>
|
||
<SortTh col="status" sortKey={invSortKey} sortDir={invSortDir} onSort={invToggle} style={{ ...TH, textAlign: 'center' }}>Status</SortTh>
|
||
<SortTh col="stripe_fee" sortKey={invSortKey} sortDir={invSortDir} onSort={invToggle} style={{ ...TH, textAlign: 'right' }}>Fees</SortTh>
|
||
<SortTh col="total" sortKey={invSortKey} sortDir={invSortDir} onSort={invToggle} style={{ ...TH, textAlign: 'right' }}>Total</SortTh>
|
||
</tr></thead>
|
||
<tbody>{sorted.map(inv => (
|
||
<tr key={inv.id}>
|
||
<td style={{ ...TD }}>
|
||
<button type="button" className="dashboard-inline-link" onClick={() => setViewingInvoice(inv)}>{inv.invoice_number}</button>
|
||
</td>
|
||
<td style={{ ...TD }}>
|
||
<button type="button" className="dashboard-inline-link" onClick={() => inv.company?.id && navigate(`/company/${inv.company.id}`)}>{inv.company?.name || inv.bill_to || '—'}</button>
|
||
</td>
|
||
<td style={{ ...TD, fontSize: 12 }}>{fmtDate(inv.invoice_date)}</td>
|
||
<td style={{ ...TD, fontSize: 12, color: inv.status !== 'paid' && new Date(inv.due_date) < new Date() ? 'var(--danger)' : 'var(--text-primary)' }}>{fmtDate(inv.due_date)}</td>
|
||
<td style={{ ...TD, textAlign: 'center' }}>
|
||
<StatusBadge status={statusColor[inv.status] || 'not_started'} label={invoiceStatusBadgeLabel(inv.status)} />
|
||
</td>
|
||
<td style={{ ...TD, textAlign: 'right', fontSize: 12, color: Number(inv.stripe_fee) > 0 ? '#ef4444' : 'var(--text-muted)' }}>
|
||
{Number(inv.stripe_fee) > 0 ? `-${fmtAmt(inv.stripe_fee)}` : '$0.00'}
|
||
</td>
|
||
<td style={{ ...TD, textAlign: 'right', color: inv.status === 'paid' ? '#4ade80' : inv.status === 'sent' ? '#F5A523' : 'var(--text-primary)' }}>
|
||
{fmtAmt(inv.total)}
|
||
</td>
|
||
</tr>
|
||
))}</tbody>
|
||
</table>
|
||
</div>
|
||
)}
|
||
</div>
|
||
<div style={{ ...CARD, display: 'flex', flexDirection: 'column', minHeight: 0 }}>
|
||
<div style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase', marginBottom: 5, flexShrink: 0 }}>Companies</div>
|
||
<div style={{ fontSize: 30, fontWeight: 400, letterSpacing: -0.5, lineHeight: 1.1, color: '#4ade80', fontVariantNumeric: 'tabular-nums', marginBottom: 14, flexShrink: 0 }}>{fmtAmt(grandTotal)}</div>
|
||
{byCompany.length === 0 ? <div className="card-empty-center">No data</div> : (
|
||
<div className="scrollbar-thin-theme table-scroll-hidden" style={{ flex: 1, minHeight: 0, overflowY: 'auto' }}>
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||
{byCompany.map(g => {
|
||
const pct = grandTotal > 0 ? (g.total / grandTotal) * 100 : 0;
|
||
return (
|
||
<div key={g.name}>
|
||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 5 }}>
|
||
<span style={{ fontSize: 12, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', maxWidth: '65%' }}>{g.name}</span>
|
||
<span style={{ fontSize: 12, color: '#4ade80', fontVariantNumeric: 'tabular-nums', flexShrink: 0 }}>{fmtAmt(g.total)}</span>
|
||
</div>
|
||
<div style={{ height: 4, borderRadius: 2, background: 'var(--border)', overflow: 'hidden' }}>
|
||
<div style={{ height: '100%', width: `${pct}%`, background: '#4ade80', borderRadius: 2, opacity: 0.7, transition: 'width 400ms ease' }} />
|
||
</div>
|
||
<div style={{ fontSize: 11, color: 'var(--text-muted)', marginTop: 3 }}>
|
||
<span style={{ color: g.outstanding > 0 ? '#F5A523' : 'var(--text-muted)' }}>{fmtAmt(g.outstanding)} outstanding</span>
|
||
{' · '}{fmtAmt(g.paid)} paid
|
||
{g.fees > 0 && <span style={{ color: '#ef4444' }}> · -{fmtAmt(g.fees)} fees</span>}
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
})()}
|
||
|
||
{financeTab === 'legacy' && <div>
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: 4, marginBottom: 10, minHeight: 'var(--btn-height)' }}>
|
||
{[
|
||
{ id: 'invoices', label: 'INVOICES' },
|
||
{ id: 'expenses', label: 'EXPENSES' },
|
||
{ id: 'sub-invoices', label: 'SUBCONTRACTOR INVOICES' },
|
||
].map((t, i, arr) => (
|
||
<button
|
||
key={t.id}
|
||
type="button"
|
||
onClick={() => setActiveTab(t.id)}
|
||
className={`section-tab-btn${activeTab === t.id ? ' is-active' : ''}`}
|
||
>{t.label}</button>
|
||
))}
|
||
</div>
|
||
|
||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, marginBottom: 10, minHeight: 'var(--btn-height)', flexWrap: 'wrap' }}>
|
||
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
|
||
{companyNames.length > 0 && activeTab === 'invoices' && (
|
||
<select className="filter-select" style={{ width: 120 }} value={filterCompany} onChange={e => setFilterCompany(e.target.value)}>
|
||
<option value="">All Companies</option>
|
||
{companyNames.map(name => <option key={name} value={name}>{name}</option>)}
|
||
</select>
|
||
)}
|
||
</div>
|
||
{activeTab === 'invoices' && (
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||
<select className="filter-select" style={{ width: 120 }} value={filter} onChange={e => setFilter(e.target.value)}>
|
||
<option value="all">All</option>
|
||
<option value="draft">Draft</option>
|
||
<option value="sent">Sent</option>
|
||
<option value="paid">Paid</option>
|
||
</select>
|
||
<button className="btn btn-primary btn-sm" onClick={() => setShowInvoiceForm(true)}>+ New Invoice</button>
|
||
</div>
|
||
)}
|
||
{activeTab === 'expenses' && (
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||
<select className="filter-select" style={{ width: 120 }} value={expenseFilter} onChange={e => setExpenseFilter(e.target.value)}>
|
||
<option value="all">All</option>
|
||
{CATEGORIES.filter(c => expenses.some(ex => ex.category === c)).map(c => (
|
||
<option key={c} value={c}>{c}</option>
|
||
))}
|
||
</select>
|
||
<button className="btn btn-primary btn-sm" onClick={() => { setEditingExpenseId(''); setNewExpense(blankExpense()); setExpensesError(''); setShowExpenseForm(true); }}>+ Add Expense</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{activeTab === 'invoices' && (
|
||
<div>
|
||
|
||
{/* ── Invoices ── */}
|
||
<div>
|
||
{loading ? (
|
||
<p style={{ color: 'var(--text-muted)' }}>Loading...</p>
|
||
) : filtered.length === 0 ? (
|
||
<div className="card card-empty-center">No invoices</div>
|
||
) : (
|
||
<div className="table-wrapper">
|
||
<table className="table-sticky-head">
|
||
<thead>
|
||
<tr>
|
||
<SortTh col="invoice_number" sortKey={invSortKey} sortDir={invSortDir} onSort={invToggle}>Invoice #</SortTh>
|
||
<SortTh col="bill_to" sortKey={invSortKey} sortDir={invSortDir} onSort={invToggle}>Bill To</SortTh>
|
||
<SortTh col="invoice_date" sortKey={invSortKey} sortDir={invSortDir} onSort={invToggle}>Date</SortTh>
|
||
<SortTh col="due_date" sortKey={invSortKey} sortDir={invSortDir} onSort={invToggle}>Due</SortTh>
|
||
<SortTh col="status" sortKey={invSortKey} sortDir={invSortDir} onSort={invToggle}>Status</SortTh>
|
||
<SortTh col="total" sortKey={invSortKey} sortDir={invSortDir} onSort={invToggle}>Total</SortTh>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{invSort(filtered, (inv, key) => {
|
||
if (key === 'total') return Number(inv.total) || 0;
|
||
if (key === 'invoice_date' || key === 'due_date') return new Date(inv[key]).getTime();
|
||
return inv[key] || inv.company?.name || '';
|
||
}).map(inv => (
|
||
<tr key={inv.id} onClick={() => setViewingInvoice(inv)} style={{ cursor: 'pointer' }}>
|
||
<td style={{ fontWeight: 400 }}>{inv.invoice_number}</td>
|
||
<td style={{ fontWeight: 400 }}>{inv.bill_to || inv.company?.name}</td>
|
||
<td style={{ color: 'var(--text-muted)' }}>{new Date(inv.invoice_date).toLocaleDateString()}</td>
|
||
<td>
|
||
<span style={{ color: inv.status !== 'paid' && new Date(inv.due_date) < new Date() ? 'var(--danger)' : 'var(--text-muted)' }}>
|
||
{new Date(inv.due_date).toLocaleDateString()}
|
||
</span>
|
||
</td>
|
||
<td style={{ textAlign: 'center' }}>
|
||
<StatusBadge
|
||
status={statusColor[inv.status] || 'not_started'}
|
||
label={invoiceStatusBadgeLabel(inv.status)}
|
||
/>
|
||
</td>
|
||
<td style={{ fontWeight: 400, color: 'var(--accent)' }}>${Number(inv.total).toFixed(2)}</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{activeTab === 'expenses' && (
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: 24 }}>
|
||
<div>
|
||
<div style={{ marginBottom: 10 }}>
|
||
|
||
{expensesLoading ? (
|
||
<p style={{ color: 'var(--text-muted)', fontSize: 13 }}>Loading...</p>
|
||
) : expensesError ? (
|
||
<p style={{ color: 'var(--danger)', fontSize: 13 }}>{expensesError}</p>
|
||
) : filteredExpenses.length === 0 ? (
|
||
<div className="card card-empty-center">No expenses</div>
|
||
) : (
|
||
<div className="table-wrapper">
|
||
<table className="table-sticky-head">
|
||
<thead>
|
||
<tr>
|
||
<SortTh col="date" sortKey={expSortKey} sortDir={expSortDir} onSort={expToggle}>Date</SortTh>
|
||
<SortTh col="description" sortKey={expSortKey} sortDir={expSortDir} onSort={expToggle}>Description</SortTh>
|
||
<SortTh col="category" sortKey={expSortKey} sortDir={expSortDir} onSort={expToggle}>Category</SortTh>
|
||
<SortTh col="notes" sortKey={expSortKey} sortDir={expSortDir} onSort={expToggle}>Notes</SortTh>
|
||
<SortTh col="amount" sortKey={expSortKey} sortDir={expSortDir} onSort={expToggle} style={{ textAlign: 'right' }}>Amount</SortTh>
|
||
<th style={{ width: 140, textAlign: 'right' }}>Action</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{expSort(filteredExpenses, (exp, key) => {
|
||
if (key === 'amount') return Number(exp.amount) || 0;
|
||
if (key === 'date') return exp.date || '';
|
||
return exp[key] || '';
|
||
}).map(exp => (
|
||
<tr key={exp.id} style={{ cursor: 'pointer' }} onClick={() => setViewingExpense(exp)}>
|
||
<td>{new Date(exp.date + 'T12:00:00').toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })}</td>
|
||
<td style={{ fontWeight: 400 }}>{exp.description}</td>
|
||
<td>{exp.category}</td>
|
||
<td style={{ color: 'var(--text-muted)' }}>
|
||
{exp.notes || exp.receipt_path ? (
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||
<span>{exp.notes || '—'}</span>
|
||
{exp.receipt_path && (
|
||
<button
|
||
className="btn btn-outline btn-sm"
|
||
style={{ width: 'fit-content' }}
|
||
onClick={e => { e.stopPropagation(); handleViewReceipt(exp); }}
|
||
>
|
||
View Receipt
|
||
</button>
|
||
)}
|
||
</div>
|
||
) : '—'}
|
||
</td>
|
||
<td style={{ textAlign: 'right', fontWeight: 400 }}>${Number(exp.amount).toFixed(2)}</td>
|
||
<td style={{ textAlign: 'right' }}>
|
||
<button
|
||
className="btn-icon btn-icon-danger"
|
||
title="Delete"
|
||
onClick={e => { e.stopPropagation(); handleDeleteExpense(exp.id); }}
|
||
>
|
||
✕
|
||
</button>
|
||
</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
</div>
|
||
|
||
</div>
|
||
)}
|
||
|
||
{activeTab === 'sub-invoices' && (
|
||
<div>
|
||
<div style={{ marginBottom: 10 }}>
|
||
{subInvoicesLoading ? (
|
||
<p style={{ color: 'var(--text-muted)', fontSize: 13 }}>Loading...</p>
|
||
) : subInvoicesError ? (
|
||
<p style={{ color: 'var(--danger)', fontSize: 13 }}>{subInvoicesError}</p>
|
||
) : subInvoices.length === 0 ? (
|
||
<div className="card card-empty-center">No sub invoices</div>
|
||
) : (
|
||
<div className="table-wrapper">
|
||
<table className="table-sticky-head">
|
||
<thead>
|
||
<tr>
|
||
<SortTh col="invoice_number" sortKey={subSortKey} sortDir={subSortDir} onSort={subToggle}>Invoice #</SortTh>
|
||
<SortTh col="subcontractor" sortKey={subSortKey} sortDir={subSortDir} onSort={subToggle}>Subcontractor</SortTh>
|
||
<SortTh col="submitted_at" sortKey={subSortKey} sortDir={subSortDir} onSort={subToggle}>Submitted</SortTh>
|
||
<SortTh col="status" sortKey={subSortKey} sortDir={subSortDir} onSort={subToggle}>Status</SortTh>
|
||
<SortTh col="total" sortKey={subSortKey} sortDir={subSortDir} onSort={subToggle} style={{ textAlign: 'right' }}>Total</SortTh>
|
||
<th style={{ textAlign: 'right' }}>Actions</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{subSort(subInvoices, (inv, key) => {
|
||
if (key === 'total') return (inv.items || []).reduce((s, i) => s + Number(i.unit_price || 0) * Number(i.quantity || 1), 0);
|
||
if (key === 'subcontractor') return inv.profile?.name || '';
|
||
if (key === 'submitted_at') return inv.submitted_at ? new Date(inv.submitted_at).getTime() : 0;
|
||
return inv[key] || '';
|
||
}).map(inv => {
|
||
const total = (inv.items || []).reduce((s, i) => s + Number(i.unit_price || 0) * Number(i.quantity || 1), 0);
|
||
const subInvoiceStatusColor = { draft: 'not_started', submitted: 'in_progress', paid: 'client_approved' };
|
||
return (
|
||
<tr key={inv.id} style={{ cursor: 'pointer' }} onClick={() => setViewingSubInvoice(inv)}>
|
||
<td style={{ fontWeight: 400 }}>{inv.invoice_number}</td>
|
||
<td>
|
||
<div style={{ fontWeight: 400 }}>{inv.profile?.name || 'External'}</div>
|
||
<div style={{ color: 'var(--text-muted)', fontSize: 12 }}>{inv.profile?.email || '—'}</div>
|
||
</td>
|
||
<td style={{ color: 'var(--text-muted)' }}>{inv.submitted_at ? new Date(inv.submitted_at).toLocaleDateString() : '—'}</td>
|
||
<td>
|
||
<StatusBadge
|
||
status={subInvoiceStatusColor[inv.status] || 'not_started'}
|
||
label={inv.status ? inv.status.charAt(0).toUpperCase() + inv.status.slice(1) : '—'}
|
||
/>
|
||
</td>
|
||
<td style={{ textAlign: 'right', fontWeight: 400, color: 'var(--accent)' }}>${total.toFixed(2)}</td>
|
||
<td />
|
||
</tr>
|
||
);
|
||
})}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>}{/* end legacy wrapper */}
|
||
|
||
{viewingSubInvoice && (() => {
|
||
const invoice = viewingSubInvoice;
|
||
const baseItems = [...(invoice.items || [])].sort((a, b) => (a.sort_order ?? 0) - (b.sort_order ?? 0));
|
||
const total = baseItems.reduce((s, item) => s + Number(item.unit_price || 0) * Number(item.quantity || 1), 0);
|
||
const invoiceStatus = invoice.status ? invoice.status.charAt(0).toUpperCase() + invoice.status.slice(1) : '—';
|
||
const subInvoiceStatusColor = { draft: 'not_started', submitted: 'in_progress', paid: 'client_approved' };
|
||
const tableItems = subPopupSort(baseItems.map(item => ({
|
||
...item,
|
||
_inferredType: { label: subInvoiceItemWorkLabel(item), badgeClass: subInvoiceItemWorkLabel(item) === 'New' ? 'badge-initial' : 'badge-client_revision' },
|
||
description: cleanSubInvoiceItemDescription(item),
|
||
})), (item, key) => {
|
||
if (key === 'type') return item._inferredType?.label || '';
|
||
if (key === 'description') return item.description || '';
|
||
if (key === 'quantity') return Number(item.quantity || 0);
|
||
if (key === 'unit_price') return Number(item.unit_price || 0);
|
||
if (key === 'line_total') return Number(item.unit_price || 0) * Number(item.quantity || 1);
|
||
return '';
|
||
});
|
||
const F = POPUP_FIELD_LABEL;
|
||
return (
|
||
<InvoiceDetailPopup
|
||
title={invoice.invoice_number || 'Subcontractor Invoice'}
|
||
subtitle={`${invoice.profile?.name || 'External'}${invoice.profile?.email ? ` · ${invoice.profile.email}` : ''}`}
|
||
headerRight={<StatusBadge status={subInvoiceStatusColor[invoice.status] || 'not_started'} label={invoiceStatus} />}
|
||
onClose={() => { if (!markingPaid) setViewingSubInvoice(null); }}
|
||
blockClose={Boolean(markingPaid)}
|
||
metaContent={<>
|
||
<div><div style={F}>Submitted</div><div style={{ fontSize: 13, color: 'var(--text-primary)' }}>{invoice.submitted_at ? new Date(invoice.submitted_at).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) : '—'}</div></div>
|
||
<div><div style={F}>Paid</div><div style={{ fontSize: 13, color: 'var(--text-primary)' }}>{invoice.paid_at ? new Date(invoice.paid_at).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) : '—'}</div></div>
|
||
<div><div style={F}>Line Items</div><div style={{ fontSize: 13, color: 'var(--text-primary)' }}>{baseItems.length}</div></div>
|
||
<div><div style={F}>Total</div><div style={{ fontSize: 18, fontWeight: 500, color: 'var(--accent)', lineHeight: 1.1 }}>${total.toFixed(2)}</div></div>
|
||
</>}
|
||
footerActions={<>
|
||
<button type="button" className="btn btn-outline" onClick={() => handleDownloadSubInvoice(invoice)}>Download Invoice</button>
|
||
{invoice.status === 'submitted' && (
|
||
<LoadingButton className="btn btn-outline" onClick={async () => {
|
||
await handleMarkSubInvoicePaid(invoice);
|
||
setViewingSubInvoice(current => current?.id === invoice.id ? { ...current, status: 'paid', paid_at: new Date().toISOString() } : current);
|
||
}} loading={markingPaid === invoice.id} loadingText="Processing…">Mark as Paid</LoadingButton>
|
||
)}
|
||
{invoice.status === 'paid' && (
|
||
<button type="button" className="btn btn-outline" onClick={() => handleDownloadSubInvoiceReceipt(invoice)}>Download Receipt</button>
|
||
)}
|
||
<button type="button" className="btn btn-outline" onClick={() => setViewingSubInvoice(null)}>Close</button>
|
||
</>}
|
||
>
|
||
<InvoicePopupTable sortKey={subPopupKey} sortDir={subPopupDir} onSort={subPopupToggle} items={tableItems} notes={invoice.notes} />
|
||
</InvoiceDetailPopup>
|
||
);
|
||
})()}
|
||
|
||
{viewingInvoice && (
|
||
<TeamInvoiceDetailPanel
|
||
invoiceId={viewingInvoice.id}
|
||
initialInvoice={viewingInvoice}
|
||
embedded
|
||
onClose={() => setViewingInvoice(null)}
|
||
onInvoiceUpdated={(updatedInvoice) => {
|
||
setViewingInvoice(updatedInvoice);
|
||
setInvoices((current) => current.map((invoice) => (invoice.id === updatedInvoice.id ? { ...invoice, ...updatedInvoice } : invoice)));
|
||
}}
|
||
onInvoiceDeleted={(deletedId) => {
|
||
setInvoices((current) => current.filter((invoice) => invoice.id !== deletedId));
|
||
setViewingInvoice((current) => (current?.id === deletedId ? null : current));
|
||
}}
|
||
/>
|
||
)}
|
||
|
||
{viewingExpense && (() => {
|
||
const LABEL = { ...FIELD_LABEL_STYLE, marginBottom: 3 };
|
||
const INPUT = FINANCE_MODAL_INPUT_STYLE;
|
||
const isPdf = viewingExpense.receipt_name?.toLowerCase().endsWith('.pdf');
|
||
const isDirty = expenseDetailEditing && (
|
||
newExpense.amount !== String(viewingExpense.amount ?? '') ||
|
||
newExpense.description !== (viewingExpense.description || '') ||
|
||
newExpense.date !== (viewingExpense.date || '') ||
|
||
newExpense.category !== (viewingExpense.category || 'Other') ||
|
||
newExpense.notes !== (viewingExpense.notes || '') ||
|
||
newExpense.receipt != null ||
|
||
newExpense.removeReceipt === true
|
||
);
|
||
return (
|
||
<div style={popupOverlayStyle} onClick={() => { setViewingExpense(null); setExpenseDetailEditing(false); }}>
|
||
<div style={{ ...popupSurfaceStyle, background: 'var(--card-bg)', width: '80vw', height: '80vh', display: 'flex', flexDirection: 'column', gap: 0 }} onClick={e => e.stopPropagation()}>
|
||
{/* Main content row */}
|
||
<div style={{ display: 'flex', gap: 24, alignItems: 'stretch', flex: 1, minHeight: 0 }}>
|
||
{/* Left: details (editable in-place) */}
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: 14, flex: '0 0 260px', overflowY: 'auto' }}>
|
||
<div style={LABEL}>Expense Detail</div>
|
||
{expenseDetailEditing ? (
|
||
<CurrencyInput
|
||
value={newExpense.amount}
|
||
required
|
||
onChange={e => setNewExpense(p => ({ ...p, amount: e.target.value }))}
|
||
/>
|
||
) : (
|
||
<div style={{ ...FINANCE_MODAL_TOTAL_VALUE_STYLE, color: 'var(--danger)' }}>
|
||
${Number(viewingExpense.amount).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
|
||
</div>
|
||
)}
|
||
<div>
|
||
<div style={LABEL}>Description</div>
|
||
{expenseDetailEditing
|
||
? <input type="text" required style={INPUT} value={newExpense.description} onChange={e => setNewExpense(p => ({ ...p, description: e.target.value }))} />
|
||
: <div style={{ fontSize: 13 }}>{viewingExpense.description || '—'}</div>}
|
||
</div>
|
||
<div>
|
||
<div style={LABEL}>Date</div>
|
||
{expenseDetailEditing
|
||
? <input type="date" required style={INPUT} value={newExpense.date} onChange={e => setNewExpense(p => ({ ...p, date: e.target.value }))} />
|
||
: <div style={{ fontSize: 13 }}>{viewingExpense.date ? new Date(viewingExpense.date + 'T12:00:00').toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' }) : '—'}</div>}
|
||
</div>
|
||
<div>
|
||
<div style={LABEL}>Category</div>
|
||
{expenseDetailEditing
|
||
? <select style={INPUT} value={newExpense.category} onChange={e => setNewExpense(p => ({ ...p, category: e.target.value }))}>{CATEGORIES.map(c => <option key={c} value={c}>{c}</option>)}</select>
|
||
: <div style={{ fontSize: 13 }}>{viewingExpense.category || '—'}</div>}
|
||
</div>
|
||
<div>
|
||
<div style={LABEL}>Notes</div>
|
||
{expenseDetailEditing
|
||
? <input type="text" style={INPUT} value={newExpense.notes} onChange={e => setNewExpense(p => ({ ...p, notes: e.target.value }))} />
|
||
: <div style={{ fontSize: 13 }}>{viewingExpense.notes || '—'}</div>}
|
||
</div>
|
||
{expenseDetailEditing && (
|
||
<div>
|
||
<div style={LABEL}>Receipt / Photo</div>
|
||
<input type="file" accept="image/*,.pdf" style={{ ...INPUT, padding: '4px 10px' }} onChange={e => setNewExpense(p => ({ ...p, receipt: e.target.files?.[0] || null, removeReceipt: false }))} />
|
||
{!newExpense.receipt && newExpense.receipt_path && (
|
||
<div style={{ marginTop: 6, display: 'flex', alignItems: 'center', gap: 8 }}>
|
||
<span style={{ fontSize: 12, color: 'var(--text-muted)' }}>{newExpense.receipt_name || 'Existing receipt'}</span>
|
||
<button type="button" className="btn btn-outline" style={{ fontSize: 11, color: 'var(--danger)' }} onClick={() => setNewExpense(p => ({ ...p, removeReceipt: true, receipt_path: null, receipt_name: null }))}>Remove</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
{/* Right: receipt preview — fixed to left column height, item scales inside */}
|
||
{viewingExpense.receipt_path && (
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, flex: 1, minHeight: 0, alignSelf: 'stretch' }}>
|
||
<div style={LABEL}>Receipt</div>
|
||
{expensePreviewUrl ? (
|
||
isPdf
|
||
? <embed src={`${expensePreviewUrl}#toolbar=0&navpanes=0&scrollbar=0`} type="application/pdf" style={{ flex: 1, width: '100%', minHeight: 0, border: '1px solid var(--border)', borderRadius: 6 }} />
|
||
: <img src={expensePreviewUrl} alt="Receipt" style={{ flex: 1, width: '100%', minHeight: 0, height: 0, objectFit: 'contain', objectPosition: 'top center', display: 'block', border: '1px solid var(--border)', borderRadius: 6, background: 'var(--card-bg-2)' }} />
|
||
) : (
|
||
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>Loading preview…</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
{/* Footer: buttons bottom-right */}
|
||
<div className="modal-action-row" style={{ paddingTop: 14, marginTop: 14, flexShrink: 0 }}>
|
||
{expenseDetailEditing ? (
|
||
<>
|
||
<button className="btn btn-outline" disabled={!isDirty || addingExpense} onClick={async () => { await saveExpense(); setExpenseDetailEditing(false); setViewingExpense(null); }}>{addingExpense ? 'Saving…' : 'Save'}</button>
|
||
<button className="btn btn-outline" onClick={() => setExpenseDetailEditing(false)}>Cancel</button>
|
||
</>
|
||
) : (
|
||
<>
|
||
{expensePreviewUrl && (
|
||
<a href={expensePreviewUrl} download={viewingExpense.receipt_name || 'receipt'} className="btn btn-outline" style={{ display: 'inline-flex' }}>Download Receipt</a>
|
||
)}
|
||
<button type="button" className="btn btn-outline" onClick={() => { setEditingExpenseId(viewingExpense.id); setNewExpense({ date: viewingExpense.date, description: viewingExpense.description || '', category: viewingExpense.category || 'Other', amount: String(viewingExpense.amount ?? ''), notes: viewingExpense.notes || '', receipt: null, receipt_path: viewingExpense.receipt_path || null, receipt_name: viewingExpense.receipt_name || null }); setExpensesError(''); setExpenseDetailEditing(true); }}>Edit</button>
|
||
<button type="button" className="btn btn-outline" onClick={() => setViewingExpense(null)}>Cancel</button>
|
||
</>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
})()}
|
||
|
||
{showExpenseForm && (() => {
|
||
const LABEL = { ...FIELD_LABEL_STYLE, marginBottom: 3 };
|
||
const INPUT = FINANCE_MODAL_INPUT_STYLE;
|
||
return (
|
||
<div style={popupOverlayStyle} onClick={cancelExpenseEdit}>
|
||
<div style={{ ...popupSurfaceStyle, background: 'var(--card-bg)', width: '80vw', height: '80vh', display: 'flex', flexDirection: 'column', gap: 0 }} onClick={e => e.stopPropagation()}>
|
||
<form onSubmit={handleAddExpense} style={{ display: 'flex', flexDirection: 'column', flex: 1, minHeight: 0, gap: 0 }}>
|
||
{/* Main content row */}
|
||
<div style={{ display: 'flex', gap: 24, alignItems: 'stretch', flex: 1, minHeight: 0 }}>
|
||
{/* Left: fields */}
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: 14, flex: '0 0 260px', overflowY: 'auto' }}>
|
||
<div style={LABEL}>New Expense</div>
|
||
<CurrencyInput
|
||
value={newExpense.amount}
|
||
required
|
||
onChange={e => setNewExpense(p => ({ ...p, amount: e.target.value }))}
|
||
/>
|
||
<div>
|
||
<div style={LABEL}>Description</div>
|
||
<input type="text" required placeholder="What was this for?" style={INPUT} value={newExpense.description} onChange={e => setNewExpense(p => ({ ...p, description: e.target.value }))} />
|
||
</div>
|
||
<div>
|
||
<div style={LABEL}>Date</div>
|
||
<input type="date" required style={INPUT} value={newExpense.date} onChange={e => setNewExpense(p => ({ ...p, date: e.target.value }))} />
|
||
</div>
|
||
<div>
|
||
<div style={LABEL}>Category</div>
|
||
<select style={INPUT} value={newExpense.category} onChange={e => setNewExpense(p => ({ ...p, category: e.target.value }))}>{CATEGORIES.map(c => <option key={c} value={c}>{c}</option>)}</select>
|
||
</div>
|
||
<div>
|
||
<div style={LABEL}>Notes</div>
|
||
<input type="text" placeholder="Any extra details" style={INPUT} value={newExpense.notes} onChange={e => setNewExpense(p => ({ ...p, notes: e.target.value }))} />
|
||
</div>
|
||
{expensesError && <div style={{ fontSize: 12, color: 'var(--danger)' }}>{expensesError}</div>}
|
||
</div>
|
||
{/* Right: file attach area */}
|
||
<div style={{ display: 'flex', flexDirection: 'column', flex: 1, minHeight: 0 }}>
|
||
<FileAttachment
|
||
files={newExpense.receipt ? [newExpense.receipt] : []}
|
||
onChange={files => setNewExpense(p => ({ ...p, receipt: files.length > 0 ? files[files.length - 1] : null }))}
|
||
/>
|
||
</div>
|
||
</div>
|
||
{/* Footer */}
|
||
<div className="modal-action-row" style={{ paddingTop: 14, marginTop: 14, flexShrink: 0 }}>
|
||
<button type="submit" className="btn btn-outline" disabled={addingExpense}>{addingExpense ? 'Saving…' : 'Save'}</button>
|
||
<button type="button" className="btn btn-outline" onClick={cancelExpenseEdit}>Cancel</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
);
|
||
})()}
|
||
|
||
</div>{/* end flex column wrapper */}
|
||
|
||
{showInvoiceForm && (() => {
|
||
const LABEL = { ...FIELD_LABEL_STYLE, marginBottom: 3 };
|
||
const INPUT = FINANCE_MODAL_INPUT_STYLE;
|
||
return (
|
||
<div style={popupOverlayStyle} onClick={() => { if (!invSaving) invClose(); }}>
|
||
<div style={{ ...popupSurfaceStyle, background: 'var(--card-bg)', width: '80vw', height: '80vh', display: 'flex', flexDirection: 'column', gap: 0 }} onClick={e => e.stopPropagation()}>
|
||
<div style={{ display: 'flex', gap: 24, alignItems: 'stretch', flex: 1, minHeight: 0 }}>
|
||
|
||
{/* Left: meta fields */}
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: 14, flex: '0 0 260px', overflowY: 'auto' }}>
|
||
<div style={LABEL}>New Invoice</div>
|
||
<div>
|
||
<div style={LABEL}>Company *</div>
|
||
<select style={INPUT} value={invCompanyId} onChange={e => setInvCompanyId(e.target.value)}>
|
||
<option value="">Choose a company…</option>
|
||
{invCompanies.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
|
||
</select>
|
||
</div>
|
||
{invSelectedCompany && <>
|
||
<div>
|
||
<div style={LABEL}>Bill To</div>
|
||
<input style={INPUT} type="text" value={invBillTo} onChange={e => setInvBillTo(e.target.value)} placeholder={invSelectedCompany.name} />
|
||
</div>
|
||
<div>
|
||
<div style={LABEL}>Email To</div>
|
||
<input style={INPUT} type="email" list="inv-recipients" value={invEmail} onChange={e => setInvEmail(e.target.value)} placeholder="client@example.com" />
|
||
<datalist id="inv-recipients">{invRecipients.map(r => <option key={r.id} value={r.email}>{r.name} ({r.role})</option>)}</datalist>
|
||
</div>
|
||
</>}
|
||
<div>
|
||
<div style={LABEL}>Notes</div>
|
||
<textarea style={FINANCE_MODAL_TEXTAREA_STYLE} value={invNotes} onChange={e => setInvNotes(e.target.value)} placeholder="Payment terms, notes…" />
|
||
</div>
|
||
<div style={{ marginTop: 'auto', paddingTop: 8 }}>
|
||
<div style={LABEL}>Total</div>
|
||
<div style={FINANCE_MODAL_TOTAL_VALUE_STYLE}>${invTotal.toFixed(2)}</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Right: unbilled tasks + line items */}
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: 14, flex: 1, minHeight: 0, overflowY: 'auto' }}>
|
||
{/* Unbilled tasks */}
|
||
{invSelectedCompany && (
|
||
<div>
|
||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 6 }}>
|
||
<div style={LABEL}>Unbilled Requests</div>
|
||
{invUnbilledTasks.length > 0 && <button type="button" className="btn btn-outline" style={{ fontSize: 11 }} onClick={() => invUnbilledTasks.forEach(t => { if (!invItems.some(i => i.task_id === t.id && !i.submission_id)) invAddTask(t); })}>+ All</button>}
|
||
</div>
|
||
{invLoadingTasks ? <div style={{ fontSize: 12, color: 'var(--text-muted)' }}>Loading…</div>
|
||
: invUnbilledTasks.length === 0 ? <div style={{ fontSize: 12, color: 'var(--text-muted)' }}>None</div>
|
||
: invUnbilledTasks.map(t => {
|
||
const price = invPriceList.find(p => p.service_type === t.service_type && p.price_type === 'new');
|
||
const added = invItems.some(i => i.task_id === t.id && !i.submission_id);
|
||
return (
|
||
<div key={t.id} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '6px 10px', background: 'var(--card-bg-2)', borderRadius: 4, border: '1px solid var(--border)', marginBottom: 4 }}>
|
||
<div>
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
|
||
<span className="badge badge-initial">New</span>
|
||
<div style={{ fontSize: 12 }}>{t.project?.name} • {t.title || t.service_type}</div>
|
||
</div>
|
||
<div style={{ fontSize: 11, color: 'var(--text-muted)' }}>{price ? `$${Number(price.price).toFixed(2)}` : 'No price'}</div>
|
||
</div>
|
||
<button type="button" className="btn btn-outline" style={{ fontSize: 11 }} disabled={added} onClick={() => invAddTask(t)}>{added ? 'Added' : '+ Add'}</button>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
)}
|
||
{/* Unbilled revisions */}
|
||
{invSelectedCompany && invUnbilledRevisions.length > 0 && (
|
||
<div>
|
||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 6 }}>
|
||
<div style={LABEL}>Unbilled Revisions</div>
|
||
<button type="button" className="btn btn-outline" style={{ fontSize: 11 }} onClick={() => invUnbilledRevisions.forEach(r => { if (!invItems.some(i => i.submission_id === r.id)) invAddRevision(r); })}>+ All</button>
|
||
</div>
|
||
{invUnbilledRevisions.map(r => {
|
||
const added = invItems.some(i => i.submission_id === r.id);
|
||
const revisionQty = getRevisionChargeQuantity(r?.version_number);
|
||
const initial = (r.task?.submissions || []).find(s => s.type === 'initial') || r.task?.submissions?.[0];
|
||
const revisionServiceType = initial?.service_type || r.service_type || r.task?.title || 'Revision';
|
||
const revisionPrice = invPriceList.find(p => p.service_type === revisionServiceType && p.price_type === 'revision');
|
||
const revisionLabel = revisionQty > 0 ? (revisionPrice ? `$${Number(revisionPrice.price).toFixed(2)}` : 'No price') : 'Free';
|
||
return (
|
||
<div key={r.id} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '6px 10px', background: 'var(--card-bg-2)', borderRadius: 4, border: '1px solid var(--border)', marginBottom: 4 }}>
|
||
<div>
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
|
||
<span className="badge badge-client_revision">Revision</span>
|
||
<div style={{ fontSize: 12 }}>{r.task?.project?.name} • {r.task?.title} • R{String(r.version_number || 0).padStart(2, '0')}</div>
|
||
</div>
|
||
<div style={{ fontSize: 11, color: 'var(--text-muted)' }}>{revisionLabel}</div>
|
||
</div>
|
||
<button type="button" className="btn btn-outline" style={{ fontSize: 11 }} disabled={added} onClick={() => invAddRevision(r)}>{added ? 'Added' : '+ Add'}</button>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
)}
|
||
{/* Line items */}
|
||
<div style={{ flex: 1 }}>
|
||
<div style={LABEL}>Line Items</div>
|
||
<div style={{ display: 'grid', gridTemplateColumns: '20px 90px 1fr 60px 100px 90px 28px', gap: 6, marginBottom: 6 }}>
|
||
{['', 'Type', 'Description', 'Qty', 'Unit Price', 'Total', ''].map((h, i) => <div key={i} style={{ fontSize: 10, fontWeight: 500, textTransform: 'uppercase', letterSpacing: 0.5, color: 'var(--text-muted)', textAlign: i > 3 ? 'right' : 'left' }}>{h}</div>)}
|
||
</div>
|
||
{invItems.map((item, idx) => (
|
||
<div key={item.id} onDragOver={e => e.preventDefault()} onDrop={() => invHandleDrop(idx)} style={{ display: 'grid', gridTemplateColumns: '20px 90px 1fr 60px 100px 90px 28px', gap: 6, alignItems: 'center', marginBottom: 4 }}>
|
||
<div draggable onDragStart={() => { invDragItem.current = idx; }} style={{ cursor: 'grab', color: 'var(--text-muted)', fontSize: 12, textAlign: 'center', userSelect: 'none' }}>⠿</div>
|
||
<span className={`badge ${item.submission_id ? 'badge-client_revision' : 'badge-initial'}`}>
|
||
{item.submission_id ? 'Revision' : 'New'}
|
||
</span>
|
||
<input style={FINANCE_MODAL_LINE_ITEM_INPUT_STYLE} type="text" placeholder="Description…" value={item.description} onChange={e => invUpdateItem(item.id, 'description', e.target.value)} />
|
||
<input style={{ ...FINANCE_MODAL_NUMBER_INPUT_STYLE, textAlign: 'center' }} type="number" min="1" value={item.quantity} onChange={e => invUpdateItem(item.id, 'quantity', e.target.value)} />
|
||
<input style={{ ...FINANCE_MODAL_NUMBER_INPUT_STYLE, textAlign: 'right' }} type="number" min="0" step="0.01" placeholder="0.00" value={item.unit_price} onChange={e => invUpdateItem(item.id, 'unit_price', e.target.value)} />
|
||
<div style={{ minHeight: 32, display: 'flex', alignItems: 'center', justifyContent: 'flex-end', textAlign: 'right', fontSize: 13, color: 'var(--text-primary)', paddingRight: 2, fontVariantNumeric: 'tabular-nums' }}>${((Number(item.quantity) || 0) * (Number(item.unit_price) || 0)).toFixed(2)}</div>
|
||
<button type="button" onClick={() => invRemoveItem(item.id)} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--danger)', fontSize: 14, padding: 2 }}>✕</button>
|
||
</div>
|
||
))}
|
||
<button type="button" className="btn btn-outline" style={{ marginTop: 8, fontSize: 12 }} onClick={() => setInvItems(prev => [...prev, invNewItem()])}>+ Line Item</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Footer */}
|
||
<div className="modal-action-row" style={{ paddingTop: 14, marginTop: 14, flexShrink: 0 }}>
|
||
<button type="button" className="btn btn-outline" disabled={invSaving} onClick={() => invHandleSave('draft')}>{invSaving ? 'Saving…' : 'Save Draft'}</button>
|
||
<LoadingButton className="btn btn-outline" style={{ color: 'var(--accent)', borderColor: 'var(--accent)' }} onClick={() => invHandleSave('sent')} loading={invSaving} loadingText="Sending…">Finalise & Send</LoadingButton>
|
||
<button type="button" className="btn btn-outline" disabled={invSaving} onClick={invClose}>Cancel</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
})()}
|
||
|
||
</Layout>
|
||
);
|
||
}
|