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 <noreply@anthropic.com>
This commit is contained in:
Krao Hasanee
2026-05-31 11:02:40 -04:00
parent 0b4705311b
commit 5b3060e190
12 changed files with 724 additions and 2393 deletions
+5 -10
View File
@@ -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 <Navigate to={`/projects/${id}`} replace />;
}
function RedirectRequestDetail() {
function RedirectToTask() {
const { id } = useParams();
return <Navigate to={`/requests/${id}`} replace />;
return <Navigate to={`/tasks/${id}`} replace />;
}
function NavigateCompanyDetail() {
@@ -95,8 +92,6 @@ export default function App() {
<Route path="/dashboard" element={<ProtectedRoute role={['team', 'external', 'client']}><TeamDashboard /></ProtectedRoute>} />
<Route path="/projects/:id" element={<ProtectedRoute role={['team', 'external', 'client']}><ProjectDetailPage /></ProtectedRoute>} />
<Route path="/tasks/:id" element={<ProtectedRoute role={['team', 'external', 'client']}><TaskDetail /></ProtectedRoute>} />
<Route path="/requests/:id" element={<ProtectedRoute role={['team', 'external', 'client']}><RequestDetail /></ProtectedRoute>} />
<Route path="/requests/:id/v2" element={<ProtectedRoute role={['team', 'external', 'client']}><TaskDetail /></ProtectedRoute>} />
<Route path="/company" element={<ProtectedRoute role={['team', 'client']}><CompaniesPage /></ProtectedRoute>} />
<Route path="/company/:id" element={<ProtectedRoute role={['team', 'client']}><CompanyDetail /></ProtectedRoute>} />
<Route path="/companies" element={<Navigate to="/company" replace />} />
@@ -132,12 +127,12 @@ export default function App() {
<Route path="/my-dashboard" element={<Navigate to="/dashboard" replace />} />
<Route path="/my-company" element={<Navigate to="/company" replace />} />
<Route path="/my-requests" element={<Navigate to="/tasks" replace />} />
<Route path="/my-requests/:id" element={<RedirectRequestDetail />} />
<Route path="/my-requests/:id" element={<RedirectToTask />} />
<Route path="/my-projects" element={<Navigate to="/tasks" replace />} />
<Route path="/my-projects/:id" element={<RedirectProjectDetail />} />
<Route path="/my-invoices" element={<ProtectedRoute role="client"><ClientMyInvoices /></ProtectedRoute>} />
<Route path="/new-request" element={<ProtectedRoute role="client"><ClientNewRequest /></ProtectedRoute>} />
<Route path="/new-project" element={<ProtectedRoute role="client"><ClientNewProject /></ProtectedRoute>} />
<Route path="/new-request" element={<Navigate to="/tasks" replace />} />
<Route path="/new-project" element={<Navigate to="/tasks" replace />} />
<Route path="/pay/:id" element={<PayInvoice />} />
<Route path="*" element={<Navigate to="/" replace />} />
+280 -439
View File
@@ -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 <span className="file-icon">📁</span>;
if (entry.type === 'dir') {
return (
<div style={{ width: 24, height: 24, display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="var(--accent)" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M22 19a2 2 0 01-2 2H4a2 2 0 01-2-2V5a2 2 0 012-2h5l2 3h9a2 2 0 012 2z" />
</svg>
</div>
);
}
const ext = (entry.name.includes('.') ? entry.name.split('.').pop() : '').toUpperCase().slice(0, 4) || 'FILE';
const { bg, color } = fileIconStyle(ext);
return (
<span className="file-icon" style={{ background: bg, color, border: 'none', fontSize: 9, fontWeight: 400, letterSpacing: 0.4, fontFamily: 'monospace' }}>
{ext}
</span>
<div style={{ width: 24, height: 24, borderRadius: 4, background: bg, display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
<span style={{ color, fontSize: 7, fontWeight: 600, letterSpacing: 0.3, fontFamily: 'monospace' }}>{ext}</span>
</div>
);
}
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);
}
} catch (err) {
setError(err.message);
} finally {
setWorking('');
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(''); }
};
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) => {
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 (readOnly) return;
const items = Array.from(e.dataTransfer.items || []);
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 => {
const rel = entry.fullPath.replace(/^\//, '');
Object.defineProperty(file, 'webkitRelativePath', { value: rel, writable: false, configurable: true });
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) => {
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([]);
}
} else { resolve([]); }
});
const handleDrop = async (e) => {
e.preventDefault();
setDragging(false);
if (draggedEntry) return;
if (!configured || loading || working) 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 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 (
<section
className={`file-browser${dragging ? ' file-browser-dragging' : ''}`}
<div
style={{ display: 'flex', flexDirection: 'column', flex: 1, minHeight: 0, borderRadius: 8, border: `1px solid ${dragging ? 'var(--accent)' : 'var(--border)'}`, transition: 'border-color 140ms ease', position: 'relative', overflow: 'hidden' }}
onDragEnter={handleDragEnter}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
>
{(loading || working || uploadProgress !== null) && (
<div className="file-browser-progress">
<div
className={`file-browser-progress-bar${uploadProgress === null ? ' indeterminate' : ''}`}
style={uploadProgress !== null ? { width: `${uploadProgress}%` } : undefined}
/>
</div>
)}
{dragging && (
<div className="file-drop-overlay">
<div className="file-drop-panel">
<div className="file-drop-icon"></div>
<div className="file-drop-title">Drop files to upload</div>
<div className="file-drop-subtitle">Files will be added to the current folder.</div>
<div style={{ position: 'absolute', inset: 0, zIndex: 10, background: 'rgba(245,165,35,0.08)', display: 'flex', alignItems: 'center', justifyContent: 'center', pointerEvents: 'none' }}>
<div style={{ textAlign: 'center' }}>
<div style={{ fontSize: 28, marginBottom: 8 }}></div>
<div style={{ fontSize: 14, fontWeight: 500, color: 'var(--accent)' }}>Drop files to upload</div>
</div>
</div>
)}
<div className="file-browser-toolbar">
<div className="file-browser-breadcrumbs">
<button type="button" onClick={() => loadFiles(rootPath)} className="file-breadcrumb">Files</button>
{breadcrumbs.slice(pathParts(rootPath).length).map((part, index) => {
const absIndex = pathParts(rootPath).length + index;
{/* Toolbar */}
<div style={{ padding: '14px 18px', borderBottom: '1px solid var(--border)', flexShrink: 0, display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, flexWrap: 'wrap' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 11, fontWeight: 500, letterSpacing: 0.6, textTransform: 'uppercase', color: 'var(--text-secondary)', flexWrap: 'wrap' }}>
<button type="button" onClick={() => loadFiles(rootPath)} style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 11, fontWeight: 500, letterSpacing: 0.6, textTransform: 'uppercase', color: 'var(--text-primary)', fontFamily: 'inherit', padding: 0 }}>Files</button>
{breadcrumbs.slice(pathParts(rootPath).length).map((part, i) => {
const absIdx = pathParts(rootPath).length + i;
return (
<button type="button" key={`${part}-${absIndex}`} onClick={() => loadFiles(pathTo(absIndex, breadcrumbs))} className="file-breadcrumb">
{part}
</button>
<span key={`${part}-${absIdx}`} style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
<span style={{ opacity: 0.4 }}>/</span>
<button type="button" onClick={() => loadFiles(pathTo(absIdx, breadcrumbs))} style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 11, fontWeight: 500, letterSpacing: 0.6, textTransform: 'uppercase', color: absIdx === breadcrumbs.length - 1 ? 'var(--accent)' : 'var(--text-primary)', fontFamily: 'inherit', padding: 0 }}>{part}</button>
</span>
);
})}
</div>
<div className="file-browser-actions">
<div style={{ display: 'flex', gap: 8, alignItems: 'center', flexShrink: 0 }}>
{uploadProgress !== null && <span style={{ fontSize: 12, color: 'var(--text-muted)' }}>Uploading {uploadProgress}%</span>}
{selected.size > 0 && (
<button className="btn btn-danger" style={{ fontSize: 11, padding: '3px 10px' }} onClick={deleteSelected}>Delete ({selected.size})</button>
)}
{!readOnly && (showFolderInput ? (
<form style={{ display: 'flex', gap: 6 }} onSubmit={createFolder}>
<input
type="text"
value={folderName}
onChange={(e) => 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 }}
/>
<LoadingButton type="submit" className="btn btn-outline btn-sm" loading={working === 'mkdir'} disabled={!folderName.trim() || !configured || loading || Boolean(working)} loadingText="Creating...">
Create
</LoadingButton>
<button type="button" className="btn btn-outline btn-sm" onClick={() => { setShowFolderInput(false); setFolderName(''); }}>Cancel</button>
<input autoFocus type="text" value={folderName} onChange={e => 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 }} />
<LoadingButton type="submit" className="btn btn-outline" style={{ fontSize: 11, padding: '3px 10px' }} loading={working === 'mkdir'} disabled={!folderName.trim() || Boolean(working)} loadingText="…">Create</LoadingButton>
<button type="button" className="btn btn-outline" style={{ fontSize: 11, padding: '3px 10px' }} onClick={() => { setShowFolderInput(false); setFolderName(''); }}>Cancel</button>
</form>
) : (
<button className="btn btn-outline btn-sm" disabled={!configured || loading || Boolean(working)} onClick={() => setShowFolderInput(true)}>
+ New Folder
</button>
<button className="btn btn-outline" style={{ fontSize: 11, padding: '3px 10px' }} disabled={Boolean(working)} onClick={() => setShowFolderInput(true)}>+ Folder</button>
))}
<LoadingButton className="btn btn-outline btn-sm" loading={loading} disabled={Boolean(working)} loadingText="Refreshing..." onClick={() => loadFiles(currentPath)}>
Refresh
</LoadingButton>
{entries.length > 0 && (
<LoadingButton className="btn btn-outline btn-sm" loading={working === `download:${currentPath}`} disabled={Boolean(working)} loadingText="Zipping..." onClick={() => downloadFile({ path: currentPath, name: breadcrumbs[breadcrumbs.length - 1] || 'files', type: 'dir' })}>
ZIP
</LoadingButton>
)}
{!readOnly && (
<>
<input ref={fileInputRef} type="file" multiple className="file-upload-input" onChange={(e) => uploadFiles(e.target.files)} />
<input ref={folderInputRef} type="file" className="file-upload-input" onChange={(e) => uploadFolder(e.target.files)} {...{ webkitdirectory: '' }} />
<LoadingButton className="btn btn-outline btn-sm" loading={working === 'upload'} disabled={!configured || loading || Boolean(working)} loadingText="Uploading..." onClick={() => folderInputRef.current?.click()}>
Folder
</LoadingButton>
<LoadingButton className="btn btn-primary btn-sm" loading={working === 'upload'} disabled={!configured || loading || Boolean(working)} loadingText="Uploading..." onClick={() => fileInputRef.current?.click()}>
Files
</LoadingButton>
<input ref={folderInputRef} type="file" style={{ display: 'none' }} onChange={e => uploadFolder(e.target.files)} {...{ webkitdirectory: '' }} />
<input ref={fileInputRef} type="file" multiple style={{ display: 'none' }} onChange={e => uploadFiles(e.target.files)} />
<LoadingButton className="btn btn-outline" style={{ fontSize: 11, padding: '3px 10px' }} loading={working === 'upload'} disabled={Boolean(working)} loadingText="Uploading" onClick={() => folderInputRef.current?.click()}> Folder</LoadingButton>
<LoadingButton className="btn btn-primary" style={{ fontSize: 11, padding: '3px 10px' }} loading={working === 'upload'} disabled={Boolean(working)} loadingText="Uploading…" onClick={() => fileInputRef.current?.click()}> Files</LoadingButton>
</>
)}
<LoadingButton className="btn btn-outline" style={{ fontSize: 11, padding: '3px 10px' }} loading={loading} disabled={Boolean(working)} loadingText="…" onClick={() => loadFiles(currentPath)}></LoadingButton>
</div>
</div>
{uploadProgress !== null && (
<div style={{ padding: '4px 12px', fontSize: 12, color: 'var(--text-muted)' }}>
Uploading... {uploadProgress}%
<div style={{ height: 2, background: 'var(--border)', flexShrink: 0 }}>
<div style={{ height: '100%', background: 'var(--accent)', width: `${uploadProgress}%`, transition: 'width 200ms' }} />
</div>
)}
{error && <div className="notification notification-info">{error}</div>}
{draggedEntry && (
<div style={{ padding: '6px 12px', fontSize: 12, color: 'var(--text-muted)' }}>
Dragging "{draggedEntry.name}" drop onto a folder to move it
</div>
)}
<div className="file-list">
{canGoUp && currentPath !== rootPath && (
<button type="button" className="file-row file-row-button" onClick={() => loadFiles(parentPath)} disabled={loading || Boolean(working)}>
<span className="file-icon"></span>
<span className="file-name">Up one folder</span>
<span className="file-meta"></span>
<span className="file-meta"></span>
<span />
</button>
)}
<div className="file-row file-row-head">
<span />
<span>Name</span>
<span style={{ textAlign: 'right' }}>Size</span>
<span style={{ textAlign: 'right' }}>Modified</span>
<span />
</div>
{/* File table */}
<div className="scrollbar-thin-theme table-scroll-hidden" style={{ flex: 1, minHeight: 0, overflowY: 'auto', padding: '18px 21px' }}>
{error && <div style={{ fontSize: 13, color: 'var(--danger)', marginBottom: 12 }}>{error}</div>}
{loading ? (
<div className="empty-state">Loading files...</div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', minHeight: 80, fontSize: 13, color: 'var(--text-muted)' }}>Loading</div>
) : entries.length === 0 ? (
<div className="empty-state">
<h3>No files here yet</h3>
<p>Upload files or create a folder to start this workspace.</p>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', minHeight: 80, fontSize: 13, color: 'var(--text-muted)' }}>This folder is empty.</div>
) : (
<table className="table-sticky-head" style={{ width: '100%', tableLayout: 'fixed', borderCollapse: 'collapse' }}>
<colgroup>
<col style={{ width: 32 }} />
<col />
<col style={{ width: 90 }} />
<col style={{ width: 180 }} />
<col style={{ width: 80 }} />
</colgroup>
<thead>
<tr>
<th style={{ ...TABLE_TH, textAlign: 'center', padding: '0 5px 12px' }}>
<input type="checkbox" checked={selected.size === entries.length && entries.length > 0} onChange={e => setSelected(e.target.checked ? new Set(entries.map(en => en.name)) : new Set())} />
</th>
<SortTh col="name" sortKey={sortKey} sortDir={sortDir} onSort={toggleSort} style={{ ...TABLE_TH, textAlign: 'left', paddingLeft: 5 }}>Name</SortTh>
<SortTh col="size" sortKey={sortKey} sortDir={sortDir} onSort={toggleSort} style={{ ...TABLE_TH, textAlign: 'right' }}>Size</SortTh>
<SortTh col="modified" sortKey={sortKey} sortDir={sortDir} onSort={toggleSort} style={{ ...TABLE_TH, textAlign: 'right' }}>Modified</SortTh>
<th style={{ ...TABLE_TH }} />
</tr>
</thead>
<tbody>
{canGoUp && currentPath !== rootPath && (
<tr style={{ cursor: 'pointer' }} onClick={() => loadFiles(parentPath)}>
<td style={TABLE_TD} />
<td style={TABLE_TD}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<div style={{ width: 24, height: 24, display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="var(--text-muted)" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M19 12H5M12 5l-7 7 7 7" /></svg>
</div>
) : entries.map(entry => {
const isMoving = movingEntry?.path === entry.path;
<span style={{ fontSize: 13, color: 'var(--text-muted)' }}>Up one folder</span>
</div>
</td>
<td /><td /><td />
</tr>
)}
{sortedEntries.map((entry, idx) => {
const isDir = entry.type === 'dir';
const childPath = 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;
const rowBg = idx % 2 === 0 ? 'var(--file-row-alt-bg, rgba(255,255,255,0.02))' : 'transparent';
return (
<div
className={`file-row${isDragOver ? ' file-row-drag-over' : ''}`}
<tr
key={`${entry.type}:${entry.path}`}
draggable={!working}
onDragStart={(e) => 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}
style={{ cursor: isDir ? 'pointer' : 'default' }}
onClick={() => isDir && loadFiles(childPath)}
onContextMenu={e => openCtxMenu(e, entry, childPath)}
>
<FileIcon entry={entry} />
<td style={{ ...TABLE_TD, background: rowBg, textAlign: 'center' }} onClick={e => { e.stopPropagation(); toggleSelect(entry.name); }}>
<input type="checkbox" checked={selected.has(entry.name)} onChange={() => toggleSelect(entry.name)} onClick={e => e.stopPropagation()} />
</td>
<td style={{ ...TABLE_TD, background: rowBg }}>
{isRenaming ? (
<form style={{ display: 'flex', gap: 6, flex: 1 }} onSubmit={renameEntry}>
<input
type="text"
value={renameValue}
onChange={(e) => 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); }}
/>
<LoadingButton type="submit" className="btn btn-outline btn-sm" loading={working === `rename:${entry.path}`} disabled={!renameValue.trim() || Boolean(working)} loadingText="Renaming...">
Save
</LoadingButton>
<button type="button" className="btn btn-outline btn-sm" onClick={() => setRenamingEntry(null)}>Cancel</button>
<form style={{ display: 'flex', gap: 6 }} onSubmit={renameEntry} onClick={e => e.stopPropagation()}>
<input autoFocus type="text" value={renameValue} onChange={e => 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 }} />
<LoadingButton type="submit" className="btn btn-outline" style={{ fontSize: 11, padding: '2px 8px' }} loading={working === `ren:${entry.path}`} disabled={!renameValue.trim()} loadingText="…">Save</LoadingButton>
<button type="button" className="btn btn-outline" style={{ fontSize: 11, padding: '2px 8px' }} onClick={() => setRenamingEntry(null)}>Cancel</button>
</form>
) : entry.type === 'dir' ? (
<button type="button" className="file-name file-name-button" onClick={() => openFolder(entry)} disabled={Boolean(working)}>
{entry.name}
</button>
) : (
<span className="file-name">{entry.name}</span>
)}
{!isRenaming && (
<>
<span className="file-meta">{formatBytes(entry.size)}</span>
<span className="file-meta">{formatDate(entry.mtime)}</span>
<span className="file-row-actions">
{readOnly ? null : isMoving ? (
<>
<span style={{ fontSize: 11, color: 'var(--text-muted)', whiteSpace: 'nowrap' }}>Move to:</span>
{targetFolders.length === 0 ? (
<span style={{ fontSize: 11, color: 'var(--text-muted)' }}>No folders here</span>
) : targetFolders.map(folder => (
<LoadingButton
key={folder.path}
className="btn btn-outline btn-sm"
loading={working === `move:${entry.path}`}
disabled={Boolean(working)}
loadingText="Moving..."
onClick={() => moveEntry(entry, folder.path)}
>
{folder.name}
</LoadingButton>
))}
<button type="button" className="btn btn-outline btn-sm" onClick={() => setMovingEntry(null)} disabled={Boolean(working)}></button>
</>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<FileIcon entry={entry} />
{isDir ? (
<button type="button" onClick={e => { e.stopPropagation(); loadFiles(childPath); }} style={{ fontSize: 13, fontWeight: 400, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', textAlign: 'left', background: 'none', border: 'none', cursor: 'pointer', padding: 0, color: 'var(--accent)' }}>{entry.name}</button>
) : (
<>
<LoadingButton className="btn-icon" title={entry.type === 'dir' ? 'Download ZIP' : 'Download'} loading={working === `download:${entry.path}`} disabled={Boolean(working)} loadingText="↓" onClick={() => downloadFile(entry)}>
</LoadingButton>
<button type="button" className="btn-icon" title="Rename" disabled={Boolean(working)} onClick={() => startRename(entry)}>
<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>
</button>
<LoadingButton className="btn-icon btn-icon-danger" title="Delete" loading={working === `delete:${entry.path}`} disabled={Boolean(working)} loadingText="…" onClick={() => deleteEntry(entry)}>
</LoadingButton>
</>
)}
</span>
</>
<span style={{ fontSize: 13, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{entry.name}</span>
)}
</div>
)}
</td>
<td style={{ ...TABLE_TD, background: rowBg, textAlign: 'right', fontSize: 12, color: 'var(--text-muted)' }}>{isDir ? '—' : formatBytes(entry.size)}</td>
<td style={{ ...TABLE_TD, background: rowBg, textAlign: 'right', fontSize: 12, color: 'var(--text-muted)' }}>{formatDate(entry.mtime)}</td>
<td style={{ ...TABLE_TD, background: rowBg, textAlign: 'right' }} onClick={e => e.stopPropagation()}>
{!isRenaming && !readOnly && (
<div style={{ display: 'flex', gap: 6, justifyContent: 'flex-end' }}>
{!isDir && (
<LoadingButton className="btn btn-outline" style={{ fontSize: 10, padding: '1px 8px', height: 22 }} loading={working === `dl:${entry.path}`} disabled={Boolean(working)} loadingText="↓" onClick={() => downloadFile(entry)}></LoadingButton>
)}
<button className="btn btn-outline" style={{ fontSize: 10, padding: '1px 8px', height: 22 }} disabled={Boolean(working)} onClick={() => { setRenamingEntry(entry); setRenameValue(entry.name); }}>
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 013 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>
</button>
<button className="btn btn-danger" style={{ fontSize: 10, padding: '1px 8px', height: 22 }} disabled={Boolean(working)} onClick={() => deleteEntry(entry)}></button>
</div>
)}
</td>
</tr>
);
})}
</tbody>
</table>
)}
</div>
{/* Context menu */}
{ctxMenu && (
<div onClick={e => 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' && (
<button style={CTX_ITEM} onMouseEnter={e => e.currentTarget.style.background = 'var(--interactive-row-hover)'} onMouseLeave={e => e.currentTarget.style.background = 'none'} onClick={() => { downloadFile(ctxMenu.entry); setCtxMenu(null); }}>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><polyline points="7,10 12,15 17,10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
Download
</button>
)}
{ctxMenu.entry && (
<>
<button style={CTX_ITEM} onMouseEnter={e => e.currentTarget.style.background = 'var(--interactive-row-hover)'} onMouseLeave={e => e.currentTarget.style.background = 'none'} onClick={() => { setRenamingEntry(ctxMenu.entry); setRenameValue(ctxMenu.entry.name); setCtxMenu(null); }}>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 013 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>
Rename
</button>
<div style={{ height: 1, background: 'var(--border)', margin: '4px 0' }} />
<button style={{ ...CTX_ITEM, color: 'var(--danger)' }} onMouseEnter={e => e.currentTarget.style.background = 'var(--interactive-row-hover)'} onMouseLeave={e => e.currentTarget.style.background = 'none'} onClick={() => { deleteEntry(ctxMenu.entry); setCtxMenu(null); }}>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><polyline points="3,6 5,6 21,6"/><path d="M19 6l-1 14a2 2 0 01-2 2H8a2 2 0 01-2-2L5 6"/><path d="M10 11v6M14 11v6"/><path d="M9 6V4a1 1 0 011-1h4a1 1 0 011 1v2"/></svg>
Delete
</button>
</>
)}
</div>
)}
</div>
</section>
);
}
+5 -5
View File
@@ -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 (
<div className="sidebar-section">
@@ -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 }) {
<>
<div className="site-header-dropdown-group">Tasks</div>
{searchResults.tasks.map(r => (
<div key={r.id} className="site-header-dropdown-item" onMouseDown={() => { navigate(`/requests/${r.id}`); setSearchQuery(''); setSearchOpen(false); }}>
<div key={r.id} className="site-header-dropdown-item" onMouseDown={() => { navigate(`/tasks/${r.id}`); setSearchQuery(''); setSearchOpen(false); }}>
<span className="nav-icon" style={{ opacity: 0.6 }}>{ICONS.requests}</span>
{r.title}
</div>
+1 -1
View File
@@ -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, ' ')
+2 -2
View File
@@ -462,7 +462,7 @@ export default function ProfilePage() {
{visibleRows.map((task) => (
<tr key={task.id} style={{ background: 'transparent' }}>
<td style={{ fontSize: 13, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', padding: '5px', border: 'none', background: 'transparent', textAlign: 'left' }}>
<button type="button" className="dashboard-inline-link" onClick={() => navigate(`/requests/${task.id}`)}>{task.title}</button>
<button type="button" className="dashboard-inline-link" onClick={() => navigate(`/tasks/${task.id}`)}>{task.title}</button>
</td>
<td style={{ fontSize: 12, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', padding: '5px', border: 'none', background: 'transparent', textAlign: 'right' }}>
{formatTaskStatus(task.status)}
@@ -490,7 +490,7 @@ export default function ProfilePage() {
<span style={{ color: 'var(--text-muted)' }}>{toSentenceTitle(ACTION_LABEL[e.action] || e.action)}</span>
{e.task_title && e.task_id && (
<><span style={{ color: 'var(--text-muted)' }}> </span>
<button type="button" className="dashboard-inline-link" onClick={() => navigate(`/requests/${e.task_id}`)}>{e.task_title}</button></>
<button type="button" className="dashboard-inline-link" onClick={() => navigate(`/tasks/${e.task_id}`)}>{e.task_title}</button></>
)}
{e.task_title && !e.task_id && <span style={{ color: 'var(--text-primary)' }}> {e.task_title}</span>}
</div>
+249 -282
View File
@@ -1,19 +1,32 @@
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();
@@ -26,71 +39,49 @@ export default function ProjectDetailPage() {
const [project, setProject] = useState(null);
const [company, setCompany] = useState(null);
const [companyUsers, setCompanyUsers] = useState([]);
const [tasks, setTasks] = useState([]);
const [submissions, setSubmissions] = useState([]);
const [members, setMembers] = useState([]);
const [externalProfiles, setExternalProfiles] = useState([]);
const [externalProfiles, setExtProfs] = useState([]);
const [companyUsers, setCompanyUsers] = useState([]);
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 [showAddJob, setShowAddJob] = useState(false);
const [jobForm, setJobForm] = useState(emptyJobForm);
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;
if (!p) { setLoading(false); 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 }),
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'),
]);
setCompany(co);
setTasks(t || []);
setCompanyUsers(users || []);
setMembers(pm || []);
setExternalProfiles(ext || []);
setExtProfs(ext || []);
}
} catch (error) {
console.error('ProjectDetailPage load failed:', error);
} finally {
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 {
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(); throw new Error(d.error || 'Delete failed'); }
} catch (err) {
alert(`Failed to delete project: ${err.message}`);
return;
}
navigate(isClient ? '/projects' : `/company/${company?.id}`);
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 {
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(); 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;
}
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,17 +140,15 @@ export default function ProjectDetailPage() {
setMembers(prev => prev.filter(m => m.profile_id !== profileId));
};
if (loading) return <Layout><PageLoader /></Layout>;
if (!project) return <Layout><p style={{ padding: 24, color: 'var(--text-muted)' }}>Project not found.</p></Layout>;
if (loading) return <Layout><p style={{ padding: 24, color: 'var(--text-muted)' }}>Loading...</p></Layout>;
if (!project) return <Layout><p>Project not found.</p></Layout>;
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) => {
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);
@@ -186,219 +157,130 @@ export default function ProjectDetailPage() {
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 (
<Layout>
<button className="back-link" onClick={() => navigate(isClient ? '/projects' : isExternal ? '/dashboard' : `/company/${company?.id}`)}>
Back to {isClient ? 'Projects' : isExternal ? 'Dashboard' : company?.name}
</button>
<div style={{ display: 'flex', gap: 24, alignItems: 'stretch', flex: 1, minHeight: 0, overflow: 'hidden' }}>
<div className="page-header">
<div>
{editingName && (isTeam || isClient) ? (
<form onSubmit={handleSaveName} style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 4 }}>
<input type="text" value={nameVal} onChange={e => setNameVal(e.target.value)} autoFocus required style={{ fontSize: 22, fontWeight: 400, padding: '4px 10px', margin: 0, width: 280 }} />
<button type="submit" className="btn btn-primary btn-sm" disabled={savingName}>{savingName ? '...' : 'Save'}</button>
<button type="button" className="btn btn-outline btn-sm" onClick={() => setEditingName(false)}>Cancel</button>
{/* Left 70% */}
<div style={{ flex: '0 0 70%', minWidth: 0, display: 'flex', flexDirection: 'column', gap: 24, minHeight: 0 }}>
{/* Header card */}
<div className="card" style={CARD}>
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 12, marginBottom: 8 }}>
{editingName && isTeam ? (
<form onSubmit={handleSaveName} style={{ display: 'flex', alignItems: 'center', gap: 8, flex: 1 }}>
<input autoFocus required value={nameVal} onChange={e => setNameVal(e.target.value)} style={{ ...MODAL_IN, fontSize: 20, padding: '4px 10px', flex: 1 }} />
<button type="submit" className="btn btn-outline" disabled={savingName}>Save</button>
<button type="button" className="btn btn-outline" onClick={() => setEditingName(false)}>Cancel</button>
</form>
) : (
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<div className="page-title">{project.name}</div>
{(isTeam || isClient) && (
<button className="btn btn-outline btn-sm" onClick={() => { setNameVal(project.name); setEditingName(true); }}>Edit</button>
<div style={{ fontSize: 28, fontWeight: 500, color: 'var(--text-primary)', lineHeight: 1.2 }}>{project.name}</div>
)}
</div>
)}
<div className="page-subtitle">
{!isClient && company && (
<>
{isExternal
? <span style={{ color: 'var(--text-secondary)' }}>{company.name}</span>
: <Link to={`/company/${company.id}`} style={{ color: 'var(--accent)' }}>{company.name}</Link>
}
{' · '}
</>
)}
{isClient
? `${tasks.length} request${tasks.length !== 1 ? 's' : ''} · Started ${new Date(project.created_at).toLocaleDateString()}`
: `Started ${new Date(project.created_at).toLocaleDateString()}`
}
</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<StatusBadge status={project.status} />
{isClient && (
<>
{tasks.length === 0 && (
<button className="btn btn-outline btn-sm" style={{ color: 'var(--danger, #dc2626)', borderColor: 'var(--danger, #dc2626)' }} onClick={handleDeleteProject}>Delete Project</button>
)}
<Link to={`/new-request?project=${encodeURIComponent(project.name)}`} className="btn btn-primary">+ Add Request</Link>
</>
<div style={{ display: 'flex', gap: 8, flexShrink: 0 }}>
{isTeam && !editingName && (
<button className="btn btn-outline" onClick={() => { setNameVal(project.name); setEditingName(true); }}>Edit</button>
)}
{isTeam && (
<>
<button className="btn btn-outline btn-sm" style={{ color: 'var(--danger, #dc2626)', borderColor: 'var(--danger, #dc2626)' }} onClick={handleDeleteProject}>Delete Project</button>
<button className="btn btn-primary btn-sm" onClick={() => setShowAddJob(s => !s)}>{showAddJob ? 'Cancel' : '+ Add Job'}</button>
</>
<button className="btn btn-outline" onClick={() => setShowAddJob(s => !s)}>+ Task</button>
)}
</div>
</div>
{/* Team: Add job form */}
{isTeam && showAddJob && (
<div className="card" style={{ marginBottom: 24, border: '1px solid var(--accent)', borderRadius: 4 }}>
<div className="card-title">Add Job {project.name}</div>
<form onSubmit={handleAddJob}>
<div className="grid-2">
<div className="form-group">
<label>Job Title *</label>
<input type="text" placeholder="e.g. Logo Design" value={jobForm.title} onChange={e => setJobForm(f => ({ ...f, title: e.target.value }))} required autoFocus />
</div>
<div className="form-group">
<label>Service Type *</label>
<select value={jobForm.serviceType} onChange={e => setJobForm(f => ({ ...f, serviceType: e.target.value }))} required>
<option value="">Select a service...</option>
{serviceTypes.map(s => <option key={s} value={s}>{s}</option>)}
</select>
</div>
</div>
<div className="grid-2">
<div className="form-group">
<label>Deadline <span style={{ fontWeight: 400, color: 'var(--text-muted)' }}>(optional)</span></label>
<input type="date" value={jobForm.deadline} onChange={e => setJobForm(f => ({ ...f, deadline: e.target.value }))} />
</div>
<div className="form-group">
<label>Requested By *</label>
<select value={jobForm.requestedBy} onChange={e => setJobForm(f => ({ ...f, requestedBy: e.target.value }))} required>
<option value="">Select requester...</option>
{requesterOptions.map(u => <option key={u.id} value={u.id}>{u.name}{u.email ? ` (${u.email})` : ''}</option>)}
</select>
</div>
</div>
<div className="form-group" style={{ marginTop: -4 }}>
<label style={{ display: 'flex', alignItems: 'center', gap: 10, fontWeight: 500, cursor: 'pointer' }}>
<input type="checkbox" checked={jobForm.isHot} onChange={e => setJobForm(f => ({ ...f, isHot: e.target.checked }))} />
<span>Mark as Hot</span>
</label>
</div>
<div className="form-group">
<label>Notes <span style={{ fontWeight: 400, color: 'var(--text-muted)' }}>(optional)</span></label>
<textarea placeholder="Any details about the job..." value={jobForm.description} onChange={e => setJobForm(f => ({ ...f, description: e.target.value }))} style={{ minHeight: 80 }} />
</div>
<div className="action-buttons">
<button type="submit" className="btn btn-primary" disabled={savingJob}>{savingJob ? 'Adding...' : 'Add Job'}</button>
<button type="button" className="btn btn-outline" onClick={() => { setShowAddJob(false); setJobForm(emptyJobForm()); }}>Cancel</button>
</div>
</form>
</div>
)}
{/* Team/External: Project info cards */}
{!isClient && (
<div className="grid-2" style={{ marginBottom: 24 }}>
<div className="card">
<div className="card-title">Project Info</div>
<div className="detail-grid" style={{ marginBottom: 0 }}>
<div className="detail-item"><label>Company</label><p>{company?.name || '—'}</p></div>
<div className="detail-item"><label>Status</label><p><StatusBadge status={project.status} /></p></div>
<div className="detail-item"><label>Started</label><p>{new Date(project.created_at).toLocaleDateString()}</p></div>
</div>
</div>
<div className="card">
<div className="card-title">Project Summary</div>
<div className="detail-grid" style={{ marginBottom: 0 }}>
<div className="detail-item"><label>Total Tasks</label><p>{tasks.length}</p></div>
<div className="detail-item"><label>Completed</label><p>{tasks.filter(t => t.status === 'client_approved').length}</p></div>
<div className="detail-item"><label>In Progress</label><p>{tasks.filter(t => t.status === 'in_progress').length}</p></div>
<div className="detail-item"><label>Awaiting Review</label><p>{tasks.filter(t => t.status === 'client_review').length}</p></div>
</div>
</div>
</div>
)}
{/* Client: mine/all filter */}
{isClient && (
<div className="card page-toolbar" style={{ marginBottom: 16 }}>
<div className="page-toolbar-grid">
<div className="page-toolbar-section">
<div className="card-title" style={{ marginBottom: 10 }}>Filter</div>
<div className="page-toolbar-filters">
<button className={`btn btn-sm ${filter === 'all' ? 'btn-primary' : 'btn-outline'}`} onClick={() => setFilter('all')}>All Requests</button>
<button className={`btn btn-sm ${filter === 'mine' ? 'btn-primary' : 'btn-outline'}`} onClick={() => setFilter('mine')}>Mine Only</button>
<Link to="/tasks" className="btn btn-outline">+ Task</Link>
)}
{(isTeam || (isClient && tasks.length === 0)) && (
<button className="btn btn-danger" onClick={handleDeleteProject}>Delete</button>
)}
</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 20 }}>
<StatusBadge status={project.status} />
</div>
<div style={{ display: 'flex', gap: 30, flexWrap: 'wrap' }}>
{!isClient && company && (
<div>
<div style={META_LABEL}>Company</div>
{isTeam
? <Link to={`/company/${company.id}`} className="table-link" style={{ fontSize: 13 }}>{company.name}</Link>
: <div style={{ fontSize: 13, color: 'var(--text-primary)' }}>{company.name}</div>
}
</div>
)}
{/* Tasks / Requests */}
<div className="card" style={{ marginBottom: 24 }}>
<div className="card-title">{isClient ? 'Requests' : 'Tasks'}</div>
{filteredTasks.length === 0 ? (
<div className="empty-state">
<div className="empty-state-icon">📋</div>
<h3>{isClient && filter === 'mine' ? "You haven't submitted any requests to this project" : isClient ? 'No requests yet' : 'No jobs yet'}</h3>
{isClient ? (
<Link to={`/new-request?project=${encodeURIComponent(project.name)}`} className="btn btn-primary" style={{ marginTop: 16 }}>Add Request</Link>
) : isTeam ? (
<button className="btn btn-primary" style={{ marginTop: 16 }} onClick={() => setShowAddJob(true)}>+ Add Job</button>
) : null}
</div>
) : isClient ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{sortedTasks.map(task => {
const taskSubs = submissions.filter(s => s.task_id === task.id);
const initialSub = taskSubs.find(s => s.type === 'initial') || taskSubs[0];
const latestSub = taskSubs[taskSubs.length - 1];
const hasRevision = latestSub && initialSub && latestSub.id !== initialSub.id && latestSub.submitted_by_name !== initialSub.submitted_by_name;
const isMine = initialSub?.submitted_by === currentUser.id;
return (
<Link key={task.id} to={`/requests/${task.id}`} className="request-card" style={{ textDecoration: 'none', cursor: 'pointer', display: 'block' }}>
<div className="request-card-header">
<div>
<div className="request-card-title">
{task.title}{' '}
<span style={{ fontWeight: 400, color: 'var(--text-muted)', fontSize: 13 }}>{rLabel(task.current_version)}</span>
{isMine && <span style={{ marginLeft: 8, fontSize: 11, background: 'rgba(245,165,35,0.15)', color: 'var(--accent)', padding: '2px 8px', borderRadius: 4, fontWeight: 400 }}>Mine</span>}
<div style={META_LABEL}>Started</div>
<div style={{ fontSize: 13, color: 'var(--text-primary)' }}>{fmtDate(project.created_at)}</div>
</div>
<div className="request-card-meta" style={{ marginTop: 4 }}>
{initialSub?.submitted_by_name && <>By {initialSub.submitted_by_name}</>}
{hasRevision && <> · Updated by {latestSub.submitted_by_name}</>}
<div>
<div style={META_LABEL}>Tasks</div>
<div style={{ fontSize: 13, color: 'var(--text-primary)' }}>{tasks.length}</div>
</div>
<div>
<div style={META_LABEL}>In Progress</div>
<div style={{ fontSize: 13, color: 'var(--text-primary)' }}>{tasks.filter(t => t.status === 'in_progress').length}</div>
</div>
<div>
<div style={META_LABEL}>In Review</div>
<div style={{ fontSize: 13, color: 'var(--text-primary)' }}>{tasks.filter(t => t.status === 'client_review').length}</div>
</div>
<div>
<div style={META_LABEL}>Approved</div>
<div style={{ fontSize: 13, color: 'var(--text-primary)' }}>{tasks.filter(t => ['client_approved','invoiced','paid'].includes(t.status)).length}</div>
</div>
</div>
<StatusBadge status={task.status} />
</div>
</Link>
);
})}
{/* Tabs + content card */}
<div style={{ display: 'flex', flexDirection: 'column', flex: 1, minHeight: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 4, marginBottom: 10 }}>
{tabs.map(tab => (
<button key={tab.id} onClick={() => setActiveTab(tab.id)} style={TAB_STYLE(activeTab === tab.id)}>{tab.label}</button>
))}
</div>
) : (
<div className="table-wrapper">
<table>
<div className="card" style={{ ...CARD, flex: 1, minHeight: 0, overflowY: 'auto' }}>
{activeTab === 'tasks' && (
tasks.length === 0
? <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', flex: 1, minHeight: 120, color: 'var(--text-muted)', fontSize: 13 }}>No tasks yet.</div>
: <div className="scrollbar-thin-theme table-scroll-fade table-scroll-hidden" style={{ overflowY: 'auto' }}>
<table className="table-sticky-head" style={{ tableLayout: 'fixed', borderCollapse: 'collapse', width: '100%' }}>
<colgroup>
<col style={{ width: '35%' }} />
<col style={{ width: isTeam ? '18%' : '20%' }} />
<col style={{ width: '12%' }} />
<col style={{ width: '17%' }} />
<col style={{ width: '14%' }} />
{isTeam && <col style={{ width: '4%' }} />}
</colgroup>
<thead>
<tr>
<SortTh col="title" sortKey={sortKey} sortDir={sortDir} onSort={toggle}>Job</SortTh>
<SortTh col="assigned_name" sortKey={sortKey} sortDir={sortDir} onSort={toggle}>Assigned To</SortTh>
<SortTh col="current_version" sortKey={sortKey} sortDir={sortDir} onSort={toggle}>Revision</SortTh>
<SortTh col="title" sortKey={sortKey} sortDir={sortDir} onSort={toggle}>Task</SortTh>
<SortTh col="assigned_name" sortKey={sortKey} sortDir={sortDir} onSort={toggle}>Assigned</SortTh>
<SortTh col="current_version" sortKey={sortKey} sortDir={sortDir} onSort={toggle}>Rev</SortTh>
<SortTh col="status" sortKey={sortKey} sortDir={sortDir} onSort={toggle}>Status</SortTh>
<SortTh col="submitted_at" sortKey={sortKey} sortDir={sortDir} onSort={toggle}>Submitted</SortTh>
<th></th>
<SortTh col="submitted_at" sortKey={sortKey} sortDir={sortDir} onSort={toggle}>Created</SortTh>
{isTeam && <th />}
</tr>
</thead>
<tbody>
{sortedTasks.map(task => (
<tr key={task.id} onClick={() => navigate(`/requests/${task.id}`)} style={{ cursor: 'pointer' }}>
<td style={{ fontWeight: 400 }}>
{task.title}
<span style={{ marginLeft: 6, fontWeight: 400, color: 'var(--text-muted)', fontSize: 12 }}>{'R' + String(task.current_version || 0).padStart(2, '0')}</span>
<tr key={task.id} onClick={() => navigate(`/tasks/${task.id}`)} style={{ cursor: 'pointer' }}>
<td style={{ padding: 5 }}>
<Link to={`/tasks/${task.id}`} className="table-link" style={{ fontSize: 13 }} onClick={e => e.stopPropagation()}>{task.title}</Link>
</td>
<td style={{ color: task.assigned_name ? 'var(--text-primary)' : 'var(--text-muted)' }}>{task.assigned_name || 'Unassigned'}</td>
<td><span className="badge badge-not_started">R{String(task.current_version || 0).padStart(2, '0')}</span></td>
<td><StatusBadge status={task.status} /></td>
<td style={{ color: 'var(--text-muted)' }}>{new Date(task.submitted_at).toLocaleDateString()}</td>
<td style={{ fontSize: 12, color: task.assigned_name ? 'var(--text-primary)' : 'var(--text-muted)', padding: 5 }}>{task.assigned_name || 'Unassigned'}</td>
<td style={{ fontSize: 12, color: 'var(--text-primary)', padding: 5 }}>R{String(task.current_version || 0).padStart(2, '0')}</td>
<td style={{ padding: 5 }}><StatusBadge status={task.status} /></td>
<td style={{ fontSize: 12, color: 'var(--text-muted)', padding: 5 }}>{fmtDate(task.submitted_at)}</td>
{isTeam && (
<td onClick={e => e.stopPropagation()}>
<button type="button" onClick={e => handleDeleteTask(task.id, e)} style={{ background: 'none', border: 'none', color: 'var(--danger, #dc2626)', cursor: 'pointer', fontSize: 16, padding: '0 4px' }} title="Delete job"></button>
<td style={{ padding: 5 }} onClick={e => e.stopPropagation()}>
<button type="button" onClick={e => handleDeleteTask(task.id, e)} className="btn btn-danger" style={{ fontSize: 10, padding: '1px 8px', height: 22 }}>Del</button>
</td>
)}
</tr>
@@ -407,40 +289,125 @@ export default function ProjectDetailPage() {
</table>
</div>
)}
</div>
{/* Team: External members */}
{isTeam && (
<div className="card" style={{ marginTop: 24 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 14 }}>
<div className="card-title" style={{ margin: 0 }}>External Members</div>
{!addingMember && <button className="btn btn-outline btn-sm" onClick={() => setAddingMember(true)}>+ Add</button>}
{activeTab === 'members' && isTeam && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div style={{ fontSize: 13, color: 'var(--text-muted)' }}>External members assigned to this project.</div>
{!addingMember && <button className="btn btn-outline" onClick={() => setAddingMember(true)}>+ Add</button>}
</div>
{addingMember && (
<div style={{ display: 'flex', gap: 8, marginBottom: 12 }}>
<select value={selectedExternal} onChange={e => setSelectedExternal(e.target.value)} style={{ flex: 1 }}>
<option value="">Select external member...</option>
<div style={{ display: 'flex', gap: 8 }}>
<select value={selectedExt} onChange={e => setSelectedExt(e.target.value)} style={{ ...MODAL_IN, flex: 1 }}>
<option value="">Select external member</option>
{externalProfiles.filter(p => !members.find(m => m.profile_id === p.id)).map(p => <option key={p.id} value={p.id}>{p.name} {p.email}</option>)}
</select>
<button className="btn btn-primary btn-sm" onClick={handleAddMember} disabled={!selectedExternal}>Add</button>
<button className="btn btn-outline btn-sm" onClick={() => { setAddingMember(false); setSelectedExternal(''); }}>Cancel</button>
<button className="btn btn-outline" onClick={handleAddMember} disabled={!selectedExt}>Add</button>
<button className="btn btn-outline" onClick={() => { setAddingMember(false); setSelectedExt(''); }}>Cancel</button>
</div>
)}
{members.filter(m => m.profile?.role === 'external').length === 0 ? (
<p style={{ fontSize: 13, color: 'var(--text-muted)', margin: 0 }}>No external members assigned to this project.</p>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{members.filter(m => m.profile?.role === 'external').map(m => (
<div key={m.id} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '8px 12px', background: 'var(--bg)', borderRadius: 4, border: '1px solid var(--border)' }}>
{members.filter(m => m.profile?.role === 'external').length === 0
? <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', minHeight: 80, color: 'var(--text-muted)', fontSize: 13 }}>No external members.</div>
: members.filter(m => m.profile?.role === 'external').map(m => (
<div key={m.id} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '10px 14px', background: 'var(--card-bg-2)', borderRadius: 6, border: '1px solid var(--border)' }}>
<div>
<div style={{ fontSize: 13, fontWeight: 400, color: 'var(--text-primary)' }}>{m.profile?.name}</div>
<div style={{ fontSize: 11, color: 'var(--text-muted)' }}>{m.profile?.email}</div>
<div style={{ fontSize: 13, fontWeight: 500, color: 'var(--text-primary)' }}>{m.profile?.name}</div>
<div style={{ fontSize: 11, color: 'var(--text-muted)', marginTop: 1 }}>{m.profile?.email}</div>
</div>
<button onClick={() => handleRemoveMember(m.profile_id)} style={{ background: 'none', border: 'none', color: 'var(--danger)', cursor: 'pointer', fontSize: 16, padding: '0 4px' }} title="Remove from project"></button>
<button className="btn btn-danger" onClick={() => handleRemoveMember(m.profile_id)} style={{ fontSize: 10, padding: '1px 8px', height: 22 }}>Remove</button>
</div>
))
}
</div>
)}
</div>
</div>
</div>
{/* Right 30% */}
<div style={{ flex: '0 0 calc(30% - 24px)', minWidth: 0, display: 'flex', flexDirection: 'column', gap: 24 }}>
<div className="card" style={CARD}>
<div style={LABEL}>Project</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
<div>
<div style={META_LABEL}>Status</div>
<StatusBadge status={project.status} />
</div>
{company && (
<div>
<div style={META_LABEL}>Company</div>
{isTeam
? <Link to={`/company/${company.id}`} className="table-link" style={{ fontSize: 13 }}>{company.name}</Link>
: <div style={{ fontSize: 13, color: 'var(--text-primary)' }}>{company.name}</div>
}
</div>
)}
<div>
<div style={META_LABEL}>Started</div>
<div style={{ fontSize: 13, color: 'var(--text-primary)' }}>{fmtDate(project.created_at)}</div>
</div>
</div>
</div>
<div className="card" style={{ ...CARD, flex: 1 }}>
<div style={LABEL}>Summary</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
{[
{ 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 }) => (
<div key={label} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div style={{ fontSize: 12, color: 'var(--text-secondary)' }}>{label}</div>
<div style={{ fontSize: 13, fontWeight: 500, color: value > 0 ? 'var(--text-primary)' : 'var(--text-muted)', fontVariantNumeric: 'tabular-nums' }}>{value}</div>
</div>
))}
</div>
)}
</div>
</div>
</div>
{/* Add Task modal — team only */}
{showAddJob && isTeam && (
<div style={popupOverlayStyle} onClick={() => { setShowAddJob(false); setJobForm(emptyJobForm()); }}>
<div style={{ ...popupSurfaceStyle, background: 'var(--card-bg)', width: 'min(520px, 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)' }}>Add Task {project.name}</div>
<form onSubmit={handleAddJob} style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
<div>
<div style={MODAL_LBL}>Task Title *</div>
<input autoFocus required value={jobForm.title} onChange={e => setJobForm(f => ({ ...f, title: e.target.value }))} placeholder="e.g. Logo Design" style={MODAL_IN} />
</div>
<div>
<div style={MODAL_LBL}>Service Type *</div>
<select required value={jobForm.serviceType} onChange={e => setJobForm(f => ({ ...f, serviceType: e.target.value }))} style={MODAL_IN}>
<option value="">Select a service</option>
{serviceTypes.map(s => <option key={s} value={s}>{s}</option>)}
</select>
</div>
<div>
<div style={MODAL_LBL}>Deadline</div>
<input type="date" value={jobForm.deadline} onChange={e => setJobForm(f => ({ ...f, deadline: e.target.value }))} style={MODAL_IN} />
</div>
<div>
<div style={MODAL_LBL}>Requested By *</div>
<select required value={jobForm.requestedBy} onChange={e => setJobForm(f => ({ ...f, requestedBy: e.target.value }))} style={MODAL_IN}>
<option value="">Select requester</option>
{requesterOptions.map(u => <option key={u.id} value={u.id}>{u.name}</option>)}
</select>
</div>
<div>
<div style={MODAL_LBL}>Notes</div>
<textarea value={jobForm.description} onChange={e => setJobForm(f => ({ ...f, description: e.target.value }))} placeholder="Any details…" rows={3} style={{ ...MODAL_IN, resize: 'vertical' }} />
</div>
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end', paddingTop: 4 }}>
<button type="submit" className="btn btn-outline" disabled={savingJob}>{savingJob ? 'Saving…' : 'Save'}</button>
<button type="button" className="btn btn-outline" onClick={() => { setShowAddJob(false); setJobForm(emptyJobForm()); }}>Cancel</button>
</div>
</form>
</div>
</div>
)}
</Layout>
File diff suppressed because it is too large Load Diff
+27 -11
View File
@@ -2,6 +2,7 @@ import { useState, useEffect, useRef } from 'react';
import { useParams, Link } from 'react-router-dom';
import Layout from '../components/Layout';
import PageLoader from '../components/PageLoader';
import FileBrowser from '../components/FileBrowser';
import StatusBadge from '../components/StatusBadge';
import { useAuth } from '../context/AuthContext';
import { supabase } from '../lib/supabase';
@@ -9,12 +10,13 @@ import { logActivity } from '../lib/activityLog';
import JSZip from 'jszip';
import { popupOverlayStyle, popupSurfaceStyle } from '../lib/popupStyles';
import { sendEmail } from '../lib/email';
import { uploadFilesToRequestInfo } from '../lib/filebrowserFolders';
import { uploadFilesToRequestInfo, safeName } from '../lib/filebrowserFolders';
const ACTION_LABEL = {
task_created: 'created this task', task_started: 'started this task', task_on_hold: 'placed task on hold',
task_resumed: 'resumed this task', task_submitted: 'submitted this task', task_approved: 'approved this task',
revision_requested: 'rejected this task', request_submitted: 'submitted this request',
task_released: 'released this task',
};
const ACTION_ICON = {
@@ -23,6 +25,7 @@ const ACTION_ICON = {
task_submitted: { bg: 'rgba(74,222,128,0.15)', color: '#4ade80', path: 'M12 19V5M5 12l7-7 7 7' },
task_approved: { bg: 'rgba(74,222,128,0.15)', color: '#4ade80', path: 'M4 13l5 5L20 7' },
task_on_hold: { bg: 'rgba(239,68,68,0.15)', color: '#ef4444', path: 'M6 4h4v16H6zM14 4h4v16h-4z' },
task_released: { bg: 'rgba(239,68,68,0.15)', color: '#ef4444', path: 'M12 5v14M5 12h14' },
task_created: { bg: 'rgba(167,139,250,0.15)', color: '#a78bfa', path: 'M12 5v14M5 12h14' },
revision_requested: { bg: 'rgba(245,158,11,0.15)', color: '#f59e0b', path: 'M4 12a8 8 0 018-8v0a8 8 0 018 8' },
request_submitted: { bg: 'rgba(167,139,250,0.15)', color: '#a78bfa', path: 'M9 12h6M9 8h6M5 3h14a2 2 0 012 2v14a2 2 0 01-2 2H5a2 2 0 01-2-2V5a2 2 0 012-2z' },
@@ -292,6 +295,7 @@ export default function TaskDetail() {
const handleSendToReview = () => updateStatus('client_review', {}, 'task_submitted');
const handleClientApprove = () => updateStatus('client_approved', { completed_at: new Date().toISOString() }, 'task_approved');
const handleClientReject = () => updateStatus('not_started', { current_version: (task.current_version || 0) + 1, assigned_to: null, assigned_name: null }, 'revision_requested');
const handleRelease = () => updateStatus('not_started', { assigned_to: null, assigned_name: null }, 'task_released');
const handleSubmitRevision = async () => {
setRevisionSaving(true);
@@ -408,19 +412,23 @@ export default function TaskDetail() {
{isTeamOrSub && status === 'not_started' && (
<button className="btn btn-outline" style={{ fontSize: 11, padding: '4px 12px' }} disabled={statusSaving} onClick={handleStart}>Start Task</button>
)}
{isTeamOrSub && status === 'in_progress' && (
{isTeamOrSub && status === 'in_progress' && task.assigned_to === currentUser?.id && (
<>
<button className="btn btn-outline" style={{ fontSize: 11, padding: '4px 12px' }} disabled={statusSaving} onClick={handleOnHold}>Place on Hold</button>
<button className="btn btn-outline" style={{ fontSize: 12, padding: '4px 12px', color: 'var(--accent)', borderColor: 'var(--accent)' }} disabled={statusSaving} onClick={handleSendToReview}>Place in Review</button>
<button className="btn btn-outline" disabled={statusSaving} onClick={handleRelease}>Release</button>
<button className="btn btn-outline" disabled={statusSaving} onClick={handleOnHold}>Place on Hold</button>
<button className="btn btn-primary" disabled={statusSaving} onClick={handleSendToReview}>Place in Review</button>
</>
)}
{isTeamOrSub && status === 'on_hold' && (
<button className="btn btn-outline" style={{ fontSize: 11, padding: '4px 12px' }} disabled={statusSaving} onClick={handleResume}>Resume</button>
{isTeamOrSub && status === 'on_hold' && task.assigned_to === currentUser?.id && (
<>
<button className="btn btn-outline" disabled={statusSaving} onClick={handleRelease}>Release</button>
<button className="btn btn-outline" disabled={statusSaving} onClick={handleResume}>Resume</button>
</>
)}
{isClient && status === 'client_review' && (
<>
<button className="btn btn-outline" style={{ fontSize: 12, padding: '4px 12px', color: 'var(--accent)', borderColor: 'var(--accent)' }} disabled={statusSaving} onClick={handleClientApprove}>Approve</button>
<button className="btn btn-danger" style={{ fontSize: 11, padding: '4px 12px' }} disabled={statusSaving} onClick={handleClientReject}>Reject</button>
<button className="btn btn-primary" disabled={statusSaving} onClick={handleClientApprove}>Approve</button>
<button className="btn btn-danger" disabled={statusSaving} onClick={handleClientReject}>Reject</button>
</>
)}
{isClient && ['client_approved', 'invoiced', 'paid'].includes(status) && (
@@ -494,8 +502,8 @@ export default function TaskDetail() {
);
})}
</div>
<div className="card" style={{ ...CARD, flex: 1, minHeight: 0, overflowY: 'auto' }}>
<div style={{ fontSize: 13 }}>
<div className="card" style={{ ...CARD, flex: 1, minHeight: 0, overflowY: activeTab === 'Folder' ? 'hidden' : 'auto', padding: activeTab === 'Folder' ? 0 : CARD.padding, ...(activeTab === 'Folder' ? { display: 'flex', flexDirection: 'column' } : {}) }}>
<div style={{ fontSize: 13, ...(activeTab === 'Folder' ? { flex: 1, display: 'flex', flexDirection: 'column', minHeight: 0 } : {}) }}>
{activeTab === 'Overview' && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 20 }}>
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 32 }}>
@@ -645,7 +653,15 @@ export default function TaskDetail() {
}
</div>
)}
{activeTab === 'Folder' && <div style={{ color: 'var(--text-muted)' }}>Folder view coming soon.</div>}
{activeTab === 'Folder' && company?.name && project?.name && (
<FileBrowser
initialPath={`/Clients/${safeName(company.name)}/Projects/${safeName(project.name)}/${safeName(task.title)}`}
rootPath={`/Clients/${safeName(company.name)}/Projects/${safeName(project.name)}`}
/>
)}
{activeTab === 'Folder' && (!company?.name || !project?.name) && (
<div style={{ color: 'var(--text-muted)' }}>No folder available task needs a project and company.</div>
)}
</div>
</div>
</div>
+66 -21
View File
@@ -210,6 +210,8 @@ export default function RequestsPage() {
const [addProjectForm, setAddProjectForm] = useState({ name: '', companyId: '' });
const [companyFilterMenuOpen, setCompanyFilterMenuOpen] = useState(false);
const companyFilterMenuRef = useRef(null);
const [projectFilterMenuOpen, setProjectFilterMenuOpen] = useState(false);
const projectFilterMenuRef = useRef(null);
useEffect(() => {
if (!companyFilterMenuOpen) return;
@@ -220,6 +222,15 @@ export default function RequestsPage() {
return () => document.removeEventListener('mousedown', onDocClick);
}, [companyFilterMenuOpen]);
useEffect(() => {
if (!projectFilterMenuOpen) return;
function onDocClick(e) {
if (projectFilterMenuRef.current && !projectFilterMenuRef.current.contains(e.target)) setProjectFilterMenuOpen(false);
}
document.addEventListener('mousedown', onDocClick);
return () => document.removeEventListener('mousedown', onDocClick);
}, [projectFilterMenuOpen]);
useEffect(() => {
let cancelled = false;
async function loadTasksPage() {
@@ -260,7 +271,7 @@ export default function RequestsPage() {
}
const tasksQ = supabase.from('tasks')
.select('id, title, status, current_version, project_id, assigned_name, assigned_to, invoiced, completed_at, submitted_at')
.select('id, title, status, current_version, project_id, assigned_name, assigned_to, invoiced, completed_at, submitted_at, assignee:profiles!assigned_to(avatar_url)')
.order('submitted_at', { ascending: false });
if (!isTeam) {
if ((scopedProjectIds || []).length > 0) tasksQ.in('project_id', scopedProjectIds);
@@ -430,6 +441,7 @@ export default function RequestsPage() {
id: task.id, title: task.title, status: task.status, projectId: task.project_id,
serviceType: sub?.service_type || '—', deadline: sub?.deadline || null,
version: task.current_version || 0, isHot: false,
assignedName: task.assigned_name || null, assigneeAvatar: task.assignee?.avatar_url || null,
submittedAt: task.submitted_at ? new Date(task.submitted_at).getTime() : 0,
};
});
@@ -448,6 +460,7 @@ export default function RequestsPage() {
serviceType, deadline: deadlineSource.deadline,
version: deadlineSource.version_number ?? 0,
isHot: deadlineSource.is_hot || false,
assignedName: task.assigned_name || null, assigneeAvatar: task.assignee?.avatar_url || null,
submittedAt: latestGroup.length > 0
? Math.max(...latestGroup.map(s => new Date(s.submitted_at).getTime()))
: (task.submitted_at ? new Date(task.submitted_at).getTime() : 0),
@@ -489,6 +502,8 @@ export default function RequestsPage() {
if (key === 'deadline') return row.deadline || '';
if (key === 'status') return TASK_STATUS_SORT_RANK[row.status] ?? 99;
if (key === 'submitted_at') return row.submittedAt || 0;
if (key === 'assigned') return row.assignedName || '';
if (key === 'priority') return row.isHot ? 0 : 1;
return '';
});
@@ -509,26 +524,36 @@ export default function RequestsPage() {
const renderRow = (row) => {
const project = projects.find(p => p.id === row.projectId);
const initials = row.assignedName ? row.assignedName.split(' ').map(n => n[0]).join('').slice(0, 2).toUpperCase() : null;
return (
<tr key={row.id}>
<td style={{ ...TASK_TABLE_TD_BASE, fontSize: 12, color: 'var(--text-primary)', fontVariantNumeric: 'tabular-nums' }}>
<Link to={`/tasks/${row.id}`} className="table-link">{`R${String(row.version).padStart(2, '0')}`}</Link>
</td>
<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}/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>
<Link to={`/tasks/${row.id}`} className="table-link">{row.title || '—'}</Link>
</td>
<td style={{ ...TASK_TABLE_TD_BASE, fontSize: 13, color: 'var(--text-primary)' }}>
{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}/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}/v2`} className="table-link">{fmtShortDate(row.deadline, 'Not specified')}</Link>
</td>
<td style={{ ...TASK_TABLE_TD_BASE, textAlign: 'center' }}>
<Link to={`/requests/${row.id}/v2`} className="table-link"><StatusBadge status={row.status || 'not_started'} /></Link>
{(() => {
const avatarStyle = { display: 'inline-flex', alignItems: 'center', justifyContent: 'center', width: 26, height: 26, borderRadius: '50%', overflow: 'hidden', verticalAlign: 'middle', flexShrink: 0 };
if (row.assignedName && row.assigneeAvatar)
return <div title={row.assignedName} style={avatarStyle}><img src={row.assigneeAvatar} alt={row.assignedName} style={{ width: '100%', height: '100%', objectFit: 'cover' }} /></div>;
if (row.assignedName)
return <div title={row.assignedName} style={{ ...avatarStyle, background: 'var(--accent)' }}><span style={{ fontSize: 10, fontWeight: 600, color: '#000', lineHeight: 1 }}>{initials}</span></div>;
return <div title="Unassigned" style={{ ...avatarStyle, background: 'rgba(255,255,255,0.08)' }}><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="rgba(255,255,255,0.3)" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="8" r="4"/><path d="M4 20c0-4 3.6-7 8-7s8 3 8 7"/></svg></div>;
})()}
</td>
<td style={{ ...TASK_TABLE_TD_BASE, fontSize: 12, textAlign: 'center' }}>
<Link to={`/tasks/${row.id}`} className="table-link" style={{ color: row.isHot ? '#ef4444' : 'var(--text-primary)', textTransform: 'uppercase', fontSize: 11, letterSpacing: 0.5 }}>{row.isHot ? 'HOT' : 'NO'}</Link>
</td>
<td style={{ ...TASK_TABLE_TD_BASE, fontSize: 12, color: 'var(--text-primary)', textAlign: 'center' }}>
<Link to={`/tasks/${row.id}`} className="table-link">{row.serviceType}</Link>
</td>
<td style={{ ...TASK_TABLE_TD_BASE, fontSize: 12, color: 'var(--text-primary)', textAlign: 'center' }}>
<Link to={`/tasks/${row.id}`} className="table-link">{fmtShortDate(row.deadline, 'Not specified')}</Link>
</td>
</tr>
);
@@ -611,10 +636,26 @@ export default function RequestsPage() {
))}
<div style={{ marginLeft: 'auto', display: 'flex', alignItems: 'center', gap: 8 }} ref={companyFilterMenuRef}>
{isExternal && projectOptions.length > 0 && (
<select className="filter-select" style={{ width: 120 }} value={filterProject} onChange={e => setFilterProject(e.target.value)}>
<option value="">All Projects</option>
{projectOptions.map(p => <option key={p.id} value={p.id}>{p.name}</option>)}
</select>
<div style={{ position: 'relative' }} ref={projectFilterMenuRef}>
<button
className="btn btn-outline"
aria-label="Filter projects" title="Filter projects"
onClick={() => setProjectFilterMenuOpen(o => !o)}
style={{ width: 30, minWidth: 30, padding: 0, display: 'inline-flex', alignItems: 'center', justifyContent: 'center' }}
>
<svg viewBox="0 0 16 16" width="13" height="13" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M2 4h12M4 8h8M6 12h4" />
</svg>
</button>
{projectFilterMenuOpen && (
<div className="site-header-avatar-menu" style={{ position: 'absolute', top: 'calc(100% + 4px)', right: 0, zIndex: 220, minWidth: 170 }}>
<button onClick={() => { setFilterProject(''); setProjectFilterMenuOpen(false); }} className="site-header-avatar-item" style={{ background: !filterProject ? 'rgba(245,165,35,0.08)' : 'transparent', color: !filterProject ? 'var(--accent)' : 'var(--text-primary)' }}>All Projects</button>
{projectOptions.map(p => (
<button key={p.id} onClick={() => { setFilterProject(p.id); setProjectFilterMenuOpen(false); }} className="site-header-avatar-item" style={{ background: filterProject === p.id ? 'rgba(245,165,35,0.08)' : 'transparent', color: filterProject === p.id ? 'var(--accent)' : 'var(--text-primary)' }}>{p.name}</button>
))}
</div>
)}
</div>
)}
{(isTeam || isClient) && (
<div style={{ position: 'relative' }}>
@@ -663,19 +704,23 @@ export default function RequestsPage() {
<div className="scrollbar-thin-theme table-scroll-fade table-scroll-hidden" style={{ flex: 1, minHeight: 0, overflowY: 'auto' }}>
<table className="table-sticky-head" style={{ tableLayout: 'fixed', borderCollapse: 'collapse', width: '100%' }}>
<colgroup>
<col style={{ width: '28%' }} />
<col style={{ width: '5%' }} />
<col style={{ width: '25%' }} />
<col style={{ width: '18%' }} />
<col style={{ width: '15%' }} />
<col style={{ width: '7%' }} />
<col style={{ width: '15%' }} />
<col style={{ width: '15%' }} />
<col style={{ width: '12%' }} />
<col style={{ width: '20%' }} />
</colgroup>
<thead>
<tr>
<SortTh col="revision" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={TASK_TABLE_TH_STYLE}>R#</SortTh>
<SortTh col="title" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={TASK_TABLE_TH_STYLE}>Name</SortTh>
<SortTh col="project" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={TASK_TABLE_TH_STYLE}>Project</SortTh>
<SortTh col="assigned" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ ...TASK_TABLE_TH_STYLE, textAlign: 'center' }}>Assigned</SortTh>
<SortTh col="priority" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ ...TASK_TABLE_TH_STYLE, textAlign: 'center' }}>Priority</SortTh>
<SortTh col="serviceType" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ ...TASK_TABLE_TH_STYLE, textAlign: 'center' }}>Task Type</SortTh>
<SortTh col="deadline" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ ...TASK_TABLE_TH_STYLE, textAlign: 'center' }}>Deadline</SortTh>
<SortTh col="status" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ ...TASK_TABLE_TH_STYLE, textAlign: 'center' }}>Status</SortTh>
</tr>
</thead>
<tbody>{sortedRows.map(renderRow)}</tbody>
-109
View File
@@ -1,109 +0,0 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import Layout from '../../components/Layout';
import { supabase } from '../../lib/supabase';
import { useAuth } from '../../context/AuthContext';
export default function NewProject() {
const { currentUser } = useAuth();
const navigate = useNavigate();
const companyOptions = currentUser.companies?.length
? currentUser.companies
: (currentUser.company ? [currentUser.company] : []);
const [selectedCompanyId, setSelectedCompanyId] = useState(companyOptions[0]?.id || '');
const [name, setName] = useState('');
const [saving, setSaving] = useState(false);
const [error, setError] = useState(null);
if (!companyOptions.length) {
return (
<Layout>
<div style={{ maxWidth: 480, margin: '0 auto', textAlign: 'center', paddingTop: 48 }}>
<h2 style={{ fontSize: 20, fontWeight: 400, marginBottom: 8 }}>Account Not Yet Active</h2>
<p style={{ color: 'var(--text-secondary)', fontSize: 14 }}>
Your account hasn't been linked to a company yet. Please contact the Fourge team to get set up.
</p>
</div>
</Layout>
);
}
const handleSubmit = async (e) => {
e.preventDefault();
if (saving) return;
const trimmedName = name.trim();
if (!trimmedName) return;
setSaving(true);
setError(null);
const { data, error: insertError } = await supabase
.from('projects')
.insert({ company_id: selectedCompanyId, name: trimmedName })
.select('id')
.single();
if (insertError) {
setError(insertError.message);
setSaving(false);
return;
}
navigate(`/my-projects/${data.id}`);
};
return (
<Layout>
<div className="page-header">
<div>
<div className="page-title">New Project</div>
<div className="page-subtitle">Create a project to organise your work.</div>
</div>
</div>
<div className="card" style={{ maxWidth: 480 }}>
<form onSubmit={handleSubmit}>
{companyOptions.length > 1 && (
<div className="form-group">
<label>Company *</label>
<select
value={selectedCompanyId}
onChange={e => setSelectedCompanyId(e.target.value)}
required
>
{companyOptions.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
</select>
</div>
)}
<div className="form-group">
<label>Project Name *</label>
<input
type="text"
placeholder="e.g. Brand Refresh 2026"
value={name}
onChange={e => setName(e.target.value)}
autoFocus
required
/>
</div>
{error && (
<div className="notification notification-error" style={{ marginBottom: 16 }}>
{error}
</div>
)}
<div className="action-buttons">
<button type="submit" className="btn btn-primary btn-lg" disabled={saving || !name.trim()}>
{saving ? 'Creating...' : 'Create Project'}
</button>
<button type="button" className="btn btn-outline" onClick={() => navigate('/my-projects')}>
Cancel
</button>
</div>
</form>
</div>
</Layout>
);
}
-161
View File
@@ -1,161 +0,0 @@
import { useState } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import Layout from '../../components/Layout';
import RequestForm from '../../components/RequestForm';
import { supabase } from '../../lib/supabase';
import { sendEmail } from '../../lib/email';
import { useAuth } from '../../context/AuthContext';
import { createInitialSubmissionForRequest, createTaskForRequest, findOrCreateProject } from '../../lib/requestSubmission';
import { uploadFilesToRequestInfo } from '../../lib/filebrowserFolders';
import { logActivity } from '../../lib/activityLog';
export default function NewRequest() {
const { currentUser } = useAuth();
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const companyOptions = currentUser.companies?.length ? currentUser.companies : (currentUser.company ? [currentUser.company] : []);
const initialCompanyId = companyOptions[0]?.id || '';
const [submitted, setSubmitted] = useState(false);
const [saving, setSaving] = useState(false);
const [error, setError] = useState('');
const [requestKey, setRequestKey] = useState(() => crypto.randomUUID());
const [formKey, setFormKey] = useState(0);
const [lastServiceType, setLastServiceType] = useState('');
const [lastProject, setLastProject] = useState('');
if (!companyOptions.length) {
return (
<Layout>
<div style={{ maxWidth: 480, margin: '0 auto', textAlign: 'center', paddingTop: 48 }}>
<div style={{ fontSize: 48, marginBottom: 16 }}></div>
<h2 style={{ fontSize: 20, fontWeight: 400, marginBottom: 8 }}>Account Not Yet Active</h2>
<p style={{ color: 'var(--text-secondary)', fontSize: 14 }}>
Your account hasn't been linked to a company yet. Please contact the Fourge team to get set up.
</p>
</div>
</Layout>
);
}
if (submitted) {
return (
<Layout>
<div style={{ maxWidth: 480, margin: '0 auto', textAlign: 'center', paddingTop: 48 }}>
<div style={{ fontSize: 48, marginBottom: 16 }}>✅</div>
<h2 style={{ fontSize: 20, fontWeight: 400, marginBottom: 8 }}>Request Submitted!</h2>
<p style={{ color: 'var(--text-secondary)', marginBottom: 24, fontSize: 14 }}>
Thanks, {currentUser?.name?.split(' ')[0]}! We've received your request for <span style={{ color: "var(--text-primary)" }}>{lastServiceType}</span>
{lastProject && <> under <span style={{ color: "var(--text-primary)" }}>{lastProject}</span></>}.
Our team will review it and update you shortly.
</p>
<div className="action-buttons" style={{ justifyContent: 'center' }}>
<button className="btn btn-primary" onClick={() => navigate('/my-projects')}>View Projects</button>
<button className="btn btn-outline" onClick={() => { setSubmitted(false); setRequestKey(crypto.randomUUID()); setFormKey(k => k + 1); }}>
Submit Another
</button>
</div>
</div>
</Layout>
);
}
const handleSubmit = async (formData, files, existingProjects) => {
if (saving) return;
setSaving(true);
setError('');
try {
const selectedCompany = companyOptions.find(c => c.id === formData.companyId) || companyOptions[0];
const resolvedProject = await findOrCreateProject(formData.companyId, formData.project.trim(), existingProjects);
const { task } = await createTaskForRequest({
projectId: resolvedProject.id,
title: formData.title.trim(),
requestKey,
});
if (!task) { setSaving(false); return; }
logActivity({ actorId: currentUser.id, actorName: currentUser.name, action: 'request_submitted', taskId: task.id, taskTitle: formData.title.trim(), projectId: resolvedProject.id, projectName: resolvedProject.name }).catch(() => {});
const { submission } = await createInitialSubmissionForRequest({
taskId: task.id,
requestKey,
isHot: formData.isHot,
serviceType: formData.serviceType,
deadline: formData.deadline,
description: formData.description,
submittedBy: currentUser.id,
submittedByName: currentUser.name,
});
if (submission && files.length > 0) {
for (const file of files) {
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(`Failed to upload "${file.name}": ${uploadError.message}`);
}
if (uploaded) {
const { error: fileRecordError } = await supabase.from('submission_files').insert({
submission_id: submission.id,
name: file.name,
storage_path: path,
size: file.size,
});
if (fileRecordError) {
await supabase.from('tasks').delete().eq('id', task.id);
throw new Error(`Failed to save file record for "${file.name}": ${fileRecordError.message}`);
}
}
}
uploadFilesToRequestInfo(files, selectedCompany?.name, resolvedProject.name, formData.title.trim()).catch(() => {});
}
sendEmail('new_request', 'hello@fourgebranding.com', {
clientName: currentUser.name,
clientEmail: currentUser.email,
company: selectedCompany?.name || '',
serviceType: formData.serviceType,
projectName: formData.project,
deadline: formData.deadline,
description: formData.description,
taskId: task.id,
}).catch(() => {});
setLastServiceType(formData.serviceType);
setLastProject(formData.project);
setSubmitted(true);
} catch (err) {
console.error('Request submission failed:', err);
setError(err.message || 'Something went wrong. Please try again.');
} finally {
setSaving(false);
}
};
return (
<Layout>
<div className="page-header">
<div>
<div className="page-title">New Request</div>
<div className="page-subtitle">Tell us what you need and we'll get to work.</div>
</div>
</div>
<div className="card" style={{ maxWidth: 600 }}>
<RequestForm
key={formKey}
companies={companyOptions}
initialCompanyId={initialCompanyId}
showRequester={false}
onSubmit={handleSubmit}
saving={saving}
error={error}
submitLabel="Submit Request"
/>
</div>
</Layout>
);
}
+5 -5
View File
@@ -176,7 +176,7 @@ function ActivityFeed({ events }) {
}
{e.action && <span style={{ color: 'var(--text-muted)' }}> {e.action}</span>}
{e.task && e.taskId
? <><span style={{ color: 'var(--text-muted)' }}> </span><button type="button" className="dashboard-inline-link" onClick={() => navigate(`/requests/${e.taskId}`)}>{e.task}</button></>
? <><span style={{ color: 'var(--text-muted)' }}> </span><button type="button" className="dashboard-inline-link" onClick={() => navigate(`/tasks/${e.taskId}`)}>{e.task}</button></>
: e.task && <span style={{ color: 'var(--text-primary)' }}> {e.task}</span>
}
</div>
@@ -269,7 +269,7 @@ function MiniCalendar({ items = [] }) {
<button
type="button"
onClick={() => {
if (dayItems.length === 1) navigate(`/requests/${dayItems[0].id}`);
if (dayItems.length === 1) navigate(`/tasks/${dayItems[0].id}`);
}}
disabled={!d}
style={{
@@ -304,7 +304,7 @@ function MiniCalendar({ items = [] }) {
<span style={{ fontSize: 11, color: '#F5A523' }}>{activeItems.length} due</span>
</div>
{activeItems.slice(0, 4).map(item => (
<button key={item.id} type="button" onClick={() => navigate(`/requests/${item.id}`)} style={{ display: 'flex', alignItems: 'center', gap: 7, width: '100%', padding: '5px 0', background: 'transparent', border: 'none', cursor: 'pointer', textAlign: 'left', fontFamily: 'inherit' }}>
<button key={item.id} type="button" onClick={() => navigate(`/tasks/${item.id}`)} style={{ display: 'flex', alignItems: 'center', gap: 7, width: '100%', padding: '5px 0', background: 'transparent', border: 'none', cursor: 'pointer', textAlign: 'left', fontFamily: 'inherit' }}>
<span style={{ width: 6, height: 6, borderRadius: '50%', background: dotColor(item), flexShrink: 0 }} />
<span style={{ flex: 1, minWidth: 0, fontSize: 12, color: 'var(--text-primary)', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{item.title}</span>
</button>
@@ -357,7 +357,7 @@ function TasksInProgressCard({ tasks = [] }) {
{visibleRows.map((t) => (
<tr key={t.id} style={{ background: 'transparent' }}>
<td style={{ fontSize: 13, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', padding: '5px', border: 'none', background: 'transparent', textAlign: 'left' }}>
<button type="button" className="dashboard-inline-link" onClick={() => navigate('/requests/' + t.id)}>{t.title}</button>
<button type="button" className="dashboard-inline-link" onClick={() => navigate('/tasks/' + t.id)}>{t.title}</button>
</td>
<td style={{ fontSize: 12, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', padding: '5px', border: 'none', background: 'transparent', textAlign: 'right' }}>
{t.assigned_to
@@ -442,7 +442,7 @@ function HotItemsCard({ submissions, tasks, isClient = false }) {
)}
</td>
<td style={{ fontSize: 13, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', padding: '5px', border: 'none', background: 'transparent', textAlign: 'left' }}>
<button type="button" className="dashboard-inline-link" onClick={() => navigate('/requests/' + s.task_id)}>{s.task.title}</button>
<button type="button" className="dashboard-inline-link" onClick={() => navigate('/tasks/' + s.task_id)}>{s.task.title}</button>
</td>
<td style={{ fontSize: 12, color: 'var(--text-primary)', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', padding: '5px', border: 'none', background: 'transparent', textAlign: 'center' }}>
{s.submitted_by ? (