cleanup
This commit is contained in:
@@ -0,0 +1,13 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
|
||||
export const useDocumentsStore = () => {
|
||||
const [status, setStatus] = useState(null);
|
||||
|
||||
const setStatusMessage = useCallback((message, variant = 'info') => {
|
||||
setStatus(message ? { message, variant } : null);
|
||||
}, []);
|
||||
|
||||
return { status, setStatusMessage };
|
||||
};
|
||||
|
||||
export default useDocumentsStore;
|
||||
@@ -0,0 +1,137 @@
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
|
||||
const useAuthManager = ({
|
||||
apiClient,
|
||||
token,
|
||||
appStatus,
|
||||
appDispatch,
|
||||
notifyApiError,
|
||||
setStatusMessage,
|
||||
setLoading,
|
||||
}) => {
|
||||
const tokenRef = useRef(token);
|
||||
const refreshPromiseRef = useRef(null);
|
||||
const initialRefreshAttemptedRef = useRef(Boolean(token));
|
||||
|
||||
const refreshAccessToken = useCallback(async () => {
|
||||
console.log('[Auth] Attempting to refresh access token…');
|
||||
appDispatch({ type: 'TOKEN_REFRESH_START' });
|
||||
try {
|
||||
const { data } = await apiClient.post('/auth/refresh');
|
||||
if (data?.access_token) {
|
||||
appDispatch({
|
||||
type: 'TOKEN_REFRESH_SUCCESS',
|
||||
token: data.access_token,
|
||||
tenant: data.tenant || null,
|
||||
});
|
||||
console.log('[Auth] Access token refreshed at', new Date().toISOString());
|
||||
return data.access_token;
|
||||
}
|
||||
throw new Error('Missing access token in refresh response');
|
||||
} catch (error) {
|
||||
console.warn('[Auth] Failed to refresh access token', error);
|
||||
appDispatch({ type: 'TOKEN_REFRESH_FAILURE', error: error?.message || null });
|
||||
throw error;
|
||||
}
|
||||
}, [apiClient, appDispatch]);
|
||||
|
||||
useEffect(() => {
|
||||
tokenRef.current = token;
|
||||
}, [token]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!token && !initialRefreshAttemptedRef.current && appStatus === 'logged-out') {
|
||||
initialRefreshAttemptedRef.current = true;
|
||||
console.log('[Auth] Attempting refresh at startup');
|
||||
refreshAccessToken().catch(() => {});
|
||||
}
|
||||
}, [token, appStatus, refreshAccessToken]);
|
||||
|
||||
useEffect(() => {
|
||||
const requestInterceptor = apiClient.interceptors.request.use((config) => {
|
||||
const currentToken = tokenRef.current;
|
||||
if (currentToken) {
|
||||
config.headers = config.headers || {};
|
||||
if (!config.headers.Authorization) {
|
||||
config.headers.Authorization = `Bearer ${currentToken}`;
|
||||
}
|
||||
}
|
||||
return config;
|
||||
});
|
||||
|
||||
const responseInterceptor = apiClient.interceptors.response.use(
|
||||
(response) => response,
|
||||
async (error) => {
|
||||
const { response, config } = error;
|
||||
if (!response || !config) {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
|
||||
const status = response.status;
|
||||
const url = typeof config.url === 'string' ? config.url : '';
|
||||
const isAuthRoute = url.includes('/auth/login') || url.includes('/auth/refresh');
|
||||
|
||||
if (status === 401 && !config._retry && !isAuthRoute) {
|
||||
console.warn('[Auth] 401 received for', url, '- attempting token refresh');
|
||||
|
||||
if (!refreshPromiseRef.current) {
|
||||
refreshPromiseRef.current = (async () => {
|
||||
try {
|
||||
return await refreshAccessToken();
|
||||
} finally {
|
||||
refreshPromiseRef.current = null;
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
try {
|
||||
const newToken = await refreshPromiseRef.current;
|
||||
if (!newToken) {
|
||||
throw new Error('No token returned from refresh');
|
||||
}
|
||||
config._retry = true;
|
||||
config.headers = config.headers || {};
|
||||
config.headers.Authorization = `Bearer ${newToken}`;
|
||||
console.log('[Auth] Retrying original request', url);
|
||||
try {
|
||||
return await apiClient(config);
|
||||
} catch (retryError) {
|
||||
if (retryError?.response?.status === 401) {
|
||||
notifyApiError(retryError, 'Session expired. Please log in again.');
|
||||
}
|
||||
throw retryError;
|
||||
}
|
||||
} catch (refreshError) {
|
||||
console.warn('[Auth] Refresh failed, clearing session');
|
||||
notifyApiError(refreshError, 'Session expired. Please log in again.');
|
||||
return Promise.reject(refreshError);
|
||||
}
|
||||
}
|
||||
|
||||
return Promise.reject(error);
|
||||
},
|
||||
);
|
||||
|
||||
return () => {
|
||||
apiClient.interceptors.request.eject(requestInterceptor);
|
||||
apiClient.interceptors.response.eject(responseInterceptor);
|
||||
};
|
||||
}, [apiClient, notifyApiError, refreshAccessToken]);
|
||||
|
||||
const handleLogout = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
await apiClient.post('/auth/logout');
|
||||
} catch (error) {
|
||||
console.warn('[Auth] Failed to revoke refresh token during logout', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
appDispatch({ type: 'LOGOUT' });
|
||||
setStatusMessage('Logged out.', 'info');
|
||||
}
|
||||
}, [apiClient, appDispatch, setLoading, setStatusMessage]);
|
||||
|
||||
return { tokenRef, refreshAccessToken, handleLogout };
|
||||
};
|
||||
|
||||
export default useAuthManager;
|
||||
@@ -0,0 +1,127 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
|
||||
const useCorrespondents = ({
|
||||
apiClient,
|
||||
notifyApiError,
|
||||
setStatusMessage,
|
||||
tenantIdRef,
|
||||
mapDocumentCaches,
|
||||
}) => {
|
||||
const [correspondents, setCorrespondents] = useState([]);
|
||||
|
||||
const refreshCorrespondents = useCallback(async () => {
|
||||
const requestTenantId = tenantIdRef.current;
|
||||
try {
|
||||
const { data } = await apiClient.get('/correspondents');
|
||||
if (tenantIdRef.current !== requestTenantId) {
|
||||
return;
|
||||
}
|
||||
setCorrespondents(data || []);
|
||||
} catch (error) {
|
||||
if (tenantIdRef.current !== requestTenantId) {
|
||||
return;
|
||||
}
|
||||
notifyApiError(error, 'Unable to load correspondents.');
|
||||
}
|
||||
}, [apiClient, notifyApiError, tenantIdRef]);
|
||||
|
||||
const handleCorrespondentUpdate = useCallback(
|
||||
async (correspondentId, changes) => {
|
||||
if (!correspondentId) {
|
||||
throw new Error('Missing correspondent identifier.');
|
||||
}
|
||||
|
||||
const payload = {};
|
||||
if (typeof changes.name === 'string') {
|
||||
const trimmed = changes.name.trim();
|
||||
if (!trimmed) {
|
||||
throw new Error('Correspondent name cannot be empty.');
|
||||
}
|
||||
payload.name = trimmed;
|
||||
}
|
||||
|
||||
if (Object.keys(payload).length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
await apiClient.patch(`/correspondents/${correspondentId}`, payload);
|
||||
await refreshCorrespondents();
|
||||
setStatusMessage('Correspondent updated.', 'success');
|
||||
return true;
|
||||
} catch (error) {
|
||||
const message = error.response?.data?.error || 'Failed to update correspondent.';
|
||||
notifyApiError(error, message);
|
||||
throw new Error(message);
|
||||
}
|
||||
},
|
||||
[apiClient, notifyApiError, refreshCorrespondents, setStatusMessage],
|
||||
);
|
||||
|
||||
const handleCorrespondentCreate = useCallback(
|
||||
async ({ name }) => {
|
||||
const trimmed = typeof name === 'string' ? name.trim() : '';
|
||||
if (!trimmed) {
|
||||
throw new Error('Correspondent name is required.');
|
||||
}
|
||||
try {
|
||||
const { data } = await apiClient.post('/correspondents', { name: trimmed });
|
||||
await refreshCorrespondents();
|
||||
setStatusMessage('Correspondent created.', 'success');
|
||||
return data;
|
||||
} catch (error) {
|
||||
const message = error.response?.data?.error || 'Failed to create correspondent.';
|
||||
notifyApiError(error, message);
|
||||
throw new Error(message);
|
||||
}
|
||||
},
|
||||
[apiClient, notifyApiError, refreshCorrespondents, setStatusMessage],
|
||||
);
|
||||
|
||||
const handleCorrespondentDelete = useCallback(
|
||||
async (correspondentId) => {
|
||||
if (!correspondentId) {
|
||||
throw new Error('Missing correspondent identifier.');
|
||||
}
|
||||
|
||||
const stripFromDoc = (doc) => {
|
||||
if (!doc || !Array.isArray(doc.correspondents)) {
|
||||
return doc;
|
||||
}
|
||||
const next = doc.correspondents.filter((entry) => entry.id !== correspondentId);
|
||||
if (next.length === doc.correspondents.length) {
|
||||
return doc;
|
||||
}
|
||||
return { ...doc, correspondents: next };
|
||||
};
|
||||
|
||||
try {
|
||||
await apiClient.delete(`/correspondents/${correspondentId}`);
|
||||
await refreshCorrespondents();
|
||||
|
||||
if (typeof mapDocumentCaches === 'function') {
|
||||
mapDocumentCaches(stripFromDoc);
|
||||
}
|
||||
|
||||
setStatusMessage('Correspondent deleted.', 'success');
|
||||
return true;
|
||||
} catch (error) {
|
||||
const message = error.response?.data?.error || 'Failed to delete correspondent.';
|
||||
notifyApiError(error, message);
|
||||
throw new Error(message);
|
||||
}
|
||||
},
|
||||
[apiClient, mapDocumentCaches, notifyApiError, refreshCorrespondents, setStatusMessage],
|
||||
);
|
||||
|
||||
return {
|
||||
correspondents,
|
||||
refreshCorrespondents,
|
||||
handleCorrespondentCreate,
|
||||
handleCorrespondentUpdate,
|
||||
handleCorrespondentDelete,
|
||||
setCorrespondents,
|
||||
};
|
||||
};
|
||||
|
||||
export default useCorrespondents;
|
||||
@@ -0,0 +1,130 @@
|
||||
import { useCallback, useMemo } from 'react';
|
||||
|
||||
const useDocumentCorrespondentActions = ({
|
||||
apiClient,
|
||||
correspondents,
|
||||
handleCorrespondentCreate,
|
||||
refreshCurrentFolder,
|
||||
notifyApiError,
|
||||
setStatusMessage,
|
||||
}) => {
|
||||
const correspondentLookupByName = useMemo(() => {
|
||||
const map = new Map();
|
||||
correspondents.forEach((correspondent) => {
|
||||
if (correspondent?.name) {
|
||||
map.set(correspondent.name.toLowerCase(), correspondent);
|
||||
}
|
||||
});
|
||||
return map;
|
||||
}, [correspondents]);
|
||||
|
||||
const handleDocumentCorrespondentAttach = useCallback(
|
||||
async ({ documentId, correspondentId }, { notify = true, refresh = true } = {}) => {
|
||||
if (!documentId || !correspondentId) {
|
||||
throw new Error('Missing document or correspondent.');
|
||||
}
|
||||
try {
|
||||
await apiClient.post(`/documents/${documentId}/correspondents`, {
|
||||
assignments: [{ correspondent_id: correspondentId }],
|
||||
replace: false,
|
||||
});
|
||||
if (refresh) {
|
||||
await refreshCurrentFolder();
|
||||
}
|
||||
if (notify) {
|
||||
setStatusMessage('Correspondent assigned.', 'success');
|
||||
}
|
||||
return true;
|
||||
} catch (error) {
|
||||
const message = error.response?.data?.error || 'Failed to assign correspondent.';
|
||||
notifyApiError(error, message);
|
||||
throw new Error(message);
|
||||
}
|
||||
},
|
||||
[apiClient, refreshCurrentFolder, notifyApiError, setStatusMessage],
|
||||
);
|
||||
|
||||
const handleCorrespondentRemove = useCallback(
|
||||
async ({ documentId, correspondentId }, { notify = true, refresh = true } = {}) => {
|
||||
if (!documentId || !correspondentId) {
|
||||
throw new Error('Missing document or correspondent.');
|
||||
}
|
||||
try {
|
||||
await apiClient.delete(`/documents/${documentId}/correspondents/${correspondentId}`);
|
||||
if (refresh) {
|
||||
await refreshCurrentFolder();
|
||||
}
|
||||
if (notify) {
|
||||
setStatusMessage('Correspondent removed.', 'success');
|
||||
}
|
||||
return true;
|
||||
} catch (error) {
|
||||
const message = error.response?.data?.error || 'Failed to remove correspondent.';
|
||||
notifyApiError(error, message);
|
||||
throw new Error(message);
|
||||
}
|
||||
},
|
||||
[apiClient, refreshCurrentFolder, notifyApiError, setStatusMessage],
|
||||
);
|
||||
|
||||
const handleCorrespondentAdd = useCallback(
|
||||
async ({ document, name, input = null, option = null }) => {
|
||||
if (!document?.id) {
|
||||
throw new Error('Missing document for correspondent assignment.');
|
||||
}
|
||||
const trimmed = typeof name === 'string' ? name.trim() : '';
|
||||
if (!trimmed) {
|
||||
setStatusMessage('Correspondent name is required.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
let target = null;
|
||||
if (option && option.id) {
|
||||
target = correspondentLookupByName.get(trimmed.toLowerCase()) || option;
|
||||
} else {
|
||||
target = correspondentLookupByName.get(trimmed.toLowerCase()) || null;
|
||||
}
|
||||
if (!target) {
|
||||
try {
|
||||
target = await handleCorrespondentCreate({ name: trimmed });
|
||||
} catch (error) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (!target?.id) {
|
||||
setStatusMessage('Unable to resolve correspondent.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await handleDocumentCorrespondentAttach({
|
||||
documentId: document.id,
|
||||
correspondentId: target.id,
|
||||
});
|
||||
if (input) {
|
||||
input.value = '';
|
||||
}
|
||||
} catch (error) {
|
||||
setStatusMessage('Failed to assign correspondent.', 'error');
|
||||
console.error('[documents] assign correspondent failed', error);
|
||||
}
|
||||
},
|
||||
[
|
||||
correspondentLookupByName,
|
||||
handleCorrespondentCreate,
|
||||
handleDocumentCorrespondentAttach,
|
||||
setStatusMessage,
|
||||
],
|
||||
);
|
||||
|
||||
return {
|
||||
correspondentLookupByName,
|
||||
handleDocumentCorrespondentAttach,
|
||||
handleCorrespondentRemove,
|
||||
handleCorrespondentAdd,
|
||||
};
|
||||
};
|
||||
|
||||
export default useDocumentCorrespondentActions;
|
||||
|
||||
@@ -0,0 +1,252 @@
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
|
||||
const useDocumentDragHandlers = ({
|
||||
selectedEntries,
|
||||
selectedDocumentIds,
|
||||
selectedFolderIds,
|
||||
applySelection,
|
||||
handleEntrySelection,
|
||||
documentLookup,
|
||||
setDraggedDocumentIds,
|
||||
setDraggedFolderId,
|
||||
resolveDocumentRowKey,
|
||||
resolveFolderRowKey,
|
||||
documentsViewMode,
|
||||
}) => {
|
||||
const dragPreviewRef = useRef(null);
|
||||
|
||||
const destroyDragPreview = useCallback(() => {
|
||||
const node = dragPreviewRef.current;
|
||||
if (node && node.parentNode) {
|
||||
node.parentNode.removeChild(node);
|
||||
}
|
||||
dragPreviewRef.current = null;
|
||||
}, []);
|
||||
|
||||
useEffect(() => destroyDragPreview, [destroyDragPreview]);
|
||||
|
||||
const createDragPreview = useCallback(
|
||||
({ documents = [], folders = [] } = {}) => {
|
||||
destroyDragPreview();
|
||||
|
||||
const docEntries = (documents || []).filter(Boolean);
|
||||
const folderEntries = (folders || []).filter(Boolean);
|
||||
const totalCount = docEntries.length + folderEntries.length;
|
||||
if (!totalCount) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const maxVisible = 4;
|
||||
const size = 64;
|
||||
const canvasSize = Math.round(size * 1.6);
|
||||
|
||||
const visibleItems = [];
|
||||
docEntries.slice(0, maxVisible).forEach((doc) => {
|
||||
visibleItems.push({ type: 'document', payload: doc });
|
||||
});
|
||||
|
||||
if (visibleItems.length < maxVisible) {
|
||||
folderEntries
|
||||
.slice(0, maxVisible - visibleItems.length)
|
||||
.forEach((folderId) => visibleItems.push({ type: 'folder', payload: folderId }));
|
||||
}
|
||||
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.className = 'document-drag-preview';
|
||||
wrapper.style.width = `${canvasSize}px`;
|
||||
wrapper.style.height = `${canvasSize}px`;
|
||||
|
||||
visibleItems.forEach((item, index) => {
|
||||
const slot = document.createElement('div');
|
||||
slot.className = 'document-drag-preview__item';
|
||||
slot.style.setProperty('--index', String(index));
|
||||
slot.style.width = `${size}px`;
|
||||
slot.style.height = `${size}px`;
|
||||
|
||||
if (item.type === 'document') {
|
||||
slot.textContent = item.payload?.title || 'Document';
|
||||
} else {
|
||||
slot.textContent = 'Folder';
|
||||
}
|
||||
wrapper.appendChild(slot);
|
||||
});
|
||||
|
||||
document.body.appendChild(wrapper);
|
||||
dragPreviewRef.current = wrapper;
|
||||
return wrapper;
|
||||
},
|
||||
[destroyDragPreview],
|
||||
);
|
||||
|
||||
const handleDocumentDragStart = useCallback(
|
||||
(event, documentOrId) => {
|
||||
const documentId = typeof documentOrId === 'string' ? documentOrId : documentOrId?.id;
|
||||
if (!documentId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const documentKey = resolveDocumentRowKey(documentId);
|
||||
if (!documentKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isGridView = documentsViewMode === 'grid';
|
||||
const isAlreadySelected = selectedDocumentIds.includes(documentId);
|
||||
const selection = isAlreadySelected
|
||||
? [...selectedDocumentIds]
|
||||
: isGridView
|
||||
? [...selectedDocumentIds, documentId]
|
||||
: [documentId];
|
||||
const folderSelection = selectedFolderIds.length ? [...selectedFolderIds] : [];
|
||||
|
||||
if (!isAlreadySelected && !isGridView) {
|
||||
applySelection([documentKey], {
|
||||
anchor: documentKey,
|
||||
interactedKeys: [documentKey],
|
||||
});
|
||||
}
|
||||
|
||||
const previewDocs = selection.map((id) => documentLookup.get(id) || null).filter(Boolean);
|
||||
const previewNode = createDragPreview({
|
||||
documents: previewDocs,
|
||||
folders: folderSelection,
|
||||
});
|
||||
|
||||
setDraggedDocumentIds(selection);
|
||||
if (folderSelection.length) {
|
||||
setDraggedFolderId(folderSelection[0] || null);
|
||||
}
|
||||
event.dataTransfer.effectAllowed = 'move';
|
||||
try {
|
||||
event.dataTransfer.setData(
|
||||
'application/x-papercrate-doc-list',
|
||||
JSON.stringify(selection),
|
||||
);
|
||||
if (folderSelection.length) {
|
||||
event.dataTransfer.setData(
|
||||
'application/x-papercrate-folder-list',
|
||||
JSON.stringify(folderSelection),
|
||||
);
|
||||
if (folderSelection.length === 1) {
|
||||
event.dataTransfer.setData('application/x-papercrate-folder', folderSelection[0]);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('[documents] Failed to populate drag payload', error);
|
||||
}
|
||||
if (previewNode) {
|
||||
const width = previewNode.offsetWidth || 96;
|
||||
const height = previewNode.offsetHeight || 96;
|
||||
event.dataTransfer.setDragImage(previewNode, width / 2, height / 2);
|
||||
}
|
||||
event.currentTarget.classList.add('dragging');
|
||||
},
|
||||
[
|
||||
selectedDocumentIds,
|
||||
selectedFolderIds,
|
||||
applySelection,
|
||||
documentLookup,
|
||||
createDragPreview,
|
||||
setDraggedFolderId,
|
||||
setDraggedDocumentIds,
|
||||
documentsViewMode,
|
||||
resolveDocumentRowKey,
|
||||
],
|
||||
);
|
||||
|
||||
const handleDocumentDragEnd = useCallback(
|
||||
(event) => {
|
||||
setDraggedDocumentIds([]);
|
||||
event.currentTarget.classList.remove('dragging');
|
||||
destroyDragPreview();
|
||||
setDraggedFolderId(null);
|
||||
},
|
||||
[destroyDragPreview, setDraggedFolderId, setDraggedDocumentIds],
|
||||
);
|
||||
|
||||
const handleFolderDragStart = useCallback(
|
||||
(event, folderId) => {
|
||||
if (folderId === 'root') {
|
||||
return;
|
||||
}
|
||||
event.stopPropagation();
|
||||
const folderKey = resolveFolderRowKey(folderId);
|
||||
const isAlreadySelected = folderKey ? selectedEntries.includes(folderKey) : false;
|
||||
|
||||
let effectiveFolderSelection = selectedFolderIds;
|
||||
let effectiveDocumentSelection = selectedDocumentIds;
|
||||
|
||||
if (!isAlreadySelected && folderKey) {
|
||||
effectiveFolderSelection = [folderId];
|
||||
effectiveDocumentSelection = [];
|
||||
handleEntrySelection(folderKey, { preventDefault: () => {} });
|
||||
}
|
||||
|
||||
const uniqueFolders = effectiveFolderSelection.length
|
||||
? Array.from(new Set(effectiveFolderSelection.filter(Boolean)))
|
||||
: [folderId];
|
||||
|
||||
setDraggedFolderId(folderId);
|
||||
if (effectiveDocumentSelection.length) {
|
||||
setDraggedDocumentIds(effectiveDocumentSelection);
|
||||
}
|
||||
|
||||
event.dataTransfer.effectAllowed = 'move';
|
||||
try {
|
||||
event.dataTransfer.setData(
|
||||
'application/x-papercrate-folder-list',
|
||||
JSON.stringify(uniqueFolders),
|
||||
);
|
||||
if (uniqueFolders.length === 1) {
|
||||
event.dataTransfer.setData('application/x-papercrate-folder', uniqueFolders[0]);
|
||||
}
|
||||
if (effectiveDocumentSelection.length) {
|
||||
event.dataTransfer.setData(
|
||||
'application/x-papercrate-doc-list',
|
||||
JSON.stringify(effectiveDocumentSelection),
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('[documents] Failed to populate folder drag payload', error);
|
||||
}
|
||||
|
||||
const previewDocs = effectiveDocumentSelection
|
||||
.map((id) => documentLookup.get(id) || null)
|
||||
.filter(Boolean);
|
||||
createDragPreview({ documents: previewDocs, folders: uniqueFolders });
|
||||
event.currentTarget.classList.add('dragging');
|
||||
},
|
||||
[
|
||||
selectedFolderIds,
|
||||
selectedEntries,
|
||||
selectedDocumentIds,
|
||||
handleEntrySelection,
|
||||
setDraggedFolderId,
|
||||
setDraggedDocumentIds,
|
||||
documentLookup,
|
||||
createDragPreview,
|
||||
resolveFolderRowKey,
|
||||
],
|
||||
);
|
||||
|
||||
const handleFolderDragEnd = useCallback(
|
||||
(event) => {
|
||||
if (event?.currentTarget) {
|
||||
event.currentTarget.classList.remove('dragging');
|
||||
}
|
||||
setDraggedFolderId(null);
|
||||
setDraggedDocumentIds([]);
|
||||
destroyDragPreview();
|
||||
},
|
||||
[setDraggedFolderId, setDraggedDocumentIds, destroyDragPreview],
|
||||
);
|
||||
|
||||
return {
|
||||
handleDocumentDragStart,
|
||||
handleDocumentDragEnd,
|
||||
handleFolderDragStart,
|
||||
handleFolderDragEnd,
|
||||
};
|
||||
};
|
||||
|
||||
export default useDocumentDragHandlers;
|
||||
@@ -0,0 +1,204 @@
|
||||
import { useCallback } from 'react';
|
||||
|
||||
const useDocumentTagging = ({
|
||||
apiClient,
|
||||
tags,
|
||||
tagManager,
|
||||
refreshTags,
|
||||
refreshCurrentFolder,
|
||||
resolveTargetDocumentIds,
|
||||
notifyApiError,
|
||||
setStatusMessage,
|
||||
setLoading,
|
||||
}) => {
|
||||
const bulkTagOperation = useCallback(
|
||||
async ({ labels, action, documentIds }) => {
|
||||
const normalized = labels.map((label) => label.trim()).filter((label) => label.length > 0);
|
||||
if (!normalized.length) {
|
||||
return { ok: false, reason: 'no-labels' };
|
||||
}
|
||||
const targetDocumentIds = resolveTargetDocumentIds(documentIds);
|
||||
if (!targetDocumentIds.length) {
|
||||
return { ok: false, reason: 'no-selection' };
|
||||
}
|
||||
|
||||
let tagIds = [];
|
||||
|
||||
if (action === 'remove') {
|
||||
const missing = normalized.find(
|
||||
(label) => !tags.some((tag) => tag.label.toLowerCase() === label.toLowerCase()),
|
||||
);
|
||||
if (missing) {
|
||||
return { ok: false, reason: 'tag-missing', label: missing };
|
||||
}
|
||||
|
||||
tagIds = normalized.map((label) => {
|
||||
const tag = tags.find((item) => item.label.toLowerCase() === label.toLowerCase());
|
||||
return tag?.id;
|
||||
}).filter(Boolean);
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
if (action === 'add') {
|
||||
const createdIds = [];
|
||||
for (const label of normalized) {
|
||||
let tag = tags.find((item) => item.label.toLowerCase() === label.toLowerCase()) || null;
|
||||
if (!tag) {
|
||||
const payload = tagManager.buildPayload({ label });
|
||||
const { data } = await apiClient.post('/tags', payload);
|
||||
tag = data;
|
||||
await refreshTags();
|
||||
}
|
||||
createdIds.push(tag.id);
|
||||
}
|
||||
tagIds = Array.from(new Set(createdIds));
|
||||
}
|
||||
|
||||
tagIds = Array.from(new Set(tagIds));
|
||||
|
||||
if (!tagIds.length) {
|
||||
return { ok: false, reason: 'no-tags' };
|
||||
}
|
||||
|
||||
await apiClient.post('/documents/bulk/tags', {
|
||||
document_ids: targetDocumentIds,
|
||||
tag_ids: tagIds,
|
||||
action,
|
||||
});
|
||||
|
||||
await refreshCurrentFolder();
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
tagCount: tagIds.length,
|
||||
docsCount: targetDocumentIds.length,
|
||||
};
|
||||
} catch (error) {
|
||||
const message =
|
||||
error.response?.data?.error ||
|
||||
(action === 'add' ? 'Failed to assign tags.' : 'Failed to remove tags.');
|
||||
notifyApiError(error, message);
|
||||
return { ok: false, reason: 'request-failed' };
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[
|
||||
resolveTargetDocumentIds,
|
||||
tags,
|
||||
refreshTags,
|
||||
refreshCurrentFolder,
|
||||
notifyApiError,
|
||||
setLoading,
|
||||
tagManager,
|
||||
apiClient,
|
||||
],
|
||||
);
|
||||
|
||||
const handleBulkTagAddFromDetail = useCallback(
|
||||
async ({ label, input, documentIds }) => {
|
||||
const trimmed = typeof label === 'string' ? label.trim() : '';
|
||||
if (!trimmed) {
|
||||
setStatusMessage('Enter a tag label.', 'error');
|
||||
return;
|
||||
}
|
||||
const targetIds = resolveTargetDocumentIds(documentIds);
|
||||
if (!targetIds.length) {
|
||||
setStatusMessage('Select documents before assigning tags.', 'error');
|
||||
return;
|
||||
}
|
||||
const result = await bulkTagOperation({
|
||||
labels: [trimmed],
|
||||
action: 'add',
|
||||
documentIds: targetIds,
|
||||
});
|
||||
if (result?.ok) {
|
||||
const { tagCount, docsCount } = result;
|
||||
setStatusMessage(
|
||||
`Assigned ${tagCount} tag${tagCount === 1 ? '' : 's'} to ${docsCount} document${
|
||||
docsCount === 1 ? '' : 's'
|
||||
}.`,
|
||||
'success',
|
||||
);
|
||||
if (input) {
|
||||
input.value = '';
|
||||
}
|
||||
}
|
||||
},
|
||||
[bulkTagOperation, resolveTargetDocumentIds, setStatusMessage],
|
||||
);
|
||||
|
||||
const handleBulkTagRemoveFromDetail = useCallback(
|
||||
async ({ label, input, documentIds }) => {
|
||||
const trimmed = typeof label === 'string' ? label.trim() : '';
|
||||
if (!trimmed) {
|
||||
setStatusMessage('Enter a tag label to remove.', 'error');
|
||||
return;
|
||||
}
|
||||
const targetIds = resolveTargetDocumentIds(documentIds);
|
||||
if (!targetIds.length) {
|
||||
setStatusMessage('Select documents before removing tags.', 'error');
|
||||
return;
|
||||
}
|
||||
const result = await bulkTagOperation({
|
||||
labels: [trimmed],
|
||||
action: 'remove',
|
||||
documentIds: targetIds,
|
||||
});
|
||||
if (result?.ok) {
|
||||
const { docsCount } = result;
|
||||
setStatusMessage(
|
||||
`Removed tags from ${docsCount} document${docsCount === 1 ? '' : 's'}.`,
|
||||
'success',
|
||||
);
|
||||
if (input) {
|
||||
input.value = '';
|
||||
}
|
||||
} else if (result?.reason === 'tag-missing') {
|
||||
setStatusMessage(`Tag “${result.label}” not found.`, 'error');
|
||||
}
|
||||
},
|
||||
[bulkTagOperation, resolveTargetDocumentIds, setStatusMessage],
|
||||
);
|
||||
|
||||
const handleBulkSelectionReanalyze = useCallback(
|
||||
async (documentIdsOverride = null) => {
|
||||
const targetIds = resolveTargetDocumentIds(documentIdsOverride);
|
||||
if (!targetIds.length) {
|
||||
setStatusMessage('Select documents before requesting re-analysis.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const { data } = await apiClient.post('/documents/bulk/reanalyze', {
|
||||
document_ids: targetIds,
|
||||
force: true,
|
||||
});
|
||||
const queued = data?.queued ?? targetIds.length;
|
||||
setStatusMessage(
|
||||
`Queued re-analysis for ${queued} document${queued === 1 ? '' : 's'}.`,
|
||||
'success',
|
||||
);
|
||||
} catch (error) {
|
||||
const message =
|
||||
error.response?.data?.error || 'Failed to queue document re-analysis.';
|
||||
notifyApiError(error, message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[resolveTargetDocumentIds, notifyApiError, setStatusMessage, setLoading, apiClient],
|
||||
);
|
||||
|
||||
return {
|
||||
bulkTagOperation,
|
||||
handleBulkTagAddFromDetail,
|
||||
handleBulkTagRemoveFromDetail,
|
||||
handleBulkSelectionReanalyze,
|
||||
};
|
||||
};
|
||||
|
||||
export default useDocumentTagging;
|
||||
|
||||
@@ -0,0 +1,303 @@
|
||||
import { useCallback, useRef, useState } from 'react';
|
||||
import useFileDrop from './useFileDrop';
|
||||
import { DEFAULT_FOLDER_NAME, hasFiles } from '../../app/appLayoutUtils';
|
||||
|
||||
const useDocumentUploads = ({
|
||||
apiClient,
|
||||
token,
|
||||
selectedFolder,
|
||||
currentFolderName,
|
||||
ensureFolderData,
|
||||
refreshCurrentFolder,
|
||||
notifyApiError,
|
||||
setStatusMessage,
|
||||
setLoading,
|
||||
shellRef,
|
||||
}) => {
|
||||
const [dropOverlayState, setDropOverlayState] = useState({
|
||||
active: false,
|
||||
folderName: DEFAULT_FOLDER_NAME,
|
||||
});
|
||||
const dragCounterRef = useRef(0);
|
||||
const folderPathCacheRef = useRef(new Map());
|
||||
|
||||
const uploadFile = useCallback(
|
||||
async (file, targetFolderId) => {
|
||||
if (!file || file.size === 0) {
|
||||
setStatusMessage('Skipped empty file.', 'error');
|
||||
return null;
|
||||
}
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('file', file, file.name);
|
||||
if (targetFolderId && targetFolderId !== 'root') {
|
||||
formData.append('folder_id', targetFolderId);
|
||||
}
|
||||
|
||||
try {
|
||||
const { data, status } = await apiClient.post('/documents', formData);
|
||||
const duplicate = data?.reused || status === 200;
|
||||
setStatusMessage(
|
||||
duplicate
|
||||
? `${file.name} already exists; reused existing document.`
|
||||
: `Uploaded ${file.name}`,
|
||||
duplicate ? 'info' : 'success',
|
||||
);
|
||||
return data;
|
||||
} catch (error) {
|
||||
const message = error.response?.data?.error || `Failed to upload ${file.name}.`;
|
||||
notifyApiError(error, message);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
[apiClient, notifyApiError, setStatusMessage],
|
||||
);
|
||||
|
||||
const ensureFolderPathOnServer = useCallback(
|
||||
async (baseFolderId, segments) => {
|
||||
const trimmedSegments = segments.map((segment) => segment.trim()).filter(Boolean);
|
||||
if (trimmedSegments.length === 0) {
|
||||
return baseFolderId ?? null;
|
||||
}
|
||||
|
||||
const cacheKey = `${baseFolderId ?? 'ROOT'}:${trimmedSegments.join('/')}`;
|
||||
const cache = folderPathCacheRef.current;
|
||||
if (cache.has(cacheKey)) {
|
||||
return cache.get(cacheKey);
|
||||
}
|
||||
|
||||
const payload = {
|
||||
parent_id: baseFolderId && baseFolderId !== 'root' ? baseFolderId : null,
|
||||
segments: trimmedSegments,
|
||||
};
|
||||
|
||||
const { data } = await apiClient.post('/folders/path', payload);
|
||||
cache.set(cacheKey, data.folder.id);
|
||||
return data.folder.id;
|
||||
},
|
||||
[apiClient],
|
||||
);
|
||||
|
||||
const extractFilesFromDataTransfer = useCallback(async (dataTransfer) => {
|
||||
if (!dataTransfer) {
|
||||
throw new Error('No drop payload found.');
|
||||
}
|
||||
|
||||
const items = Array.from(dataTransfer.items || []);
|
||||
console.info('[Uploads] drop start', {
|
||||
items: items.length,
|
||||
files: (dataTransfer.files || []).length,
|
||||
});
|
||||
|
||||
const results = [];
|
||||
const seenKeys = new Set();
|
||||
|
||||
const pushFile = (file, ancestors = []) => {
|
||||
if (!file) return;
|
||||
const segments = (ancestors || []).filter(Boolean);
|
||||
const key = `${segments.join('/')}/${file.name}:${file.size}`;
|
||||
if (seenKeys.has(key)) {
|
||||
return;
|
||||
}
|
||||
seenKeys.add(key);
|
||||
results.push({ file, segments });
|
||||
};
|
||||
|
||||
const readAllEntries = async (reader) => {
|
||||
const entries = [];
|
||||
let batch = [];
|
||||
do {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
batch = await new Promise((resolve, reject) => reader.readEntries(resolve, reject));
|
||||
if (batch.length) {
|
||||
entries.push(...batch);
|
||||
}
|
||||
} while (batch.length);
|
||||
return entries;
|
||||
};
|
||||
|
||||
const walkEntry = async (entry, ancestors = []) => {
|
||||
if (!entry) return;
|
||||
if (entry.isFile) {
|
||||
const file = await new Promise((resolve, reject) => {
|
||||
try {
|
||||
entry.file(resolve, reject);
|
||||
} catch (error) {
|
||||
console.warn('[Uploads] entry.file failed', error);
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
pushFile(file, ancestors);
|
||||
return;
|
||||
}
|
||||
if (entry.isDirectory) {
|
||||
const nextAncestors = entry.name ? [...ancestors, entry.name] : [...ancestors];
|
||||
const reader = entry.createReader();
|
||||
const entries = await readAllEntries(reader);
|
||||
for (const child of entries) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await walkEntry(child, nextAncestors);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
await Promise.all(
|
||||
items.map(async (item, index) => {
|
||||
if (item.kind !== 'file') return;
|
||||
|
||||
const fileFromItem = typeof item.getAsFile === 'function' ? item.getAsFile() : null;
|
||||
if (fileFromItem) {
|
||||
const relativePath =
|
||||
typeof fileFromItem.webkitRelativePath === 'string'
|
||||
? fileFromItem.webkitRelativePath
|
||||
: '';
|
||||
const segments = relativePath
|
||||
? relativePath
|
||||
.split('/')
|
||||
.slice(0, -1)
|
||||
.filter(Boolean)
|
||||
: [];
|
||||
pushFile(fileFromItem, segments);
|
||||
}
|
||||
|
||||
if (typeof item.webkitGetAsEntry === 'function') {
|
||||
try {
|
||||
const entry = item.webkitGetAsEntry();
|
||||
if (entry) {
|
||||
await walkEntry(entry, []);
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('[Uploads] webkitGetAsEntry failed', error);
|
||||
}
|
||||
}
|
||||
|
||||
if (!fileFromItem) {
|
||||
console.info('[Uploads] item missing file handle', index);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
Array.from(dataTransfer.files || []).forEach((file) => {
|
||||
if (!file) return;
|
||||
const relativePath =
|
||||
typeof file.webkitRelativePath === 'string' ? file.webkitRelativePath : '';
|
||||
const segments = relativePath
|
||||
? relativePath
|
||||
.split('/')
|
||||
.slice(0, -1)
|
||||
.filter(Boolean)
|
||||
: [];
|
||||
pushFile(file, segments);
|
||||
});
|
||||
|
||||
if (!results.length) {
|
||||
throw new Error('No files detected in drop payload.');
|
||||
}
|
||||
|
||||
console.info('[Uploads] prepared files', results.length);
|
||||
|
||||
return results;
|
||||
}, []);
|
||||
|
||||
const handleFileDrop = useCallback(
|
||||
async (dataTransfer, targetFolderId) => {
|
||||
if (!token) {
|
||||
setStatusMessage('Please log in before uploading.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
folderPathCacheRef.current.clear();
|
||||
|
||||
let extracted;
|
||||
try {
|
||||
extracted = await extractFilesFromDataTransfer(dataTransfer);
|
||||
} catch (error) {
|
||||
const message = error.message || 'Failed to process dropped files.';
|
||||
notifyApiError(error, message);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!extracted.length) {
|
||||
setStatusMessage('No files to upload.', 'info');
|
||||
return;
|
||||
}
|
||||
const baseFolderId =
|
||||
targetFolderId && targetFolderId !== 'root' ? targetFolderId : null;
|
||||
|
||||
for (const { file, segments } of extracted) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
const destinationId = segments.length
|
||||
? await ensureFolderPathOnServer(baseFolderId, segments)
|
||||
: baseFolderId;
|
||||
|
||||
const uploadTarget =
|
||||
destinationId ??
|
||||
(targetFolderId && targetFolderId !== 'root' ? targetFolderId : 'root');
|
||||
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await uploadFile(file, uploadTarget);
|
||||
}
|
||||
|
||||
await refreshCurrentFolder();
|
||||
|
||||
if (
|
||||
targetFolderId &&
|
||||
targetFolderId !== 'root' &&
|
||||
targetFolderId !== selectedFolder
|
||||
) {
|
||||
await ensureFolderData(targetFolderId, { force: true, prefetchDepth: 1 });
|
||||
}
|
||||
} catch (error) {
|
||||
notifyApiError(error, 'Failed to upload files.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[
|
||||
token,
|
||||
extractFilesFromDataTransfer,
|
||||
ensureFolderPathOnServer,
|
||||
uploadFile,
|
||||
refreshCurrentFolder,
|
||||
selectedFolder,
|
||||
ensureFolderData,
|
||||
notifyApiError,
|
||||
setStatusMessage,
|
||||
setLoading,
|
||||
],
|
||||
);
|
||||
|
||||
useFileDrop({
|
||||
shellRef,
|
||||
token,
|
||||
currentFolderName,
|
||||
selectedFolder,
|
||||
handleFileDrop,
|
||||
hasFiles,
|
||||
defaultFolderName: DEFAULT_FOLDER_NAME,
|
||||
dragCounterRef,
|
||||
dropOverlayState,
|
||||
setDropOverlayState,
|
||||
});
|
||||
|
||||
const resetUploadsState = useCallback(() => {
|
||||
dragCounterRef.current = 0;
|
||||
setDropOverlayState({ active: false, folderName: DEFAULT_FOLDER_NAME });
|
||||
}, []);
|
||||
|
||||
return {
|
||||
dropOverlayState,
|
||||
setDropOverlayState,
|
||||
dragCounterRef,
|
||||
handleFileDrop,
|
||||
uploadFile,
|
||||
extractFilesFromDataTransfer,
|
||||
resetUploadsState,
|
||||
};
|
||||
};
|
||||
|
||||
export default useDocumentUploads;
|
||||
@@ -0,0 +1,91 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
|
||||
const useDocuments = ({ setSearchResults, setFolderContents }) => {
|
||||
const [documents, setDocuments] = useState([]);
|
||||
|
||||
const mapDocumentCaches = useCallback(
|
||||
(mapper) => {
|
||||
if (typeof mapper !== 'function') {
|
||||
return;
|
||||
}
|
||||
|
||||
const applyToList = (list) => {
|
||||
let changed = false;
|
||||
const next = list.map((doc) => {
|
||||
const updated = mapper(doc);
|
||||
if (updated === undefined || updated === doc) {
|
||||
return doc;
|
||||
}
|
||||
changed = true;
|
||||
return updated;
|
||||
});
|
||||
return changed ? next : list;
|
||||
};
|
||||
|
||||
setDocuments((prev) => applyToList(prev));
|
||||
setSearchResults((prev) => {
|
||||
if (!Array.isArray(prev)) {
|
||||
return prev;
|
||||
}
|
||||
return applyToList(prev);
|
||||
});
|
||||
setFolderContents((prev) => {
|
||||
if (!prev.size) {
|
||||
return prev;
|
||||
}
|
||||
let changed = false;
|
||||
const next = new Map();
|
||||
prev.forEach((contents, key) => {
|
||||
const docs = Array.isArray(contents?.documents) ? contents.documents : null;
|
||||
if (!docs || docs.length === 0) {
|
||||
next.set(key, contents);
|
||||
return;
|
||||
}
|
||||
let docsChanged = false;
|
||||
const updatedDocs = docs.map((doc) => {
|
||||
const updated = mapper(doc);
|
||||
if (updated === undefined || updated === doc) {
|
||||
return doc;
|
||||
}
|
||||
docsChanged = true;
|
||||
return updated;
|
||||
});
|
||||
if (docsChanged) {
|
||||
changed = true;
|
||||
next.set(key, { ...contents, documents: updatedDocs });
|
||||
} else {
|
||||
next.set(key, contents);
|
||||
}
|
||||
});
|
||||
return changed ? next : prev;
|
||||
});
|
||||
},
|
||||
[setFolderContents, setSearchResults],
|
||||
);
|
||||
|
||||
const updateDocumentCaches = useCallback(
|
||||
(documentId, updater) => {
|
||||
if (!documentId || typeof updater !== 'function') {
|
||||
return;
|
||||
}
|
||||
|
||||
mapDocumentCaches((doc) => {
|
||||
if (!doc || doc.id !== documentId) {
|
||||
return doc;
|
||||
}
|
||||
const updated = updater(doc);
|
||||
return updated === undefined ? doc : updated;
|
||||
});
|
||||
},
|
||||
[mapDocumentCaches],
|
||||
);
|
||||
|
||||
return {
|
||||
documents,
|
||||
setDocuments,
|
||||
mapDocumentCaches,
|
||||
updateDocumentCaches,
|
||||
};
|
||||
};
|
||||
|
||||
export default useDocuments;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,84 @@
|
||||
import { useEffect } from 'react';
|
||||
|
||||
const useFileDrop = ({
|
||||
shellRef,
|
||||
token,
|
||||
currentFolderName,
|
||||
selectedFolder,
|
||||
handleFileDrop,
|
||||
hasFiles,
|
||||
defaultFolderName,
|
||||
dragCounterRef,
|
||||
setDropOverlayState,
|
||||
}) => {
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) {
|
||||
setDropOverlayState((prev) => ({ ...prev, active: false }));
|
||||
dragCounterRef.current = 0;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const handleDragEnter = (event) => {
|
||||
if (!hasFiles(event)) return;
|
||||
event.preventDefault();
|
||||
dragCounterRef.current += 1;
|
||||
setDropOverlayState({ active: true, folderName: currentFolderName });
|
||||
};
|
||||
|
||||
const handleDragOver = (event) => {
|
||||
if (!hasFiles(event)) return;
|
||||
event.preventDefault();
|
||||
event.dataTransfer.dropEffect = 'copy';
|
||||
};
|
||||
|
||||
const handleDragLeave = (event) => {
|
||||
if (!hasFiles(event)) return;
|
||||
dragCounterRef.current = Math.max(0, dragCounterRef.current - 1);
|
||||
if (dragCounterRef.current === 0) {
|
||||
setDropOverlayState((prev) => ({ ...prev, active: false }));
|
||||
}
|
||||
};
|
||||
|
||||
const handleDrop = async (event) => {
|
||||
if (!hasFiles(event)) return;
|
||||
event.preventDefault();
|
||||
dragCounterRef.current = 0;
|
||||
setDropOverlayState((prev) => ({ ...prev, active: false }));
|
||||
await handleFileDrop(event.dataTransfer, selectedFolder);
|
||||
};
|
||||
|
||||
const dropTarget = shellRef.current;
|
||||
if (!dropTarget) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
dropTarget.addEventListener('dragenter', handleDragEnter);
|
||||
dropTarget.addEventListener('dragover', handleDragOver);
|
||||
dropTarget.addEventListener('dragleave', handleDragLeave);
|
||||
dropTarget.addEventListener('drop', handleDrop);
|
||||
|
||||
return () => {
|
||||
dropTarget.removeEventListener('dragenter', handleDragEnter);
|
||||
dropTarget.removeEventListener('dragover', handleDragOver);
|
||||
dropTarget.removeEventListener('dragleave', handleDragLeave);
|
||||
dropTarget.removeEventListener('drop', handleDrop);
|
||||
dragCounterRef.current = 0;
|
||||
setDropOverlayState((prev) => ({ ...prev, active: false }));
|
||||
};
|
||||
}, [
|
||||
token,
|
||||
handleFileDrop,
|
||||
currentFolderName,
|
||||
defaultFolderName,
|
||||
selectedFolder,
|
||||
hasFiles,
|
||||
shellRef,
|
||||
dragCounterRef,
|
||||
setDropOverlayState,
|
||||
]);
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export default useFileDrop;
|
||||
@@ -0,0 +1,432 @@
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import {
|
||||
DEFAULT_FOLDER_NAME,
|
||||
createRootNode,
|
||||
getRowId,
|
||||
isDocumentRowKey,
|
||||
isFolderRowKey,
|
||||
resolveDocumentRowKey,
|
||||
resolveFolderRowKey,
|
||||
} from '../../app/appLayoutUtils';
|
||||
|
||||
const useFolderTree = ({
|
||||
initialSelectedFolder = 'root',
|
||||
assetManager,
|
||||
apiClient,
|
||||
tenantIdRef,
|
||||
documentsSortFieldRef,
|
||||
documentsSortDirectionRef,
|
||||
selectionHelpers,
|
||||
setDocuments,
|
||||
setFolderContents,
|
||||
folderContentsRef,
|
||||
}) => {
|
||||
const [folderNodes, setFolderNodes] = useState(() => {
|
||||
const rootNode = createRootNode();
|
||||
return new Map([[rootNode.id, rootNode]]);
|
||||
});
|
||||
|
||||
const [selectedFolder, setSelectedFolder] = useState(initialSelectedFolder || 'root');
|
||||
const [currentFolder, setCurrentFolder] = useState(null);
|
||||
const [currentSubfolders, setCurrentSubfolders] = useState([]);
|
||||
|
||||
const {
|
||||
focusedDocumentId,
|
||||
setFocusedDocumentId,
|
||||
setSelectedEntries,
|
||||
setSelectionOrder,
|
||||
selectionOrderRef,
|
||||
selectionAnchorRef,
|
||||
} = selectionHelpers;
|
||||
|
||||
const applySelectedFolder = useCallback(
|
||||
(folderId, contents) => {
|
||||
const subfolders = contents?.subfolders ?? [];
|
||||
const docs = assetManager.hydrateDocuments(contents?.documents ?? []);
|
||||
const folderInfo = contents?.folder ?? null;
|
||||
|
||||
setCurrentSubfolders(subfolders);
|
||||
setDocuments(docs);
|
||||
setCurrentFolder(folderInfo);
|
||||
|
||||
const availableDocKeys = docs
|
||||
.map((doc) => resolveDocumentRowKey(doc.id))
|
||||
.filter(Boolean);
|
||||
const availableDocKeySet = new Set(availableDocKeys);
|
||||
const availableFolderKeys = new Set(
|
||||
subfolders
|
||||
.map((folder) => resolveFolderRowKey(folder.id))
|
||||
.filter(Boolean),
|
||||
);
|
||||
|
||||
let nextDocKeys = [];
|
||||
let mergedSelection = [];
|
||||
|
||||
setSelectedEntries((previous) => {
|
||||
const previousFolderKeys = previous
|
||||
.filter(isFolderRowKey)
|
||||
.filter((key) => availableFolderKeys.has(key));
|
||||
const previousDocKeys = previous.filter(isDocumentRowKey);
|
||||
nextDocKeys = previousDocKeys.filter((key) => availableDocKeySet.has(key));
|
||||
mergedSelection = [...previousFolderKeys, ...nextDocKeys];
|
||||
return mergedSelection;
|
||||
});
|
||||
|
||||
const nextFocus = (() => {
|
||||
const currentFocusedKey = resolveDocumentRowKey(focusedDocumentId);
|
||||
if (currentFocusedKey && availableDocKeySet.has(currentFocusedKey)) {
|
||||
return focusedDocumentId;
|
||||
}
|
||||
if (nextDocKeys.length) {
|
||||
const lastDocKey = nextDocKeys[nextDocKeys.length - 1];
|
||||
return getRowId(lastDocKey) || null;
|
||||
}
|
||||
return null;
|
||||
})();
|
||||
|
||||
setFocusedDocumentId(nextFocus);
|
||||
selectionAnchorRef.current = nextDocKeys.length
|
||||
? nextDocKeys[nextDocKeys.length - 1]
|
||||
: null;
|
||||
selectionOrderRef.current = mergedSelection;
|
||||
setSelectionOrder(mergedSelection);
|
||||
},
|
||||
[
|
||||
assetManager,
|
||||
focusedDocumentId,
|
||||
selectionAnchorRef,
|
||||
selectionOrderRef,
|
||||
setDocuments,
|
||||
setFocusedDocumentId,
|
||||
setSelectedEntries,
|
||||
setSelectionOrder,
|
||||
],
|
||||
);
|
||||
|
||||
const expandFolderAncestors = useCallback((targetId) => {
|
||||
if (!targetId || targetId === 'root') {
|
||||
setFolderNodes((prev) => {
|
||||
const root = prev.get('root');
|
||||
if (root?.expanded) return prev;
|
||||
const next = new Map(prev);
|
||||
next.set('root', { ...root, expanded: true });
|
||||
return next;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setFolderNodes((prev) => {
|
||||
const next = new Map(prev);
|
||||
let currentId = targetId;
|
||||
let guard = 0;
|
||||
while (currentId && guard < 32) {
|
||||
guard += 1;
|
||||
const node = next.get(currentId);
|
||||
if (!node) break;
|
||||
if (!node.expanded) {
|
||||
next.set(currentId, { ...node, expanded: true });
|
||||
}
|
||||
currentId = node.parentId ?? 'root';
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const ensureFolderData = useCallback(
|
||||
async (
|
||||
folderId,
|
||||
{
|
||||
includeDocuments = true,
|
||||
prefetchDepth = 0,
|
||||
force = false,
|
||||
sortField = documentsSortFieldRef.current,
|
||||
sortDirection = documentsSortDirectionRef.current,
|
||||
} = {},
|
||||
) => {
|
||||
const requestTenantId = tenantIdRef.current;
|
||||
const cached = folderContentsRef.current.get(folderId);
|
||||
const cachedSortField = cached?.__sortField || documentsSortFieldRef.current;
|
||||
const cachedSortDirection = cached?.__sortDirection || documentsSortDirectionRef.current;
|
||||
const cachedSortMatches = cachedSortField === sortField && cachedSortDirection === sortDirection;
|
||||
|
||||
if (!force && cached) {
|
||||
const includesDocuments = Boolean(cached.__includesDocuments);
|
||||
if (!includeDocuments || (includesDocuments && cachedSortMatches)) {
|
||||
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;
|
||||
const params = {};
|
||||
if (!includeDocuments) {
|
||||
params.include_documents = false;
|
||||
} else {
|
||||
params.sort = sortField;
|
||||
params.dir = sortDirection;
|
||||
}
|
||||
const requestConfig = Object.keys(params).length ? { params } : {};
|
||||
const { data } = await apiClient.get(`/folders/${path}/contents`, requestConfig);
|
||||
const hydrated = assetManager.hydrateFolderContents(data);
|
||||
const childFolders = Array.isArray(data.subfolders) ? data.subfolders : [];
|
||||
const childIds = childFolders.map((child) => child.id);
|
||||
|
||||
const enriched = {
|
||||
...hydrated,
|
||||
__includesDocuments: includeDocuments,
|
||||
__sortField: includeDocuments ? sortField : cachedSortField,
|
||||
__sortDirection: includeDocuments ? sortDirection : cachedSortDirection,
|
||||
};
|
||||
|
||||
if (tenantIdRef.current !== requestTenantId) {
|
||||
return enriched;
|
||||
}
|
||||
|
||||
setFolderNodes((prev) => {
|
||||
const next = new Map(prev);
|
||||
const existingNode = next.get(folderId) || {
|
||||
id: folderId,
|
||||
name: folderId === 'root' ? DEFAULT_FOLDER_NAME : data.folder?.name || 'Folder',
|
||||
parentId: data.folder?.parent_id || 'root',
|
||||
children: [],
|
||||
expanded: folderId === 'root',
|
||||
loaded: false,
|
||||
hasChildren: false,
|
||||
};
|
||||
|
||||
next.set(folderId, {
|
||||
...existingNode,
|
||||
name: folderId === 'root' ? DEFAULT_FOLDER_NAME : data.folder?.name || existingNode.name,
|
||||
parentId: data.folder?.parent_id ?? existingNode.parentId ?? 'root',
|
||||
children: childIds,
|
||||
expanded: folderId === 'root' ? true : existingNode.expanded,
|
||||
loaded: true,
|
||||
hasChildren: childIds.length > 0,
|
||||
});
|
||||
|
||||
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: previousChildren,
|
||||
expanded: childNode?.expanded ?? false,
|
||||
loaded: childNode?.loaded ?? false,
|
||||
hasChildren: childHasChildren,
|
||||
});
|
||||
});
|
||||
|
||||
return next;
|
||||
});
|
||||
|
||||
if (prefetchDepth > 0 && childIds.length > 0 && tenantIdRef.current === requestTenantId) {
|
||||
await Promise.allSettled(
|
||||
childIds.map((childId) =>
|
||||
ensureFolderData(childId, {
|
||||
includeDocuments: false,
|
||||
force: false,
|
||||
prefetchDepth: prefetchDepth - 1,
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
setFolderContents((prev) => {
|
||||
if (tenantIdRef.current !== requestTenantId) {
|
||||
return prev;
|
||||
}
|
||||
const next = new Map(prev);
|
||||
if (includeDocuments) {
|
||||
next.set(folderId, enriched);
|
||||
} else {
|
||||
const existingEntry = next.get(folderId);
|
||||
if (existingEntry) {
|
||||
next.set(folderId, {
|
||||
...existingEntry,
|
||||
...hydrated,
|
||||
documents: existingEntry.__includesDocuments
|
||||
? existingEntry.documents
|
||||
: hydrated.documents,
|
||||
__includesDocuments: existingEntry.__includesDocuments || false,
|
||||
__sortField: existingEntry.__sortField ?? enriched.__sortField,
|
||||
__sortDirection: existingEntry.__sortDirection ?? enriched.__sortDirection,
|
||||
});
|
||||
} else {
|
||||
next.set(folderId, enriched);
|
||||
}
|
||||
}
|
||||
return next;
|
||||
});
|
||||
|
||||
return enriched;
|
||||
},
|
||||
[
|
||||
apiClient,
|
||||
assetManager,
|
||||
documentsSortDirectionRef,
|
||||
documentsSortFieldRef,
|
||||
tenantIdRef,
|
||||
setFolderContents,
|
||||
folderContentsRef,
|
||||
],
|
||||
);
|
||||
|
||||
const ensureFolderAncestorsLoaded = useCallback(
|
||||
async (targetId) => {
|
||||
if (!targetId || targetId === 'root') {
|
||||
return;
|
||||
}
|
||||
let current = targetId;
|
||||
let guard = 0;
|
||||
while (current && current !== 'root' && guard < 32) {
|
||||
guard += 1;
|
||||
const node = folderNodes.get(current);
|
||||
if (node?.loaded) {
|
||||
current = node.parentId ?? 'root';
|
||||
continue;
|
||||
}
|
||||
await ensureFolderData(current, { includeDocuments: false, prefetchDepth: 0 });
|
||||
current = folderNodes.get(current)?.parentId ?? 'root';
|
||||
}
|
||||
},
|
||||
[folderNodes, ensureFolderData],
|
||||
);
|
||||
|
||||
const isInvalidFolderDrop = useCallback(
|
||||
(sourceId, targetId) => {
|
||||
if (!sourceId) return false;
|
||||
if (!targetId || targetId === 'root') {
|
||||
return false;
|
||||
}
|
||||
if (sourceId === targetId) {
|
||||
return true;
|
||||
}
|
||||
|
||||
let current = targetId;
|
||||
const visited = new Set();
|
||||
while (current && current !== 'root' && !visited.has(current)) {
|
||||
visited.add(current);
|
||||
if (current === sourceId) {
|
||||
return true;
|
||||
}
|
||||
const node = folderNodes.get(current);
|
||||
if (!node) break;
|
||||
current = node.parentId ?? 'root';
|
||||
}
|
||||
return false;
|
||||
},
|
||||
[folderNodes],
|
||||
);
|
||||
|
||||
const resetFolderTreeState = useCallback(() => {
|
||||
const rootNode = createRootNode();
|
||||
setFolderNodes(new Map([[rootNode.id, rootNode]]));
|
||||
setFolderContents(new Map());
|
||||
setSelectedFolder('root');
|
||||
setCurrentFolder(null);
|
||||
setCurrentSubfolders([]);
|
||||
}, [setFolderContents]);
|
||||
|
||||
const currentFolderName = useMemo(() => {
|
||||
if (selectedFolder === 'root' || !currentFolder) return DEFAULT_FOLDER_NAME;
|
||||
return currentFolder.name;
|
||||
}, [selectedFolder, currentFolder]);
|
||||
|
||||
const folderOptions = useMemo(() => {
|
||||
const cache = new Map();
|
||||
const computePath = (id) => {
|
||||
if (cache.has(id)) {
|
||||
return cache.get(id);
|
||||
}
|
||||
if (!id || id === 'root') {
|
||||
cache.set('root', DEFAULT_FOLDER_NAME);
|
||||
return DEFAULT_FOLDER_NAME;
|
||||
}
|
||||
const node = folderNodes.get(id);
|
||||
if (!node) {
|
||||
return 'Folder';
|
||||
}
|
||||
const parentId = node.parentId || 'root';
|
||||
const parentPath = computePath(parentId);
|
||||
const name = node.name || 'Folder';
|
||||
const fullPath = parentId === 'root' ? name : `${parentPath}/${name}`;
|
||||
cache.set(id, fullPath);
|
||||
return fullPath;
|
||||
};
|
||||
|
||||
const entries = [];
|
||||
folderNodes.forEach((node, id) => {
|
||||
if (!node) return;
|
||||
entries.push({ id, label: computePath(id) });
|
||||
});
|
||||
|
||||
entries.sort((a, b) => {
|
||||
if (a.id === 'root') return -1;
|
||||
if (b.id === 'root') return 1;
|
||||
return a.label.localeCompare(b.label, undefined, { sensitivity: 'base' });
|
||||
});
|
||||
|
||||
return entries;
|
||||
}, [folderNodes]);
|
||||
|
||||
const folderLabelMap = useMemo(() => {
|
||||
const map = new Map();
|
||||
folderOptions.forEach((option) => {
|
||||
map.set(option.id, option.label);
|
||||
});
|
||||
return map;
|
||||
}, [folderOptions]);
|
||||
|
||||
return {
|
||||
folderNodes,
|
||||
setFolderNodes,
|
||||
selectedFolder,
|
||||
setSelectedFolder,
|
||||
currentFolder,
|
||||
setCurrentFolder,
|
||||
currentSubfolders,
|
||||
setCurrentSubfolders,
|
||||
currentFolderName,
|
||||
folderOptions,
|
||||
folderLabelMap,
|
||||
applySelectedFolder,
|
||||
ensureFolderData,
|
||||
ensureFolderAncestorsLoaded,
|
||||
expandFolderAncestors,
|
||||
isInvalidFolderDrop,
|
||||
resetFolderTreeState,
|
||||
};
|
||||
};
|
||||
|
||||
export default useFolderTree;
|
||||
@@ -0,0 +1,125 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
|
||||
const useTags = ({
|
||||
apiClient,
|
||||
notifyApiError,
|
||||
setStatusMessage,
|
||||
tagManager,
|
||||
tenantIdRef,
|
||||
setActiveTagFilters,
|
||||
mapDocumentCaches,
|
||||
}) => {
|
||||
const [tags, setTags] = useState([]);
|
||||
|
||||
const refreshTags = useCallback(async () => {
|
||||
const requestTenantId = tenantIdRef.current;
|
||||
try {
|
||||
const { data } = await apiClient.get('/tags');
|
||||
if (tenantIdRef.current !== requestTenantId) {
|
||||
return;
|
||||
}
|
||||
setTags(data || []);
|
||||
} catch (error) {
|
||||
if (tenantIdRef.current !== requestTenantId) {
|
||||
return;
|
||||
}
|
||||
notifyApiError(error, 'Unable to load tags.');
|
||||
}
|
||||
}, [apiClient, notifyApiError, tenantIdRef]);
|
||||
|
||||
const handleTagUpdate = useCallback(
|
||||
async (tagId, changes) => {
|
||||
if (!tagId) {
|
||||
throw new Error('Missing tag identifier.');
|
||||
}
|
||||
|
||||
const payload = {};
|
||||
if (typeof changes.label === 'string') {
|
||||
payload.label = changes.label;
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(changes, 'color')) {
|
||||
payload.color = changes.color;
|
||||
}
|
||||
|
||||
if (Object.keys(payload).length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
await apiClient.patch(`/tags/${tagId}`, payload);
|
||||
await refreshTags();
|
||||
setStatusMessage('Tag updated.', 'success');
|
||||
return true;
|
||||
} catch (error) {
|
||||
const message = error.response?.data?.error || 'Failed to update tag.';
|
||||
notifyApiError(error, message);
|
||||
throw new Error(message);
|
||||
}
|
||||
},
|
||||
[apiClient, notifyApiError, refreshTags, setStatusMessage],
|
||||
);
|
||||
|
||||
const handleTagCreate = useCallback(
|
||||
async ({ label, color } = {}) => {
|
||||
const payload = tagManager.buildPayload({ label, color });
|
||||
try {
|
||||
await apiClient.post('/tags', payload);
|
||||
await refreshTags();
|
||||
setStatusMessage('Tag created.', 'success');
|
||||
} catch (error) {
|
||||
const message = error.response?.data?.error || 'Failed to create tag.';
|
||||
notifyApiError(error, message);
|
||||
throw new Error(message);
|
||||
}
|
||||
},
|
||||
[apiClient, notifyApiError, refreshTags, setStatusMessage, tagManager],
|
||||
);
|
||||
|
||||
const handleTagDelete = useCallback(
|
||||
async (tagId) => {
|
||||
if (!tagId) {
|
||||
throw new Error('Missing tag identifier.');
|
||||
}
|
||||
|
||||
try {
|
||||
await apiClient.delete(`/tags/${tagId}`);
|
||||
setActiveTagFilters((prev) => prev.filter((id) => id !== tagId));
|
||||
|
||||
const stripTagFromDoc = (doc) => {
|
||||
if (!doc || !Array.isArray(doc.tags)) {
|
||||
return doc;
|
||||
}
|
||||
const nextTags = doc.tags.filter((tag) => tag.id !== tagId);
|
||||
if (nextTags.length === doc.tags.length) {
|
||||
return doc;
|
||||
}
|
||||
return { ...doc, tags: nextTags };
|
||||
};
|
||||
|
||||
if (typeof mapDocumentCaches === 'function') {
|
||||
mapDocumentCaches(stripTagFromDoc);
|
||||
}
|
||||
|
||||
await refreshTags();
|
||||
setStatusMessage('Tag deleted.', 'success');
|
||||
return true;
|
||||
} catch (error) {
|
||||
const message = error.response?.data?.error || 'Failed to delete tag.';
|
||||
notifyApiError(error, message);
|
||||
throw new Error(message);
|
||||
}
|
||||
},
|
||||
[apiClient, mapDocumentCaches, notifyApiError, refreshTags, setActiveTagFilters, setStatusMessage],
|
||||
);
|
||||
|
||||
return {
|
||||
tags,
|
||||
refreshTags,
|
||||
handleTagUpdate,
|
||||
handleTagCreate,
|
||||
handleTagDelete,
|
||||
setTags,
|
||||
};
|
||||
};
|
||||
|
||||
export default useTags;
|
||||
@@ -0,0 +1,104 @@
|
||||
import { useCallback } from 'react';
|
||||
|
||||
const useTenantManager = ({
|
||||
apiClient,
|
||||
appDispatch,
|
||||
currentTenantId,
|
||||
resetWorkspaceState,
|
||||
setStatusMessage,
|
||||
notifyApiError,
|
||||
setLoading,
|
||||
refreshTags,
|
||||
refreshCorrespondents,
|
||||
loadFolder,
|
||||
handleDocumentsViewModeChange,
|
||||
navigate,
|
||||
tokenRef,
|
||||
tenantIdRef,
|
||||
}) => {
|
||||
const handleTenantSelect = useCallback(
|
||||
async (tenantOption, { refreshOnly = false } = {}) => {
|
||||
const requestedTenantId = tenantOption?.id ?? null;
|
||||
if (!refreshOnly && (!requestedTenantId || requestedTenantId === currentTenantId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
if (!refreshOnly) {
|
||||
setStatusMessage('Switching tenant…', 'info');
|
||||
}
|
||||
|
||||
if (refreshOnly) {
|
||||
const { data } = await apiClient.get('/auth/tenants');
|
||||
appDispatch({
|
||||
type: 'SET_TENANTS',
|
||||
tenants: Array.isArray(data?.tenants) ? data.tenants : [],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const { data } = await apiClient.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,
|
||||
});
|
||||
|
||||
apiClient.defaults.headers.common.Authorization = `Bearer ${data.access_token}`;
|
||||
if (tokenRef) {
|
||||
tokenRef.current = data.access_token;
|
||||
}
|
||||
if (tenantIdRef) {
|
||||
tenantIdRef.current = data?.tenant?.id ?? null;
|
||||
}
|
||||
|
||||
if (Array.isArray(data?.tenants)) {
|
||||
appDispatch({ type: 'SET_TENANTS', tenants: data.tenants });
|
||||
}
|
||||
|
||||
handleDocumentsViewModeChange('list');
|
||||
navigate('/documents', { replace: true });
|
||||
|
||||
await Promise.all([refreshTags(), refreshCorrespondents()]);
|
||||
await loadFolder('root', { showLoading: false, preserveSearch: false });
|
||||
|
||||
const tenantLabel = data?.tenant?.name || data?.tenant?.id || 'tenant';
|
||||
setStatusMessage(`Switched to ${tenantLabel}.`, 'info');
|
||||
} catch (error) {
|
||||
notifyApiError(error, 'Failed to switch tenant.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[
|
||||
apiClient,
|
||||
appDispatch,
|
||||
currentTenantId,
|
||||
handleDocumentsViewModeChange,
|
||||
loadFolder,
|
||||
navigate,
|
||||
notifyApiError,
|
||||
refreshCorrespondents,
|
||||
refreshTags,
|
||||
resetWorkspaceState,
|
||||
setLoading,
|
||||
setStatusMessage,
|
||||
tenantIdRef,
|
||||
tokenRef,
|
||||
],
|
||||
);
|
||||
|
||||
return { handleTenantSelect };
|
||||
};
|
||||
|
||||
export default useTenantManager;
|
||||
Reference in New Issue
Block a user