fix: crash bugs in TeamInvoices + faster load

Bug fixes:
- TeamInvoices: add useAuth import/currentUser (invoice-create crash)
- TeamInvoices: setChartYear -> setExportYear (year-dropdown crash)
- TeamDashboard: drop redundant setState-in-effect (cascading renders)
- Companies: delete dead _UnusedClientCompanies (illegal hook calls)
- annotate intentional empty PDF-fallback catches

Load speed:
- preconnect/dns-prefetch to Supabase origin
- lazy-load heic-to in Converters: page chunk 2737KB -> 9KB
- split recharts into its own 'charts' vendor chunk

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Krao Hasanee
2026-06-08 12:59:11 -04:00
parent 85625f4d95
commit 04e0911e9f
101 changed files with 11786 additions and 7445 deletions
+374 -163
View File
@@ -1,19 +1,20 @@
import { useState, useEffect, useRef, useCallback } from '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 FileBrowser from '../components/FileBrowser';
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 { sendEmail } from '../lib/email';
import { uploadFilesToRequestInfo, safeName } from '../lib/filebrowserFolders';
import { useRefetchOnFocus } from '../hooks/useRefetchOnFocus';
import { useRealtimeSubscription } from '../hooks/useRealtimeSubscription';
import { useLiveRefresh } from '../hooks/useLiveRefresh';
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',
@@ -63,8 +64,24 @@ 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', 'Folder'];
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;
@@ -135,19 +152,7 @@ function TimelineCard({ task, activityLog, currentSubmission }) {
}
function AssigneePortrait({ name, avatarUrl }) {
if (avatarUrl) {
return (
<div style={{ width: 32, height: 32, borderRadius: '50%', overflow: 'hidden', flexShrink: 0 }}>
<img src={avatarUrl} alt={name} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
</div>
);
}
const initials = (name || '?').split(' ').map(w => w[0]).slice(0, 2).join('').toUpperCase();
return (
<div style={{ width: 32, height: 32, borderRadius: '50%', background: 'var(--accent)', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
<span style={{ fontSize: 12, fontWeight: 600, color: '#000', lineHeight: 1 }}>{initials}</span>
</div>
);
return <ProfileAvatar name={name} avatarUrl={avatarUrl} size={32} fontSize={12} />;
}
const fmtSize = (bytes) => {
@@ -194,13 +199,55 @@ function SubmissionFiles({ files, downloading, onDownloadAll }) {
);
}
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, setRefreshKey] = useState(0);
const refresh = useCallback(() => setRefreshKey(k => k + 1), []);
useRefetchOnFocus(refresh);
useRealtimeSubscription(['tasks', 'submissions', 'activity_log', 'task_comments'], refresh);
const { refreshKey } = useLiveRefresh(['tasks', 'submissions', 'activity_log', 'task_comments']);
const [task, setTask] = useState(null);
const [submissions, setSubmissions] = useState([]);
const [activityLog, setActivityLog] = useState([]);
@@ -213,25 +260,55 @@ export default function TaskDetail() {
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 [amendDragging, setAmendDragging] = useState(false);
const amendDragCounter = useRef(0);
const amendFileRef = useRef(null);
const [reviewModal, setReviewModal] = useState(false);
const [reviewFiles, setReviewFiles] = useState([]);
const [reviewNotes, setReviewNotes] = useState('');
const [reviewSaving, setReviewSaving] = useState(false);
const [reviewDragging, setReviewDragging] = useState(false);
const reviewDragCounter = useRef(0);
const reviewFileRef = useRef(null);
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([
@@ -240,11 +317,7 @@ export default function TaskDetail() {
.select('*, project:projects(id, name, company:companies(id, name)), assignee:profiles!assigned_to(id, name, avatar_url)')
.eq('id', id)
.single(),
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, files:submission_files(id, name, storage_path, size)')
.eq('task_id', id)
.order('submitted_at', { ascending: true }),
fetchTaskSubmissions(id),
supabase
.from('activity_log')
.select('id, created_at, actor_id, actor_name, action, task_id, task_title')
@@ -256,7 +329,7 @@ export default function TaskDetail() {
.select('id, author_id, author_name, body, created_at')
.eq('task_id', id)
.order('created_at', { ascending: true }),
]).then(async ([{ data: taskData }, { data: subRows }, { data: actData }, { data: commentData }]) => {
]).then(async ([{ data: taskData }, subRows, { data: actData }, { data: commentData }]) => {
setTask(taskData);
setSubmissions(subRows || []);
setActivityLog(actData || []);
@@ -277,76 +350,225 @@ export default function TaskDetail() {
if (loading) return <Layout><PageLoader /></Layout>;
if (!task) return <Layout><p style={{ padding: 24 }}>Task not found.</p></Layout>;
const submission = submissions.find(s => s.type === 'initial') || submissions[0] || null;
const amendments = submissions.filter(s => s.type === 'amendment');
const revisions = submissions.filter(s => s.type === 'revision').sort((a, b) => a.version_number - b.version_number);
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 = submission?.submitted_by_name || null;
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 = async (newStatus, extra = {}, action = null) => {
setStatusSaving(true);
await supabase.from('tasks').update({ status: newStatus, ...extra }).eq('id', id);
const prevTask = task;
setTask(t => ({ ...t, status: newStatus, ...extra }));
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 || []);
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);
}
setStatusSaving(false);
};
const handleStart = () => updateStatus('in_progress', { assigned_to: currentUser.id, assigned_name: currentUser.name }, 'task_started');
const handleOnHold = () => updateStatus('on_hold', {}, 'task_on_hold');
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 = async () => {
setReviewSaving(true);
try {
const briefId = submissions.find(s => s.type === 'initial')?.id || submissions[0]?.id;
const { data: newDel } = await supabase.from('deliveries').insert({ submission_id: briefId, sent_by: currentUser.name, message: reviewNotes.trim() || '', version_number: task.current_version || 0 }).select().single();
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 } = await supabase.storage.from('deliveries').upload(path, file);
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) await supabase.from('delivery_files').insert(uploaded);
if (uploaded.length > 0) {
const { error: deliveryFilesError } = await supabase.from('delivery_files').insert(uploaded);
if (deliveryFilesError) throw deliveryFilesError;
}
}
const subIds = submissions.map(s => s.id);
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 = () => updateStatus('client_approved', { completed_at: new Date().toISOString() }, 'task_approved');
const handleClientReject = () => updateStatus('not_started', { current_version: (task.current_version || 0) + 1, assigned_to: null, assigned_name: null }, 'revision_requested');
const handleClientApprove = 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 = async () => {
if (!rejectNote.trim()) return;
setRejectSaving(true);
try {
const rejectedVersion = task.current_version || 0;
const { data: rejectedNoteSubmission } = 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();
if (rejectedNoteSubmission) 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 = 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: null, assigned_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: task.title, deadline: revisionForm.deadline || null, description: revisionForm.description, submitted_by: currentUser.id, submitted_by_name: currentUser.name }).select().single();
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) {
@@ -355,18 +577,27 @@ export default function TaskDetail() {
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 })));
if (project?.name && task?.title) uploadFilesToRequestInfo(revisionFiles, company?.name, project.name, task.title, newVersion).catch(() => {});
}
await logActivity({ actorId: currentUser.id, actorName: currentUser.name, action: 'revision_requested', taskId: id, taskTitle: task?.title, projectId: project?.id, projectName: project?.name });
sendEmail('revision_submitted', ['hello@fourgebranding.com'], { clientName: currentUser.name, serviceType: task.title, projectName: project?.name, version: `R${String(newVersion).padStart(2, '0')}`, deadline: revisionForm.deadline, description: revisionForm.description, taskId: id }).catch(() => {});
const [{ data: taskData }, { data: subRows }, { data: actData }] = await Promise.all([
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(),
supabase.from('submissions').select('id, type, version_number, submitted_by, submitted_by_name, submitted_at, service_type, deadline, description, is_hot, sign_family, sign_count, files:submission_files(id, name, storage_path, size)').eq('task_id', id).order('submitted_at', { ascending: true }),
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([]);
@@ -378,7 +609,7 @@ export default function TaskDetail() {
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: task.title, description: amendForm.description, submitted_by: currentUser.id, submitted_by_name: currentUser.name }).select().single();
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 = [];
@@ -390,11 +621,20 @@ export default function TaskDetail() {
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 })));
if (project?.name && task?.title) uploadFilesToRequestInfo(amendFiles, company?.name, project.name, task.title, baseline).catch(() => {});
setDlProgress({ active: false, label: '', percent: 0 });
}
const { data: subRows } = await supabase.from('submissions').select('id, type, version_number, submitted_by, submitted_by_name, submitted_at, service_type, deadline, description, is_hot, sign_family, sign_count, files:submission_files(id, name, storage_path, size)').eq('task_id', id).order('submitted_at', { ascending: true });
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);
@@ -454,38 +694,38 @@ export default function TaskDetail() {
<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={statusSaving} onClick={handleStart}>Start Task</button>
<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={statusSaving} onClick={handleRelease}>Release</button>
<button className="btn btn-outline" disabled={statusSaving} onClick={handleOnHold}>Place on Hold</button>
<button className="btn btn-primary" disabled={statusSaving} onClick={handleSendToReview}>Place in Review</button>
<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={statusSaving} onClick={handleRemoveFromReview}>Remove from Review</button>
<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={statusSaving} onClick={handleRelease}>Release</button>
<button className="btn btn-outline" disabled={statusSaving} onClick={handleResume}>Resume</button>
<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={statusSaving} onClick={handleClientApprove}>Approve</button>
<button className="btn btn-danger" disabled={statusSaving} onClick={handleClientReject}>Reject</button>
<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" onClick={() => setRevisionModal(true)}>Request Revision</button>
<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 }}>
{submission?.service_type && (
<span className="badge badge-status badge-client">{submission.service_type}</span>
{canonicalTaskServiceType !== '—' && (
<span className="badge badge-status badge-client">{canonicalTaskServiceType}</span>
)}
<StatusBadge status={task.status || 'not_started'} />
</div>
@@ -501,7 +741,7 @@ export default function TaskDetail() {
<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' }}>{(task.current_version ?? 0) === 0 ? 'New' : String(task.current_version).padStart(2, '0')}</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 }}>
@@ -541,7 +781,7 @@ export default function TaskDetail() {
<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' ? comments.length : 0;
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>}
@@ -549,8 +789,8 @@ export default function TaskDetail() {
);
})}
</div>
<div className="card" style={{ ...CARD, flex: 1, minHeight: 0, overflowY: activeTab === 'Folder' ? 'hidden' : 'auto', padding: activeTab === 'Folder' ? 0 : CARD.padding, ...(activeTab === 'Folder' ? { display: 'flex', flexDirection: 'column' } : {}) }}>
<div style={{ fontSize: 13, ...(activeTab === 'Folder' ? { flex: 1, display: 'flex', flexDirection: 'column', minHeight: 0 } : {}) }}>
<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 }}>
@@ -573,6 +813,11 @@ export default function TaskDetail() {
<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>
@@ -586,6 +831,7 @@ export default function TaskDetail() {
<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 }}>
@@ -609,7 +855,7 @@ export default function TaskDetail() {
)}
{activeTab === 'Revisions' && (
revisions.length === 0
? <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', flex: 1, minHeight: 120, color: 'var(--text-muted)' }}>No revisions yet.</div>
? <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')}`;
@@ -621,7 +867,7 @@ export default function TaskDetail() {
<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)' }}>{rev.submitted_by_name || '—'}</div>
<div style={{ fontSize: 13, color: 'var(--text-primary)' }}>{getSubmissionDisplayName(rev) || '—'}</div>
</div>
<div>
<div style={META_LABEL}>Requested</div>
@@ -640,6 +886,11 @@ export default function TaskDetail() {
<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>
@@ -653,7 +904,8 @@ export default function TaskDetail() {
<div style={{ fontSize: 13, color: 'var(--text-primary)' }}>{rev.sign_family}</div>
</div>
)}
{!rev.description && !rev.sign_family && (
<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)} />
@@ -664,7 +916,7 @@ export default function TaskDetail() {
)}
{activeTab === 'Submissions' && (
reviewSubmissions.length === 0
? <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', flex: 1, minHeight: 120, color: 'var(--text-muted)' }}>No submissions yet.</div>
? <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
@@ -681,7 +933,7 @@ export default function TaskDetail() {
<div style={META_LABEL}>Date</div>
<div style={{ fontSize: 13, color: 'var(--text-primary)' }}>{fmtDate}</div>
</div>
<div style={{ marginLeft: 'auto' }}>
<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>
@@ -713,8 +965,8 @@ export default function TaskDetail() {
<button className="btn btn-outline" disabled={commentSaving || !commentBody.trim()} onClick={handlePostComment}>Post</button>
</div>
{comments.length === 0
? <div style={{ color: 'var(--text-muted)', fontSize: 13 }}>No comments yet.</div>
: comments.map((c, i) => {
? <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 });
@@ -737,22 +989,6 @@ export default function TaskDetail() {
}
</div>
)}
{activeTab === 'Folder' && company?.name && project?.name && (() => {
const fbInitial = role === 'external'
? `/Projects/${safeName(project.name)}/${safeName(task.title)}`
: role === 'client'
? `/${safeName(company.name)}/Projects/${safeName(project.name)}/${safeName(task.title)}`
: `/Clients/${safeName(company.name)}/Projects/${safeName(project.name)}/${safeName(task.title)}`;
const fbRoot = role === 'external'
? `/Projects/${safeName(project.name)}`
: role === 'client'
? `/${safeName(company.name)}/Projects/${safeName(project.name)}`
: `/Clients/${safeName(company.name)}/Projects/${safeName(project.name)}`;
return <FileBrowser initialPath={fbInitial} rootPath={fbRoot} />;
})()}
{activeTab === 'Folder' && (!company?.name || !project?.name) && (
<div style={{ color: 'var(--text-muted)' }}>No folder available task needs a project and company.</div>
)}
</div>
</div>
</div>
@@ -776,7 +1012,7 @@ export default function TaskDetail() {
<div className="card" style={{ ...CARD, flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column' }}>
<div style={LABEL}>Activity</div>
{activityLog.length === 0 ? (
<div style={{ fontSize: 13, color: 'var(--text-muted)' }}>No activity yet.</div>
<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 => (
@@ -797,36 +1033,11 @@ export default function TaskDetail() {
<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>
<div style={{ fontSize: 11, fontWeight: 500, textTransform: 'uppercase', letterSpacing: 0.8, color: 'var(--text-secondary)', marginBottom: 6 }}>Attachments <span style={{ color: 'var(--text-muted)', fontWeight: 400, textTransform: 'none', letterSpacing: 0 }}>(optional)</span></div>
<input ref={reviewFileRef} type="file" multiple id="review-file-input" style={{ display: 'none' }} onChange={e => { setReviewFiles(prev => [...prev, ...Array.from(e.target.files)]); e.target.value = ''; }} />
<div
onDragEnter={e => { e.preventDefault(); reviewDragCounter.current++; setReviewDragging(true); }}
onDragLeave={e => { e.preventDefault(); reviewDragCounter.current--; if (reviewDragCounter.current === 0) setReviewDragging(false); }}
onDragOver={e => e.preventDefault()}
onDrop={e => { e.preventDefault(); reviewDragCounter.current = 0; setReviewDragging(false); setReviewFiles(prev => [...prev, ...Array.from(e.dataTransfer.files)]); }}
onClick={() => reviewFileRef.current?.click()}
style={{ border: `2px dashed ${reviewDragging ? 'var(--accent)' : reviewFiles.length > 0 ? 'var(--accent)' : 'var(--border)'}`, borderRadius: 8, padding: '16px', textAlign: 'center', background: reviewDragging ? 'color-mix(in srgb, var(--accent) 8%, var(--bg))' : 'transparent', transition: 'all 160ms', cursor: 'pointer' }}
>
<div style={{ fontSize: 20, marginBottom: 4 }}>{reviewDragging ? '📂' : '📎'}</div>
<div style={{ fontSize: 13, color: 'var(--text-primary)' }}>
{reviewDragging ? 'Drop files here' : reviewFiles.length > 0 ? `${reviewFiles.length} file${reviewFiles.length !== 1 ? 's' : ''} attached — click or drag to add more` : 'Click or drag files here'}
</div>
<div style={{ fontSize: 12, color: 'var(--text-secondary)', marginTop: 2 }}>Any file type accepted</div>
</div>
{reviewFiles.length > 0 && (
<div style={{ marginTop: 8, display: 'flex', flexDirection: 'column', gap: 4 }}>
{reviewFiles.map((f, i) => (
<div key={i} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '5px 10px', background: 'var(--card-bg-2)', borderRadius: 6, border: '1px solid var(--border)' }}>
<span style={{ fontSize: 12, color: 'var(--text-primary)' }}>{f.name}</span>
<button type="button" className="btn btn-danger" onClick={() => setReviewFiles(prev => prev.filter((_, j) => j !== i))}>Remove</button>
</div>
))}
</div>
)}
<FileAttachment files={reviewFiles} onChange={setReviewFiles} />
</div>
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end', paddingTop: 4 }}>
<button className="btn btn-outline" disabled={reviewSaving} onClick={handleConfirmReview}>{reviewSaving ? 'Submitting…' : 'Place in Review'}</button>
<button className="btn btn-outline" disabled={reviewSaving} onClick={() => { setReviewModal(false); setReviewFiles([]); setReviewNotes(''); }}>Cancel</button>
<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>
@@ -858,9 +1069,9 @@ export default function TaskDetail() {
<div className="form-group">
<FileAttachment files={revisionFiles} onChange={setRevisionFiles} />
</div>
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
<button className="btn btn-outline" disabled={revisionSaving} onClick={() => { setRevisionModal(false); setRevisionForm({ description: '', deadline: '' }); setRevisionFiles([]); }}>Cancel</button>
<button className="btn btn-outline" style={{ color: 'var(--accent)', borderColor: 'var(--accent)' }} disabled={revisionSaving || !revisionForm.description.trim()} onClick={handleSubmitRevision}>
<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>
@@ -868,6 +1079,30 @@ export default function TaskDetail() {
</div>
)}
{rejectModal && (
<div style={popupOverlayStyle} onClick={() => { if (!rejectSaving) { setRejectModal(false); setRejectNote(''); } }}>
<div style={{ ...popupSurfaceStyle, background: 'var(--card-bg)', 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, background: 'var(--card-bg)', width: 'min(480px, 96vw)', maxHeight: 'calc(100vh - 48px)', overflowY: 'auto', display: 'flex', flexDirection: 'column', gap: 16 }} onClick={e => e.stopPropagation()}>
@@ -878,35 +1113,11 @@ export default function TaskDetail() {
</div>
<div>
<div style={{ fontSize: 11, fontWeight: 500, textTransform: 'uppercase', letterSpacing: 0.8, color: 'var(--text-secondary)', marginBottom: 6 }}>Attach Files</div>
<input ref={amendFileRef} type="file" multiple id="amend-file-input" style={{ display: 'none' }} onChange={e => { setAmendFiles(prev => [...prev, ...Array.from(e.target.files)]); e.target.value = ''; }} />
<div
onDragEnter={e => { e.preventDefault(); amendDragCounter.current++; setAmendDragging(true); }}
onDragLeave={e => { e.preventDefault(); amendDragCounter.current--; if (amendDragCounter.current === 0) setAmendDragging(false); }}
onDragOver={e => e.preventDefault()}
onDrop={e => { e.preventDefault(); amendDragCounter.current = 0; setAmendDragging(false); setAmendFiles(prev => [...prev, ...Array.from(e.dataTransfer.files)]); }}
style={{ border: `2px dashed ${amendDragging ? 'var(--accent)' : amendFiles.length > 0 ? 'var(--accent)' : 'var(--border)'}`, borderRadius: 8, padding: '16px', textAlign: 'center', background: amendDragging ? 'color-mix(in srgb, var(--accent) 8%, var(--bg))' : 'transparent', transition: 'all 160ms', cursor: 'pointer' }}
onClick={() => amendFileRef.current?.click()}
>
<div style={{ fontSize: 20, marginBottom: 4 }}>{amendDragging ? '📂' : '📎'}</div>
<div style={{ fontSize: 13, color: 'var(--text-primary)' }}>
{amendDragging ? 'Drop files here' : amendFiles.length > 0 ? `${amendFiles.length} file${amendFiles.length !== 1 ? 's' : ''} attached — click or drag to add more` : 'Click or drag files here'}
</div>
<div style={{ fontSize: 12, color: 'var(--text-secondary)', marginTop: 2 }}>Any file type accepted</div>
</div>
{amendFiles.length > 0 && (
<div style={{ marginTop: 8, display: 'flex', flexDirection: 'column', gap: 4 }}>
{amendFiles.map((f, i) => (
<div key={i} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '5px 10px', background: 'var(--card-bg-2)', borderRadius: 6, border: '1px solid var(--border)' }}>
<span style={{ fontSize: 12, color: 'var(--text-primary)' }}>{f.name}</span>
<button type="button" className="btn btn-danger" onClick={() => setAmendFiles(prev => prev.filter((_, idx) => idx !== i))}>Remove</button>
</div>
))}
</div>
)}
<FileAttachment files={amendFiles} onChange={setAmendFiles} />
</div>
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end', paddingTop: 4 }}>
<button className="btn btn-outline" disabled={amendSaving} onClick={handleSubmitAmendment}>{amendSaving ? 'Saving…' : 'Save'}</button>
<button className="btn btn-outline" disabled={amendSaving} onClick={() => { setAmendModal(false); setAmendForm({ description: '' }); setAmendFiles([]); }}>Cancel</button>
<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>