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:
+33
-18
@@ -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
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user