Session 2026-05-30: tasks/projects unification, code consolidation, file renames
- Unified Tasks page: 3 role render blocks → 1, normalized row shape, single renderRow/sort/tabs - Fixed role scoping: external = all tasks in member projects, client = all tasks in company projects - Fixed client bug: was scoped by submitted_by, now company→project→tasks - Aligned dashboard client scope to match tasks page - Hot tasks dashboard: now filters to active statuses only (not completed/invoiced/paid) - Removed Projects.jsx (dead), fixed /project/ → /projects/ links - Renamed all page files to match folder path (Team*, External*, Client* prefixes) - Renamed: RequestsPage→Tasks, Settings→Profile, CompaniesPage→Companies, ProjectDetailPage→ProjectDetail - Dropped dead fetches from Tasks page (invoices, invoice_items, subcontractor_invoice_items) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+73
-71
@@ -1,9 +1,11 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import JSZip from 'jszip';
|
||||
import Layout from '../components/Layout';
|
||||
import PageLoader from '../components/PageLoader';
|
||||
import SortTh from '../components/SortTh';
|
||||
import { supabase } from '../lib/supabase';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import { popupMenuStyle } from '../lib/popupStyles';
|
||||
|
||||
const CARD = {
|
||||
background: 'var(--card-bg)',
|
||||
@@ -108,6 +110,43 @@ function FileIcon({ name, isDir }) {
|
||||
|
||||
const FBQ_FN = `${import.meta.env.VITE_SUPABASE_URL}/functions/v1/fbq-proxy`;
|
||||
|
||||
async function downloadViaLocalFilebrowser(path, session) {
|
||||
if (!session?.access_token) throw new Error('Missing session token for download.');
|
||||
const response = await fetch(`/api/filebrowser?action=download-blob&path=${encodeURIComponent(path)}&sb_access_token=${encodeURIComponent(session.access_token)}`, {
|
||||
headers: { Authorization: `Bearer ${session?.access_token ?? ''}` },
|
||||
});
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
throw new Error(text || `Error ${response.status}`);
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
async function downloadViaApiToken(path, filename) {
|
||||
const { data: { session } } = await supabase.auth.getSession();
|
||||
if (!session?.access_token) throw new Error('Missing session token for download.');
|
||||
const metaResponse = await fetch(`/api/filebrowser?action=download&path=${encodeURIComponent(path)}&sb_access_token=${encodeURIComponent(session.access_token)}`, {
|
||||
headers: { Authorization: `Bearer ${session.access_token}` },
|
||||
});
|
||||
if (!metaResponse.ok) {
|
||||
const text = await metaResponse.text();
|
||||
throw new Error(text || `Error ${metaResponse.status}`);
|
||||
}
|
||||
const { url, token } = await metaResponse.json();
|
||||
if (!url || !token) throw new Error('Download URL unavailable.');
|
||||
const urlObj = new URL(url, window.location.origin);
|
||||
urlObj.searchParams.set('auth', token);
|
||||
const link = document.createElement('a');
|
||||
link.href = urlObj.toString();
|
||||
link.download = filename;
|
||||
link.target = '_blank';
|
||||
link.rel = 'noopener noreferrer';
|
||||
link.style.display = 'none';
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
}
|
||||
|
||||
async function fbqProxy(op, path, extra = {}) {
|
||||
const { data: { session } } = await supabase.auth.getSession();
|
||||
const headers = {
|
||||
@@ -121,6 +160,18 @@ async function fbqProxy(op, path, extra = {}) {
|
||||
headers,
|
||||
body: extra.body ?? undefined,
|
||||
});
|
||||
|
||||
if (!response.ok && op === 'download') {
|
||||
const text = await response.text();
|
||||
const shouldFallbackToLocalDownload =
|
||||
response.status === 404
|
||||
|| (response.status === 400 && text.toLowerCase().includes('no files specified'));
|
||||
if (shouldFallbackToLocalDownload) {
|
||||
return downloadViaLocalFilebrowser(path, session);
|
||||
}
|
||||
throw new Error(text || `Error ${response.status}`);
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
throw new Error(text || `Error ${response.status}`);
|
||||
@@ -148,8 +199,11 @@ const TREE_TOGGLE_W = 18;
|
||||
|
||||
export default function FileSharing() {
|
||||
const { currentUser } = useAuth();
|
||||
const home = rootPath(currentUser);
|
||||
const isTeam = currentUser?.role === 'team';
|
||||
const accessScope = useMemo(() => ({
|
||||
home: rootPath(currentUser),
|
||||
canManage: currentUser?.role === 'team',
|
||||
}), [currentUser]);
|
||||
const home = accessScope.home;
|
||||
const pinsStorageKey = useMemo(() => `fbq_pins:${currentUser?.id ?? 'anon'}`, [currentUser?.id]);
|
||||
|
||||
const [path, setPath] = useState(home);
|
||||
@@ -435,8 +489,7 @@ export default function FileSharing() {
|
||||
async function handleDownload(entry) {
|
||||
const nextPath = entry.path ?? (path === '/' ? `/${entry.name}` : `${path}/${entry.name}`);
|
||||
try {
|
||||
const blob = await (await fbqProxy('download', nextPath)).blob();
|
||||
triggerDownload(URL.createObjectURL(blob), entry.name);
|
||||
await downloadViaApiToken(nextPath, entry.name);
|
||||
} catch (err) {
|
||||
setError(err?.message || 'Download failed.');
|
||||
}
|
||||
@@ -756,7 +809,7 @@ export default function FileSharing() {
|
||||
|
||||
<div style={{ ...CARD, padding: '14px 16px', flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
|
||||
<div style={{ ...LABEL, flexShrink: 0 }}>Navigation</div>
|
||||
<div style={{ flex: 1, overflowY: 'auto', marginRight: -6, paddingRight: 6 }}>
|
||||
<div className="table-scroll-hidden" style={{ flex: 1, overflowY: 'auto', overflowX: 'hidden' }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate(home)}
|
||||
@@ -775,6 +828,7 @@ export default function FileSharing() {
|
||||
style={{
|
||||
...CARD,
|
||||
padding: 0,
|
||||
position: 'relative',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
overflow: 'hidden',
|
||||
@@ -845,6 +899,7 @@ export default function FileSharing() {
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="table-scroll-hidden"
|
||||
ref={tableScrollRef}
|
||||
style={{
|
||||
flex: 1,
|
||||
@@ -861,11 +916,11 @@ export default function FileSharing() {
|
||||
>
|
||||
{error && <div style={{ fontSize: 13, color: 'var(--danger)', paddingBottom: 12 }}>{error}</div>}
|
||||
{loading ? (
|
||||
<div style={{ fontSize: 13, color: 'var(--text-muted)', padding: '32px 0', textAlign: 'center' }}>Loading...</div>
|
||||
<div className="card-empty-center">Loading...</div>
|
||||
) : entries.length === 0 ? (
|
||||
<div style={{ fontSize: 13, color: 'var(--text-muted)', padding: '32px 0', textAlign: 'center' }}>This folder is empty.</div>
|
||||
<div className="card-empty-center">This folder is empty.</div>
|
||||
) : (
|
||||
<table style={{ width: '100%', tableLayout: 'fixed', borderCollapse: 'collapse' }}>
|
||||
<table className="table-sticky-head" style={{ width: '100%', tableLayout: 'fixed', borderCollapse: 'collapse' }}>
|
||||
<thead ref={tableHeadRef}>
|
||||
<tr>
|
||||
<th style={{ ...TABLE_TH, width: 32, textAlign: 'center', padding: '0 5px 12px 5px', cursor: 'default' }}>
|
||||
@@ -917,74 +972,21 @@ export default function FileSharing() {
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
{Array.from({ length: fillerRowCount }).map((_, index) => {
|
||||
const rowBg = (sortedEntries.length + index) % 2 === 0 ? 'var(--file-row-alt-bg)' : 'transparent';
|
||||
return (
|
||||
<tr key={`filler-${index}`} aria-hidden="true">
|
||||
<td style={{ ...TABLE_TD, width: 32, background: rowBg }}> </td>
|
||||
<td style={{ ...TABLE_TD, background: rowBg }}> </td>
|
||||
<td style={{ ...TABLE_TD, background: rowBg }}> </td>
|
||||
<td style={{ ...TABLE_TD, background: rowBg }}> </td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{(loading || uploading || downloadingSelection) && (
|
||||
<PageLoader
|
||||
scope="container"
|
||||
label={uploading ? 'Uploading' : downloadingSelection ? 'Downloading' : 'Loading'}
|
||||
progress={uploading ? uploadProgress : downloadingSelection ? downloadProgress : null}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(loading || uploading || downloadingSelection) && (
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
zIndex: 1200,
|
||||
background: 'rgba(0,0,0,0.58)',
|
||||
backdropFilter: 'blur(6px)',
|
||||
WebkitBackdropFilter: 'blur(6px)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: 24,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
background: 'var(--card-bg)',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 8,
|
||||
padding: '18px 21px',
|
||||
backdropFilter: 'blur(12px)',
|
||||
WebkitBackdropFilter: 'blur(12px)',
|
||||
width: 'min(320px, 100%)',
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', textTransform: 'uppercase', letterSpacing: 0.8, marginBottom: 14 }}>
|
||||
{uploading ? 'Uploading' : downloadingSelection ? 'Downloading' : 'Loading'}
|
||||
</div>
|
||||
{(() => {
|
||||
const pct = uploading ? uploadProgress : downloadingSelection ? downloadProgress : null;
|
||||
const hasProgress = (uploading || downloadingSelection) && pct !== null;
|
||||
return (
|
||||
<>
|
||||
<div style={{ height: 4, borderRadius: 2, background: 'var(--border)', overflow: 'hidden', marginBottom: hasProgress ? 8 : 0 }}>
|
||||
<div
|
||||
className={hasProgress ? undefined : 'file-browser-progress-bar indeterminate'}
|
||||
style={{ height: '100%', borderRadius: 2, background: 'var(--accent)', width: hasProgress ? `${pct}%` : undefined, transition: 'width 0.2s ease' }}
|
||||
/>
|
||||
</div>
|
||||
{hasProgress && (
|
||||
<div style={{ fontSize: 12, color: 'var(--text-secondary)', textAlign: 'right' }}>{pct}%</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{ctxMenu && (() => {
|
||||
const { x, y, entry, childPath } = ctxMenu;
|
||||
const isDir = entry ? (entry.type === 'directory' || entry.isDir) : false;
|
||||
@@ -1016,7 +1018,7 @@ export default function FileSharing() {
|
||||
const separatorStyle = { height: 1, background: 'var(--border)', margin: '4px 0' };
|
||||
|
||||
return (
|
||||
<div onClick={(event) => event.stopPropagation()} style={{ position: 'fixed', left: x, top: y, zIndex: 1000, background: 'rgba(20,20,20,0.96)', border: '1px solid var(--border)', borderRadius: 8, backdropFilter: 'blur(16px)', padding: '4px 0', minWidth: 160, boxShadow: '0 8px 24px rgba(0,0,0,0.4)' }}>
|
||||
<div onClick={(event) => event.stopPropagation()} style={{ ...popupMenuStyle, position: 'fixed', left: x, top: y, zIndex: 1000, padding: '4px 0', minWidth: 160 }}>
|
||||
{entry && isDir && (
|
||||
<button
|
||||
style={itemStyle}
|
||||
@@ -1131,7 +1133,7 @@ export default function FileSharing() {
|
||||
</>
|
||||
)}
|
||||
|
||||
{isTeam && entry && (
|
||||
{accessScope.canManage && entry && (
|
||||
<>
|
||||
<div style={separatorStyle} />
|
||||
<button
|
||||
|
||||
Reference in New Issue
Block a user