342fa0805f
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
413 lines
22 KiB
React
Executable File
413 lines
22 KiB
React
Executable File
import { useState, useEffect } from 'react';
|
||
import { useParams, useNavigate, Link } from 'react-router-dom';
|
||
import Layout from '../../components/Layout';
|
||
import StatusBadge from '../../components/StatusBadge';
|
||
import { supabase } from '../../lib/supabase';
|
||
import { sendEmail } from '../../lib/email';
|
||
import { useAuth } from '../../context/AuthContext';
|
||
|
||
const vLabel = (v) => 'v' + String(v).padStart(2, '0');
|
||
const MAX_FILES = 20;
|
||
const MAX_SIZE_MB = 10;
|
||
const MAX_SIZE_BYTES = MAX_SIZE_MB * 1024 * 1024;
|
||
const formatSize = (bytes) => bytes < 1024 * 1024 ? (bytes / 1024).toFixed(1) + ' KB' : (bytes / (1024 * 1024)).toFixed(1) + ' MB';
|
||
|
||
export default function TaskDetail() {
|
||
const { id } = useParams();
|
||
const navigate = useNavigate();
|
||
const { currentUser } = useAuth();
|
||
|
||
const [task, setTask] = useState(null);
|
||
const [project, setProject] = useState(null);
|
||
const [company, setCompany] = useState(null);
|
||
const [submissions, setSubmissions] = useState([]);
|
||
const [teamMembers, setTeamMembers] = useState([]);
|
||
const [loading, setLoading] = useState(true);
|
||
|
||
const [notification, setNotification] = useState(null);
|
||
const [showSendForm, setShowSendForm] = useState(false);
|
||
const [sendForm, setSendForm] = useState({ files: [], message: '' });
|
||
const [fileErrors, setFileErrors] = useState([]);
|
||
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 }, { data: team }] = 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'),
|
||
supabase.from('profiles').select('*').eq('role', 'team'),
|
||
]);
|
||
setProject(p);
|
||
setSubmissions(subs || []);
|
||
setTeamMembers(team || []);
|
||
|
||
if (p) {
|
||
const { data: co } = await supabase.from('companies').select('*').eq('id', p.company_id).single();
|
||
setCompany(co);
|
||
}
|
||
setLoading(false);
|
||
}
|
||
load();
|
||
}, [id]);
|
||
|
||
const updateStatus = async (newStatus, message) => {
|
||
setSaving(true);
|
||
await supabase.from('tasks').update({ status: newStatus }).eq('id', id);
|
||
setTask(t => ({ ...t, status: newStatus }));
|
||
setNotification(message);
|
||
setSaving(false);
|
||
};
|
||
|
||
const handleStart = () => updateStatus('in_progress', '✓ Job started — status set to In Progress.');
|
||
const handleOnHold = () => updateStatus('on_hold', '✓ Job placed on hold.');
|
||
const handleResume = () => updateStatus('in_progress', '✓ Job resumed — back to In Progress.');
|
||
|
||
const handleAssign = async (e) => {
|
||
const member = teamMembers.find(m => m.id === e.target.value);
|
||
await supabase.from('tasks').update({
|
||
assigned_to: e.target.value || null,
|
||
assigned_name: member?.name || null,
|
||
}).eq('id', id);
|
||
setTask(t => ({ ...t, assigned_to: e.target.value || null, assigned_name: member?.name || null }));
|
||
setNotification('✓ Job assigned.');
|
||
};
|
||
|
||
const handleFileChange = (e) => {
|
||
const incoming = Array.from(e.target.files);
|
||
const combined = [...sendForm.files, ...incoming];
|
||
const errors = [];
|
||
if (combined.length > MAX_FILES) errors.push(`Maximum ${MAX_FILES} files allowed.`);
|
||
incoming.filter(f => f.size > MAX_SIZE_BYTES).forEach(f => errors.push(`"${f.name}" exceeds 10 MB limit.`));
|
||
if (errors.length > 0) { setFileErrors(errors); return; }
|
||
setFileErrors([]);
|
||
setSendForm(f => ({ ...f, files: combined }));
|
||
e.target.value = '';
|
||
};
|
||
|
||
const removeFile = (index) => {
|
||
setSendForm(f => ({ ...f, files: f.files.filter((_, i) => i !== index) }));
|
||
setFileErrors([]);
|
||
};
|
||
|
||
const handleSendToClient = async (e) => {
|
||
e.preventDefault();
|
||
setSaving(true);
|
||
const latestSub = submissions[submissions.length - 1];
|
||
if (!latestSub) return;
|
||
|
||
const uploadedFiles = [];
|
||
for (const file of sendForm.files) {
|
||
const path = `${id}/${Date.now()}_${file.name}`;
|
||
const { data: uploaded } = await supabase.storage.from('deliveries').upload(path, file);
|
||
if (uploaded) uploadedFiles.push({ name: file.name, storage_path: path, size: file.size });
|
||
}
|
||
|
||
const { data: delivery } = await supabase.from('deliveries').insert({
|
||
submission_id: latestSub.id,
|
||
sent_by: currentUser?.name,
|
||
message: sendForm.message,
|
||
}).select().single();
|
||
|
||
if (delivery && uploadedFiles.length > 0) {
|
||
await supabase.from('delivery_files').insert(
|
||
uploadedFiles.map(f => ({ ...f, delivery_id: delivery.id }))
|
||
);
|
||
}
|
||
|
||
await supabase.from('tasks').update({ status: 'client_review' }).eq('id', id);
|
||
setTask(t => ({ ...t, status: 'client_review' }));
|
||
|
||
const { data: subs } = await supabase
|
||
.from('submissions')
|
||
.select('*, delivery:deliveries(*, files:delivery_files(*))')
|
||
.eq('task_id', id)
|
||
.order('version_number');
|
||
setSubmissions(subs || []);
|
||
|
||
if (company?.email) {
|
||
sendEmail('sent_to_client', company.email, {
|
||
clientFirstName: company.name,
|
||
serviceType: task.title,
|
||
projectName: project?.name,
|
||
message: sendForm.message,
|
||
taskId: id,
|
||
});
|
||
}
|
||
|
||
setShowSendForm(false);
|
||
setSendForm({ files: [], message: '' });
|
||
setNotification(`✓ Sent to client — ${company?.name} has been notified with ${uploadedFiles.length} file${uploadedFiles.length !== 1 ? 's' : ''}.`);
|
||
setSaving(false);
|
||
};
|
||
|
||
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 titleWithVersion = `${task.title} ${vLabel(task.current_version)}`;
|
||
|
||
return (
|
||
<Layout>
|
||
<button className="back-link" onClick={() => navigate(`/projects/${task.project_id}`)}>
|
||
← Back to {project?.name}
|
||
</button>
|
||
|
||
<div className="page-header">
|
||
<div>
|
||
<div className="page-title">{titleWithVersion}</div>
|
||
<div className="page-subtitle">
|
||
<Link to={`/companies/${company?.id}`} style={{ color: 'var(--accent)' }}>{company?.name}</Link>
|
||
{' › '}
|
||
<Link to={`/projects/${project?.id}`} style={{ color: 'var(--accent)' }}>{project?.name}</Link>
|
||
</div>
|
||
</div>
|
||
<StatusBadge status={task.status} />
|
||
</div>
|
||
|
||
{notification && <div className="notification notification-success">{notification}</div>}
|
||
|
||
{showSendForm && (
|
||
<div className="card" style={{ marginBottom: 24, border: '1px solid var(--accent)', borderRadius: 12 }}>
|
||
<div className="card-title">Send to Client — {company?.name}</div>
|
||
<p style={{ fontSize: 13, color: 'var(--text-secondary)', marginBottom: 16 }}>
|
||
Upload the completed file and add an optional message. An email will be sent to <strong>{company?.email}</strong>.
|
||
</p>
|
||
<form onSubmit={handleSendToClient}>
|
||
<div className="form-group">
|
||
<label>
|
||
Attach Files *
|
||
<span style={{ fontWeight: 400, color: 'var(--text-muted)', marginLeft: 6 }}>
|
||
Up to {MAX_FILES} files · Max {MAX_SIZE_MB} MB each
|
||
</span>
|
||
</label>
|
||
<div style={{
|
||
border: `2px dashed ${sendForm.files.length > 0 ? 'var(--accent)' : 'var(--border)'}`,
|
||
borderRadius: 8, padding: '20px 16px', textAlign: 'center',
|
||
background: sendForm.files.length > 0 ? '#fffbeb' : '#fafafa',
|
||
}}>
|
||
<input type="file" multiple onChange={handleFileChange} style={{ display: 'none' }} id="file-upload" />
|
||
<label htmlFor="file-upload" style={{ cursor: 'pointer' }}>
|
||
<div style={{ fontSize: 24, marginBottom: 6 }}>📎</div>
|
||
<div style={{ fontWeight: 600, fontSize: 13 }}>
|
||
{sendForm.files.length > 0 ? `${sendForm.files.length} file${sendForm.files.length !== 1 ? 's' : ''} added` : 'Click to add files'}
|
||
</div>
|
||
<div style={{ fontSize: 12, color: 'var(--text-secondary)', marginTop: 4 }}>Any file type accepted</div>
|
||
</label>
|
||
</div>
|
||
{fileErrors.map((err, i) => <div key={i} style={{ fontSize: 12, color: 'var(--danger)', marginTop: 4 }}>⚠ {err}</div>)}
|
||
{sendForm.files.length > 0 && (
|
||
<div style={{ marginTop: 10, display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||
{sendForm.files.map((file, i) => (
|
||
<div key={i} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '8px 12px', background: 'white', borderRadius: 8, border: '1px solid var(--border)' }}>
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||
<span>📄</span>
|
||
<div>
|
||
<div style={{ fontSize: 13, fontWeight: 600 }}>{file.name}</div>
|
||
<div style={{ fontSize: 11, color: 'var(--text-muted)' }}>{formatSize(file.size)}</div>
|
||
</div>
|
||
</div>
|
||
<button type="button" onClick={() => removeFile(i)} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--danger)', fontSize: 16 }}>✕</button>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
<div className="form-group">
|
||
<label>Message to Client <span style={{ fontWeight: 400, color: 'var(--text-muted)' }}>(optional)</span></label>
|
||
<textarea
|
||
placeholder={`Hi ${company?.name}, your ${task.title} is ready for review!`}
|
||
value={sendForm.message}
|
||
onChange={e => setSendForm(f => ({ ...f, message: e.target.value }))}
|
||
style={{ minHeight: 80 }}
|
||
/>
|
||
</div>
|
||
<div className="action-buttons">
|
||
<button type="submit" className="btn btn-success" disabled={sendForm.files.length === 0 || saving}>
|
||
{saving ? 'Uploading...' : `✉️ Send to Client${sendForm.files.length > 0 ? ` (${sendForm.files.length} file${sendForm.files.length !== 1 ? 's' : ''})` : ''}`}
|
||
</button>
|
||
<button type="button" className="btn btn-outline" onClick={() => { setShowSendForm(false); setSendForm({ files: [], message: '' }); }}>Cancel</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
)}
|
||
|
||
<div className="grid-2" style={{ marginBottom: 24 }}>
|
||
<div className="card">
|
||
<div className="card-title">Job Details</div>
|
||
<div className="detail-grid" style={{ marginBottom: 16 }}>
|
||
<div className="detail-item"><label>Status</label><p><StatusBadge status={task.status} /></p></div>
|
||
<div className="detail-item"><label>Version</label><p style={{ fontWeight: 700, fontSize: 16 }}>{vLabel(task.current_version)}</p></div>
|
||
<div className="detail-item"><label>Submitted</label><p>{new Date(task.submitted_at).toLocaleDateString()}</p></div>
|
||
<div className="detail-item"><label>Completed</label><p>{task.completed_at ? new Date(task.completed_at).toLocaleDateString() : '—'}</p></div>
|
||
</div>
|
||
<div className="form-group">
|
||
<label>Assigned To</label>
|
||
<select value={task.assigned_to || ''} onChange={handleAssign} style={{ width: '100%' }}>
|
||
<option value="">Unassigned</option>
|
||
{teamMembers.map(m => <option key={m.id} value={m.id}>{m.name}</option>)}
|
||
</select>
|
||
</div>
|
||
<div className="action-buttons" style={{ marginTop: 8 }}>
|
||
{task.status === 'not_started' && (
|
||
<button className="btn btn-primary" onClick={handleStart} disabled={saving}>▶ Start Job</button>
|
||
)}
|
||
{task.status === 'in_progress' && !showSendForm && (
|
||
<>
|
||
<button className="btn btn-success" onClick={() => setShowSendForm(true)}>✉️ Send to Client</button>
|
||
<button className="btn btn-outline" onClick={handleOnHold} disabled={saving}>⏸ Put On Hold</button>
|
||
</>
|
||
)}
|
||
{task.status === 'on_hold' && (
|
||
<button className="btn btn-primary" onClick={handleResume} disabled={saving}>▶ Resume</button>
|
||
)}
|
||
{task.status === 'client_review' && (
|
||
<div style={{ padding: '10px 14px', background: '#f5f3ff', borderRadius: 8, fontSize: 13, color: '#7c3aed', fontWeight: 500 }}>
|
||
⏳ Awaiting client review — no action needed.
|
||
</div>
|
||
)}
|
||
{task.status === 'client_approved' && (
|
||
<div style={{ padding: '10px 14px', background: '#f0fdf4', borderRadius: 8, fontSize: 13, color: '#16a34a', fontWeight: 500 }}>
|
||
✓ Client approved this job.
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="card">
|
||
<div className="card-title">Current Request Notes</div>
|
||
{submissions.length === 0 ? (
|
||
<p style={{ color: 'var(--text-muted)', fontSize: 13 }}>No request notes yet.</p>
|
||
) : (() => {
|
||
const currentVersion = task.current_version + 1;
|
||
const currentGroup = submissions.filter(s => s.version_number === currentVersion);
|
||
const primary = currentGroup.find(s => s.type !== 'amendment') || currentGroup[0];
|
||
const amendments = currentGroup.filter(s => s.type === 'amendment');
|
||
if (!primary) return null;
|
||
return (
|
||
<>
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 14 }}>
|
||
<span style={{ fontWeight: 700, fontSize: 13 }}>{vLabel(primary.version_number - 1)}</span>
|
||
<StatusBadge status={primary.type} />
|
||
<span style={{ fontSize: 12, color: 'var(--text-muted)', marginLeft: 'auto' }}>
|
||
{primary.submitted_by_name} · {new Date(primary.submitted_at).toLocaleDateString()}
|
||
</span>
|
||
</div>
|
||
<div className="detail-grid" style={{ marginBottom: 14 }}>
|
||
<div className="detail-item"><label>Service Type</label><p>{primary.service_type}</p></div>
|
||
<div className="detail-item"><label>Deadline</label><p>{primary.deadline || '—'}</p></div>
|
||
</div>
|
||
<div>
|
||
<label style={{ fontSize: 11, fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.5px', color: 'var(--text-muted)' }}>Description</label>
|
||
<p style={{ marginTop: 8, fontSize: 14, lineHeight: 1.7, color: 'var(--text-primary)', background: 'var(--bg)', padding: '12px 14px', borderRadius: 8, border: '1px solid var(--border)', 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: 6, 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>
|
||
))}
|
||
</>
|
||
);
|
||
})()}
|
||
</div>
|
||
</div>
|
||
|
||
{submissions.length > 0 && (
|
||
<div className="card">
|
||
<div className="card-title">Version History</div>
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||
{Object.values(
|
||
submissions.reduce((groups, sub) => {
|
||
const key = sub.version_number;
|
||
if (!groups[key]) groups[key] = [];
|
||
groups[key].push(sub);
|
||
return groups;
|
||
}, {})
|
||
).map((group, gi, all) => {
|
||
const primary = group.find(s => s.type !== 'amendment') || group[0];
|
||
const amendments = group.filter(s => s.type === 'amendment');
|
||
const delivery = primary.delivery;
|
||
const isCurrent = gi === all.length - 1;
|
||
return (
|
||
<div key={primary.id} style={{ borderRadius: 8, border: `1px solid ${isCurrent ? 'var(--accent)' : 'var(--border)'}`, background: 'var(--bg)', overflow: 'hidden', opacity: isCurrent ? 1 : 0.85 }}>
|
||
<div style={{ padding: '12px 16px', display: 'flex', alignItems: 'center', gap: 10, borderBottom: '1px solid var(--border)', background: 'var(--card-bg-2)' }}>
|
||
<span style={{ fontWeight: 700, fontSize: 13 }}>{vLabel(primary.version_number - 1)}</span>
|
||
<StatusBadge status={primary.type} />
|
||
{isCurrent && <span style={{ fontSize: 11, color: 'var(--accent)', fontWeight: 600 }}>Current</span>}
|
||
<span style={{ fontSize: 12, color: 'var(--text-muted)', marginLeft: 'auto' }}>
|
||
{primary.submitted_by_name} · {new Date(primary.submitted_at).toLocaleDateString()}
|
||
</span>
|
||
</div>
|
||
<div style={{ padding: '12px 16px' }}>
|
||
<div style={{ display: 'flex', gap: 24, marginBottom: 8 }}>
|
||
<div>
|
||
<span style={{ fontSize: 11, fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.5px', color: 'var(--text-muted)' }}>Service</span>
|
||
<div style={{ fontSize: 13, marginTop: 2 }}>{primary.service_type}</div>
|
||
</div>
|
||
<div>
|
||
<span style={{ fontSize: 11, fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.5px', color: 'var(--text-muted)' }}>Deadline</span>
|
||
<div style={{ fontSize: 13, marginTop: 2 }}>{primary.deadline || '—'}</div>
|
||
</div>
|
||
</div>
|
||
<p style={{ fontSize: 13, color: 'var(--text-secondary)', lineHeight: 1.6, whiteSpace: 'pre-wrap', margin: 0 }}>{primary.description}</p>
|
||
|
||
{amendments.map(amendment => (
|
||
<div key={amendment.id} style={{ marginTop: 12, padding: '12px 14px', background: 'var(--card-bg)', borderRadius: 8, border: '1px solid var(--border)' }}>
|
||
<div style={{ fontSize: 11, fontWeight: 700, textTransform: 'uppercase', color: 'var(--accent)', marginBottom: 6, 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>
|
||
))}
|
||
</div>
|
||
{delivery ? (
|
||
<div style={{ padding: '10px 16px', borderTop: '1px solid var(--border)' }}>
|
||
<div style={{ fontSize: 11, fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.5px', color: '#16a34a', marginBottom: 8 }}>
|
||
✓ Delivered by {delivery.sent_by} on {new Date(delivery.sent_at).toLocaleDateString()}
|
||
</div>
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||
{(delivery.files || []).map((file, fi) => (
|
||
<div key={fi} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '6px 10px', background: 'var(--card-bg-2)', borderRadius: 6, border: '1px solid var(--border)' }}>
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||
<span>📄</span>
|
||
<span style={{ fontSize: 13, fontWeight: 500 }}>{file.name}</span>
|
||
{file.size > 0 && <span style={{ fontSize: 11, color: 'var(--text-muted)' }}>{formatSize(file.size)}</span>}
|
||
</div>
|
||
<button className="btn btn-outline btn-sm" onClick={() => getFileUrl(file.storage_path)}>📥 View</button>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<div style={{ padding: '10px 16px', borderTop: '1px solid var(--border)' }}>
|
||
<span style={{ fontSize: 12, color: 'var(--text-muted)' }}>📎 No file delivered yet for this version.</span>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</Layout>
|
||
);
|
||
}
|