From 5b3060e190f875d299bdb3b01c6aeb0df7a570e4 Mon Sep 17 00:00:00 2001 From: Krao Hasanee Date: Sun, 31 May 2026 11:02:40 -0400 Subject: [PATCH] Session 2026-05-31: tasks table overhaul, FileBrowser redesign, folder path fix - Tasks table: added R#, Assigned To (avatar), Priority (HOT/NO) columns; removed Status column; sortable columns; adjustable widths - FileBrowser: full visual rewrite to match FileSharing page (table layout, colored ext badges, SVG folder icon, sortable columns, multi-select, context menu, drag-drop) - TaskDetail folder tab: fix path with safeName(), rootPath set to project folder level - filebrowserFolders: export safeName for reuse Co-Authored-By: Claude Sonnet 4.6 --- src/App.jsx | 15 +- src/components/FileBrowser.jsx | 763 ++++++--------- src/components/Layout.jsx | 10 +- src/lib/filebrowserFolders.js | 2 +- src/pages/Profile.jsx | 4 +- src/pages/ProjectDetail.jsx | 655 ++++++------- src/pages/RequestDetail.jsx | 1263 ------------------------- src/pages/TaskDetail.jsx | 38 +- src/pages/Tasks.jsx | 87 +- src/pages/client/ClientNewProject.jsx | 109 --- src/pages/client/ClientNewRequest.jsx | 161 ---- src/pages/team/TeamDashboard.jsx | 10 +- 12 files changed, 724 insertions(+), 2393 deletions(-) delete mode 100644 src/pages/RequestDetail.jsx delete mode 100644 src/pages/client/ClientNewProject.jsx delete mode 100644 src/pages/client/ClientNewRequest.jsx diff --git a/src/App.jsx b/src/App.jsx index aabd340..9fe1851 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -43,7 +43,6 @@ const ProfilePage = lazy(() => import('./pages/Profile')); const CompaniesPage = lazy(() => import('./pages/Companies')); const CompanyDetail = lazy(() => import('./pages/CompanyDetail')); const TeamInvoices = lazy(() => import('./pages/team/TeamInvoices')); -const RequestDetail = lazy(() => import('./pages/RequestDetail')); const TaskDetail = lazy(() => import('./pages/TaskDetail')); const TeamCreateInvoice = lazy(() => import('./pages/team/TeamCreateInvoice')); const TeamCreateSubcontractorPO = lazy(() => import('./pages/team/TeamCreateSubcontractorPO')); @@ -63,17 +62,15 @@ const ProjectDetailPage = lazy(() => import('./pages/ProjectDetail')); const TeamDashboard = lazy(() => import('./pages/team/TeamDashboard')); const RequestsPage = lazy(() => import('./pages/Tasks')); const ClientMyInvoices = lazy(() => import('./pages/client/ClientMyInvoices')); -const ClientNewRequest = lazy(() => import('./pages/client/ClientNewRequest')); -const ClientNewProject = lazy(() => import('./pages/client/ClientNewProject')); function RedirectProjectDetail() { const { id } = useParams(); return ; } -function RedirectRequestDetail() { +function RedirectToTask() { const { id } = useParams(); - return ; + return ; } function NavigateCompanyDetail() { @@ -95,8 +92,6 @@ export default function App() { } /> } /> } /> - } /> - } /> } /> } /> } /> @@ -132,12 +127,12 @@ export default function App() { } /> } /> } /> - } /> + } /> } /> } /> } /> - } /> - } /> + } /> + } /> } /> } /> diff --git a/src/components/FileBrowser.jsx b/src/components/FileBrowser.jsx index 76a1426..bb461fd 100644 --- a/src/components/FileBrowser.jsx +++ b/src/components/FileBrowser.jsx @@ -1,73 +1,70 @@ import { useEffect, useMemo, useRef, useState } from 'react'; import LoadingButton from './LoadingButton'; +import SortTh from './SortTh'; import { supabase } from '../lib/supabase'; -import { useAuth } from '../context/AuthContext'; +import { popupMenuStyle } from '../lib/popupStyles'; + +const TABLE_TH = { fontSize: 10, fontWeight: 500, color: 'var(--text-muted)', letterSpacing: 0.6, textTransform: 'uppercase', padding: '0 0 12px', border: 'none', background: 'transparent', verticalAlign: 'top' }; +const TABLE_TD = { padding: '5px', fontSize: 13, color: 'var(--text-primary)', border: 'none', background: 'transparent' }; function formatBytes(bytes) { const value = Number(bytes || 0); if (!value) return 'β€”'; - const units = ['B', 'KB', 'MB', 'GB', 'TB']; + const units = ['B', 'KB', 'MB', 'GB']; const index = Math.min(Math.floor(Math.log(value) / Math.log(1024)), units.length - 1); return `${(value / (1024 ** index)).toFixed(index === 0 ? 0 : 1)} ${units[index]}`; } function formatDate(dt) { if (!dt) return 'β€”'; - return new Date(dt).toLocaleDateString(); + const d = new Date(dt); + return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) + ' ' + d.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit', hour12: true }); } function fileIconStyle(ext) { const e = ext.toLowerCase(); if (['jpg','jpeg','png','gif','webp','svg','ico','bmp','tiff','avif','heic'].includes(e)) return { bg: '#16a34a', color: '#fff' }; if (['mp4','mov','avi','mkv','webm','m4v','wmv','flv'].includes(e)) return { bg: '#7c3aed', color: '#fff' }; - if (['mp3','wav','ogg','flac','aac','m4a','wma'].includes(e)) return { bg: '#db2777', color: '#fff' }; + if (['mp3','wav','ogg','flac','aac','m4a'].includes(e)) return { bg: '#db2777', color: '#fff' }; if (e === 'pdf') return { bg: '#dc2626', color: '#fff' }; - if (['doc','docx','rtf','odt'].includes(e)) return { bg: '#2563eb', color: '#fff' }; + if (['doc','docx','rtf'].includes(e)) return { bg: '#2563eb', color: '#fff' }; if (['txt','md'].includes(e)) return { bg: '#64748b', color: '#fff' }; - if (['xls','xlsx','csv','ods','numbers'].includes(e)) return { bg: '#16a34a', color: '#fff' }; - if (['ppt','pptx','odp','key'].includes(e)) return { bg: '#ea580c', color: '#fff' }; - if (['zip','rar','7z','tar','gz','bz2','xz'].includes(e)) return { bg: '#92400e', color: '#fff' }; - if (['js','ts','jsx','tsx','html','css','scss','json','py','rb','php','java','c','cpp','cs','go','rs'].includes(e)) return { bg: '#0891b2', color: '#fff' }; - if (['ttf','otf','woff','woff2'].includes(e)) return { bg: '#6b7280', color: '#fff' }; + if (['xls','xlsx','csv'].includes(e)) return { bg: '#16a34a', color: '#fff' }; + if (['ppt','pptx'].includes(e)) return { bg: '#ea580c', color: '#fff' }; + if (['zip','rar','7z','tar','gz'].includes(e)) return { bg: '#92400e', color: '#fff' }; if (['ai','eps'].includes(e)) return { bg: '#ff6c00', color: '#fff' }; if (['psd','psb'].includes(e)) return { bg: '#001e36', color: '#31a8ff' }; if (['indd','idml'].includes(e)) return { bg: '#49021f', color: '#ff3366' }; - if (['fig','sketch','xd'].includes(e)) return { bg: '#7c3aed', color: '#fff' }; + if (['fig','sketch'].includes(e)) return { bg: '#7c3aed', color: '#fff' }; return { bg: '#475569', color: '#fff' }; } function FileIcon({ entry }) { - if (entry.type === 'dir') return πŸ“; + if (entry.type === 'dir') { + return ( +
+ + + +
+ ); + } const ext = (entry.name.includes('.') ? entry.name.split('.').pop() : '').toUpperCase().slice(0, 4) || 'FILE'; const { bg, color } = fileIconStyle(ext); return ( - - {ext} - +
+ {ext} +
); } -function pathParts(path) { - return String(path || '/').split('/').filter(Boolean); -} +function pathParts(path) { return String(path || '/').split('/').filter(Boolean); } +function pathTo(index, parts) { return `/${parts.slice(0, index + 1).join('/')}`; } +function joinVirtualPath(...parts) { return ('/' + parts.join('/')).replace(/\/+/g, '/').replace(/\/$/, '') || '/'; } -function pathTo(index, parts) { - return `/${parts.slice(0, index + 1).join('/')}`; -} - -function encodeFbPath(path) { - return path.split('/').map(p => encodeURIComponent(p)).join('/'); -} - -function joinVirtualPath(...parts) { - return ('/' + parts.join('/')).replace(/\/+/g, '/').replace(/\/$/, '') || '/'; -} - -export default function FileBrowser({ initialPath = '/', rootPath = '/', showSync = false }) { - const { currentUser } = useAuth(); +export default function FileBrowser({ initialPath = '/', rootPath = '/' }) { const [currentPath, setCurrentPath] = useState(initialPath); const [entries, setEntries] = useState([]); - const [configured, setConfigured] = useState(true); const [parentPath, setParentPath] = useState('/'); const [canGoUp, setCanGoUp] = useState(false); const [loading, setLoading] = useState(true); @@ -77,21 +74,28 @@ export default function FileBrowser({ initialPath = '/', rootPath = '/', showSyn const [readOnly, setReadOnly] = useState(false); const [folderName, setFolderName] = useState(''); const [showFolderInput, setShowFolderInput] = useState(false); - const [movingEntry, setMovingEntry] = useState(null); const [renamingEntry, setRenamingEntry] = useState(null); const [renameValue, setRenameValue] = useState(''); - const [draggedEntry, setDraggedEntry] = useState(null); - const [dragOverFolder, setDragOverFolder] = useState(null); const [uploadProgress, setUploadProgress] = useState(null); + const [selected, setSelected] = useState(new Set()); + const [sortKey, setSortKey] = useState('name'); + const [sortDir, setSortDir] = useState('asc'); + const [ctxMenu, setCtxMenu] = useState(null); const fileInputRef = useRef(null); const folderInputRef = useRef(null); const breadcrumbs = useMemo(() => pathParts(currentPath), [currentPath]); + useEffect(() => { + if (!ctxMenu) return; + const close = () => setCtxMenu(null); + window.addEventListener('click', close); + return () => window.removeEventListener('click', close); + }, [ctxMenu]); + const apiFetch = async (url, options = {}) => { const { data: { session } } = await supabase.auth.getSession(); - if (!session?.access_token) throw new Error('Your session expired. Please sign in again.'); - + if (!session?.access_token) throw new Error('Session expired. Please sign in again.'); const response = await fetch(url, { ...options, headers: { @@ -100,7 +104,6 @@ export default function FileBrowser({ initialPath = '/', rootPath = '/', showSyn ...(options.headers || {}), }, }); - const data = await response.json().catch(() => ({})); if (!response.ok) throw new Error(data.error || 'File request failed.'); return data; @@ -109,16 +112,16 @@ export default function FileBrowser({ initialPath = '/', rootPath = '/', showSyn const loadFiles = async (path = currentPath) => { setLoading(true); setError(''); + setSelected(new Set()); try { const params = new URLSearchParams({ action: 'list', path }); const data = await apiFetch(`/api/filebrowser?${params}`); - setConfigured(data.configured !== false); + if (data.configured === false) { setError(data.error || 'File browser not configured.'); return; } setEntries(data.entries || []); - setCurrentPath(data.path || '/'); + setCurrentPath(data.path || path); setParentPath(data.parentPath || '/'); setCanGoUp(data.canGoUp || false); setReadOnly(data.readOnly || false); - if (data.configured === false) setError(data.error || 'FileBrowser is not configured.'); } catch (err) { setError(err.message); } finally { @@ -126,286 +129,174 @@ export default function FileBrowser({ initialPath = '/', rootPath = '/', showSyn } }; - useEffect(() => { - loadFiles(initialPath); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [initialPath]); + useEffect(() => { loadFiles(initialPath); }, [initialPath]); // eslint-disable-line react-hooks/exhaustive-deps - const openFolder = (entry) => { - if (entry.type === 'dir') loadFiles(entry.path); + const toggleSort = (col) => { + if (sortKey === col) setSortDir(d => d === 'asc' ? 'desc' : 'asc'); + else { setSortKey(col); setSortDir(col === 'modified' ? 'desc' : 'asc'); } }; + const sortedEntries = useMemo(() => { + return [...entries].sort((a, b) => { + const aDir = a.type === 'dir'; const bDir = b.type === 'dir'; + if (aDir !== bDir) return aDir ? -1 : 1; + let av, bv; + if (sortKey === 'name') { av = (a.name || '').toLowerCase(); bv = (b.name || '').toLowerCase(); } + else if (sortKey === 'size') { av = Number(a.size || 0); bv = Number(b.size || 0); } + else if (sortKey === 'modified') { av = new Date(a.mtime || 0).getTime(); bv = new Date(b.mtime || 0).getTime(); } + else { av = ''; bv = ''; } + const r = typeof av === 'string' ? av.localeCompare(bv) : (av > bv ? 1 : av < bv ? -1 : 0); + return sortDir === 'asc' ? r : -r; + }); + }, [entries, sortKey, sortDir]); + const downloadFile = async (entry) => { - setWorking(`download:${entry.path}`); - setError(''); + setWorking(`dl:${entry.path}`); try { const data = await apiFetch(`/api/filebrowser?action=download&path=${encodeURIComponent(entry.path)}`); if (data.url && data.token) { - // Append token as query param for browser direct download const sep = data.url.includes('?') ? '&' : '?'; const a = document.createElement('a'); a.href = `${data.url}${sep}auth=${encodeURIComponent(data.token)}`; - a.download = entry.name; - a.target = '_blank'; - a.rel = 'noopener noreferrer'; - document.body.appendChild(a); - a.click(); - document.body.removeChild(a); + a.download = entry.name; a.target = '_blank'; a.rel = 'noopener noreferrer'; + document.body.appendChild(a); a.click(); document.body.removeChild(a); } - } catch (err) { - setError(err.message); - } finally { - setWorking(''); - } + } catch (err) { setError(err.message); } + finally { setWorking(''); } }; const deleteEntry = async (entry) => { - const kind = entry.type === 'dir' ? 'folder' : 'file'; - if (!window.confirm(`Delete "${entry.name}" ${kind}? This cannot be undone.`)) return; - - setWorking(`delete:${entry.path}`); - setError(''); + if (!window.confirm(`Delete "${entry.name}"? This cannot be undone.`)) return; + setWorking(`del:${entry.path}`); try { - await apiFetch(`/api/filebrowser?action=delete&path=${encodeURIComponent(entry.path)}`, { - method: 'DELETE', - }); + await apiFetch(`/api/filebrowser?action=delete&path=${encodeURIComponent(entry.path)}`, { method: 'DELETE' }); await loadFiles(currentPath); - } catch (err) { - setError(err.message); - } finally { - setWorking(''); + } catch (err) { setError(err.message); } + finally { setWorking(''); } + }; + + const deleteSelected = async () => { + if (!selected.size || !window.confirm(`Delete ${selected.size} item(s)?`)) return; + for (const name of selected) { + const entry = entries.find(e => e.name === name); + if (!entry) continue; + await apiFetch(`/api/filebrowser?action=delete&path=${encodeURIComponent(entry.path)}`, { method: 'DELETE' }).catch(() => {}); } + setSelected(new Set()); + await loadFiles(currentPath); }; const createFolder = async (e) => { e.preventDefault(); if (!folderName.trim()) return; - setWorking('mkdir'); - setError(''); try { - await apiFetch('/api/filebrowser?action=mkdir', { - method: 'POST', - body: JSON.stringify({ path: currentPath, name: folderName }), - }); - setFolderName(''); - setShowFolderInput(false); + await apiFetch('/api/filebrowser?action=mkdir', { method: 'POST', body: JSON.stringify({ path: currentPath, name: folderName }) }); + setFolderName(''); setShowFolderInput(false); await loadFiles(currentPath); - } catch (err) { - setError(err.message); - } finally { - setWorking(''); - } + } catch (err) { setError(err.message); } + finally { setWorking(''); } }; const renameEntry = async (e) => { e.preventDefault(); const newName = renameValue.trim(); - if (!newName || newName === renamingEntry.name) { - setRenamingEntry(null); - return; - } - - setWorking(`rename:${renamingEntry.path}`); - setError(''); + if (!newName || newName === renamingEntry.name) { setRenamingEntry(null); return; } + setWorking(`ren:${renamingEntry.path}`); try { - await apiFetch('/api/filebrowser?action=rename', { - method: 'POST', - body: JSON.stringify({ path: renamingEntry.path, name: newName }), - }); + await apiFetch('/api/filebrowser?action=rename', { method: 'POST', body: JSON.stringify({ path: renamingEntry.path, name: newName }) }); setRenamingEntry(null); await loadFiles(currentPath); - } catch (err) { - setError(err.message); - } finally { - setWorking(''); - } - }; - - const startRename = (entry) => { - setMovingEntry(null); - setRenamingEntry(entry); - setRenameValue(entry.name); + } catch (err) { setError(err.message); } + finally { setWorking(''); } }; async function uploadOneFile(url, token, fbPath, file, retries = 3) { for (let attempt = 1; attempt <= retries; attempt++) { try { const res = await fetch(`${url}/api/resources?source=files&path=${encodeURIComponent(fbPath)}&override=true`, { - method: 'POST', - headers: { - Authorization: `Bearer ${token}`, - 'Content-Type': 'application/octet-stream', - }, - body: file, + method: 'POST', headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/octet-stream' }, body: file, }); - if (!res.ok) { - const text = await res.text(); - throw new Error(`${text || res.status}`); - } + if (!res.ok) { const t = await res.text(); throw new Error(t || res.status); } return; - } catch (e) { - if (attempt === retries) throw new Error(`Upload failed for ${file.name} after ${retries} attempts: ${e.message}`); + } catch (err) { + if (attempt === retries) throw err; await new Promise(r => setTimeout(r, attempt * 1000)); } } } - async function runConcurrent(tasks, concurrency = 2) { - let index = 0; - let done = 0; - const total = tasks.length; - const errors = []; - await Promise.all(Array.from({ length: concurrency }, async () => { - while (index < total) { - const task = tasks[index++]; - try { await task(); } catch (e) { errors.push(e); } - done++; - setUploadProgress(Math.round((done / total) * 100)); - } - })); - if (errors.length) throw errors[0]; - } - - // Upload files directly to FileBrowser using admin token const uploadFiles = async (files) => { - const selected = Array.from(files || []); - if (!selected.length) return; - - setWorking('upload'); - setUploadProgress(0); - setError(''); + const sel = Array.from(files || []); + if (!sel.length) return; + setWorking('upload'); setUploadProgress(0); setError(''); try { - const tokenData = await apiFetch('/api/filebrowser?action=upload-token', { - method: 'POST', - body: JSON.stringify({ path: currentPath }), - }); - + const tokenData = await apiFetch('/api/filebrowser?action=upload-token', { method: 'POST', body: JSON.stringify({ path: currentPath }) }); const { token, url, fbPath } = tokenData; - const tasks = selected.map(file => () => uploadOneFile(url, token, joinVirtualPath(fbPath, file.name), file)); - await runConcurrent(tasks); - setUploadProgress(100); - } catch (err) { - setError(err.message); - } finally { - setWorking(''); - setUploadProgress(null); + for (let i = 0; i < sel.length; i++) { + await uploadOneFile(url, token, joinVirtualPath(fbPath, sel[i].name), sel[i]); + setUploadProgress(Math.round(((i + 1) / sel.length) * 100)); + } + } catch (err) { setError(err.message); } + finally { + setWorking(''); setUploadProgress(null); if (fileInputRef.current) fileInputRef.current.value = ''; - setDragging(false); - await loadFiles(currentPath); + setDragging(false); await loadFiles(currentPath); } }; const uploadFolder = async (files) => { - const selected = Array.from(files || []).filter(f => f.webkitRelativePath); - if (!selected.length) return; - - setWorking('upload'); - setUploadProgress(0); - setError(''); - + const sel = Array.from(files || []).filter(f => f.webkitRelativePath); + if (!sel.length) return; + setWorking('upload'); setUploadProgress(0); setError(''); try { - const tokenData = await apiFetch('/api/filebrowser?action=upload-token', { - method: 'POST', - body: JSON.stringify({ path: currentPath }), - }); - + const tokenData = await apiFetch('/api/filebrowser?action=upload-token', { method: 'POST', body: JSON.stringify({ path: currentPath }) }); const { token, url, fbPath } = tokenData; - - // Create directories sequentially shallow-first const dirsNeeded = new Set(); - for (const file of selected) { + for (const file of sel) { const parts = file.webkitRelativePath.split('/').slice(0, -1); for (let i = 1; i <= parts.length; i++) dirsNeeded.add(parts.slice(0, i).join('/')); } - const sortedDirs = [...dirsNeeded].sort((a, b) => a.split('/').length - b.split('/').length); - for (const dir of sortedDirs) { - const dirFbPath = joinVirtualPath(fbPath, dir); - await fetch(`${url}/api/resources?source=files&path=${encodeURIComponent(dirFbPath)}&isDir=true`, { - method: 'POST', - headers: { Authorization: `Bearer ${token}` }, - }).catch(() => {}); + for (const dir of [...dirsNeeded].sort((a, b) => a.split('/').length - b.split('/').length)) { + await fetch(`${url}/api/resources?source=files&path=${encodeURIComponent(joinVirtualPath(fbPath, dir))}&isDir=true`, { method: 'POST', headers: { Authorization: `Bearer ${token}` } }).catch(() => {}); } - - // Upload files concurrently - const tasks = selected.map(file => () => uploadOneFile(url, token, joinVirtualPath(fbPath, file.webkitRelativePath), file)); - await runConcurrent(tasks); - setUploadProgress(100); - } catch (err) { - setError(err.message); - } finally { - setWorking(''); - setUploadProgress(null); + for (let i = 0; i < sel.length; i++) { + await uploadOneFile(url, token, joinVirtualPath(fbPath, sel[i].webkitRelativePath), sel[i]); + setUploadProgress(Math.round(((i + 1) / sel.length) * 100)); + } + } catch (err) { setError(err.message); } + finally { + setWorking(''); setUploadProgress(null); if (folderInputRef.current) folderInputRef.current.value = ''; await loadFiles(currentPath); } }; - const moveEntry = async (entry, targetFolderPath) => { - setWorking(`move:${entry.path}`); - setError(''); - try { - await apiFetch('/api/filebrowser?action=move', { - method: 'POST', - body: JSON.stringify({ srcPath: entry.path, dstPath: targetFolderPath }), - }); - setMovingEntry(null); - await loadFiles(currentPath); - } catch (err) { - setError(err.message); - } finally { - setWorking(''); - } - }; - - const handleDragEnter = (e) => { - e.preventDefault(); - if (!configured || loading || working || draggedEntry || readOnly) return; - setDragging(true); - }; - - const handleDragOver = (e) => { - e.preventDefault(); - if (!configured || loading || working || draggedEntry || readOnly) return; - e.dataTransfer.dropEffect = 'copy'; - setDragging(true); - }; - - const handleDragLeave = (e) => { - e.preventDefault(); - if (!e.currentTarget.contains(e.relatedTarget)) setDragging(false); - }; - - const readFsEntry = (entry) => new Promise((resolve) => { - if (entry.isFile) { - entry.file(file => { - const rel = entry.fullPath.replace(/^\//, ''); - Object.defineProperty(file, 'webkitRelativePath', { value: rel, writable: false, configurable: true }); - resolve([file]); - }); - } else if (entry.isDirectory) { - const reader = entry.createReader(); - const readAll = (acc) => reader.readEntries(async (entries) => { - if (!entries.length) { resolve(acc); return; } - const nested = await Promise.all(entries.map(readFsEntry)); - readAll([...acc, ...nested.flat()]); - }); - readAll([]); - } else { - resolve([]); - } - }); - + const handleDragEnter = (e) => { e.preventDefault(); if (!readOnly) setDragging(true); }; + const handleDragOver = (e) => { e.preventDefault(); if (!readOnly) { e.dataTransfer.dropEffect = 'copy'; setDragging(true); } }; + const handleDragLeave = (e) => { e.preventDefault(); if (!e.currentTarget.contains(e.relatedTarget)) setDragging(false); }; const handleDrop = async (e) => { - e.preventDefault(); - setDragging(false); - if (draggedEntry) return; - if (!configured || loading || working) return; - + e.preventDefault(); setDragging(false); + if (readOnly) return; const items = Array.from(e.dataTransfer.items || []); - const fsEntries = items.map(item => item.webkitGetAsEntry?.()).filter(Boolean); - - if (fsEntries.length && fsEntries.some(en => en.isDirectory)) { + const fsEntries = items.map(i => i.webkitGetAsEntry?.()).filter(Boolean); + if (fsEntries.some(en => en.isDirectory)) { + const readFsEntry = (entry) => new Promise(resolve => { + if (entry.isFile) { + entry.file(file => { + Object.defineProperty(file, 'webkitRelativePath', { value: entry.fullPath.replace(/^\//, ''), writable: false, configurable: true }); + resolve([file]); + }); + } else if (entry.isDirectory) { + const reader = entry.createReader(); + const readAll = acc => reader.readEntries(async entries => { + if (!entries.length) { resolve(acc); return; } + const nested = await Promise.all(entries.map(readFsEntry)); + readAll([...acc, ...nested.flat()]); + }); + readAll([]); + } else { resolve([]); } + }); const allFiles = (await Promise.all(fsEntries.map(readFsEntry))).flat(); uploadFolder(allFiles); } else { @@ -414,251 +305,201 @@ export default function FileBrowser({ initialPath = '/', rootPath = '/', showSyn } }; - const handleRowDragStart = (e, entry) => { - e.stopPropagation(); - setDraggedEntry(entry); - e.dataTransfer.effectAllowed = 'move'; + const toggleSelect = (name) => setSelected(prev => { const next = new Set(prev); next.has(name) ? next.delete(name) : next.add(name); return next; }); + + const openCtxMenu = (e, entry, childPath) => { + e.preventDefault(); e.stopPropagation(); + setCtxMenu({ x: e.clientX, y: e.clientY, entry, childPath }); }; - const handleRowDragEnd = () => { - setDraggedEntry(null); - setDragOverFolder(null); - }; - - const handleFolderDragOver = (e, folder) => { - if (!draggedEntry || draggedEntry.path === folder.path) return; - e.preventDefault(); - e.stopPropagation(); - e.dataTransfer.dropEffect = 'move'; - setDragOverFolder(folder.path); - }; - - const handleFolderDragLeave = (e) => { - if (!e.currentTarget.contains(e.relatedTarget)) setDragOverFolder(null); - }; - - const handleFolderDrop = (e, folder) => { - e.preventDefault(); - e.stopPropagation(); - setDragOverFolder(null); - if (!draggedEntry || draggedEntry.path === folder.path) return; - const entry = draggedEntry; - setDraggedEntry(null); - moveEntry(entry, folder.path); - }; + const CTX_ITEM = { display: 'flex', alignItems: 'center', gap: 8, padding: '7px 14px', fontSize: 13, cursor: 'pointer', color: 'var(--text-primary)', background: 'none', border: 'none', width: '100%', textAlign: 'left', borderRadius: 4, whiteSpace: 'nowrap' }; return ( -
- {(loading || working || uploadProgress !== null) && ( -
-
-
- )} - {dragging && ( -
-
-
↑
-
Drop files to upload
-
Files will be added to the current folder.
+
+
+
↑
+
Drop files to upload
)} -
-
- - {breadcrumbs.slice(pathParts(rootPath).length).map((part, index) => { - const absIndex = pathParts(rootPath).length + index; + {/* Toolbar */} +
+
+ + {breadcrumbs.slice(pathParts(rootPath).length).map((part, i) => { + const absIdx = pathParts(rootPath).length + i; return ( - + + / + + ); })}
-
+
+ {uploadProgress !== null && Uploading {uploadProgress}%} + {selected.size > 0 && ( + + )} {!readOnly && (showFolderInput ? (
- setFolderName(e.target.value)} - placeholder="Folder name" - autoFocus - disabled={!configured || loading || Boolean(working)} - style={{ fontSize: 13, padding: '4px 8px', borderRadius: 4, border: '1px solid var(--border)', background: 'var(--input-bg)', color: 'var(--text-primary)', width: 160 }} - /> - - Create - - + setFolderName(e.target.value)} placeholder="Folder name" disabled={Boolean(working)} style={{ fontSize: 12, padding: '3px 8px', borderRadius: 4, border: '1px solid var(--border)', background: 'var(--card-bg-2)', color: 'var(--text-primary)', width: 140 }} /> + Create +
) : ( - + ))} - loadFiles(currentPath)}> - ⟳ Refresh - - {entries.length > 0 && ( - downloadFile({ path: currentPath, name: breadcrumbs[breadcrumbs.length - 1] || 'files', type: 'dir' })}> - ↓ ZIP - - )} {!readOnly && ( <> - uploadFiles(e.target.files)} /> - uploadFolder(e.target.files)} {...{ webkitdirectory: '' }} /> - folderInputRef.current?.click()}> - ↑ Folder - - fileInputRef.current?.click()}> - ↑ Files - + uploadFolder(e.target.files)} {...{ webkitdirectory: '' }} /> + uploadFiles(e.target.files)} /> + folderInputRef.current?.click()}>↑ Folder + fileInputRef.current?.click()}>↑ Files )} + loadFiles(currentPath)}>⟳
{uploadProgress !== null && ( -
- Uploading... {uploadProgress}% +
+
)} - {error &&
{error}
} - - {draggedEntry && ( -
- Dragging "{draggedEntry.name}" β€” drop onto a folder to move it -
- )} - -
- {canGoUp && currentPath !== rootPath && ( - - )} - -
- - Name - Size - Modified - -
- + {/* File table */} +
+ {error &&
{error}
} {loading ? ( -
Loading files...
+
Loading…
) : entries.length === 0 ? ( -
-

No files here yet

-

Upload files or create a folder to start this workspace.

-
- ) : entries.map(entry => { - const isMoving = movingEntry?.path === entry.path; - const isRenaming = renamingEntry?.path === entry.path; - const targetFolders = entries.filter(e => e.type === 'dir' && e.path !== entry.path); - const isDragTarget = entry.type === 'dir' && draggedEntry && draggedEntry.path !== entry.path; - const isDragOver = dragOverFolder === entry.path; - - return ( -
handleRowDragStart(e, entry)} - onDragEnd={handleRowDragEnd} - onDragOver={isDragTarget ? (e) => handleFolderDragOver(e, entry) : undefined} - onDragLeave={isDragTarget ? handleFolderDragLeave : undefined} - onDrop={isDragTarget ? (e) => handleFolderDrop(e, entry) : undefined} - style={isDragOver ? { outline: '2px solid var(--accent)', borderRadius: 4 } : undefined} - > - - {isRenaming ? ( -
- setRenameValue(e.target.value)} - autoFocus - disabled={Boolean(working)} - style={{ fontSize: 13, padding: '2px 8px', borderRadius: 4, border: '1px solid var(--border)', background: 'var(--input-bg)', color: 'var(--text-primary)', flex: 1, minWidth: 0 }} - onKeyDown={(e) => { if (e.key === 'Escape') setRenamingEntry(null); }} - /> - - Save - - -
- ) : entry.type === 'dir' ? ( - - ) : ( - {entry.name} +
This folder is empty.
+ ) : ( + + + + + + + + + + + + Name + Size + Modified + + + + {canGoUp && currentPath !== rootPath && ( + loadFiles(parentPath)}> + + )} - {!isRenaming && ( - <> - {formatBytes(entry.size)} - {formatDate(entry.mtime)} - - {readOnly ? null : isMoving ? ( - <> - Move to: - {targetFolders.length === 0 ? ( - No folders here - ) : targetFolders.map(folder => ( - moveEntry(entry, folder.path)} - > - {folder.name} - - ))} - - - ) : ( - <> - downloadFile(entry)}> - ↓ - - - deleteEntry(entry)}> - βœ• - - - )} - - - )} - - ); - })} + {sortedEntries.map((entry, idx) => { + const isDir = entry.type === 'dir'; + const childPath = entry.path; + const isRenaming = renamingEntry?.path === entry.path; + const rowBg = idx % 2 === 0 ? 'var(--file-row-alt-bg, rgba(255,255,255,0.02))' : 'transparent'; + return ( + isDir && loadFiles(childPath)} + onContextMenu={e => openCtxMenu(e, entry, childPath)} + > + + + + + + + ); + })} + +
+ 0} onChange={e => setSelected(e.target.checked ? new Set(entries.map(en => en.name)) : new Set())} /> + +
+ +
+
+ +
+ Up one folder +
+
+
{ e.stopPropagation(); toggleSelect(entry.name); }}> + toggleSelect(entry.name)} onClick={e => e.stopPropagation()} /> + + {isRenaming ? ( +
e.stopPropagation()}> + setRenameValue(e.target.value)} disabled={Boolean(working)} onKeyDown={e => { if (e.key === 'Escape') setRenamingEntry(null); }} style={{ fontSize: 13, padding: '2px 8px', borderRadius: 4, border: '1px solid var(--border)', background: 'var(--card-bg-2)', color: 'var(--text-primary)', flex: 1, minWidth: 0 }} /> + Save + +
+ ) : ( +
+ + {isDir ? ( + + ) : ( + {entry.name} + )} +
+ )} +
{isDir ? 'β€”' : formatBytes(entry.size)}{formatDate(entry.mtime)} e.stopPropagation()}> + {!isRenaming && !readOnly && ( +
+ {!isDir && ( + downloadFile(entry)}>↓ + )} + + +
+ )} +
+ )}
-
+ + {/* Context menu */} + {ctxMenu && ( +
e.stopPropagation()} style={{ ...popupMenuStyle, position: 'fixed', left: ctxMenu.x, top: ctxMenu.y, zIndex: 1000, padding: '4px 0', minWidth: 150 }}> + {ctxMenu.entry && ctxMenu.entry.type !== 'dir' && ( + + )} + {ctxMenu.entry && ( + <> + +
+ + + )} +
+ )} +
); } diff --git a/src/components/Layout.jsx b/src/components/Layout.jsx index 12f1e50..4ae40d5 100644 --- a/src/components/Layout.jsx +++ b/src/components/Layout.jsx @@ -41,7 +41,7 @@ function TeamNav({ onNav }) { const isCompaniesActive = location.pathname === '/company' && !location.search.includes('tab=users'); const isUsersActive = location.pathname === '/company' && location.search.includes('tab=users'); - const isTasksActive = location.pathname === '/tasks' || location.pathname.startsWith('/requests/'); + const isTasksActive = location.pathname === '/tasks' || location.pathname.startsWith('/tasks/') || location.pathname.startsWith('/projects/') || location.pathname.startsWith('/requests/'); return (
@@ -69,7 +69,7 @@ function TeamNav({ onNav }) { function ClientNav({ onNav }) { const location = useLocation(); - const isTasksActive = location.pathname === '/tasks' || location.pathname.startsWith('/requests/'); + const isTasksActive = location.pathname === '/tasks' || location.pathname.startsWith('/tasks/') || location.pathname.startsWith('/projects/') || location.pathname.startsWith('/requests/'); const links = [ { to: '/dashboard', label: 'Dashboard', icon: ICONS.dashboard }, { to: '/tasks', label: 'Tasks', icon: ICONS.requests }, @@ -90,7 +90,7 @@ function ClientNav({ onNav }) { function ExternalNav({ onNav }) { const location = useLocation(); - const isTasksActive = location.pathname === '/tasks' || location.pathname.startsWith('/requests/'); + const isTasksActive = location.pathname === '/tasks' || location.pathname.startsWith('/tasks/') || location.pathname.startsWith('/projects/') || location.pathname.startsWith('/requests/'); const links = [ { to: '/dashboard', label: 'Dashboard', icon: ICONS.dashboard }, { to: '/tasks', label: 'Tasks', icon: ICONS.requests }, @@ -145,7 +145,7 @@ export default function Layout({ children }) { const firstName = currentUser?.name?.split(' ')[0] || ''; const isProfileRoute = location.pathname === '/profile' || location.pathname.startsWith('/profile/'); const isFileSharingRoute = location.pathname === '/file-sharing'; - const isTaskDetailRoute = location.pathname.startsWith('/requests/'); + const isTaskDetailRoute = location.pathname.startsWith('/tasks/') || location.pathname.startsWith('/projects/') || location.pathname.startsWith('/requests/'); const isRequestsRoute = location.pathname === '/tasks' || location.pathname === '/requests' || location.pathname === '/team/tasks' || isTaskDetailRoute; const headerTitle = isProfileRoute ? 'Profile' : isFileSharingRoute ? 'File Sharing' : isRequestsRoute && !isTaskDetailRoute ? 'Tasks & Projects' : !isTaskDetailRoute ? `Good ${timeOfDay}${firstName ? `, ${firstName}` : ''}` : null; const headerSubtitle = isProfileRoute @@ -291,7 +291,7 @@ export default function Layout({ children }) { <>
Tasks
{searchResults.tasks.map(r => ( -
{ navigate(`/requests/${r.id}`); setSearchQuery(''); setSearchOpen(false); }}> +
{ navigate(`/tasks/${r.id}`); setSearchQuery(''); setSearchOpen(false); }}> {ICONS.requests} {r.title}
diff --git a/src/lib/filebrowserFolders.js b/src/lib/filebrowserFolders.js index c00d391..8dab5ea 100644 --- a/src/lib/filebrowserFolders.js +++ b/src/lib/filebrowserFolders.js @@ -14,7 +14,7 @@ async function fbq(op, path, extra = {}) { await fetch(FBQ_FN, { method: 'POST', headers, body: extra.body ?? undefined }).catch(() => {}); } -function safeName(v) { +export function safeName(v) { return String(v || '').trim() .replace(/[\\/:*?"<>|#%{}^~[\]`]+/g, '-') .replace(/\s+/g, ' ') diff --git a/src/pages/Profile.jsx b/src/pages/Profile.jsx index 51deb3f..f8a7307 100644 --- a/src/pages/Profile.jsx +++ b/src/pages/Profile.jsx @@ -462,7 +462,7 @@ export default function ProfilePage() { {visibleRows.map((task) => ( - + {formatTaskStatus(task.status)} @@ -490,7 +490,7 @@ export default function ProfilePage() { {toSentenceTitle(ACTION_LABEL[e.action] || e.action)} {e.task_title && e.task_id && ( <> - + )} {e.task_title && !e.task_id && {e.task_title}}
diff --git a/src/pages/ProjectDetail.jsx b/src/pages/ProjectDetail.jsx index 0fe3b94..dcb2cc5 100644 --- a/src/pages/ProjectDetail.jsx +++ b/src/pages/ProjectDetail.jsx @@ -1,95 +1,86 @@ import { useState, useEffect } from 'react'; import { useParams, Link, useNavigate } from 'react-router-dom'; import Layout from '../components/Layout'; +import PageLoader from '../components/PageLoader'; import StatusBadge from '../components/StatusBadge'; import SortTh from '../components/SortTh'; import { supabase } from '../lib/supabase'; import { useAuth } from '../context/AuthContext'; -import { serviceTypes } from '../data/mockData'; -import { addDaysToDateOnly, getTodayDateOnlyEST } from '../lib/dates'; import { logActivity } from '../lib/activityLog'; import { createTaskFolder } from '../lib/filebrowserFolders'; import { useSortable } from '../hooks/useSortable'; +import { serviceTypes } from '../data/mockData'; +import { addDaysToDateOnly, getTodayDateOnlyEST } from '../lib/dates'; +import { popupOverlayStyle, popupSurfaceStyle } from '../lib/popupStyles'; +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 MODAL_LBL = { fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase', marginBottom: 6 }; +const MODAL_IN = { 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' }; +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 rLabel = (v) => 'R' + String(v || 0).padStart(2, '0'); -const emptyJobForm = () => ({ title: '', serviceType: '', deadline: addDaysToDateOnly(getTodayDateOnlyEST(), 3), description: '', requestedBy: '', isHot: false }); +const emptyJobForm = () => ({ title: '', serviceType: '', deadline: addDaysToDateOnly(getTodayDateOnlyEST(), 3), description: '', requestedBy: '' }); export default function ProjectDetailPage() { const { id } = useParams(); const navigate = useNavigate(); const { currentUser } = useAuth(); - const isClient = currentUser?.role === 'client'; + const isClient = currentUser?.role === 'client'; const isExternal = currentUser?.role === 'external'; - const isTeam = currentUser?.role === 'team'; + const isTeam = currentUser?.role === 'team'; - const [project, setProject] = useState(null); - const [company, setCompany] = useState(null); + const [project, setProject] = useState(null); + const [company, setCompany] = useState(null); + const [tasks, setTasks] = useState([]); + const [members, setMembers] = useState([]); + const [externalProfiles, setExtProfs] = useState([]); const [companyUsers, setCompanyUsers] = useState([]); - const [tasks, setTasks] = useState([]); - const [submissions, setSubmissions] = useState([]); - const [members, setMembers] = useState([]); - const [externalProfiles, setExternalProfiles] = useState([]); - const [loading, setLoading] = useState(true); + const [loading, setLoading] = useState(true); + const [activeTab, setActiveTab] = useState('tasks'); - const [editingName, setEditingName] = useState(false); - const [nameVal, setNameVal] = useState(''); - const [savingName, setSavingName] = useState(false); + const [editingName, setEditingName] = useState(false); + const [nameVal, setNameVal] = useState(''); + const [savingName, setSavingName] = useState(false); - const [showAddJob, setShowAddJob] = useState(false); - const [jobForm, setJobForm] = useState(emptyJobForm); - const [savingJob, setSavingJob] = useState(false); + const [showAddJob, setShowAddJob] = useState(false); + const [jobForm, setJobForm] = useState(emptyJobForm()); + const [savingJob, setSavingJob] = useState(false); - const [selectedExternal, setSelectedExternal] = useState(''); + const [selectedExt, setSelectedExt] = useState(''); const [addingMember, setAddingMember] = useState(false); - - const [filter, setFilter] = useState('all'); - const { sortKey, sortDir, toggle, sort } = useSortable('title'); - - const requesterOptions = [ - ...(currentUser ? [{ id: currentUser.id, name: `${currentUser.name} (You)`, email: currentUser.email || '' }] : []), - ...companyUsers.filter(u => u.id !== currentUser?.id), - ]; + const { sortKey, sortDir, toggle, sort } = useSortable('submitted_at'); useEffect(() => { async function load() { - try { - const { data: p } = await supabase.from('projects').select('*').eq('id', id).single(); - if (!p) return; - setProject(p); - - if (isClient) { - const [{ data: co }, { data: t }] = await Promise.all([ - supabase.from('companies').select('*').eq('id', p.company_id).single(), - supabase.from('tasks').select('*').eq('project_id', id).order('submitted_at', { ascending: false }), - ]); - setCompany(co); - setTasks(t || []); - if (t && t.length > 0) { - const { data: subs } = await supabase.from('submissions').select('id, task_id, submitted_by, submitted_by_name, version_number, type').in('task_id', t.map(task => task.id)).order('version_number'); - setSubmissions(subs || []); - } - } else { - const [{ data: co }, { data: t }, { data: users }, { data: pm }, { data: ext }] = await Promise.all([ - supabase.from('companies').select('*').eq('id', p.company_id).single(), - supabase.from('tasks').select('*').eq('project_id', id).order('submitted_at', { ascending: false }), - supabase.from('profiles').select('id, name, email').eq('company_id', p.company_id).eq('role', 'client'), - supabase.from('project_members').select('*, profile:profiles(id, name, email, role)').eq('project_id', id), - supabase.from('profiles').select('id, name, email').eq('role', 'external').order('name'), - ]); - setCompany(co); - setTasks(t || []); - setCompanyUsers(users || []); - setMembers(pm || []); - setExternalProfiles(ext || []); - } - } catch (error) { - console.error('ProjectDetailPage load failed:', error); - } finally { - setLoading(false); + const { data: p } = await supabase.from('projects').select('*').eq('id', id).single(); + if (!p) { setLoading(false); return; } + setProject(p); + const [{ data: co }, { data: t }] = await Promise.all([ + supabase.from('companies').select('*').eq('id', p.company_id).single(), + supabase.from('tasks').select('*').eq('project_id', id).order('submitted_at', { ascending: false }), + ]); + setCompany(co); + setTasks(t || []); + if (isTeam) { + const [{ data: users }, { data: pm }, { data: ext }] = await Promise.all([ + supabase.from('profiles').select('id, name, email').eq('company_id', p.company_id).eq('role', 'client'), + supabase.from('project_members').select('*, profile:profiles(id, name, email, role)').eq('project_id', id), + supabase.from('profiles').select('id, name, email').eq('role', 'external').order('name'), + ]); + setCompanyUsers(users || []); + setMembers(pm || []); + setExtProfs(ext || []); } + setLoading(false); } load(); }, [id]); // eslint-disable-line react-hooks/exhaustive-deps @@ -98,44 +89,26 @@ export default function ProjectDetailPage() { e.preventDefault(); if (!nameVal.trim()) return; setSavingName(true); - const { error } = await supabase.from('projects').update({ name: nameVal.trim() }).eq('id', id); - if (!error) { setProject(p => ({ ...p, name: nameVal.trim() })); setEditingName(false); } - else alert('Failed to save name.'); + await supabase.from('projects').update({ name: nameVal.trim() }).eq('id', id); + setProject(p => ({ ...p, name: nameVal.trim() })); + setEditingName(false); setSavingName(false); }; const handleDeleteProject = async () => { - if (!window.confirm(`Delete project "${project.name}"? All jobs and files will be permanently deleted.`)) return; - try { - const { data: { session } } = await supabase.auth.getSession(); - const res = await fetch(`/api/delete-project?id=${id}`, { - method: 'DELETE', - headers: { Authorization: `Bearer ${session?.access_token}` }, - }); - if (!res.ok) { const d = await res.json(); throw new Error(d.error || 'Delete failed'); } - } catch (err) { - alert(`Failed to delete project: ${err.message}`); - return; - } - navigate(isClient ? '/projects' : `/company/${company?.id}`); + if (!window.confirm(`Delete project "${project.name}"?`)) return; + const { data: { session } } = await supabase.auth.getSession(); + const res = await fetch(`/api/delete-project?id=${id}`, { method: 'DELETE', headers: { Authorization: `Bearer ${session?.access_token}` } }); + if (!res.ok) { const d = await res.json(); alert(d.error || 'Delete failed'); return; } + navigate(isClient ? '/tasks' : `/company/${company?.id}`); }; const handleDeleteTask = async (taskId, e) => { e.stopPropagation(); - if (!window.confirm('Delete this job? All submissions and files will be permanently deleted.')) return; - try { - const { data: { session } } = await supabase.auth.getSession(); - const res = await fetch(`/api/delete-task?id=${taskId}`, { - method: 'DELETE', - headers: { Authorization: `Bearer ${session?.access_token}` }, - }); - if (!res.ok) { const d = await res.json(); throw new Error(d.error || 'Delete failed'); } - const d = await res.json(); - if (d.archiveError) console.warn('[delete-task] archive error:', d.archiveError); - } catch (err) { - alert(`Failed to delete job: ${err.message}`); - return; - } + if (!window.confirm('Delete this task?')) return; + const { data: { session } } = await supabase.auth.getSession(); + const res = await fetch(`/api/delete-task?id=${taskId}`, { method: 'DELETE', headers: { Authorization: `Bearer ${session?.access_token}` } }); + if (!res.ok) { const d = await res.json(); alert(d.error || 'Delete failed'); return; } setTasks(prev => prev.filter(t => t.id !== taskId)); }; @@ -146,7 +119,7 @@ export default function ProjectDetailPage() { if (!requestor) { setSavingJob(false); return; } const { data: task } = await supabase.from('tasks').insert({ project_id: id, title: jobForm.title.trim(), status: 'not_started', current_version: 0 }).select().single(); if (task) { - await supabase.from('submissions').insert({ task_id: task.id, version_number: 0, type: 'initial', is_hot: jobForm.isHot, service_type: jobForm.serviceType, deadline: jobForm.deadline || null, description: jobForm.description.trim() || null, submitted_by: requestor.id, submitted_by_name: requestor.name.replace(' (You)', '') }); + await supabase.from('submissions').insert({ task_id: task.id, version_number: 0, type: 'initial', service_type: jobForm.serviceType, deadline: jobForm.deadline || null, description: jobForm.description.trim() || null, submitted_by: requestor.id, submitted_by_name: requestor.name.replace(' (You)', '') }); logActivity({ actorId: currentUser.id, actorName: currentUser.name, action: 'task_created', taskId: task.id, taskTitle: task.title, projectId: id, projectName: project?.name }); createTaskFolder(company?.name, project?.name, task.title).catch(() => {}); setTasks(prev => [task, ...prev]); @@ -157,9 +130,9 @@ export default function ProjectDetailPage() { }; const handleAddMember = async () => { - if (!selectedExternal) return; - const { data } = await supabase.from('project_members').insert({ project_id: id, profile_id: selectedExternal }).select('*, profile:profiles(id, name, email)').single(); - if (data) { setMembers(prev => [...prev, data]); setSelectedExternal(''); setAddingMember(false); } + if (!selectedExt) return; + const { data } = await supabase.from('project_members').insert({ project_id: id, profile_id: selectedExt }).select('*, profile:profiles(id, name, email)').single(); + if (data) { setMembers(prev => [...prev, data]); setSelectedExt(''); setAddingMember(false); } }; const handleRemoveMember = async (profileId) => { @@ -167,282 +140,276 @@ export default function ProjectDetailPage() { setMembers(prev => prev.filter(m => m.profile_id !== profileId)); }; + if (loading) return ; + if (!project) return

Project not found.

; - if (loading) return

Loading...

; - if (!project) return

Project not found.

; + const requesterOptions = [ + ...(currentUser ? [{ id: currentUser.id, name: `${currentUser.name} (You)` }] : []), + ...companyUsers.filter(u => u.id !== currentUser?.id), + ]; - const filteredTasks = isClient && filter === 'mine' - ? tasks.filter(task => { - const initial = submissions.find(s => s.task_id === task.id && s.type === 'initial'); - return initial?.submitted_by === currentUser.id; - }) - : tasks; - const sortedTasks = sort(filteredTasks, (task, key) => { - if (key === 'title') return task.title || ''; - if (key === 'assigned_name') return task.assigned_name || ''; + const sortedTasks = sort(tasks, (task, key) => { + if (key === 'title') return task.title || ''; + if (key === 'assigned_name') return task.assigned_name || ''; if (key === 'current_version') return Number(task.current_version || 0); - if (key === 'status') return task.status || ''; - if (key === 'submitted_at') return task.submitted_at || ''; + if (key === 'status') return task.status || ''; + if (key === 'submitted_at') return task.submitted_at || ''; return ''; }); + const tabs = [ + { id: 'tasks', label: 'Tasks' }, + ...(isTeam ? [{ id: 'members', label: 'Members' }] : []), + ]; + + const fmtDate = (d) => d ? new Date(d).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) : 'β€”'; + return ( - +
-
-
- {editingName && (isTeam || isClient) ? ( -
- setNameVal(e.target.value)} autoFocus required style={{ fontSize: 22, fontWeight: 400, padding: '4px 10px', margin: 0, width: 280 }} /> - - -
- ) : ( -
-
{project.name}
- {(isTeam || isClient) && ( - + {/* Left 70% */} +
+ + {/* Header card */} +
+
+ {editingName && isTeam ? ( +
+ setNameVal(e.target.value)} style={{ ...MODAL_IN, fontSize: 20, padding: '4px 10px', flex: 1 }} /> + + +
+ ) : ( +
{project.name}
+ )} +
+ {isTeam && !editingName && ( + + )} + {isTeam && ( + + )} + {isClient && ( + + Task + )} + {(isTeam || (isClient && tasks.length === 0)) && ( + + )} +
+
+ +
+ +
+ +
+ {!isClient && company && ( +
+
Company
+ {isTeam + ? {company.name} + :
{company.name}
+ } +
+ )} +
+
Started
+
{fmtDate(project.created_at)}
+
+
+
Tasks
+
{tasks.length}
+
+
+
In Progress
+
{tasks.filter(t => t.status === 'in_progress').length}
+
+
+
In Review
+
{tasks.filter(t => t.status === 'client_review').length}
+
+
+
Approved
+
{tasks.filter(t => ['client_approved','invoiced','paid'].includes(t.status)).length}
+
+
+
+ + {/* Tabs + content card */} +
+
+ {tabs.map(tab => ( + + ))} +
+ +
+ {activeTab === 'tasks' && ( + tasks.length === 0 + ?
No tasks yet.
+ :
+ + + + + + + + {isTeam && } + + + + Task + Assigned + Rev + Status + Created + {isTeam && + + + {sortedTasks.map(task => ( + navigate(`/tasks/${task.id}`)} style={{ cursor: 'pointer' }}> + + + + + + {isTeam && ( + + )} + + ))} + +
} +
+ e.stopPropagation()}>{task.title} + {task.assigned_name || 'Unassigned'}R{String(task.current_version || 0).padStart(2, '0')}{fmtDate(task.submitted_at)} e.stopPropagation()}> + +
+
+ )} + + {activeTab === 'members' && isTeam && ( +
+
+
External members assigned to this project.
+ {!addingMember && } +
+ {addingMember && ( +
+ + + +
+ )} + {members.filter(m => m.profile?.role === 'external').length === 0 + ?
No external members.
+ : members.filter(m => m.profile?.role === 'external').map(m => ( +
+
+
{m.profile?.name}
+
{m.profile?.email}
+
+ +
+ )) + } +
)}
- )} -
- {!isClient && company && ( - <> - {isExternal - ? {company.name} - : {company.name} - } - {' Β· '} - - )} - {isClient - ? `${tasks.length} request${tasks.length !== 1 ? 's' : ''} Β· Started ${new Date(project.created_at).toLocaleDateString()}` - : `Started ${new Date(project.created_at).toLocaleDateString()}` - }
-
- - {isClient && ( - <> - {tasks.length === 0 && ( - + + {/* Right 30% */} +
+
+
Project
+
+
+
Status
+ +
+ {company && ( +
+
Company
+ {isTeam + ? {company.name} + :
{company.name}
+ } +
)} - + Add Request - - )} - {isTeam && ( - <> - - - - )} +
+
Started
+
{fmtDate(project.created_at)}
+
+
+
+ +
+
Summary
+
+ {[ + { label: 'Total', value: tasks.length }, + { label: 'Not Started', value: tasks.filter(t => t.status === 'not_started').length }, + { label: 'In Progress', value: tasks.filter(t => t.status === 'in_progress').length }, + { label: 'On Hold', value: tasks.filter(t => t.status === 'on_hold').length }, + { label: 'In Review', value: tasks.filter(t => t.status === 'client_review').length }, + { label: 'Approved', value: tasks.filter(t => ['client_approved','invoiced','paid'].includes(t.status)).length }, + ].map(({ label, value }) => ( +
+
{label}
+
0 ? 'var(--text-primary)' : 'var(--text-muted)', fontVariantNumeric: 'tabular-nums' }}>{value}
+
+ ))} +
+
- {/* Team: Add job form */} - {isTeam && showAddJob && ( -
-
Add Job β€” {project.name}
-
-
-
- - setJobForm(f => ({ ...f, title: e.target.value }))} required autoFocus /> + {/* Add Task modal β€” team only */} + {showAddJob && isTeam && ( +
{ setShowAddJob(false); setJobForm(emptyJobForm()); }}> +
e.stopPropagation()}> +
Add Task β€” {project.name}
+ +
+
Task Title *
+ setJobForm(f => ({ ...f, title: e.target.value }))} placeholder="e.g. Logo Design" style={MODAL_IN} />
-
- - setJobForm(f => ({ ...f, serviceType: e.target.value }))} style={MODAL_IN}> + {serviceTypes.map(s => )}
-
-
-
- - setJobForm(f => ({ ...f, deadline: e.target.value }))} /> +
+
Deadline
+ setJobForm(f => ({ ...f, deadline: e.target.value }))} style={MODAL_IN} />
-
- - setJobForm(f => ({ ...f, requestedBy: e.target.value }))} style={MODAL_IN}> + + {requesterOptions.map(u => )}
-
-
- -
-
- -