c91e292066
Design system (Layout2): - Solid token-driven theme (no animated grid); single-source tokens for bg, cards (bg/border/radius), popups, fonts (Inter), and full semantic + data-viz color palette. Swept ~168 hardcoded colors to tokens. Dashboard: - Reflow: To Do + Calendar row (fixed right rail) over Activity / In-Progress / Team Performance; CSS height-match (To Do = Calendar); responsive stacking. - 'Tasks Not Started' -> 'To Do' card with R#/Assigned columns; compact money fmt. Tasks page: - Combined tabs into Tasks + Completed with a Status column. - Column-header sort + filter (Status, R#/new-vs-revision, Project, Task Type, Company) via portaled, scrollable FilterDropdown; removed toolbar filters and the projects side panel; headers stay visible when empty; renamed to 'Tasks'. Perf: - FK/filter index migration; removed redundant client/external scope waterfall (rely on RLS); parallel follow-up queries. Mobile: - Responsive grids; mobile topbar = hamburger only, blends with bg, proper offset. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1151 lines
68 KiB
React
1151 lines
68 KiB
React
import { useState, useEffect, useRef } from 'react';
|
|
import { useParams, Link } from 'react-router-dom';
|
|
import Layout from '../components/Layout';
|
|
import PageLoader from '../components/PageLoader';
|
|
import ProfileAvatar from '../components/ProfileAvatar';
|
|
import StatusBadge from '../components/StatusBadge';
|
|
import { useAuth } from '../context/AuthContext';
|
|
import { supabase } from '../lib/supabase';
|
|
import { logActivity } from '../lib/activityLog';
|
|
import JSZip from 'jszip';
|
|
import { popupOverlayStyle, popupSurfaceStyle } from '../lib/popupStyles';
|
|
import { useLiveRefresh } from '../hooks/useLiveRefresh';
|
|
import { useActionLock } from '../hooks/useActionLock';
|
|
import FileAttachment from '../components/FileAttachment';
|
|
import { sendTaskStatusUpdate } from '../lib/taskNotifications';
|
|
import { encodeRejectedNote, parseRejectedNote, isReviewShadowSubmission, REVIEW_SHADOW_PREFIX, getVisibleTaskSubmissions, getTaskDerivedState } from '../lib/taskVersions';
|
|
import { getCurrentVersionForTask } from '../lib/taskDeadlines';
|
|
import { getSubmissionDisplayName, mergeSubmissionDisplayNames } from '../lib/submissionDisplay';
|
|
|
|
const ACTION_LABEL = {
|
|
task_created: 'created this task', task_started: 'started this task', task_on_hold: 'placed task on hold',
|
|
task_resumed: 'resumed this task', task_submitted: 'submitted this task', task_approved: 'approved this task',
|
|
revision_requested: 'rejected this task', request_submitted: 'submitted this request',
|
|
task_released: 'released this task',
|
|
};
|
|
|
|
const ACTION_ICON = {
|
|
task_started: { bg: 'color-mix(in srgb, var(--info) 15%, transparent)', color: 'var(--info)', path: 'M6 4l14 8-14 8V4z' },
|
|
task_resumed: { bg: 'color-mix(in srgb, var(--info) 15%, transparent)', color: 'var(--info)', path: 'M6 4l14 8-14 8V4z' },
|
|
task_submitted: { bg: 'color-mix(in srgb, var(--positive) 15%, transparent)', color: 'var(--positive)', path: 'M12 19V5M5 12l7-7 7 7' },
|
|
task_approved: { bg: 'color-mix(in srgb, var(--positive) 15%, transparent)', color: 'var(--positive)', path: 'M4 13l5 5L20 7' },
|
|
task_on_hold: { bg: 'color-mix(in srgb, var(--danger) 15%, transparent)', color: 'var(--danger)', path: 'M6 4h4v16H6zM14 4h4v16h-4z' },
|
|
task_released: { bg: 'color-mix(in srgb, var(--danger) 15%, transparent)', color: 'var(--danger)', path: 'M12 5v14M5 12h14' },
|
|
task_created: { bg: 'color-mix(in srgb, var(--violet) 15%, transparent)', color: 'var(--violet)', path: 'M12 5v14M5 12h14' },
|
|
revision_requested: { bg: 'color-mix(in srgb, var(--warning) 15%, transparent)', color: 'var(--warning)', path: 'M4 12a8 8 0 018-8v0a8 8 0 018 8' },
|
|
request_submitted: { bg: 'color-mix(in srgb, var(--violet) 15%, transparent)', color: 'var(--violet)', path: 'M9 12h6M9 8h6M5 3h14a2 2 0 012 2v14a2 2 0 01-2 2H5a2 2 0 01-2-2V5a2 2 0 012-2z' },
|
|
};
|
|
|
|
function ActivityItem({ e }) {
|
|
const icon = ACTION_ICON[e.action] || { bg: 'rgba(255,255,255,0.08)', color: 'rgba(255,255,255,0.4)', path: null };
|
|
const label = ACTION_LABEL[e.action] || e.action;
|
|
const actor = e.actor_name || 'Fourge';
|
|
const d = new Date(e.created_at);
|
|
const dateStr = d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
|
|
const timeStr = d.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit', hour12: true });
|
|
return (
|
|
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 10 }}>
|
|
<div style={{ width: 27, height: 27, borderRadius: '50%', background: icon.bg, display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0, marginTop: 1 }}>
|
|
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke={icon.color} strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
|
{icon.path && <path d={icon.path} />}
|
|
</svg>
|
|
</div>
|
|
<div style={{ flex: 1, minWidth: 0 }}>
|
|
<div style={{ fontSize: 13, lineHeight: 1.4 }}>
|
|
<span style={{ color: 'var(--text-primary)', fontWeight: 500 }}>{actor}</span>
|
|
<span style={{ color: 'var(--text-muted)' }}> {label}</span>
|
|
</div>
|
|
<div style={{ fontSize: 11, color: 'var(--text-muted)', marginTop: 2 }}>{dateStr} · {timeStr}</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const CARD = { padding: '18px 21px', borderRadius: 8 };
|
|
const LABEL = { fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase', marginBottom: 14 };
|
|
const META_LABEL = { fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase', marginBottom: 4 };
|
|
const CARD_META_LABEL = { fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase', marginBottom: 2 };
|
|
const MODAL_ACTION_ROW = { display: 'flex', gap: 8, justifyContent: 'flex-end', paddingTop: 4, flexWrap: 'nowrap' };
|
|
const MODAL_ACTION_BUTTON = { width: 132, justifyContent: 'center', flex: '0 0 132px' };
|
|
|
|
const TABS = ['Overview', 'Revisions', 'Submissions', 'Comments'];
|
|
function RejectedNoteBlock({ note }) {
|
|
if (!note?.body) return null;
|
|
const d = note.created_at ? new Date(note.created_at) : null;
|
|
const dateStr = d ? d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) : '—';
|
|
return (
|
|
<div style={{ padding: '12px 14px', borderRadius: 8, border: '1px solid color-mix(in srgb, var(--danger) 30%, var(--border))', background: 'color-mix(in srgb, var(--danger) 8%, var(--card-bg-2))' }}>
|
|
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 12, marginBottom: 6 }}>
|
|
<div style={{ fontSize: 11, fontWeight: 500, color: 'var(--danger)', letterSpacing: 0.8, textTransform: 'uppercase' }}>Rejected Notes</div>
|
|
<div style={{ fontSize: 11, color: 'var(--text-muted)', flexShrink: 0 }}>{dateStr}</div>
|
|
</div>
|
|
<div style={{ fontSize: 13, color: 'var(--text-primary)', lineHeight: 1.6, whiteSpace: 'pre-wrap' }}>{note.body}</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function TimelineCard({ task, activityLog, currentSubmission }) {
|
|
const status = task?.status;
|
|
let currentStage;
|
|
if (['client_approved', 'invoiced', 'paid'].includes(status)) currentStage = 4;
|
|
else if (status === 'client_review') currentStage = 2;
|
|
else if (status === 'in_progress' || status === 'on_hold') currentStage = 1;
|
|
else currentStage = 0;
|
|
|
|
const fmtDate = (d) => d ? new Date(d).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) : null;
|
|
|
|
// Only look at activity from the current revision cycle
|
|
const lastRevision = activityLog.find(e => e.action === 'revision_requested');
|
|
const cutoff = lastRevision ? new Date(lastRevision.created_at).getTime() : 0;
|
|
const currentLog = activityLog.filter(e => new Date(e.created_at).getTime() > cutoff);
|
|
|
|
const startedEntry = [...currentLog].reverse().find(e => e.action === 'task_started');
|
|
const reviewEntry = [...currentLog].reverse().find(e => e.action === 'task_submitted');
|
|
|
|
const stages = [
|
|
{ label: 'Task Created', date: fmtDate(currentSubmission?.submitted_at || task?.submitted_at) },
|
|
{ label: 'In Progress', date: fmtDate(startedEntry?.created_at) },
|
|
{ label: 'In Review', date: fmtDate(reviewEntry?.created_at) },
|
|
{ label: 'Approved', date: fmtDate(task?.completed_at) },
|
|
];
|
|
|
|
return (
|
|
<div className="card" style={CARD}>
|
|
<div style={LABEL}>Timeline</div>
|
|
<div style={{ display: 'flex', flexDirection: 'column' }}>
|
|
{stages.map((stage, i) => {
|
|
const isDone = i < currentStage;
|
|
const isCurrent = i === currentStage;
|
|
let circle;
|
|
if (isDone) {
|
|
circle = (
|
|
<div style={{ width: 22, height: 22, borderRadius: '50%', background: 'var(--accent)', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
|
|
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="var(--text-on-accent)" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"><polyline points="4,13 9,18 20,7"/></svg>
|
|
</div>
|
|
);
|
|
} else if (isCurrent) {
|
|
circle = (
|
|
<div style={{ width: 22, height: 22, borderRadius: '50%', border: '2px solid var(--accent)', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
|
|
<div style={{ width: 8, height: 8, borderRadius: '50%', background: 'var(--accent)' }} />
|
|
</div>
|
|
);
|
|
} else {
|
|
circle = <div style={{ width: 22, height: 22, borderRadius: '50%', border: '2px solid var(--border)', flexShrink: 0 }} />;
|
|
}
|
|
return (
|
|
<div key={i} style={{ display: 'flex', gap: 12, alignItems: 'flex-start' }}>
|
|
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
|
|
{circle}
|
|
{i < stages.length - 1 && (
|
|
<div style={{ width: 1, height: 26, background: isDone ? 'var(--accent)' : 'var(--border)', margin: '3px 0' }} />
|
|
)}
|
|
</div>
|
|
<div style={{ paddingTop: 2, paddingBottom: i < stages.length - 1 ? 0 : 0 }}>
|
|
<div style={{ fontSize: 12, fontWeight: 500, color: isDone || isCurrent ? 'var(--text-primary)' : 'var(--text-muted)' }}>{stage.label}</div>
|
|
<div style={{ fontSize: 11, color: 'var(--text-muted)', marginTop: 1, marginBottom: 8 }}>{stage.date || '—'}</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function AssigneePortrait({ name, avatarUrl }) {
|
|
return <ProfileAvatar name={name} avatarUrl={avatarUrl} size={32} fontSize={12} />;
|
|
}
|
|
|
|
const fmtSize = (bytes) => {
|
|
if (!bytes) return '';
|
|
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
|
|
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
|
|
};
|
|
|
|
const FILE_EXT_COLOR = { pdf: 'var(--danger)', jpg: 'var(--warning)', jpeg: 'var(--warning)', png: 'var(--data-blue)', gif: 'var(--data-purple)', svg: 'var(--data-emerald)', ai: 'var(--data-orange)', psd: 'var(--data-blue)', zip: 'var(--data-gray)', rar: 'var(--data-gray)', doc: 'var(--data-indigo)', docx: 'var(--data-indigo)', xls: 'var(--success-strong)', xlsx: 'var(--success-strong)', mp4: 'var(--data-pink)', mov: 'var(--data-pink)' };
|
|
const getExt = (name = '') => (name.split('.').pop() || '').toLowerCase();
|
|
|
|
function SubmissionFiles({ files, downloading, onDownloadAll }) {
|
|
if (!files || files.length === 0) return null;
|
|
return (
|
|
<div style={{ marginTop: 14 }}>
|
|
<div style={{ marginBottom: 8 }}>
|
|
<div style={META_LABEL}>Attached Files ({files.length})</div>
|
|
</div>
|
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
|
|
{files.map((file) => {
|
|
const ext = getExt(file.name);
|
|
const color = FILE_EXT_COLOR[ext] || 'var(--text-muted)';
|
|
const label = ext ? ext.toUpperCase() : '—';
|
|
return (
|
|
<div key={file.id} title={`${file.name}${file.size ? ' · ' + fmtSize(file.size) : ''}`} style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 3, width: 40 }}>
|
|
<div style={{ width: 32, height: 38, borderRadius: 5, background: 'var(--card-bg-2)', border: '1px solid var(--border)', display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 1 }}>
|
|
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke={color} strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>
|
|
<span style={{ fontSize: 6, fontWeight: 700, color, letterSpacing: 0.3 }}>{label}</span>
|
|
</div>
|
|
<div style={{ fontSize: 9, color: 'var(--text-muted)', width: '100%', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', textAlign: 'center' }}>{file.name.replace(/\.[^.]+$/, '')}</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
<button
|
|
className="btn btn-outline"
|
|
disabled={Boolean(downloading)}
|
|
onClick={() => onDownloadAll(files)}
|
|
style={{ marginTop: 10 }}
|
|
>
|
|
{downloading === 'all' ? 'Downloading…' : 'Download All'}
|
|
</button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function SubmissionSigns({ signs = [], signCount = null }) {
|
|
const normalizedCount = Number(signCount || 0);
|
|
if ((!signs || signs.length === 0) && normalizedCount <= 0) return null;
|
|
return (
|
|
<div>
|
|
<div style={META_LABEL}>Signs</div>
|
|
{signs && signs.length > 0 ? (
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
|
{signs
|
|
.slice()
|
|
.sort((a, b) => Number(a.sign_number || 0) - Number(b.sign_number || 0))
|
|
.map((sign) => (
|
|
<div key={sign.id || `${sign.sign_number}-${sign.sign_name}`} style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
|
<div style={{ fontSize: 13, color: 'var(--text-primary)', lineHeight: 1.5 }}>
|
|
<span style={{ color: 'var(--text-secondary)', marginRight: 8 }}>Sign {sign.sign_number || '—'}</span>
|
|
<span>{sign.sign_name || 'Unnamed sign'}</span>
|
|
</div>
|
|
{sign.sign_family ? (
|
|
<div style={{ fontSize: 12, color: 'var(--text-muted)', lineHeight: 1.4 }}>
|
|
{sign.sign_family}
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<div style={{ fontSize: 13, color: 'var(--text-primary)', lineHeight: 1.5 }}>
|
|
N/A
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function normalizeSubmissionSigns(submission) {
|
|
return (Array.isArray(submission?.signs) ? submission.signs : [])
|
|
.map((sign, index) => ({
|
|
...sign,
|
|
sign_number: Number(sign?.sign_number || index + 1),
|
|
sign_name: String(sign?.sign_name || '').trim(),
|
|
sign_family: String(sign?.sign_family || '').trim(),
|
|
}))
|
|
.filter(sign => sign.sign_name || sign.sign_family || Number.isFinite(sign.sign_number));
|
|
}
|
|
|
|
export default function TaskDetail() {
|
|
const { id } = useParams();
|
|
const { currentUser } = useAuth();
|
|
const { refreshKey } = useLiveRefresh(['tasks', 'submissions', 'activity_log', 'task_comments']);
|
|
const [task, setTask] = useState(null);
|
|
const [submissions, setSubmissions] = useState([]);
|
|
const [activityLog, setActivityLog] = useState([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [activeTab, setActiveTab] = useState('Overview');
|
|
const [statusSaving, setStatusSaving] = useState(false);
|
|
const guard = useActionLock();
|
|
const [downloading, setDownloading] = useState('');
|
|
const [revisionModal, setRevisionModal] = useState(false);
|
|
const [revisionForm, setRevisionForm] = useState({ description: '', deadline: '' });
|
|
const [revisionFiles, setRevisionFiles] = useState([]);
|
|
const [revisionSaving, setRevisionSaving] = useState(false);
|
|
const revisionFileRef = useRef(null);
|
|
const [rejectModal, setRejectModal] = useState(false);
|
|
const [rejectNote, setRejectNote] = useState('');
|
|
const [rejectSaving, setRejectSaving] = useState(false);
|
|
const [amendModal, setAmendModal] = useState(false);
|
|
const [amendForm, setAmendForm] = useState({ description: '' });
|
|
const [amendFiles, setAmendFiles] = useState([]);
|
|
const [amendSaving, setAmendSaving] = useState(false);
|
|
const [reviewModal, setReviewModal] = useState(false);
|
|
const [reviewFiles, setReviewFiles] = useState([]);
|
|
const [reviewNotes, setReviewNotes] = useState('');
|
|
const [reviewSaving, setReviewSaving] = useState(false);
|
|
const [comments, setComments] = useState([]);
|
|
const [commentBody, setCommentBody] = useState('');
|
|
const [commentSaving, setCommentSaving] = useState(false);
|
|
const [deliveries, setDeliveries] = useState([]);
|
|
const [dlProgress, setDlProgress] = useState({ active: false, label: '', percent: 0 });
|
|
const canonicalTaskServiceType = getTaskDerivedState(task, submissions, deliveries).serviceType;
|
|
|
|
const fetchTaskSubmissions = async (taskId) => {
|
|
const { data: subRows } = await supabase
|
|
.from('submissions')
|
|
.select('id, type, version_number, request_key, submitted_by, submitted_by_name, submitted_at, service_type, deadline, description, is_hot, sign_family, sign_count, signs, files:submission_files(id, name, storage_path, size)')
|
|
.eq('task_id', taskId)
|
|
.order('submitted_at', { ascending: true });
|
|
|
|
const submitterIds = [...new Set((subRows || []).map((submission) => submission.submitted_by).filter(Boolean))];
|
|
if (submitterIds.length === 0) {
|
|
return (subRows || []).map(submissionRow => ({ ...submissionRow, signs: normalizeSubmissionSigns(submissionRow) }));
|
|
}
|
|
|
|
const { data: submitterProfiles } = await supabase
|
|
.from('profiles')
|
|
.select('id, name')
|
|
.in('id', submitterIds);
|
|
|
|
return mergeSubmissionDisplayNames(
|
|
(subRows || []).map(submissionRow => ({ ...submissionRow, signs: normalizeSubmissionSigns(submissionRow) })),
|
|
submitterProfiles || []
|
|
);
|
|
};
|
|
|
|
const reloadTaskRow = async () => {
|
|
const { data } = await supabase
|
|
.from('tasks')
|
|
.select('*, project:projects(id, name, company:companies(id, name)), assignee:profiles!assigned_to(id, name, avatar_url)')
|
|
.eq('id', id)
|
|
.single();
|
|
if (data) setTask(data);
|
|
};
|
|
|
|
useEffect(() => {
|
|
Promise.all([
|
|
supabase
|
|
.from('tasks')
|
|
.select('*, project:projects(id, name, company:companies(id, name)), assignee:profiles!assigned_to(id, name, avatar_url)')
|
|
.eq('id', id)
|
|
.single(),
|
|
fetchTaskSubmissions(id),
|
|
supabase
|
|
.from('activity_log')
|
|
.select('id, created_at, actor_id, actor_name, action, task_id, task_title')
|
|
.eq('task_id', id)
|
|
.order('created_at', { ascending: false })
|
|
.limit(20),
|
|
supabase
|
|
.from('task_comments')
|
|
.select('id, author_id, author_name, body, created_at')
|
|
.eq('task_id', id)
|
|
.order('created_at', { ascending: true }),
|
|
]).then(async ([{ data: taskData }, subRows, { data: actData }, { data: commentData }]) => {
|
|
setTask(taskData);
|
|
setSubmissions(subRows || []);
|
|
setActivityLog(actData || []);
|
|
setComments(commentData || []);
|
|
const subIds = (subRows || []).map(s => s.id);
|
|
if (subIds.length > 0) {
|
|
const { data: delRows } = await supabase
|
|
.from('deliveries')
|
|
.select('*, files:delivery_files(id, name, storage_path, size)')
|
|
.in('submission_id', subIds)
|
|
.order('sent_at', { ascending: false });
|
|
setDeliveries(delRows || []);
|
|
}
|
|
setLoading(false);
|
|
});
|
|
}, [id, refreshKey]);
|
|
|
|
if (loading) return <Layout><PageLoader /></Layout>;
|
|
if (!task) return <Layout><p style={{ padding: 24 }}>Task not found.</p></Layout>;
|
|
|
|
const visibleSubmissions = getVisibleTaskSubmissions(submissions);
|
|
const submission = visibleSubmissions.find(s => s.type === 'initial') || visibleSubmissions[0] || null;
|
|
const rejectedNoteSubmissions = visibleSubmissions
|
|
.filter(s => s.type === 'amendment' && parseRejectedNote(s.description))
|
|
.map(s => ({ ...s, rejected: parseRejectedNote(s.description) }));
|
|
const amendments = visibleSubmissions.filter(s => s.type === 'amendment' && !parseRejectedNote(s.description));
|
|
const revisions = visibleSubmissions.filter(s => s.type === 'revision').sort((a, b) => a.version_number - b.version_number);
|
|
const reviewSubmissions = deliveries;
|
|
const commentRows = comments;
|
|
const rejectedNoteByVersion = new Map(
|
|
rejectedNoteSubmissions.map(note => [note.rejected.versionNumber, { ...note, body: note.rejected.body, created_at: note.submitted_at }])
|
|
);
|
|
const displayCurrentVersion = getCurrentVersionForTask(task, submissions);
|
|
|
|
const assignee = task.assignee;
|
|
const assigneeName = assignee?.name || task.assigned_name || null;
|
|
const assigneeAvatar = assignee?.avatar_url || null;
|
|
const project = task.project;
|
|
const company = project?.company;
|
|
const requester = getSubmissionDisplayName(submission) || null;
|
|
const role = currentUser?.role;
|
|
const isClient = role === 'client';
|
|
const isTeamOrSub = role === 'team' || role === 'external';
|
|
const status = task.status;
|
|
const notificationBase = {
|
|
taskId: id,
|
|
taskTitle: task?.title || '',
|
|
projectId: project?.id || '',
|
|
projectName: project?.name || '',
|
|
companyId: company?.id || '',
|
|
companyName: company?.name || '',
|
|
assignedProfileId: task?.assigned_to || currentUser?.id || '',
|
|
};
|
|
|
|
const actionBusy = statusSaving || reviewSaving || revisionSaving || rejectSaving || amendSaving;
|
|
|
|
const log = (action) => logActivity({ actorId: currentUser.id, actorName: currentUser.name, action, taskId: id, taskTitle: task?.title, projectId: task?.project?.id, projectName: task?.project?.name });
|
|
|
|
const updateStatus = guard('status', async (newStatus, extra = {}, action = null) => {
|
|
setStatusSaving(true);
|
|
const prevTask = task;
|
|
setTask(t => ({ ...t, status: newStatus, ...extra }));
|
|
try {
|
|
const { error: taskUpdateError } = await supabase.from('tasks').update({ status: newStatus, ...extra }).eq('id', id);
|
|
if (taskUpdateError) throw taskUpdateError;
|
|
await reloadTaskRow();
|
|
if (action) {
|
|
await log(action);
|
|
const { data } = await supabase.from('activity_log').select('id, created_at, actor_id, actor_name, action').eq('task_id', id).order('created_at', { ascending: false }).limit(20);
|
|
setActivityLog(data || []);
|
|
}
|
|
} catch (err) {
|
|
setTask(prevTask);
|
|
console.error('Status update failed:', err);
|
|
window.alert(err?.message || 'Failed to update status.');
|
|
throw err;
|
|
} finally {
|
|
setStatusSaving(false);
|
|
}
|
|
});
|
|
|
|
const handleStart = () => updateStatus('in_progress', { assigned_to: currentUser.id, assigned_name: currentUser.name }, 'task_started');
|
|
const handleOnHold = async () => {
|
|
await updateStatus('on_hold', {}, 'task_on_hold');
|
|
sendTaskStatusUpdate({
|
|
...notificationBase,
|
|
statusLabel: 'On Hold',
|
|
headline: 'Task On Hold',
|
|
subject: `Task On Hold: ${task?.title || 'Task'}`,
|
|
message: `${currentUser.name} placed ${task?.title || 'this task'} on hold${project?.name ? ` for ${project.name}` : ''}.`,
|
|
includeTeam: true,
|
|
includeAssigned: true,
|
|
includeClient: true,
|
|
}).catch(() => {});
|
|
};
|
|
const handleResume = () => updateStatus('in_progress', {}, 'task_resumed');
|
|
const handleSendToReview = () => { setReviewFiles([]); setReviewNotes(''); setReviewModal(true); };
|
|
const handleRemoveFromReview = () => updateStatus('in_progress', {}, 'task_resumed');
|
|
const handleConfirmReview = guard('review', async () => {
|
|
setReviewSaving(true);
|
|
try {
|
|
const targetVersion = task.current_version || 0;
|
|
const versionSubmissions = submissions.filter(s => (s.version_number || 0) === targetVersion && s.type !== 'amendment');
|
|
const usedSubmissionIds = new Set(
|
|
deliveries
|
|
.filter(d => (d.version_number || 0) === targetVersion)
|
|
.filter(d => versionSubmissions.some(s => s.id === d.submission_id))
|
|
.map(d => d.submission_id)
|
|
);
|
|
let targetSubmission = versionSubmissions.find(s => !usedSubmissionIds.has(s.id)) || null;
|
|
if (!targetSubmission) {
|
|
const { data: createdSubmission, error: createdSubmissionError } = await supabase
|
|
.from('submissions')
|
|
.insert({
|
|
task_id: id,
|
|
version_number: targetVersion,
|
|
type: targetVersion === 0 ? 'initial' : 'revision',
|
|
revision_type: targetVersion > 0 ? 'client_revision' : null,
|
|
service_type: canonicalTaskServiceType !== '—' ? canonicalTaskServiceType : null,
|
|
description: `${REVIEW_SHADOW_PREFIX}${targetVersion}`,
|
|
submitted_by: currentUser.id,
|
|
submitted_by_name: currentUser.name,
|
|
})
|
|
.select('id, type, version_number, request_key, submitted_by, submitted_by_name, submitted_at, service_type, deadline, description, is_hot, sign_family, sign_count, signs')
|
|
.single();
|
|
if (createdSubmissionError) throw createdSubmissionError;
|
|
targetSubmission = createdSubmission || null;
|
|
}
|
|
if (!targetSubmission?.id) throw new Error('No submission found for current version.');
|
|
const { data: newDel, error: deliveryError } = await supabase
|
|
.from('deliveries')
|
|
.insert({ submission_id: targetSubmission.id, sent_by: currentUser.name, message: reviewNotes.trim() || '', version_number: targetVersion })
|
|
.select()
|
|
.single();
|
|
if (deliveryError) throw deliveryError;
|
|
if (!newDel?.id) throw new Error('Failed to create review submission.');
|
|
if (newDel && reviewFiles.length > 0) {
|
|
const uploaded = [];
|
|
for (const file of reviewFiles) {
|
|
const path = `${id}/${Date.now()}_${file.name}`;
|
|
const { data: up, error: uploadError } = await supabase.storage.from('deliveries').upload(path, file);
|
|
if (uploadError) throw uploadError;
|
|
if (up) uploaded.push({ name: file.name, storage_path: path, size: file.size, delivery_id: newDel.id });
|
|
}
|
|
if (uploaded.length > 0) {
|
|
const { error: deliveryFilesError } = await supabase.from('delivery_files').insert(uploaded);
|
|
if (deliveryFilesError) throw deliveryFilesError;
|
|
}
|
|
}
|
|
const refreshedSubRows = await fetchTaskSubmissions(id);
|
|
const allSubRows = refreshedSubRows || [];
|
|
setSubmissions(allSubRows);
|
|
const subIds = allSubRows.map(s => s.id);
|
|
const { data: delRows } = await supabase.from('deliveries').select('*, files:delivery_files(id, name, storage_path, size)').in('submission_id', subIds).order('sent_at', { ascending: false });
|
|
setDeliveries(delRows || []);
|
|
await updateStatus('client_review', {}, 'task_submitted');
|
|
sendTaskStatusUpdate({
|
|
...notificationBase,
|
|
statusLabel: 'In Review',
|
|
headline: 'Task Ready For Review',
|
|
subject: `In Review: ${task?.title || 'Task'}`,
|
|
message: `${currentUser.name} placed ${task?.title || 'this task'} in review${project?.name ? ` for ${project.name}` : ''}.${reviewNotes.trim() ? ` Notes: ${reviewNotes.trim()}` : ''}`,
|
|
includeTeam: true,
|
|
includeAssigned: true,
|
|
includeClient: true,
|
|
}).catch(() => {});
|
|
setReviewModal(false);
|
|
setReviewFiles([]);
|
|
setReviewNotes('');
|
|
} catch (err) {
|
|
console.error('Place in review failed:', err);
|
|
window.alert(err?.message || 'Failed to place task in review.');
|
|
} finally {
|
|
setReviewSaving(false);
|
|
}
|
|
});
|
|
const handleClientApprove = guard('approve', async () => {
|
|
await updateStatus('client_approved', { completed_at: new Date().toISOString() }, 'task_approved');
|
|
sendTaskStatusUpdate({
|
|
...notificationBase,
|
|
statusLabel: 'Approved',
|
|
headline: 'Task Approved',
|
|
subject: `Approved: ${task?.title || 'Task'}`,
|
|
message: `${currentUser.name} approved ${task?.title || 'this task'}${project?.name ? ` for ${project.name}` : ''}.`,
|
|
includeTeam: true,
|
|
includeAssigned: true,
|
|
includeClient: true,
|
|
}).catch(() => {});
|
|
});
|
|
const handleClientReject = () => { setRejectNote(''); setRejectModal(true); };
|
|
const handleSubmitReject = guard('reject', async () => {
|
|
if (!rejectNote.trim()) return;
|
|
setRejectSaving(true);
|
|
try {
|
|
const rejectedVersion = task.current_version || 0;
|
|
const { data: rejectedNoteSubmission, error: rejectNoteError } = await supabase
|
|
.from('submissions')
|
|
.insert({
|
|
task_id: id,
|
|
version_number: rejectedVersion,
|
|
type: 'amendment',
|
|
service_type: canonicalTaskServiceType !== '—' ? canonicalTaskServiceType : null,
|
|
description: encodeRejectedNote(rejectedVersion, rejectNote.trim()),
|
|
submitted_by: currentUser.id,
|
|
submitted_by_name: currentUser.name,
|
|
})
|
|
.select()
|
|
.single();
|
|
// The rejection reason lives only in this submission row. If it didn't
|
|
// save, do NOT flip status or log the rejection — otherwise the task
|
|
// shows as "rejected" with no reason. Surface the error and bail.
|
|
if (rejectNoteError || !rejectedNoteSubmission) {
|
|
console.error('Reject note save failed:', rejectNoteError);
|
|
window.alert(rejectNoteError?.message || 'Failed to save the rejection reason. The task was not rejected — please try again.');
|
|
return;
|
|
}
|
|
setSubmissions(prev => [...prev, rejectedNoteSubmission]);
|
|
await updateStatus('in_progress', {}, 'revision_requested');
|
|
sendTaskStatusUpdate({
|
|
...notificationBase,
|
|
statusLabel: 'Rejected',
|
|
headline: 'Task Rejected',
|
|
subject: `Rejected: ${task?.title || 'Task'}`,
|
|
message: `${currentUser.name} rejected ${task?.title || 'this task'}${project?.name ? ` for ${project.name}` : ''}. Notes: ${rejectNote.trim()}`,
|
|
includeTeam: true,
|
|
includeAssigned: true,
|
|
includeClient: true,
|
|
}).catch(() => {});
|
|
setRejectModal(false);
|
|
setRejectNote('');
|
|
} finally {
|
|
setRejectSaving(false);
|
|
}
|
|
});
|
|
const handleRelease = () => updateStatus('not_started', { assigned_to: null, assigned_name: null }, 'task_released');
|
|
|
|
const handleSubmitRevision = guard('revision', async () => {
|
|
setRevisionSaving(true);
|
|
const baseline = Math.max(task.current_version || 0, ...submissions.map(s => s.version_number || 0));
|
|
const newVersion = baseline + 1;
|
|
await supabase.from('tasks').update({
|
|
status: 'not_started',
|
|
current_version: newVersion,
|
|
assigned_to: task?.assigned_to || null,
|
|
assigned_name: task?.assigned_name || task?.assignee?.name || null,
|
|
}).eq('id', id);
|
|
const { data: newSub } = await supabase.from('submissions').insert({ task_id: id, version_number: newVersion, type: 'revision', revision_type: 'client_revision', service_type: canonicalTaskServiceType !== '—' ? canonicalTaskServiceType : null, deadline: revisionForm.deadline || null, description: revisionForm.description, submitted_by: currentUser.id, submitted_by_name: currentUser.name }).select().single();
|
|
if (newSub && revisionFiles.length > 0) {
|
|
const uploadedFiles = [];
|
|
for (const file of revisionFiles) {
|
|
const path = `${id}/${newVersion}/${Date.now()}_${file.name}`;
|
|
const { data: uploaded } = await supabase.storage.from('submissions').upload(path, file);
|
|
if (uploaded) uploadedFiles.push({ name: file.name, storage_path: path, size: file.size });
|
|
}
|
|
if (uploadedFiles.length > 0) await supabase.from('submission_files').insert(uploadedFiles.map(f => ({ ...f, submission_id: newSub.id })));
|
|
}
|
|
await logActivity({ actorId: currentUser.id, actorName: currentUser.name, action: 'revision_requested', taskId: id, taskTitle: task?.title, projectId: project?.id, projectName: project?.name });
|
|
const [{ data: taskData }, subRows, { data: actData }] = await Promise.all([
|
|
supabase.from('tasks').select('*, project:projects(id, name, company:companies(id, name)), assignee:profiles!assigned_to(id, name, avatar_url)').eq('id', id).single(),
|
|
fetchTaskSubmissions(id),
|
|
supabase.from('activity_log').select('id, created_at, actor_id, actor_name, action, task_id, task_title').eq('task_id', id).order('created_at', { ascending: false }).limit(20),
|
|
]);
|
|
setTask(taskData);
|
|
setSubmissions(subRows || []);
|
|
setActivityLog(actData || []);
|
|
sendTaskStatusUpdate({
|
|
...notificationBase,
|
|
assignedProfileId: task?.assigned_to || '',
|
|
statusLabel: `Revision Request ${`R${String(newVersion).padStart(2, '0')}`}`,
|
|
headline: 'Revision Requested',
|
|
subject: `Revision Request: ${task?.title || 'Task'} ${`R${String(newVersion).padStart(2, '0')}`}`,
|
|
message: `${currentUser.name} requested a revision for ${task?.title || 'this task'}${project?.name ? ` on ${project.name}` : ''}. ${revisionForm.description}`,
|
|
includeTeam: true,
|
|
includeAssigned: true,
|
|
includeClient: true,
|
|
}).catch(() => {});
|
|
setRevisionModal(false);
|
|
setRevisionForm({ description: '', deadline: '' });
|
|
setRevisionFiles([]);
|
|
setRevisionSaving(false);
|
|
});
|
|
|
|
const handleSubmitAmendment = guard('amend', async () => {
|
|
if (!amendForm.description.trim()) return;
|
|
setAmendSaving(true);
|
|
setAmendModal(false);
|
|
const baseline = Math.max(task.current_version || 0, ...submissions.map(s => s.version_number || 0));
|
|
const { data: newSub } = await supabase.from('submissions').insert({ task_id: id, version_number: baseline, type: 'amendment', service_type: canonicalTaskServiceType !== '—' ? canonicalTaskServiceType : null, description: amendForm.description, submitted_by: currentUser.id, submitted_by_name: currentUser.name }).select().single();
|
|
if (newSub && amendFiles.length > 0) {
|
|
setDlProgress({ active: true, label: 'Uploading files…', percent: 0 });
|
|
const uploaded = [];
|
|
for (let i = 0; i < amendFiles.length; i++) {
|
|
const file = amendFiles[i];
|
|
setDlProgress({ active: true, label: file.name, percent: Math.round((i / amendFiles.length) * 100) });
|
|
const path = `${id}/${Date.now()}_${file.name}`;
|
|
const { data: up } = await supabase.storage.from('submissions').upload(path, file);
|
|
if (up) uploaded.push({ name: file.name, storage_path: path, size: file.size });
|
|
}
|
|
if (uploaded.length > 0) await supabase.from('submission_files').insert(uploaded.map(f => ({ ...f, submission_id: newSub.id })));
|
|
setDlProgress({ active: false, label: '', percent: 0 });
|
|
}
|
|
const subRows = await fetchTaskSubmissions(id);
|
|
setSubmissions(subRows || []);
|
|
sendTaskStatusUpdate({
|
|
...notificationBase,
|
|
statusLabel: 'Request Amended',
|
|
headline: 'Request Amended',
|
|
subject: `Request Amended: ${task?.title || 'Task'}`,
|
|
message: `${currentUser.name} amended ${task?.title || 'this task'}${project?.name ? ` for ${project.name}` : ''}. Notes: ${amendForm.description.trim()}`,
|
|
includeTeam: true,
|
|
includeAssigned: true,
|
|
includeClient: true,
|
|
}).catch(() => {});
|
|
setAmendForm({ description: '' });
|
|
setAmendFiles([]);
|
|
setAmendSaving(false);
|
|
});
|
|
|
|
const handlePostComment = guard('comment', async () => {
|
|
if (!commentBody.trim()) return;
|
|
setCommentSaving(true);
|
|
const { data } = await supabase.from('task_comments').insert({ task_id: id, author_id: currentUser.id, author_name: currentUser.name, body: commentBody.trim() }).select().single();
|
|
if (data) setComments(prev => [...prev, data]);
|
|
setCommentBody('');
|
|
setCommentSaving(false);
|
|
});
|
|
|
|
const handleDeleteComment = async (commentId) => {
|
|
await supabase.from('task_comments').delete().eq('id', commentId);
|
|
setComments(prev => prev.filter(c => c.id !== commentId));
|
|
};
|
|
|
|
const downloadAllFiles = async (files, label = 'files', bucket = 'submissions') => {
|
|
if (downloading) return;
|
|
setDownloading('all');
|
|
setDlProgress({ active: true, label: 'Preparing…', percent: 0 });
|
|
try {
|
|
const zip = new JSZip();
|
|
for (let i = 0; i < files.length; i++) {
|
|
const file = files[i];
|
|
setDlProgress({ active: true, label: file.name, percent: Math.round((i / (files.length + 1)) * 100) });
|
|
try {
|
|
const { data } = await supabase.storage.from(bucket).createSignedUrl(file.storage_path, 3600, { download: file.name });
|
|
if (data?.signedUrl) {
|
|
const response = await fetch(data.signedUrl);
|
|
if (response.ok) zip.file(file.name, await response.blob());
|
|
}
|
|
} catch { /* skip bad file */ }
|
|
}
|
|
setDlProgress({ active: true, label: 'Building zip…', percent: 95 });
|
|
const content = await zip.generateAsync({ type: 'blob' });
|
|
const zipName = `${task?.title || 'files'} ${label}.zip`.replace(/[^a-z0-9 ._-]/gi, '_');
|
|
const a = document.createElement('a');
|
|
a.href = URL.createObjectURL(content);
|
|
a.download = zipName;
|
|
a.click();
|
|
URL.revokeObjectURL(a.href);
|
|
} finally {
|
|
setDlProgress({ active: false, label: '', percent: 0 });
|
|
setDownloading('');
|
|
}
|
|
};
|
|
|
|
return (
|
|
<Layout>
|
|
<div style={{ display: 'flex', gap: 24, alignItems: 'stretch', flex: 1, minHeight: 0, overflow: 'hidden' }}>
|
|
<div style={{ flex: '0 0 70%', minWidth: 0, display: 'flex', flexDirection: 'column', gap: 24, minHeight: 0 }}>
|
|
<div className="card" style={{ ...CARD, position: 'relative', ...(submission?.is_hot ? { background: 'radial-gradient(ellipse 90% 55% at 50% 120%, rgba(239,80,10,0.18) 0%, color-mix(in srgb, var(--accent) 9%, transparent) 45%, transparent 70%), var(--card-bg)' } : {}) }}>
|
|
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 12, marginBottom: 8 }}>
|
|
<div style={{ fontSize: 28, fontWeight: 500, color: 'var(--text-primary)', lineHeight: 1.2 }}>{task.title}</div>
|
|
<div style={{ display: 'flex', gap: 8, flexShrink: 0 }}>
|
|
{isTeamOrSub && status === 'not_started' && (
|
|
<button className="btn btn-outline" disabled={actionBusy} onClick={handleStart}>Start Task</button>
|
|
)}
|
|
{isTeamOrSub && status === 'in_progress' && task.assigned_to === currentUser?.id && (
|
|
<>
|
|
<button className="btn btn-outline" disabled={actionBusy} onClick={handleRelease}>Release</button>
|
|
<button className="btn btn-outline" disabled={actionBusy} onClick={handleOnHold}>Place on Hold</button>
|
|
<button className="btn btn-primary" disabled={actionBusy} onClick={handleSendToReview}>Place in Review</button>
|
|
</>
|
|
)}
|
|
{isTeamOrSub && status === 'client_review' && task.assigned_to === currentUser?.id && (
|
|
<button className="btn btn-outline" disabled={actionBusy} onClick={handleRemoveFromReview}>Remove from Review</button>
|
|
)}
|
|
{isTeamOrSub && status === 'on_hold' && task.assigned_to === currentUser?.id && (
|
|
<>
|
|
<button className="btn btn-outline" disabled={actionBusy} onClick={handleRelease}>Release</button>
|
|
<button className="btn btn-outline" disabled={actionBusy} onClick={handleResume}>Resume</button>
|
|
</>
|
|
)}
|
|
{isClient && status === 'client_review' && (
|
|
<>
|
|
<button className="btn btn-primary" disabled={actionBusy} onClick={handleClientApprove}>Approve</button>
|
|
<button className="btn btn-danger" disabled={actionBusy} onClick={handleClientReject}>Reject</button>
|
|
</>
|
|
)}
|
|
{isClient && ['client_approved', 'invoiced', 'paid'].includes(status) && (
|
|
<button className="btn btn-outline" disabled={actionBusy} onClick={() => setRevisionModal(true)}>Request Revision</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 20 }}>
|
|
{canonicalTaskServiceType !== '—' && (
|
|
<span className="badge badge-status badge-client">{canonicalTaskServiceType}</span>
|
|
)}
|
|
<StatusBadge status={task.status || 'not_started'} />
|
|
</div>
|
|
<div style={{ display: 'flex', gap: 30, flexWrap: 'wrap' }}>
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="var(--text-muted)" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" style={{ flexShrink: 0 }}><line x1="4" y1="9" x2="20" y2="9"/><line x1="4" y1="15" x2="20" y2="15"/><line x1="10" y1="3" x2="8" y2="21"/><line x1="16" y1="3" x2="14" y2="21"/></svg>
|
|
<div>
|
|
<div style={CARD_META_LABEL}>Task ID</div>
|
|
<div style={{ fontSize: 13, color: 'var(--text-primary)', fontVariantNumeric: 'tabular-nums' }}>{task.task_number || task.id.slice(0, 8).toUpperCase()}</div>
|
|
</div>
|
|
</div>
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="var(--text-muted)" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" style={{ flexShrink: 0 }}><polyline points="17,1 21,5 17,9"/><path d="M3 11V9a4 4 0 0 1 4-4h14"/><polyline points="7,23 3,19 7,15"/><path d="M21 13v2a4 4 0 0 1-4 4H3"/></svg>
|
|
<div>
|
|
<div style={CARD_META_LABEL}>Revision</div>
|
|
<div style={{ fontSize: 13, color: 'var(--text-primary)', fontVariantNumeric: 'tabular-nums' }}>{displayCurrentVersion === 0 ? 'New' : String(displayCurrentVersion).padStart(2, '0')}</div>
|
|
</div>
|
|
</div>
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="var(--text-muted)" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" style={{ flexShrink: 0 }}><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>
|
|
<div>
|
|
<div style={CARD_META_LABEL}>Project</div>
|
|
{project
|
|
? <Link to={`/projects/${project.id}`} className="table-link" style={{ fontSize: 13 }}>{project.name}</Link>
|
|
: <div style={{ fontSize: 13, color: 'var(--text-muted)' }}>—</div>}
|
|
</div>
|
|
</div>
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="var(--text-muted)" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" style={{ flexShrink: 0 }}><rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>
|
|
<div>
|
|
<div style={CARD_META_LABEL}>Requested</div>
|
|
<div style={{ fontSize: 13, color: 'var(--text-primary)' }}>
|
|
{submission?.submitted_at || task.submitted_at
|
|
? new Date(submission?.submitted_at || task.submitted_at).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
|
|
: '—'}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="var(--text-muted)" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" style={{ flexShrink: 0 }}><circle cx="12" cy="12" r="10"/><polyline points="12,6 12,12 16,14"/></svg>
|
|
<div>
|
|
<div style={CARD_META_LABEL}>Due Date</div>
|
|
<div style={{ fontSize: 13, color: 'var(--text-primary)' }}>
|
|
{submission?.deadline
|
|
? new Date(submission.deadline).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
|
|
: '—'}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div style={{ display: 'flex', flexDirection: 'column', flex: 1, minHeight: 0 }}>
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 4, marginBottom: 10 }}>
|
|
{TABS.map(tab => {
|
|
const count = tab === 'Revisions' ? revisions.length : tab === 'Submissions' ? reviewSubmissions.length : tab === 'Comments' ? commentRows.length : 0;
|
|
return (
|
|
<button key={tab} onClick={() => setActiveTab(tab)} className={`section-tab-btn${activeTab === tab ? ' is-active' : ''}`}>
|
|
{tab}{count > 0 && <span style={{ marginLeft: 5, fontSize: 12, fontWeight: 600, color: activeTab === tab ? 'var(--accent)' : 'var(--text-muted)', background: 'var(--card-bg-2)', border: '1px solid var(--border)', borderRadius: 10, padding: '3px 8px' }}>{count}</span>}
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
<div className="card" style={{ ...CARD, flex: 1, minHeight: 0, overflowY: 'auto' }}>
|
|
<div style={{ fontSize: 13 }}>
|
|
{activeTab === 'Overview' && (
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 20 }}>
|
|
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 32 }}>
|
|
<div>
|
|
<div style={META_LABEL}>Requested By</div>
|
|
<div style={{ fontSize: 13, color: 'var(--text-primary)' }}>{requester || '—'}</div>
|
|
</div>
|
|
<div>
|
|
<div style={META_LABEL}>Requested</div>
|
|
<div style={{ fontSize: 13, color: 'var(--text-primary)' }}>
|
|
{submission?.submitted_at ? new Date(submission.submitted_at).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) : '—'}
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<div style={META_LABEL}>Sign Count</div>
|
|
<div style={{ fontSize: 13, color: 'var(--text-primary)' }}>{submission?.sign_count > 0 ? submission.sign_count : 'N/A'}</div>
|
|
</div>
|
|
{isClient && ['not_started', 'in_progress'].includes(status) && (
|
|
<div style={{ marginLeft: 'auto' }}>
|
|
<button className="btn btn-outline" onClick={() => setAmendModal(true)}>Amend Request</button>
|
|
</div>
|
|
)}
|
|
{rejectedNoteByVersion.get(0) && (
|
|
<div style={{ marginLeft: 'auto', flex: '0 0 min(320px, 36%)' }}>
|
|
<RejectedNoteBlock note={rejectedNoteByVersion.get(0)} />
|
|
</div>
|
|
)}
|
|
</div>
|
|
{submission?.description && (
|
|
<div>
|
|
<div style={META_LABEL}>Notes</div>
|
|
<div style={{ fontSize: 13, color: 'var(--text-primary)', lineHeight: 1.6, whiteSpace: 'pre-wrap' }}>{submission.description}</div>
|
|
</div>
|
|
)}
|
|
{submission?.sign_family && (
|
|
<div>
|
|
<div style={META_LABEL}>Sign Family</div>
|
|
<div style={{ fontSize: 13, color: 'var(--text-primary)' }}>{submission.sign_family}</div>
|
|
</div>
|
|
)}
|
|
<SubmissionSigns signs={submission?.signs} signCount={submission?.sign_count} />
|
|
<SubmissionFiles files={submission?.files} downloading={downloading} onDownloadAll={downloadAllFiles} />
|
|
{amendments.length > 0 && (
|
|
<div style={{ borderTop: '1px solid var(--border)', paddingTop: 20, display: 'flex', flexDirection: 'column', gap: 20 }}>
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
|
<span style={{ fontSize: 11, fontWeight: 600, letterSpacing: 0.8, color: 'var(--text-secondary)', background: 'var(--card-bg-2)', border: '1px solid var(--border)', borderRadius: 4, padding: '2px 8px', textTransform: 'uppercase' }}>Amendment</span>
|
|
</div>
|
|
{amendments.map((am) => (
|
|
<div key={am.id} style={{ display: 'flex', flexDirection: 'column', gap: 20 }}>
|
|
{am.description && (
|
|
<div>
|
|
<div style={META_LABEL}>Notes</div>
|
|
<div style={{ fontSize: 13, color: 'var(--text-primary)', lineHeight: 1.6, whiteSpace: 'pre-wrap' }}>{am.description}</div>
|
|
</div>
|
|
)}
|
|
<SubmissionFiles files={am.files} downloading={downloading} onDownloadAll={downloadAllFiles} />
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
{activeTab === 'Revisions' && (
|
|
revisions.length === 0
|
|
? <div className="card-empty-center">No revisions</div>
|
|
: <div style={{ display: 'flex', flexDirection: 'column', gap: 20 }}>
|
|
{[...revisions].reverse().map((rev, i, arr) => {
|
|
const rNum = `R${String(rev.version_number).padStart(2, '0')}`;
|
|
const fmtDate = rev.submitted_at
|
|
? new Date(rev.submitted_at).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
|
|
: '—';
|
|
return (
|
|
<div key={rev.id} style={{ display: 'flex', flexDirection: 'column', gap: 20, borderBottom: i < arr.length - 1 ? '1px solid var(--border)' : 'none', paddingBottom: i < arr.length - 1 ? 20 : 0 }}>
|
|
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 32 }}>
|
|
<div>
|
|
<div style={META_LABEL}>Requested By</div>
|
|
<div style={{ fontSize: 13, color: 'var(--text-primary)' }}>{getSubmissionDisplayName(rev) || '—'}</div>
|
|
</div>
|
|
<div>
|
|
<div style={META_LABEL}>Requested</div>
|
|
<div style={{ fontSize: 13, color: 'var(--text-primary)' }}>{fmtDate}</div>
|
|
</div>
|
|
<div>
|
|
<div style={META_LABEL}>Sign Count</div>
|
|
<div style={{ fontSize: 13, color: 'var(--text-primary)' }}>{rev.sign_count > 0 ? rev.sign_count : 'N/A'}</div>
|
|
</div>
|
|
<div>
|
|
<div style={META_LABEL}>Revision</div>
|
|
<div style={{ fontSize: 13, color: 'var(--text-primary)' }}>{rNum}</div>
|
|
</div>
|
|
{isClient && i === 0 && ['not_started', 'in_progress'].includes(status) && (
|
|
<div style={{ marginLeft: 'auto' }}>
|
|
<button className="btn btn-outline" onClick={() => setAmendModal(true)}>Amend</button>
|
|
</div>
|
|
)}
|
|
{rejectedNoteByVersion.get(rev.version_number) && (
|
|
<div style={{ marginLeft: 'auto', flex: '0 0 min(320px, 36%)' }}>
|
|
<RejectedNoteBlock note={rejectedNoteByVersion.get(rev.version_number)} />
|
|
</div>
|
|
)}
|
|
</div>
|
|
{rev.description && (
|
|
<div>
|
|
<div style={META_LABEL}>Description</div>
|
|
<div style={{ fontSize: 13, color: 'var(--text-primary)', lineHeight: 1.6, whiteSpace: 'pre-wrap' }}>{rev.description}</div>
|
|
</div>
|
|
)}
|
|
{rev.sign_family && (
|
|
<div>
|
|
<div style={META_LABEL}>Sign Family</div>
|
|
<div style={{ fontSize: 13, color: 'var(--text-primary)' }}>{rev.sign_family}</div>
|
|
</div>
|
|
)}
|
|
<SubmissionSigns signs={rev.signs} signCount={rev.sign_count} />
|
|
{!rev.description && !rev.sign_family && !(rev.signs?.length > 0) && !(Number(rev.sign_count || 0) > 0) && (
|
|
<div style={{ fontSize: 13, color: 'var(--text-muted)' }}>No notes provided.</div>
|
|
)}
|
|
<SubmissionFiles files={rev.files} downloading={downloading} onDownloadAll={(f) => downloadAllFiles(f, rNum)} />
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
{activeTab === 'Submissions' && (
|
|
reviewSubmissions.length === 0
|
|
? <div className="card-empty-center">No submissions</div>
|
|
: <div style={{ display: 'flex', flexDirection: 'column', gap: 20 }}>
|
|
{reviewSubmissions.map((sub, i, arr) => {
|
|
const fmtDate = sub.sent_at
|
|
? new Date(sub.sent_at).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
|
|
: '—';
|
|
return (
|
|
<div key={sub.id} style={{ display: 'flex', flexDirection: 'column', gap: 20, borderBottom: i < arr.length - 1 ? '1px solid var(--border)' : 'none', paddingBottom: i < arr.length - 1 ? 20 : 0 }}>
|
|
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 32 }}>
|
|
<div>
|
|
<div style={META_LABEL}>Submitted By</div>
|
|
<div style={{ fontSize: 13, color: 'var(--text-primary)' }}>{sub.sent_by || '—'}</div>
|
|
</div>
|
|
<div>
|
|
<div style={META_LABEL}>Date</div>
|
|
<div style={{ fontSize: 13, color: 'var(--text-primary)' }}>{fmtDate}</div>
|
|
</div>
|
|
<div>
|
|
<div style={META_LABEL}>Version</div>
|
|
<div style={{ fontSize: 13, color: 'var(--text-primary)', fontWeight: 500 }}>{`R${String(sub.version_number ?? 0).padStart(2, '0')}`}</div>
|
|
</div>
|
|
</div>
|
|
{sub.message
|
|
? <div>
|
|
<div style={META_LABEL}>Notes</div>
|
|
<div style={{ fontSize: 13, color: 'var(--text-primary)', lineHeight: 1.6, whiteSpace: 'pre-wrap' }}>{sub.message}</div>
|
|
</div>
|
|
: <div style={{ fontSize: 13, color: 'var(--text-muted)' }}>No notes provided.</div>
|
|
}
|
|
<SubmissionFiles files={sub.files} downloading={downloading} onDownloadAll={(f) => downloadAllFiles(f, `Submission ${i + 1}`, 'deliveries')} />
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
{activeTab === 'Comments' && (
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 0 }}>
|
|
<div style={{ display: 'flex', gap: 8, marginBottom: 20 }}>
|
|
<input
|
|
type="text"
|
|
placeholder="Write a comment…"
|
|
value={commentBody}
|
|
onChange={e => setCommentBody(e.target.value)}
|
|
onKeyDown={e => { if (e.key === 'Enter') handlePostComment(); }}
|
|
style={{ flex: 1, fontSize: 13, color: 'var(--text-primary)', background: 'var(--card-bg-2)', border: '1px solid var(--border)', borderRadius: 6, padding: '6px 10px', fontFamily: 'inherit', height: 30 }}
|
|
/>
|
|
<button className="btn btn-outline" disabled={commentSaving || !commentBody.trim()} onClick={handlePostComment}>Post</button>
|
|
</div>
|
|
{comments.length === 0
|
|
? <div className="card-empty-center">No comments</div>
|
|
: commentRows.map((c, i) => {
|
|
const d = new Date(c.created_at);
|
|
const dateStr = d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
|
|
const timeStr = d.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit', hour12: true });
|
|
const isOwn = c.author_id === currentUser?.id;
|
|
return (
|
|
<div key={c.id} style={{ borderTop: '1px solid var(--border)', paddingTop: 20, paddingBottom: 20 }}>
|
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 6 }}>
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
|
<span style={{ fontSize: 13, fontWeight: 500, color: 'var(--text-primary)' }}>{c.author_name}</span>
|
|
<span style={{ fontSize: 11, color: 'var(--text-muted)' }}>{dateStr} · {timeStr}</span>
|
|
</div>
|
|
{isOwn && (
|
|
<button className="btn btn-danger" onClick={() => handleDeleteComment(c.id)}>Delete</button>
|
|
)}
|
|
</div>
|
|
<div style={{ fontSize: 13, color: 'var(--text-primary)', lineHeight: 1.6, whiteSpace: 'pre-wrap' }}>{c.body}</div>
|
|
</div>
|
|
);
|
|
})
|
|
}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div style={{ flex: '0 0 calc(30% - 24px)', minWidth: 0, display: 'flex', flexDirection: 'column', gap: 24, minHeight: 0 }}>
|
|
<div className="card" style={CARD}>
|
|
<div style={LABEL}>Assigned To</div>
|
|
{assigneeName ? (
|
|
<Link to={`/profile/${assignee?.id}`} className="dashboard-inline-link" style={{ display: 'flex', alignItems: 'center', gap: 10, textDecoration: 'none' }}>
|
|
<AssigneePortrait name={assigneeName} avatarUrl={assigneeAvatar} />
|
|
<span style={{ fontSize: 13, fontWeight: 500 }}>{assigneeName}</span>
|
|
</Link>
|
|
) : (
|
|
<div style={{ fontSize: 13, color: 'var(--text-muted)' }}>Unassigned</div>
|
|
)}
|
|
</div>
|
|
|
|
<TimelineCard task={task} activityLog={activityLog} currentSubmission={submission} />
|
|
|
|
<div className="card" style={{ ...CARD, flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column' }}>
|
|
<div style={LABEL}>Activity</div>
|
|
{activityLog.length === 0 ? (
|
|
<div className="card-empty-center">No activity</div>
|
|
) : (
|
|
<div className="scrollbar-thin-theme" style={{ flex: 1, minHeight: 0, overflowY: 'auto', display: 'flex', flexDirection: 'column', gap: 10 }}>
|
|
{activityLog.map(e => (
|
|
<ActivityItem key={e.id} e={e} />
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{reviewModal && (
|
|
<div style={popupOverlayStyle} onClick={() => { if (!reviewSaving) { setReviewModal(false); setReviewFiles([]); setReviewNotes(''); } }}>
|
|
<div style={{ ...popupSurfaceStyle, width: 'min(480px, 96vw)', maxHeight: 'calc(100vh - 48px)', overflowY: 'auto', display: 'flex', flexDirection: 'column', gap: 14 }} onClick={e => e.stopPropagation()}>
|
|
<div style={{ fontSize: 11, fontWeight: 500, textTransform: 'uppercase', letterSpacing: 0.8, color: 'var(--text-secondary)' }}>Place in Review</div>
|
|
<div>
|
|
<div style={{ fontSize: 11, fontWeight: 500, textTransform: 'uppercase', letterSpacing: 0.8, color: 'var(--text-secondary)', marginBottom: 6 }}>Notes <span style={{ color: 'var(--text-muted)', fontWeight: 400, textTransform: 'none', letterSpacing: 0 }}>(optional)</span></div>
|
|
<textarea rows={4} placeholder="Add notes for this submission…" value={reviewNotes} onChange={e => setReviewNotes(e.target.value)} style={{ fontSize: 13, color: 'var(--text-primary)', background: 'var(--card-bg-2)', border: '1px solid var(--border)', borderRadius: 6, padding: '6px 10px', width: '100%', fontFamily: 'inherit', boxSizing: 'border-box', resize: 'vertical' }} />
|
|
</div>
|
|
<div>
|
|
<FileAttachment files={reviewFiles} onChange={setReviewFiles} />
|
|
</div>
|
|
<div className="modal-action-row" style={MODAL_ACTION_ROW}>
|
|
<button className="btn btn-outline" style={MODAL_ACTION_BUTTON} disabled={reviewSaving} onClick={handleConfirmReview}>{reviewSaving ? 'Submitting…' : 'Place in Review'}</button>
|
|
<button className="btn btn-outline" style={MODAL_ACTION_BUTTON} disabled={reviewSaving} onClick={() => { setReviewModal(false); setReviewFiles([]); setReviewNotes(''); }}>Cancel</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{revisionModal && (
|
|
<div style={{ position: 'fixed', inset: 0, background: 'var(--overlay-scrim, rgba(0,0,0,0.58))', zIndex: 1200, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 24 }} onClick={() => { if (!revisionSaving) { setRevisionModal(false); setRevisionForm({ description: '', deadline: '' }); setRevisionFiles([]); } }}>
|
|
<div className="card" style={{ ...CARD, width: '100%', maxWidth: 480, display: 'flex', flexDirection: 'column', gap: 18 }} onClick={e => e.stopPropagation()}>
|
|
<div style={{ fontSize: 18, fontWeight: 500, color: 'var(--text-primary)', letterSpacing: 0.2, lineHeight: 1.1 }}>Request Revision</div>
|
|
<div className="form-group">
|
|
<label style={{ fontSize: 11, fontWeight: 500, textTransform: 'uppercase', letterSpacing: 0.8, color: 'var(--text-secondary)' }}>Revision Notes</label>
|
|
<textarea
|
|
rows={5}
|
|
placeholder="Describe what needs to be changed…"
|
|
value={revisionForm.description}
|
|
onChange={e => setRevisionForm(f => ({ ...f, description: e.target.value }))}
|
|
style={{ width: '100%', fontSize: 13, color: 'var(--text-primary)', background: 'var(--card-bg-2)', border: '1px solid var(--border)', borderRadius: 6, padding: '8px 10px', resize: 'vertical', fontFamily: 'inherit' }}
|
|
/>
|
|
</div>
|
|
<div className="form-group">
|
|
<label style={{ fontSize: 11, fontWeight: 500, textTransform: 'uppercase', letterSpacing: 0.8, color: 'var(--text-secondary)' }}>Deadline</label>
|
|
<input
|
|
type="date"
|
|
value={revisionForm.deadline}
|
|
onChange={e => setRevisionForm(f => ({ ...f, deadline: e.target.value }))}
|
|
style={{ fontSize: 13, color: 'var(--text-primary)', background: 'var(--card-bg-2)', border: '1px solid var(--border)', borderRadius: 6, padding: '6px 10px', width: '100%' }}
|
|
/>
|
|
</div>
|
|
<div className="form-group">
|
|
<FileAttachment files={revisionFiles} onChange={setRevisionFiles} />
|
|
</div>
|
|
<div className="modal-action-row" style={MODAL_ACTION_ROW}>
|
|
<button className="btn btn-outline" style={MODAL_ACTION_BUTTON} disabled={revisionSaving} onClick={() => { setRevisionModal(false); setRevisionForm({ description: '', deadline: '' }); setRevisionFiles([]); }}>Cancel</button>
|
|
<button className="btn btn-outline" style={{ ...MODAL_ACTION_BUTTON, color: 'var(--accent)', borderColor: 'var(--accent)' }} disabled={revisionSaving || !revisionForm.description.trim()} onClick={handleSubmitRevision}>
|
|
{revisionSaving ? 'Submitting…' : 'Submit Revision'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{rejectModal && (
|
|
<div style={popupOverlayStyle} onClick={() => { if (!rejectSaving) { setRejectModal(false); setRejectNote(''); } }}>
|
|
<div style={{ ...popupSurfaceStyle, width: 'min(480px, 96vw)', maxHeight: 'calc(100vh - 48px)', overflowY: 'auto', display: 'flex', flexDirection: 'column', gap: 16 }} onClick={e => e.stopPropagation()}>
|
|
<div style={{ fontSize: 11, fontWeight: 500, textTransform: 'uppercase', letterSpacing: 0.8, color: 'var(--text-secondary)', lineHeight: 1.1 }}>Reject Task</div>
|
|
<div>
|
|
<div style={{ fontSize: 11, fontWeight: 500, textTransform: 'uppercase', letterSpacing: 0.8, color: 'var(--text-secondary)', marginBottom: 6 }}>Rejected Notes</div>
|
|
<textarea
|
|
rows={5}
|
|
placeholder="Tell the team what needs to change before approval…"
|
|
value={rejectNote}
|
|
onChange={e => setRejectNote(e.target.value)}
|
|
style={{ width: '100%', fontSize: 13, color: 'var(--text-primary)', background: 'var(--card-bg-2)', border: '1px solid var(--border)', borderRadius: 6, padding: '8px 10px', resize: 'vertical', fontFamily: 'inherit', boxSizing: 'border-box' }}
|
|
/>
|
|
</div>
|
|
<div className="modal-action-row" style={MODAL_ACTION_ROW}>
|
|
<button className="btn btn-outline" style={MODAL_ACTION_BUTTON} disabled={rejectSaving} onClick={() => { setRejectModal(false); setRejectNote(''); }}>Cancel</button>
|
|
<button className="btn btn-danger" style={MODAL_ACTION_BUTTON} disabled={rejectSaving || !rejectNote.trim()} onClick={handleSubmitReject}>
|
|
{rejectSaving ? 'Saving…' : 'Reject'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{amendModal && (
|
|
<div style={popupOverlayStyle} onClick={() => { setAmendModal(false); setAmendForm({ description: '' }); setAmendFiles([]); }}>
|
|
<div style={{ ...popupSurfaceStyle, width: 'min(480px, 96vw)', maxHeight: 'calc(100vh - 48px)', overflowY: 'auto', display: 'flex', flexDirection: 'column', gap: 16 }} onClick={e => e.stopPropagation()}>
|
|
<div style={{ fontSize: 11, fontWeight: 500, textTransform: 'uppercase', letterSpacing: 0.8, color: 'var(--text-secondary)', lineHeight: 1.1 }}>Amend Request</div>
|
|
<div>
|
|
<div style={{ fontSize: 11, fontWeight: 500, textTransform: 'uppercase', letterSpacing: 0.8, color: 'var(--text-secondary)', marginBottom: 6 }}>Notes</div>
|
|
<textarea rows={4} placeholder="Describe what you'd like to add or change…" value={amendForm.description} onChange={e => setAmendForm(f => ({ ...f, description: e.target.value }))} style={{ width: '100%', fontSize: 13, color: 'var(--text-primary)', background: 'var(--card-bg-2)', border: '1px solid var(--border)', borderRadius: 6, padding: '8px 10px', resize: 'vertical', fontFamily: 'inherit', boxSizing: 'border-box' }} />
|
|
</div>
|
|
<div>
|
|
<div style={{ fontSize: 11, fontWeight: 500, textTransform: 'uppercase', letterSpacing: 0.8, color: 'var(--text-secondary)', marginBottom: 6 }}>Attach Files</div>
|
|
<FileAttachment files={amendFiles} onChange={setAmendFiles} />
|
|
</div>
|
|
<div className="modal-action-row" style={MODAL_ACTION_ROW}>
|
|
<button className="btn btn-outline" style={MODAL_ACTION_BUTTON} disabled={amendSaving} onClick={handleSubmitAmendment}>{amendSaving ? 'Saving…' : 'Save'}</button>
|
|
<button className="btn btn-outline" style={MODAL_ACTION_BUTTON} disabled={amendSaving} onClick={() => { setAmendModal(false); setAmendForm({ description: '' }); setAmendFiles([]); }}>Cancel</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{dlProgress.active && (
|
|
<div style={{ position: 'fixed', inset: 0, background: 'var(--overlay-scrim, rgba(0,0,0,0.58))', zIndex: 1200, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 24 }}>
|
|
<div className="card" style={{ ...CARD, width: '100%', maxWidth: 340, display: 'flex', flexDirection: 'column', gap: 12 }}>
|
|
<div style={{ fontSize: 11, fontWeight: 500, textTransform: 'uppercase', letterSpacing: 0.8, color: 'var(--text-secondary)' }}>Downloading</div>
|
|
<div style={{ fontSize: 12, color: 'var(--text-secondary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{dlProgress.label}</div>
|
|
<div style={{ height: 4, borderRadius: 2, background: 'var(--card-bg-2)', overflow: 'hidden' }}>
|
|
<div style={{ height: '100%', borderRadius: 2, background: 'var(--accent)', width: `${dlProgress.percent}%`, transition: 'width 200ms ease' }} />
|
|
</div>
|
|
<div style={{ fontSize: 12, color: 'var(--text-muted)', textAlign: 'right' }}>{dlProgress.percent}%</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</Layout>
|
|
);
|
|
}
|