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:
+374
-163
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user