Files
fourge-portal/src/pages/team/TaskDetail.jsx
T
2026-03-27 00:24:31 -04:00

413 lines
22 KiB
React
Executable File
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
}