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:
@@ -5,20 +5,32 @@ import { useAuth } from '../../context/AuthContext';
|
|||||||
|
|
||||||
export default function MyCompany() {
|
export default function MyCompany() {
|
||||||
const { currentUser } = useAuth();
|
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 [members, setMembers] = useState([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(!!company?.id);
|
||||||
const [editing, setEditing] = useState(false);
|
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);
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!company?.id) { setLoading(false); return; }
|
if (!company?.id) return;
|
||||||
setForm({ name: company.name || '', phone: company.phone || '', address: company.address || '' });
|
setForm({ name: company.name || '', phone: company.phone || '', address: company.address || '' });
|
||||||
|
setEditing(false);
|
||||||
|
setLoading(true);
|
||||||
async function load() {
|
async function load() {
|
||||||
const { data: m } = await supabase
|
const [{ data: primaryMembers }, { data: memberRows }] = await Promise.all([
|
||||||
.from('profiles').select('id, name, email').eq('company_id', company.id).eq('role', 'client');
|
supabase.from('profiles').select('id, name, email').eq('company_id', company.id).in('role', ['client', 'external']),
|
||||||
setMembers(m || []);
|
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);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
load();
|
load();
|
||||||
@@ -37,15 +49,46 @@ export default function MyCompany() {
|
|||||||
setEditing(false);
|
setEditing(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (!company) return (
|
||||||
|
<Layout>
|
||||||
|
<div className="page-header"><div className="page-title">My Company</div></div>
|
||||||
|
<p style={{ color: 'var(--text-muted)' }}>No company linked to your account.</p>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
|
||||||
if (loading) return <Layout><p style={{ padding: 24, color: 'var(--text-muted)' }}>Loading...</p></Layout>;
|
if (loading) return <Layout><p style={{ padding: 24, color: 'var(--text-muted)' }}>Loading...</p></Layout>;
|
||||||
|
|
||||||
|
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 (
|
return (
|
||||||
<Layout>
|
<Layout>
|
||||||
<div className="page-header">
|
<div className="page-header">
|
||||||
<div>
|
<div>
|
||||||
<div className="page-title">{form.name || company?.name}</div>
|
{companies.length > 1 ? (
|
||||||
|
<div style={{ marginBottom: 4 }}>
|
||||||
|
<select
|
||||||
|
value={selectedId}
|
||||||
|
onChange={e => setSelectedId(e.target.value)}
|
||||||
|
style={{
|
||||||
|
fontSize: 22, fontWeight: 700, background: 'var(--card-bg)',
|
||||||
|
border: '1px solid var(--border)', borderRadius: 6,
|
||||||
|
color: 'var(--text-primary)', cursor: 'pointer',
|
||||||
|
padding: '4px 8px', fontFamily: 'inherit',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{companies.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="page-title">{form.name || company.name}</div>
|
||||||
|
)}
|
||||||
<div className="page-subtitle">
|
<div className="page-subtitle">
|
||||||
{[company?.phone, company?.address].filter(Boolean).join(' · ') || 'No contact info on file'}
|
{[company.phone, company.address].filter(Boolean).join(' · ') || 'No contact info on file'}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{!editing && (
|
{!editing && (
|
||||||
@@ -53,6 +96,15 @@ export default function MyCompany() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="stats-grid" style={{ marginBottom: 24 }}>
|
||||||
|
{companyDetails.map(detail => (
|
||||||
|
<div key={detail.label} className={`stat-card${detail.label === 'Members' ? ' stat-card-highlight' : ''}`}>
|
||||||
|
<div className="stat-value" style={{ fontSize: detail.label === 'Members' ? 28 : 18 }}>{detail.value}</div>
|
||||||
|
<div className="stat-label">{detail.label}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
{editing && (
|
{editing && (
|
||||||
<div className="card" style={{ marginBottom: 24, maxWidth: 520 }}>
|
<div className="card" style={{ marginBottom: 24, maxWidth: 520 }}>
|
||||||
<div className="card-title">Edit Company Info</div>
|
<div className="card-title">Edit Company Info</div>
|
||||||
@@ -90,7 +142,10 @@ export default function MyCompany() {
|
|||||||
<button type="submit" className="btn btn-primary" disabled={saving || !form.name.trim()}>
|
<button type="submit" className="btn btn-primary" disabled={saving || !form.name.trim()}>
|
||||||
{saving ? 'Saving...' : 'Save Changes'}
|
{saving ? 'Saving...' : 'Save Changes'}
|
||||||
</button>
|
</button>
|
||||||
<button type="button" className="btn btn-outline" onClick={() => { setEditing(false); setForm({ name: company.name || '', phone: company.phone || '', address: company.address || '' }); }}>
|
<button type="button" className="btn btn-outline" onClick={() => {
|
||||||
|
setEditing(false);
|
||||||
|
setForm({ name: company.name || '', phone: company.phone || '', address: company.address || '' });
|
||||||
|
}}>
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,8 +4,9 @@ import Layout from '../../components/Layout';
|
|||||||
import StatusBadge from '../../components/StatusBadge';
|
import StatusBadge from '../../components/StatusBadge';
|
||||||
import { supabase } from '../../lib/supabase';
|
import { supabase } from '../../lib/supabase';
|
||||||
import { useAuth } from '../../context/AuthContext';
|
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() {
|
export default function MyProjectDetail() {
|
||||||
const { id } = useParams();
|
const { id } = useParams();
|
||||||
@@ -23,24 +24,45 @@ export default function MyProjectDetail() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
async function load() {
|
async function load() {
|
||||||
const { data: p } = await supabase.from('projects').select('*').eq('id', id).single();
|
try {
|
||||||
if (!p) { setLoading(false); return; }
|
const { data: p } = await withTimeout(
|
||||||
setProject(p);
|
supabase.from('projects').select('*').eq('id', id).single(),
|
||||||
|
12000,
|
||||||
|
'Project detail load'
|
||||||
|
);
|
||||||
|
if (!p) return;
|
||||||
|
setProject(p);
|
||||||
|
|
||||||
const { data: t } = await supabase
|
const { data: t } = await withTimeout(
|
||||||
.from('tasks').select('*').eq('project_id', id).order('submitted_at', { ascending: false });
|
supabase
|
||||||
setTasks(t || []);
|
.from('tasks')
|
||||||
|
.select('*')
|
||||||
|
.eq('project_id', id)
|
||||||
|
.order('submitted_at', { ascending: false }),
|
||||||
|
12000,
|
||||||
|
'Project tasks load'
|
||||||
|
);
|
||||||
|
setTasks(t || []);
|
||||||
|
|
||||||
if (t && t.length > 0) {
|
if (t && t.length > 0) {
|
||||||
const { data: subs } = await supabase
|
const { data: subs } = await withTimeout(
|
||||||
.from('submissions')
|
supabase
|
||||||
.select('id, task_id, submitted_by, submitted_by_name, version_number, type')
|
.from('submissions')
|
||||||
.in('task_id', t.map(task => task.id))
|
.select('id, task_id, submitted_by, submitted_by_name, version_number, type')
|
||||||
.order('version_number');
|
.in('task_id', t.map(task => task.id))
|
||||||
setSubmissions(subs || []);
|
.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();
|
load();
|
||||||
}, [id]);
|
}, [id]);
|
||||||
@@ -49,9 +71,13 @@ export default function MyProjectDetail() {
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!nameVal.trim()) return;
|
if (!nameVal.trim()) return;
|
||||||
setSavingName(true);
|
setSavingName(true);
|
||||||
await supabase.from('projects').update({ name: nameVal.trim() }).eq('id', id);
|
const { error } = await supabase.from('projects').update({ name: nameVal.trim() }).eq('id', id);
|
||||||
setProject(p => ({ ...p, name: nameVal.trim() }));
|
if (!error) {
|
||||||
setEditingName(false);
|
setProject(p => ({ ...p, name: nameVal.trim() }));
|
||||||
|
setEditingName(false);
|
||||||
|
} else {
|
||||||
|
alert('Failed to save name.');
|
||||||
|
}
|
||||||
setSavingName(false);
|
setSavingName(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -104,20 +130,27 @@ export default function MyProjectDetail() {
|
|||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Filter toggle */}
|
<div className="card page-toolbar">
|
||||||
<div style={{ display: 'flex', gap: 8, marginBottom: 20 }}>
|
<div className="page-toolbar-grid">
|
||||||
<button
|
<div className="page-toolbar-section">
|
||||||
className={`btn btn-sm ${filter === 'all' ? 'btn-primary' : 'btn-outline'}`}
|
<div className="card-title" style={{ marginBottom: 10 }}>Filter</div>
|
||||||
onClick={() => setFilter('all')}
|
<div className="page-toolbar-filters">
|
||||||
>
|
<button
|
||||||
All Requests
|
className={`btn btn-sm ${filter === 'all' ? 'btn-primary' : 'btn-outline'}`}
|
||||||
</button>
|
onClick={() => setFilter('all')}
|
||||||
<button
|
>
|
||||||
className={`btn btn-sm ${filter === 'mine' ? 'btn-primary' : 'btn-outline'}`}
|
All Requests
|
||||||
onClick={() => setFilter('mine')}
|
</button>
|
||||||
>
|
<button
|
||||||
Mine Only
|
className={`btn btn-sm ${filter === 'mine' ? 'btn-primary' : 'btn-outline'}`}
|
||||||
</button>
|
onClick={() => setFilter('mine')}
|
||||||
|
>
|
||||||
|
Mine Only
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{filteredTasks.length === 0 ? (
|
{filteredTasks.length === 0 ? (
|
||||||
@@ -143,7 +176,7 @@ export default function MyProjectDetail() {
|
|||||||
<div className="request-card-title">
|
<div className="request-card-title">
|
||||||
{task.title}{' '}
|
{task.title}{' '}
|
||||||
<span style={{ fontWeight: 400, color: 'var(--text-muted)', fontSize: 13 }}>
|
<span style={{ fontWeight: 400, color: 'var(--text-muted)', fontSize: 13 }}>
|
||||||
{vLabel(task.current_version)}
|
{rLabel(task.current_version)}
|
||||||
</span>
|
</span>
|
||||||
{isMine && (
|
{isMine && (
|
||||||
<span style={{ marginLeft: 8, fontSize: 11, background: 'rgba(245,165,35,0.15)', color: 'var(--accent)', padding: '2px 8px', borderRadius: 4, fontWeight: 600 }}>
|
<span style={{ marginLeft: 8, fontSize: 11, background: 'rgba(245,165,35,0.15)', color: 'var(--accent)', padding: '2px 8px', borderRadius: 4, fontWeight: 600 }}>
|
||||||
|
|||||||
+249
-141
@@ -4,12 +4,16 @@ import JSZip from 'jszip';
|
|||||||
import Layout from '../../components/Layout';
|
import Layout from '../../components/Layout';
|
||||||
import StatusBadge from '../../components/StatusBadge';
|
import StatusBadge from '../../components/StatusBadge';
|
||||||
import FileAttachment from '../../components/FileAttachment';
|
import FileAttachment from '../../components/FileAttachment';
|
||||||
|
import LoadingButton from '../../components/LoadingButton';
|
||||||
import { supabase } from '../../lib/supabase';
|
import { supabase } from '../../lib/supabase';
|
||||||
import { sendEmail } from '../../lib/email';
|
import { sendEmail } from '../../lib/email';
|
||||||
import { useAuth } from '../../context/AuthContext';
|
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() {
|
export default function RequestDetail() {
|
||||||
const { id } = useParams();
|
const { id } = useParams();
|
||||||
@@ -26,24 +30,30 @@ export default function RequestDetail() {
|
|||||||
const [savingTitle, setSavingTitle] = useState(false);
|
const [savingTitle, setSavingTitle] = useState(false);
|
||||||
|
|
||||||
const [action, setAction] = useState(null);
|
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 [revisionFiles, setRevisionFiles] = useState([]);
|
||||||
const [submitted, setSubmitted] = useState(false);
|
const [submitted, setSubmitted] = useState(false);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [downloading, setDownloading] = useState('');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
async function load() {
|
async function load() {
|
||||||
const { data: t } = await supabase.from('tasks').select('*').eq('id', id).single();
|
try {
|
||||||
if (!t) { setLoading(false); return; }
|
const { data: t } = await supabase.from('tasks').select('*').eq('id', id).single();
|
||||||
setTask(t);
|
if (!t) return;
|
||||||
|
setTask(t);
|
||||||
|
|
||||||
const [{ data: p }, { data: subs }] = await Promise.all([
|
const [{ data: p }, { data: subs }] = await Promise.all([
|
||||||
supabase.from('projects').select('*').eq('id', t.project_id).single(),
|
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'),
|
supabase.from('submissions').select('*, files:submission_files(*), delivery:deliveries(*, files:delivery_files(*))').eq('task_id', id).order('version_number'),
|
||||||
]);
|
]);
|
||||||
setProject(p);
|
setProject(p);
|
||||||
setSubmissions(subs || []);
|
setSubmissions(subs || []);
|
||||||
setLoading(false);
|
} catch (error) {
|
||||||
|
console.error('RequestDetail load failed:', error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
load();
|
load();
|
||||||
}, [id]);
|
}, [id]);
|
||||||
@@ -58,132 +68,182 @@ export default function RequestDetail() {
|
|||||||
serviceType: task.title,
|
serviceType: task.title,
|
||||||
projectName: project?.name,
|
projectName: project?.name,
|
||||||
taskId: id,
|
taskId: id,
|
||||||
|
}).catch((emailError) => {
|
||||||
|
console.error('Client approved email failed:', emailError);
|
||||||
});
|
});
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDelete = async () => {
|
const handleDelete = async () => {
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
|
try {
|
||||||
const { data: subs } = await supabase.from('submissions').select('id').eq('task_id', id);
|
// Clean up storage files — non-blocking (don't let storage errors prevent DB delete)
|
||||||
if (subs && subs.length > 0) {
|
try {
|
||||||
const { data: storageFiles } = await supabase
|
const { data: subs } = await supabase.from('submissions').select('id').eq('task_id', id);
|
||||||
.from('submission_files').select('storage_path').in('submission_id', subs.map(s => s.id));
|
if (subs && subs.length > 0) {
|
||||||
if (storageFiles && storageFiles.length > 0) {
|
const { data: storageFiles } = await supabase
|
||||||
await supabase.storage.from('submissions').remove(storageFiles.map(f => f.storage_path));
|
.from('submission_files').select('storage_path').in('submission_id', subs.map(s => s.id));
|
||||||
}
|
if (storageFiles && storageFiles.length > 0) {
|
||||||
const { data: deliveries } = await supabase
|
await supabase.storage.from('submissions').remove(storageFiles.map(f => f.storage_path));
|
||||||
.from('deliveries').select('id').in('submission_id', subs.map(s => s.id));
|
}
|
||||||
if (deliveries && deliveries.length > 0) {
|
const { data: deliveries } = await supabase
|
||||||
const { data: deliveryFiles } = await supabase
|
.from('deliveries').select('id').in('submission_id', subs.map(s => s.id));
|
||||||
.from('delivery_files').select('storage_path').in('delivery_id', deliveries.map(d => d.id));
|
if (deliveries && deliveries.length > 0) {
|
||||||
if (deliveryFiles && deliveryFiles.length > 0) {
|
const { data: deliveryFiles } = await supabase
|
||||||
await supabase.storage.from('deliveries').remove(deliveryFiles.map(f => f.storage_path));
|
.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) => {
|
const handleRevisionSubmit = async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
|
|
||||||
if (action === 'edit') {
|
try {
|
||||||
// No version bump — amendment notes attach to the current version
|
if (action === 'edit') {
|
||||||
const { data: newSub } = await supabase.from('submissions').insert({
|
// No version bump — amendment notes attach to the current version
|
||||||
task_id: id,
|
const { data: newSub, error: subError } = await supabase.from('submissions').insert({
|
||||||
version_number: (task.current_version || 0) + 1,
|
task_id: id,
|
||||||
type: 'amendment',
|
version_number: getRevisionBaseline(task, submissions),
|
||||||
service_type: task.title,
|
type: 'amendment',
|
||||||
deadline: revisionForm.deadline || null,
|
is_hot: revisionForm.isHot,
|
||||||
description: revisionForm.description,
|
service_type: task.title,
|
||||||
submitted_by: currentUser.id,
|
deadline: revisionForm.deadline || null,
|
||||||
submitted_by_name: currentUser.name,
|
description: revisionForm.description,
|
||||||
}).select().single();
|
submitted_by: currentUser.id,
|
||||||
|
submitted_by_name: currentUser.name,
|
||||||
|
}).select().single();
|
||||||
|
if (subError) throw new Error(subError.message);
|
||||||
|
|
||||||
if (newSub && revisionFiles.length > 0) {
|
if (newSub && revisionFiles.length > 0) {
|
||||||
for (const file of revisionFiles) {
|
for (const file of revisionFiles) {
|
||||||
const path = `${id}/${Date.now()}_${file.name}`;
|
const path = `${id}/${Date.now()}_${file.name}`;
|
||||||
const { data: uploaded } = await supabase.storage.from('submissions').upload(path, file);
|
const { data: uploaded, error: uploadError } = await supabase.storage.from('submissions').upload(path, file);
|
||||||
if (uploaded) {
|
if (uploadError) throw new Error(`Failed to upload "${file.name}": ${uploadError.message}`);
|
||||||
await supabase.from('submission_files').insert({
|
if (uploaded) {
|
||||||
submission_id: newSub.id, name: file.name, storage_path: path, size: file.size,
|
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 {
|
||||||
} else {
|
const newVersion = getRevisionBaseline(task, submissions) + 1;
|
||||||
const newVersion = (task.current_version || 0) + 1;
|
await supabase.from('tasks').update({
|
||||||
await supabase.from('tasks').update({ status: 'not_started', current_version: newVersion }).eq('id', id);
|
status: 'not_started',
|
||||||
|
current_version: newVersion,
|
||||||
|
assigned_to: null,
|
||||||
|
assigned_name: null,
|
||||||
|
}).eq('id', id);
|
||||||
|
|
||||||
const { data: newSub } = await supabase.from('submissions').insert({
|
const { data: newSub, error: subError } = await supabase.from('submissions').insert({
|
||||||
task_id: id,
|
task_id: id,
|
||||||
version_number: newVersion + 1,
|
version_number: newVersion,
|
||||||
type: 'revision',
|
type: 'revision',
|
||||||
revision_type: revisionForm.revisionType,
|
is_hot: revisionForm.isHot,
|
||||||
service_type: revisionForm.serviceType,
|
revision_type: revisionForm.revisionType,
|
||||||
deadline: revisionForm.deadline || null,
|
service_type: revisionForm.serviceType,
|
||||||
description: revisionForm.description,
|
deadline: revisionForm.deadline || null,
|
||||||
submitted_by: currentUser.id,
|
description: revisionForm.description,
|
||||||
submitted_by_name: currentUser.name,
|
submitted_by: currentUser.id,
|
||||||
}).select().single();
|
submitted_by_name: currentUser.name,
|
||||||
|
}).select().single();
|
||||||
|
if (subError) throw new Error(subError.message);
|
||||||
|
|
||||||
if (newSub && revisionFiles.length > 0) {
|
if (newSub && revisionFiles.length > 0) {
|
||||||
for (const file of revisionFiles) {
|
for (const file of revisionFiles) {
|
||||||
const path = `${id}/${Date.now()}_${file.name}`;
|
const path = `${id}/${Date.now()}_${file.name}`;
|
||||||
const { data: uploaded } = await supabase.storage.from('submissions').upload(path, file);
|
const { data: uploaded, error: uploadError } = await supabase.storage.from('submissions').upload(path, file);
|
||||||
if (uploaded) {
|
if (uploadError) throw new Error(`Failed to upload "${file.name}": ${uploadError.message}`);
|
||||||
await supabase.from('submission_files').insert({
|
if (uploaded) {
|
||||||
submission_id: newSub.id, name: file.name, storage_path: path, size: file.size,
|
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', {
|
setSubmitted(true);
|
||||||
clientName: currentUser.name,
|
setAction(null);
|
||||||
serviceType: task.title,
|
} catch (err) {
|
||||||
projectName: project?.name,
|
console.error('Revision submit failed:', err);
|
||||||
version: vLabel(newVersion),
|
alert(`Failed to submit: ${err.message}`);
|
||||||
deadline: revisionForm.deadline,
|
} finally {
|
||||||
description: revisionForm.description,
|
setSaving(false);
|
||||||
taskId: id,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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 getFileUrl = async (file) => {
|
||||||
const { data } = await supabase.storage.from('deliveries').createSignedUrl(path, 3600);
|
const key = `delivery:${file.storage_path}`;
|
||||||
if (data?.signedUrl) window.open(data.signedUrl, '_blank');
|
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 getSubmissionFileUrl = async (file) => {
|
||||||
const { data } = await supabase.storage.from('submissions').createSignedUrl(path, 3600);
|
const key = `submission:${file.storage_path}`;
|
||||||
if (data?.signedUrl) window.open(data.signedUrl, '_blank');
|
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) => {
|
const handleSaveTitle = async (e) => {
|
||||||
@@ -197,22 +257,31 @@ export default function RequestDetail() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const downloadAllSubmissionFiles = async (files, versionLabel) => {
|
const downloadAllSubmissionFiles = async (files, versionLabel) => {
|
||||||
const zip = new JSZip();
|
const key = `zip:${versionLabel}`;
|
||||||
for (const file of files) {
|
if (downloading) return;
|
||||||
const { data } = await supabase.storage.from('submissions').createSignedUrl(file.storage_path, 3600);
|
setDownloading(key);
|
||||||
if (data?.signedUrl) {
|
try {
|
||||||
const response = await fetch(data.signedUrl);
|
const zip = new JSZip();
|
||||||
const blob = await response.blob();
|
for (const file of files) {
|
||||||
zip.file(file.name, blob);
|
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 <Layout><p style={{ padding: 24, color: 'var(--text-muted)' }}>Loading...</p></Layout>;
|
if (loading) return <Layout><p style={{ padding: 24, color: 'var(--text-muted)' }}>Loading...</p></Layout>;
|
||||||
@@ -221,13 +290,14 @@ export default function RequestDetail() {
|
|||||||
const canEdit = ['not_started', 'in_progress'].includes(task.status);
|
const canEdit = ['not_started', 'in_progress'].includes(task.status);
|
||||||
const canReview = task.status === 'client_review';
|
const canReview = task.status === 'client_review';
|
||||||
const canReopen = task.status === 'client_approved';
|
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'
|
const formTitle = action === 'edit'
|
||||||
? `Amend Request — ${vLabel(task.current_version || 0)}`
|
? `Amend Request — ${rLabel(revisionBaseline)}`
|
||||||
: action === 'reopen'
|
: action === 'reopen'
|
||||||
? `Request New Revision — will become ${vLabel((task.current_version || 0) + 1)}`
|
? `Request New Revision — will become ${rLabel(revisionBaseline + 1)}`
|
||||||
: `Request a Revision — will become ${vLabel((task.current_version || 0) + 1)}`;
|
: `Request a Revision — will become ${rLabel(revisionBaseline + 1)}`;
|
||||||
|
|
||||||
const formPlaceholder = action === 'edit'
|
const formPlaceholder = action === 'edit'
|
||||||
? "Describe what you'd like to update or change..."
|
? "Describe what you'd like to update or change..."
|
||||||
@@ -291,13 +361,13 @@ export default function RequestDetail() {
|
|||||||
|
|
||||||
{submitted && (
|
{submitted && (
|
||||||
<div className="notification notification-success">
|
<div className="notification notification-success">
|
||||||
✓ 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.
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{action === 'approved' && (
|
{action === 'approved' && (
|
||||||
<div className="notification notification-success">
|
<div className="notification notification-success">
|
||||||
✓ You've approved {vLabel(task.current_version)}. This job is now complete!
|
✓ You've approved {rLabel(revisionBaseline)}. This job is now complete!
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -320,7 +390,22 @@ export default function RequestDetail() {
|
|||||||
<p style={{ fontSize: 13, color: 'var(--text-secondary)', marginBottom: 16 }}>
|
<p style={{ fontSize: 13, color: 'var(--text-secondary)', marginBottom: 16 }}>
|
||||||
Your request is still being worked on. You can update the details or requirements.
|
Your request is still being worked on. You can update the details or requirements.
|
||||||
</p>
|
</p>
|
||||||
<button className="btn btn-warning" onClick={() => { setAction('edit'); setRevisionForm(f => ({ ...f, serviceType: task.title })); }}>Edit Request</button>
|
<button
|
||||||
|
className="btn btn-warning"
|
||||||
|
onClick={() => {
|
||||||
|
const currentPrimary = submissions.find(sub => sub.version_number === getRevisionBaseline(task, submissions) && sub.type !== 'amendment') || submissions[0];
|
||||||
|
setAction('edit');
|
||||||
|
setRevisionForm({
|
||||||
|
serviceType: task.title,
|
||||||
|
deadline: currentPrimary?.deadline || addDaysToDateOnly(getTodayDateOnlyEST(), 3),
|
||||||
|
description: currentPrimary?.description || '',
|
||||||
|
revisionType: 'client_revision',
|
||||||
|
isHot: Boolean(currentPrimary?.is_hot),
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Edit Request
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -330,7 +415,21 @@ export default function RequestDetail() {
|
|||||||
<p style={{ fontSize: 13, color: 'var(--text-secondary)', marginBottom: 16 }}>
|
<p style={{ fontSize: 13, color: 'var(--text-secondary)', marginBottom: 16 }}>
|
||||||
This job was approved but you can still request a new revision if needed.
|
This job was approved but you can still request a new revision if needed.
|
||||||
</p>
|
</p>
|
||||||
<button className="btn btn-warning" onClick={() => { setAction('reopen'); setRevisionForm(f => ({ ...f, serviceType: task.title })); }}>Request New Revision</button>
|
<button
|
||||||
|
className="btn btn-warning"
|
||||||
|
onClick={() => {
|
||||||
|
setAction('reopen');
|
||||||
|
setRevisionForm({
|
||||||
|
serviceType: task.title,
|
||||||
|
deadline: addDaysToDateOnly(getTodayDateOnlyEST(), 3),
|
||||||
|
description: '',
|
||||||
|
revisionType: 'client_revision',
|
||||||
|
isHot: false,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Request New Revision
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -348,6 +447,16 @@ export default function RequestDetail() {
|
|||||||
<input type="date" value={revisionForm.deadline} onChange={set('deadline')} />
|
<input type="date" value={revisionForm.deadline} onChange={set('deadline')} />
|
||||||
</div>
|
</div>
|
||||||
</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={revisionForm.isHot}
|
||||||
|
onChange={set('isHot')}
|
||||||
|
/>
|
||||||
|
<span>Mark as Hot</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
{(action === 'revision' || action === 'reopen') && (
|
{(action === 'revision' || action === 'reopen') && (
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label>Revision Type *</label>
|
<label>Revision Type *</label>
|
||||||
@@ -396,7 +505,7 @@ export default function RequestDetail() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="card-title">Version History</div>
|
<div className="card-title">Revision History</div>
|
||||||
<div className="version-timeline">
|
<div className="version-timeline">
|
||||||
{Object.values(
|
{Object.values(
|
||||||
submissions.reduce((groups, sub) => {
|
submissions.reduce((groups, sub) => {
|
||||||
@@ -413,18 +522,19 @@ export default function RequestDetail() {
|
|||||||
<div key={primary.id} className="version-item">
|
<div key={primary.id} className="version-item">
|
||||||
<div className="version-header">
|
<div className="version-header">
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||||
<div className="version-number">{vLabel(primary.version_number - 1)}</div>
|
<div className="version-number">{rLabel(primary.version_number)}</div>
|
||||||
<StatusBadge status={primary.type} />
|
<StatusBadge status={primary.type} />
|
||||||
{primary.revision_type && <StatusBadge status={primary.revision_type} />}
|
{primary.revision_type && <StatusBadge status={primary.revision_type} />}
|
||||||
</div>
|
</div>
|
||||||
<div style={{ fontSize: 12, color: 'var(--text-secondary)' }}>
|
<div style={{ fontSize: 12, color: 'var(--text-secondary)' }}>
|
||||||
{primary.submitted_by_name && <span>{primary.submitted_by_name} · </span>}
|
{primary.submitted_by_name && <span>{primary.submitted_by_name} · </span>}
|
||||||
{new Date(primary.submitted_at).toLocaleDateString()}
|
{formatDateEST(primary.submitted_at)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="detail-grid">
|
<div className="detail-grid">
|
||||||
<div className="detail-item"><label>Service</label><p>{primary.service_type}</p></div>
|
<div className="detail-item"><label>Service</label><p>{primary.service_type}</p></div>
|
||||||
<div className="detail-item"><label>Deadline</label><p>{primary.deadline || '—'}</p></div>
|
<div className="detail-item"><label>Deadline</label><p>{primary.deadline || '—'}</p></div>
|
||||||
|
<div className="detail-item"><label>Hot</label><p>{primary.is_hot ? 'Yes' : 'No'}</p></div>
|
||||||
</div>
|
</div>
|
||||||
<div className="detail-item">
|
<div className="detail-item">
|
||||||
<label>Description</label>
|
<label>Description</label>
|
||||||
@@ -435,9 +545,7 @@ export default function RequestDetail() {
|
|||||||
<div style={{ marginTop: 10 }}>
|
<div style={{ marginTop: 10 }}>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 4 }}>
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 4 }}>
|
||||||
<label style={{ fontSize: 11, fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.5px', color: 'var(--text-muted)' }}>Attached Files</label>
|
<label style={{ fontSize: 11, fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.5px', color: 'var(--text-muted)' }}>Attached Files</label>
|
||||||
{primary.files.length > 1 && (
|
<LoadingButton className="btn btn-outline btn-sm" loading={downloading === `zip:${rLabel(primary.version_number)}`} disabled={Boolean(downloading)} loadingText="Downloading..." onClick={() => downloadAllSubmissionFiles(primary.files, rLabel(primary.version_number))}>⬇ Download All</LoadingButton>
|
||||||
<button className="btn btn-outline btn-sm" onClick={() => downloadAllSubmissionFiles(primary.files, vLabel(primary.version_number - 1))}>⬇ Download All</button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||||
{primary.files.map((file, fi) => (
|
{primary.files.map((file, fi) => (
|
||||||
@@ -446,7 +554,7 @@ export default function RequestDetail() {
|
|||||||
<span>📎</span>
|
<span>📎</span>
|
||||||
<span style={{ fontSize: 13, fontWeight: 500 }}>{file.name}</span>
|
<span style={{ fontSize: 13, fontWeight: 500 }}>{file.name}</span>
|
||||||
</div>
|
</div>
|
||||||
<button className="btn btn-outline btn-sm" onClick={() => getSubmissionFileUrl(file.storage_path)}>📥 View</button>
|
<LoadingButton className="btn btn-outline btn-sm" loading={downloading === `submission:${file.storage_path}`} disabled={Boolean(downloading)} loadingText="Downloading..." onClick={() => getSubmissionFileUrl(file)}>📥 Download</LoadingButton>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -459,7 +567,7 @@ export default function RequestDetail() {
|
|||||||
Amended Request
|
Amended Request
|
||||||
</div>
|
</div>
|
||||||
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginBottom: 6 }}>
|
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginBottom: 6 }}>
|
||||||
{amendment.submitted_by_name} · {new Date(amendment.submitted_at).toLocaleDateString()}
|
{amendment.submitted_by_name} · {formatDateEST(amendment.submitted_at)}
|
||||||
</div>
|
</div>
|
||||||
<p style={{ fontSize: 13, lineHeight: 1.6, color: 'var(--text-secondary)', whiteSpace: 'pre-wrap', margin: 0 }}>{amendment.description}</p>
|
<p style={{ fontSize: 13, lineHeight: 1.6, color: 'var(--text-secondary)', whiteSpace: 'pre-wrap', margin: 0 }}>{amendment.description}</p>
|
||||||
{amendment.files?.length > 0 && (
|
{amendment.files?.length > 0 && (
|
||||||
@@ -470,7 +578,7 @@ export default function RequestDetail() {
|
|||||||
<span>📎</span>
|
<span>📎</span>
|
||||||
<span style={{ fontSize: 12, fontWeight: 500 }}>{file.name}</span>
|
<span style={{ fontSize: 12, fontWeight: 500 }}>{file.name}</span>
|
||||||
</div>
|
</div>
|
||||||
<button className="btn btn-outline btn-sm" onClick={() => getSubmissionFileUrl(file.storage_path)}>📥 View</button>
|
<LoadingButton className="btn btn-outline btn-sm" loading={downloading === `submission:${file.storage_path}`} disabled={Boolean(downloading)} loadingText="Downloading..." onClick={() => getSubmissionFileUrl(file)}>📥 Download</LoadingButton>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -481,7 +589,7 @@ export default function RequestDetail() {
|
|||||||
{delivery && delivery.files && delivery.files.length > 0 && (
|
{delivery && delivery.files && delivery.files.length > 0 && (
|
||||||
<div style={{ marginTop: 12, padding: '10px 14px', background: 'var(--bg)', borderRadius: 8, border: '1px solid var(--border)' }}>
|
<div style={{ marginTop: 12, padding: '10px 14px', background: 'var(--bg)', borderRadius: 8, border: '1px solid var(--border)' }}>
|
||||||
<div style={{ fontSize: 11, fontWeight: 600, textTransform: 'uppercase', color: '#16a34a', marginBottom: 8 }}>
|
<div style={{ fontSize: 11, fontWeight: 600, textTransform: 'uppercase', color: '#16a34a', marginBottom: 8 }}>
|
||||||
✓ Delivered {new Date(delivery.sent_at).toLocaleDateString()}
|
✓ Delivered {formatDateEST(delivery.sent_at)}
|
||||||
</div>
|
</div>
|
||||||
{delivery.files.map((file, fi) => (
|
{delivery.files.map((file, fi) => (
|
||||||
<div key={fi} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '6px 10px', background: 'var(--card-bg)', borderRadius: 6, border: '1px solid var(--border)', marginBottom: 4 }}>
|
<div key={fi} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '6px 10px', background: 'var(--card-bg)', borderRadius: 6, border: '1px solid var(--border)', marginBottom: 4 }}>
|
||||||
@@ -489,7 +597,7 @@ export default function RequestDetail() {
|
|||||||
<span>📄</span>
|
<span>📄</span>
|
||||||
<span style={{ fontSize: 13, fontWeight: 500 }}>{file.name}</span>
|
<span style={{ fontSize: 13, fontWeight: 500 }}>{file.name}</span>
|
||||||
</div>
|
</div>
|
||||||
<button className="btn btn-outline btn-sm" onClick={() => getFileUrl(file.storage_path)}>📥 View</button>
|
<LoadingButton className="btn btn-outline btn-sm" loading={downloading === `delivery:${file.storage_path}`} disabled={Boolean(downloading)} loadingText="Downloading..." onClick={() => getFileUrl(file)}>📥 Download</LoadingButton>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { useParams, useNavigate, useLocation, Link } from 'react-router-dom';
|
import { useParams, useNavigate, useLocation, Link } from 'react-router-dom';
|
||||||
import Layout from '../../components/Layout';
|
import Layout from '../../components/Layout';
|
||||||
|
import LoadingButton from '../../components/LoadingButton';
|
||||||
import { supabase } from '../../lib/supabase';
|
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' };
|
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 [invoice, setInvoice] = useState(state?.invoice || null);
|
||||||
const [company, setCompany] = useState(null);
|
const [company, setCompany] = useState(null);
|
||||||
|
const [companies, setCompanies] = useState([]);
|
||||||
const [items, setItems] = useState([]);
|
const [items, setItems] = useState([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [saving, setSaving] = useState(false);
|
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(() => {
|
useEffect(() => {
|
||||||
async function load() {
|
async function load() {
|
||||||
@@ -24,11 +32,15 @@ export default function InvoiceDetail() {
|
|||||||
if (!inv) return;
|
if (!inv) return;
|
||||||
setInvoice(inv);
|
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('*').eq('id', inv.company_id).single(),
|
||||||
|
supabase.from('companies').select('*').order('name'),
|
||||||
supabase.from('invoice_items').select('*').eq('invoice_id', id).order('created_at'),
|
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);
|
setCompany(co);
|
||||||
|
setCompanies(companyList || []);
|
||||||
|
setEmailRecipient(defaultEmail);
|
||||||
setItems(its || []);
|
setItems(its || []);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('InvoiceDetail load failed:', error);
|
console.error('InvoiceDetail load failed:', error);
|
||||||
@@ -41,13 +53,144 @@ export default function InvoiceDetail() {
|
|||||||
|
|
||||||
const updateStatus = async (status) => {
|
const updateStatus = async (status) => {
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
await supabase.from('invoices').update({ status }).eq('id', id);
|
const updates = { status };
|
||||||
setInvoice(i => ({ ...i, 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);
|
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 () => {
|
const handleDelete = async () => {
|
||||||
if (!window.confirm('Delete this invoice? This cannot be undone.')) return;
|
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
const { data: freshItems } = await supabase.from('invoice_items').select('task_id, submission_id').eq('invoice_id', id);
|
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 () => {
|
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>;
|
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' }}>
|
<span className={`badge badge-${statusColor[invoice.status]}`} style={{ fontSize: 13, padding: '6px 14px', textTransform: 'capitalize' }}>
|
||||||
{invoice.status}{isOverdue ? ' · Overdue' : ''}
|
{invoice.status}{isOverdue ? ' · Overdue' : ''}
|
||||||
</span>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -101,14 +344,59 @@ export default function InvoiceDetail() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="card">
|
<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-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">
|
||||||
<div className="detail-item"><label>Due Date</label>
|
<label>Invoice Date</label>
|
||||||
<p style={{ color: isOverdue ? 'var(--danger)' : 'inherit' }}>{new Date(invoice.due_date).toLocaleDateString()}</p>
|
{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>
|
||||||
<div className="detail-item"><label>Terms</label><p>Net 30</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>
|
<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 && (
|
{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>
|
<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="card-title">Actions</div>
|
||||||
<div className="action-buttons">
|
<div className="action-buttons">
|
||||||
{invoice.status === 'draft' && (
|
{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' && (
|
{invoice.status === 'sent' && (
|
||||||
<button className="btn btn-success" onClick={() => updateStatus('paid')} disabled={saving}>Mark as Paid</button>
|
<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' && (
|
{invoice.status === 'paid' && (
|
||||||
<button className="btn btn-outline" onClick={() => updateStatus('sent')} disabled={saving}>Reopen</button>
|
<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>
|
<button className="btn btn-danger" onClick={handleDelete} disabled={saving}>Delete Invoice</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -6,6 +6,9 @@ import { supabase } from '../../lib/supabase';
|
|||||||
import { useAuth } from '../../context/AuthContext';
|
import { useAuth } from '../../context/AuthContext';
|
||||||
import { serviceTypes } from '../../data/mockData';
|
import { serviceTypes } from '../../data/mockData';
|
||||||
import { cleanupTaskStorage } from '../../lib/deleteHelpers';
|
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() {
|
export default function ProjectDetail() {
|
||||||
const { id } = useParams();
|
const { id } = useParams();
|
||||||
@@ -25,33 +28,42 @@ export default function ProjectDetail() {
|
|||||||
const [savingName, setSavingName] = useState(false);
|
const [savingName, setSavingName] = useState(false);
|
||||||
|
|
||||||
const [showAddJob, setShowAddJob] = 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 [savingJob, setSavingJob] = useState(false);
|
||||||
|
|
||||||
const [members, setMembers] = useState([]);
|
const [members, setMembers] = useState([]);
|
||||||
const [externalProfiles, setExternalProfiles] = useState([]);
|
const [externalProfiles, setExternalProfiles] = useState([]);
|
||||||
const [selectedExternal, setSelectedExternal] = useState('');
|
const [selectedExternal, setSelectedExternal] = useState('');
|
||||||
const [addingMember, setAddingMember] = useState(false);
|
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(() => {
|
useEffect(() => {
|
||||||
async function load() {
|
async function load() {
|
||||||
const { data: p } = await supabase.from('projects').select('*').eq('id', id).single();
|
try {
|
||||||
if (!p) { setLoading(false); return; }
|
const { data: p } = await supabase.from('projects').select('*').eq('id', id).single();
|
||||||
setProject(p);
|
if (!p) return;
|
||||||
|
setProject(p);
|
||||||
|
|
||||||
const [{ data: co }, { data: t }, { data: users }, { data: pm }, { data: ext }] = await Promise.all([
|
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('companies').select('*').eq('id', p.company_id).single(),
|
||||||
supabase.from('tasks').select('*').eq('project_id', id).order('submitted_at', { ascending: false }),
|
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('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('project_members').select('*, profile:profiles(id, name, email)').eq('project_id', id),
|
||||||
supabase.from('profiles').select('id, name, email').eq('role', 'external').order('name'),
|
supabase.from('profiles').select('id, name, email').eq('role', 'external').order('name'),
|
||||||
]);
|
]);
|
||||||
setCompany(co);
|
setCompany(co);
|
||||||
setTasks(t || []);
|
setTasks(t || []);
|
||||||
setCompanyUsers(users || []);
|
setCompanyUsers(users || []);
|
||||||
setMembers(pm || []);
|
setMembers(pm || []);
|
||||||
setExternalProfiles(ext || []);
|
setExternalProfiles(ext || []);
|
||||||
setLoading(false);
|
} catch (error) {
|
||||||
|
console.error('ProjectDetail load failed:', error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
load();
|
load();
|
||||||
}, [id]);
|
}, [id]);
|
||||||
@@ -75,15 +87,24 @@ export default function ProjectDetail() {
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!nameVal.trim()) return;
|
if (!nameVal.trim()) return;
|
||||||
setSavingName(true);
|
setSavingName(true);
|
||||||
await supabase.from('projects').update({ name: nameVal.trim() }).eq('id', id);
|
const { error } = await supabase.from('projects').update({ name: nameVal.trim() }).eq('id', id);
|
||||||
setProject(p => ({ ...p, name: nameVal.trim() }));
|
if (!error) {
|
||||||
setEditingName(false);
|
setProject(p => ({ ...p, name: nameVal.trim() }));
|
||||||
|
setEditingName(false);
|
||||||
|
} else {
|
||||||
|
alert('Failed to save name.');
|
||||||
|
}
|
||||||
setSavingName(false);
|
setSavingName(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleAddJob = async (e) => {
|
const handleAddJob = async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setSavingJob(true);
|
setSavingJob(true);
|
||||||
|
const requestor = requesterOptions.find(u => u.id === jobForm.requestedBy);
|
||||||
|
if (!requestor) {
|
||||||
|
setSavingJob(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const { data: task } = await supabase.from('tasks').insert({
|
const { data: task } = await supabase.from('tasks').insert({
|
||||||
project_id: id,
|
project_id: id,
|
||||||
@@ -93,20 +114,20 @@ export default function ProjectDetail() {
|
|||||||
}).select().single();
|
}).select().single();
|
||||||
|
|
||||||
if (task) {
|
if (task) {
|
||||||
const requestor = companyUsers.find(u => u.id === jobForm.requestedBy);
|
|
||||||
await supabase.from('submissions').insert({
|
await supabase.from('submissions').insert({
|
||||||
task_id: task.id,
|
task_id: task.id,
|
||||||
version_number: 1,
|
version_number: 0,
|
||||||
type: 'initial',
|
type: 'initial',
|
||||||
|
is_hot: jobForm.isHot,
|
||||||
service_type: jobForm.serviceType,
|
service_type: jobForm.serviceType,
|
||||||
deadline: jobForm.deadline || null,
|
deadline: jobForm.deadline || null,
|
||||||
description: jobForm.description.trim() || null,
|
description: jobForm.description.trim() || null,
|
||||||
submitted_by: requestor?.id || null,
|
submitted_by: requestor.id,
|
||||||
submitted_by_name: requestor?.name || 'Team',
|
submitted_by_name: requestor.name.replace(' (You)', ''),
|
||||||
});
|
});
|
||||||
|
|
||||||
setTasks(prev => [task, ...prev]);
|
setTasks(prev => [task, ...prev]);
|
||||||
setJobForm({ title: '', serviceType: '', deadline: '', description: '', requestedBy: '' });
|
setJobForm(emptyJobForm());
|
||||||
setShowAddJob(false);
|
setShowAddJob(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -229,18 +250,27 @@ export default function ProjectDetail() {
|
|||||||
onChange={e => setJobForm(f => ({ ...f, deadline: e.target.value }))}
|
onChange={e => setJobForm(f => ({ ...f, deadline: e.target.value }))}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{companyUsers.length > 0 && (
|
<div className="form-group">
|
||||||
<div className="form-group">
|
<label>Requested By *</label>
|
||||||
<label>Requested By <span style={{ fontWeight: 400, color: 'var(--text-muted)' }}>(optional)</span></label>
|
<select
|
||||||
<select
|
value={jobForm.requestedBy}
|
||||||
value={jobForm.requestedBy}
|
onChange={e => setJobForm(f => ({ ...f, requestedBy: e.target.value }))}
|
||||||
onChange={e => setJobForm(f => ({ ...f, requestedBy: e.target.value }))}
|
required
|
||||||
>
|
>
|
||||||
<option value="">Team (no client)</option>
|
<option value="">Select requester...</option>
|
||||||
{companyUsers.map(u => <option key={u.id} value={u.id}>{u.name}</option>)}
|
{requesterOptions.map(u => <option key={u.id} value={u.id}>{u.name}{u.email ? ` (${u.email})` : ''}</option>)}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</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>
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label>Notes <span style={{ fontWeight: 400, color: 'var(--text-muted)' }}>(optional)</span></label>
|
<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}>
|
<button type="submit" className="btn btn-primary" disabled={savingJob}>
|
||||||
{savingJob ? 'Adding...' : 'Add Job'}
|
{savingJob ? 'Adding...' : 'Add Job'}
|
||||||
</button>
|
</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
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -298,7 +328,7 @@ export default function ProjectDetail() {
|
|||||||
<tr>
|
<tr>
|
||||||
<th>Job</th>
|
<th>Job</th>
|
||||||
<th>Assigned To</th>
|
<th>Assigned To</th>
|
||||||
<th>Version</th>
|
<th>Revision</th>
|
||||||
<th>Status</th>
|
<th>Status</th>
|
||||||
<th>Submitted</th>
|
<th>Submitted</th>
|
||||||
<th></th>
|
<th></th>
|
||||||
@@ -310,14 +340,14 @@ export default function ProjectDetail() {
|
|||||||
<td>
|
<td>
|
||||||
{task.title}
|
{task.title}
|
||||||
<span style={{ marginLeft: 6, fontWeight: 600, color: 'var(--text-muted)', fontSize: 12 }}>
|
<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>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td style={{ color: task.assigned_name ? 'var(--text-primary)' : 'var(--text-muted)' }}>
|
<td style={{ color: task.assigned_name ? 'var(--text-primary)' : 'var(--text-muted)' }}>
|
||||||
{task.assigned_name || 'Unassigned'}
|
{task.assigned_name || 'Unassigned'}
|
||||||
</td>
|
</td>
|
||||||
<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>
|
||||||
<td><StatusBadge status={task.status} /></td>
|
<td><StatusBadge status={task.status} /></td>
|
||||||
<td style={{ color: 'var(--text-secondary)' }}>
|
<td style={{ color: 'var(--text-secondary)' }}>
|
||||||
|
|||||||
@@ -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;
|
||||||
Reference in New Issue
Block a user