Files
fourge-portal/src/pages/team/CreateInvoice.jsx
T

530 lines
25 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 AZ</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>
);
}