feat: expense/invoice popups, task row hover fix, delivery submissions

- Expense detail popup: 80vw×80vh, inline editing, save gated on dirty state, Download Receipt in footer
- Add expense form: same layout with FileAttachment drag-drop on right
- New invoice popup: 80vw×80vh inline form replacing navigate-away page
- Tasks table: suppress row hover background, only Name/Project table-links highlight
- TaskDetail Submissions tab: reads from deliveries table, shows R## version, sent_by, message
- Submit for Review: writes to deliveries + delivery_files (deliveries bucket)
- deliveries.version_number column added and backfilled from revision history
- Port 4173 for dev server

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Krao Hasanee
2026-06-02 21:41:02 -04:00
parent 65f10036d8
commit 96e90561b9
11 changed files with 693 additions and 165 deletions
+33 -18
View File
@@ -229,6 +229,7 @@ export default function TaskDetail() {
const [comments, setComments] = useState([]);
const [commentBody, setCommentBody] = useState('');
const [commentSaving, setCommentSaving] = useState(false);
const [deliveries, setDeliveries] = useState([]);
const [dlProgress, setDlProgress] = useState({ active: false, label: '', percent: 0 });
useEffect(() => {
@@ -240,7 +241,7 @@ export default function TaskDetail() {
.single(),
supabase
.from('submissions')
.select('id, type, version_number, submitted_by, submitted_by_name, submitted_at, service_type, deadline, description, is_hot, sign_family, sign_count, files:submission_files(id, name, storage_path, size)')
.select('id, type, version_number, request_key, submitted_by, submitted_by_name, submitted_at, service_type, deadline, description, is_hot, sign_family, sign_count, files:submission_files(id, name, storage_path, size)')
.eq('task_id', id)
.order('submitted_at', { ascending: true }),
supabase
@@ -254,11 +255,20 @@ export default function TaskDetail() {
.select('id, author_id, author_name, body, created_at')
.eq('task_id', id)
.order('created_at', { ascending: true }),
]).then(([{ data: taskData }, { data: subRows }, { data: actData }, { data: commentData }]) => {
]).then(async ([{ data: taskData }, { data: subRows }, { data: actData }, { data: commentData }]) => {
setTask(taskData);
setSubmissions(subRows || []);
setActivityLog(actData || []);
setComments(commentData || []);
const subIds = (subRows || []).map(s => s.id);
if (subIds.length > 0) {
const { data: delRows } = await supabase
.from('deliveries')
.select('*, files:delivery_files(id, name, storage_path, size)')
.in('submission_id', subIds)
.order('sent_at', { ascending: false });
setDeliveries(delRows || []);
}
setLoading(false);
});
}, [id, refreshKey]);
@@ -269,7 +279,7 @@ export default function TaskDetail() {
const submission = submissions.find(s => s.type === 'initial') || submissions[0] || null;
const amendments = submissions.filter(s => s.type === 'amendment');
const revisions = submissions.filter(s => s.type === 'revision').sort((a, b) => a.version_number - b.version_number);
const reviewSubmissions = submissions.filter(s => s.type === 'initial').sort((a, b) => new Date(b.submitted_at) - new Date(a.submitted_at));
const reviewSubmissions = deliveries;
const assignee = task.assignee;
const assigneeName = assignee?.name || task.assigned_name || null;
@@ -304,19 +314,20 @@ export default function TaskDetail() {
const handleConfirmReview = async () => {
setReviewSaving(true);
try {
const { data: newSub } = await supabase.from('submissions').insert({ task_id: id, type: 'initial', version_number: task.current_version || 0, service_type: task.title, description: reviewNotes.trim() || null, submitted_by: currentUser.id, submitted_by_name: currentUser.name }).select().single();
if (newSub && reviewFiles.length > 0) {
const briefId = submissions.find(s => s.type === 'initial')?.id || submissions[0]?.id;
const { data: newDel } = await supabase.from('deliveries').insert({ submission_id: briefId, sent_by: currentUser.name, message: reviewNotes.trim() || '', version_number: task.current_version || 0 }).select().single();
if (newDel && reviewFiles.length > 0) {
const uploaded = [];
for (const file of reviewFiles) {
const path = `${id}/${Date.now()}_${file.name}`;
const { data: up } = await supabase.storage.from('submissions').upload(path, file);
if (up) uploaded.push({ name: file.name, storage_path: path, size: file.size, submission_id: newSub.id });
const { data: up } = await supabase.storage.from('deliveries').upload(path, file);
if (up) uploaded.push({ name: file.name, storage_path: path, size: file.size, delivery_id: newDel.id });
}
if (uploaded.length > 0) await supabase.from('submission_files').insert(uploaded);
if (project?.name && task?.title) uploadFilesToRequestInfo(reviewFiles, company?.name, project.name, task.title, task.current_version || 0).catch(() => {});
if (uploaded.length > 0) await supabase.from('delivery_files').insert(uploaded);
}
const { data: subRows } = await supabase.from('submissions').select('id, type, version_number, submitted_by, submitted_by_name, submitted_at, service_type, deadline, description, is_hot, sign_family, sign_count, files:submission_files(id, name, storage_path, size)').eq('task_id', id).order('submitted_at', { ascending: true });
setSubmissions(subRows || []);
const subIds = submissions.map(s => s.id);
const { data: delRows } = await supabase.from('deliveries').select('*, files:delivery_files(id, name, storage_path, size)').in('submission_id', subIds).order('sent_at', { ascending: false });
setDeliveries(delRows || []);
await updateStatus('client_review', {}, 'task_submitted');
setReviewModal(false);
setReviewFiles([]);
@@ -655,25 +666,29 @@ export default function TaskDetail() {
? <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', flex: 1, minHeight: 120, color: 'var(--text-muted)' }}>No submissions yet.</div>
: <div style={{ display: 'flex', flexDirection: 'column', gap: 20 }}>
{reviewSubmissions.map((sub, i, arr) => {
const fmtDate = sub.submitted_at
? new Date(sub.submitted_at).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
const fmtDate = sub.sent_at
? new Date(sub.sent_at).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
: '—';
return (
<div key={sub.id} style={{ display: 'flex', flexDirection: 'column', gap: 20, borderBottom: i < arr.length - 1 ? '1px solid var(--border)' : 'none', paddingBottom: i < arr.length - 1 ? 20 : 0 }}>
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 32 }}>
<div>
<div style={META_LABEL}>Submitted By</div>
<div style={{ fontSize: 13, color: 'var(--text-primary)' }}>{sub.submitted_by_name || '—'}</div>
<div style={{ fontSize: 13, color: 'var(--text-primary)' }}>{sub.sent_by || '—'}</div>
</div>
<div>
<div style={META_LABEL}>Date</div>
<div style={{ fontSize: 13, color: 'var(--text-primary)' }}>{fmtDate}</div>
</div>
<div style={{ marginLeft: 'auto' }}>
<div style={META_LABEL}>Version</div>
<div style={{ fontSize: 13, color: 'var(--text-primary)', fontWeight: 500 }}>{`R${String(sub.version_number ?? 0).padStart(2, '0')}`}</div>
</div>
</div>
{sub.description
{sub.message
? <div>
<div style={META_LABEL}>Notes</div>
<div style={{ fontSize: 13, color: 'var(--text-primary)', lineHeight: 1.6, whiteSpace: 'pre-wrap' }}>{sub.description}</div>
<div style={{ fontSize: 13, color: 'var(--text-primary)', lineHeight: 1.6, whiteSpace: 'pre-wrap' }}>{sub.message}</div>
</div>
: <div style={{ fontSize: 13, color: 'var(--text-muted)' }}>No notes provided.</div>
}
@@ -817,8 +832,8 @@ export default function TaskDetail() {
)}
{revisionModal && (
<div style={{ position: 'fixed', inset: 0, background: 'var(--overlay-scrim, rgba(0,0,0,0.58))', zIndex: 1200, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 24 }}>
<div className="card" style={{ ...CARD, width: '100%', maxWidth: 480, display: 'flex', flexDirection: 'column', gap: 18 }}>
<div style={{ position: 'fixed', inset: 0, background: 'var(--overlay-scrim, rgba(0,0,0,0.58))', zIndex: 1200, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 24 }} onClick={() => { if (!revisionSaving) { setRevisionModal(false); setRevisionForm({ description: '', deadline: '' }); setRevisionFiles([]); } }}>
<div className="card" style={{ ...CARD, width: '100%', maxWidth: 480, display: 'flex', flexDirection: 'column', gap: 18 }} onClick={e => e.stopPropagation()}>
<div style={{ fontSize: 18, fontWeight: 500, color: 'var(--text-primary)', letterSpacing: 0.2, lineHeight: 1.1 }}>Request Revision</div>
<div className="form-group">
<label style={{ fontSize: 11, fontWeight: 500, textTransform: 'uppercase', letterSpacing: 0.8, color: 'var(--text-secondary)' }}>Revision Notes</label>
+6 -6
View File
@@ -521,7 +521,7 @@ export default function RequestsPage() {
return (
<tr key={row.id}>
<td style={{ ...TASK_TABLE_TD_BASE, fontSize: 12, color: 'var(--text-primary)', fontVariantNumeric: 'tabular-nums' }}>
<Link to={`/tasks/${row.id}`} className="table-link">{`R${String(row.version).padStart(2, '0')}`}</Link>
{`R${String(row.version).padStart(2, '0')}`}
</td>
<td style={{ ...TASK_TABLE_TD_BASE, fontSize: 13, color: 'var(--text-primary)' }}>
<Link to={`/tasks/${row.id}`} className="table-link">{row.title || '—'}</Link>
@@ -541,14 +541,14 @@ export default function RequestsPage() {
return <div title="Unassigned" style={{ ...avatarStyle, background: 'rgba(255,255,255,0.08)' }}><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="rgba(255,255,255,0.3)" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="8" r="4"/><path d="M4 20c0-4 3.6-7 8-7s8 3 8 7"/></svg></div>;
})()}
</td>
<td style={{ ...TASK_TABLE_TD_BASE, fontSize: 12, textAlign: 'center' }}>
<Link to={`/tasks/${row.id}`} className="table-link" style={{ color: row.isHot ? '#ef4444' : 'var(--text-primary)', textTransform: 'uppercase', fontSize: 11, letterSpacing: 0.5 }}>{row.isHot ? 'HOT' : 'NO'}</Link>
<td style={{ ...TASK_TABLE_TD_BASE, fontSize: 11, textAlign: 'center', color: row.isHot ? '#ef4444' : 'var(--text-primary)', textTransform: 'uppercase', letterSpacing: 0.5 }}>
{row.isHot ? 'HOT' : 'NO'}
</td>
<td style={{ ...TASK_TABLE_TD_BASE, fontSize: 12, color: 'var(--text-primary)', textAlign: 'center' }}>
<Link to={`/tasks/${row.id}`} className="table-link">{row.serviceType}</Link>
{row.serviceType}
</td>
<td style={{ ...TASK_TABLE_TD_BASE, fontSize: 12, color: 'var(--text-primary)', textAlign: 'center' }}>
<Link to={`/tasks/${row.id}`} className="table-link">{fmtShortDate(row.deadline, 'Not specified')}</Link>
{fmtShortDate(row.deadline, 'Not specified')}
</td>
</tr>
);
@@ -697,7 +697,7 @@ export default function RequestsPage() {
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 13, color: 'var(--text-muted)' }}>No tasks.</div>
) : (
<div className="scrollbar-thin-theme table-scroll-fade table-scroll-hidden" style={{ flex: 1, minHeight: 0, overflowY: 'auto' }}>
<table className="table-sticky-head" style={{ tableLayout: 'fixed', borderCollapse: 'collapse', width: '100%' }}>
<table className="table-sticky-head table-no-row-hover" style={{ tableLayout: 'fixed', borderCollapse: 'collapse', width: '100%' }}>
<colgroup>
<col style={{ width: '5%' }} />
<col style={{ width: '25%' }} />
+371 -141
View File
@@ -8,9 +8,12 @@ import StatusBadge from '../../components/StatusBadge';
import { useSortable } from '../../hooks/useSortable';
import { supabase } from '../../lib/supabase';
import { readPageCache, writePageCache } from '../../lib/pageCache';
import { exportCPAPackage, generateSubcontractorPOPDF } from '../../lib/invoice';
import { 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';
const CATEGORIES = ['Software', 'Contractor', 'Advertising', 'Office', 'Travel', 'Meals', 'Equipment', 'Other'];
const statusColor = { draft: 'not_started', sent: 'in_progress', paid: 'client_approved' };
@@ -38,6 +41,11 @@ 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 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 blankExpense = () => ({
date: new Date().toISOString().slice(0, 10),
description: '',
@@ -120,6 +128,116 @@ export default function Invoices() {
const [markingPaid, setMarkingPaid] = useState('');
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);
useEffect(() => {
supabase.from('companies').select('id, name, contact_email').order('name').then(({ data }) => setInvCompanies(data || []));
}, []);
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: 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 revMap = new Map();
for (const r of (revisions || [])) { 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 = `${task.project?.name || 'No Project'}${task.title || task.service_type || 'Untitled'}`;
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}`;
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 {}
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]);
invClose();
navigate(`/invoices/${invoice.id}`);
} catch (e) { alert(`Failed to save invoice: ${e.message || 'Unknown error'}`); }
setInvSaving(false);
};
useEffect(() => {
async function load() {
const { data } = await supabase
@@ -199,22 +317,6 @@ export default function Invoices() {
loadSubInvoices();
}, []);
const startEditExpense = (expense) => {
setEditingExpenseId(expense.id);
setNewExpense({
date: expense.date,
description: expense.description || '',
category: expense.category || 'Other',
amount: String(expense.amount ?? ''),
notes: expense.notes || '',
receipt: null,
receipt_path: expense.receipt_path || null,
receipt_name: expense.receipt_name || null,
removeReceipt: false,
});
setExpensesError('');
setShowExpenseForm(true);
};
const cancelExpenseEdit = () => {
setEditingExpenseId('');
@@ -621,7 +723,7 @@ export default function Invoices() {
)}
{financeTab === 'invoices' && (
<div style={{ marginLeft: 'auto', display: 'flex', alignItems: 'center', gap: 8 }}>
<button className="btn btn-outline" onClick={() => navigate('/invoices/new')}>+ Invoice</button>
<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>
@@ -1184,7 +1286,7 @@ export default function Invoices() {
<option value="sent">Sent</option>
<option value="paid">Paid</option>
</select>
<button className="btn btn-primary btn-sm" onClick={() => navigate('/invoices/new')}>+ New Invoice</button>
<button className="btn btn-primary btn-sm" onClick={() => setShowInvoiceForm(true)}>+ New Invoice</button>
</div>
)}
{activeTab === 'expenses' && (
@@ -1284,7 +1386,7 @@ export default function Invoices() {
if (key === 'date') return exp.date || '';
return exp[key] || '';
}).map(exp => (
<tr key={exp.id} style={{ cursor: 'pointer' }} onClick={() => startEditExpense(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>
@@ -1390,142 +1492,270 @@ export default function Invoices() {
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 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={() => { if (!expenseDetailEditing) setViewingExpense(null); }}>
<div style={{ ...popupSurfaceStyle, background: 'var(--card-bg)', width: viewingExpense.receipt_path ? 'min(680px, 96vw)' : 'min(500px, 96vw)', maxHeight: 'calc(100vh - 48px)', display: 'flex', gap: 24, alignItems: 'stretch' }} onClick={e => e.stopPropagation()}>
{/* Left: details / edit */}
<div style={{ display: 'flex', flexDirection: 'column', gap: 14, flex: '0 0 220px', minHeight: 560, overflowY: 'auto' }}>
<div style={LABEL}>Expense Detail</div>
{expenseDetailEditing ? (
<input type="number" step="0.01" min="0" required style={{ ...INPUT, fontSize: 22, fontWeight: 500, color: '#ef4444' }} value={newExpense.amount} 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' }}>
${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 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 ? (
<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 }))} />
) : (
<div style={{ fontSize: 30, fontWeight: 400, letterSpacing: -0.5, lineHeight: 1.1, color: '#ef4444', fontVariantNumeric: 'tabular-nums' }}>
${Number(viewingExpense.amount).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
</div>
)}
<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 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 style={{ display: 'flex', gap: 8, marginTop: 'auto', paddingTop: 4 }}>
{expenseDetailEditing ? (
<>
<button className="btn btn-outline" disabled={addingExpense} onClick={async () => { await saveExpense(); setExpenseDetailEditing(false); setViewingExpense(null); }}>{addingExpense ? 'Saving…' : 'Save'}</button>
<button className="btn btn-outline" onClick={() => setExpenseDetailEditing(false)}>Cancel</button>
</>
) : (
<>
<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)}>Close</button>
</>
)}
</div>
</div>
{/* Right: receipt preview — same height as left column */}
{viewingExpense.receipt_path && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, flex: 1, minHeight: 0, overflow: 'hidden' }}>
<div style={LABEL}>Receipt</div>
{expensePreviewUrl ? (
isPdf
? <embed src={`${expensePreviewUrl}#toolbar=0&navpanes=0&scrollbar=0`} type="application/pdf" style={{ flex: 1, width: '100%', border: '1px solid var(--border)', borderRadius: 6 }} />
: <img src={expensePreviewUrl} alt="Receipt" style={{ flex: 1, width: '100%', minHeight: 0, objectFit: 'contain', objectPosition: 'top', 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>
)}
{expensePreviewUrl && (
<a href={expensePreviewUrl} download={viewingExpense.receipt_name || 'receipt'} className="btn btn-outline" style={{ display: 'inline-flex', alignSelf: 'flex-start' }}>Download Receipt</a>
)}
</div>
)}
{/* Footer: buttons bottom-right */}
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8, 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 && (
<div style={popupOverlayStyle} onClick={cancelExpenseEdit}>
<div style={{ ...popupSurfaceStyle, background: 'var(--card-bg)', width: 'min(520px, 96vw)', maxHeight: 'calc(100vh - 48px)', overflowY: 'auto', display: 'flex', flexDirection: 'column', gap: 16 }} onClick={e => e.stopPropagation()}>
<div style={{ fontSize: 11, fontWeight: 500, textTransform: 'uppercase', letterSpacing: 0.8, color: 'var(--text-secondary)' }}>{editingExpenseId ? 'Edit Expense' : 'New Expense'}</div>
<form onSubmit={handleAddExpense} style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 14 }}>
<div>
<div style={FIELD_LABEL_STYLE}>Date *</div>
<input type="date" required value={newExpense.date} onChange={e => setNewExpense(p => ({ ...p, date: e.target.value }))} style={{ fontSize: 13, color: 'var(--text-primary)', background: 'var(--card-bg-2)', border: '1px solid var(--border)', borderRadius: 6, padding: '6px 10px', width: '100%', fontFamily: 'inherit', boxSizing: 'border-box' }} />
</div>
<div>
<div style={FIELD_LABEL_STYLE}>Amount (USD) *</div>
<input type="number" step="0.01" min="0" required placeholder="0.00" value={newExpense.amount} onChange={e => setNewExpense(p => ({ ...p, amount: e.target.value }))} style={{ fontSize: 13, color: 'var(--text-primary)', background: 'var(--card-bg-2)', border: '1px solid var(--border)', borderRadius: 6, padding: '6px 10px', width: '100%', fontFamily: 'inherit', boxSizing: 'border-box' }} />
</div>
</div>
<div>
<div style={FIELD_LABEL_STYLE}>Description *</div>
<input type="text" required placeholder="What was this for?" value={newExpense.description} onChange={e => setNewExpense(p => ({ ...p, description: e.target.value }))} style={{ fontSize: 13, color: 'var(--text-primary)', background: 'var(--card-bg-2)', border: '1px solid var(--border)', borderRadius: 6, padding: '6px 10px', width: '100%', fontFamily: 'inherit', boxSizing: 'border-box' }} />
</div>
<div>
<div style={FIELD_LABEL_STYLE}>Category</div>
<select value={newExpense.category} onChange={e => setNewExpense(p => ({ ...p, category: e.target.value }))} style={{ fontSize: 13, color: 'var(--text-primary)', background: 'var(--card-bg-2)', border: '1px solid var(--border)', borderRadius: 6, padding: '6px 10px', width: '100%', fontFamily: 'inherit', boxSizing: 'border-box' }}>
{CATEGORIES.map(c => <option key={c} value={c}>{c}</option>)}
</select>
</div>
<div>
<div style={FIELD_LABEL_STYLE}>Notes</div>
<input type="text" placeholder="Any extra details" value={newExpense.notes} onChange={e => setNewExpense(p => ({ ...p, notes: e.target.value }))} style={{ fontSize: 13, color: 'var(--text-primary)', background: 'var(--card-bg-2)', border: '1px solid var(--border)', borderRadius: 6, padding: '6px 10px', width: '100%', fontFamily: 'inherit', boxSizing: 'border-box' }} />
</div>
<div>
<div style={FIELD_LABEL_STYLE}>Receipt / Photo</div>
<input type="file" accept="image/*,.pdf" onChange={e => setNewExpense(p => ({ ...p, receipt: e.target.files?.[0] || null, removeReceipt: false }))} style={{ fontSize: 13, color: 'var(--text-primary)', background: 'var(--card-bg-2)', border: '1px solid var(--border)', borderRadius: 6, padding: '6px 10px', width: '100%', fontFamily: 'inherit', boxSizing: 'border-box' }} />
{!newExpense.receipt && newExpense.receipt_path && (
<div style={{ marginTop: 6, display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
<span style={{ fontSize: 12, color: 'var(--text-muted)' }}>{newExpense.receipt_name || 'Existing receipt attached'}</span>
<button type="button" className="btn btn-outline" onClick={() => handleViewReceipt(newExpense)}>View</button>
<button type="button" className="btn btn-outline" style={{ color: 'var(--danger)' }} onClick={() => setNewExpense(p => ({ ...p, removeReceipt: true, receipt_path: null, receipt_name: null }))}>Remove</button>
{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' };
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>
<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 }))} />
<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>
)}
{newExpense.receipt && <div style={{ marginTop: 6, fontSize: 12, color: 'var(--text-muted)' }}>{newExpense.receipt.name}</div>}
</div>
{expensesError && <div style={{ fontSize: 12, color: 'var(--danger)' }}>{expensesError}</div>}
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end', paddingTop: 4 }}>
<button type="submit" className="btn btn-outline" disabled={addingExpense}>{addingExpense ? 'Saving…' : 'Save'}</button>
<button type="button" className="btn btn-outline" onClick={cancelExpenseEdit}>Cancel</button>
</div>
</form>
{/* 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 style={{ display: 'flex', justifyContent: 'flex-end', gap: 8, 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>
)}
);
})()}
</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' };
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={{ ...INPUT, minHeight: 72, resize: 'vertical' }} 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>
</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={{ fontSize: 12 }}>{t.project?.name} {t.title || t.service_type}</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);
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>
<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 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>
{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 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>
<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 style={{ display: 'flex', justifyContent: 'flex-end', gap: 8, 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>
);
}