565d2ed4bc
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
530 lines
25 KiB
React
530 lines
25 KiB
React
import { useState, useEffect, useRef } from 'react';
|
||
import { useNavigate } from 'react-router-dom';
|
||
import Layout from '../../components/Layout';
|
||
import LoadingButton from '../../components/LoadingButton';
|
||
import { supabase } from '../../lib/supabase';
|
||
import { useAuth } from '../../context/AuthContext';
|
||
import { generateInvoicePDF } from '../../lib/invoice';
|
||
import { blobToEmailAttachment, sendEmail } from '../../lib/email';
|
||
import { withTimeout } from '../../lib/withTimeout';
|
||
|
||
// Computed at module load time — stable for the lifetime of the invoice creation session
|
||
const INVOICE_TODAY = new Date().toISOString().split('T')[0];
|
||
const INVOICE_NET30 = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString().split('T')[0];
|
||
|
||
function newItem(description = '', unit_price = '', quantity = 1, task_id = null, submission_id = null) {
|
||
return { id: crypto.randomUUID(), description, unit_price, quantity, task_id, submission_id };
|
||
}
|
||
|
||
const buildNewItemDescription = (task) => {
|
||
const projectName = task.project?.name || 'No Project';
|
||
const taskTitle = task.title || task.service_type || 'Untitled';
|
||
return `${projectName} • ${taskTitle}`;
|
||
};
|
||
|
||
const buildRevisionItemDescription = (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}`;
|
||
};
|
||
|
||
export default function CreateInvoice() {
|
||
const navigate = useNavigate();
|
||
const { currentUser } = useAuth();
|
||
|
||
const [companies, setCompanies] = useState([]);
|
||
const [selectedCompanyId, setSelectedCompanyId] = useState('');
|
||
const [uninvoicedTasks, setUninvoicedTasks] = useState([]);
|
||
const [uninvoicedRevisions, setUninvoicedRevisions] = useState([]);
|
||
const [priceList, setPriceList] = useState([]);
|
||
const [billTo, setBillTo] = useState('');
|
||
const [invoiceEmail, setInvoiceEmail] = useState('');
|
||
const [companyRecipients, setCompanyRecipients] = useState([]);
|
||
const [items, setItems] = useState([newItem()]);
|
||
const [notes, setNotes] = useState('');
|
||
const [saving, setSaving] = useState(false);
|
||
const [loadingTasks, setLoadingTasks] = useState(false);
|
||
const dragItem = useRef(null);
|
||
const today = INVOICE_TODAY;
|
||
const net30 = INVOICE_NET30;
|
||
|
||
useEffect(() => {
|
||
supabase.from('companies').select('id, name, contact_email').order('name').then(({ data }) => setCompanies(data || []));
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
if (!selectedCompanyId) {
|
||
setUninvoicedTasks([]);
|
||
setUninvoicedRevisions([]);
|
||
setPriceList([]);
|
||
setItems([newItem()]);
|
||
setBillTo('');
|
||
setInvoiceEmail('');
|
||
setCompanyRecipients([]);
|
||
return;
|
||
}
|
||
setBillTo(companies.find(c => c.id === selectedCompanyId)?.name || '');
|
||
setLoadingTasks(true);
|
||
Promise.all([
|
||
supabase.from('projects').select('id').eq('company_id', selectedCompanyId),
|
||
supabase.from('company_prices').select('*').eq('company_id', selectedCompanyId),
|
||
supabase.from('company_members').select('profile:profiles(id, name, email, role, company_id)').eq('company_id', selectedCompanyId),
|
||
supabase.from('profiles').select('id, name, email, role, company_id').eq('company_id', selectedCompanyId).in('role', ['client', 'external']).order('name'),
|
||
]).then(async ([{ data: projects }, { data: prices }, { data: memberRows }, { data: primaryUsers }]) => {
|
||
setPriceList(prices || []);
|
||
const recipientMap = new Map();
|
||
(memberRows || []).forEach(row => {
|
||
if (row.profile?.email) recipientMap.set(row.profile.id, row.profile);
|
||
});
|
||
(primaryUsers || []).forEach(user => {
|
||
if (user.email) recipientMap.set(user.id, user);
|
||
});
|
||
const recipients = [...recipientMap.values()]
|
||
.sort((a, b) => {
|
||
if (a.role === 'client' && b.role !== 'client') return -1;
|
||
if (a.role !== 'client' && b.role === 'client') return 1;
|
||
return (a.name || '').localeCompare(b.name || '');
|
||
});
|
||
setCompanyRecipients(recipients);
|
||
setInvoiceEmail(recipients[0]?.email || companies.find(c => c.id === selectedCompanyId)?.contact_email || '');
|
||
if (projects && projects.length > 0) {
|
||
const projectIds = projects.map(p => p.id);
|
||
const [{ data: tasks }, { data: revisions }] = await Promise.all([
|
||
supabase
|
||
.from('tasks')
|
||
.select('*, project:projects(name), submissions(service_type, type, version_number)')
|
||
.in('project_id', projectIds)
|
||
.eq('invoiced', false),
|
||
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',
|
||
(await supabase.from('tasks').select('id').in('project_id', projectIds)).data?.map(t => t.id) || []
|
||
),
|
||
]);
|
||
const tasksWithService = (tasks || []).map(t => {
|
||
const initial = (t.submissions || []).find(s => s.type === 'initial') || (t.submissions || [])[0];
|
||
return { ...t, service_type: initial?.service_type || t.title };
|
||
});
|
||
setUninvoicedTasks(tasksWithService);
|
||
setUninvoicedRevisions(revisions || []);
|
||
} else {
|
||
setUninvoicedTasks([]);
|
||
setUninvoicedRevisions([]);
|
||
}
|
||
setLoadingTasks(false);
|
||
});
|
||
}, [selectedCompanyId, companies]);
|
||
|
||
const addTaskAsItem = (task) => {
|
||
const price = priceList.find(p => p.service_type === task.service_type && p.price_type === 'new');
|
||
const description = buildNewItemDescription(task);
|
||
setItems(prev => {
|
||
if (prev.length === 1 && !prev[0].description && !prev[0].unit_price) {
|
||
return [newItem(description, price?.price || '', 1, task.id)];
|
||
}
|
||
return [...prev, newItem(description, price?.price || '', 1, task.id)];
|
||
});
|
||
};
|
||
|
||
const getRevisionServiceType = (revision) => {
|
||
const initial = (revision.task?.submissions || []).find(s => s.type === 'initial') || (revision.task?.submissions || [])[0];
|
||
return initial?.service_type || revision.service_type || revision.task?.title || 'Revision';
|
||
};
|
||
|
||
const addRevisionAsItem = (revision) => {
|
||
const serviceLabel = getRevisionServiceType(revision);
|
||
const description = buildRevisionItemDescription(revision);
|
||
const price = priceList.find(p => p.service_type === serviceLabel && p.price_type === 'revision');
|
||
setItems(prev => {
|
||
if (prev.length === 1 && !prev[0].description && !prev[0].unit_price) {
|
||
return [newItem(description, price?.price || '', 1, revision.task_id, revision.id)];
|
||
}
|
||
return [...prev, newItem(description, price?.price || '', 1, revision.task_id, revision.id)];
|
||
});
|
||
};
|
||
|
||
const updateItem = (id, field, value) => {
|
||
setItems(prev => prev.map(item => item.id === id ? { ...item, [field]: value } : item));
|
||
};
|
||
|
||
const removeItem = (id) => setItems(prev => prev.filter(item => item.id !== id));
|
||
|
||
const handleDrop = (targetIndex) => {
|
||
if (dragItem.current === null || dragItem.current === targetIndex) { dragItem.current = null; return; }
|
||
setItems(prev => {
|
||
const next = [...prev];
|
||
const [moved] = next.splice(dragItem.current, 1);
|
||
next.splice(targetIndex, 0, moved);
|
||
return next;
|
||
});
|
||
dragItem.current = null;
|
||
};
|
||
|
||
const sortItems = (mode) => {
|
||
setItems(prev => {
|
||
const next = [...prev];
|
||
if (mode === 'new-first') return next.sort((a, b) => (!!a.submission_id) - (!!b.submission_id));
|
||
if (mode === 'revision-first') return next.sort((a, b) => (!!b.submission_id) - (!!a.submission_id));
|
||
if (mode === 'az') return next.sort((a, b) => a.description.localeCompare(b.description));
|
||
return next;
|
||
});
|
||
};
|
||
|
||
const total = items.reduce((sum, item) => sum + (Number(item.quantity) || 0) * (Number(item.unit_price) || 0), 0);
|
||
|
||
const handleSave = async (status) => {
|
||
if (!selectedCompanyId) return alert('Please select a company.');
|
||
if (items.every(i => !i.description)) return alert('Please add at least one line item.');
|
||
if (status === 'sent' && !invoiceEmail.trim()) return alert('Please enter an email recipient before finalizing and sending.');
|
||
if (status === 'sent' && !selectedCompany) return alert('Please select a valid company before finalizing and sending.');
|
||
setSaving(true);
|
||
try {
|
||
const year = new Date().getFullYear();
|
||
const { count, error: countError } = await supabase
|
||
.from('invoices')
|
||
.select('*', { count: 'exact', head: true })
|
||
.gte('created_at', `${year}-01-01`);
|
||
if (countError) throw countError;
|
||
|
||
const invoiceNumber = `INV-${year}-${String((count || 0) + 1).padStart(3, '0')}`;
|
||
const initialStatus = status === 'sent' ? 'draft' : status;
|
||
const { data: invoice, error } = await supabase.from('invoices').insert({
|
||
company_id: selectedCompanyId,
|
||
invoice_number: invoiceNumber,
|
||
invoice_date: today,
|
||
due_date: net30,
|
||
status: initialStatus,
|
||
bill_to: billTo || null,
|
||
invoice_email: invoiceEmail.trim() || null,
|
||
notes: notes || null,
|
||
total,
|
||
created_by: currentUser?.id,
|
||
}).select().single();
|
||
if (error || !invoice) throw error || new Error('Invoice record was not created.');
|
||
|
||
const validItems = items.filter(i => i.description);
|
||
if (validItems.length > 0) {
|
||
const { error: itemError } = await supabase.from('invoice_items').insert(
|
||
validItems.map(item => ({
|
||
invoice_id: invoice.id,
|
||
task_id: item.task_id || null,
|
||
submission_id: item.submission_id || null,
|
||
description: item.description,
|
||
quantity: Number(item.quantity) || 1,
|
||
unit_price: Number(item.unit_price) || 0,
|
||
}))
|
||
);
|
||
if (itemError) throw itemError;
|
||
}
|
||
|
||
const taskIds = [...new Set(validItems.filter(i => i.task_id && !i.submission_id).map(i => i.task_id))];
|
||
if (taskIds.length > 0) {
|
||
const { error: taskError } = await supabase.from('tasks').update({ invoiced: true, status: 'invoiced' }).in('id', taskIds);
|
||
if (taskError) throw taskError;
|
||
}
|
||
|
||
const submissionIds = [...new Set(validItems.filter(i => i.submission_id).map(i => i.submission_id))];
|
||
if (submissionIds.length > 0) {
|
||
const { error: submissionError } = await supabase.from('submissions').update({ invoiced: true }).in('id', submissionIds);
|
||
if (submissionError) throw submissionError;
|
||
}
|
||
|
||
let nextInvoice = invoice;
|
||
if (status === 'sent') {
|
||
try {
|
||
const dueDate = new Date(net30).toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' });
|
||
const payUrl = `https://portal.fourgebranding.com/pay/${encodeURIComponent(invoiceNumber)}`;
|
||
const invoiceForPdf = { ...invoice, status: 'sent' };
|
||
const pdfItems = validItems.map(item => ({
|
||
description: item.description,
|
||
quantity: Number(item.quantity) || 1,
|
||
unit_price: Number(item.unit_price) || 0,
|
||
}));
|
||
const emailData = {
|
||
invoiceNumber,
|
||
billTo: billTo || selectedCompany.name,
|
||
total: `$${total.toFixed(2)}`,
|
||
dueDate,
|
||
payUrl,
|
||
notes: notes || '',
|
||
};
|
||
|
||
let attachments = [];
|
||
let attachmentWarning = '';
|
||
try {
|
||
const invoicePdf = await withTimeout(
|
||
generateInvoicePDF(invoiceForPdf, selectedCompany, pdfItems, { save: false }),
|
||
8000,
|
||
'Invoice PDF generation'
|
||
);
|
||
const attachment = await withTimeout(
|
||
blobToEmailAttachment(invoicePdf, `${invoiceNumber}.pdf`),
|
||
5000,
|
||
'Invoice attachment encoding'
|
||
);
|
||
attachments = [attachment];
|
||
} catch (attachmentError) {
|
||
console.error('Invoice PDF attachment skipped during creation:', attachmentError);
|
||
attachmentWarning = ' The invoice email was sent without the PDF attachment.';
|
||
}
|
||
|
||
await withTimeout(
|
||
sendEmail('invoice_sent', invoiceEmail.trim(), emailData, attachments),
|
||
12000,
|
||
'Sending invoice email'
|
||
);
|
||
|
||
const { data: sentInvoice, error: sentError } = await supabase
|
||
.from('invoices')
|
||
.update({ status: 'sent' })
|
||
.eq('id', invoice.id)
|
||
.select()
|
||
.single();
|
||
if (sentError) throw sentError;
|
||
nextInvoice = sentInvoice || { ...invoice, status: 'sent' };
|
||
if (attachmentWarning) {
|
||
alert(`Invoice sent successfully.${attachmentWarning}`);
|
||
}
|
||
} catch (sendError) {
|
||
console.error('Failed to send invoice during creation:', sendError);
|
||
alert(`Invoice was saved as a draft, but the email was not sent: ${sendError.message}`);
|
||
}
|
||
}
|
||
|
||
navigate(`/invoices/${invoice.id}`, { state: { invoice: nextInvoice } });
|
||
} catch (saveError) {
|
||
console.error('Failed to save invoice:', saveError);
|
||
alert(`Failed to save invoice: ${saveError.message || 'Unknown error'}`);
|
||
} finally {
|
||
setSaving(false);
|
||
}
|
||
};
|
||
|
||
const selectedCompany = companies.find(c => c.id === selectedCompanyId);
|
||
|
||
return (
|
||
<Layout>
|
||
<button className="back-link" onClick={() => navigate('/invoices')}>← Back to Invoices</button>
|
||
|
||
<div className="page-header">
|
||
<div>
|
||
<div className="page-title">New Invoice</div>
|
||
<div className="page-subtitle">Invoice date: {new Date(today).toLocaleDateString()} · Due: {new Date(net30).toLocaleDateString()} (Net 30)</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="card" style={{ marginBottom: 24 }}>
|
||
<div className="card-title">Company</div>
|
||
<div className="form-group">
|
||
<label>Select Company *</label>
|
||
<select value={selectedCompanyId} onChange={e => setSelectedCompanyId(e.target.value)}>
|
||
<option value="">Choose a company...</option>
|
||
{companies.map(c => (
|
||
<option key={c.id} value={c.id}>{c.name}</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
{selectedCompany && (
|
||
<div className="grid-2" style={{ marginTop: 12 }}>
|
||
<div className="form-group" style={{ marginBottom: 0 }}>
|
||
<label>Bill To</label>
|
||
<input
|
||
type="text"
|
||
value={billTo}
|
||
onChange={e => setBillTo(e.target.value)}
|
||
placeholder={selectedCompany.name}
|
||
/>
|
||
</div>
|
||
<div className="form-group" style={{ marginBottom: 0 }}>
|
||
<label>Email To</label>
|
||
<input
|
||
type="email"
|
||
list="company-invoice-recipients"
|
||
value={invoiceEmail}
|
||
onChange={e => setInvoiceEmail(e.target.value)}
|
||
placeholder={companyRecipients[0]?.email || 'client@example.com'}
|
||
/>
|
||
<datalist id="company-invoice-recipients">
|
||
{companyRecipients.map(recipient => (
|
||
<option key={recipient.id} value={recipient.email}>
|
||
{recipient.name ? `${recipient.name} (${recipient.role})` : recipient.role}
|
||
</option>
|
||
))}
|
||
</datalist>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
<div className="card" style={{ marginBottom: 24 }}>
|
||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 12 }}>
|
||
<div className="card-title" style={{ marginBottom: 0 }}>Unbilled New Requests</div>
|
||
{uninvoicedTasks.length > 0 && !loadingTasks && (
|
||
<button
|
||
className="btn btn-outline btn-sm"
|
||
onClick={() => uninvoicedTasks.forEach(task => { if (!items.some(i => i.task_id === task.id && !i.submission_id)) addTaskAsItem(task); })}
|
||
>
|
||
+ Add All
|
||
</button>
|
||
)}
|
||
</div>
|
||
{!selectedCompanyId ? (
|
||
<p style={{ fontSize: 13, color: 'var(--text-muted)' }}>Select a company to see their unbilled requests.</p>
|
||
) : loadingTasks ? (
|
||
<p style={{ fontSize: 13, color: 'var(--text-muted)' }}>Loading...</p>
|
||
) : uninvoicedTasks.length === 0 ? (
|
||
<p style={{ fontSize: 13, color: 'var(--text-muted)' }}>No unbilled new requests.</p>
|
||
) : (
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||
{uninvoicedTasks.map(task => {
|
||
const price = priceList.find(p => p.service_type === task.service_type && p.price_type === 'new');
|
||
const alreadyAdded = items.some(i => i.task_id === task.id && !i.submission_id);
|
||
return (
|
||
<div key={task.id} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '8px 12px', background: 'var(--bg)', borderRadius: 6, border: '1px solid var(--border)' }}>
|
||
<div>
|
||
<div style={{ fontSize: 13, fontWeight: 600 }}>{buildNewItemDescription(task)}</div>
|
||
<div style={{ fontSize: 11, color: 'var(--text-muted)' }}>
|
||
{task.service_type || 'Other'} • {price ? `$${Number(price.price).toFixed(2)}` : 'No price set'}
|
||
</div>
|
||
</div>
|
||
<button
|
||
className={`btn btn-sm ${alreadyAdded ? 'btn-outline' : 'btn-primary'}`}
|
||
onClick={() => !alreadyAdded && addTaskAsItem(task)}
|
||
disabled={alreadyAdded}
|
||
>
|
||
{alreadyAdded ? 'Added' : '+ Add'}
|
||
</button>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{selectedCompanyId && (uninvoicedRevisions.length > 0 || loadingTasks) && (
|
||
<div className="card" style={{ marginBottom: 24 }}>
|
||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 12 }}>
|
||
<div className="card-title" style={{ marginBottom: 0 }}>Unbilled Client Revisions</div>
|
||
{uninvoicedRevisions.length > 0 && !loadingTasks && (
|
||
<button
|
||
className="btn btn-outline btn-sm"
|
||
onClick={() => uninvoicedRevisions.forEach(rev => { if (!items.some(i => i.submission_id === rev.id)) addRevisionAsItem(rev); })}
|
||
>
|
||
+ Add All
|
||
</button>
|
||
)}
|
||
</div>
|
||
{loadingTasks ? (
|
||
<p style={{ fontSize: 13, color: 'var(--text-muted)' }}>Loading...</p>
|
||
) : (
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||
{uninvoicedRevisions.map(rev => {
|
||
const revServiceType = getRevisionServiceType(rev);
|
||
const price = priceList.find(p => p.service_type === revServiceType && p.price_type === 'revision');
|
||
const alreadyAdded = items.some(i => i.submission_id === rev.id);
|
||
return (
|
||
<div key={rev.id} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '8px 12px', background: 'var(--bg)', borderRadius: 6, border: '1px solid var(--border)' }}>
|
||
<div>
|
||
<div style={{ fontSize: 13, fontWeight: 600 }}>{buildRevisionItemDescription(rev)}</div>
|
||
<div style={{ fontSize: 11, color: 'var(--text-muted)' }}>
|
||
{revServiceType || 'Other'} • {price ? `$${Number(price.price).toFixed(2)}` : 'No price set'}
|
||
</div>
|
||
</div>
|
||
<button
|
||
className={`btn btn-sm ${alreadyAdded ? 'btn-outline' : 'btn-primary'}`}
|
||
onClick={() => !alreadyAdded && addRevisionAsItem(rev)}
|
||
disabled={alreadyAdded}
|
||
>
|
||
{alreadyAdded ? 'Added' : '+ Add'}
|
||
</button>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
<div className="card" style={{ marginBottom: 24 }}>
|
||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 12 }}>
|
||
<div className="card-title" style={{ marginBottom: 0 }}>Line Items</div>
|
||
{items.some(i => i.description) && (
|
||
<select
|
||
onChange={e => { if (e.target.value) { sortItems(e.target.value); e.target.value = ''; } }}
|
||
defaultValue=""
|
||
style={{ fontSize: 12, padding: '4px 8px', borderRadius: 6, border: '1px solid var(--border)', background: 'var(--card-bg)', color: 'var(--text-primary)', cursor: 'pointer' }}
|
||
>
|
||
<option value="" disabled>Sort by…</option>
|
||
<option value="new-first">New first</option>
|
||
<option value="revision-first">Revision first</option>
|
||
<option value="az">Description A–Z</option>
|
||
</select>
|
||
)}
|
||
</div>
|
||
|
||
<div style={{ display: 'grid', gridTemplateColumns: '20px 90px 1fr 80px 120px 120px 40px', gap: 8, marginBottom: 8 }}>
|
||
{['', 'Type', 'Description', 'Qty', 'Unit Price', 'Total', ''].map((h, i) => (
|
||
<div key={i} style={{ fontSize: 11, fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.5px', color: 'var(--text-muted)', textAlign: i > 3 ? 'right' : 'left' }}>{h}</div>
|
||
))}
|
||
</div>
|
||
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||
{items.map((item, index) => (
|
||
<div
|
||
key={item.id}
|
||
onDragOver={e => e.preventDefault()}
|
||
onDrop={() => handleDrop(index)}
|
||
style={{ display: 'grid', gridTemplateColumns: '20px 90px 1fr 80px 120px 120px 40px', gap: 8, alignItems: 'center' }}
|
||
>
|
||
<div
|
||
draggable
|
||
onDragStart={e => { dragItem.current = index; e.dataTransfer.effectAllowed = 'move'; }}
|
||
style={{ cursor: 'grab', color: 'var(--text-muted)', fontSize: 14, textAlign: 'center', userSelect: 'none' }}
|
||
>⠿</div>
|
||
<span className={`badge ${item.submission_id ? 'badge-client_revision' : 'badge-initial'}`}>
|
||
{item.submission_id ? 'Revision' : 'New'}
|
||
</span>
|
||
<input type="text" placeholder="Description..." value={item.description} onChange={e => updateItem(item.id, 'description', e.target.value)} style={{ margin: 0 }} />
|
||
<input type="number" min="1" value={item.quantity} onChange={e => updateItem(item.id, 'quantity', e.target.value)} style={{ margin: 0, textAlign: 'center' }} />
|
||
<input type="number" min="0" step="0.01" placeholder="0.00" value={item.unit_price} onChange={e => updateItem(item.id, 'unit_price', e.target.value)} style={{ margin: 0, textAlign: 'right' }} />
|
||
<div style={{ textAlign: 'right', fontSize: 14, fontWeight: 600, color: 'var(--text-primary)', paddingRight: 4 }}>
|
||
${((Number(item.quantity) || 0) * (Number(item.unit_price) || 0)).toFixed(2)}
|
||
</div>
|
||
<button onClick={() => removeItem(item.id)} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--danger)', fontSize: 16, padding: 4 }}>✕</button>
|
||
</div>
|
||
))}
|
||
</div>
|
||
|
||
<button className="btn btn-outline btn-sm" style={{ marginTop: 12 }} onClick={() => setItems(prev => [...prev, newItem()])}>
|
||
+ Add Line Item
|
||
</button>
|
||
|
||
<div style={{ display: 'flex', justifyContent: 'flex-end', marginTop: 20, paddingTop: 16, borderTop: '1px solid var(--border)' }}>
|
||
<div style={{ textAlign: 'right' }}>
|
||
<div style={{ fontSize: 11, fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.5px', color: 'var(--text-muted)', marginBottom: 4 }}>Total</div>
|
||
<div style={{ fontSize: 26, fontWeight: 700, color: 'var(--accent)' }}>${total.toFixed(2)}</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="card" style={{ marginBottom: 24 }}>
|
||
<div className="card-title">Notes</div>
|
||
<textarea placeholder="Additional notes, payment instructions, or terms..." value={notes} onChange={e => setNotes(e.target.value)} style={{ minHeight: 80 }} />
|
||
</div>
|
||
|
||
<div className="action-buttons">
|
||
<button className="btn btn-outline" onClick={() => handleSave('draft')} disabled={saving}>Save Draft</button>
|
||
<LoadingButton className="btn btn-primary" onClick={() => handleSave('sent')} loading={saving} loadingText="Finalizing & Sending...">
|
||
Finalise & Send
|
||
</LoadingButton>
|
||
<button className="btn btn-outline" onClick={() => navigate('/invoices')}>Cancel</button>
|
||
</div>
|
||
</Layout>
|
||
);
|
||
}
|