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 (
New Invoice
Invoice date: {new Date(today).toLocaleDateString()} · Due: {new Date(net30).toLocaleDateString()} (Net 30)
Company
{selectedCompany && (
setBillTo(e.target.value)} placeholder={selectedCompany.name} />
setInvoiceEmail(e.target.value)} placeholder={companyRecipients[0]?.email || 'client@example.com'} /> {companyRecipients.map(recipient => ( ))}
)}
Unbilled New Requests
{uninvoicedTasks.length > 0 && !loadingTasks && ( )}
{!selectedCompanyId ? (

Select a company to see their unbilled requests.

) : loadingTasks ? (

Loading...

) : uninvoicedTasks.length === 0 ? (

No unbilled new requests.

) : (
{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 (
{buildNewItemDescription(task)}
{task.service_type || 'Other'} • {price ? `$${Number(price.price).toFixed(2)}` : 'No price set'}
); })}
)}
{selectedCompanyId && (uninvoicedRevisions.length > 0 || loadingTasks) && (
Unbilled Client Revisions
{uninvoicedRevisions.length > 0 && !loadingTasks && ( )}
{loadingTasks ? (

Loading...

) : (
{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 (
{buildRevisionItemDescription(rev)}
{revServiceType || 'Other'} • {price ? `$${Number(price.price).toFixed(2)}` : 'No price set'}
); })}
)}
)}
Line Items
{items.some(i => i.description) && ( )}
{['', 'Type', 'Description', 'Qty', 'Unit Price', 'Total', ''].map((h, i) => (
3 ? 'right' : 'left' }}>{h}
))}
{items.map((item, index) => (
e.preventDefault()} onDrop={() => handleDrop(index)} style={{ display: 'grid', gridTemplateColumns: '20px 90px 1fr 80px 120px 120px 40px', gap: 8, alignItems: 'center' }} >
{ dragItem.current = index; e.dataTransfer.effectAllowed = 'move'; }} style={{ cursor: 'grab', color: 'var(--text-muted)', fontSize: 14, textAlign: 'center', userSelect: 'none' }} >⠿
{item.submission_id ? 'Revision' : 'New'} updateItem(item.id, 'description', e.target.value)} style={{ margin: 0 }} /> updateItem(item.id, 'quantity', e.target.value)} style={{ margin: 0, textAlign: 'center' }} /> updateItem(item.id, 'unit_price', e.target.value)} style={{ margin: 0, textAlign: 'right' }} />
${((Number(item.quantity) || 0) * (Number(item.unit_price) || 0)).toFixed(2)}
))}
Total
${total.toFixed(2)}
Notes