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 (
{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 (
);
}
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 (
{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) => (
))}
)}
)}
{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) || '—'}
Sign Count
{rev.sign_count > 0 ? rev.sign_count : 'N/A'}
{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 || '—'}
Version
{`R${String(sub.version_number ?? 0).padStart(2, '0')}`}
{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
)}
{revisionModal && (
{ if (!revisionSaving) { setRevisionModal(false); setRevisionForm({ description: '', deadline: '' }); setRevisionFiles([]); } }}>
e.stopPropagation()}>
Request Revision
setRevisionForm(f => ({ ...f, deadline: e.target.value }))}
style={{ fontSize: 13, color: 'var(--text-primary)', background: 'var(--card-bg-2)', border: '1px solid var(--border)', borderRadius: 6, padding: '6px 10px', width: '100%' }}
/>
)}
{rejectModal && (
{ if (!rejectSaving) { setRejectModal(false); setRejectNote(''); } }}>
e.stopPropagation()}>
Reject Task
)}
{amendModal && (
{ setAmendModal(false); setAmendForm({ description: '' }); setAmendFiles([]); }}>
e.stopPropagation()}>
Amend Request
)}
{dlProgress.active && (
Downloading
{dlProgress.label}
{dlProgress.percent}%
)}
);
}