Files
fourge-portal/src/pages/TaskDetail.jsx
T
Krao Hasanee c91e292066 redesign: Layout2 design-system + dashboard/tasks rework + perf
Design system (Layout2):
- Solid token-driven theme (no animated grid); single-source tokens for bg,
  cards (bg/border/radius), popups, fonts (Inter), and full semantic +
  data-viz color palette. Swept ~168 hardcoded colors to tokens.

Dashboard:
- Reflow: To Do + Calendar row (fixed right rail) over Activity / In-Progress /
  Team Performance; CSS height-match (To Do = Calendar); responsive stacking.
- 'Tasks Not Started' -> 'To Do' card with R#/Assigned columns; compact money fmt.

Tasks page:
- Combined tabs into Tasks + Completed with a Status column.
- Column-header sort + filter (Status, R#/new-vs-revision, Project, Task Type,
  Company) via portaled, scrollable FilterDropdown; removed toolbar filters and
  the projects side panel; headers stay visible when empty; renamed to 'Tasks'.

Perf:
- FK/filter index migration; removed redundant client/external scope waterfall
  (rely on RLS); parallel follow-up queries.

Mobile:
- Responsive grids; mobile topbar = hamburger only, blends with bg, proper offset.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 10:08:40 -04:00

1151 lines
68 KiB
React

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 (
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 10 }}>
<div style={{ width: 27, height: 27, borderRadius: '50%', background: icon.bg, display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0, marginTop: 1 }}>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke={icon.color} strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
{icon.path && <path d={icon.path} />}
</svg>
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 13, lineHeight: 1.4 }}>
<span style={{ color: 'var(--text-primary)', fontWeight: 500 }}>{actor}</span>
<span style={{ color: 'var(--text-muted)' }}> {label}</span>
</div>
<div style={{ fontSize: 11, color: 'var(--text-muted)', marginTop: 2 }}>{dateStr} · {timeStr}</div>
</div>
</div>
);
}
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 (
<div style={{ padding: '12px 14px', borderRadius: 8, border: '1px solid color-mix(in srgb, var(--danger) 30%, var(--border))', background: 'color-mix(in srgb, var(--danger) 8%, var(--card-bg-2))' }}>
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 12, marginBottom: 6 }}>
<div style={{ fontSize: 11, fontWeight: 500, color: 'var(--danger)', letterSpacing: 0.8, textTransform: 'uppercase' }}>Rejected Notes</div>
<div style={{ fontSize: 11, color: 'var(--text-muted)', flexShrink: 0 }}>{dateStr}</div>
</div>
<div style={{ fontSize: 13, color: 'var(--text-primary)', lineHeight: 1.6, whiteSpace: 'pre-wrap' }}>{note.body}</div>
</div>
);
}
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 (
<div className="card" style={CARD}>
<div style={LABEL}>Timeline</div>
<div style={{ display: 'flex', flexDirection: 'column' }}>
{stages.map((stage, i) => {
const isDone = i < currentStage;
const isCurrent = i === currentStage;
let circle;
if (isDone) {
circle = (
<div style={{ width: 22, height: 22, borderRadius: '50%', background: 'var(--accent)', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="var(--text-on-accent)" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"><polyline points="4,13 9,18 20,7"/></svg>
</div>
);
} else if (isCurrent) {
circle = (
<div style={{ width: 22, height: 22, borderRadius: '50%', border: '2px solid var(--accent)', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
<div style={{ width: 8, height: 8, borderRadius: '50%', background: 'var(--accent)' }} />
</div>
);
} else {
circle = <div style={{ width: 22, height: 22, borderRadius: '50%', border: '2px solid var(--border)', flexShrink: 0 }} />;
}
return (
<div key={i} style={{ display: 'flex', gap: 12, alignItems: 'flex-start' }}>
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
{circle}
{i < stages.length - 1 && (
<div style={{ width: 1, height: 26, background: isDone ? 'var(--accent)' : 'var(--border)', margin: '3px 0' }} />
)}
</div>
<div style={{ paddingTop: 2, paddingBottom: i < stages.length - 1 ? 0 : 0 }}>
<div style={{ fontSize: 12, fontWeight: 500, color: isDone || isCurrent ? 'var(--text-primary)' : 'var(--text-muted)' }}>{stage.label}</div>
<div style={{ fontSize: 11, color: 'var(--text-muted)', marginTop: 1, marginBottom: 8 }}>{stage.date || '—'}</div>
</div>
</div>
);
})}
</div>
</div>
);
}
function AssigneePortrait({ name, avatarUrl }) {
return <ProfileAvatar name={name} avatarUrl={avatarUrl} size={32} fontSize={12} />;
}
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 (
<div style={{ marginTop: 14 }}>
<div style={{ marginBottom: 8 }}>
<div style={META_LABEL}>Attached Files ({files.length})</div>
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
{files.map((file) => {
const ext = getExt(file.name);
const color = FILE_EXT_COLOR[ext] || 'var(--text-muted)';
const label = ext ? ext.toUpperCase() : '—';
return (
<div key={file.id} title={`${file.name}${file.size ? ' · ' + fmtSize(file.size) : ''}`} style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 3, width: 40 }}>
<div style={{ width: 32, height: 38, borderRadius: 5, background: 'var(--card-bg-2)', border: '1px solid var(--border)', display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 1 }}>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke={color} strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>
<span style={{ fontSize: 6, fontWeight: 700, color, letterSpacing: 0.3 }}>{label}</span>
</div>
<div style={{ fontSize: 9, color: 'var(--text-muted)', width: '100%', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', textAlign: 'center' }}>{file.name.replace(/\.[^.]+$/, '')}</div>
</div>
);
})}
</div>
<button
className="btn btn-outline"
disabled={Boolean(downloading)}
onClick={() => onDownloadAll(files)}
style={{ marginTop: 10 }}
>
{downloading === 'all' ? 'Downloading…' : 'Download All'}
</button>
</div>
);
}
function SubmissionSigns({ signs = [], signCount = null }) {
const normalizedCount = Number(signCount || 0);
if ((!signs || signs.length === 0) && normalizedCount <= 0) return null;
return (
<div>
<div style={META_LABEL}>Signs</div>
{signs && signs.length > 0 ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{signs
.slice()
.sort((a, b) => Number(a.sign_number || 0) - Number(b.sign_number || 0))
.map((sign) => (
<div key={sign.id || `${sign.sign_number}-${sign.sign_name}`} style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<div style={{ fontSize: 13, color: 'var(--text-primary)', lineHeight: 1.5 }}>
<span style={{ color: 'var(--text-secondary)', marginRight: 8 }}>Sign {sign.sign_number || '—'}</span>
<span>{sign.sign_name || 'Unnamed sign'}</span>
</div>
{sign.sign_family ? (
<div style={{ fontSize: 12, color: 'var(--text-muted)', lineHeight: 1.4 }}>
{sign.sign_family}
</div>
) : null}
</div>
))}
</div>
) : (
<div style={{ fontSize: 13, color: 'var(--text-primary)', lineHeight: 1.5 }}>
N/A
</div>
)}
</div>
);
}
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 <Layout><PageLoader /></Layout>;
if (!task) return <Layout><p style={{ padding: 24 }}>Task not found.</p></Layout>;
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 (
<Layout>
<div style={{ display: 'flex', gap: 24, alignItems: 'stretch', flex: 1, minHeight: 0, overflow: 'hidden' }}>
<div style={{ flex: '0 0 70%', minWidth: 0, display: 'flex', flexDirection: 'column', gap: 24, minHeight: 0 }}>
<div className="card" style={{ ...CARD, position: 'relative', ...(submission?.is_hot ? { background: 'radial-gradient(ellipse 90% 55% at 50% 120%, rgba(239,80,10,0.18) 0%, color-mix(in srgb, var(--accent) 9%, transparent) 45%, transparent 70%), var(--card-bg)' } : {}) }}>
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 12, marginBottom: 8 }}>
<div style={{ fontSize: 28, fontWeight: 500, color: 'var(--text-primary)', lineHeight: 1.2 }}>{task.title}</div>
<div style={{ display: 'flex', gap: 8, flexShrink: 0 }}>
{isTeamOrSub && status === 'not_started' && (
<button className="btn btn-outline" disabled={actionBusy} onClick={handleStart}>Start Task</button>
)}
{isTeamOrSub && status === 'in_progress' && task.assigned_to === currentUser?.id && (
<>
<button className="btn btn-outline" disabled={actionBusy} onClick={handleRelease}>Release</button>
<button className="btn btn-outline" disabled={actionBusy} onClick={handleOnHold}>Place on Hold</button>
<button className="btn btn-primary" disabled={actionBusy} onClick={handleSendToReview}>Place in Review</button>
</>
)}
{isTeamOrSub && status === 'client_review' && task.assigned_to === currentUser?.id && (
<button className="btn btn-outline" disabled={actionBusy} onClick={handleRemoveFromReview}>Remove from Review</button>
)}
{isTeamOrSub && status === 'on_hold' && task.assigned_to === currentUser?.id && (
<>
<button className="btn btn-outline" disabled={actionBusy} onClick={handleRelease}>Release</button>
<button className="btn btn-outline" disabled={actionBusy} onClick={handleResume}>Resume</button>
</>
)}
{isClient && status === 'client_review' && (
<>
<button className="btn btn-primary" disabled={actionBusy} onClick={handleClientApprove}>Approve</button>
<button className="btn btn-danger" disabled={actionBusy} onClick={handleClientReject}>Reject</button>
</>
)}
{isClient && ['client_approved', 'invoiced', 'paid'].includes(status) && (
<button className="btn btn-outline" disabled={actionBusy} onClick={() => setRevisionModal(true)}>Request Revision</button>
)}
</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 20 }}>
{canonicalTaskServiceType !== '—' && (
<span className="badge badge-status badge-client">{canonicalTaskServiceType}</span>
)}
<StatusBadge status={task.status || 'not_started'} />
</div>
<div style={{ display: 'flex', gap: 30, flexWrap: 'wrap' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="var(--text-muted)" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" style={{ flexShrink: 0 }}><line x1="4" y1="9" x2="20" y2="9"/><line x1="4" y1="15" x2="20" y2="15"/><line x1="10" y1="3" x2="8" y2="21"/><line x1="16" y1="3" x2="14" y2="21"/></svg>
<div>
<div style={CARD_META_LABEL}>Task ID</div>
<div style={{ fontSize: 13, color: 'var(--text-primary)', fontVariantNumeric: 'tabular-nums' }}>{task.task_number || task.id.slice(0, 8).toUpperCase()}</div>
</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="var(--text-muted)" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" style={{ flexShrink: 0 }}><polyline points="17,1 21,5 17,9"/><path d="M3 11V9a4 4 0 0 1 4-4h14"/><polyline points="7,23 3,19 7,15"/><path d="M21 13v2a4 4 0 0 1-4 4H3"/></svg>
<div>
<div style={CARD_META_LABEL}>Revision</div>
<div style={{ fontSize: 13, color: 'var(--text-primary)', fontVariantNumeric: 'tabular-nums' }}>{displayCurrentVersion === 0 ? 'New' : String(displayCurrentVersion).padStart(2, '0')}</div>
</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="var(--text-muted)" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" style={{ flexShrink: 0 }}><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>
<div>
<div style={CARD_META_LABEL}>Project</div>
{project
? <Link to={`/projects/${project.id}`} className="table-link" style={{ fontSize: 13 }}>{project.name}</Link>
: <div style={{ fontSize: 13, color: 'var(--text-muted)' }}></div>}
</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="var(--text-muted)" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" style={{ flexShrink: 0 }}><rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>
<div>
<div style={CARD_META_LABEL}>Requested</div>
<div style={{ fontSize: 13, color: 'var(--text-primary)' }}>
{submission?.submitted_at || task.submitted_at
? new Date(submission?.submitted_at || task.submitted_at).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
: '—'}
</div>
</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="var(--text-muted)" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" style={{ flexShrink: 0 }}><circle cx="12" cy="12" r="10"/><polyline points="12,6 12,12 16,14"/></svg>
<div>
<div style={CARD_META_LABEL}>Due Date</div>
<div style={{ fontSize: 13, color: 'var(--text-primary)' }}>
{submission?.deadline
? new Date(submission.deadline).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
: '—'}
</div>
</div>
</div>
</div>
</div>
<div style={{ display: 'flex', flexDirection: 'column', flex: 1, minHeight: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 4, marginBottom: 10 }}>
{TABS.map(tab => {
const count = tab === 'Revisions' ? revisions.length : tab === 'Submissions' ? reviewSubmissions.length : tab === 'Comments' ? commentRows.length : 0;
return (
<button key={tab} onClick={() => setActiveTab(tab)} className={`section-tab-btn${activeTab === tab ? ' is-active' : ''}`}>
{tab}{count > 0 && <span style={{ marginLeft: 5, fontSize: 12, fontWeight: 600, color: activeTab === tab ? 'var(--accent)' : 'var(--text-muted)', background: 'var(--card-bg-2)', border: '1px solid var(--border)', borderRadius: 10, padding: '3px 8px' }}>{count}</span>}
</button>
);
})}
</div>
<div className="card" style={{ ...CARD, flex: 1, minHeight: 0, overflowY: 'auto' }}>
<div style={{ fontSize: 13 }}>
{activeTab === 'Overview' && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 20 }}>
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 32 }}>
<div>
<div style={META_LABEL}>Requested By</div>
<div style={{ fontSize: 13, color: 'var(--text-primary)' }}>{requester || '—'}</div>
</div>
<div>
<div style={META_LABEL}>Requested</div>
<div style={{ fontSize: 13, color: 'var(--text-primary)' }}>
{submission?.submitted_at ? new Date(submission.submitted_at).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) : '—'}
</div>
</div>
<div>
<div style={META_LABEL}>Sign Count</div>
<div style={{ fontSize: 13, color: 'var(--text-primary)' }}>{submission?.sign_count > 0 ? submission.sign_count : 'N/A'}</div>
</div>
{isClient && ['not_started', 'in_progress'].includes(status) && (
<div style={{ marginLeft: 'auto' }}>
<button className="btn btn-outline" onClick={() => setAmendModal(true)}>Amend Request</button>
</div>
)}
{rejectedNoteByVersion.get(0) && (
<div style={{ marginLeft: 'auto', flex: '0 0 min(320px, 36%)' }}>
<RejectedNoteBlock note={rejectedNoteByVersion.get(0)} />
</div>
)}
</div>
{submission?.description && (
<div>
<div style={META_LABEL}>Notes</div>
<div style={{ fontSize: 13, color: 'var(--text-primary)', lineHeight: 1.6, whiteSpace: 'pre-wrap' }}>{submission.description}</div>
</div>
)}
{submission?.sign_family && (
<div>
<div style={META_LABEL}>Sign Family</div>
<div style={{ fontSize: 13, color: 'var(--text-primary)' }}>{submission.sign_family}</div>
</div>
)}
<SubmissionSigns signs={submission?.signs} signCount={submission?.sign_count} />
<SubmissionFiles files={submission?.files} downloading={downloading} onDownloadAll={downloadAllFiles} />
{amendments.length > 0 && (
<div style={{ borderTop: '1px solid var(--border)', paddingTop: 20, display: 'flex', flexDirection: 'column', gap: 20 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span style={{ fontSize: 11, fontWeight: 600, letterSpacing: 0.8, color: 'var(--text-secondary)', background: 'var(--card-bg-2)', border: '1px solid var(--border)', borderRadius: 4, padding: '2px 8px', textTransform: 'uppercase' }}>Amendment</span>
</div>
{amendments.map((am) => (
<div key={am.id} style={{ display: 'flex', flexDirection: 'column', gap: 20 }}>
{am.description && (
<div>
<div style={META_LABEL}>Notes</div>
<div style={{ fontSize: 13, color: 'var(--text-primary)', lineHeight: 1.6, whiteSpace: 'pre-wrap' }}>{am.description}</div>
</div>
)}
<SubmissionFiles files={am.files} downloading={downloading} onDownloadAll={downloadAllFiles} />
</div>
))}
</div>
)}
</div>
)}
{activeTab === 'Revisions' && (
revisions.length === 0
? <div className="card-empty-center">No revisions</div>
: <div style={{ display: 'flex', flexDirection: 'column', gap: 20 }}>
{[...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 (
<div key={rev.id} style={{ display: 'flex', flexDirection: 'column', gap: 20, borderBottom: i < arr.length - 1 ? '1px solid var(--border)' : 'none', paddingBottom: i < arr.length - 1 ? 20 : 0 }}>
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 32 }}>
<div>
<div style={META_LABEL}>Requested By</div>
<div style={{ fontSize: 13, color: 'var(--text-primary)' }}>{getSubmissionDisplayName(rev) || '—'}</div>
</div>
<div>
<div style={META_LABEL}>Requested</div>
<div style={{ fontSize: 13, color: 'var(--text-primary)' }}>{fmtDate}</div>
</div>
<div>
<div style={META_LABEL}>Sign Count</div>
<div style={{ fontSize: 13, color: 'var(--text-primary)' }}>{rev.sign_count > 0 ? rev.sign_count : 'N/A'}</div>
</div>
<div>
<div style={META_LABEL}>Revision</div>
<div style={{ fontSize: 13, color: 'var(--text-primary)' }}>{rNum}</div>
</div>
{isClient && i === 0 && ['not_started', 'in_progress'].includes(status) && (
<div style={{ marginLeft: 'auto' }}>
<button className="btn btn-outline" onClick={() => setAmendModal(true)}>Amend</button>
</div>
)}
{rejectedNoteByVersion.get(rev.version_number) && (
<div style={{ marginLeft: 'auto', flex: '0 0 min(320px, 36%)' }}>
<RejectedNoteBlock note={rejectedNoteByVersion.get(rev.version_number)} />
</div>
)}
</div>
{rev.description && (
<div>
<div style={META_LABEL}>Description</div>
<div style={{ fontSize: 13, color: 'var(--text-primary)', lineHeight: 1.6, whiteSpace: 'pre-wrap' }}>{rev.description}</div>
</div>
)}
{rev.sign_family && (
<div>
<div style={META_LABEL}>Sign Family</div>
<div style={{ fontSize: 13, color: 'var(--text-primary)' }}>{rev.sign_family}</div>
</div>
)}
<SubmissionSigns signs={rev.signs} signCount={rev.sign_count} />
{!rev.description && !rev.sign_family && !(rev.signs?.length > 0) && !(Number(rev.sign_count || 0) > 0) && (
<div style={{ fontSize: 13, color: 'var(--text-muted)' }}>No notes provided.</div>
)}
<SubmissionFiles files={rev.files} downloading={downloading} onDownloadAll={(f) => downloadAllFiles(f, rNum)} />
</div>
);
})}
</div>
)}
{activeTab === 'Submissions' && (
reviewSubmissions.length === 0
? <div className="card-empty-center">No submissions</div>
: <div style={{ display: 'flex', flexDirection: 'column', gap: 20 }}>
{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 (
<div key={sub.id} style={{ display: 'flex', flexDirection: 'column', gap: 20, borderBottom: i < arr.length - 1 ? '1px solid var(--border)' : 'none', paddingBottom: i < arr.length - 1 ? 20 : 0 }}>
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 32 }}>
<div>
<div style={META_LABEL}>Submitted By</div>
<div style={{ fontSize: 13, color: 'var(--text-primary)' }}>{sub.sent_by || '—'}</div>
</div>
<div>
<div style={META_LABEL}>Date</div>
<div style={{ fontSize: 13, color: 'var(--text-primary)' }}>{fmtDate}</div>
</div>
<div>
<div style={META_LABEL}>Version</div>
<div style={{ fontSize: 13, color: 'var(--text-primary)', fontWeight: 500 }}>{`R${String(sub.version_number ?? 0).padStart(2, '0')}`}</div>
</div>
</div>
{sub.message
? <div>
<div style={META_LABEL}>Notes</div>
<div style={{ fontSize: 13, color: 'var(--text-primary)', lineHeight: 1.6, whiteSpace: 'pre-wrap' }}>{sub.message}</div>
</div>
: <div style={{ fontSize: 13, color: 'var(--text-muted)' }}>No notes provided.</div>
}
<SubmissionFiles files={sub.files} downloading={downloading} onDownloadAll={(f) => downloadAllFiles(f, `Submission ${i + 1}`, 'deliveries')} />
</div>
);
})}
</div>
)}
{activeTab === 'Comments' && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 0 }}>
<div style={{ display: 'flex', gap: 8, marginBottom: 20 }}>
<input
type="text"
placeholder="Write a comment…"
value={commentBody}
onChange={e => 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 }}
/>
<button className="btn btn-outline" disabled={commentSaving || !commentBody.trim()} onClick={handlePostComment}>Post</button>
</div>
{comments.length === 0
? <div className="card-empty-center">No comments</div>
: 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 (
<div key={c.id} style={{ borderTop: '1px solid var(--border)', paddingTop: 20, paddingBottom: 20 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 6 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span style={{ fontSize: 13, fontWeight: 500, color: 'var(--text-primary)' }}>{c.author_name}</span>
<span style={{ fontSize: 11, color: 'var(--text-muted)' }}>{dateStr} · {timeStr}</span>
</div>
{isOwn && (
<button className="btn btn-danger" onClick={() => handleDeleteComment(c.id)}>Delete</button>
)}
</div>
<div style={{ fontSize: 13, color: 'var(--text-primary)', lineHeight: 1.6, whiteSpace: 'pre-wrap' }}>{c.body}</div>
</div>
);
})
}
</div>
)}
</div>
</div>
</div>
</div>
<div style={{ flex: '0 0 calc(30% - 24px)', minWidth: 0, display: 'flex', flexDirection: 'column', gap: 24, minHeight: 0 }}>
<div className="card" style={CARD}>
<div style={LABEL}>Assigned To</div>
{assigneeName ? (
<Link to={`/profile/${assignee?.id}`} className="dashboard-inline-link" style={{ display: 'flex', alignItems: 'center', gap: 10, textDecoration: 'none' }}>
<AssigneePortrait name={assigneeName} avatarUrl={assigneeAvatar} />
<span style={{ fontSize: 13, fontWeight: 500 }}>{assigneeName}</span>
</Link>
) : (
<div style={{ fontSize: 13, color: 'var(--text-muted)' }}>Unassigned</div>
)}
</div>
<TimelineCard task={task} activityLog={activityLog} currentSubmission={submission} />
<div className="card" style={{ ...CARD, flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column' }}>
<div style={LABEL}>Activity</div>
{activityLog.length === 0 ? (
<div className="card-empty-center">No activity</div>
) : (
<div className="scrollbar-thin-theme" style={{ flex: 1, minHeight: 0, overflowY: 'auto', display: 'flex', flexDirection: 'column', gap: 10 }}>
{activityLog.map(e => (
<ActivityItem key={e.id} e={e} />
))}
</div>
)}
</div>
</div>
</div>
{reviewModal && (
<div style={popupOverlayStyle} onClick={() => { if (!reviewSaving) { setReviewModal(false); setReviewFiles([]); setReviewNotes(''); } }}>
<div style={{ ...popupSurfaceStyle, width: 'min(480px, 96vw)', maxHeight: 'calc(100vh - 48px)', overflowY: 'auto', display: 'flex', flexDirection: 'column', gap: 14 }} onClick={e => e.stopPropagation()}>
<div style={{ fontSize: 11, fontWeight: 500, textTransform: 'uppercase', letterSpacing: 0.8, color: 'var(--text-secondary)' }}>Place in Review</div>
<div>
<div style={{ fontSize: 11, fontWeight: 500, textTransform: 'uppercase', letterSpacing: 0.8, color: 'var(--text-secondary)', marginBottom: 6 }}>Notes <span style={{ color: 'var(--text-muted)', fontWeight: 400, textTransform: 'none', letterSpacing: 0 }}>(optional)</span></div>
<textarea rows={4} placeholder="Add notes for this submission…" value={reviewNotes} onChange={e => setReviewNotes(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%', fontFamily: 'inherit', boxSizing: 'border-box', resize: 'vertical' }} />
</div>
<div>
<FileAttachment files={reviewFiles} onChange={setReviewFiles} />
</div>
<div className="modal-action-row" style={MODAL_ACTION_ROW}>
<button className="btn btn-outline" style={MODAL_ACTION_BUTTON} disabled={reviewSaving} onClick={handleConfirmReview}>{reviewSaving ? 'Submitting…' : 'Place in Review'}</button>
<button className="btn btn-outline" style={MODAL_ACTION_BUTTON} disabled={reviewSaving} onClick={() => { setReviewModal(false); setReviewFiles([]); setReviewNotes(''); }}>Cancel</button>
</div>
</div>
</div>
)}
{revisionModal && (
<div style={{ position: 'fixed', inset: 0, background: 'var(--overlay-scrim, rgba(0,0,0,0.58))', zIndex: 1200, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 24 }} onClick={() => { if (!revisionSaving) { setRevisionModal(false); setRevisionForm({ description: '', deadline: '' }); setRevisionFiles([]); } }}>
<div className="card" style={{ ...CARD, width: '100%', maxWidth: 480, display: 'flex', flexDirection: 'column', gap: 18 }} onClick={e => e.stopPropagation()}>
<div style={{ fontSize: 18, fontWeight: 500, color: 'var(--text-primary)', letterSpacing: 0.2, lineHeight: 1.1 }}>Request Revision</div>
<div className="form-group">
<label style={{ fontSize: 11, fontWeight: 500, textTransform: 'uppercase', letterSpacing: 0.8, color: 'var(--text-secondary)' }}>Revision Notes</label>
<textarea
rows={5}
placeholder="Describe what needs to be changed…"
value={revisionForm.description}
onChange={e => setRevisionForm(f => ({ ...f, description: e.target.value }))}
style={{ width: '100%', fontSize: 13, color: 'var(--text-primary)', background: 'var(--card-bg-2)', border: '1px solid var(--border)', borderRadius: 6, padding: '8px 10px', resize: 'vertical', fontFamily: 'inherit' }}
/>
</div>
<div className="form-group">
<label style={{ fontSize: 11, fontWeight: 500, textTransform: 'uppercase', letterSpacing: 0.8, color: 'var(--text-secondary)' }}>Deadline</label>
<input
type="date"
value={revisionForm.deadline}
onChange={e => 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%' }}
/>
</div>
<div className="form-group">
<FileAttachment files={revisionFiles} onChange={setRevisionFiles} />
</div>
<div className="modal-action-row" style={MODAL_ACTION_ROW}>
<button className="btn btn-outline" style={MODAL_ACTION_BUTTON} disabled={revisionSaving} onClick={() => { setRevisionModal(false); setRevisionForm({ description: '', deadline: '' }); setRevisionFiles([]); }}>Cancel</button>
<button className="btn btn-outline" style={{ ...MODAL_ACTION_BUTTON, color: 'var(--accent)', borderColor: 'var(--accent)' }} disabled={revisionSaving || !revisionForm.description.trim()} onClick={handleSubmitRevision}>
{revisionSaving ? 'Submitting…' : 'Submit Revision'}
</button>
</div>
</div>
</div>
)}
{rejectModal && (
<div style={popupOverlayStyle} onClick={() => { if (!rejectSaving) { setRejectModal(false); setRejectNote(''); } }}>
<div style={{ ...popupSurfaceStyle, width: 'min(480px, 96vw)', maxHeight: 'calc(100vh - 48px)', overflowY: 'auto', display: 'flex', flexDirection: 'column', gap: 16 }} onClick={e => e.stopPropagation()}>
<div style={{ fontSize: 11, fontWeight: 500, textTransform: 'uppercase', letterSpacing: 0.8, color: 'var(--text-secondary)', lineHeight: 1.1 }}>Reject Task</div>
<div>
<div style={{ fontSize: 11, fontWeight: 500, textTransform: 'uppercase', letterSpacing: 0.8, color: 'var(--text-secondary)', marginBottom: 6 }}>Rejected Notes</div>
<textarea
rows={5}
placeholder="Tell the team what needs to change before approval…"
value={rejectNote}
onChange={e => setRejectNote(e.target.value)}
style={{ width: '100%', fontSize: 13, color: 'var(--text-primary)', background: 'var(--card-bg-2)', border: '1px solid var(--border)', borderRadius: 6, padding: '8px 10px', resize: 'vertical', fontFamily: 'inherit', boxSizing: 'border-box' }}
/>
</div>
<div className="modal-action-row" style={MODAL_ACTION_ROW}>
<button className="btn btn-outline" style={MODAL_ACTION_BUTTON} disabled={rejectSaving} onClick={() => { setRejectModal(false); setRejectNote(''); }}>Cancel</button>
<button className="btn btn-danger" style={MODAL_ACTION_BUTTON} disabled={rejectSaving || !rejectNote.trim()} onClick={handleSubmitReject}>
{rejectSaving ? 'Saving…' : 'Reject'}
</button>
</div>
</div>
</div>
)}
{amendModal && (
<div style={popupOverlayStyle} onClick={() => { setAmendModal(false); setAmendForm({ description: '' }); setAmendFiles([]); }}>
<div style={{ ...popupSurfaceStyle, width: 'min(480px, 96vw)', maxHeight: 'calc(100vh - 48px)', overflowY: 'auto', display: 'flex', flexDirection: 'column', gap: 16 }} onClick={e => e.stopPropagation()}>
<div style={{ fontSize: 11, fontWeight: 500, textTransform: 'uppercase', letterSpacing: 0.8, color: 'var(--text-secondary)', lineHeight: 1.1 }}>Amend Request</div>
<div>
<div style={{ fontSize: 11, fontWeight: 500, textTransform: 'uppercase', letterSpacing: 0.8, color: 'var(--text-secondary)', marginBottom: 6 }}>Notes</div>
<textarea rows={4} placeholder="Describe what you'd like to add or change…" value={amendForm.description} onChange={e => setAmendForm(f => ({ ...f, description: e.target.value }))} style={{ width: '100%', fontSize: 13, color: 'var(--text-primary)', background: 'var(--card-bg-2)', border: '1px solid var(--border)', borderRadius: 6, padding: '8px 10px', resize: 'vertical', fontFamily: 'inherit', boxSizing: 'border-box' }} />
</div>
<div>
<div style={{ fontSize: 11, fontWeight: 500, textTransform: 'uppercase', letterSpacing: 0.8, color: 'var(--text-secondary)', marginBottom: 6 }}>Attach Files</div>
<FileAttachment files={amendFiles} onChange={setAmendFiles} />
</div>
<div className="modal-action-row" style={MODAL_ACTION_ROW}>
<button className="btn btn-outline" style={MODAL_ACTION_BUTTON} disabled={amendSaving} onClick={handleSubmitAmendment}>{amendSaving ? 'Saving…' : 'Save'}</button>
<button className="btn btn-outline" style={MODAL_ACTION_BUTTON} disabled={amendSaving} onClick={() => { setAmendModal(false); setAmendForm({ description: '' }); setAmendFiles([]); }}>Cancel</button>
</div>
</div>
</div>
)}
{dlProgress.active && (
<div style={{ position: 'fixed', inset: 0, background: 'var(--overlay-scrim, rgba(0,0,0,0.58))', zIndex: 1200, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 24 }}>
<div className="card" style={{ ...CARD, width: '100%', maxWidth: 340, display: 'flex', flexDirection: 'column', gap: 12 }}>
<div style={{ fontSize: 11, fontWeight: 500, textTransform: 'uppercase', letterSpacing: 0.8, color: 'var(--text-secondary)' }}>Downloading</div>
<div style={{ fontSize: 12, color: 'var(--text-secondary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{dlProgress.label}</div>
<div style={{ height: 4, borderRadius: 2, background: 'var(--card-bg-2)', overflow: 'hidden' }}>
<div style={{ height: '100%', borderRadius: 2, background: 'var(--accent)', width: `${dlProgress.percent}%`, transition: 'width 200ms ease' }} />
</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)', textAlign: 'right' }}>{dlProgress.percent}%</div>
</div>
</div>
)}
</Layout>
);
}