Fix task delete cascade, multi-company UI, and error handling
- RequestDetail: remove auto-project-delete when last task deleted - MyCompany: support multiple companies with selector dropdown - MyCompany: fetch members from both profiles and company_members - ProjectDetail/MyProjectDetail/InvoiceDetail: check Supabase errors before updating state - Migration: drop client project-delete RLS policy (no longer valid) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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 <Layout><p style={{ padding: 24, color: 'var(--text-muted)' }}>Loading...</p></Layout>;
|
||||
@@ -88,7 +322,16 @@ export default function InvoiceDetail() {
|
||||
<span className={`badge badge-${statusColor[invoice.status]}`} style={{ fontSize: 13, padding: '6px 14px', textTransform: 'capitalize' }}>
|
||||
{invoice.status}{isOverdue ? ' · Overdue' : ''}
|
||||
</span>
|
||||
<button className="btn btn-primary" onClick={handleDownload}>Download PDF</button>
|
||||
<LoadingButton className="btn btn-primary" loading={generating === 'invoice'} disabled={Boolean(generating)} loadingText="Generating... PDF" onClick={handleDownload}>Download Invoice</LoadingButton>
|
||||
{invoice.status === 'sent' && (
|
||||
<button className="btn btn-outline" onClick={handleResendInvoice} disabled={saving}>Resend Invoice</button>
|
||||
)}
|
||||
{invoice.status === 'paid' && (
|
||||
<>
|
||||
<LoadingButton className="btn btn-success" loading={generating === 'receipt'} disabled={Boolean(generating)} loadingText="Generating... PDF" onClick={handleReceipt}>Download Receipt</LoadingButton>
|
||||
<button className="btn btn-outline" onClick={handleSendReceipt} disabled={saving}>Send Receipt</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -101,14 +344,59 @@ export default function InvoiceDetail() {
|
||||
</div>
|
||||
</div>
|
||||
<div className="card">
|
||||
<div className="card-title">Invoice Details</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 12 }}>
|
||||
<div className="card-title" style={{ marginBottom: 0 }}>Invoice Details</div>
|
||||
{!editingDates
|
||||
? <button className="btn btn-outline btn-sm" onClick={handleEditDates}>Edit Dates</button>
|
||||
: <div style={{ display: 'flex', gap: 8 }}>
|
||||
<button className="btn btn-primary btn-sm" onClick={handleSaveDates} disabled={saving}>Save</button>
|
||||
<button className="btn btn-outline btn-sm" onClick={() => setEditingDates(false)} disabled={saving}>Cancel</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<div className="detail-grid" style={{ marginBottom: 0 }}>
|
||||
<div className="detail-item"><label>Invoice Date</label><p>{new Date(invoice.invoice_date).toLocaleDateString()}</p></div>
|
||||
<div className="detail-item"><label>Due Date</label>
|
||||
<p style={{ color: isOverdue ? 'var(--danger)' : 'inherit' }}>{new Date(invoice.due_date).toLocaleDateString()}</p>
|
||||
<div className="detail-item">
|
||||
<label>Invoice Date</label>
|
||||
{editingDates
|
||||
? <input type="date" className="input" style={{ margin: 0 }} value={dateForm.invoice_date} onChange={e => setDateForm(f => ({ ...f, invoice_date: e.target.value }))} />
|
||||
: <p>{new Date(invoice.invoice_date).toLocaleDateString()}</p>}
|
||||
</div>
|
||||
<div className="detail-item">
|
||||
<label>Due Date</label>
|
||||
{editingDates
|
||||
? <input type="date" className="input" style={{ margin: 0 }} value={dateForm.due_date} onChange={e => setDateForm(f => ({ ...f, due_date: e.target.value }))} />
|
||||
: <p style={{ color: isOverdue ? 'var(--danger)' : 'inherit' }}>{new Date(invoice.due_date).toLocaleDateString()}</p>}
|
||||
</div>
|
||||
<div className="detail-item"><label>Terms</label><p>Net 30</p></div>
|
||||
<div className="detail-item">
|
||||
<label>Company</label>
|
||||
<select
|
||||
className="input"
|
||||
style={{ margin: 0 }}
|
||||
value={invoice.company_id || ''}
|
||||
onChange={e => handleCompanyChange(e.target.value)}
|
||||
disabled={saving}
|
||||
>
|
||||
{companies.map(co => <option key={co.id} value={co.id}>{co.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="detail-item">
|
||||
<label>Email To</label>
|
||||
<input
|
||||
type="email"
|
||||
className="input"
|
||||
style={{ margin: 0 }}
|
||||
value={emailRecipient}
|
||||
onChange={e => setEmailRecipient(e.target.value)}
|
||||
onBlur={handleEmailBlur}
|
||||
placeholder="client@example.com"
|
||||
disabled={saving}
|
||||
/>
|
||||
</div>
|
||||
<div className="detail-item"><label>Total</label><p style={{ fontSize: 18, fontWeight: 700, color: 'var(--accent)' }}>${Number(invoice.total).toFixed(2)}</p></div>
|
||||
{invoice.paid_at && (
|
||||
<div className="detail-item"><label>Paid On</label><p style={{ color: 'var(--success, #16a34a)', fontWeight: 600 }}>{new Date(invoice.paid_at).toLocaleDateString()}</p></div>
|
||||
)}
|
||||
{invoice.status === 'paid' && invoice.stripe_fee != null && (
|
||||
<>
|
||||
<div className="detail-item"><label>Stripe Fee</label><p style={{ color: 'var(--text-secondary)' }}>−${Number(invoice.stripe_fee).toFixed(2)}</p></div>
|
||||
@@ -178,7 +466,14 @@ export default function InvoiceDetail() {
|
||||
<div className="card-title">Actions</div>
|
||||
<div className="action-buttons">
|
||||
{invoice.status === 'draft' && (
|
||||
<button className="btn btn-primary" onClick={() => updateStatus('sent')} disabled={saving}>Mark as Sent</button>
|
||||
<LoadingButton className="btn btn-primary" onClick={handleFinalizeSend} loading={saving} loadingText="Finalizing & Sending...">
|
||||
Finalize & Send
|
||||
</LoadingButton>
|
||||
)}
|
||||
{invoice.status === 'sent' && (
|
||||
<LoadingButton className="btn btn-outline" onClick={handleResendInvoice} loading={saving} loadingText="Resending...">
|
||||
Resend Invoice
|
||||
</LoadingButton>
|
||||
)}
|
||||
{invoice.status === 'sent' && (
|
||||
<button className="btn btn-success" onClick={() => updateStatus('paid')} disabled={saving}>Mark as Paid</button>
|
||||
@@ -186,7 +481,15 @@ export default function InvoiceDetail() {
|
||||
{invoice.status === 'paid' && (
|
||||
<button className="btn btn-outline" onClick={() => updateStatus('sent')} disabled={saving}>Reopen</button>
|
||||
)}
|
||||
<button className="btn btn-primary" onClick={handleDownload}>Download PDF</button>
|
||||
<LoadingButton className="btn btn-primary" loading={generating === 'invoice'} disabled={Boolean(generating)} loadingText="Generating... PDF" onClick={handleDownload}>Download Invoice</LoadingButton>
|
||||
{invoice.status === 'paid' && (
|
||||
<>
|
||||
<LoadingButton className="btn btn-success" loading={generating === 'receipt'} disabled={Boolean(generating)} loadingText="Generating... PDF" onClick={handleReceipt}>Download Receipt</LoadingButton>
|
||||
<LoadingButton className="btn btn-outline" onClick={handleSendReceipt} loading={saving} loadingText="Sending Receipt...">
|
||||
Send Receipt
|
||||
</LoadingButton>
|
||||
</>
|
||||
)}
|
||||
<button className="btn btn-danger" onClick={handleDelete} disabled={saving}>Delete Invoice</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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 }))}
|
||||
/>
|
||||
</div>
|
||||
{companyUsers.length > 0 && (
|
||||
<div className="form-group">
|
||||
<label>Requested By <span style={{ fontWeight: 400, color: 'var(--text-muted)' }}>(optional)</span></label>
|
||||
<select
|
||||
value={jobForm.requestedBy}
|
||||
onChange={e => setJobForm(f => ({ ...f, requestedBy: e.target.value }))}
|
||||
>
|
||||
<option value="">Team (no client)</option>
|
||||
{companyUsers.map(u => <option key={u.id} value={u.id}>{u.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
<div className="form-group">
|
||||
<label>Requested By *</label>
|
||||
<select
|
||||
value={jobForm.requestedBy}
|
||||
onChange={e => setJobForm(f => ({ ...f, requestedBy: e.target.value }))}
|
||||
required
|
||||
>
|
||||
<option value="">Select requester...</option>
|
||||
{requesterOptions.map(u => <option key={u.id} value={u.id}>{u.name}{u.email ? ` (${u.email})` : ''}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-group" style={{ marginTop: -4 }}>
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 10, fontWeight: 500, cursor: 'pointer' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={jobForm.isHot}
|
||||
onChange={e => setJobForm(f => ({ ...f, isHot: e.target.checked }))}
|
||||
/>
|
||||
<span>Mark as Hot</span>
|
||||
</label>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Notes <span style={{ fontWeight: 400, color: 'var(--text-muted)' }}>(optional)</span></label>
|
||||
@@ -255,7 +285,7 @@ export default function ProjectDetail() {
|
||||
<button type="submit" className="btn btn-primary" disabled={savingJob}>
|
||||
{savingJob ? 'Adding...' : 'Add Job'}
|
||||
</button>
|
||||
<button type="button" className="btn btn-outline" onClick={() => { setShowAddJob(false); setJobForm({ title: '', serviceType: '', deadline: '', description: '', requestedBy: '' }); }}>
|
||||
<button type="button" className="btn btn-outline" onClick={() => { setShowAddJob(false); setJobForm(emptyJobForm()); }}>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
@@ -298,7 +328,7 @@ export default function ProjectDetail() {
|
||||
<tr>
|
||||
<th>Job</th>
|
||||
<th>Assigned To</th>
|
||||
<th>Version</th>
|
||||
<th>Revision</th>
|
||||
<th>Status</th>
|
||||
<th>Submitted</th>
|
||||
<th></th>
|
||||
@@ -310,14 +340,14 @@ export default function ProjectDetail() {
|
||||
<td>
|
||||
{task.title}
|
||||
<span style={{ marginLeft: 6, fontWeight: 600, color: 'var(--text-muted)', fontSize: 12 }}>
|
||||
{'v' + String(task.current_version || 0).padStart(2, '0')}
|
||||
{'R' + String(task.current_version || 0).padStart(2, '0')}
|
||||
</span>
|
||||
</td>
|
||||
<td style={{ color: task.assigned_name ? 'var(--text-primary)' : 'var(--text-muted)' }}>
|
||||
{task.assigned_name || 'Unassigned'}
|
||||
</td>
|
||||
<td>
|
||||
<span className="badge badge-not_started">v{task.current_version}</span>
|
||||
<span className="badge badge-not_started">R{String(task.current_version || 0).padStart(2, '0')}</span>
|
||||
</td>
|
||||
<td><StatusBadge status={task.status} /></td>
|
||||
<td style={{ color: 'var(--text-secondary)' }}>
|
||||
|
||||
Reference in New Issue
Block a user