import React, { useCallback, useEffect, useMemo, useState, useSyncExternalStore } from 'react'; import { DEFAULT_FOLDER_NAME } from '../../../app/workspaceUtils'; import { useAppShell } from '../../../lib/context/AppShellContext'; import FoldersManager from '../../FoldersManager'; import { TrashIcon, AnalyzeIcon, IconX, FolderOutlineIcon, TagIcon, CorrespondentIcon, } from '../../../components/icons'; import SelectionAssignmentMenu, { SelectionAssignmentMenuItem } from './SelectionAssignmentMenu'; import SelectionFolderMenu from './SelectionFolderMenu'; import SelectionSummary from './SelectionSummary'; import { useWorkspaceSelectionContext } from '../../../app/WorkspaceSelectionContext'; import type { DocumentId } from '../../../types/identifiers'; import type { FolderTreeNode } from '../../../lib/api/apiTypes'; type NullableDocumentId = DocumentId | null; type SelectedIdList = NullableDocumentId[] | null; interface TagOption { id?: DocumentId; label?: string; name?: string; color?: string | null; } interface CorrespondentOption { id?: DocumentId; name?: string; label?: string; } import type { Document } from '../../../types/documents'; interface BulkTagMutationArgs { label: string; input: unknown; documentIds: DocumentId[]; } interface BulkCorrespondentAddArgs { name: string; input: unknown; documentIds: DocumentId[]; } interface BulkCorrespondentRemoveArgs { assignments: Array<{ correspondent_id: DocumentId }>; documentIds: DocumentId[]; } interface SelectionFloatingActionsProps { selectionCount?: number; selectedDocumentIds?: SelectedIdList; selectedFolderIds?: SelectedIdList; documentLookup?: Map | null; tags?: TagOption[] | null; tagLookupById?: Map | null; correspondents?: CorrespondentOption[] | null; onBulkTagAdd?: (args: BulkTagMutationArgs) => Promise | void; onBulkTagRemove?: (args: BulkTagMutationArgs) => Promise | void; onBulkCorrespondentAdd?: (args: BulkCorrespondentAddArgs) => Promise | void; onBulkCorrespondentRemove?: (args: BulkCorrespondentRemoveArgs) => Promise | void; onBulkReanalyze?: (documentIds: DocumentId[]) => Promise | void; onDeleteSelection?: () => void; onClearSelection?: () => void; onMoveDocumentsToFolder?: (documentIds: DocumentId[], folderId: DocumentId | null) => Promise | void; } const normalizeDocumentList = (selectedIds?: SelectedIdList): DocumentId[] => Array.isArray(selectedIds) ? selectedIds.filter((value): value is DocumentId => value !== null && value !== undefined) : []; const buildTagAssignments = ( selectedDocuments: Document[], tagLookupById: Map | null, tags: TagOption[] | null, total: number, ): SelectionAssignmentMenuItem[] => { if (!total) { return []; } const map = new Map(); const ensureEntry = (id?: DocumentId, label?: string, color: string | null = null) => { const key = id ?? label; if (!key || !label) { return null; } if (!map.has(key)) { map.set(key, { id, label, color, count: 0, total, }); } return map.get(key) ?? null; }; selectedDocuments.forEach((doc) => { (doc?.tags || []).forEach((tag) => { const lookupColor = tag?.id && tagLookupById instanceof Map ? tagLookupById.get(tag.id)?.color : null; const entry = ensureEntry(tag?.id, tag?.label, tag?.color ?? lookupColor ?? null); if (entry) { entry.count += 1; } }); }); (tags || []).forEach((tag) => { const lookupColor = tag?.id && tagLookupById instanceof Map ? tagLookupById.get(tag.id)?.color : null; ensureEntry(tag?.id, tag?.label, tag?.color ?? lookupColor ?? null); }); return Array.from(map.values()).map((entry) => { const count = entry.count || 0; const state = count === total ? 'all' : count > 0 ? 'partial' : 'none'; return { id: entry.id ?? entry.label, label: entry.label, color: entry.color ?? null, count, total, state, payload: entry, }; }); }; const buildCorrespondentAssignments = ( selectedDocuments: Document[], correspondents: CorrespondentOption[] | null, total: number, ): SelectionAssignmentMenuItem[] => { if (!total) { return []; } const map = new Map(); const ensureEntry = (id?: DocumentId, name?: string) => { const key = id ?? name; if (!key || !name) { return null; } if (!map.has(key)) { map.set(key, { id, label: name, count: 0, total, }); } return map.get(key) ?? null; }; selectedDocuments.forEach((doc) => { (doc?.correspondents || []).forEach((entry) => { const target = ensureEntry(entry?.id, entry?.name); if (target) { target.count += 1; } }); }); (correspondents || []).forEach((entry) => { ensureEntry(entry?.id, entry?.name || entry?.label); }); return Array.from(map.values()).map((entry) => { const count = entry.count || 0; const state = count === total ? 'all' : count > 0 ? 'partial' : 'none'; return { id: entry.id ?? entry.label, label: entry.label, count, total, state, payload: entry, }; }); }; const SelectionFloatingActions: React.FC = ({ selectionCount = 0, selectedDocumentIds = [], selectedFolderIds = [], documentLookup, tags = [], tagLookupById, correspondents = [], onBulkTagAdd, onBulkTagRemove, onBulkCorrespondentAdd, onBulkCorrespondentRemove, onBulkReanalyze, onDeleteSelection, onClearSelection = null, onMoveDocumentsToFolder, }) => { const documentLookupMap = useMemo(() => ( documentLookup instanceof Map ? documentLookup : new Map() ), [documentLookup]); const tagLookupMap = tagLookupById instanceof Map ? tagLookupById : null; const shell = useAppShell(); const foldersManager = shell.foldersManager as FoldersManager; const [remoteFolderTree, setRemoteFolderTree] = useState([]); // Sync with manager const treeSnapshot = useSyncExternalStore( useCallback(cb => foldersManager.subscribe(cb), [foldersManager]), () => foldersManager.getTreeSnapshot(), () => foldersManager.getTreeSnapshot(), ); useEffect(() => { setRemoteFolderTree(treeSnapshot); }, [treeSnapshot]); const requestFolderTree = useCallback(() => { foldersManager.ensureTree(); }, [foldersManager]); const handleMoveMenuOpen = useCallback(() => { requestFolderTree(); }, [requestFolderTree]); const documentIdList = useMemo( () => normalizeDocumentList(selectedDocumentIds), [selectedDocumentIds], ); const folderIdList = useMemo( () => normalizeDocumentList(selectedFolderIds), [selectedFolderIds], ); const documentCount = documentIdList.length; const folderCount = folderIdList.length; const totalCount = selectionCount ?? documentCount + folderCount; const selectedDocuments = useMemo(() => { if (!documentIdList.length || !(documentLookupMap instanceof Map)) { return []; } return documentIdList .map((id) => documentLookupMap.get(id)) .filter((doc): doc is Document => Boolean(doc)); }, [documentIdList, documentLookupMap]); const selectedDocCount = selectedDocuments.length; const tagAssignments = useMemo( () => buildTagAssignments(selectedDocuments, tagLookupMap, tags, selectedDocCount), [selectedDocuments, tagLookupMap, tags, selectedDocCount], ); const correspondentAssignments = useMemo( () => buildCorrespondentAssignments(selectedDocuments, correspondents, selectedDocCount), [selectedDocuments, correspondents, selectedDocCount], ); const handleToggleTagAssignment = useCallback( async (item: SelectionAssignmentMenuItem) => { if (!selectedDocCount || !item) { return; } if (item.state === 'all') { await onBulkTagRemove?.({ label: item.label || '', input: null, documentIds: documentIdList }); } else { await onBulkTagAdd?.({ label: item.label || '', input: null, documentIds: documentIdList }); } }, [selectedDocCount, onBulkTagAdd, onBulkTagRemove, documentIdList], ); const handleCreateTagAssignment = useCallback( async (label: string) => { if (!selectedDocCount || !label) { return; } await onBulkTagAdd?.({ label, input: null, documentIds: documentIdList }); }, [selectedDocCount, onBulkTagAdd, documentIdList], ); const handleToggleCorrespondentAssignment = useCallback( async (item: SelectionAssignmentMenuItem) => { if (!selectedDocCount || !item) { return; } if (item.state === 'all') { if (!item.id) { return; } await onBulkCorrespondentRemove?.({ assignments: [{ correspondent_id: item.id }], documentIds: documentIdList, }); } else { await onBulkCorrespondentAdd?.({ name: item.label || '', input: null, documentIds: documentIdList }); } }, [selectedDocCount, onBulkCorrespondentAdd, onBulkCorrespondentRemove, documentIdList], ); const handleCreateCorrespondentAssignment = useCallback( async (name: string) => { if (!selectedDocCount || !name) { return; } await onBulkCorrespondentAdd?.({ name, input: null, documentIds: documentIdList }); }, [selectedDocCount, onBulkCorrespondentAdd, documentIdList], ); const handleMoveSelectionToFolder = useCallback( async (folderId: DocumentId | null) => { const itemsToMove = [...documentIdList, ...folderIdList]; if (!itemsToMove.length || !onMoveDocumentsToFolder) { return; } await onMoveDocumentsToFolder(itemsToMove, folderId); }, [documentIdList, folderIdList, onMoveDocumentsToFolder], ); const summaryNode = totalCount > 0 ? ( ) : null; const showPrimaryButtons = Boolean(onBulkReanalyze || onDeleteSelection || onClearSelection); const rootTitle = (remoteFolderTree && remoteFolderTree.length === 1) ? remoteFolderTree[0].name : DEFAULT_FOLDER_NAME; const moveMenu = onMoveDocumentsToFolder ? (