diff --git a/backend/src/openapi.rs b/backend/src/openapi.rs index c16fa19..fe85d02 100644 --- a/backend/src/openapi.rs +++ b/backend/src/openapi.rs @@ -57,7 +57,8 @@ use uuid::Uuid; schemas::LoginResponseVariants, schemas::DocumentResponse, schemas::DocumentDetailResponse, - schemas::DocumentVersion, + schemas::DocumentVersionResponse, + schemas::DocumentVersionDetailResponse, schemas::DocumentAssetSummary, schemas::DocumentAssetDetail, schemas::DocumentAssetObject, @@ -238,6 +239,27 @@ mod doc { )] pub(super) fn delete_document() {} + #[utoipa::path( + get, + path = "/api/documents/{id}/versions", + params(("id" = Uuid, Path, description = "Document ID")), + responses((status = 200, description = "Document versions", body = [DocumentVersionResponse])), + tag = "Documents" + )] + pub(super) fn list_document_versions() {} + + #[utoipa::path( + get, + path = "/api/documents/{id}/versions/{version_id}", + params( + ("id" = Uuid, Path, description = "Document ID"), + ("version_id" = Uuid, Path, description = "Version ID"), + ), + responses((status = 200, description = "Document version detail", body = DocumentVersionDetailResponse)), + tag = "Documents" + )] + pub(super) fn get_document_version() {} + #[utoipa::path( post, path = "/api/documents/{id}/restore", @@ -650,15 +672,22 @@ pub mod schemas { } #[derive(Serialize, Deserialize, ToSchema)] - pub struct DocumentVersion { + pub struct DocumentVersionResponse { pub id: Uuid, pub version_number: i32, - pub checksum: String, + pub s3_key: String, pub size_bytes: i64, + pub checksum: String, pub created_at: String, pub metadata: Value, - #[schema(nullable)] - pub assets: Option>, + } + + #[derive(Serialize, Deserialize, ToSchema)] + pub struct DocumentVersionDetailResponse { + #[serde(flatten)] + pub version: DocumentVersionResponse, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub assets: Vec, pub download_path: String, } @@ -692,7 +721,7 @@ pub mod schemas { #[schema(nullable)] pub correspondents: Option>, #[schema(nullable)] - pub current_version: Option, + pub current_version: Option, } #[derive(Serialize, Deserialize, ToSchema)] diff --git a/backend/src/routes/documents.rs b/backend/src/routes/documents.rs index 3903f3c..46057f7 100644 --- a/backend/src/routes/documents.rs +++ b/backend/src/routes/documents.rs @@ -133,7 +133,6 @@ impl From for TagResponse { pub struct DocumentVersionResponse { pub id: Uuid, pub version_number: i32, - pub s3_key: String, pub size_bytes: i64, pub checksum: String, pub created_at: String, @@ -175,7 +174,7 @@ pub struct DocumentAssetDetailResponse { } #[derive(Serialize, Clone, ToSchema)] -pub struct DocumentCurrentVersionResponse { +pub struct DocumentVersionDetailResponse { #[serde(flatten)] pub version: DocumentVersionResponse, #[serde(default, skip_serializing_if = "Vec::is_empty")] @@ -209,7 +208,7 @@ pub struct DocumentResponse { #[serde(default, skip_serializing_if = "Vec::is_empty")] pub correspondents: Vec, #[serde(skip_serializing_if = "Option::is_none")] - pub current_version: Option, + pub current_version: Option, } #[derive(Serialize, ToSchema)] pub struct DocumentDetailResponse { @@ -1145,6 +1144,73 @@ pub async fn get_document_asset( Ok(Json(to_asset_detail_response(asset, object_responses))) } +pub async fn list_document_versions( + Path(document_id): Path, + TenantScopedConn { + mut conn, + tenant_id, + .. + }: TenantScopedConn, +) -> AppResult>> { + let document: Document = documents::table + .find(document_id) + .filter(documents::tenant_id.eq(tenant_id)) + .first(&mut conn)?; + + if document.deleted_at.is_some() { + return Err(AppError::not_found()); + } + + let versions: Vec = document_versions::table + .filter(document_versions::document_id.eq(document_id)) + .filter(document_versions::tenant_id.eq(tenant_id)) + .order(document_versions::version_number.asc()) + .load(&mut conn)?; + + let versions: Vec = + versions.into_iter().map(to_version_response).collect(); + + Ok(Json(versions)) +} + +pub async fn get_document_version( + State(state): State, + Path((document_id, version_id)): Path<(Uuid, Uuid)>, + TenantScopedConn { + mut conn, + tenant_id, + user_id, + .. + }: TenantScopedConn, +) -> AppResult> { + let document: Document = documents::table + .find(document_id) + .filter(documents::tenant_id.eq(tenant_id)) + .first(&mut conn)?; + + if document.deleted_at.is_some() { + return Err(AppError::not_found()); + } + + let version: DocumentVersion = document_versions::table + .find(version_id) + .filter(document_versions::document_id.eq(document_id)) + .filter(document_versions::tenant_id.eq(tenant_id)) + .first(&mut conn)?; + + drop(conn); + + let assets = load_asset_responses(&state, tenant_id, version.id).await?; + let download_path = build_download_path(&state, &document, user_id)?; + let version_core = to_version_response(version); + + Ok(Json(DocumentVersionDetailResponse { + version: version_core, + assets, + download_path, + })) +} + pub async fn download_document( State(state): State, Path(document_id): Path, @@ -2574,7 +2640,7 @@ pub(crate) fn to_document_response( ) -> AppResult { let current_version = if let Some((version, assets)) = current_version { let download_path = build_download_path(state, &doc, user_id)?; - Some(DocumentCurrentVersionResponse { + Some(DocumentVersionDetailResponse { version, assets, download_path, diff --git a/backend/src/routes/documents/asset_utils.rs b/backend/src/routes/documents/asset_utils.rs index ee683e7..cb6b018 100644 --- a/backend/src/routes/documents/asset_utils.rs +++ b/backend/src/routes/documents/asset_utils.rs @@ -28,7 +28,6 @@ pub fn to_version_response(version: DocumentVersion) -> DocumentVersionResponse DocumentVersionResponse { id: version.id, version_number: version.version_number, - s3_key: version.s3_key, size_bytes: version.size_bytes, checksum: version.checksum, created_at: to_iso(version.created_at), diff --git a/backend/src/routes/mod.rs b/backend/src/routes/mod.rs index 470371e..52351f3 100644 --- a/backend/src/routes/mod.rs +++ b/backend/src/routes/mod.rs @@ -85,6 +85,11 @@ pub fn create_router(state: AppState) -> Router<()> { get(documents::list_document_assets).post(documents::request_document_assets), ) .route("/:id/folder", patch(documents::move_document)) + .route("/:id/versions", get(documents::list_document_versions)) + .route( + "/:id/versions/:version_id", + get(documents::get_document_version), + ) .route("/:id/restore", post(documents::restore_document)) .route("/:id/tags", post(documents::assign_tags)) .route("/:id/tags/:tag_id", delete(documents::remove_tag)) diff --git a/frontend/src/index.jsx b/frontend/src/index.jsx index 6694368..b5440a2 100644 --- a/frontend/src/index.jsx +++ b/frontend/src/index.jsx @@ -313,6 +313,7 @@ const createRootNode = () => ({ children: [], expanded: true, loaded: false, + hasChildren: false, }); const StatusBanner = ({ status }) => { @@ -421,7 +422,9 @@ const AppLayout = () => { const routeFolderId = folderMatch?.params?.folderId || null; const routeDocumentId = docMatch?.params?.documentId || null; const previewDocumentId = routeDocumentId; - const { status: appStatus, token } = appState; + const { status: appStatus, token, tenant, tenants: tenantOptions = [] } = appState; + const tenantSlug = tenant?.slug || null; + const currentTenantId = tenant?.tenant_id || null; const [status, setStatus] = useState(null); const setStatusMessage = useCallback((message, variant = 'info') => { setStatus(message ? { message, variant } : null); @@ -475,6 +478,7 @@ const AppLayout = () => { const refreshPromiseRef = useRef(null); const breadcrumbFetchRef = useRef(new Set()); const tagRemovalCursorActiveRef = useRef(false); + const tenantIdRef = useRef(currentTenantId); const setTagRemovalCursor = useCallback((active) => { if (typeof document === 'undefined') { return; @@ -584,6 +588,7 @@ const AppLayout = () => { } const tagManager = tagManagerRef.current; + const buildTagPayload = useCallback( ({ label, color } = {}) => tagManager.buildPayload({ label, color }), [tagManager], @@ -680,7 +685,6 @@ const AppLayout = () => { const bootstrapInitializedRef = useRef(false); const selectionInitializedRef = useRef(false); const dragCounterRef = useRef(0); - const prefetchedFoldersRef = useRef(new Set(['root'])); const detailFolderFetchRef = useRef(new Set()); const selectionAnchorRef = useRef(initialRowSelection[initialRowSelection.length - 1] || null); const selectionOrderRef = useRef(initialRowSelection); @@ -729,11 +733,11 @@ const AppLayout = () => { setPreviewEntries(() => new Map()); previewInflightRef.current = new Map(); dragCounterRef.current = 0; - prefetchedFoldersRef.current = new Set(['root']); breadcrumbFetchRef.current = new Set(); detailFolderFetchRef.current = new Set(); bootstrapInitializedRef.current = false; selectionInitializedRef.current = false; + tenantIdRef.current = null; }, [assetManager]); const tagLookupById = useMemo(() => { @@ -797,6 +801,10 @@ const AppLayout = () => { tokenRef.current = token; }, [token]); + useEffect(() => { + tenantIdRef.current = currentTenantId; + }, [currentTenantId]); + useEffect(() => { const requestInterceptor = api.interceptors.request.use((config) => { const currentToken = tokenRef.current; @@ -1401,9 +1409,29 @@ const AppLayout = () => { const ensureFolderData = useCallback( - async (folderId, { force = false, includeDocuments = true } = {}) => { - if (!force && folderContents.has(folderId)) { - return folderContents.get(folderId); + async ( + folderId, + { force = false, includeDocuments = true, prefetchDepth = 0 } = {}, + ) => { + const requestTenantId = tenantIdRef.current; + const cached = folderContents.get(folderId); + if (!force && cached) { + const includesDocuments = Boolean(cached.__includesDocuments); + if (!includeDocuments || includesDocuments) { + if (prefetchDepth > 0) { + const subfolders = Array.isArray(cached.subfolders) ? cached.subfolders : []; + await Promise.allSettled( + subfolders.map((entry) => + ensureFolderData(entry.id, { + includeDocuments: false, + prefetchDepth: prefetchDepth - 1, + force: false, + }), + ), + ); + } + return cached; + } } const path = folderId === 'root' ? 'root' : folderId; @@ -1414,6 +1442,12 @@ const AppLayout = () => { params, }); const hydrated = assetManager.hydrateFolderContents(data); + const childFolders = Array.isArray(data.subfolders) ? data.subfolders : []; + const childIds = childFolders.map((child) => child.id); + + if (tenantIdRef.current !== requestTenantId) { + return { ...hydrated, __includesDocuments: includeDocuments }; + } setFolderNodes((prev) => { const next = new Map(prev); @@ -1424,9 +1458,9 @@ const AppLayout = () => { children: [], expanded: folderId === 'root', loaded: false, + hasChildren: false, }; - const childIds = (data.subfolders || []).map((child) => child.id); next.set(folderId, { ...existingNode, name: folderId === 'root' ? DEFAULT_FOLDER_NAME : data.folder?.name || existingNode.name, @@ -1434,62 +1468,96 @@ const AppLayout = () => { children: childIds, expanded: folderId === 'root' ? true : existingNode.expanded, loaded: true, + hasChildren: childIds.length > 0, }); - (data.subfolders || []).forEach((child) => { + childFolders.forEach((child) => { const childNode = next.get(child.id); + const previousChildren = Array.isArray(childNode?.children) ? childNode.children : []; + const childHasChildren = (() => { + if (childNode?.loaded) { + return previousChildren.length > 0; + } + if (Array.isArray(child?.subfolders)) { + return child.subfolders.length > 0; + } + if (typeof child?.has_children === 'boolean') { + return child.has_children; + } + if (typeof child?.hasChildren === 'boolean') { + return child.hasChildren; + } + if (typeof childNode?.hasChildren === 'boolean') { + return childNode.hasChildren; + } + return false; + })(); next.set(child.id, { id: child.id, name: child.name, parentId: child.parent_id ?? 'root', - children: childNode?.children ?? [], + children: previousChildren, expanded: childNode?.expanded ?? false, loaded: childNode?.loaded ?? false, + hasChildren: childHasChildren, }); }); return next; }); - setFolderContents((prev) => { - const next = new Map(prev); - next.set(folderId, hydrated); - return next; - }); - - return hydrated; - }, - [assetManager, folderContents], - ); - const greedyPrefetchFolders = useCallback( - async (startIds) => { - const queue = Array.isArray(startIds) ? [...startIds] : []; - const visited = prefetchedFoldersRef.current; - - while (queue.length) { - const nextId = queue.shift(); - if (!nextId || visited.has(nextId)) { - continue; - } - visited.add(nextId); - - try { - const contents = await ensureFolderData(nextId, { - force: false, - includeDocuments: false, - }); - const subfolders = Array.isArray(contents?.subfolders) ? contents.subfolders : []; - subfolders.forEach((entry) => { - if (entry?.id && !visited.has(entry.id)) { - queue.push(entry.id); - } - }); - } catch (error) { - console.warn('[Folders] Failed to prefetch folder tree for', nextId, error); - } + if (prefetchDepth > 0 && childIds.length > 0 && tenantIdRef.current === requestTenantId) { + await Promise.allSettled( + childIds.map((childId) => + ensureFolderData(childId, { + includeDocuments: false, + force: false, + prefetchDepth: prefetchDepth - 1, + }), + ), + ); } + + const enriched = { + ...hydrated, + __includesDocuments: includeDocuments, + }; + + if (includeDocuments) { + setFolderContents((prev) => { + if (tenantIdRef.current !== requestTenantId) { + return prev; + } + const next = new Map(prev); + next.set(folderId, enriched); + return next; + }); + } else { + setFolderContents((prev) => { + if (tenantIdRef.current !== requestTenantId) { + return prev; + } + const next = new Map(prev); + const existingEntry = next.get(folderId); + if (existingEntry) { + next.set(folderId, { + ...existingEntry, + ...hydrated, + documents: existingEntry.__includesDocuments + ? existingEntry.documents + : hydrated.documents, + __includesDocuments: existingEntry.__includesDocuments || false, + }); + } else { + next.set(folderId, enriched); + } + return next; + }); + } + + return enriched; }, - [ensureFolderData], + [api, assetManager, folderContents], ); const isInvalidFolderDrop = useCallback( @@ -1550,9 +1618,13 @@ const AppLayout = () => { const previousParent = next.get(previousParentKey); if (previousParent) { + const remainingChildren = (previousParent.children || []).filter( + (childId) => childId !== folderId, + ); next.set(previousParentKey, { ...previousParent, - children: (previousParent.children || []).filter((childId) => childId !== folderId), + children: remainingChildren, + hasChildren: remainingChildren.length > 0, }); } @@ -1564,6 +1636,7 @@ const AppLayout = () => { children: [], expanded: targetKey === 'root', loaded: false, + hasChildren: false, }); } @@ -1572,6 +1645,7 @@ const AppLayout = () => { next.set(targetKey, { ...targetNode, children: [...targetNode.children, folderId], + hasChildren: true, }); } @@ -1581,14 +1655,14 @@ const AppLayout = () => { const refreshTargets = new Set([previousParentKey, targetKey]); for (const key of refreshTargets) { if (key === 'root') { - await ensureFolderData('root', { force: true }); + await ensureFolderData('root', { force: true, prefetchDepth: 1 }); } else { - await ensureFolderData(key, { force: true }); + await ensureFolderData(key, { force: true, prefetchDepth: 1 }); } } if (selectedFolder === folderId) { - await ensureFolderData(folderId, { force: true }); + await ensureFolderData(folderId, { force: true, prefetchDepth: 1 }); setSelectedFolder(folderId); } @@ -1600,9 +1674,9 @@ const AppLayout = () => { const refreshTargets = new Set([previousParentKey, targetKey]); for (const key of refreshTargets) { if (key === 'root') { - await ensureFolderData('root', { force: true }); + await ensureFolderData('root', { force: true, prefetchDepth: 1 }); } else { - await ensureFolderData(key, { force: true }); + await ensureFolderData(key, { force: true, prefetchDepth: 1 }); } } } @@ -1611,19 +1685,33 @@ const AppLayout = () => { ); const refreshTags = useCallback(async () => { + const requestTenantId = tenantIdRef.current; try { const { data } = await api.get('/tags'); + if (tenantIdRef.current !== requestTenantId) { + return; + } setTags(data || []); } catch (error) { + if (tenantIdRef.current !== requestTenantId) { + return; + } notifyApiError(error, 'Unable to load tags.'); } }, [api, notifyApiError]); const refreshCorrespondents = useCallback(async () => { + const requestTenantId = tenantIdRef.current; try { const { data } = await api.get('/correspondents'); + if (tenantIdRef.current !== requestTenantId) { + return; + } setCorrespondents(data || []); } catch (error) { + if (tenantIdRef.current !== requestTenantId) { + return; + } notifyApiError(error, 'Unable to load correspondents.'); } }, [api, notifyApiError]); @@ -1914,10 +2002,14 @@ const AppLayout = () => { setSelectedFolder(targetId); if (showLoading) setLoading(true); try { - const contents = await ensureFolderData(targetId, { force: true }); + const contents = await ensureFolderData(targetId, { force: true, prefetchDepth: 1 }); if (targetId !== 'root') { try { - await ensureFolderData('root', { force: false }); + await ensureFolderData('root', { + force: false, + includeDocuments: false, + prefetchDepth: 1, + }); } catch (error) { console.warn('Failed to refresh root folder tree', error); } @@ -1935,27 +2027,6 @@ const AppLayout = () => { [ensureFolderData, applySelectedFolder, notifyApiError], ); - useEffect(() => { - if (!folderContents || typeof folderContents.forEach !== 'function') { - return; - } - - const pendingIds = []; - folderContents.forEach((contents) => { - const subfolders = Array.isArray(contents?.subfolders) ? contents.subfolders : []; - subfolders.forEach((entry) => { - if (entry?.id && !prefetchedFoldersRef.current.has(entry.id)) { - pendingIds.push(entry.id); - } - }); - }); - - if (!pendingIds.length) { - return; - } - - greedyPrefetchFolders(pendingIds).catch(() => {}); - }, [folderContents, greedyPrefetchFolders]); const expandFolderAncestors = useCallback( (targetId) => { @@ -2051,7 +2122,11 @@ const AppLayout = () => { } if (!folderNodes.has(ancestorId)) { try { - await ensureFolderData(ancestorId, { includeDocuments: false, force: false }); + await ensureFolderData(ancestorId, { + includeDocuments: false, + force: false, + prefetchDepth: 1, + }); } catch (error) { console.warn('Failed to ensure ancestor folder for navigation', ancestorId, error); } @@ -2136,7 +2211,10 @@ const AppLayout = () => { const refreshCurrentFolder = useCallback(async () => { setLoading(true); try { - const contents = await ensureFolderData(selectedFolder, { force: true }); + const contents = await ensureFolderData(selectedFolder, { + force: true, + prefetchDepth: 1, + }); applySelectedFolder(selectedFolder, contents); } catch (error) { notifyApiError(error, 'Failed to refresh folder.'); @@ -2311,7 +2389,9 @@ const AppLayout = () => { const target = form.get('target')?.toString() || 'root'; const folderId = target === 'root' ? null : target; - setLoading(true); + if (!refreshOnly) { + setLoading(true); + } try { await api.post('/documents/bulk/move', { document_ids: selectedDocumentIds, @@ -2331,9 +2411,9 @@ const AppLayout = () => { applySelection([], { anchor: null }); - await refreshCurrentFolder(); + await refreshCurrentFolder(); if (folderId && folderId !== selectedFolder) { - await ensureFolderData(folderId, { force: true }); + await ensureFolderData(folderId, { force: true, prefetchDepth: 1 }); } event.currentTarget.reset(); @@ -2433,7 +2513,9 @@ const AppLayout = () => { notifyApiError(error, message); return { ok: false, reason: 'request-failed' }; } finally { - setLoading(false); + if (!refreshOnly) { + setLoading(false); + } } }, [ @@ -3224,7 +3306,7 @@ const AppLayout = () => { targetFolderId !== 'root' && targetFolderId !== selectedFolder ) { - await ensureFolderData(targetFolderId, { force: true }); + await ensureFolderData(targetFolderId, { force: true, prefetchDepth: 1 }); } } catch (error) { notifyApiError(error, 'Failed to upload files.'); @@ -3285,7 +3367,7 @@ const AppLayout = () => { await refreshCurrentFolder(); if (targetFolderId && targetFolderId !== selectedFolder) { - await ensureFolderData(targetFolderId, { force: true }); + await ensureFolderData(targetFolderId, { force: true, prefetchDepth: 1 }); } } catch (error) { const message = error.response?.data?.error || 'Failed to move documents.'; @@ -3728,7 +3810,10 @@ const AppLayout = () => { } setLoading(true); try { - const contents = await ensureFolderData(folderId, { force: true }); + const contents = await ensureFolderData(folderId, { + force: true, + prefetchDepth: 1, + }); const hasChildren = (contents.subfolders || []).length > 0; const hasDocs = (contents.documents || []).length > 0; if (hasChildren || hasDocs) { @@ -3744,9 +3829,11 @@ const AppLayout = () => { const parentId = node.parentId || 'root'; const parentNode = next.get(parentId); if (parentNode) { + const remaining = parentNode.children.filter((id) => id !== folderId); next.set(parentId, { ...parentNode, - children: parentNode.children.filter((id) => id !== folderId), + children: remaining, + hasChildren: remaining.length > 0, }); } } @@ -3761,11 +3848,14 @@ const AppLayout = () => { const node = folderNodes.get(folderId); const parentId = node?.parentId || 'root'; setSelectedFolder(parentId); - const parentContents = await ensureFolderData(parentId, { force: true }); + const parentContents = await ensureFolderData(parentId, { + force: true, + prefetchDepth: 1, + }); applySelectedFolder(parentId, parentContents); } else if (selectedFolder !== 'root') { - await ensureFolderData(selectedFolder, { force: true }); - } + await ensureFolderData(selectedFolder, { force: true, prefetchDepth: 1 }); + } setStatusMessage('Folder deleted.', 'success'); } catch (error) { const message = error.response?.data?.error || 'Failed to delete folder.'; @@ -3871,6 +3961,7 @@ const AppLayout = () => { ...parentNode, children: parentNode.children.concat([data.folder.id]), loaded: true, + hasChildren: true, }); } next.set(data.folder.id, { @@ -3880,10 +3971,11 @@ const AppLayout = () => { children: [], expanded: false, loaded: false, + hasChildren: false, }); return next; }); - await ensureFolderData(selectedFolder, { force: true }); + await ensureFolderData(selectedFolder, { force: true, prefetchDepth: 1 }); succeeded = true; } catch (error) { const message = error.response?.data?.error || 'Failed to create folder.'; @@ -4350,9 +4442,22 @@ const AppLayout = () => { const folderClickHandlers = { onToggle: async (folderId) => { const node = folderNodes.get(folderId); - if (node && !node.loaded) { + const nextExpanded = !(node?.expanded ?? false); + if (nextExpanded) { try { - await ensureFolderData(folderId, { includeDocuments: false }); + await ensureFolderData(folderId, { + includeDocuments: false, + prefetchDepth: 1, + }); + } catch (error) { + notifyApiError(error, 'Failed to load folder.'); + } + } else if (node && !node.loaded) { + try { + await ensureFolderData(folderId, { + includeDocuments: false, + prefetchDepth: 1, + }); } catch (error) { notifyApiError(error, 'Failed to load folder.'); } @@ -4361,7 +4466,7 @@ const AppLayout = () => { const next = new Map(prev); const current = next.get(folderId); if (!current) return prev; - next.set(folderId, { ...current, expanded: !current.expanded }); + next.set(folderId, { ...current, expanded: nextExpanded }); return next; }); }, @@ -4789,38 +4894,6 @@ const AppLayout = () => { const previewActive = Boolean(previewDocumentId && previewWorkspaceDocument); - const sidebarProps = { - folderNodes, - onToggle: folderClickHandlers.onToggle, - onSelect: folderClickHandlers.onSelect, - onDrop: folderClickHandlers.onDrop, - onDragOver: folderClickHandlers.onDragOver, - onDragLeave: folderClickHandlers.onDragLeave, - onDeleteFolder: handleFolderDelete, - onRenameFolder: handleFolderRename, - selectedFolder, - onFolderDragStart: handleFolderDragStart, - onFolderDragEnd: handleFolderDragEnd, - draggedFolderId, - tags, - activeTagIds: activeTagFilters, - onToggleTagFilter: toggleTagFilter, - correspondents, - activeCorrespondentIds: activeCorrespondentFilters, - onToggleCorrespondentFilter: toggleCorrespondentFilter, - appStatus, - loading, - previewActive, - searchQuery, - onSearchChange: handleSearchChange, - onSearchSubmit: handleSearchSubmit, - onSearchClear: clearFilters, - isFilterActive, - onLogout: handleLogout, - status, - onCollapse: collapseSidebar, - }; - const resolveThumbnailUrlForDoc = useCallback( (doc) => resolveDocumentAssetUrl(doc, 'thumbnail', { @@ -4842,6 +4915,81 @@ const AppLayout = () => { }); }, []); + const handleTenantSelect = useCallback( + async (tenantOption, { refreshOnly = false } = {}) => { + const requestedTenantId = + tenantOption?.tenant_id || tenantOption?.tenantId || tenantOption?.id || null; + if (!refreshOnly && (!requestedTenantId || requestedTenantId === currentTenantId)) { + return; + } + + setLoading(true); + try { + if (!refreshOnly) { + setStatusMessage('Switching tenant…', 'info'); + } + + if (refreshOnly) { + const { data } = await api.get('/auth/tenants'); + appDispatch({ + type: 'SET_TENANTS', + tenants: Array.isArray(data?.tenants) ? data.tenants : [], + }); + return; + } + + const { data } = await api.post('/auth/select-tenant', { tenant_id: requestedTenantId }); + if (!data?.access_token) { + throw new Error('Missing access token in tenant switch response.'); + } + + appDispatch({ type: 'LOGOUT' }); + resetWorkspaceState(); + + appDispatch({ + type: 'LOGIN_SUCCESS', + token: data.access_token, + tenant: data.tenant || null, + }); + + api.defaults.headers.common.Authorization = `Bearer ${data.access_token}`; + tokenRef.current = data.access_token; + tenantIdRef.current = data?.tenant?.tenant_id || null; + + if (Array.isArray(data?.tenants)) { + appDispatch({ type: 'SET_TENANTS', tenants: data.tenants }); + } + + setWorkspaceMode('table'); + navigate('/documents', { replace: true }); + + await Promise.all([refreshTags(), refreshCorrespondents()]); + await loadFolder('root', { showLoading: false, preserveSearch: false }); + + setStatusMessage( + `Switched to ${data?.tenant?.slug || data?.tenant?.tenant_id || 'tenant'}.`, + 'info', + ); + } catch (error) { + notifyApiError(error, 'Failed to switch tenant.'); + } finally { + setLoading(false); + } + }, + [ + currentTenantId, + appDispatch, + notifyApiError, + setStatusMessage, + resetWorkspaceState, + setWorkspaceMode, + navigate, + refreshTags, + refreshCorrespondents, + loadFolder, + ], + ); + const documentsTableProps = { currentFolderName, breadcrumbs, @@ -4894,6 +5042,52 @@ const AppLayout = () => { onClearSelection: clearDocumentSelection, }; + const sidebarProps = { + folderNodes, + onToggle: folderClickHandlers.onToggle, + onSelect: folderClickHandlers.onSelect, + onDrop: folderClickHandlers.onDrop, + onDragOver: folderClickHandlers.onDragOver, + onDragLeave: folderClickHandlers.onDragLeave, + onDeleteFolder: handleFolderDelete, + onRenameFolder: handleFolderRename, + selectedFolder, + onFolderDragStart: handleFolderDragStart, + onFolderDragEnd: handleFolderDragEnd, + draggedFolderId, + tags, + activeTagIds: activeTagFilters, + onToggleTagFilter: toggleTagFilter, + correspondents, + activeCorrespondentIds: activeCorrespondentFilters, + onToggleCorrespondentFilter: toggleCorrespondentFilter, + appStatus, + loading, + previewActive, + searchQuery, + onSearchChange: handleSearchChange, + onSearchSubmit: handleSearchSubmit, + onSearchClear: clearFilters, + isFilterActive, + onLogout: handleLogout, + status, + onCollapse: collapseSidebar, + tenantSlug, + tenants: tenantOptions, + activeTenantId: currentTenantId, + onSelectTenant: handleTenantSelect, + }; + + const sidebarPropsWithActions = useMemo( + () => ({ + ...sidebarProps, + onManageTags: openTagsModal, + onManageCorrespondents: openCorrespondentsModal, + onCollapse: collapseSidebar, + }), + [sidebarProps, openTagsModal, openCorrespondentsModal, collapseSidebar], + ); + const detailPanelProps = { selectedDocuments: orderedSelectedDocuments, tags, diff --git a/frontend/src/sidebar/Sidebar.jsx b/frontend/src/sidebar/Sidebar.jsx index 11851b0..240ca78 100644 --- a/frontend/src/sidebar/Sidebar.jsx +++ b/frontend/src/sidebar/Sidebar.jsx @@ -1,5 +1,13 @@ -import React, { useCallback, useMemo } from 'react'; -import { ChevronIcon, TrashIcon, EditIcon, FolderIcon, ChevronsLeftIcon } from '../ui/icons'; +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { + ChevronIcon, + TrashIcon, + EditIcon, + FolderIcon, + ChevronsLeftIcon, + LogoutIcon, + ChevronDownIcon, +} from '../ui/icons'; import { getTagColorStyle } from '../utils/colors'; @@ -20,9 +28,9 @@ const FolderNode = ({ draggingFolderId, }) => { const isRoot = node.id === 'root'; - const hasChildren = node.children.length > 0; + const hasChildren = Boolean(node.hasChildren); const canToggle = !isRoot && (hasChildren || !node.loaded); - const showChevron = !isRoot && hasChildren; + const showChevron = !isRoot && (hasChildren || !node.loaded); const icon = showChevron ? : null; const canDrag = !isRoot; const isDragging = draggingFolderId === node.id; @@ -149,6 +157,10 @@ const Sidebar = ({ onLogout, status, onCollapse, + tenantSlug, + tenants = [], + activeTenantId = null, + onSelectTenant, }) => { const sortedCorrespondents = useMemo( () => @@ -181,7 +193,59 @@ const Sidebar = ({ const handleSearchClear = useCallback(() => { onSearchClear?.(); }, [onSearchClear]); - const handleLogoutClick = useCallback(() => { + const tenantButtonRef = useRef(null); + const tenantMenuRef = useRef(null); + const [tenantMenuOpen, setTenantMenuOpen] = useState(false); + + const toggleTenantMenu = useCallback(() => { + const next = !tenantMenuOpen; + setTenantMenuOpen(next); + if (next && tenants.length === 0 && onSelectTenant) { + onSelectTenant(null, { refreshOnly: true }); + } + }, [tenantMenuOpen, tenants.length, onSelectTenant]); + + useEffect(() => { + if (!tenantMenuOpen) { + return undefined; + } + const handlePointer = (event) => { + const menuNode = tenantMenuRef.current; + const buttonNode = tenantButtonRef.current; + if (!menuNode) return; + if (menuNode.contains(event.target)) return; + if (buttonNode && buttonNode.contains(event.target)) return; + setTenantMenuOpen(false); + }; + const handleKeyDown = (event) => { + if (event.key === 'Escape') { + setTenantMenuOpen(false); + } + }; + document.addEventListener('mousedown', handlePointer); + document.addEventListener('touchstart', handlePointer); + document.addEventListener('keydown', handleKeyDown); + return () => { + document.removeEventListener('mousedown', handlePointer); + document.removeEventListener('touchstart', handlePointer); + document.removeEventListener('keydown', handleKeyDown); + }; + }, [tenantMenuOpen]); + + const handleTenantSelect = useCallback( + (tenant) => { + const targetId = tenant?.tenant_id || tenant?.tenantId || tenant?.id || null; + if (!targetId) { + return; + } + setTenantMenuOpen(false); + onSelectTenant?.(tenant); + }, + [onSelectTenant], + ); + + const handleLogoutFromMenu = useCallback(() => { + setTenantMenuOpen(false); onLogout?.(); }, [onLogout]); @@ -237,7 +301,61 @@ const Sidebar = ({ ); diff --git a/frontend/src/styles.css b/frontend/src/styles.css index fe93b5e..0a0f6d2 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -203,8 +203,8 @@ button.danger:hover:not([disabled]) { .panel-actions button, .panel-actions a.icon-button { display: inline-flex; - align-items: center; - justify-content: center; + align-items: flex-start; + justify-content: flex-start; border: none; background: transparent; color: var(--muted); @@ -213,6 +213,7 @@ button.danger:hover:not([disabled]) { cursor: pointer; transition: background 0.15s ease, color 0.15s ease; text-decoration: none; + text-align: left; } .panel-actions .icon-button:hover:not([disabled]), @@ -1051,6 +1052,142 @@ button.danger:hover:not([disabled]) { color: var(--fg); } +.sidebar__tenant { + color: var(--muted); + font-weight: 500; +} + +.sidebar__header { + position: relative; +} + +.sidebar__title-button { + display: inline-flex; + align-items: center; + gap: 0.35rem; + border: none; + background: none; + color: inherit; + font: inherit; + cursor: pointer; + padding: 0; +} + +.sidebar__title-button:focus-visible { + outline: 2px solid var(--accent, #2563eb); + outline-offset: 2px; +} + +.sidebar__title-chevron { + transition: transform 0.2s ease; +} + +.sidebar__title-chevron.is-open { + transform: rotate(180deg); +} + +.menu { + position: absolute; + top: 100%; + left: 1em; + right: auto; + background: var(--surface); + border: 1px solid var(--border-muted, var(--border)); + border-radius: 0.5rem; + box-shadow: 0 12px 28px rgba(15, 23, 42, 0.18); + min-width: 220px; + z-index: 20; + overflow: hidden; +} + +.menu__list { + max-height: 260px; + overflow-y: auto; + padding: 0.35rem 0; + display: flex; + flex-direction: column; + gap: 0.15rem; +} + +.menu button.menu__item, +.menu .menu__item { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.6rem; + width: 100%; + padding: 0.55rem 1rem; + border: none; + background: none; + color: inherit; + font: inherit; + text-align: left; + cursor: pointer; + transition: background 0.15s ease, color 0.15s ease; +} + +.menu button.menu__item:hover, +.menu button.menu__item:focus-visible, +.menu .menu__item:hover, +.menu .menu__item:focus-visible { + background: var(--sidebar-hover-bg); + color: var(--fg); +} + +.menu button.menu__item.active, +.menu .menu__item.active { + font-weight: 600; + color: var(--accent, #2563eb); +} + +.menu button.menu__item:focus-visible, +.menu .menu__item:focus-visible { + outline: 2px solid var(--accent, #2563eb); + outline-offset: -2px; +} + +.menu__label { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; +} + +.menu__active-indicator { + font-size: 0.75rem; + color: var(--muted); +} + +.menu__empty { + display: block; + padding: 0.6rem 0.75rem; + color: var(--muted); + font-size: 0.85rem; +} + +.menu__footer { + border-top: 1px solid var(--border-muted, var(--border)); + padding: 0.35rem 0.5rem; +} + +.menu__logout { + width: 100%; + display: inline-flex; + align-items: center; + gap: 0.4rem; + border: none; + background: none; + color: var(--danger, #d14343); + font: inherit; + cursor: pointer; + padding: 0.4rem 0.5rem; + border-radius: 0.35rem; +} + +.menu__logout:hover, +.menu__logout:focus-visible { + background: rgba(209, 67, 67, 0.12); +} + .sidebar__hint { color: var(--muted); font-size: 0.9rem; @@ -1108,6 +1245,7 @@ button.danger:hover:not([disabled]) { .sidebar-section__header { display: flex; align-items: center; + font-size: 0.75rem; justify-content: space-between; color: var(--muted); } @@ -1142,9 +1280,7 @@ button.danger:hover:not([disabled]) { .sidebar-section__header h3 { margin: 0; - font-size: 0.9rem; font-weight: 600; - color: inherit; } .sidebar-item { diff --git a/frontend/src/ui/icons.js b/frontend/src/ui/icons.js index b8533b6..0ad4606 100644 --- a/frontend/src/ui/icons.js +++ b/frontend/src/ui/icons.js @@ -18,6 +18,8 @@ import { IconFolderPlus, IconRefresh, IconMinusVertical, + IconLogout, + IconChevronDown, } from '@tabler/icons-react'; import FolderSvg from '../assets/folder.svg'; @@ -201,6 +203,24 @@ export const WindowMaximizeIcon = ({ className, size = '1em', stroke = 1.6, ...r /> ); +export const LogoutIcon = ({ className, size = '1em', stroke = 1.6, ...rest }) => ( + +); + +export const ChevronDownIcon = ({ className, size = '1em', stroke = 1.6, ...rest }) => ( + +); + export default { ChevronIcon, TrashIcon, @@ -219,6 +239,8 @@ export default { RefreshIcon, ArrowUpIcon, MinusVerticalIcon, + LogoutIcon, + ChevronDownIcon, }; export const TextScanIcon = ({ className, size = '1em', stroke = 1.6, ...rest }) => (