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:
Krao Hasanee
2026-05-30 22:44:16 -04:00
parent 13ef1f4ded
commit 0b4705311b
28 changed files with 1491 additions and 1552 deletions
+126 -35
View File
@@ -108,12 +108,21 @@ function FileIcon({ name, isDir }) {
);
}
const FBQ_FN = `${import.meta.env.VITE_SUPABASE_URL}/functions/v1/fbq-proxy`;
async function getAccessTokenOrThrow() {
const { data: { session } } = await supabase.auth.getSession();
if (session?.access_token) return session.access_token;
const { data: refreshed, error } = await supabase.auth.refreshSession();
if (error) throw new Error('Session expired. Please sign in again.');
if (refreshed?.session?.access_token) return refreshed.session.access_token;
throw new Error('Session expired. Please sign in again.');
}
async function downloadViaLocalFilebrowser(path, session) {
if (!session?.access_token) throw new Error('Missing session token for download.');
const response = await fetch(`/api/filebrowser?action=download-blob&path=${encodeURIComponent(path)}&sb_access_token=${encodeURIComponent(session.access_token)}`, {
headers: { Authorization: `Bearer ${session?.access_token ?? ''}` },
const accessToken = session?.access_token || await getAccessTokenOrThrow();
const response = await fetch(`/api/filebrowser?action=download-blob&path=${encodeURIComponent(path)}&sb_access_token=${encodeURIComponent(accessToken)}`, {
headers: { Authorization: `Bearer ${accessToken}` },
});
if (!response.ok) {
const text = await response.text();
@@ -123,10 +132,9 @@ async function downloadViaLocalFilebrowser(path, session) {
}
async function downloadViaApiToken(path, filename) {
const { data: { session } } = await supabase.auth.getSession();
if (!session?.access_token) throw new Error('Missing session token for download.');
const metaResponse = await fetch(`/api/filebrowser?action=download&path=${encodeURIComponent(path)}&sb_access_token=${encodeURIComponent(session.access_token)}`, {
headers: { Authorization: `Bearer ${session.access_token}` },
const accessToken = await getAccessTokenOrThrow();
const metaResponse = await fetch(`/api/filebrowser?action=download&path=${encodeURIComponent(path)}&sb_access_token=${encodeURIComponent(accessToken)}`, {
headers: { Authorization: `Bearer ${accessToken}` },
});
if (!metaResponse.ok) {
const text = await metaResponse.text();
@@ -147,36 +155,119 @@ async function downloadViaApiToken(path, filename) {
document.body.removeChild(link);
}
async function downloadBlobViaLocalFilebrowser(path) {
const accessToken = await getAccessTokenOrThrow();
const response = await downloadViaLocalFilebrowser(path, { access_token: accessToken });
return response.blob();
}
async function fbqProxy(op, path, extra = {}) {
const { data: { session } } = await supabase.auth.getSession();
const headers = {
Authorization: `Bearer ${session?.access_token ?? ''}`,
'X-Operation': op,
'X-Path': path,
...extra.headers,
};
const response = await fetch(FBQ_FN, {
method: 'POST',
headers,
body: extra.body ?? undefined,
});
const accessToken = await getAccessTokenOrThrow();
const authHeaders = { Authorization: `Bearer ${accessToken}` };
if (!response.ok && op === 'download') {
const text = await response.text();
const shouldFallbackToLocalDownload =
response.status === 404
|| (response.status === 400 && text.toLowerCase().includes('no files specified'));
if (shouldFallbackToLocalDownload) {
return downloadViaLocalFilebrowser(path, session);
if (op === 'list') {
const response = await fetch(`/api/filebrowser?action=list&path=${encodeURIComponent(path)}&sb_access_token=${encodeURIComponent(accessToken)}`, {
method: 'GET',
headers: authHeaders,
});
if (!response.ok) {
const text = await response.text();
throw new Error(text || `Error ${response.status}`);
}
throw new Error(text || `Error ${response.status}`);
const data = await response.json();
const entries = Array.isArray(data?.entries) ? data.entries : [];
const folders = entries
.filter((entry) => entry.type === 'dir' || entry.type === 'directory' || entry.isDir)
.map((entry) => ({ name: entry.name, filename: entry.name, path: entry.path, type: 'directory', isDir: true, size: 0, modified: entry.mtime || null }));
const files = entries
.filter((entry) => !(entry.type === 'dir' || entry.type === 'directory' || entry.isDir))
.map((entry) => ({ name: entry.name, filename: entry.name, path: entry.path, type: 'file', isDir: false, size: entry.size || 0, modified: entry.mtime || null }));
return new Response(JSON.stringify({ folders, files }), { status: 200, headers: { 'Content-Type': 'application/json' } });
}
if (!response.ok) {
const text = await response.text();
throw new Error(text || `Error ${response.status}`);
if (op === 'download') {
return downloadViaLocalFilebrowser(path, { access_token: accessToken });
}
return response;
if (op === 'mkdir') {
const normalized = String(path || '/');
const parts = normalized.split('/').filter(Boolean);
const name = parts.pop() || '';
const parentPath = `/${parts.join('/')}`;
const response = await fetch(`/api/filebrowser?action=mkdir&sb_access_token=${encodeURIComponent(accessToken)}`, {
method: 'POST',
headers: { ...authHeaders, 'Content-Type': 'application/json' },
body: JSON.stringify({ path: parentPath || '/', name }),
});
if (!response.ok) {
const text = await response.text();
throw new Error(text || `Error ${response.status}`);
}
return response;
}
if (op === 'upload') {
const normalized = String(path || '/');
const parts = normalized.split('/').filter(Boolean);
const fileName = parts.pop() || '';
const parentPath = `/${parts.join('/')}` || '/';
const tokenResponse = await fetch(`/api/filebrowser?action=upload-token&sb_access_token=${encodeURIComponent(accessToken)}`, {
method: 'POST',
headers: { ...authHeaders, 'Content-Type': 'application/json' },
body: JSON.stringify({ path: parentPath }),
});
if (!tokenResponse.ok) {
const text = await tokenResponse.text();
throw new Error(text || `Error ${tokenResponse.status}`);
}
const { token, url, fbPath } = await tokenResponse.json();
if (!token || !url || !fbPath) throw new Error('Upload token unavailable.');
const file = extra.body?.get?.('file');
if (!file) throw new Error('No file provided.');
const uploadResponse = await fetch(`${url}/api/resources?source=srv&path=${encodeURIComponent(`${fbPath}/${fileName}`)}&override=true&auth=${encodeURIComponent(token)}`, {
method: 'POST',
headers: { 'Content-Type': 'application/octet-stream' },
body: file,
});
if (!uploadResponse.ok) {
const text = await uploadResponse.text();
throw new Error(text || `Error ${uploadResponse.status}`);
}
return uploadResponse;
}
if (op === 'delete') {
const files = JSON.parse(extra.headers?.['X-Files'] || '[]');
for (const filePath of files) {
const response = await fetch(`/api/filebrowser?action=delete&path=${encodeURIComponent(filePath)}&sb_access_token=${encodeURIComponent(accessToken)}`, {
method: 'DELETE',
headers: authHeaders,
});
if (!response.ok) {
const text = await response.text();
throw new Error(text || `Error ${response.status}`);
}
}
return new Response(JSON.stringify({ ok: true }), { status: 200, headers: { 'Content-Type': 'application/json' } });
}
if (op === 'move' || op === 'copy') {
const files = JSON.parse(extra.headers?.['X-Files'] || '[]');
for (const srcPath of files) {
const response = await fetch(`/api/filebrowser?action=move&sb_access_token=${encodeURIComponent(accessToken)}`, {
method: 'POST',
headers: { ...authHeaders, 'Content-Type': 'application/json' },
body: JSON.stringify({ srcPath, dstPath: path, mode: op }),
});
if (!response.ok) {
const text = await response.text();
throw new Error(text || `Error ${response.status}`);
}
}
return new Response(JSON.stringify({ ok: true }), { status: 200, headers: { 'Content-Type': 'application/json' } });
}
throw new Error(`Unsupported operation: ${op}`);
}
function rootPath(user) {
@@ -513,7 +604,7 @@ export default function FileSharing() {
const fileName = file.name ?? file.filename ?? '';
if (!fileName) continue;
const filePath = file.path ?? (targetPath === '/' ? `/${fileName}` : `${targetPath}/${fileName}`);
const blob = await (await fbqProxy('download', filePath)).blob();
const blob = await downloadBlobViaLocalFilebrowser(filePath);
zip.file(zipPath ? `${zipPath}/${fileName}` : fileName, blob);
}
}
@@ -535,7 +626,7 @@ export default function FileSharing() {
const isDir = entry.type === 'directory' || entry.isDir;
if (!isDir) {
const blob = await (await fbqProxy('download', targetPath)).blob();
const blob = await downloadBlobViaLocalFilebrowser(targetPath);
setDownloadProgress(100);
triggerDownload(URL.createObjectURL(blob), name);
return;
@@ -559,7 +650,7 @@ export default function FileSharing() {
if (isDir) {
await addPathToZip(zip, targetPath, name);
} else {
const blob = await (await fbqProxy('download', targetPath)).blob();
const blob = await downloadBlobViaLocalFilebrowser(targetPath);
zip.file(name, blob);
}
setDownloadProgress(Math.round(((i + 1) / selectedEntries.length) * 100));