fix: crash bugs in TeamInvoices + faster load
Bug fixes: - TeamInvoices: add useAuth import/currentUser (invoice-create crash) - TeamInvoices: setChartYear -> setExportYear (year-dropdown crash) - TeamDashboard: drop redundant setState-in-effect (cascading renders) - Companies: delete dead _UnusedClientCompanies (illegal hook calls) - annotate intentional empty PDF-fallback catches Load speed: - preconnect/dns-prefetch to Supabase origin - lazy-load heic-to in Converters: page chunk 2737KB -> 9KB - split recharts into its own 'charts' vendor chunk Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+462
-85
@@ -7,6 +7,7 @@ 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';
|
||||
@@ -14,6 +15,9 @@ 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';
|
||||
|
||||
const CATEGORIES = ['Software', 'Contractor', 'Advertising', 'Office', 'Travel', 'Meals', 'Equipment', 'Other'];
|
||||
const statusColor = { draft: 'not_started', sent: 'in_progress', paid: 'client_approved' };
|
||||
@@ -40,12 +44,156 @@ const invoiceStatusBadgeLabel = (status) => {
|
||||
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: '',
|
||||
@@ -78,6 +226,7 @@ function FinancesChartTooltip({ active, payload, label, year }) {
|
||||
}
|
||||
|
||||
export default function Invoices() {
|
||||
const { currentUser } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const cached = readPageCache('team_invoices');
|
||||
@@ -126,6 +275,9 @@ export default function Invoices() {
|
||||
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 ──────────────────────────────────────────────────────
|
||||
@@ -143,11 +295,30 @@ export default function Invoices() {
|
||||
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);
|
||||
@@ -163,12 +334,32 @@ export default function Invoices() {
|
||||
setInvEmail((users || [])[0]?.email || co?.contact_email || '');
|
||||
if (projects?.length > 0) {
|
||||
const pids = projects.map(p => p.id);
|
||||
const { data: tasks } = await supabase.from('tasks').select('*, project:projects(name), submissions(service_type, type, version_number)').in('project_id', pids).eq('invoiced', false).eq('status', 'client_approved');
|
||||
const tids = (tasks || []).map(t => t.id);
|
||||
const { data: revisions } = tids.length > 0 ? await supabase.from('submissions').select('*, task:tasks(id, title, project:projects(name), submissions(service_type, type))').eq('type', 'revision').or('revision_type.eq.client_revision,revision_type.is.null').eq('invoiced', false).in('task_id', tids) : { data: [] };
|
||||
setInvUnbilledTasks((tasks || []).map(t => { const ini = (t.submissions || []).find(s => s.type === 'initial') || t.submissions?.[0]; return { ...t, service_type: ini?.service_type || t.title }; }));
|
||||
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 || [])) { const k = `${r.task_id}:${r.version_number}`; if (!revMap.has(k)) revMap.set(k, r); }
|
||||
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);
|
||||
@@ -177,18 +368,17 @@ export default function Invoices() {
|
||||
|
||||
const invAddTask = (task) => {
|
||||
const price = invPriceList.find(p => p.service_type === task.service_type && p.price_type === 'new');
|
||||
const desc = `${task.project?.name || 'No Project'} • ${task.title || task.service_type || 'Untitled'}`;
|
||||
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 version = Number(rev.version_number || 0);
|
||||
const qty = version >= 2 ? 1 : 1;
|
||||
const unitPrice = version >= 2 ? (price?.price || '') : 0;
|
||||
const vLabel = 'R' + String(version).padStart(2, '0');
|
||||
const desc = `${rev.task?.project?.name || 'No Project'} • ${rev.task?.title || 'Revision'} • Revision ${vLabel}`;
|
||||
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));
|
||||
@@ -226,14 +416,18 @@ export default function Invoices() {
|
||||
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 {}
|
||||
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}`); }
|
||||
}
|
||||
setInvoices(prev => [{ ...invoice, company: { name: invSelectedCompany?.name || '' } }, ...prev]);
|
||||
const createdInvoice = {
|
||||
...invoice,
|
||||
company: invSelectedCompany ? { id: invSelectedCompany.id, name: invSelectedCompany.name } : null,
|
||||
};
|
||||
setInvoices(prev => [createdInvoice, ...prev]);
|
||||
invClose();
|
||||
navigate(`/invoices/${invoice.id}`);
|
||||
setViewingInvoice(createdInvoice);
|
||||
} catch (e) { alert(`Failed to save invoice: ${e.message || 'Unknown error'}`); }
|
||||
setInvSaving(false);
|
||||
};
|
||||
@@ -409,26 +603,7 @@ export default function Invoices() {
|
||||
};
|
||||
|
||||
const handleSendSubcontractorPO = async (po) => {
|
||||
const sentPO = await updateSubcontractorPO(po, { status: 'sent', sent_at: new Date().toISOString() }, 'Failed to send PO');
|
||||
if (!sentPO?.profile?.email) return;
|
||||
try {
|
||||
await sendEmail('subcontractor_po_sent', sentPO.profile.email, {
|
||||
poNumber: sentPO.po_number || 'Purchase Order',
|
||||
subcontractorName: sentPO.profile?.name || 'there',
|
||||
projectName: sentPO.project?.name || 'Subcontractor Work',
|
||||
companyName: sentPO.project?.company?.name || 'Fourge Branding',
|
||||
amount: `$${Number(sentPO.amount).toFixed(2)}`,
|
||||
dueDate: sentPO.due_date ? new Date(sentPO.due_date).toLocaleDateString() : 'Not set',
|
||||
terms: sentPO.terms || 'Net 15',
|
||||
scope: sentPO.items?.length
|
||||
? sentPO.items.map(item => `${item.description} — $${Number(item.amount).toFixed(2)}`).join('\n')
|
||||
: sentPO.description,
|
||||
portalUrl: 'https://portal.fourgebranding.com/my-purchase-orders',
|
||||
});
|
||||
} catch (emailError) {
|
||||
console.error('Failed to email subcontractor PO:', emailError);
|
||||
alert(`PO was marked sent, but the email failed: ${emailError.message || 'unknown error'}`);
|
||||
}
|
||||
await updateSubcontractorPO(po, { status: 'sent', sent_at: new Date().toISOString() }, 'Failed to send PO');
|
||||
};
|
||||
|
||||
const handleReadyToPaySubcontractorPO = (po) => {
|
||||
@@ -490,6 +665,56 @@ export default function Invoices() {
|
||||
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');
|
||||
};
|
||||
@@ -622,7 +847,7 @@ export default function Invoices() {
|
||||
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<Layout loading={pageLoading}>
|
||||
<div style={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
|
||||
{/* Top row: 60% chart + 40% stat cards */}
|
||||
{(() => {
|
||||
@@ -653,7 +878,7 @@ export default function Invoices() {
|
||||
<div style={{ ...CARD }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 16 }}>
|
||||
<span style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase' }}>Revenue Overview</span>
|
||||
<select className="filter-select" value={chartYear} onChange={e => setChartYear(Number(e.target.value))} style={{ width: 90, borderRadius: 8, fontSize: 11, fontWeight: 500 }}>
|
||||
<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>
|
||||
@@ -694,7 +919,6 @@ export default function Invoices() {
|
||||
{ id: 'expenses', label: 'Expenses' },
|
||||
{ id: 'subcontractors', label: 'Subcontractors' },
|
||||
{ id: 'invoices', label: 'Invoices' },
|
||||
{ id: 'legacy', label: 'Legacy' },
|
||||
].map(t => (
|
||||
<button
|
||||
key={t.id}
|
||||
@@ -1075,7 +1299,7 @@ export default function Invoices() {
|
||||
return (
|
||||
<tr key={inv.id}>
|
||||
<td style={{ ...TD }}>
|
||||
<button type="button" className="table-link" onClick={() => navigate(`/sub-invoices/${inv.id}`)}>
|
||||
<button type="button" className="table-link" onClick={() => setViewingSubInvoice(inv)}>
|
||||
{inv.invoice_number || '—'}
|
||||
</button>
|
||||
</td>
|
||||
@@ -1195,7 +1419,7 @@ export default function Invoices() {
|
||||
<tbody>{sorted.map(inv => (
|
||||
<tr key={inv.id}>
|
||||
<td style={{ ...TD }}>
|
||||
<button type="button" className="dashboard-inline-link" onClick={() => navigate(`/invoices/${inv.id}`)}>{inv.invoice_number}</button>
|
||||
<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>
|
||||
@@ -1252,24 +1476,22 @@ export default function Invoices() {
|
||||
})()}
|
||||
|
||||
{financeTab === 'legacy' && <div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 0, marginBottom: 16 }}>
|
||||
<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) => (
|
||||
<span key={t.id} style={{ display: 'flex', alignItems: 'center' }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveTab(t.id)}
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 0, fontSize: 13, letterSpacing: 0.5, fontWeight: activeTab === t.id ? 600 : 400, color: activeTab === t.id ? 'var(--text-primary)' : 'var(--text-muted)', fontFamily: 'inherit' }}
|
||||
>{t.label}</button>
|
||||
{i < arr.length - 1 && <span style={{ margin: '0 10px', color: 'var(--border)', userSelect: 'none' }}>|</span>}
|
||||
</span>
|
||||
<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: 16, flexWrap: 'wrap' }}>
|
||||
<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)}>
|
||||
@@ -1310,10 +1532,10 @@ export default function Invoices() {
|
||||
{loading ? (
|
||||
<p style={{ color: 'var(--text-muted)' }}>Loading...</p>
|
||||
) : filtered.length === 0 ? (
|
||||
<p style={{ color: 'var(--text-muted)', fontSize: 13 }}>No invoices.</p>
|
||||
<div className="card card-empty-center">No invoices</div>
|
||||
) : (
|
||||
<div style={{ background: 'var(--card-bg)', borderRadius: 4, border: '1px solid var(--border)' }}>
|
||||
<table>
|
||||
<div className="table-wrapper">
|
||||
<table className="table-sticky-head">
|
||||
<thead>
|
||||
<tr>
|
||||
<SortTh col="invoice_number" sortKey={invSortKey} sortDir={invSortDir} onSort={invToggle}>Invoice #</SortTh>
|
||||
@@ -1330,7 +1552,7 @@ export default function Invoices() {
|
||||
if (key === 'invoice_date' || key === 'due_date') return new Date(inv[key]).getTime();
|
||||
return inv[key] || inv.company?.name || '';
|
||||
}).map(inv => (
|
||||
<tr key={inv.id} onClick={() => navigate(`/invoices/${inv.id}`)} style={{ cursor: 'pointer' }}>
|
||||
<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>
|
||||
@@ -1359,17 +1581,17 @@ export default function Invoices() {
|
||||
{activeTab === 'expenses' && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 24 }}>
|
||||
<div>
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<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 ? (
|
||||
<p style={{ color: 'var(--text-muted)', fontSize: 13 }}>No expenses yet.</p>
|
||||
<div className="card card-empty-center">No expenses</div>
|
||||
) : (
|
||||
<div style={{ background: 'var(--card-bg)', borderRadius: 4, border: '1px solid var(--border)' }}>
|
||||
<table>
|
||||
<div className="table-wrapper">
|
||||
<table className="table-sticky-head">
|
||||
<thead>
|
||||
<tr>
|
||||
<SortTh col="date" sortKey={expSortKey} sortDir={expSortDir} onSort={expToggle}>Date</SortTh>
|
||||
@@ -1431,16 +1653,16 @@ export default function Invoices() {
|
||||
|
||||
{activeTab === 'sub-invoices' && (
|
||||
<div>
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<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 ? (
|
||||
<p style={{ color: 'var(--text-muted)', fontSize: 13 }}>No sub invoices yet.</p>
|
||||
<div className="card card-empty-center">No sub invoices</div>
|
||||
) : (
|
||||
<div style={{ background: 'var(--card-bg)', borderRadius: 4, border: '1px solid var(--border)' }}>
|
||||
<table>
|
||||
<div className="table-wrapper">
|
||||
<table className="table-sticky-head">
|
||||
<thead>
|
||||
<tr>
|
||||
<SortTh col="invoice_number" sortKey={subSortKey} sortDir={subSortDir} onSort={subToggle}>Invoice #</SortTh>
|
||||
@@ -1461,7 +1683,7 @@ export default function Invoices() {
|
||||
const total = (inv.items || []).reduce((s, i) => s + Number(i.unit_price || 0) * Number(i.quantity || 1), 0);
|
||||
const subInvoiceStatusColor = { draft: 'not_started', submitted: 'in_progress', paid: 'client_approved' };
|
||||
return (
|
||||
<tr key={inv.id} style={{ cursor: 'pointer' }} onClick={() => navigate(`/sub-invoices/${inv.id}`)}>
|
||||
<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>
|
||||
@@ -1488,9 +1710,139 @@ export default function Invoices() {
|
||||
)}
|
||||
</div>}{/* end legacy wrapper */}
|
||||
|
||||
{viewingSubInvoice && (() => {
|
||||
const invoice = viewingSubInvoice;
|
||||
const sortedItems = [...(invoice.items || [])].sort((a, b) => (a.sort_order ?? 0) - (b.sort_order ?? 0));
|
||||
const total = sortedItems.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' };
|
||||
return (
|
||||
<div style={popupOverlayStyle} onClick={() => { if (!markingPaid) setViewingSubInvoice(null); }}>
|
||||
<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', alignItems: 'flex-start', justifyContent: 'space-between', gap: 16, paddingBottom: 18, borderBottom: '1px solid var(--border)' }}>
|
||||
<div>
|
||||
<div style={{ fontSize: 18, fontWeight: 500, color: 'var(--text-primary)', letterSpacing: 0.2, lineHeight: 1.1 }}>{invoice.invoice_number || 'Subcontractor Invoice'}</div>
|
||||
<div style={{ marginTop: 6, fontSize: 13, color: 'var(--text-secondary)' }}>
|
||||
{invoice.profile?.name || 'External'}{invoice.profile?.email ? ` · ${invoice.profile.email}` : ''}
|
||||
</div>
|
||||
</div>
|
||||
<StatusBadge status={subInvoiceStatusColor[invoice.status] || 'not_started'} label={invoiceStatus} />
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr 1fr', gap: 18, padding: '18px 0', borderBottom: '1px solid var(--border)' }}>
|
||||
<div>
|
||||
<div style={FIELD_LABEL_STYLE}>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={FIELD_LABEL_STYLE}>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={FIELD_LABEL_STYLE}>Line Items</div>
|
||||
<div style={{ fontSize: 13, color: 'var(--text-primary)' }}>{sortedItems.length}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style={FIELD_LABEL_STYLE}>Total</div>
|
||||
<div style={{ fontSize: 18, fontWeight: 500, color: 'var(--accent)', lineHeight: 1.1 }}>${total.toFixed(2)}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="scrollbar-thin-theme table-scroll-hidden" style={{ flex: 1, minHeight: 0, overflowY: 'auto', paddingTop: 18 }}>
|
||||
{sortedItems.length === 0 ? (
|
||||
<div className="card-empty-center">No line items</div>
|
||||
) : (
|
||||
<table className="table-sticky-head" style={{ width: '100%', borderCollapse: 'collapse', tableLayout: 'fixed' }}>
|
||||
<colgroup>
|
||||
<col style={{ width: '8%' }} />
|
||||
<col style={{ width: '10%' }} />
|
||||
<col style={{ width: '32%' }} />
|
||||
<col style={{ width: '16%' }} />
|
||||
<col style={{ width: '10%' }} />
|
||||
<col style={{ width: '12%' }} />
|
||||
<col style={{ width: '12%' }} />
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ textAlign: 'center' }}>Work</th>
|
||||
<th style={{ textAlign: 'center' }}>R#</th>
|
||||
<th style={{ textAlign: 'left' }}>Description</th>
|
||||
<th style={{ textAlign: 'center' }}>Type</th>
|
||||
<th style={{ textAlign: 'center' }}>Qty</th>
|
||||
<th style={{ textAlign: 'center' }}>Unit Price</th>
|
||||
<th style={{ textAlign: 'center' }}>Amount</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sortedItems.map(item => (
|
||||
<tr key={item.id}>
|
||||
<td style={{ padding: '5px 0', fontSize: 13, color: 'var(--text-primary)', textAlign: 'center' }}>{subInvoiceItemWorkLabel(item)}</td>
|
||||
<td style={{ padding: '5px 0', fontSize: 13, color: 'var(--text-primary)', textAlign: 'center' }}>{subInvoiceItemVersionLabel(item)}</td>
|
||||
<td style={{ padding: '5px 0', fontSize: 13, color: 'var(--text-primary)' }}>{cleanSubInvoiceItemDescription(item)}</td>
|
||||
<td style={{ padding: '5px 0', fontSize: 13, color: 'var(--text-primary)', textAlign: 'center' }}>{viewingSubInvoiceTaskTypeMap[item.task_id] || 'Other'}</td>
|
||||
<td style={{ padding: '5px 0', textAlign: 'center', fontSize: 13, color: 'var(--text-primary)' }}>{item.quantity || 1}</td>
|
||||
<td style={{ padding: '5px 0', textAlign: 'center', fontSize: 13, color: 'var(--text-primary)' }}>${Number(item.unit_price || 0).toFixed(2)}</td>
|
||||
<td style={{ padding: '5px 0', textAlign: 'center', fontSize: 13, color: 'var(--text-primary)' }}>${(Number(item.unit_price || 0) * Number(item.quantity || 1)).toFixed(2)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
|
||||
{invoice.notes && (
|
||||
<div style={{ marginTop: 18, padding: '12px 14px', background: 'var(--card-bg-2)', borderRadius: 6, border: '1px solid var(--border)' }}>
|
||||
<div style={FIELD_LABEL_STYLE}>Notes</div>
|
||||
<div style={{ fontSize: 13, color: 'var(--text-secondary)', whiteSpace: 'pre-wrap' }}>{invoice.notes}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="modal-action-row" style={{ paddingTop: 18, borderTop: '1px solid var(--border)' }}>
|
||||
<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)}>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
{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 = { fontSize: 11, fontWeight: 500, textTransform: 'uppercase', letterSpacing: 0.8, color: 'var(--text-secondary)', marginBottom: 3 };
|
||||
const INPUT = { fontSize: 13, color: 'var(--text-primary)', background: 'var(--card-bg-2)', border: '1px solid var(--border)', borderRadius: 6, padding: '6px 10px', width: '100%', fontFamily: 'inherit', boxSizing: 'border-box' };
|
||||
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 ?? '') ||
|
||||
@@ -1510,9 +1862,13 @@ export default function Invoices() {
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 14, flex: '0 0 260px', overflowY: 'auto' }}>
|
||||
<div style={LABEL}>Expense Detail</div>
|
||||
{expenseDetailEditing ? (
|
||||
<input type="number" step="0.01" min="0" required style={{ ...INPUT, fontSize: 30, fontWeight: 400, letterSpacing: -0.5, lineHeight: 1.1, color: '#ef4444', fontVariantNumeric: 'tabular-nums', padding: '0 6px' }} value={newExpense.amount} onChange={e => setNewExpense(p => ({ ...p, amount: e.target.value }))} />
|
||||
<CurrencyInput
|
||||
value={newExpense.amount}
|
||||
required
|
||||
onChange={e => setNewExpense(p => ({ ...p, amount: e.target.value }))}
|
||||
/>
|
||||
) : (
|
||||
<div style={{ fontSize: 30, fontWeight: 400, letterSpacing: -0.5, lineHeight: 1.1, color: '#ef4444', fontVariantNumeric: 'tabular-nums' }}>
|
||||
<div style={{ ...FINANCE_MODAL_TOTAL_VALUE_STYLE, color: 'var(--danger)' }}>
|
||||
${Number(viewingExpense.amount).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
|
||||
</div>
|
||||
)}
|
||||
@@ -1568,7 +1924,7 @@ export default function Invoices() {
|
||||
)}
|
||||
</div>
|
||||
{/* Footer: buttons bottom-right */}
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8, paddingTop: 14, marginTop: 14, flexShrink: 0 }}>
|
||||
<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>
|
||||
@@ -1590,8 +1946,8 @@ export default function Invoices() {
|
||||
})()}
|
||||
|
||||
{showExpenseForm && (() => {
|
||||
const LABEL = { fontSize: 11, fontWeight: 500, textTransform: 'uppercase', letterSpacing: 0.8, color: 'var(--text-secondary)', marginBottom: 3 };
|
||||
const INPUT = { fontSize: 13, color: 'var(--text-primary)', background: 'var(--card-bg-2)', border: '1px solid var(--border)', borderRadius: 6, padding: '6px 10px', width: '100%', fontFamily: 'inherit', boxSizing: 'border-box' };
|
||||
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()}>
|
||||
@@ -1601,7 +1957,11 @@ export default function Invoices() {
|
||||
{/* Left: fields */}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 14, flex: '0 0 260px', overflowY: 'auto' }}>
|
||||
<div style={LABEL}>New Expense</div>
|
||||
<input type="number" step="0.01" min="0" required placeholder="0.00" style={{ ...INPUT, fontSize: 30, fontWeight: 400, letterSpacing: -0.5, lineHeight: 1.1, color: '#ef4444', fontVariantNumeric: 'tabular-nums', padding: '0 6px' }} value={newExpense.amount} onChange={e => setNewExpense(p => ({ ...p, amount: e.target.value }))} />
|
||||
<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 }))} />
|
||||
@@ -1629,7 +1989,7 @@ export default function Invoices() {
|
||||
</div>
|
||||
</div>
|
||||
{/* Footer */}
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8, paddingTop: 14, marginTop: 14, flexShrink: 0 }}>
|
||||
<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>
|
||||
@@ -1642,8 +2002,8 @@ export default function Invoices() {
|
||||
</div>{/* end flex column wrapper */}
|
||||
|
||||
{showInvoiceForm && (() => {
|
||||
const LABEL = { fontSize: 11, fontWeight: 500, textTransform: 'uppercase', letterSpacing: 0.8, color: 'var(--text-secondary)', marginBottom: 3 };
|
||||
const INPUT = { fontSize: 13, color: 'var(--text-primary)', background: 'var(--card-bg-2)', border: '1px solid var(--border)', borderRadius: 6, padding: '6px 10px', width: '100%', fontFamily: 'inherit', boxSizing: 'border-box' };
|
||||
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()}>
|
||||
@@ -1672,11 +2032,11 @@ export default function Invoices() {
|
||||
</>}
|
||||
<div>
|
||||
<div style={LABEL}>Notes</div>
|
||||
<textarea style={{ ...INPUT, minHeight: 72, resize: 'vertical' }} value={invNotes} onChange={e => setInvNotes(e.target.value)} placeholder="Payment terms, notes…" />
|
||||
<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={{ fontSize: 28, fontWeight: 400, color: 'var(--accent)', fontVariantNumeric: 'tabular-nums' }}>${invTotal.toFixed(2)}</div>
|
||||
<div style={FINANCE_MODAL_TOTAL_VALUE_STYLE}>${invTotal.toFixed(2)}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1697,7 +2057,10 @@ export default function Invoices() {
|
||||
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={{ fontSize: 12 }}>{t.project?.name} • {t.title || t.service_type}</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>
|
||||
@@ -1715,9 +2078,20 @@ export default function Invoices() {
|
||||
</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 style={{ fontSize: 12 }}>{r.task?.project?.name} • {r.task?.title} • R{String(r.version_number || 0).padStart(2, '0')}</div>
|
||||
<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>
|
||||
);
|
||||
@@ -1727,16 +2101,19 @@ export default function Invoices() {
|
||||
{/* Line items */}
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={LABEL}>Line Items</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '20px 1fr 60px 100px 90px 28px', gap: 6, marginBottom: 6 }}>
|
||||
{['', '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 > 2 ? 'right' : 'left' }}>{h}</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 1fr 60px 100px 90px 28px', gap: 6, alignItems: 'center', marginBottom: 4 }}>
|
||||
<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>
|
||||
<input style={{ ...INPUT, padding: '4px 8px' }} type="text" placeholder="Description…" value={item.description} onChange={e => invUpdateItem(item.id, 'description', e.target.value)} />
|
||||
<input style={{ ...INPUT, padding: '4px 6px', textAlign: 'center' }} type="number" min="1" value={item.quantity} onChange={e => invUpdateItem(item.id, 'quantity', e.target.value)} />
|
||||
<input style={{ ...INPUT, padding: '4px 8px', 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={{ textAlign: 'right', fontSize: 13, color: 'var(--text-primary)', paddingRight: 2 }}>${((Number(item.quantity) || 0) * (Number(item.unit_price) || 0)).toFixed(2)}</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>
|
||||
))}
|
||||
@@ -1746,7 +2123,7 @@ export default function Invoices() {
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8, paddingTop: 14, marginTop: 14, flexShrink: 0 }}>
|
||||
<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>
|
||||
|
||||
Reference in New Issue
Block a user