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:
+5
-10
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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, ' ')
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 ? (
|
||||
|
||||
Reference in New Issue
Block a user