Session 2026-05-30: task detail page overhaul
- New TaskDetail replaces /tasks/:id (was v2 at /requests/:id/v2) - Overview tab: R00 request info, Requested By/Date/Sign Count inline row, Notes, Sign Family, files, amendments - Revisions tab: R01+ from submissions, newest first, same layout as overview, per-entry Amend button - Comments tab: single-line input, post on Enter, delete own comments, Supabase task_comments table - Folder tab: placeholder for file sharing - Tab badges showing revision/comment counts - Amend Request modal: drag-drop file zone, popupOverlayStyle/Surface, Save+Cancel buttons - Request Revision modal for clients on approved/invoiced/paid tasks - JSZip download-all with progress popup for submission files - Upload progress popup for Add Task and amend file uploads - FileAttachment drop zone: 8px radius, transparent bg, label style fix (no all-caps override) - PageLoader on task detail load - task_comments migration with RLS policies Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,790 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { useParams, Link } from 'react-router-dom';
|
||||
import Layout from '../components/Layout';
|
||||
import PageLoader from '../components/PageLoader';
|
||||
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 } from '../lib/filebrowserFolders';
|
||||
|
||||
const ACTION_LABEL = {
|
||||
task_created: 'created this task', task_started: 'started this task', task_on_hold: 'placed task on hold',
|
||||
task_resumed: 'resumed this task', task_submitted: 'submitted this task', task_approved: 'approved this task',
|
||||
revision_requested: 'rejected this task', request_submitted: 'submitted this request',
|
||||
};
|
||||
|
||||
const ACTION_ICON = {
|
||||
task_started: { bg: 'rgba(96,165,250,0.15)', color: '#60a5fa', path: 'M6 4l14 8-14 8V4z' },
|
||||
task_resumed: { bg: 'rgba(96,165,250,0.15)', color: '#60a5fa', path: 'M6 4l14 8-14 8V4z' },
|
||||
task_submitted: { bg: 'rgba(74,222,128,0.15)', color: '#4ade80', path: 'M12 19V5M5 12l7-7 7 7' },
|
||||
task_approved: { bg: 'rgba(74,222,128,0.15)', color: '#4ade80', path: 'M4 13l5 5L20 7' },
|
||||
task_on_hold: { bg: 'rgba(239,68,68,0.15)', color: '#ef4444', path: 'M6 4h4v16H6zM14 4h4v16h-4z' },
|
||||
task_created: { bg: 'rgba(167,139,250,0.15)', color: '#a78bfa', path: 'M12 5v14M5 12h14' },
|
||||
revision_requested: { bg: 'rgba(245,158,11,0.15)', color: '#f59e0b', path: 'M4 12a8 8 0 018-8v0a8 8 0 018 8' },
|
||||
request_submitted: { bg: 'rgba(167,139,250,0.15)', color: '#a78bfa', path: 'M9 12h6M9 8h6M5 3h14a2 2 0 012 2v14a2 2 0 01-2 2H5a2 2 0 01-2-2V5a2 2 0 012-2z' },
|
||||
};
|
||||
|
||||
function ActivityItem({ e }) {
|
||||
const icon = ACTION_ICON[e.action] || { bg: 'rgba(255,255,255,0.08)', color: 'rgba(255,255,255,0.4)', path: null };
|
||||
const label = ACTION_LABEL[e.action] || e.action;
|
||||
const actor = e.actor_name || 'Fourge';
|
||||
const d = new Date(e.created_at);
|
||||
const dateStr = d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
|
||||
const timeStr = d.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit', hour12: true });
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 10 }}>
|
||||
<div style={{ width: 27, height: 27, borderRadius: '50%', background: icon.bg, display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0, marginTop: 1 }}>
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke={icon.color} strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
{icon.path && <path d={icon.path} />}
|
||||
</svg>
|
||||
</div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontSize: 13, lineHeight: 1.4 }}>
|
||||
<span style={{ color: 'var(--text-primary)', fontWeight: 500 }}>{actor}</span>
|
||||
<span style={{ color: 'var(--text-muted)' }}> {label}</span>
|
||||
</div>
|
||||
<div style={{ fontSize: 11, color: 'var(--text-muted)', marginTop: 2 }}>{dateStr} · {timeStr}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const CARD = { padding: '18px 21px', borderRadius: 8 };
|
||||
const LABEL = { fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase', marginBottom: 14 };
|
||||
const META_LABEL = { fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase', marginBottom: 4 };
|
||||
const CARD_META_LABEL = { fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase', marginBottom: 2 };
|
||||
const TAB_STYLE = (active) => ({
|
||||
fontFamily: "Fourge, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif",
|
||||
fontSize: 13, fontWeight: 500, letterSpacing: 0.2, padding: '2px 0 5px', margin: '0 8px',
|
||||
borderRadius: 0, border: 'none', borderBottom: active ? '2px solid var(--accent)' : '2px solid transparent',
|
||||
cursor: 'pointer', background: 'transparent',
|
||||
color: active ? 'var(--text-primary)' : 'var(--text-secondary)', transition: 'all 160ms',
|
||||
});
|
||||
|
||||
const TABS = ['Overview', 'Revisions', 'Comments', 'Folder'];
|
||||
|
||||
function TimelineCard({ task, activityLog, currentSubmission }) {
|
||||
const status = task?.status;
|
||||
let currentStage;
|
||||
if (['client_approved', 'invoiced', 'paid'].includes(status)) currentStage = 4;
|
||||
else if (status === 'client_review') currentStage = 2;
|
||||
else if (status === 'in_progress' || status === 'on_hold') currentStage = 1;
|
||||
else currentStage = 0;
|
||||
|
||||
const fmtDate = (d) => d ? new Date(d).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) : null;
|
||||
|
||||
// Only look at activity from the current revision cycle
|
||||
const lastRevision = activityLog.find(e => e.action === 'revision_requested');
|
||||
const cutoff = lastRevision ? new Date(lastRevision.created_at).getTime() : 0;
|
||||
const currentLog = activityLog.filter(e => new Date(e.created_at).getTime() > cutoff);
|
||||
|
||||
const startedEntry = [...currentLog].reverse().find(e => e.action === 'task_started');
|
||||
const reviewEntry = [...currentLog].reverse().find(e => e.action === 'task_submitted');
|
||||
|
||||
const stages = [
|
||||
{ label: 'Task Created', date: fmtDate(currentSubmission?.submitted_at || task?.submitted_at) },
|
||||
{ label: 'In Progress', date: fmtDate(startedEntry?.created_at) },
|
||||
{ label: 'In Review', date: fmtDate(reviewEntry?.created_at) },
|
||||
{ label: 'Approved', date: fmtDate(task?.completed_at) },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="card" style={CARD}>
|
||||
<div style={LABEL}>Timeline</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column' }}>
|
||||
{stages.map((stage, i) => {
|
||||
const isDone = i < currentStage;
|
||||
const isCurrent = i === currentStage;
|
||||
let circle;
|
||||
if (isDone) {
|
||||
circle = (
|
||||
<div style={{ width: 22, height: 22, borderRadius: '50%', background: 'var(--accent)', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
|
||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="#000" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"><polyline points="4,13 9,18 20,7"/></svg>
|
||||
</div>
|
||||
);
|
||||
} else if (isCurrent) {
|
||||
circle = (
|
||||
<div style={{ width: 22, height: 22, borderRadius: '50%', border: '2px solid var(--accent)', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
|
||||
<div style={{ width: 8, height: 8, borderRadius: '50%', background: 'var(--accent)' }} />
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
circle = <div style={{ width: 22, height: 22, borderRadius: '50%', border: '2px solid var(--border)', flexShrink: 0 }} />;
|
||||
}
|
||||
return (
|
||||
<div key={i} style={{ display: 'flex', gap: 12, alignItems: 'flex-start' }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
|
||||
{circle}
|
||||
{i < stages.length - 1 && (
|
||||
<div style={{ width: 1, height: 26, background: isDone ? 'var(--accent)' : 'var(--border)', margin: '3px 0' }} />
|
||||
)}
|
||||
</div>
|
||||
<div style={{ paddingTop: 2, paddingBottom: i < stages.length - 1 ? 0 : 0 }}>
|
||||
<div style={{ fontSize: 12, fontWeight: 500, color: isDone || isCurrent ? 'var(--text-primary)' : 'var(--text-muted)' }}>{stage.label}</div>
|
||||
<div style={{ fontSize: 11, color: 'var(--text-muted)', marginTop: 1, marginBottom: 8 }}>{stage.date || '—'}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AssigneePortrait({ name, avatarUrl }) {
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
const fmtSize = (bytes) => {
|
||||
if (!bytes) return '';
|
||||
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
|
||||
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
|
||||
};
|
||||
|
||||
const FILE_EXT_COLOR = { pdf: '#ef4444', jpg: '#f59e0b', jpeg: '#f59e0b', png: '#3b82f6', gif: '#8b5cf6', svg: '#10b981', ai: '#f97316', psd: '#3b82f6', zip: '#6b7280', rar: '#6b7280', doc: '#2563eb', docx: '#2563eb', xls: '#16a34a', xlsx: '#16a34a', mp4: '#ec4899', mov: '#ec4899' };
|
||||
const getExt = (name = '') => (name.split('.').pop() || '').toLowerCase();
|
||||
|
||||
function SubmissionFiles({ files, downloading, onDownloadAll }) {
|
||||
if (!files || files.length === 0) return null;
|
||||
return (
|
||||
<div style={{ marginTop: 14 }}>
|
||||
<div style={{ marginBottom: 8 }}>
|
||||
<div style={META_LABEL}>Attached Files ({files.length})</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
|
||||
{files.map((file) => {
|
||||
const ext = getExt(file.name);
|
||||
const color = FILE_EXT_COLOR[ext] || 'var(--text-muted)';
|
||||
const label = ext ? ext.toUpperCase() : '—';
|
||||
return (
|
||||
<div key={file.id} title={`${file.name}${file.size ? ' · ' + fmtSize(file.size) : ''}`} style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 3, width: 40 }}>
|
||||
<div style={{ width: 32, height: 38, borderRadius: 5, background: 'var(--card-bg-2)', border: '1px solid var(--border)', display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 1 }}>
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke={color} strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>
|
||||
<span style={{ fontSize: 6, fontWeight: 700, color, letterSpacing: 0.3 }}>{label}</span>
|
||||
</div>
|
||||
<div style={{ fontSize: 9, color: 'var(--text-muted)', width: '100%', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', textAlign: 'center' }}>{file.name.replace(/\.[^.]+$/, '')}</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<button
|
||||
className="btn btn-outline"
|
||||
disabled={Boolean(downloading)}
|
||||
onClick={() => onDownloadAll(files)}
|
||||
style={{ marginTop: 10, fontSize: 10, padding: '2px 10px', letterSpacing: 0.6, textTransform: 'uppercase' }}
|
||||
>
|
||||
{downloading === 'all' ? 'Downloading…' : 'Download All'}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function TaskDetail() {
|
||||
const { id } = useParams();
|
||||
const { currentUser } = useAuth();
|
||||
const [task, setTask] = useState(null);
|
||||
const [submissions, setSubmissions] = useState([]);
|
||||
const [activityLog, setActivityLog] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [activeTab, setActiveTab] = useState('Overview');
|
||||
const [statusSaving, setStatusSaving] = useState(false);
|
||||
const [downloading, setDownloading] = useState('');
|
||||
const [revisionModal, setRevisionModal] = useState(false);
|
||||
const [revisionForm, setRevisionForm] = useState({ description: '', deadline: '' });
|
||||
const [revisionFiles, setRevisionFiles] = useState([]);
|
||||
const [revisionSaving, setRevisionSaving] = useState(false);
|
||||
const revisionFileRef = useRef(null);
|
||||
const [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 [comments, setComments] = useState([]);
|
||||
const [commentBody, setCommentBody] = useState('');
|
||||
const [commentSaving, setCommentSaving] = useState(false);
|
||||
const [dlProgress, setDlProgress] = useState({ active: false, label: '', percent: 0 });
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([
|
||||
supabase
|
||||
.from('tasks')
|
||||
.select('*, project:projects(id, name, company:companies(id, name)), assignee:profiles!assigned_to(id, name, avatar_url)')
|
||||
.eq('id', id)
|
||||
.single(),
|
||||
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 }),
|
||||
supabase
|
||||
.from('activity_log')
|
||||
.select('id, created_at, actor_id, actor_name, action, task_id, task_title')
|
||||
.eq('task_id', id)
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(20),
|
||||
supabase
|
||||
.from('task_comments')
|
||||
.select('id, author_id, author_name, body, created_at')
|
||||
.eq('task_id', id)
|
||||
.order('created_at', { ascending: true }),
|
||||
]).then(([{ data: taskData }, { data: subRows }, { data: actData }, { data: commentData }]) => {
|
||||
setTask(taskData);
|
||||
setSubmissions(subRows || []);
|
||||
setActivityLog(actData || []);
|
||||
setComments(commentData || []);
|
||||
setLoading(false);
|
||||
});
|
||||
}, [id]);
|
||||
|
||||
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 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 role = currentUser?.role;
|
||||
const isClient = role === 'client';
|
||||
const isTeamOrSub = role === 'team' || role === 'external';
|
||||
const status = task.status;
|
||||
|
||||
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);
|
||||
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 || []);
|
||||
}
|
||||
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 handleResume = () => updateStatus('in_progress', {}, 'task_resumed');
|
||||
const handleSendToReview = () => updateStatus('client_review', {}, 'task_submitted');
|
||||
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 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();
|
||||
if (newSub && revisionFiles.length > 0) {
|
||||
const uploadedFiles = [];
|
||||
for (const file of revisionFiles) {
|
||||
const path = `${id}/${newVersion}/${Date.now()}_${file.name}`;
|
||||
const { data: uploaded } = await supabase.storage.from('submissions').upload(path, file);
|
||||
if (uploaded) uploadedFiles.push({ name: file.name, storage_path: path, size: file.size });
|
||||
}
|
||||
if (uploadedFiles.length > 0) await supabase.from('submission_files').insert(uploadedFiles.map(f => ({ ...f, submission_id: newSub.id })));
|
||||
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([
|
||||
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 }),
|
||||
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 || []);
|
||||
setRevisionModal(false);
|
||||
setRevisionForm({ description: '', deadline: '' });
|
||||
setRevisionFiles([]);
|
||||
setRevisionSaving(false);
|
||||
};
|
||||
|
||||
const handleSubmitAmendment = async () => {
|
||||
if (!amendForm.description.trim()) return;
|
||||
setAmendSaving(true);
|
||||
setAmendModal(false);
|
||||
const baseline = Math.max(task.current_version || 0, ...submissions.map(s => s.version_number || 0));
|
||||
const { data: newSub } = await supabase.from('submissions').insert({ task_id: id, version_number: baseline, type: 'amendment', service_type: task.title, description: amendForm.description, submitted_by: currentUser.id, submitted_by_name: currentUser.name }).select().single();
|
||||
if (newSub && amendFiles.length > 0) {
|
||||
setDlProgress({ active: true, label: 'Uploading files…', percent: 0 });
|
||||
const uploaded = [];
|
||||
for (let i = 0; i < amendFiles.length; i++) {
|
||||
const file = amendFiles[i];
|
||||
setDlProgress({ active: true, label: file.name, percent: Math.round((i / amendFiles.length) * 100) });
|
||||
const path = `${id}/${Date.now()}_${file.name}`;
|
||||
const { data: up } = await supabase.storage.from('submissions').upload(path, file);
|
||||
if (up) uploaded.push({ name: file.name, storage_path: path, size: file.size });
|
||||
}
|
||||
if (uploaded.length > 0) await supabase.from('submission_files').insert(uploaded.map(f => ({ ...f, submission_id: newSub.id })));
|
||||
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 });
|
||||
setSubmissions(subRows || []);
|
||||
setAmendForm({ description: '' });
|
||||
setAmendFiles([]);
|
||||
setAmendSaving(false);
|
||||
};
|
||||
|
||||
const handlePostComment = async () => {
|
||||
if (!commentBody.trim()) return;
|
||||
setCommentSaving(true);
|
||||
const { data } = await supabase.from('task_comments').insert({ task_id: id, author_id: currentUser.id, author_name: currentUser.name, body: commentBody.trim() }).select().single();
|
||||
if (data) setComments(prev => [...prev, data]);
|
||||
setCommentBody('');
|
||||
setCommentSaving(false);
|
||||
};
|
||||
|
||||
const handleDeleteComment = async (commentId) => {
|
||||
await supabase.from('task_comments').delete().eq('id', commentId);
|
||||
setComments(prev => prev.filter(c => c.id !== commentId));
|
||||
};
|
||||
|
||||
const downloadAllFiles = async (files, label = 'files') => {
|
||||
if (downloading) return;
|
||||
setDownloading('all');
|
||||
setDlProgress({ active: true, label: 'Preparing…', percent: 0 });
|
||||
try {
|
||||
const zip = new JSZip();
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
const file = files[i];
|
||||
setDlProgress({ active: true, label: file.name, percent: Math.round((i / (files.length + 1)) * 100) });
|
||||
try {
|
||||
const { data } = await supabase.storage.from('submissions').createSignedUrl(file.storage_path, 3600, { download: file.name });
|
||||
if (data?.signedUrl) {
|
||||
const response = await fetch(data.signedUrl);
|
||||
if (response.ok) zip.file(file.name, await response.blob());
|
||||
}
|
||||
} catch { /* skip bad file */ }
|
||||
}
|
||||
setDlProgress({ active: true, label: 'Building zip…', percent: 95 });
|
||||
const content = await zip.generateAsync({ type: 'blob' });
|
||||
const zipName = `${task?.title || 'files'} ${label}.zip`.replace(/[^a-z0-9 ._-]/gi, '_');
|
||||
const a = document.createElement('a');
|
||||
a.href = URL.createObjectURL(content);
|
||||
a.download = zipName;
|
||||
a.click();
|
||||
URL.revokeObjectURL(a.href);
|
||||
} finally {
|
||||
setDlProgress({ active: false, label: '', percent: 0 });
|
||||
setDownloading('');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<div style={{ display: 'flex', gap: 24, alignItems: 'stretch', flex: 1, minHeight: 0, overflow: 'hidden' }}>
|
||||
<div style={{ flex: '0 0 70%', minWidth: 0, display: 'flex', flexDirection: 'column', gap: 24, minHeight: 0 }}>
|
||||
<div className="card" style={{ ...CARD, position: 'relative', ...(submission?.is_hot ? { background: 'radial-gradient(ellipse 90% 55% at 50% 120%, rgba(239,80,10,0.18) 0%, rgba(245,165,35,0.09) 45%, transparent 70%), var(--card-bg)' } : {}) }}>
|
||||
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 12, marginBottom: 8 }}>
|
||||
<div style={{ fontSize: 28, fontWeight: 500, color: 'var(--text-primary)', lineHeight: 1.2 }}>{task.title}</div>
|
||||
<div style={{ display: 'flex', gap: 8, flexShrink: 0 }}>
|
||||
{isTeamOrSub && status === 'not_started' && (
|
||||
<button className="btn btn-outline" style={{ fontSize: 11, padding: '4px 12px' }} disabled={statusSaving} onClick={handleStart}>Start Task</button>
|
||||
)}
|
||||
{isTeamOrSub && status === 'in_progress' && (
|
||||
<>
|
||||
<button className="btn btn-outline" style={{ fontSize: 11, padding: '4px 12px' }} disabled={statusSaving} onClick={handleOnHold}>Place on Hold</button>
|
||||
<button className="btn btn-outline" style={{ fontSize: 12, padding: '4px 12px', color: 'var(--accent)', borderColor: 'var(--accent)' }} disabled={statusSaving} onClick={handleSendToReview}>Place in Review</button>
|
||||
</>
|
||||
)}
|
||||
{isTeamOrSub && status === 'on_hold' && (
|
||||
<button className="btn btn-outline" style={{ fontSize: 11, padding: '4px 12px' }} disabled={statusSaving} onClick={handleResume}>Resume</button>
|
||||
)}
|
||||
{isClient && status === 'client_review' && (
|
||||
<>
|
||||
<button className="btn btn-outline" style={{ fontSize: 12, padding: '4px 12px', color: 'var(--accent)', borderColor: 'var(--accent)' }} disabled={statusSaving} onClick={handleClientApprove}>Approve</button>
|
||||
<button className="btn btn-danger" style={{ fontSize: 11, padding: '4px 12px' }} disabled={statusSaving} onClick={handleClientReject}>Reject</button>
|
||||
</>
|
||||
)}
|
||||
{isClient && ['client_approved', 'invoiced', 'paid'].includes(status) && (
|
||||
<button className="btn btn-outline" style={{ fontSize: 11, padding: '4px 12px' }} 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" style={{ background: 'rgba(245,165,35,0.12)', color: 'var(--accent)', border: '1px solid rgba(245,165,35,0.25)', minWidth: 'unset' }}>{submission.service_type}</span>
|
||||
)}
|
||||
<StatusBadge status={task.status || 'not_started'} />
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 30, flexWrap: 'wrap' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="var(--text-muted)" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" style={{ flexShrink: 0 }}><line x1="4" y1="9" x2="20" y2="9"/><line x1="4" y1="15" x2="20" y2="15"/><line x1="10" y1="3" x2="8" y2="21"/><line x1="16" y1="3" x2="14" y2="21"/></svg>
|
||||
<div>
|
||||
<div style={CARD_META_LABEL}>Task ID</div>
|
||||
<div style={{ fontSize: 13, color: 'var(--text-primary)', fontVariantNumeric: 'tabular-nums' }}>{task.task_number || task.id.slice(0, 8).toUpperCase()}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="var(--text-muted)" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" style={{ flexShrink: 0 }}><polyline points="17,1 21,5 17,9"/><path d="M3 11V9a4 4 0 0 1 4-4h14"/><polyline points="7,23 3,19 7,15"/><path d="M21 13v2a4 4 0 0 1-4 4H3"/></svg>
|
||||
<div>
|
||||
<div style={CARD_META_LABEL}>Revision</div>
|
||||
<div style={{ fontSize: 13, color: 'var(--text-primary)', fontVariantNumeric: 'tabular-nums' }}>{(task.current_version ?? 0) === 0 ? 'New' : String(task.current_version).padStart(2, '0')}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="var(--text-muted)" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" style={{ flexShrink: 0 }}><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>
|
||||
<div>
|
||||
<div style={CARD_META_LABEL}>Project</div>
|
||||
{project
|
||||
? <Link to={`/projects/${project.id}`} className="table-link" style={{ fontSize: 13 }}>{project.name}</Link>
|
||||
: <div style={{ fontSize: 13, color: 'var(--text-muted)' }}>—</div>}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="var(--text-muted)" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" style={{ flexShrink: 0 }}><rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>
|
||||
<div>
|
||||
<div style={CARD_META_LABEL}>Requested</div>
|
||||
<div style={{ fontSize: 13, color: 'var(--text-primary)' }}>
|
||||
{submission?.submitted_at || task.submitted_at
|
||||
? new Date(submission?.submitted_at || task.submitted_at).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
|
||||
: '—'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="var(--text-muted)" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" style={{ flexShrink: 0 }}><circle cx="12" cy="12" r="10"/><polyline points="12,6 12,12 16,14"/></svg>
|
||||
<div>
|
||||
<div style={CARD_META_LABEL}>Due Date</div>
|
||||
<div style={{ fontSize: 13, color: 'var(--text-primary)' }}>
|
||||
{submission?.deadline
|
||||
? new Date(submission.deadline).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
|
||||
: '—'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', flex: 1, minHeight: 0 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 4, marginBottom: 10 }}>
|
||||
{TABS.map(tab => {
|
||||
const count = tab === 'Revisions' ? revisions.length : tab === 'Comments' ? comments.length : 0;
|
||||
return (
|
||||
<button key={tab} onClick={() => setActiveTab(tab)} style={TAB_STYLE(activeTab === tab)}>
|
||||
{tab}{count > 0 && <span style={{ marginLeft: 5, fontSize: 12, fontWeight: 600, color: activeTab === tab ? 'var(--accent)' : 'var(--text-muted)', background: 'var(--card-bg-2)', border: '1px solid var(--border)', borderRadius: 10, padding: '3px 8px' }}>{count}</span>}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="card" style={{ ...CARD, flex: 1, minHeight: 0, overflowY: 'auto' }}>
|
||||
<div style={{ fontSize: 13 }}>
|
||||
{activeTab === 'Overview' && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 20 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 32 }}>
|
||||
<div>
|
||||
<div style={META_LABEL}>Requested By</div>
|
||||
<div style={{ fontSize: 13, color: 'var(--text-primary)' }}>{requester || '—'}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style={META_LABEL}>Requested</div>
|
||||
<div style={{ fontSize: 13, color: 'var(--text-primary)' }}>
|
||||
{submission?.submitted_at ? new Date(submission.submitted_at).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) : '—'}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style={META_LABEL}>Sign Count</div>
|
||||
<div style={{ fontSize: 13, color: 'var(--text-primary)' }}>{submission?.sign_count > 0 ? submission.sign_count : 'N/A'}</div>
|
||||
</div>
|
||||
{isClient && ['not_started', 'in_progress'].includes(status) && (
|
||||
<div style={{ marginLeft: 'auto' }}>
|
||||
<button className="btn btn-outline" style={{ fontSize: 11, padding: '4px 12px' }} onClick={() => setAmendModal(true)}>Amend Request</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{submission?.description && (
|
||||
<div>
|
||||
<div style={META_LABEL}>Notes</div>
|
||||
<div style={{ fontSize: 13, color: 'var(--text-primary)', lineHeight: 1.6, whiteSpace: 'pre-wrap' }}>{submission.description}</div>
|
||||
</div>
|
||||
)}
|
||||
{submission?.sign_family && (
|
||||
<div>
|
||||
<div style={META_LABEL}>Sign Family</div>
|
||||
<div style={{ fontSize: 13, color: 'var(--text-primary)' }}>{submission.sign_family}</div>
|
||||
</div>
|
||||
)}
|
||||
<SubmissionFiles files={submission?.files} downloading={downloading} onDownloadAll={downloadAllFiles} />
|
||||
{amendments.length > 0 && (
|
||||
<div style={{ borderTop: '1px solid var(--border)', paddingTop: 20, display: 'flex', flexDirection: 'column', gap: 20 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<span style={{ fontSize: 11, fontWeight: 600, letterSpacing: 0.8, color: 'var(--text-secondary)', background: 'var(--card-bg-2)', border: '1px solid var(--border)', borderRadius: 4, padding: '2px 8px', textTransform: 'uppercase' }}>Amendment</span>
|
||||
</div>
|
||||
{amendments.map((am) => (
|
||||
<div key={am.id} style={{ display: 'flex', flexDirection: 'column', gap: 20 }}>
|
||||
{am.description && (
|
||||
<div>
|
||||
<div style={META_LABEL}>Notes</div>
|
||||
<div style={{ fontSize: 13, color: 'var(--text-primary)', lineHeight: 1.6, whiteSpace: 'pre-wrap' }}>{am.description}</div>
|
||||
</div>
|
||||
)}
|
||||
<SubmissionFiles files={am.files} downloading={downloading} onDownloadAll={downloadAllFiles} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{activeTab === 'Revisions' && (
|
||||
revisions.length === 0
|
||||
? <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', flex: 1, minHeight: 120, color: 'var(--text-muted)' }}>No revisions yet.</div>
|
||||
: <div style={{ display: 'flex', flexDirection: 'column', gap: 20 }}>
|
||||
{[...revisions].reverse().map((rev, i, arr) => {
|
||||
const rNum = `R${String(rev.version_number).padStart(2, '0')}`;
|
||||
const fmtDate = rev.submitted_at
|
||||
? new Date(rev.submitted_at).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
|
||||
: '—';
|
||||
return (
|
||||
<div key={rev.id} style={{ display: 'flex', flexDirection: 'column', gap: 20, borderBottom: i < arr.length - 1 ? '1px solid var(--border)' : 'none', paddingBottom: i < arr.length - 1 ? 20 : 0 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 32 }}>
|
||||
<div>
|
||||
<div style={META_LABEL}>Requested By</div>
|
||||
<div style={{ fontSize: 13, color: 'var(--text-primary)' }}>{rev.submitted_by_name || '—'}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style={META_LABEL}>Requested</div>
|
||||
<div style={{ fontSize: 13, color: 'var(--text-primary)' }}>{fmtDate}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style={META_LABEL}>Sign Count</div>
|
||||
<div style={{ fontSize: 13, color: 'var(--text-primary)' }}>{rev.sign_count > 0 ? rev.sign_count : 'N/A'}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style={META_LABEL}>Revision</div>
|
||||
<div style={{ fontSize: 13, color: 'var(--text-primary)' }}>{rNum}</div>
|
||||
</div>
|
||||
{isClient && i === 0 && ['not_started', 'in_progress'].includes(status) && (
|
||||
<div style={{ marginLeft: 'auto' }}>
|
||||
<button className="btn btn-outline" style={{ fontSize: 11, padding: '4px 12px' }} onClick={() => setAmendModal(true)}>Amend</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{rev.description && (
|
||||
<div>
|
||||
<div style={META_LABEL}>Description</div>
|
||||
<div style={{ fontSize: 13, color: 'var(--text-primary)', lineHeight: 1.6, whiteSpace: 'pre-wrap' }}>{rev.description}</div>
|
||||
</div>
|
||||
)}
|
||||
{rev.sign_family && (
|
||||
<div>
|
||||
<div style={META_LABEL}>Sign Family</div>
|
||||
<div style={{ fontSize: 13, color: 'var(--text-primary)' }}>{rev.sign_family}</div>
|
||||
</div>
|
||||
)}
|
||||
{!rev.description && !rev.sign_family && (
|
||||
<div style={{ fontSize: 13, color: 'var(--text-muted)' }}>No notes provided.</div>
|
||||
)}
|
||||
<SubmissionFiles files={rev.files} downloading={downloading} onDownloadAll={(f) => downloadAllFiles(f, rNum)} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{activeTab === 'Comments' && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 0 }}>
|
||||
<div style={{ display: 'flex', gap: 8, marginBottom: 20 }}>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Write a comment…"
|
||||
value={commentBody}
|
||||
onChange={e => setCommentBody(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter') handlePostComment(); }}
|
||||
style={{ flex: 1, fontSize: 13, color: 'var(--text-primary)', background: 'var(--card-bg-2)', border: '1px solid var(--border)', borderRadius: 6, padding: '6px 10px', fontFamily: 'inherit', height: 30 }}
|
||||
/>
|
||||
<button className="btn btn-outline" disabled={commentSaving || !commentBody.trim()} onClick={handlePostComment} style={{ fontSize: 11, padding: '0 12px', alignSelf: 'flex-end', height: 30 }}>Post</button>
|
||||
</div>
|
||||
{comments.length === 0
|
||||
? <div style={{ color: 'var(--text-muted)', fontSize: 13 }}>No comments yet.</div>
|
||||
: comments.map((c, i) => {
|
||||
const d = new Date(c.created_at);
|
||||
const dateStr = d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
|
||||
const timeStr = d.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit', hour12: true });
|
||||
const isOwn = c.author_id === currentUser?.id;
|
||||
return (
|
||||
<div key={c.id} style={{ borderTop: '1px solid var(--border)', paddingTop: 20, paddingBottom: 20 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 6 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<span style={{ fontSize: 13, fontWeight: 500, color: 'var(--text-primary)' }}>{c.author_name}</span>
|
||||
<span style={{ fontSize: 11, color: 'var(--text-muted)' }}>{dateStr} · {timeStr}</span>
|
||||
</div>
|
||||
{isOwn && (
|
||||
<button className="btn btn-danger" onClick={() => handleDeleteComment(c.id)} style={{ fontSize: 11, padding: '0 10px', height: 26 }}>Delete</button>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ fontSize: 13, color: 'var(--text-primary)', lineHeight: 1.6, whiteSpace: 'pre-wrap' }}>{c.body}</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
}
|
||||
</div>
|
||||
)}
|
||||
{activeTab === 'Folder' && <div style={{ color: 'var(--text-muted)' }}>Folder view coming soon.</div>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ flex: '0 0 calc(30% - 24px)', minWidth: 0, display: 'flex', flexDirection: 'column', gap: 24, minHeight: 0 }}>
|
||||
<div className="card" style={CARD}>
|
||||
<div style={LABEL}>Assigned To</div>
|
||||
{assigneeName ? (
|
||||
<Link to={`/profile/${assignee?.id}`} className="dashboard-inline-link" style={{ display: 'flex', alignItems: 'center', gap: 10, textDecoration: 'none' }}>
|
||||
<AssigneePortrait name={assigneeName} avatarUrl={assigneeAvatar} />
|
||||
<span style={{ fontSize: 13, fontWeight: 500 }}>{assigneeName}</span>
|
||||
</Link>
|
||||
) : (
|
||||
<div style={{ fontSize: 13, color: 'var(--text-muted)' }}>Unassigned</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<TimelineCard task={task} activityLog={activityLog} currentSubmission={submission} />
|
||||
|
||||
<div className="card" style={{ ...CARD, flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column' }}>
|
||||
<div style={LABEL}>Activity</div>
|
||||
{activityLog.length === 0 ? (
|
||||
<div style={{ fontSize: 13, color: 'var(--text-muted)' }}>No activity yet.</div>
|
||||
) : (
|
||||
<div className="scrollbar-thin-theme" style={{ flex: 1, minHeight: 0, overflowY: 'auto', display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||
{activityLog.map(e => (
|
||||
<ActivityItem key={e.id} e={e} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{revisionModal && (
|
||||
<div style={{ position: 'fixed', inset: 0, background: 'var(--overlay-scrim, rgba(0,0,0,0.58))', zIndex: 1200, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 24 }}>
|
||||
<div className="card" style={{ ...CARD, width: '100%', maxWidth: 480, display: 'flex', flexDirection: 'column', gap: 18 }}>
|
||||
<div style={{ fontSize: 18, fontWeight: 500, color: 'var(--text-primary)', letterSpacing: 0.2, lineHeight: 1.1 }}>Request Revision</div>
|
||||
<div className="form-group">
|
||||
<label style={{ fontSize: 11, fontWeight: 500, textTransform: 'uppercase', letterSpacing: 0.8, color: 'var(--text-secondary)' }}>Revision Notes</label>
|
||||
<textarea
|
||||
rows={5}
|
||||
placeholder="Describe what needs to be changed…"
|
||||
value={revisionForm.description}
|
||||
onChange={e => setRevisionForm(f => ({ ...f, description: e.target.value }))}
|
||||
style={{ width: '100%', fontSize: 13, color: 'var(--text-primary)', background: 'var(--card-bg-2)', border: '1px solid var(--border)', borderRadius: 6, padding: '8px 10px', resize: 'vertical', fontFamily: 'inherit' }}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label style={{ fontSize: 11, fontWeight: 500, textTransform: 'uppercase', letterSpacing: 0.8, color: 'var(--text-secondary)' }}>Deadline</label>
|
||||
<input
|
||||
type="date"
|
||||
value={revisionForm.deadline}
|
||||
onChange={e => setRevisionForm(f => ({ ...f, deadline: e.target.value }))}
|
||||
style={{ fontSize: 13, color: 'var(--text-primary)', background: 'var(--card-bg-2)', border: '1px solid var(--border)', borderRadius: 6, padding: '6px 10px', width: '100%' }}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label style={{ fontSize: 11, fontWeight: 500, textTransform: 'uppercase', letterSpacing: 0.8, color: 'var(--text-secondary)', display: 'block', marginBottom: 6 }}>Attach Files</label>
|
||||
<input ref={revisionFileRef} type="file" multiple style={{ display: 'none' }} onChange={e => setRevisionFiles(prev => [...prev, ...Array.from(e.target.files)])} />
|
||||
<button type="button" className="btn btn-outline" style={{ fontSize: 11, padding: '4px 12px' }} onClick={() => revisionFileRef.current?.click()}>+ Add Files</button>
|
||||
{revisionFiles.length > 0 && (
|
||||
<div style={{ marginTop: 8, display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||
{revisionFiles.map((f, i) => (
|
||||
<div key={i} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', fontSize: 12, color: 'var(--text-secondary)' }}>
|
||||
<span>{f.name}</span>
|
||||
<button type="button" onClick={() => setRevisionFiles(prev => prev.filter((_, idx) => idx !== i))} style={{ background: 'none', border: 'none', color: 'var(--danger)', cursor: 'pointer', fontSize: 14, padding: '0 4px' }}>✕</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
|
||||
<button className="btn btn-outline" style={{ fontSize: 11, padding: '4px 12px' }} disabled={revisionSaving} onClick={() => { setRevisionModal(false); setRevisionForm({ description: '', deadline: '' }); setRevisionFiles([]); }}>Cancel</button>
|
||||
<button className="btn btn-outline" style={{ fontSize: 11, padding: '4px 12px', color: 'var(--accent)', borderColor: 'var(--accent)' }} disabled={revisionSaving || !revisionForm.description.trim()} onClick={handleSubmitRevision}>
|
||||
{revisionSaving ? 'Submitting…' : 'Submit Revision'}
|
||||
</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()}>
|
||||
<div style={{ fontSize: 11, fontWeight: 500, textTransform: 'uppercase', letterSpacing: 0.8, color: 'var(--text-secondary)', lineHeight: 1.1 }}>Amend Request</div>
|
||||
<div>
|
||||
<div style={{ fontSize: 11, fontWeight: 500, textTransform: 'uppercase', letterSpacing: 0.8, color: 'var(--text-secondary)', marginBottom: 6 }}>Notes</div>
|
||||
<textarea rows={4} placeholder="Describe what you'd like to add or change…" value={amendForm.description} onChange={e => setAmendForm(f => ({ ...f, description: e.target.value }))} style={{ width: '100%', fontSize: 13, color: 'var(--text-primary)', background: 'var(--card-bg-2)', border: '1px solid var(--border)', borderRadius: 6, padding: '8px 10px', resize: 'vertical', fontFamily: 'inherit', boxSizing: 'border-box' }} />
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontSize: 11, fontWeight: 500, textTransform: 'uppercase', letterSpacing: 0.8, color: 'var(--text-secondary)', marginBottom: 6 }}>Attach Files</div>
|
||||
<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))} style={{ fontSize: 10, padding: '1px 8px', height: 22 }}>Remove</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{dlProgress.active && (
|
||||
<div style={{ position: 'fixed', inset: 0, background: 'var(--overlay-scrim, rgba(0,0,0,0.58))', zIndex: 1200, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 24 }}>
|
||||
<div className="card" style={{ ...CARD, width: '100%', maxWidth: 340, display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
<div style={{ fontSize: 11, fontWeight: 500, textTransform: 'uppercase', letterSpacing: 0.8, color: 'var(--text-secondary)' }}>Downloading</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-secondary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{dlProgress.label}</div>
|
||||
<div style={{ height: 4, borderRadius: 2, background: 'var(--card-bg-2)', overflow: 'hidden' }}>
|
||||
<div style={{ height: '100%', borderRadius: 2, background: 'var(--accent)', width: `${dlProgress.percent}%`, transition: 'width 200ms ease' }} />
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)', textAlign: 'right' }}>{dlProgress.percent}%</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user