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:
+39
-15
@@ -8,6 +8,7 @@ import { useAuth } from '../context/AuthContext';
|
||||
import { readPageCache, writePageCache } from '../lib/pageCache';
|
||||
import { withTimeout } from '../lib/withTimeout';
|
||||
import { getCurrentVersionForTask, getDeadlineSourceSubmission } from '../lib/taskDeadlines';
|
||||
import { logActivity } from '../lib/activityLog';
|
||||
import { fmtShortDate } from '../lib/dates';
|
||||
import { createInitialSubmissionForRequest, createTaskForRequest, findOrCreateProject } from '../lib/requestSubmission';
|
||||
import { sendEmail } from '../lib/email';
|
||||
@@ -31,21 +32,20 @@ const TASK_MODAL_TITLE_STYLE = { fontSize: 11, fontWeight: 500, textTransform: '
|
||||
const TASK_MODAL_LABEL_STYLE = { fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', textTransform: 'uppercase', letterSpacing: 0.8, marginBottom: 6 };
|
||||
const TASK_MODAL_INPUT_STYLE = { fontSize: 13, padding: '0 5px', textAlign: 'left', lineHeight: 1 };
|
||||
const TASK_MODAL_BUTTON_STYLE = { lineHeight: 1, display: 'inline-flex', alignItems: 'center', justifyContent: 'center' };
|
||||
const TASK_MODAL_SHELL_STYLE = { ...popupSurfaceStyle, background: 'var(--card-bg)', width: 'min(434px, 100%)', maxHeight: 'calc(100vh - 48px)', overflowY: 'auto' };
|
||||
const TASK_MODAL_SHELL_STYLE = { ...popupSurfaceStyle, background: 'var(--card-bg)', width: 'min(720px, 96vw)', maxHeight: 'calc(100vh - 48px)', overflowY: 'auto' };
|
||||
const PROJECT_MODAL_SHELL_STYLE = { ...popupSurfaceStyle, background: 'var(--card-bg)', width: 'min(434px, 100%)', maxHeight: 'calc(100vh - 48px)', overflowY: 'auto' };
|
||||
const TASK_STATUS_SORT_RANK = { not_started: 0, in_progress: 1, on_hold: 2, client_review: 3, client_approved: 4, invoiced: 5, paid: 6 };
|
||||
|
||||
function TaskStatCard({ label, value, iconBg, iconColor, iconPath }) {
|
||||
return (
|
||||
<div style={{ background: 'var(--card-bg)', backdropFilter: 'blur(12px)', WebkitBackdropFilter: 'blur(12px)', border: '1px solid var(--border)', borderRadius: 8, padding: '14px 21px', minHeight: 86 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 10 }}>
|
||||
<div style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase' }}>{label}</div>
|
||||
<div style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase', marginBottom: 5 }}>{label}</div>
|
||||
<div style={{ width: 27, height: 27, borderRadius: '50%', background: iconBg, display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke={iconColor} strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" dangerouslySetInnerHTML={{ __html: iconPath }} />
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ marginTop: 8 }}>
|
||||
<div style={{ fontSize: 26, fontWeight: 400, color: 'var(--text-primary)', letterSpacing: -0.5, lineHeight: 1.1 }}>{value}</div>
|
||||
</div>
|
||||
<div style={{ fontSize: 30, fontWeight: 400, color: 'var(--text-primary)', letterSpacing: -0.5, lineHeight: 1.1 }}>{value}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -202,6 +202,7 @@ export default function RequestsPage() {
|
||||
const [addFormKey, setAddFormKey] = useState(0);
|
||||
const [addSaving, setAddSaving] = useState(false);
|
||||
const [addError, setAddError] = useState('');
|
||||
const [uploadProgress, setUploadProgress] = useState({ active: false, label: '', percent: 0 });
|
||||
const [addRequestKey, setAddRequestKey] = useState(() => crypto.randomUUID());
|
||||
const [showAddProjectForm, setShowAddProjectForm] = useState(false);
|
||||
const [addProjectSaving, setAddProjectSaving] = useState(false);
|
||||
@@ -317,10 +318,13 @@ export default function RequestsPage() {
|
||||
createTaskFolder(taskCompany?.name, resolvedProject.name, formData.title.trim()).catch(() => {});
|
||||
const { submission: sub } = await createInitialSubmissionForRequest({
|
||||
taskId: task.id, requestKey: addRequestKey, isHot: formData.isHot,
|
||||
serviceType: formData.serviceType, deadline: formData.deadline,
|
||||
serviceType: formData.serviceType, signFamily: formData.signFamily,
|
||||
signCount: formData.signCount, signs: formData.signs,
|
||||
deadline: formData.deadline,
|
||||
description: formData.description, submittedBy: formData.requestedBy, submittedByName: formData.requestedByName,
|
||||
});
|
||||
if (!sub) throw new Error('Failed to create submission.');
|
||||
logActivity({ actorId: currentUser.id, actorName: currentUser.name, action: 'request_submitted', taskId: task.id, taskTitle: task.title, projectId: resolvedProject.id, projectName: resolvedProject.name }).catch(() => {});
|
||||
const [{ data: newSubs }, { data: newTasks }] = await Promise.all([
|
||||
supabase.from('submissions').select('id, task_id, submitted_at, submitted_by, submitted_by_name, is_hot, service_type, deadline, version_number, type').order('submitted_at', { ascending: false }),
|
||||
supabase.from('tasks').select('id, title, status, current_version, project_id, assigned_name, assigned_to, invoiced, completed_at, submitted_at').order('submitted_at', { ascending: false }),
|
||||
@@ -343,21 +347,29 @@ export default function RequestsPage() {
|
||||
createTaskFolder(selectedCompany?.name, resolvedProject.name, formData.title.trim()).catch(() => {});
|
||||
const { submission } = await createInitialSubmissionForRequest({
|
||||
taskId: task.id, requestKey: addRequestKey, isHot: formData.isHot,
|
||||
serviceType: formData.serviceType, deadline: formData.deadline,
|
||||
serviceType: formData.serviceType, signFamily: formData.signFamily,
|
||||
signCount: formData.signCount, signs: formData.signs,
|
||||
deadline: formData.deadline,
|
||||
description: formData.description, submittedBy: currentUser.id, submittedByName: currentUser.name,
|
||||
});
|
||||
if (submission && files.length > 0) {
|
||||
for (const file of files) {
|
||||
setShowAddForm(false);
|
||||
setUploadProgress({ active: true, label: 'Uploading files…', percent: 0 });
|
||||
for (let fi = 0; fi < files.length; fi++) {
|
||||
const file = files[fi];
|
||||
setUploadProgress({ active: true, label: file.name, percent: Math.round((fi / files.length) * 100) });
|
||||
const path = `${task.id}/${Date.now()}_${file.name}`;
|
||||
const { data: uploaded, error: uploadError } = await supabase.storage.from('submissions').upload(path, file);
|
||||
if (uploadError) { await supabase.from('tasks').delete().eq('id', task.id); throw new Error(`Upload failed: ${uploadError.message}`); }
|
||||
if (uploadError) { await supabase.from('tasks').delete().eq('id', task.id); setUploadProgress({ active: false, label: '', percent: 0 }); throw new Error(`Upload failed: ${uploadError.message}`); }
|
||||
if (uploaded) {
|
||||
const { error: fileErr } = await supabase.from('submission_files').insert({ submission_id: submission.id, name: file.name, storage_path: path, size: file.size });
|
||||
if (fileErr) { await supabase.from('tasks').delete().eq('id', task.id); throw new Error(`File record failed: ${fileErr.message}`); }
|
||||
if (fileErr) { await supabase.from('tasks').delete().eq('id', task.id); setUploadProgress({ active: false, label: '', percent: 0 }); throw new Error(`File record failed: ${fileErr.message}`); }
|
||||
}
|
||||
}
|
||||
uploadFilesToRequestInfo(files, selectedCompany?.name, resolvedProject.name, formData.title.trim()).catch(() => {});
|
||||
setUploadProgress({ active: false, label: '', percent: 0 });
|
||||
}
|
||||
logActivity({ actorId: currentUser.id, actorName: currentUser.name, action: 'request_submitted', taskId: task.id, taskTitle: task.title, projectId: resolvedProject.id, projectName: resolvedProject.name }).catch(() => {});
|
||||
sendEmail('new_request', 'hello@fourgebranding.com', {
|
||||
clientName: currentUser.name, clientEmail: currentUser.email,
|
||||
company: selectedCompany?.name || '', serviceType: formData.serviceType,
|
||||
@@ -501,7 +513,7 @@ export default function RequestsPage() {
|
||||
<tr key={row.id}>
|
||||
<td style={{ ...TASK_TABLE_TD_BASE, fontSize: 13, color: 'var(--text-primary)' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
|
||||
<Link to={`/requests/${row.id}`} className="table-link">{row.title || '—'}</Link>
|
||||
<Link to={`/requests/${row.id}/v2`} className="table-link">{row.title || '—'}</Link>
|
||||
<span style={{ color: 'var(--text-muted)' }}>{`R${String(row.version).padStart(2, '0')}`}</span>
|
||||
{row.isHot && <span className="badge badge-needs_revision">HOT</span>}
|
||||
</div>
|
||||
@@ -510,13 +522,13 @@ export default function RequestsPage() {
|
||||
{project ? <Link to={`/projects/${project.id}`} className="table-link">{project.name}</Link> : '—'}
|
||||
</td>
|
||||
<td style={{ ...TASK_TABLE_TD_BASE, fontSize: 12, color: 'var(--text-primary)', textAlign: 'center' }}>
|
||||
<Link to={`/requests/${row.id}`} className="table-link">{row.serviceType}</Link>
|
||||
<Link to={`/requests/${row.id}/v2`} className="table-link">{row.serviceType}</Link>
|
||||
</td>
|
||||
<td style={{ ...TASK_TABLE_TD_BASE, fontSize: 12, color: 'var(--text-primary)', textAlign: 'center' }}>
|
||||
<Link to={`/requests/${row.id}`} className="table-link">{fmtShortDate(row.deadline, 'Not specified')}</Link>
|
||||
<Link to={`/requests/${row.id}/v2`} className="table-link">{fmtShortDate(row.deadline, 'Not specified')}</Link>
|
||||
</td>
|
||||
<td style={{ ...TASK_TABLE_TD_BASE, textAlign: 'center' }}>
|
||||
<Link to={`/requests/${row.id}`} className="table-link"><StatusBadge status={row.status || 'not_started'} /></Link>
|
||||
<Link to={`/requests/${row.id}/v2`} className="table-link"><StatusBadge status={row.status || 'not_started'} /></Link>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
@@ -541,7 +553,7 @@ export default function RequestsPage() {
|
||||
{/* Add project modal */}
|
||||
{showAddProjectForm && (
|
||||
<div style={popupOverlayStyle} onClick={() => { setShowAddProjectForm(false); setAddProjectForm({ name: '', companyId: '' }); setAddProjectError(''); }}>
|
||||
<div style={TASK_MODAL_SHELL_STYLE} onClick={e => e.stopPropagation()}>
|
||||
<div style={PROJECT_MODAL_SHELL_STYLE} onClick={e => e.stopPropagation()}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8, marginBottom: 14 }}>
|
||||
<div style={TASK_MODAL_TITLE_STYLE}>Add Project</div>
|
||||
</div>
|
||||
@@ -673,6 +685,18 @@ export default function RequestsPage() {
|
||||
</div>
|
||||
{error && <div style={{ color: 'var(--danger)', marginTop: 16, fontSize: 13 }}>{error}</div>}
|
||||
</TasksPageShell>
|
||||
{uploadProgress.active && (
|
||||
<div style={{ ...popupOverlayStyle, zIndex: 1200 }}>
|
||||
<div style={{ background: 'var(--card-bg)', border: '1px solid var(--border)', borderRadius: 8, padding: '18px 21px', backdropFilter: 'blur(12px)', WebkitBackdropFilter: 'blur(12px)', 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)' }}>Uploading</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-secondary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{uploadProgress.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: `${uploadProgress.percent}%`, transition: 'width 200ms ease' }} />
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)', textAlign: 'right' }}>{uploadProgress.percent}%</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user