diff --git a/src/pages/client/MyCompany.jsx b/src/pages/client/MyCompany.jsx index 33fa345..4fb1659 100644 --- a/src/pages/client/MyCompany.jsx +++ b/src/pages/client/MyCompany.jsx @@ -5,20 +5,32 @@ import { useAuth } from '../../context/AuthContext'; export default function MyCompany() { const { currentUser } = useAuth(); - const company = currentUser?.company; + const companies = currentUser?.companies || []; + const [selectedId, setSelectedId] = useState(companies[0]?.id || null); + const company = companies.find(c => c.id === selectedId) || companies[0] || null; + const [members, setMembers] = useState([]); - const [loading, setLoading] = useState(true); + const [loading, setLoading] = useState(!!company?.id); const [editing, setEditing] = useState(false); - const [form, setForm] = useState({ name: '', phone: '', address: '' }); + const [form, setForm] = useState({ name: company?.name || '', phone: company?.phone || '', address: company?.address || '' }); const [saving, setSaving] = useState(false); useEffect(() => { - if (!company?.id) { setLoading(false); return; } + if (!company?.id) return; setForm({ name: company.name || '', phone: company.phone || '', address: company.address || '' }); + setEditing(false); + setLoading(true); async function load() { - const { data: m } = await supabase - .from('profiles').select('id, name, email').eq('company_id', company.id).eq('role', 'client'); - setMembers(m || []); + const [{ data: primaryMembers }, { data: memberRows }] = await Promise.all([ + supabase.from('profiles').select('id, name, email').eq('company_id', company.id).in('role', ['client', 'external']), + supabase.from('company_members').select('profile:profiles(id, name, email)').eq('company_id', company.id), + ]); + const memberMap = new Map(); + (primaryMembers || []).forEach(m => memberMap.set(m.id, m)); + (memberRows || []).forEach(row => { + if (row.profile) memberMap.set(row.profile.id, row.profile); + }); + setMembers([...memberMap.values()]); setLoading(false); } load(); @@ -37,15 +49,46 @@ export default function MyCompany() { setEditing(false); }; + if (!company) return ( + +
My Company
+

No company linked to your account.

+
+ ); + if (loading) return

Loading...

; + const companyDetails = [ + { label: 'Company Name', value: form.name || company.name || '—' }, + { label: 'Phone', value: company.phone || '—' }, + { label: 'Address', value: company.address || '—' }, + { label: 'Members', value: String(members.length) }, + ]; + return (
-
{form.name || company?.name}
+ {companies.length > 1 ? ( +
+ +
+ ) : ( +
{form.name || company.name}
+ )}
- {[company?.phone, company?.address].filter(Boolean).join(' · ') || 'No contact info on file'} + {[company.phone, company.address].filter(Boolean).join(' · ') || 'No contact info on file'}
{!editing && ( @@ -53,6 +96,15 @@ export default function MyCompany() { )}
+
+ {companyDetails.map(detail => ( +
+
{detail.value}
+
{detail.label}
+
+ ))} +
+ {editing && (
Edit Company Info
@@ -90,7 +142,10 @@ export default function MyCompany() { -
diff --git a/src/pages/client/MyProjectDetail.jsx b/src/pages/client/MyProjectDetail.jsx index 54452ae..ae4d2e1 100644 --- a/src/pages/client/MyProjectDetail.jsx +++ b/src/pages/client/MyProjectDetail.jsx @@ -4,8 +4,9 @@ import Layout from '../../components/Layout'; import StatusBadge from '../../components/StatusBadge'; import { supabase } from '../../lib/supabase'; import { useAuth } from '../../context/AuthContext'; +import { withTimeout } from '../../lib/withTimeout'; -const vLabel = (v) => 'v' + String(v || 0).padStart(2, '0'); +const rLabel = (v) => 'R' + String(v || 0).padStart(2, '0'); export default function MyProjectDetail() { const { id } = useParams(); @@ -23,24 +24,45 @@ export default function MyProjectDetail() { useEffect(() => { async function load() { - const { data: p } = await supabase.from('projects').select('*').eq('id', id).single(); - if (!p) { setLoading(false); return; } - setProject(p); + try { + const { data: p } = await withTimeout( + supabase.from('projects').select('*').eq('id', id).single(), + 12000, + 'Project detail load' + ); + if (!p) return; + setProject(p); - const { data: t } = await supabase - .from('tasks').select('*').eq('project_id', id).order('submitted_at', { ascending: false }); - setTasks(t || []); + const { data: t } = await withTimeout( + supabase + .from('tasks') + .select('*') + .eq('project_id', id) + .order('submitted_at', { ascending: false }), + 12000, + 'Project tasks load' + ); + setTasks(t || []); - if (t && t.length > 0) { - const { data: subs } = await supabase - .from('submissions') - .select('id, task_id, submitted_by, submitted_by_name, version_number, type') - .in('task_id', t.map(task => task.id)) - .order('version_number'); - setSubmissions(subs || []); + if (t && t.length > 0) { + const { data: subs } = await withTimeout( + supabase + .from('submissions') + .select('id, task_id, submitted_by, submitted_by_name, version_number, type') + .in('task_id', t.map(task => task.id)) + .order('version_number'), + 12000, + 'Project submissions load' + ); + setSubmissions(subs || []); + } else { + setSubmissions([]); + } + } catch (error) { + console.error('MyProjectDetail load failed:', error); + } finally { + setLoading(false); } - - setLoading(false); } load(); }, [id]); @@ -49,9 +71,13 @@ export default function MyProjectDetail() { e.preventDefault(); if (!nameVal.trim()) return; setSavingName(true); - await supabase.from('projects').update({ name: nameVal.trim() }).eq('id', id); - setProject(p => ({ ...p, name: nameVal.trim() })); - setEditingName(false); + const { error } = await supabase.from('projects').update({ name: nameVal.trim() }).eq('id', id); + if (!error) { + setProject(p => ({ ...p, name: nameVal.trim() })); + setEditingName(false); + } else { + alert('Failed to save name.'); + } setSavingName(false); }; @@ -104,20 +130,27 @@ export default function MyProjectDetail() { - {/* Filter toggle */} -
- - +
+
+
+
Filter
+
+ + +
+
+ +
{filteredTasks.length === 0 ? ( @@ -143,7 +176,7 @@ export default function MyProjectDetail() {
{task.title}{' '} - {vLabel(task.current_version)} + {rLabel(task.current_version)} {isMine && ( diff --git a/src/pages/client/RequestDetail.jsx b/src/pages/client/RequestDetail.jsx index 0359f65..693a700 100755 --- a/src/pages/client/RequestDetail.jsx +++ b/src/pages/client/RequestDetail.jsx @@ -4,12 +4,16 @@ import JSZip from 'jszip'; import Layout from '../../components/Layout'; import StatusBadge from '../../components/StatusBadge'; import FileAttachment from '../../components/FileAttachment'; +import LoadingButton from '../../components/LoadingButton'; import { supabase } from '../../lib/supabase'; import { sendEmail } from '../../lib/email'; import { useAuth } from '../../context/AuthContext'; -import { serviceTypes } from '../../data/mockData'; +import { formatDateEST } from '../../lib/dates'; +import { addDaysToDateOnly, getTodayDateOnlyEST } from '../../lib/dates'; -const vLabel = (v) => 'v' + String(v || 0).padStart(2, '0'); +const rLabel = (v) => 'R' + String(v || 0).padStart(2, '0'); +const getRevisionBaseline = (task, submissions) => + Math.max(task?.current_version || 0, ...(submissions || []).map(sub => sub.version_number || 0)); export default function RequestDetail() { const { id } = useParams(); @@ -26,24 +30,30 @@ export default function RequestDetail() { const [savingTitle, setSavingTitle] = useState(false); const [action, setAction] = useState(null); - const [revisionForm, setRevisionForm] = useState({ serviceType: '', deadline: '', description: '', revisionType: 'client_revision' }); + const [revisionForm, setRevisionForm] = useState({ serviceType: '', deadline: addDaysToDateOnly(getTodayDateOnlyEST(), 3), description: '', revisionType: 'client_revision', isHot: false }); const [revisionFiles, setRevisionFiles] = useState([]); const [submitted, setSubmitted] = useState(false); const [saving, setSaving] = useState(false); + const [downloading, setDownloading] = useState(''); useEffect(() => { async function load() { - const { data: t } = await supabase.from('tasks').select('*').eq('id', id).single(); - if (!t) { setLoading(false); return; } - setTask(t); + try { + const { data: t } = await supabase.from('tasks').select('*').eq('id', id).single(); + if (!t) return; + setTask(t); - const [{ data: p }, { data: subs }] = await Promise.all([ - supabase.from('projects').select('*').eq('id', t.project_id).single(), - supabase.from('submissions').select('*, files:submission_files(*), delivery:deliveries(*, files:delivery_files(*))').eq('task_id', id).order('version_number'), - ]); - setProject(p); - setSubmissions(subs || []); - setLoading(false); + const [{ data: p }, { data: subs }] = await Promise.all([ + supabase.from('projects').select('*').eq('id', t.project_id).single(), + supabase.from('submissions').select('*, files:submission_files(*), delivery:deliveries(*, files:delivery_files(*))').eq('task_id', id).order('version_number'), + ]); + setProject(p); + setSubmissions(subs || []); + } catch (error) { + console.error('RequestDetail load failed:', error); + } finally { + setLoading(false); + } } load(); }, [id]); @@ -58,132 +68,182 @@ export default function RequestDetail() { serviceType: task.title, projectName: project?.name, taskId: id, + }).catch((emailError) => { + console.error('Client approved email failed:', emailError); }); setSaving(false); }; const handleDelete = async () => { setSaving(true); - - const { data: subs } = await supabase.from('submissions').select('id').eq('task_id', id); - if (subs && subs.length > 0) { - const { data: storageFiles } = await supabase - .from('submission_files').select('storage_path').in('submission_id', subs.map(s => s.id)); - if (storageFiles && storageFiles.length > 0) { - await supabase.storage.from('submissions').remove(storageFiles.map(f => f.storage_path)); - } - const { data: deliveries } = await supabase - .from('deliveries').select('id').in('submission_id', subs.map(s => s.id)); - if (deliveries && deliveries.length > 0) { - const { data: deliveryFiles } = await supabase - .from('delivery_files').select('storage_path').in('delivery_id', deliveries.map(d => d.id)); - if (deliveryFiles && deliveryFiles.length > 0) { - await supabase.storage.from('deliveries').remove(deliveryFiles.map(f => f.storage_path)); + try { + // Clean up storage files — non-blocking (don't let storage errors prevent DB delete) + try { + const { data: subs } = await supabase.from('submissions').select('id').eq('task_id', id); + if (subs && subs.length > 0) { + const { data: storageFiles } = await supabase + .from('submission_files').select('storage_path').in('submission_id', subs.map(s => s.id)); + if (storageFiles && storageFiles.length > 0) { + await supabase.storage.from('submissions').remove(storageFiles.map(f => f.storage_path)); + } + const { data: deliveries } = await supabase + .from('deliveries').select('id').in('submission_id', subs.map(s => s.id)); + if (deliveries && deliveries.length > 0) { + const { data: deliveryFiles } = await supabase + .from('delivery_files').select('storage_path').in('delivery_id', deliveries.map(d => d.id)); + if (deliveryFiles && deliveryFiles.length > 0) { + await supabase.storage.from('deliveries').remove(deliveryFiles.map(f => f.storage_path)); + } + } } + } catch (storageErr) { + console.warn('Storage cleanup failed, continuing with DB delete:', storageErr.message); } + + const { error: deleteError } = await supabase.from('tasks').delete().eq('id', id); + if (deleteError) throw new Error(deleteError.message); + + navigate('/my-projects'); + } catch (err) { + console.error('Delete failed:', err); + alert(`Failed to delete: ${err.message}`); + setSaving(false); } - - await supabase.from('tasks').delete().eq('id', id); - - const { data: remaining } = await supabase.from('tasks').select('id').eq('project_id', task.project_id); - if (!remaining || remaining.length === 0) { - await supabase.from('projects').delete().eq('id', task.project_id); - } - - navigate('/my-projects'); }; const handleRevisionSubmit = async (e) => { e.preventDefault(); setSaving(true); - if (action === 'edit') { - // No version bump — amendment notes attach to the current version - const { data: newSub } = await supabase.from('submissions').insert({ - task_id: id, - version_number: (task.current_version || 0) + 1, - type: 'amendment', - service_type: task.title, - deadline: revisionForm.deadline || null, - description: revisionForm.description, - submitted_by: currentUser.id, - submitted_by_name: currentUser.name, - }).select().single(); + try { + if (action === 'edit') { + // No version bump — amendment notes attach to the current version + const { data: newSub, error: subError } = await supabase.from('submissions').insert({ + task_id: id, + version_number: getRevisionBaseline(task, submissions), + type: 'amendment', + is_hot: revisionForm.isHot, + service_type: task.title, + deadline: revisionForm.deadline || null, + description: revisionForm.description, + submitted_by: currentUser.id, + submitted_by_name: currentUser.name, + }).select().single(); + if (subError) throw new Error(subError.message); - if (newSub && revisionFiles.length > 0) { - for (const file of revisionFiles) { - const path = `${id}/${Date.now()}_${file.name}`; - const { data: uploaded } = await supabase.storage.from('submissions').upload(path, file); - if (uploaded) { - await supabase.from('submission_files').insert({ - submission_id: newSub.id, name: file.name, storage_path: path, size: file.size, - }); + if (newSub && revisionFiles.length > 0) { + for (const file of revisionFiles) { + const path = `${id}/${Date.now()}_${file.name}`; + const { data: uploaded, error: uploadError } = await supabase.storage.from('submissions').upload(path, file); + if (uploadError) throw new Error(`Failed to upload "${file.name}": ${uploadError.message}`); + if (uploaded) { + const { error: fileRecordError } = await supabase.from('submission_files').insert({ + submission_id: newSub.id, name: file.name, storage_path: path, size: file.size, + }); + if (fileRecordError) throw new Error(`Failed to save file record for "${file.name}": ${fileRecordError.message}`); + } } } - } - } else { - const newVersion = (task.current_version || 0) + 1; - await supabase.from('tasks').update({ status: 'not_started', current_version: newVersion }).eq('id', id); + } else { + const newVersion = getRevisionBaseline(task, submissions) + 1; + await supabase.from('tasks').update({ + status: 'not_started', + current_version: newVersion, + assigned_to: null, + assigned_name: null, + }).eq('id', id); - const { data: newSub } = await supabase.from('submissions').insert({ - task_id: id, - version_number: newVersion + 1, - type: 'revision', - revision_type: revisionForm.revisionType, - service_type: revisionForm.serviceType, - deadline: revisionForm.deadline || null, - description: revisionForm.description, - submitted_by: currentUser.id, - submitted_by_name: currentUser.name, - }).select().single(); + const { data: newSub, error: subError } = await supabase.from('submissions').insert({ + task_id: id, + version_number: newVersion, + type: 'revision', + is_hot: revisionForm.isHot, + revision_type: revisionForm.revisionType, + service_type: revisionForm.serviceType, + deadline: revisionForm.deadline || null, + description: revisionForm.description, + submitted_by: currentUser.id, + submitted_by_name: currentUser.name, + }).select().single(); + if (subError) throw new Error(subError.message); - if (newSub && revisionFiles.length > 0) { - for (const file of revisionFiles) { - const path = `${id}/${Date.now()}_${file.name}`; - const { data: uploaded } = await supabase.storage.from('submissions').upload(path, file); - if (uploaded) { - await supabase.from('submission_files').insert({ - submission_id: newSub.id, name: file.name, storage_path: path, size: file.size, - }); + if (newSub && revisionFiles.length > 0) { + for (const file of revisionFiles) { + const path = `${id}/${Date.now()}_${file.name}`; + const { data: uploaded, error: uploadError } = await supabase.storage.from('submissions').upload(path, file); + if (uploadError) throw new Error(`Failed to upload "${file.name}": ${uploadError.message}`); + if (uploaded) { + const { error: fileRecordError } = await supabase.from('submission_files').insert({ + submission_id: newSub.id, name: file.name, storage_path: path, size: file.size, + }); + if (fileRecordError) throw new Error(`Failed to save file record for "${file.name}": ${fileRecordError.message}`); + } } } + + setTask(t => ({ ...t, status: 'not_started', current_version: newVersion, assigned_to: null, assigned_name: null })); + + sendEmail('revision_submitted', 'hello@fourgebranding.com', { + clientName: currentUser.name, + serviceType: task.title, + projectName: project?.name, + version: rLabel(newVersion), + deadline: revisionForm.deadline, + description: revisionForm.description, + taskId: id, + }).catch((emailError) => { + console.error('Revision submitted email failed:', emailError); + }); } - setTask(t => ({ ...t, status: 'not_started', current_version: newVersion })); + const { data: refreshed } = await supabase + .from('submissions') + .select('*, files:submission_files(*), delivery:deliveries(*, files:delivery_files(*))') + .eq('task_id', id) + .order('version_number'); + setSubmissions(refreshed || []); - sendEmail('revision_submitted', 'hello@fourgebranding.com', { - clientName: currentUser.name, - serviceType: task.title, - projectName: project?.name, - version: vLabel(newVersion), - deadline: revisionForm.deadline, - description: revisionForm.description, - taskId: id, - }); + setSubmitted(true); + setAction(null); + } catch (err) { + console.error('Revision submit failed:', err); + alert(`Failed to submit: ${err.message}`); + } finally { + setSaving(false); } - - const { data: refreshed } = await supabase - .from('submissions') - .select('*, files:submission_files(*), delivery:deliveries(*, files:delivery_files(*))') - .eq('task_id', id) - .order('version_number'); - setSubmissions(refreshed || []); - - setSubmitted(true); - setAction(null); - setSaving(false); }; - const set = (field) => (e) => setRevisionForm(f => ({ ...f, [field]: e.target.value })); + const set = (field) => (e) => setRevisionForm(f => ({ + ...f, + [field]: e.target.type === 'checkbox' ? e.target.checked : e.target.value, + })); - const getFileUrl = async (path) => { - const { data } = await supabase.storage.from('deliveries').createSignedUrl(path, 3600); - if (data?.signedUrl) window.open(data.signedUrl, '_blank'); + const getFileUrl = async (file) => { + const key = `delivery:${file.storage_path}`; + if (downloading) return; + setDownloading(key); + try { + const { data } = await supabase.storage.from('deliveries').createSignedUrl(file.storage_path, 3600, { + download: file.name, + }); + if (data?.signedUrl) window.open(data.signedUrl, '_blank'); + } finally { + setDownloading(''); + } }; - const getSubmissionFileUrl = async (path) => { - const { data } = await supabase.storage.from('submissions').createSignedUrl(path, 3600); - if (data?.signedUrl) window.open(data.signedUrl, '_blank'); + const getSubmissionFileUrl = async (file) => { + const key = `submission:${file.storage_path}`; + if (downloading) return; + setDownloading(key); + try { + const { data } = await supabase.storage.from('submissions').createSignedUrl(file.storage_path, 3600, { + download: file.name, + }); + if (data?.signedUrl) window.open(data.signedUrl, '_blank'); + } finally { + setDownloading(''); + } }; const handleSaveTitle = async (e) => { @@ -197,22 +257,31 @@ export default function RequestDetail() { }; const downloadAllSubmissionFiles = async (files, versionLabel) => { - const zip = new JSZip(); - for (const file of files) { - const { data } = await supabase.storage.from('submissions').createSignedUrl(file.storage_path, 3600); - if (data?.signedUrl) { - const response = await fetch(data.signedUrl); - const blob = await response.blob(); - zip.file(file.name, blob); + const key = `zip:${versionLabel}`; + if (downloading) return; + setDownloading(key); + try { + const zip = new JSZip(); + for (const file of files) { + const { data } = await supabase.storage.from('submissions').createSignedUrl(file.storage_path, 3600, { + download: file.name, + }); + if (data?.signedUrl) { + const response = await fetch(data.signedUrl); + const blob = await response.blob(); + zip.file(file.name, blob); + } } + const content = await zip.generateAsync({ type: 'blob' }); + const zipName = `${project?.name || 'files'} ${versionLabel}.zip`.replace(/[^a-z0-9 ._-]/gi, '_'); + const a = document.createElement('a'); + a.href = URL.createObjectURL(content); + a.download = zipName; + a.click(); + URL.revokeObjectURL(a.href); + } finally { + setDownloading(''); } - const content = await zip.generateAsync({ type: 'blob' }); - const zipName = `${project?.name || 'files'} ${versionLabel}.zip`.replace(/[^a-z0-9 ._-]/gi, '_'); - const a = document.createElement('a'); - a.href = URL.createObjectURL(content); - a.download = zipName; - a.click(); - URL.revokeObjectURL(a.href); }; if (loading) return

Loading...

; @@ -221,13 +290,14 @@ export default function RequestDetail() { const canEdit = ['not_started', 'in_progress'].includes(task.status); const canReview = task.status === 'client_review'; const canReopen = task.status === 'client_approved'; - const titleWithVersion = `${task.title} ${vLabel(task.current_version)}`; + const revisionBaseline = getRevisionBaseline(task, submissions); + const titleWithVersion = `${task.title} ${rLabel(revisionBaseline)}`; const formTitle = action === 'edit' - ? `Amend Request — ${vLabel(task.current_version || 0)}` + ? `Amend Request — ${rLabel(revisionBaseline)}` : action === 'reopen' - ? `Request New Revision — will become ${vLabel((task.current_version || 0) + 1)}` - : `Request a Revision — will become ${vLabel((task.current_version || 0) + 1)}`; + ? `Request New Revision — will become ${rLabel(revisionBaseline + 1)}` + : `Request a Revision — will become ${rLabel(revisionBaseline + 1)}`; const formPlaceholder = action === 'edit' ? "Describe what you'd like to update or change..." @@ -291,13 +361,13 @@ export default function RequestDetail() { {submitted && (
- ✓ Your {action === 'edit' ? 'changes have' : 'revision request has'} been submitted as {vLabel(task.current_version)}. Our team will get started shortly. + ✓ Your {action === 'edit' ? 'changes have' : 'revision request has'} been submitted as {rLabel(revisionBaseline)}. Our team will get started shortly.
)} {action === 'approved' && (
- ✓ You've approved {vLabel(task.current_version)}. This job is now complete! + ✓ You've approved {rLabel(revisionBaseline)}. This job is now complete!
)} @@ -320,7 +390,22 @@ export default function RequestDetail() {

Your request is still being worked on. You can update the details or requirements.

- +
)} @@ -330,7 +415,21 @@ export default function RequestDetail() {

This job was approved but you can still request a new revision if needed.

- +
)} @@ -348,6 +447,16 @@ export default function RequestDetail() { +
+ +
{(action === 'revision' || action === 'reopen') && (
@@ -396,7 +505,7 @@ export default function RequestDetail() {
)} -
Version History
+
Revision History
{Object.values( submissions.reduce((groups, sub) => { @@ -413,18 +522,19 @@ export default function RequestDetail() {
-
{vLabel(primary.version_number - 1)}
+
{rLabel(primary.version_number)}
{primary.revision_type && }
{primary.submitted_by_name && {primary.submitted_by_name} · } - {new Date(primary.submitted_at).toLocaleDateString()} + {formatDateEST(primary.submitted_at)}

{primary.service_type}

{primary.deadline || '—'}

+

{primary.is_hot ? 'Yes' : 'No'}

@@ -435,9 +545,7 @@ export default function RequestDetail() {
- {primary.files.length > 1 && ( - - )} + downloadAllSubmissionFiles(primary.files, rLabel(primary.version_number))}>⬇ Download All
{primary.files.map((file, fi) => ( @@ -446,7 +554,7 @@ export default function RequestDetail() { 📎 {file.name}
- + getSubmissionFileUrl(file)}>📥 Download
))}
@@ -459,7 +567,7 @@ export default function RequestDetail() { Amended Request
- {amendment.submitted_by_name} · {new Date(amendment.submitted_at).toLocaleDateString()} + {amendment.submitted_by_name} · {formatDateEST(amendment.submitted_at)}

{amendment.description}

{amendment.files?.length > 0 && ( @@ -470,7 +578,7 @@ export default function RequestDetail() { 📎 {file.name}
- + getSubmissionFileUrl(file)}>📥 Download ))} @@ -481,7 +589,7 @@ export default function RequestDetail() { {delivery && delivery.files && delivery.files.length > 0 && (
- ✓ Delivered {new Date(delivery.sent_at).toLocaleDateString()} + ✓ Delivered {formatDateEST(delivery.sent_at)}
{delivery.files.map((file, fi) => (
@@ -489,7 +597,7 @@ export default function RequestDetail() { 📄 {file.name}
- + getFileUrl(file)}>📥 Download
))} diff --git a/src/pages/team/InvoiceDetail.jsx b/src/pages/team/InvoiceDetail.jsx index f09d371..a1e0a7a 100644 --- a/src/pages/team/InvoiceDetail.jsx +++ b/src/pages/team/InvoiceDetail.jsx @@ -1,8 +1,11 @@ import { useState, useEffect } from 'react'; import { useParams, useNavigate, useLocation, Link } from 'react-router-dom'; import Layout from '../../components/Layout'; +import LoadingButton from '../../components/LoadingButton'; import { supabase } from '../../lib/supabase'; -import { generateInvoicePDF } from '../../lib/invoice'; +import { generateInvoicePDF, generateReceiptPDF } from '../../lib/invoice'; +import { blobToEmailAttachment, sendEmail } from '../../lib/email'; +import { withTimeout } from '../../lib/withTimeout'; const statusColor = { draft: 'not_started', sent: 'in_progress', paid: 'client_approved' }; @@ -13,9 +16,14 @@ export default function InvoiceDetail() { const [invoice, setInvoice] = useState(state?.invoice || null); const [company, setCompany] = useState(null); + const [companies, setCompanies] = useState([]); const [items, setItems] = useState([]); const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); + const [generating, setGenerating] = useState(''); + const [editingDates, setEditingDates] = useState(false); + const [dateForm, setDateForm] = useState({ invoice_date: '', due_date: '' }); + const [emailRecipient, setEmailRecipient] = useState(''); useEffect(() => { async function load() { @@ -24,11 +32,15 @@ export default function InvoiceDetail() { if (!inv) return; setInvoice(inv); - const [{ data: co }, { data: its }] = await Promise.all([ + const [{ data: co }, { data: companyList }, { data: its }] = await Promise.all([ supabase.from('companies').select('*').eq('id', inv.company_id).single(), + supabase.from('companies').select('*').order('name'), supabase.from('invoice_items').select('*').eq('invoice_id', id).order('created_at'), ]); + const defaultEmail = inv.invoice_email || await getDefaultInvoiceEmail(inv.company_id, co); setCompany(co); + setCompanies(companyList || []); + setEmailRecipient(defaultEmail); setItems(its || []); } catch (error) { console.error('InvoiceDetail load failed:', error); @@ -41,13 +53,144 @@ export default function InvoiceDetail() { const updateStatus = async (status) => { setSaving(true); - await supabase.from('invoices').update({ status }).eq('id', id); - setInvoice(i => ({ ...i, status })); + const updates = { status }; + if (status === 'paid' && !invoice.paid_at) updates.paid_at = new Date().toISOString(); + if (status !== 'paid') updates.paid_at = null; + const { error } = await supabase.from('invoices').update(updates).eq('id', id); + if (!error) { + setInvoice(i => ({ ...i, ...updates })); + } else { + alert('Failed to update status.'); + } setSaving(false); }; + const getEmailRecipient = () => emailRecipient.trim(); + + async function getDefaultInvoiceEmail(companyId, companyData = null) { + if (!companyId) return ''; + const [{ data: memberRows }, { data: primaryUsers }] = await Promise.all([ + supabase.from('company_members').select('profile:profiles(id, name, email, role)').eq('company_id', companyId), + supabase.from('profiles').select('id, name, email, role').eq('company_id', companyId).in('role', ['client', 'external']).order('name'), + ]); + 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 || ''); + }); + return recipients[0]?.email || companyData?.contact_email || ''; + } + + const persistInvoiceEmail = async () => { + const contactEmail = getEmailRecipient(); + if (!contactEmail) throw new Error('Enter an email recipient before sending.'); + const { error } = await withTimeout( + supabase.from('invoices').update({ invoice_email: contactEmail }).eq('id', id), + 12000, + 'Saving invoice email' + ); + if (error) throw error; + setInvoice(inv => ({ ...inv, invoice_email: contactEmail })); + return contactEmail; + }; + + const sendInvoiceEmail = async () => { + const contactEmail = await persistInvoiceEmail(); + + const payUrl = `https://portal.fourgebranding.com/pay/${encodeURIComponent(invoice.invoice_number)}`; + const dueDate = new Date(invoice.due_date).toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' }); + const emailData = { + invoiceNumber: invoice.invoice_number, + billTo: invoice.bill_to || company?.name, + total: `$${Number(invoice.total).toFixed(2)}`, + dueDate, + payUrl, + notes: invoice.notes || '', + }; + + let attachments = []; + let attachmentWarning = ''; + try { + const invoicePdf = await withTimeout( + generateInvoicePDF(invoice, company, items, { save: false }), + 8000, + 'Invoice PDF generation' + ); + const attachment = await withTimeout( + blobToEmailAttachment(invoicePdf, `${invoice.invoice_number}.pdf`), + 5000, + 'Invoice attachment encoding' + ); + attachments = [attachment]; + } catch (attachmentError) { + console.error('Invoice PDF attachment skipped on send:', attachmentError); + attachmentWarning = ' Invoice email sent without PDF attachment.'; + } + + await withTimeout( + sendEmail('invoice_sent', [contactEmail], emailData, attachments), + 12000, + 'Sending invoice email' + ); + return { attachmentWarning }; + }; + + const handleFinalizeSend = async () => { + const contactEmail = getEmailRecipient(); + if (!contactEmail) { + alert('Enter an email recipient before sending.'); + return; + } + + setSaving(true); + try { + const { attachmentWarning } = await sendInvoiceEmail(); + + const updates = { status: 'sent' }; + const { error } = await withTimeout( + supabase.from('invoices').update(updates).eq('id', id), + 12000, + 'Updating invoice status' + ); + if (error) throw error; + setInvoice(i => ({ ...i, ...updates })); + alert(`Invoice email sent successfully.${attachmentWarning || ''}`); + } catch (err) { + console.error('Failed to finalize and send invoice:', err); + alert(`Failed to send invoice: ${err.message}`); + } finally { + setSaving(false); + } + }; + + const handleResendInvoice = async () => { + const contactEmail = getEmailRecipient(); + if (!contactEmail) { + alert('Enter an email recipient before sending.'); + return; + } + + setSaving(true); + try { + const { attachmentWarning } = await sendInvoiceEmail(); + alert(`Invoice email sent successfully.${attachmentWarning || ''}`); + } catch (err) { + console.error('Failed to resend invoice:', err); + alert(`Failed to send invoice: ${err.message}`); + } finally { + setSaving(false); + } + }; + const handleDelete = async () => { - if (!window.confirm('Delete this invoice? This cannot be undone.')) return; setSaving(true); try { const { data: freshItems } = await supabase.from('invoice_items').select('task_id, submission_id').eq('invoice_id', id); @@ -65,7 +208,98 @@ export default function InvoiceDetail() { }; const handleDownload = async () => { - await generateInvoicePDF(invoice, company, items); + if (generating) return; + setGenerating('invoice'); + try { + await generateInvoicePDF(invoice, company, items); + } finally { + setGenerating(''); + } + }; + + const handleReceipt = async () => { + if (generating) return; + setGenerating('receipt'); + try { + await generateReceiptPDF(invoice, company, items); + } finally { + setGenerating(''); + } + }; + + const handleSendReceipt = async () => { + const contactEmail = getEmailRecipient(); + if (!contactEmail) { + alert('Enter an email recipient before sending.'); + return; + } + + setSaving(true); + try { + const savedEmail = await persistInvoiceEmail(); + const paidDate = invoice.paid_at + ? new Date(invoice.paid_at).toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' }) + : new Date().toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' }); + const receiptPdf = await generateReceiptPDF(invoice, company, items, { save: false }); + const attachment = await blobToEmailAttachment(receiptPdf, `${invoice.invoice_number}-receipt.pdf`); + await sendEmail('receipt_sent', [savedEmail], { + invoiceNumber: invoice.invoice_number, + billTo: invoice.bill_to || company?.name, + total: `$${Number(invoice.total).toFixed(2)}`, + paidDate, + }, [attachment]); + alert('Receipt sent successfully!'); + } catch (err) { + alert(`Failed to send receipt: ${err.message}`); + } + setSaving(false); + }; + + const handleEditDates = () => { + setDateForm({ + invoice_date: invoice.invoice_date?.slice(0, 10) || '', + due_date: invoice.due_date?.slice(0, 10) || '', + }); + setEditingDates(true); + }; + + const handleSaveDates = async () => { + setSaving(true); + await supabase.from('invoices').update({ + invoice_date: dateForm.invoice_date, + due_date: dateForm.due_date, + }).eq('id', id); + setInvoice(i => ({ ...i, invoice_date: dateForm.invoice_date, due_date: dateForm.due_date })); + setEditingDates(false); + setSaving(false); + }; + + const handleEmailBlur = async () => { + const nextEmail = getEmailRecipient(); + if ((invoice.invoice_email || '') === nextEmail) return; + const { error } = await supabase.from('invoices').update({ invoice_email: nextEmail || null }).eq('id', id); + if (!error) setInvoice(inv => ({ ...inv, invoice_email: nextEmail || null })); + }; + + const handleCompanyChange = async (companyId) => { + if (!companyId || companyId === invoice.company_id) return; + const nextCompany = companies.find(c => c.id === companyId); + if (!nextCompany) return; + setSaving(true); + const defaultEmail = await getDefaultInvoiceEmail(companyId, nextCompany); + const { error } = await supabase.from('invoices').update({ + company_id: companyId, + bill_to: nextCompany.name, + invoice_email: defaultEmail, + }).eq('id', id); + setSaving(false); + if (error) { + alert('Failed to update invoice company. Please try again.'); + return; + } + setInvoice(inv => ({ ...inv, company_id: companyId, bill_to: nextCompany.name, invoice_email: defaultEmail })); + setCompany(nextCompany); + setEmailRecipient(defaultEmail); }; if (loading) return

Loading...

; @@ -88,7 +322,16 @@ export default function InvoiceDetail() { {invoice.status}{isOverdue ? ' · Overdue' : ''} - + Download Invoice + {invoice.status === 'sent' && ( + + )} + {invoice.status === 'paid' && ( + <> + Download Receipt + + + )} @@ -101,14 +344,59 @@ export default function InvoiceDetail() {
-
Invoice Details
+
+
Invoice Details
+ {!editingDates + ? + :
+ + +
+ } +
-

{new Date(invoice.invoice_date).toLocaleDateString()}

-
-

{new Date(invoice.due_date).toLocaleDateString()}

+
+ + {editingDates + ? setDateForm(f => ({ ...f, invoice_date: e.target.value }))} /> + :

{new Date(invoice.invoice_date).toLocaleDateString()}

} +
+
+ + {editingDates + ? setDateForm(f => ({ ...f, due_date: e.target.value }))} /> + :

{new Date(invoice.due_date).toLocaleDateString()}

}

Net 30

+
+ + +
+
+ + setEmailRecipient(e.target.value)} + onBlur={handleEmailBlur} + placeholder="client@example.com" + disabled={saving} + /> +

${Number(invoice.total).toFixed(2)}

+ {invoice.paid_at && ( +

{new Date(invoice.paid_at).toLocaleDateString()}

+ )} {invoice.status === 'paid' && invoice.stripe_fee != null && ( <>

−${Number(invoice.stripe_fee).toFixed(2)}

@@ -178,7 +466,14 @@ export default function InvoiceDetail() {
Actions
{invoice.status === 'draft' && ( - + + Finalize & Send + + )} + {invoice.status === 'sent' && ( + + Resend Invoice + )} {invoice.status === 'sent' && ( @@ -186,7 +481,15 @@ export default function InvoiceDetail() { {invoice.status === 'paid' && ( )} - + Download Invoice + {invoice.status === 'paid' && ( + <> + Download Receipt + + Send Receipt + + + )}
diff --git a/src/pages/team/ProjectDetail.jsx b/src/pages/team/ProjectDetail.jsx index cb1446d..c38b18c 100755 --- a/src/pages/team/ProjectDetail.jsx +++ b/src/pages/team/ProjectDetail.jsx @@ -6,6 +6,9 @@ import { supabase } from '../../lib/supabase'; import { useAuth } from '../../context/AuthContext'; import { serviceTypes } from '../../data/mockData'; import { cleanupTaskStorage } from '../../lib/deleteHelpers'; +import { addDaysToDateOnly, getTodayDateOnlyEST } from '../../lib/dates'; + +const emptyJobForm = () => ({ title: '', serviceType: '', deadline: addDaysToDateOnly(getTodayDateOnlyEST(), 3), description: '', requestedBy: '', isHot: false }); export default function ProjectDetail() { const { id } = useParams(); @@ -25,33 +28,42 @@ export default function ProjectDetail() { const [savingName, setSavingName] = useState(false); const [showAddJob, setShowAddJob] = useState(false); - const [jobForm, setJobForm] = useState({ title: '', serviceType: '', deadline: '', description: '', requestedBy: '' }); + const [jobForm, setJobForm] = useState(emptyJobForm); const [savingJob, setSavingJob] = useState(false); const [members, setMembers] = useState([]); const [externalProfiles, setExternalProfiles] = useState([]); const [selectedExternal, setSelectedExternal] = useState(''); const [addingMember, setAddingMember] = useState(false); + const requesterOptions = [ + ...(currentUser ? [{ id: currentUser.id, name: `${currentUser.name} (You)`, email: currentUser.email || '' }] : []), + ...companyUsers.filter(user => user.id !== currentUser?.id), + ]; useEffect(() => { async function load() { - const { data: p } = await supabase.from('projects').select('*').eq('id', id).single(); - if (!p) { setLoading(false); return; } - setProject(p); + try { + const { data: p } = await supabase.from('projects').select('*').eq('id', id).single(); + if (!p) return; + setProject(p); - const [{ data: co }, { data: t }, { data: users }, { data: pm }, { data: ext }] = await Promise.all([ - supabase.from('companies').select('*').eq('id', p.company_id).single(), - supabase.from('tasks').select('*').eq('project_id', id).order('submitted_at', { ascending: false }), - supabase.from('profiles').select('id, name, email').eq('company_id', p.company_id).eq('role', 'client'), - supabase.from('project_members').select('*, profile:profiles(id, name, email)').eq('project_id', id), - supabase.from('profiles').select('id, name, email').eq('role', 'external').order('name'), - ]); - setCompany(co); - setTasks(t || []); - setCompanyUsers(users || []); - setMembers(pm || []); - setExternalProfiles(ext || []); - setLoading(false); + const [{ data: co }, { data: t }, { data: users }, { data: pm }, { data: ext }] = await Promise.all([ + supabase.from('companies').select('*').eq('id', p.company_id).single(), + supabase.from('tasks').select('*').eq('project_id', id).order('submitted_at', { ascending: false }), + supabase.from('profiles').select('id, name, email').eq('company_id', p.company_id).eq('role', 'client'), + supabase.from('project_members').select('*, profile:profiles(id, name, email)').eq('project_id', id), + supabase.from('profiles').select('id, name, email').eq('role', 'external').order('name'), + ]); + setCompany(co); + setTasks(t || []); + setCompanyUsers(users || []); + setMembers(pm || []); + setExternalProfiles(ext || []); + } catch (error) { + console.error('ProjectDetail load failed:', error); + } finally { + setLoading(false); + } } load(); }, [id]); @@ -75,15 +87,24 @@ export default function ProjectDetail() { e.preventDefault(); if (!nameVal.trim()) return; setSavingName(true); - await supabase.from('projects').update({ name: nameVal.trim() }).eq('id', id); - setProject(p => ({ ...p, name: nameVal.trim() })); - setEditingName(false); + const { error } = await supabase.from('projects').update({ name: nameVal.trim() }).eq('id', id); + if (!error) { + setProject(p => ({ ...p, name: nameVal.trim() })); + setEditingName(false); + } else { + alert('Failed to save name.'); + } setSavingName(false); }; const handleAddJob = async (e) => { e.preventDefault(); setSavingJob(true); + const requestor = requesterOptions.find(u => u.id === jobForm.requestedBy); + if (!requestor) { + setSavingJob(false); + return; + } const { data: task } = await supabase.from('tasks').insert({ project_id: id, @@ -93,20 +114,20 @@ export default function ProjectDetail() { }).select().single(); if (task) { - const requestor = companyUsers.find(u => u.id === jobForm.requestedBy); await supabase.from('submissions').insert({ task_id: task.id, - version_number: 1, + version_number: 0, type: 'initial', + is_hot: jobForm.isHot, service_type: jobForm.serviceType, deadline: jobForm.deadline || null, description: jobForm.description.trim() || null, - submitted_by: requestor?.id || null, - submitted_by_name: requestor?.name || 'Team', + submitted_by: requestor.id, + submitted_by_name: requestor.name.replace(' (You)', ''), }); setTasks(prev => [task, ...prev]); - setJobForm({ title: '', serviceType: '', deadline: '', description: '', requestedBy: '' }); + setJobForm(emptyJobForm()); setShowAddJob(false); } @@ -229,18 +250,27 @@ export default function ProjectDetail() { onChange={e => setJobForm(f => ({ ...f, deadline: e.target.value }))} />
- {companyUsers.length > 0 && ( -
- - -
- )} +
+ + +
+
+
+
@@ -255,7 +285,7 @@ export default function ProjectDetail() { -
@@ -298,7 +328,7 @@ export default function ProjectDetail() { Job Assigned To - Version + Revision Status Submitted @@ -310,14 +340,14 @@ export default function ProjectDetail() { {task.title} - {'v' + String(task.current_version || 0).padStart(2, '0')} + {'R' + String(task.current_version || 0).padStart(2, '0')} {task.assigned_name || 'Unassigned'} - v{task.current_version} + R{String(task.current_version || 0).padStart(2, '0')} diff --git a/supabase/migrations/20260513150000_fix_client_project_delete_policy.sql b/supabase/migrations/20260513150000_fix_client_project_delete_policy.sql new file mode 100644 index 0000000..1f63235 --- /dev/null +++ b/supabase/migrations/20260513150000_fix_client_project_delete_policy.sql @@ -0,0 +1,4 @@ +-- Remove the client project-delete policy. +-- It was added to support auto-deleting empty projects when a client deletes their +-- last task, but that behavior is wrong — projects should only be deleted explicitly. +drop policy if exists "Client deletes company projects" on public.projects;