import { useState, useEffect, useRef } from 'react'; import { useParams, Link } from 'react-router-dom'; import Layout from '../components/Layout'; import PageLoader from '../components/PageLoader'; import ProfileAvatar from '../components/ProfileAvatar'; import StatusBadge from '../components/StatusBadge'; import { useAuth } from '../context/AuthContext'; import { supabase } from '../lib/supabase'; import { logActivity } from '../lib/activityLog'; import JSZip from 'jszip'; import { popupOverlayStyle, popupSurfaceStyle } from '../lib/popupStyles'; import { useLiveRefresh } from '../hooks/useLiveRefresh'; import { useActionLock } from '../hooks/useActionLock'; import FileAttachment from '../components/FileAttachment'; import { sendTaskStatusUpdate } from '../lib/taskNotifications'; import { encodeRejectedNote, parseRejectedNote, isReviewShadowSubmission, REVIEW_SHADOW_PREFIX, getVisibleTaskSubmissions, getTaskDerivedState } from '../lib/taskVersions'; import { getCurrentVersionForTask } from '../lib/taskDeadlines'; import { getSubmissionDisplayName, mergeSubmissionDisplayNames } from '../lib/submissionDisplay'; const ACTION_LABEL = { task_created: 'created this task', task_started: 'started this task', task_on_hold: 'placed task on hold', task_resumed: 'resumed this task', task_submitted: 'submitted this task', task_approved: 'approved this task', revision_requested: 'rejected this task', request_submitted: 'submitted this request', task_released: 'released this task', }; const ACTION_ICON = { task_started: { bg: 'color-mix(in srgb, var(--info) 15%, transparent)', color: 'var(--info)', path: 'M6 4l14 8-14 8V4z' }, task_resumed: { bg: 'color-mix(in srgb, var(--info) 15%, transparent)', color: 'var(--info)', path: 'M6 4l14 8-14 8V4z' }, task_submitted: { bg: 'color-mix(in srgb, var(--positive) 15%, transparent)', color: 'var(--positive)', path: 'M12 19V5M5 12l7-7 7 7' }, task_approved: { bg: 'color-mix(in srgb, var(--positive) 15%, transparent)', color: 'var(--positive)', path: 'M4 13l5 5L20 7' }, task_on_hold: { bg: 'color-mix(in srgb, var(--danger) 15%, transparent)', color: 'var(--danger)', path: 'M6 4h4v16H6zM14 4h4v16h-4z' }, task_released: { bg: 'color-mix(in srgb, var(--danger) 15%, transparent)', color: 'var(--danger)', path: 'M12 5v14M5 12h14' }, task_created: { bg: 'color-mix(in srgb, var(--violet) 15%, transparent)', color: 'var(--violet)', path: 'M12 5v14M5 12h14' }, revision_requested: { bg: 'color-mix(in srgb, var(--warning) 15%, transparent)', color: 'var(--warning)', path: 'M4 12a8 8 0 018-8v0a8 8 0 018 8' }, request_submitted: { bg: 'color-mix(in srgb, var(--violet) 15%, transparent)', color: 'var(--violet)', path: 'M9 12h6M9 8h6M5 3h14a2 2 0 012 2v14a2 2 0 01-2 2H5a2 2 0 01-2-2V5a2 2 0 012-2z' }, }; function ActivityItem({ e }) { const icon = ACTION_ICON[e.action] || { bg: 'rgba(255,255,255,0.08)', color: 'rgba(255,255,255,0.4)', path: null }; const label = ACTION_LABEL[e.action] || e.action; const actor = e.actor_name || 'Fourge'; const d = new Date(e.created_at); const dateStr = d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }); const timeStr = d.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit', hour12: true }); return (
{icon.path && }
{actor} {label}
{dateStr} · {timeStr}
); } const CARD = { padding: '18px 21px', borderRadius: 8 }; const LABEL = { fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase', marginBottom: 14 }; const META_LABEL = { fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase', marginBottom: 4 }; const CARD_META_LABEL = { fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase', marginBottom: 2 }; const MODAL_ACTION_ROW = { display: 'flex', gap: 8, justifyContent: 'flex-end', paddingTop: 4, flexWrap: 'nowrap' }; const MODAL_ACTION_BUTTON = { width: 132, justifyContent: 'center', flex: '0 0 132px' }; const TABS = ['Overview', 'Revisions', 'Submissions', 'Comments']; function RejectedNoteBlock({ note }) { if (!note?.body) return null; const d = note.created_at ? new Date(note.created_at) : null; const dateStr = d ? d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) : '—'; return (
Rejected Notes
{dateStr}
{note.body}
); } function TimelineCard({ task, activityLog, currentSubmission }) { const status = task?.status; let currentStage; if (['client_approved', 'invoiced', 'paid'].includes(status)) currentStage = 4; else if (status === 'client_review') currentStage = 2; else if (status === 'in_progress' || status === 'on_hold') currentStage = 1; else currentStage = 0; const fmtDate = (d) => d ? new Date(d).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) : null; // Only look at activity from the current revision cycle const lastRevision = activityLog.find(e => e.action === 'revision_requested'); const cutoff = lastRevision ? new Date(lastRevision.created_at).getTime() : 0; const currentLog = activityLog.filter(e => new Date(e.created_at).getTime() > cutoff); const startedEntry = [...currentLog].reverse().find(e => e.action === 'task_started'); const reviewEntry = [...currentLog].reverse().find(e => e.action === 'task_submitted'); const stages = [ { label: 'Task Created', date: fmtDate(currentSubmission?.submitted_at || task?.submitted_at) }, { label: 'In Progress', date: fmtDate(startedEntry?.created_at) }, { label: 'In Review', date: fmtDate(reviewEntry?.created_at) }, { label: 'Approved', date: fmtDate(task?.completed_at) }, ]; return (
Timeline
{stages.map((stage, i) => { const isDone = i < currentStage; const isCurrent = i === currentStage; let circle; if (isDone) { circle = (
); } else if (isCurrent) { circle = (
); } else { circle =
; } return (
{circle} {i < stages.length - 1 && (
)}
{stage.label}
{stage.date || '—'}
); })}
); } function AssigneePortrait({ name, avatarUrl }) { return ; } const fmtSize = (bytes) => { if (!bytes) return ''; if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'; return (bytes / (1024 * 1024)).toFixed(1) + ' MB'; }; const FILE_EXT_COLOR = { pdf: 'var(--danger)', jpg: 'var(--warning)', jpeg: 'var(--warning)', png: 'var(--data-blue)', gif: 'var(--data-purple)', svg: 'var(--data-emerald)', ai: 'var(--data-orange)', psd: 'var(--data-blue)', zip: 'var(--data-gray)', rar: 'var(--data-gray)', doc: 'var(--data-indigo)', docx: 'var(--data-indigo)', xls: 'var(--success-strong)', xlsx: 'var(--success-strong)', mp4: 'var(--data-pink)', mov: 'var(--data-pink)' }; const getExt = (name = '') => (name.split('.').pop() || '').toLowerCase(); function SubmissionFiles({ files, downloading, onDownloadAll }) { if (!files || files.length === 0) return null; return (
Attached Files ({files.length})
{files.map((file) => { const ext = getExt(file.name); const color = FILE_EXT_COLOR[ext] || 'var(--text-muted)'; const label = ext ? ext.toUpperCase() : '—'; return (
{label}
{file.name.replace(/\.[^.]+$/, '')}
); })}
); } function SubmissionSigns({ signs = [], signCount = null }) { const normalizedCount = Number(signCount || 0); if ((!signs || signs.length === 0) && normalizedCount <= 0) return null; return (
Signs
{signs && signs.length > 0 ? (
{signs .slice() .sort((a, b) => Number(a.sign_number || 0) - Number(b.sign_number || 0)) .map((sign) => (
Sign {sign.sign_number || '—'} {sign.sign_name || 'Unnamed sign'}
{sign.sign_family ? (
{sign.sign_family}
) : null}
))}
) : (
N/A
)}
); } function normalizeSubmissionSigns(submission) { return (Array.isArray(submission?.signs) ? submission.signs : []) .map((sign, index) => ({ ...sign, sign_number: Number(sign?.sign_number || index + 1), sign_name: String(sign?.sign_name || '').trim(), sign_family: String(sign?.sign_family || '').trim(), })) .filter(sign => sign.sign_name || sign.sign_family || Number.isFinite(sign.sign_number)); } export default function TaskDetail() { const { id } = useParams(); const { currentUser } = useAuth(); const { refreshKey } = useLiveRefresh(['tasks', 'submissions', 'activity_log', 'task_comments']); const [task, setTask] = useState(null); const [submissions, setSubmissions] = useState([]); const [activityLog, setActivityLog] = useState([]); const [loading, setLoading] = useState(true); const [activeTab, setActiveTab] = useState('Overview'); const [statusSaving, setStatusSaving] = useState(false); const guard = useActionLock(); const [downloading, setDownloading] = useState(''); const [revisionModal, setRevisionModal] = useState(false); const [revisionForm, setRevisionForm] = useState({ description: '', deadline: '' }); const [revisionFiles, setRevisionFiles] = useState([]); const [revisionSaving, setRevisionSaving] = useState(false); const revisionFileRef = useRef(null); const [rejectModal, setRejectModal] = useState(false); const [rejectNote, setRejectNote] = useState(''); const [rejectSaving, setRejectSaving] = useState(false); const [amendModal, setAmendModal] = useState(false); const [amendForm, setAmendForm] = useState({ description: '' }); const [amendFiles, setAmendFiles] = useState([]); const [amendSaving, setAmendSaving] = useState(false); const [reviewModal, setReviewModal] = useState(false); const [reviewFiles, setReviewFiles] = useState([]); const [reviewNotes, setReviewNotes] = useState(''); const [reviewSaving, setReviewSaving] = useState(false); const [comments, setComments] = useState([]); const [commentBody, setCommentBody] = useState(''); const [commentSaving, setCommentSaving] = useState(false); const [deliveries, setDeliveries] = useState([]); const [dlProgress, setDlProgress] = useState({ active: false, label: '', percent: 0 }); const canonicalTaskServiceType = getTaskDerivedState(task, submissions, deliveries).serviceType; const fetchTaskSubmissions = async (taskId) => { const { data: subRows } = await supabase .from('submissions') .select('id, type, version_number, request_key, submitted_by, submitted_by_name, submitted_at, service_type, deadline, description, is_hot, sign_family, sign_count, signs, files:submission_files(id, name, storage_path, size)') .eq('task_id', taskId) .order('submitted_at', { ascending: true }); const submitterIds = [...new Set((subRows || []).map((submission) => submission.submitted_by).filter(Boolean))]; if (submitterIds.length === 0) { return (subRows || []).map(submissionRow => ({ ...submissionRow, signs: normalizeSubmissionSigns(submissionRow) })); } const { data: submitterProfiles } = await supabase .from('profiles') .select('id, name') .in('id', submitterIds); return mergeSubmissionDisplayNames( (subRows || []).map(submissionRow => ({ ...submissionRow, signs: normalizeSubmissionSigns(submissionRow) })), submitterProfiles || [] ); }; const reloadTaskRow = async () => { const { data } = await supabase .from('tasks') .select('*, project:projects(id, name, company:companies(id, name)), assignee:profiles!assigned_to(id, name, avatar_url)') .eq('id', id) .single(); if (data) setTask(data); }; useEffect(() => { Promise.all([ supabase .from('tasks') .select('*, project:projects(id, name, company:companies(id, name)), assignee:profiles!assigned_to(id, name, avatar_url)') .eq('id', id) .single(), fetchTaskSubmissions(id), supabase .from('activity_log') .select('id, created_at, actor_id, actor_name, action, task_id, task_title') .eq('task_id', id) .order('created_at', { ascending: false }) .limit(20), supabase .from('task_comments') .select('id, author_id, author_name, body, created_at') .eq('task_id', id) .order('created_at', { ascending: true }), ]).then(async ([{ data: taskData }, subRows, { data: actData }, { data: commentData }]) => { setTask(taskData); setSubmissions(subRows || []); setActivityLog(actData || []); setComments(commentData || []); const subIds = (subRows || []).map(s => s.id); if (subIds.length > 0) { const { data: delRows } = await supabase .from('deliveries') .select('*, files:delivery_files(id, name, storage_path, size)') .in('submission_id', subIds) .order('sent_at', { ascending: false }); setDeliveries(delRows || []); } setLoading(false); }); }, [id, refreshKey]); if (loading) return ; if (!task) return

Task not found.

; const visibleSubmissions = getVisibleTaskSubmissions(submissions); const submission = visibleSubmissions.find(s => s.type === 'initial') || visibleSubmissions[0] || null; const rejectedNoteSubmissions = visibleSubmissions .filter(s => s.type === 'amendment' && parseRejectedNote(s.description)) .map(s => ({ ...s, rejected: parseRejectedNote(s.description) })); const amendments = visibleSubmissions.filter(s => s.type === 'amendment' && !parseRejectedNote(s.description)); const revisions = visibleSubmissions.filter(s => s.type === 'revision').sort((a, b) => a.version_number - b.version_number); const reviewSubmissions = deliveries; const commentRows = comments; const rejectedNoteByVersion = new Map( rejectedNoteSubmissions.map(note => [note.rejected.versionNumber, { ...note, body: note.rejected.body, created_at: note.submitted_at }]) ); const displayCurrentVersion = getCurrentVersionForTask(task, submissions); const assignee = task.assignee; const assigneeName = assignee?.name || task.assigned_name || null; const assigneeAvatar = assignee?.avatar_url || null; const project = task.project; const company = project?.company; const requester = getSubmissionDisplayName(submission) || null; const role = currentUser?.role; const isClient = role === 'client'; const isTeamOrSub = role === 'team' || role === 'external'; const status = task.status; const notificationBase = { taskId: id, taskTitle: task?.title || '', projectId: project?.id || '', projectName: project?.name || '', companyId: company?.id || '', companyName: company?.name || '', assignedProfileId: task?.assigned_to || currentUser?.id || '', }; const actionBusy = statusSaving || reviewSaving || revisionSaving || rejectSaving || amendSaving; const log = (action) => logActivity({ actorId: currentUser.id, actorName: currentUser.name, action, taskId: id, taskTitle: task?.title, projectId: task?.project?.id, projectName: task?.project?.name }); const updateStatus = guard('status', async (newStatus, extra = {}, action = null) => { setStatusSaving(true); const prevTask = task; setTask(t => ({ ...t, status: newStatus, ...extra })); try { const { error: taskUpdateError } = await supabase.from('tasks').update({ status: newStatus, ...extra }).eq('id', id); if (taskUpdateError) throw taskUpdateError; await reloadTaskRow(); if (action) { await log(action); const { data } = await supabase.from('activity_log').select('id, created_at, actor_id, actor_name, action').eq('task_id', id).order('created_at', { ascending: false }).limit(20); setActivityLog(data || []); } } catch (err) { setTask(prevTask); console.error('Status update failed:', err); window.alert(err?.message || 'Failed to update status.'); throw err; } finally { setStatusSaving(false); } }); const handleStart = () => updateStatus('in_progress', { assigned_to: currentUser.id, assigned_name: currentUser.name }, 'task_started'); const handleOnHold = async () => { await updateStatus('on_hold', {}, 'task_on_hold'); sendTaskStatusUpdate({ ...notificationBase, statusLabel: 'On Hold', headline: 'Task On Hold', subject: `Task On Hold: ${task?.title || 'Task'}`, message: `${currentUser.name} placed ${task?.title || 'this task'} on hold${project?.name ? ` for ${project.name}` : ''}.`, includeTeam: true, includeAssigned: true, includeClient: true, }).catch(() => {}); }; const handleResume = () => updateStatus('in_progress', {}, 'task_resumed'); const handleSendToReview = () => { setReviewFiles([]); setReviewNotes(''); setReviewModal(true); }; const handleRemoveFromReview = () => updateStatus('in_progress', {}, 'task_resumed'); const handleConfirmReview = guard('review', async () => { setReviewSaving(true); try { const targetVersion = task.current_version || 0; const versionSubmissions = submissions.filter(s => (s.version_number || 0) === targetVersion && s.type !== 'amendment'); const usedSubmissionIds = new Set( deliveries .filter(d => (d.version_number || 0) === targetVersion) .filter(d => versionSubmissions.some(s => s.id === d.submission_id)) .map(d => d.submission_id) ); let targetSubmission = versionSubmissions.find(s => !usedSubmissionIds.has(s.id)) || null; if (!targetSubmission) { const { data: createdSubmission, error: createdSubmissionError } = await supabase .from('submissions') .insert({ task_id: id, version_number: targetVersion, type: targetVersion === 0 ? 'initial' : 'revision', revision_type: targetVersion > 0 ? 'client_revision' : null, service_type: canonicalTaskServiceType !== '—' ? canonicalTaskServiceType : null, description: `${REVIEW_SHADOW_PREFIX}${targetVersion}`, submitted_by: currentUser.id, submitted_by_name: currentUser.name, }) .select('id, type, version_number, request_key, submitted_by, submitted_by_name, submitted_at, service_type, deadline, description, is_hot, sign_family, sign_count, signs') .single(); if (createdSubmissionError) throw createdSubmissionError; targetSubmission = createdSubmission || null; } if (!targetSubmission?.id) throw new Error('No submission found for current version.'); const { data: newDel, error: deliveryError } = await supabase .from('deliveries') .insert({ submission_id: targetSubmission.id, sent_by: currentUser.name, message: reviewNotes.trim() || '', version_number: targetVersion }) .select() .single(); if (deliveryError) throw deliveryError; if (!newDel?.id) throw new Error('Failed to create review submission.'); if (newDel && reviewFiles.length > 0) { const uploaded = []; for (const file of reviewFiles) { const path = `${id}/${Date.now()}_${file.name}`; const { data: up, error: uploadError } = await supabase.storage.from('deliveries').upload(path, file); if (uploadError) throw uploadError; if (up) uploaded.push({ name: file.name, storage_path: path, size: file.size, delivery_id: newDel.id }); } if (uploaded.length > 0) { const { error: deliveryFilesError } = await supabase.from('delivery_files').insert(uploaded); if (deliveryFilesError) throw deliveryFilesError; } } const refreshedSubRows = await fetchTaskSubmissions(id); const allSubRows = refreshedSubRows || []; setSubmissions(allSubRows); const subIds = allSubRows.map(s => s.id); const { data: delRows } = await supabase.from('deliveries').select('*, files:delivery_files(id, name, storage_path, size)').in('submission_id', subIds).order('sent_at', { ascending: false }); setDeliveries(delRows || []); await updateStatus('client_review', {}, 'task_submitted'); sendTaskStatusUpdate({ ...notificationBase, statusLabel: 'In Review', headline: 'Task Ready For Review', subject: `In Review: ${task?.title || 'Task'}`, message: `${currentUser.name} placed ${task?.title || 'this task'} in review${project?.name ? ` for ${project.name}` : ''}.${reviewNotes.trim() ? ` Notes: ${reviewNotes.trim()}` : ''}`, includeTeam: true, includeAssigned: true, includeClient: true, }).catch(() => {}); setReviewModal(false); setReviewFiles([]); setReviewNotes(''); } catch (err) { console.error('Place in review failed:', err); window.alert(err?.message || 'Failed to place task in review.'); } finally { setReviewSaving(false); } }); const handleClientApprove = guard('approve', async () => { await updateStatus('client_approved', { completed_at: new Date().toISOString() }, 'task_approved'); sendTaskStatusUpdate({ ...notificationBase, statusLabel: 'Approved', headline: 'Task Approved', subject: `Approved: ${task?.title || 'Task'}`, message: `${currentUser.name} approved ${task?.title || 'this task'}${project?.name ? ` for ${project.name}` : ''}.`, includeTeam: true, includeAssigned: true, includeClient: true, }).catch(() => {}); }); const handleClientReject = () => { setRejectNote(''); setRejectModal(true); }; const handleSubmitReject = guard('reject', async () => { if (!rejectNote.trim()) return; setRejectSaving(true); try { const rejectedVersion = task.current_version || 0; const { data: rejectedNoteSubmission, error: rejectNoteError } = await supabase .from('submissions') .insert({ task_id: id, version_number: rejectedVersion, type: 'amendment', service_type: canonicalTaskServiceType !== '—' ? canonicalTaskServiceType : null, description: encodeRejectedNote(rejectedVersion, rejectNote.trim()), submitted_by: currentUser.id, submitted_by_name: currentUser.name, }) .select() .single(); // The rejection reason lives only in this submission row. If it didn't // save, do NOT flip status or log the rejection — otherwise the task // shows as "rejected" with no reason. Surface the error and bail. if (rejectNoteError || !rejectedNoteSubmission) { console.error('Reject note save failed:', rejectNoteError); window.alert(rejectNoteError?.message || 'Failed to save the rejection reason. The task was not rejected — please try again.'); return; } setSubmissions(prev => [...prev, rejectedNoteSubmission]); await updateStatus('in_progress', {}, 'revision_requested'); sendTaskStatusUpdate({ ...notificationBase, statusLabel: 'Rejected', headline: 'Task Rejected', subject: `Rejected: ${task?.title || 'Task'}`, message: `${currentUser.name} rejected ${task?.title || 'this task'}${project?.name ? ` for ${project.name}` : ''}. Notes: ${rejectNote.trim()}`, includeTeam: true, includeAssigned: true, includeClient: true, }).catch(() => {}); setRejectModal(false); setRejectNote(''); } finally { setRejectSaving(false); } }); const handleRelease = () => updateStatus('not_started', { assigned_to: null, assigned_name: null }, 'task_released'); const handleSubmitRevision = guard('revision', async () => { setRevisionSaving(true); const baseline = Math.max(task.current_version || 0, ...submissions.map(s => s.version_number || 0)); const newVersion = baseline + 1; await supabase.from('tasks').update({ status: 'not_started', current_version: newVersion, assigned_to: task?.assigned_to || null, assigned_name: task?.assigned_name || task?.assignee?.name || null, }).eq('id', id); const { data: newSub } = await supabase.from('submissions').insert({ task_id: id, version_number: newVersion, type: 'revision', revision_type: 'client_revision', service_type: canonicalTaskServiceType !== '—' ? canonicalTaskServiceType : null, deadline: revisionForm.deadline || null, description: revisionForm.description, submitted_by: currentUser.id, submitted_by_name: currentUser.name }).select().single(); if (newSub && revisionFiles.length > 0) { const uploadedFiles = []; for (const file of revisionFiles) { const path = `${id}/${newVersion}/${Date.now()}_${file.name}`; const { data: uploaded } = await supabase.storage.from('submissions').upload(path, file); if (uploaded) uploadedFiles.push({ name: file.name, storage_path: path, size: file.size }); } if (uploadedFiles.length > 0) await supabase.from('submission_files').insert(uploadedFiles.map(f => ({ ...f, submission_id: newSub.id }))); } await logActivity({ actorId: currentUser.id, actorName: currentUser.name, action: 'revision_requested', taskId: id, taskTitle: task?.title, projectId: project?.id, projectName: project?.name }); const [{ data: taskData }, subRows, { data: actData }] = await Promise.all([ supabase.from('tasks').select('*, project:projects(id, name, company:companies(id, name)), assignee:profiles!assigned_to(id, name, avatar_url)').eq('id', id).single(), fetchTaskSubmissions(id), supabase.from('activity_log').select('id, created_at, actor_id, actor_name, action, task_id, task_title').eq('task_id', id).order('created_at', { ascending: false }).limit(20), ]); setTask(taskData); setSubmissions(subRows || []); setActivityLog(actData || []); sendTaskStatusUpdate({ ...notificationBase, assignedProfileId: task?.assigned_to || '', statusLabel: `Revision Request ${`R${String(newVersion).padStart(2, '0')}`}`, headline: 'Revision Requested', subject: `Revision Request: ${task?.title || 'Task'} ${`R${String(newVersion).padStart(2, '0')}`}`, message: `${currentUser.name} requested a revision for ${task?.title || 'this task'}${project?.name ? ` on ${project.name}` : ''}. ${revisionForm.description}`, includeTeam: true, includeAssigned: true, includeClient: true, }).catch(() => {}); setRevisionModal(false); setRevisionForm({ description: '', deadline: '' }); setRevisionFiles([]); setRevisionSaving(false); }); const handleSubmitAmendment = guard('amend', async () => { if (!amendForm.description.trim()) return; setAmendSaving(true); setAmendModal(false); const baseline = Math.max(task.current_version || 0, ...submissions.map(s => s.version_number || 0)); const { data: newSub } = await supabase.from('submissions').insert({ task_id: id, version_number: baseline, type: 'amendment', service_type: canonicalTaskServiceType !== '—' ? canonicalTaskServiceType : null, description: amendForm.description, submitted_by: currentUser.id, submitted_by_name: currentUser.name }).select().single(); if (newSub && amendFiles.length > 0) { setDlProgress({ active: true, label: 'Uploading files…', percent: 0 }); const uploaded = []; for (let i = 0; i < amendFiles.length; i++) { const file = amendFiles[i]; setDlProgress({ active: true, label: file.name, percent: Math.round((i / amendFiles.length) * 100) }); const path = `${id}/${Date.now()}_${file.name}`; const { data: up } = await supabase.storage.from('submissions').upload(path, file); if (up) uploaded.push({ name: file.name, storage_path: path, size: file.size }); } if (uploaded.length > 0) await supabase.from('submission_files').insert(uploaded.map(f => ({ ...f, submission_id: newSub.id }))); setDlProgress({ active: false, label: '', percent: 0 }); } const subRows = await fetchTaskSubmissions(id); setSubmissions(subRows || []); sendTaskStatusUpdate({ ...notificationBase, statusLabel: 'Request Amended', headline: 'Request Amended', subject: `Request Amended: ${task?.title || 'Task'}`, message: `${currentUser.name} amended ${task?.title || 'this task'}${project?.name ? ` for ${project.name}` : ''}. Notes: ${amendForm.description.trim()}`, includeTeam: true, includeAssigned: true, includeClient: true, }).catch(() => {}); setAmendForm({ description: '' }); setAmendFiles([]); setAmendSaving(false); }); const handlePostComment = guard('comment', async () => { if (!commentBody.trim()) return; setCommentSaving(true); const { data } = await supabase.from('task_comments').insert({ task_id: id, author_id: currentUser.id, author_name: currentUser.name, body: commentBody.trim() }).select().single(); if (data) setComments(prev => [...prev, data]); setCommentBody(''); setCommentSaving(false); }); const handleDeleteComment = async (commentId) => { await supabase.from('task_comments').delete().eq('id', commentId); setComments(prev => prev.filter(c => c.id !== commentId)); }; const downloadAllFiles = async (files, label = 'files', bucket = 'submissions') => { if (downloading) return; setDownloading('all'); setDlProgress({ active: true, label: 'Preparing…', percent: 0 }); try { const zip = new JSZip(); for (let i = 0; i < files.length; i++) { const file = files[i]; setDlProgress({ active: true, label: file.name, percent: Math.round((i / (files.length + 1)) * 100) }); try { const { data } = await supabase.storage.from(bucket).createSignedUrl(file.storage_path, 3600, { download: file.name }); if (data?.signedUrl) { const response = await fetch(data.signedUrl); if (response.ok) zip.file(file.name, await response.blob()); } } catch { /* skip bad file */ } } setDlProgress({ active: true, label: 'Building zip…', percent: 95 }); const content = await zip.generateAsync({ type: 'blob' }); const zipName = `${task?.title || 'files'} ${label}.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 { setDlProgress({ active: false, label: '', percent: 0 }); setDownloading(''); } }; return (
{task.title}
{isTeamOrSub && status === 'not_started' && ( )} {isTeamOrSub && status === 'in_progress' && task.assigned_to === currentUser?.id && ( <> )} {isTeamOrSub && status === 'client_review' && task.assigned_to === currentUser?.id && ( )} {isTeamOrSub && status === 'on_hold' && task.assigned_to === currentUser?.id && ( <> )} {isClient && status === 'client_review' && ( <> )} {isClient && ['client_approved', 'invoiced', 'paid'].includes(status) && ( )}
{canonicalTaskServiceType !== '—' && ( {canonicalTaskServiceType} )}
Task ID
{task.task_number || task.id.slice(0, 8).toUpperCase()}
Revision
{displayCurrentVersion === 0 ? 'New' : String(displayCurrentVersion).padStart(2, '0')}
Project
{project ? {project.name} :
}
Requested
{submission?.submitted_at || task.submitted_at ? new Date(submission?.submitted_at || task.submitted_at).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) : '—'}
Due Date
{submission?.deadline ? new Date(submission.deadline).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) : '—'}
{TABS.map(tab => { const count = tab === 'Revisions' ? revisions.length : tab === 'Submissions' ? reviewSubmissions.length : tab === 'Comments' ? commentRows.length : 0; return ( ); })}
{activeTab === 'Overview' && (
Requested By
{requester || '—'}
Requested
{submission?.submitted_at ? new Date(submission.submitted_at).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) : '—'}
Sign Count
{submission?.sign_count > 0 ? submission.sign_count : 'N/A'}
{isClient && ['not_started', 'in_progress'].includes(status) && (
)} {rejectedNoteByVersion.get(0) && (
)}
{submission?.description && (
Notes
{submission.description}
)} {submission?.sign_family && (
Sign Family
{submission.sign_family}
)} {amendments.length > 0 && (
Amendment
{amendments.map((am) => (
{am.description && (
Notes
{am.description}
)}
))}
)}
)} {activeTab === 'Revisions' && ( revisions.length === 0 ?
No revisions
:
{[...revisions].reverse().map((rev, i, arr) => { const rNum = `R${String(rev.version_number).padStart(2, '0')}`; const fmtDate = rev.submitted_at ? new Date(rev.submitted_at).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) : '—'; return (
Requested By
{getSubmissionDisplayName(rev) || '—'}
Requested
{fmtDate}
Sign Count
{rev.sign_count > 0 ? rev.sign_count : 'N/A'}
Revision
{rNum}
{isClient && i === 0 && ['not_started', 'in_progress'].includes(status) && (
)} {rejectedNoteByVersion.get(rev.version_number) && (
)}
{rev.description && (
Description
{rev.description}
)} {rev.sign_family && (
Sign Family
{rev.sign_family}
)} {!rev.description && !rev.sign_family && !(rev.signs?.length > 0) && !(Number(rev.sign_count || 0) > 0) && (
No notes provided.
)} downloadAllFiles(f, rNum)} />
); })}
)} {activeTab === 'Submissions' && ( reviewSubmissions.length === 0 ?
No submissions
:
{reviewSubmissions.map((sub, i, arr) => { const fmtDate = sub.sent_at ? new Date(sub.sent_at).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) : '—'; return (
Submitted By
{sub.sent_by || '—'}
Date
{fmtDate}
Version
{`R${String(sub.version_number ?? 0).padStart(2, '0')}`}
{sub.message ?
Notes
{sub.message}
:
No notes provided.
} downloadAllFiles(f, `Submission ${i + 1}`, 'deliveries')} />
); })}
)} {activeTab === 'Comments' && (
setCommentBody(e.target.value)} onKeyDown={e => { if (e.key === 'Enter') handlePostComment(); }} style={{ flex: 1, fontSize: 13, color: 'var(--text-primary)', background: 'var(--card-bg-2)', border: '1px solid var(--border)', borderRadius: 6, padding: '6px 10px', fontFamily: 'inherit', height: 30 }} />
{comments.length === 0 ?
No comments
: commentRows.map((c, i) => { const d = new Date(c.created_at); const dateStr = d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }); const timeStr = d.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit', hour12: true }); const isOwn = c.author_id === currentUser?.id; return (
{c.author_name} {dateStr} · {timeStr}
{isOwn && ( )}
{c.body}
); }) }
)}
Assigned To
{assigneeName ? ( {assigneeName} ) : (
Unassigned
)}
Activity
{activityLog.length === 0 ? (
No activity
) : (
{activityLog.map(e => ( ))}
)}
{reviewModal && (
{ if (!reviewSaving) { setReviewModal(false); setReviewFiles([]); setReviewNotes(''); } }}>
e.stopPropagation()}>
Place in Review
Notes (optional)