From f1f2dee70116e59731b7d101b14ef66e7a11d4ef Mon Sep 17 00:00:00 2001 From: Nils Schneider Date: Wed, 29 Oct 2025 12:02:24 +0100 Subject: [PATCH] frontend --- frontend/src/detail/DetailPanel.jsx | 11 +- frontend/src/index.jsx | 168 +++++++++++++++++++--------- 2 files changed, 125 insertions(+), 54 deletions(-) diff --git a/frontend/src/detail/DetailPanel.jsx b/frontend/src/detail/DetailPanel.jsx index 3e75aef..f235e1b 100644 --- a/frontend/src/detail/DetailPanel.jsx +++ b/frontend/src/detail/DetailPanel.jsx @@ -1104,6 +1104,7 @@ const DetailPanel = ({ event.preventDefault(); event.stopPropagation(); }; + const documentIds = Array.from(new Set(selectedDocuments.map((doc) => doc?.id).filter(Boolean))); return ( <> @@ -1165,8 +1166,10 @@ const DetailPanel = ({ title="Tags" tags={bulkTagUnion} emptyMessage="No tags assigned." - onRemove={(tag) => onBulkTagRemove?.({ label: tag.label })} - onAdd={({ value, input }) => onBulkTagAdd?.({ label: value, input })} + onRemove={(tag) => onBulkTagRemove?.({ label: tag.label, documentIds })} + onAdd={({ value, input }) => + onBulkTagAdd?.({ label: value, input, documentIds }) + } addPlaceholder="Add tag to selection" addButtonLabel="Add tag" datalistId="tag-catalog-bulk" @@ -1178,7 +1181,7 @@ const DetailPanel = ({ entries={bulkCorrespondents} onRemove={handleBulkCorrespondentRemove} onAdd={({ name, input }) => - onBulkCorrespondentAdd?.({ name, input }) + onBulkCorrespondentAdd?.({ name, input, documentIds }) } addPlaceholder="Add correspondent to selection" datalistId="correspondent-catalog-bulk" @@ -1229,7 +1232,7 @@ const DetailPanel = ({ className="icon-button ghost" onClick={(event) => { event.stopPropagation(); - onBulkReanalyze(); + onBulkReanalyze(documentIds); }} aria-label="Re-run analysis for selection" title="Re-run analysis for selection" diff --git a/frontend/src/index.jsx b/frontend/src/index.jsx index cec98fc..6f54260 100644 --- a/frontend/src/index.jsx +++ b/frontend/src/index.jsx @@ -736,6 +736,8 @@ const AppLayout = () => { ); const [detailPanelOpen, setDetailPanelOpen] = useState(() => selectedDocumentIds.length > 0); + const [detailPanelDocIds, setDetailPanelDocIds] = useState([]); + const [detailPanelDocs, setDetailPanelDocs] = useState([]); useEffect(() => { if (selectedDocumentIds.length > 0 && !detailPanelOpen) { @@ -771,6 +773,8 @@ const AppLayout = () => { setDropOverlayState({ active: false, folderName: DEFAULT_FOLDER_NAME }); setActivePreviewId(null); setDetailPanelOpen(false); + setDetailPanelDocIds([]); + setDetailPanelDocs([]); assetManager.reset(); setPreviewEntries(() => new Map()); previewInflightRef.current = new Map(); @@ -2335,14 +2339,28 @@ const AppLayout = () => { } }, [selectedFolder, ensureFolderData, applySelectedFolder, notifyApiError]); + const resolveTargetDocumentIds = useCallback( + (candidateIds) => { + const normalized = Array.isArray(candidateIds) + ? candidateIds.filter(Boolean) + : []; + if (normalized.length) { + return Array.from(new Set(normalized)); + } + return selectedDocumentIds; + }, + [selectedDocumentIds], + ); + const handleBulkCorrespondentAdd = useCallback( - async ({ name, input }) => { + async ({ name, input, documentIds }) => { const trimmed = (name || '').trim(); if (!trimmed) { setStatusMessage('Correspondent name is required.', 'error'); return; } - if (!selectedDocumentIds.length) { + const targets = resolveTargetDocumentIds(documentIds); + if (!targets.length) { setStatusMessage('Select documents before assigning correspondents.', 'error'); return; } @@ -2362,7 +2380,7 @@ const AppLayout = () => { } const response = await api.post('/documents/bulk/correspondents', { - document_ids: selectedDocumentIds, + document_ids: targets, assignments: [ { correspondent_id: target.id, @@ -2397,7 +2415,7 @@ const AppLayout = () => { handleCorrespondentCreate, api, refreshCurrentFolder, - selectedDocumentIds, + resolveTargetDocumentIds, setStatusMessage, ], ); @@ -2409,9 +2427,7 @@ const AppLayout = () => { return; } - const targets = Array.isArray(documentIds) && documentIds.length - ? documentIds - : selectedDocumentIds; + const targets = resolveTargetDocumentIds(documentIds); if (!targets.length) { setStatusMessage('Select documents before removing correspondents.', 'error'); @@ -2444,7 +2460,7 @@ const AppLayout = () => { setStatusMessage('No correspondents changed.', 'info'); } }, - [api, refreshCurrentFolder, selectedDocumentIds, setStatusMessage], + [api, refreshCurrentFolder, resolveTargetDocumentIds, setStatusMessage], ); useEffect(() => { @@ -2550,12 +2566,13 @@ const AppLayout = () => { }, []); const bulkTagOperation = useCallback( - async ({ labels, action }) => { + 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' }; } - if (!selectedDocumentIds.length) { + const targetDocumentIds = resolveTargetDocumentIds(documentIds); + if (!targetDocumentIds.length) { return { ok: false, reason: 'no-selection' }; } @@ -2599,7 +2616,7 @@ const AppLayout = () => { } await api.post('/documents/bulk/tags', { - document_ids: selectedDocumentIds, + document_ids: targetDocumentIds, tag_ids: tagIds, action, }); @@ -2609,7 +2626,7 @@ const AppLayout = () => { return { ok: true, tagCount: tagIds.length, - docsCount: selectedDocumentIds.length, + docsCount: targetDocumentIds.length, }; } catch (error) { const message = @@ -2618,13 +2635,11 @@ const AppLayout = () => { notifyApiError(error, message); return { ok: false, reason: 'request-failed' }; } finally { - if (!refreshOnly) { - setLoading(false); - } + setLoading(false); } }, [ - selectedDocumentIds, + resolveTargetDocumentIds, tags, api, refreshTags, @@ -2711,17 +2726,22 @@ const AppLayout = () => { ); const handleBulkTagAddFromDetail = useCallback( - async ({ label, input }) => { + async ({ label, input, documentIds }) => { const trimmed = (label || '').trim(); if (!trimmed) { setStatusMessage('Enter a tag label.', 'error'); return; } - if (!selectedDocumentIds.length) { + const targetIds = resolveTargetDocumentIds(documentIds); + if (!targetIds.length) { setStatusMessage('Select documents before assigning tags.', 'error'); return; } - const result = await bulkTagOperation({ labels: [trimmed], action: 'add' }); + const result = await bulkTagOperation({ + labels: [trimmed], + action: 'add', + documentIds: targetIds, + }); if (result?.ok) { const { tagCount, docsCount } = result; setStatusMessage( @@ -2735,21 +2755,26 @@ const AppLayout = () => { } } }, - [bulkTagOperation, selectedDocumentIds, setStatusMessage], + [bulkTagOperation, resolveTargetDocumentIds, setStatusMessage], ); const handleBulkTagRemoveFromDetail = useCallback( - async ({ label, input }) => { + async ({ label, input, documentIds }) => { const trimmed = (label || '').trim(); if (!trimmed) { setStatusMessage('Enter a tag label to remove.', 'error'); return; } - if (!selectedDocumentIds.length) { + const targetIds = resolveTargetDocumentIds(documentIds); + if (!targetIds.length) { setStatusMessage('Select documents before removing tags.', 'error'); return; } - const result = await bulkTagOperation({ labels: [trimmed], action: 'remove' }); + const result = await bulkTagOperation({ + labels: [trimmed], + action: 'remove', + documentIds: targetIds, + }); if (result?.ok) { const { docsCount } = result; setStatusMessage( @@ -2763,34 +2788,37 @@ const AppLayout = () => { setStatusMessage(`Tag “${result.label}” not found.`, 'error'); } }, - [bulkTagOperation, selectedDocumentIds, setStatusMessage], + [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; + } - const handleBulkSelectionReanalyze = useCallback(async () => { - if (!selectedDocumentIds.length) { - setStatusMessage('Select documents before requesting re-analysis.', 'error'); - return; - } - - setLoading(true); - try { - const { data } = await api.post('/documents/bulk/reanalyze', { - document_ids: selectedDocumentIds, - force: true, - }); - const queued = data?.queued ?? selectedDocumentIds.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); - } - }, [selectedDocumentIds, api, notifyApiError, setStatusMessage]); + setLoading(true); + try { + const { data } = await api.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, api, notifyApiError, setStatusMessage], + ); const uploadFile = useCallback( async (file, targetFolderId) => { @@ -4851,6 +4879,44 @@ const AppLayout = () => { return ordered; }, [selectionOrder, documentLookup, selectedDocumentIds]); + useEffect(() => { + if (!orderedSelectedDocuments.length) { + return; + } + + const nextIds = orderedSelectedDocuments + .map((doc) => (doc?.id ? doc.id : null)) + .filter(Boolean); + + setDetailPanelDocIds((prev) => { + if (nextIds.length === prev.length && nextIds.every((id, index) => id === prev[index])) { + return prev; + } + return nextIds; + }); + }, [orderedSelectedDocuments]); + + useEffect(() => { + if (!detailPanelDocIds.length) { + setDetailPanelDocs([]); + return; + } + + setDetailPanelDocs((prevDocs) => { + const prevMap = new Map((prevDocs || []).map((doc) => [doc.id, doc])); + const next = detailPanelDocIds + .map((id) => documentLookup.get(id) || prevMap.get(id) || null) + .filter(Boolean); + + if (next.length === prevDocs.length && next.every((doc, index) => doc === prevDocs[index])) { + return prevDocs; + } + return next; + }); + }, [detailPanelDocIds, documentLookup]); + + const detailPanelSelectedDocuments = detailPanelDocs; + const lastScrolledDetailDocRef = useRef(null); useEffect(() => { @@ -5288,10 +5354,12 @@ const AppLayout = () => { const handleDetailPanelClose = useCallback(() => { setDetailPanelOpen(false); clearDocumentSelection(); + setDetailPanelDocIds([]); + setDetailPanelDocs([]); }, [clearDocumentSelection]); const detailPanelProps = { - selectedDocuments: orderedSelectedDocuments, + selectedDocuments: detailPanelSelectedDocuments, tags, tagLookupById, onTagAdd: handleTagAdd,