aeaf6558ac
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
375 lines
17 KiB
React
Executable File
375 lines
17 KiB
React
Executable File
import { useState, useEffect } from 'react';
|
|
import { useParams, useNavigate } from 'react-router-dom';
|
|
import Layout from '../../components/Layout';
|
|
import StatusBadge from '../../components/StatusBadge';
|
|
import FileAttachment from '../../components/FileAttachment';
|
|
import { supabase } from '../../lib/supabase';
|
|
import { sendEmail } from '../../lib/email';
|
|
import { useAuth } from '../../context/AuthContext';
|
|
import { serviceTypes } from '../../data/mockData';
|
|
|
|
const vLabel = (v) => 'v' + String(v).padStart(2, '0');
|
|
|
|
export default function RequestDetail() {
|
|
const { id } = useParams();
|
|
const navigate = useNavigate();
|
|
const { currentUser } = useAuth();
|
|
|
|
const [task, setTask] = useState(null);
|
|
const [project, setProject] = useState(null);
|
|
const [submissions, setSubmissions] = useState([]);
|
|
const [loading, setLoading] = useState(true);
|
|
|
|
const [action, setAction] = useState(null);
|
|
const [revisionForm, setRevisionForm] = useState({ serviceType: '', deadline: '', description: '' });
|
|
const [revisionFiles, setRevisionFiles] = useState([]);
|
|
const [submitted, setSubmitted] = useState(false);
|
|
const [saving, setSaving] = useState(false);
|
|
|
|
useEffect(() => {
|
|
async function load() {
|
|
const { data: t } = await supabase.from('tasks').select('*').eq('id', id).single();
|
|
if (!t) { setLoading(false); return; }
|
|
setTask(t);
|
|
|
|
const [{ data: p }, { data: subs }] = await Promise.all([
|
|
supabase.from('projects').select('*').eq('id', t.project_id).single(),
|
|
supabase.from('submissions').select('*, delivery:deliveries(*, files:delivery_files(*))').eq('task_id', id).order('version_number'),
|
|
]);
|
|
setProject(p);
|
|
setSubmissions(subs || []);
|
|
setLoading(false);
|
|
}
|
|
load();
|
|
}, [id]);
|
|
|
|
const handleApprove = async () => {
|
|
setSaving(true);
|
|
await supabase.from('tasks').update({ status: 'client_approved', completed_at: new Date().toISOString() }).eq('id', id);
|
|
setTask(t => ({ ...t, status: 'client_approved' }));
|
|
setAction('approved');
|
|
sendEmail('client_approved', 'hello@fourgebranding.com', {
|
|
clientName: currentUser.name,
|
|
serviceType: task.title,
|
|
projectName: project?.name,
|
|
taskId: id,
|
|
});
|
|
setSaving(false);
|
|
};
|
|
|
|
const handleDelete = async () => {
|
|
setSaving(true);
|
|
|
|
const { data: subs } = await supabase.from('submissions').select('id').eq('task_id', id);
|
|
if (subs && subs.length > 0) {
|
|
const { data: storageFiles } = await supabase
|
|
.from('submission_files').select('storage_path').in('submission_id', subs.map(s => s.id));
|
|
if (storageFiles && storageFiles.length > 0) {
|
|
await supabase.storage.from('submissions').remove(storageFiles.map(f => f.storage_path));
|
|
}
|
|
const { data: deliveries } = await supabase
|
|
.from('deliveries').select('id').in('submission_id', subs.map(s => s.id));
|
|
if (deliveries && deliveries.length > 0) {
|
|
const { data: deliveryFiles } = await supabase
|
|
.from('delivery_files').select('storage_path').in('delivery_id', deliveries.map(d => d.id));
|
|
if (deliveryFiles && deliveryFiles.length > 0) {
|
|
await supabase.storage.from('deliveries').remove(deliveryFiles.map(f => f.storage_path));
|
|
}
|
|
}
|
|
}
|
|
|
|
await supabase.from('tasks').delete().eq('id', id);
|
|
|
|
const { data: remaining } = await supabase.from('tasks').select('id').eq('project_id', task.project_id);
|
|
if (!remaining || remaining.length === 0) {
|
|
await supabase.from('projects').delete().eq('id', task.project_id);
|
|
}
|
|
|
|
navigate('/my-projects');
|
|
};
|
|
|
|
const handleRevisionSubmit = async (e) => {
|
|
e.preventDefault();
|
|
setSaving(true);
|
|
|
|
if (action === 'edit') {
|
|
// No version bump — amendment notes attach to the current version
|
|
const { data: newSub } = await supabase.from('submissions').insert({
|
|
task_id: id,
|
|
version_number: (task.current_version || 0) + 1,
|
|
type: 'amendment',
|
|
service_type: task.title,
|
|
deadline: revisionForm.deadline || null,
|
|
description: revisionForm.description,
|
|
submitted_by: currentUser.id,
|
|
submitted_by_name: currentUser.name,
|
|
}).select().single();
|
|
|
|
if (newSub && revisionFiles.length > 0) {
|
|
for (const file of revisionFiles) {
|
|
const path = `${id}/${Date.now()}_${file.name}`;
|
|
const { data: uploaded } = await supabase.storage.from('submissions').upload(path, file);
|
|
if (uploaded) {
|
|
await supabase.from('submission_files').insert({
|
|
submission_id: newSub.id, name: file.name, storage_path: path, size: file.size,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
const newVersion = (task.current_version || 0) + 1;
|
|
await supabase.from('tasks').update({ status: 'not_started', current_version: newVersion }).eq('id', id);
|
|
|
|
const { data: newSub } = await supabase.from('submissions').insert({
|
|
task_id: id,
|
|
version_number: newVersion + 1,
|
|
type: 'revision',
|
|
service_type: revisionForm.serviceType,
|
|
deadline: revisionForm.deadline || null,
|
|
description: revisionForm.description,
|
|
submitted_by: currentUser.id,
|
|
submitted_by_name: currentUser.name,
|
|
}).select().single();
|
|
|
|
if (newSub && revisionFiles.length > 0) {
|
|
for (const file of revisionFiles) {
|
|
const path = `${id}/${Date.now()}_${file.name}`;
|
|
const { data: uploaded } = await supabase.storage.from('submissions').upload(path, file);
|
|
if (uploaded) {
|
|
await supabase.from('submission_files').insert({
|
|
submission_id: newSub.id, name: file.name, storage_path: path, size: file.size,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
setTask(t => ({ ...t, status: 'not_started', current_version: newVersion }));
|
|
|
|
sendEmail('revision_submitted', 'hello@fourgebranding.com', {
|
|
clientName: currentUser.name,
|
|
serviceType: task.title,
|
|
projectName: project?.name,
|
|
version: vLabel(newVersion),
|
|
deadline: revisionForm.deadline,
|
|
description: revisionForm.description,
|
|
taskId: id,
|
|
});
|
|
}
|
|
|
|
const { data: refreshed } = await supabase
|
|
.from('submissions')
|
|
.select('*, 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 getFileUrl = async (path) => {
|
|
const { data } = await supabase.storage.from('deliveries').createSignedUrl(path, 3600);
|
|
if (data?.signedUrl) window.open(data.signedUrl, '_blank');
|
|
};
|
|
|
|
if (loading) return <Layout><p style={{ padding: 24, color: 'var(--text-muted)' }}>Loading...</p></Layout>;
|
|
if (!task) return <Layout><p>Job not found.</p></Layout>;
|
|
|
|
const canEdit = ['not_started', 'in_progress'].includes(task.status);
|
|
const canReview = task.status === 'client_review';
|
|
const canReopen = task.status === 'client_approved';
|
|
const titleWithVersion = `${task.title} ${vLabel(task.current_version)}`;
|
|
|
|
const formTitle = action === 'edit'
|
|
? `Amend Request — ${vLabel(task.current_version || 0)}`
|
|
: action === 'reopen'
|
|
? `Request New Revision — will become ${vLabel((task.current_version || 0) + 1)}`
|
|
: `Request a Revision — will become ${vLabel((task.current_version || 0) + 1)}`;
|
|
|
|
const formPlaceholder = action === 'edit'
|
|
? "Describe what you'd like to update or change..."
|
|
: "Describe exactly what you'd like us to change or improve...";
|
|
|
|
return (
|
|
<Layout>
|
|
<button className="back-link" onClick={() => navigate('/my-projects')}>← Back to Projects</button>
|
|
|
|
<div className="page-header">
|
|
<div>
|
|
<div className="page-title">{titleWithVersion}</div>
|
|
<div className="page-subtitle">{project?.name}</div>
|
|
</div>
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
|
<StatusBadge status={task.status} />
|
|
{action !== 'confirm-delete' && (
|
|
<button
|
|
className="btn btn-sm"
|
|
style={{ background: '#ef4444', color: 'white', border: 'none' }}
|
|
onClick={() => setAction('confirm-delete')}
|
|
>
|
|
Delete
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{action === 'confirm-delete' && (
|
|
<div className="card" style={{ background: '#fef2f2', borderColor: '#fecaca', marginBottom: 24 }}>
|
|
<div style={{ fontWeight: 600, marginBottom: 8 }}>⚠ Delete this request?</div>
|
|
<p style={{ fontSize: 13, color: 'var(--text-secondary)', marginBottom: 16 }}>
|
|
This will permanently delete <strong>{titleWithVersion}</strong> and all its history. This cannot be undone.
|
|
</p>
|
|
<div className="action-buttons">
|
|
<button className="btn" style={{ background: '#ef4444', color: 'white', border: 'none' }} onClick={handleDelete} disabled={saving}>
|
|
{saving ? 'Deleting...' : 'Yes, Delete'}
|
|
</button>
|
|
<button className="btn btn-outline" onClick={() => setAction(null)}>Cancel</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{submitted && (
|
|
<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.
|
|
</div>
|
|
)}
|
|
|
|
{action === 'approved' && (
|
|
<div className="notification notification-success">
|
|
✓ You've approved {vLabel(task.current_version)}. This job is now complete!
|
|
</div>
|
|
)}
|
|
|
|
{canReview && !submitted && action !== 'confirm-delete' && action !== 'revision' && (
|
|
<div className="card" style={{ borderColor: 'var(--accent)', marginBottom: 24 }}>
|
|
<div style={{ fontWeight: 600, marginBottom: 8 }}>🎨 Your work is ready for review!</div>
|
|
<p style={{ fontSize: 13, color: 'var(--text-secondary)', marginBottom: 16 }}>
|
|
Please review the delivered work for <strong>{titleWithVersion}</strong> and let us know if you're happy or need changes.
|
|
</p>
|
|
<div className="action-buttons">
|
|
<button className="btn btn-success" onClick={handleApprove} disabled={saving}>✓ Approve — I'm Happy!</button>
|
|
<button className="btn btn-warning" onClick={() => { setAction('revision'); setRevisionForm(f => ({ ...f, serviceType: task.title })); }}>✏️ Request Revision</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{canEdit && !submitted && action !== 'confirm-delete' && action !== 'edit' && (
|
|
<div className="card" style={{ marginBottom: 24 }}>
|
|
<div style={{ fontWeight: 600, marginBottom: 8 }}>✏️ Need to make changes?</div>
|
|
<p style={{ fontSize: 13, color: 'var(--text-secondary)', marginBottom: 16 }}>
|
|
Your request is still being worked on. You can update the details or requirements.
|
|
</p>
|
|
<button className="btn btn-warning" onClick={() => { setAction('edit'); setRevisionForm(f => ({ ...f, serviceType: task.title })); }}>Edit Request</button>
|
|
</div>
|
|
)}
|
|
|
|
{canReopen && !submitted && action !== 'confirm-delete' && action !== 'reopen' && (
|
|
<div className="card" style={{ marginBottom: 24 }}>
|
|
<div style={{ fontWeight: 600, marginBottom: 8 }}>🔄 Need more changes?</div>
|
|
<p style={{ fontSize: 13, color: 'var(--text-secondary)', marginBottom: 16 }}>
|
|
This job was approved but you can still request a new revision if needed.
|
|
</p>
|
|
<button className="btn btn-warning" onClick={() => { setAction('reopen'); setRevisionForm(f => ({ ...f, serviceType: task.title })); }}>Request New Revision</button>
|
|
</div>
|
|
)}
|
|
|
|
{(action === 'revision' || action === 'edit' || action === 'reopen') && (
|
|
<div className="card" style={{ marginBottom: 24 }}>
|
|
<div className="card-title">{formTitle}</div>
|
|
<form onSubmit={handleRevisionSubmit}>
|
|
<div className="grid-2">
|
|
<div className="form-group">
|
|
<label>Service Type</label>
|
|
<input type="text" value={revisionForm.serviceType} readOnly disabled style={{ opacity: 0.6, cursor: 'not-allowed' }} />
|
|
</div>
|
|
<div className="form-group">
|
|
<label>Deadline</label>
|
|
<input type="date" value={revisionForm.deadline} onChange={set('deadline')} />
|
|
</div>
|
|
</div>
|
|
<div className="form-group">
|
|
<label>{action === 'edit' ? 'What would you like to change? *' : 'What needs to be changed? *'}</label>
|
|
<textarea placeholder={formPlaceholder} value={revisionForm.description} onChange={set('description')} required />
|
|
</div>
|
|
<FileAttachment files={revisionFiles} onChange={setRevisionFiles} />
|
|
<div className="action-buttons">
|
|
<button type="submit" className="btn btn-primary" disabled={saving}>{saving ? 'Submitting...' : 'Submit'}</button>
|
|
<button type="button" className="btn btn-outline" onClick={() => setAction(null)}>Cancel</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
)}
|
|
|
|
<div className="card-title">Version History</div>
|
|
<div className="version-timeline">
|
|
{Object.values(
|
|
submissions.reduce((groups, sub) => {
|
|
const key = sub.version_number;
|
|
if (!groups[key]) groups[key] = [];
|
|
groups[key].push(sub);
|
|
return groups;
|
|
}, {})
|
|
).map(group => {
|
|
const primary = group.find(s => s.type !== 'amendment') || group[0];
|
|
const amendments = group.filter(s => s.type === 'amendment');
|
|
const delivery = primary.delivery;
|
|
return (
|
|
<div key={primary.id} className="version-item">
|
|
<div className="version-header">
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
|
<div className="version-number">{vLabel(primary.version_number - 1)}</div>
|
|
<StatusBadge status={primary.type} />
|
|
</div>
|
|
<div style={{ fontSize: 12, color: 'var(--text-secondary)' }}>
|
|
{primary.submitted_by_name && <span>{primary.submitted_by_name} · </span>}
|
|
{new Date(primary.submitted_at).toLocaleDateString()}
|
|
</div>
|
|
</div>
|
|
<div className="detail-grid">
|
|
<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>
|
|
<div className="detail-item">
|
|
<label>Description</label>
|
|
<p style={{ marginTop: 4, lineHeight: 1.6, color: 'var(--text-secondary)', whiteSpace: 'pre-wrap' }}>{primary.description}</p>
|
|
</div>
|
|
|
|
{amendments.map(amendment => (
|
|
<div key={amendment.id} style={{ marginTop: 12, padding: '12px 14px', background: 'var(--bg)', borderRadius: 8, border: '1px solid var(--border)' }}>
|
|
<div style={{ fontSize: 11, fontWeight: 700, textTransform: 'uppercase', color: 'var(--accent)', marginBottom: 8, letterSpacing: 0.5 }}>
|
|
Amended Request
|
|
</div>
|
|
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginBottom: 6 }}>
|
|
{amendment.submitted_by_name} · {new Date(amendment.submitted_at).toLocaleDateString()}
|
|
</div>
|
|
<p style={{ fontSize: 13, lineHeight: 1.6, color: 'var(--text-secondary)', whiteSpace: 'pre-wrap', margin: 0 }}>{amendment.description}</p>
|
|
</div>
|
|
))}
|
|
|
|
{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={{ fontSize: 11, fontWeight: 600, textTransform: 'uppercase', color: '#16a34a', marginBottom: 8 }}>
|
|
✓ Delivered {new Date(delivery.sent_at).toLocaleDateString()}
|
|
</div>
|
|
{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 style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
|
<span>📄</span>
|
|
<span style={{ fontSize: 13, fontWeight: 500 }}>{file.name}</span>
|
|
</div>
|
|
<button className="btn btn-outline btn-sm" onClick={() => getFileUrl(file.storage_path)}>📥 View</button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</Layout>
|
|
);
|
|
}
|