import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { getFolderTree } from '../lib/apiClient'; import { TrashIcon, AnalyzeIcon, IconX, FolderOutlineIcon, TagIcon, CorrespondentIcon, } from '../ui/icons'; import SelectionAssignmentMenu, { SelectionAssignmentMenuItem } from './SelectionAssignmentMenu'; import SelectionSummary from './SelectionSummary'; import { useAppState } from '../app/appState'; import { useWorkspaceSelectionContext } from '../app/WorkspaceSelectionContext'; import { DEFAULT_FOLDER_NAME } from '../constants/workspace'; import type { DocumentId } from '../types/identifiers'; type NullableDocumentId = DocumentId | null; type SelectedIdList = NullableDocumentId[] | null; type FolderTreeNode = { id?: DocumentId; name?: string; label?: string; value?: DocumentId; children?: FolderTreeNode[]; }; interface TagOption { id?: DocumentId; label?: string; name?: string; color?: string | null; } interface CorrespondentOption { id?: DocumentId; name?: string; label?: string; } interface DocumentLike { id?: DocumentId; tags?: TagOption[]; correspondents?: CorrespondentOption[]; [key: string]: unknown; } 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[]; } export interface SelectionFloatingActionsProps { selectionCount?: number; selectedDocumentIds?: SelectedIdList; selectedFolderIds?: SelectedIdList; documentLookup?: Map | null; tags?: TagOption[] | null; tagLookupById?: Map | null; correspondents?: CorrespondentOption[] | null; folderOptions?: SelectionAssignmentMenuItem[] | 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) => Promise | void; } const normalizeDocumentList = (selectedIds?: SelectedIdList): DocumentId[] => Array.isArray(selectedIds) ? selectedIds.filter((value): value is DocumentId => value !== null && value !== undefined) : []; const isRecord = (value: unknown): value is Record => value != null && Object(value) === value; const splitLabelSegments = (input: unknown): string[] => { const text = `${input ?? ''}`.trim(); return text ? text.split('/') : []; }; const buildFolderTreeOptions = (tree?: FolderTreeNode[] | null): SelectionAssignmentMenuItem[] => { const entries: SelectionAssignmentMenuItem[] = []; const traverse = (nodes: FolderTreeNode[] | null, parentSegments: string[]) => { if (!Array.isArray(nodes) || nodes.length === 0) { return; } nodes.forEach((node) => { if (!node || !node.id) { return; } const trimmedName = node.name?.trim?.(); const name = trimmedName?.length ? trimmedName : 'Folder'; const nextSegments = parentSegments.concat([name]); const label = nextSegments.join('/'); entries.push({ id: node.id, label, state: 'none', payload: { id: node.id, label, segments: nextSegments, depth: Math.max(nextSegments.length - 1, 0), }, }); if (Array.isArray(node.children) && node.children.length) { traverse(node.children, nextSegments); } }); }; traverse(Array.isArray(tree) ? tree : [], [DEFAULT_FOLDER_NAME]); entries.sort((a, b) => (a.label || '').localeCompare(b.label || '', undefined, { sensitivity: 'base' })); return [{ id: 'root', label: DEFAULT_FOLDER_NAME, state: 'none', payload: { id: 'root' } }, ...entries]; }; const buildTagAssignments = ( selectedDocuments: DocumentLike[], 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: DocumentLike[], 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 = [], folderOptions = [], onBulkTagAdd, onBulkTagRemove, onBulkCorrespondentAdd, onBulkCorrespondentRemove, onBulkReanalyze, onDeleteSelection, onClearSelection = null, onMoveDocumentsToFolder, }) => { const { token, tenant } = useAppState() as { token?: string; tenant?: { id?: DocumentId } | null }; const tenantId = tenant?.id ?? null; const documentLookupMap = useMemo(() => ( documentLookup instanceof Map ? documentLookup : new Map() ), [documentLookup]); const tagLookupMap = tagLookupById instanceof Map ? tagLookupById : null; const [remoteFolderOptions, setRemoteFolderOptions] = useState(null); const folderTreeFetchRef = useRef | null>(null); useEffect(() => { setRemoteFolderOptions(null); folderTreeFetchRef.current = null; }, [tenantId, token]); const requestFolderTree = useCallback(async (): Promise => { if (!token) { setRemoteFolderOptions([]); return []; } if (Array.isArray(remoteFolderOptions)) { return remoteFolderOptions; } if (folderTreeFetchRef.current) { return folderTreeFetchRef.current; } const fetchPromise = (async () => { try { const data = await getFolderTree(); const options = buildFolderTreeOptions(data); setRemoteFolderOptions(options); return options; } catch (error) { console.warn('[selection] Failed to load folder tree', error); setRemoteFolderOptions([]); return []; } finally { folderTreeFetchRef.current = null; } })(); folderTreeFetchRef.current = fetchPromise; return fetchPromise; }, [remoteFolderOptions, token]); const handleMoveMenuOpen = useCallback(() => { requestFolderTree(); }, [requestFolderTree]); const effectiveFolderOptions = useMemo(() => { if (remoteFolderOptions !== null) { return remoteFolderOptions; } return Array.isArray(folderOptions) ? folderOptions : []; }, [remoteFolderOptions, folderOptions]); 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 DocumentLike => Boolean(doc)); }, [documentIdList, documentLookupMap]); const selectedDocCount = selectedDocuments.length; const moveAssignments = useMemo(() => { if (!Array.isArray(effectiveFolderOptions)) { return []; } return effectiveFolderOptions .map((option) => { const id = (option?.id ?? option?.payload?.id ?? option?.value) as DocumentId | undefined; if (!id) { return null; } const label = option?.label || option?.payload?.label || option?.name || String(id); const segments = splitLabelSegments(label); const depth = Math.max(segments.length - 1, 0); return { id, label, state: 'none', payload: { id, label, segments, depth, }, }; }) .filter((entry): entry is SelectionAssignmentMenuItem => Boolean(entry)); }, [effectiveFolderOptions]); const tagAssignments = useMemo( () => buildTagAssignments(selectedDocuments, tagLookupMap, tags, selectedDocCount), [selectedDocuments, tagLookupMap, tags, selectedDocCount], ); const correspondentAssignments = useMemo( () => buildCorrespondentAssignments(selectedDocuments, correspondents, selectedDocCount), [selectedDocuments, correspondents, selectedDocCount], ); const renderFolderLabel = useCallback((item: SelectionAssignmentMenuItem) => { const payload = (item?.payload as { segments?: string[]; depth?: number }) || {}; const segments = payload.segments || splitLabelSegments(item.label); const depth = payload.depth ?? Math.max(segments.length - 1, 0); const clampedDepth = Math.min(depth, 6); const indentWidth = clampedDepth > 0 ? clampedDepth * 0.9 : 0; const name = segments.length ? segments[segments.length - 1] : item?.label || 'Folder'; const parentPath = segments.length > 1 ? segments.slice(0, -1).join(' / ') : ''; return ( <> {indentWidth ? ( )} items={moveAssignments} placeholder="Search folders…" emptyMessage="No folders" onToggle={(item) => handleMoveSelectionToFolder(item?.payload || item)} disabled={!documentCount} createLabel={null} showStateIndicators={false} showCounts={false} onOpenMenu={handleMoveMenuOpen} renderItemLabel={renderFolderLabel} /> ) : null; const primaryButtons = showPrimaryButtons ? (
{onBulkReanalyze ? ( ) : null} {onDeleteSelection ? ( ) : null} {onClearSelection ? ( ) : null}
) : null; return ( <> {summaryNode ? ( {summaryNode} ) : null}
{moveMenu}
{primaryButtons} ); }; export type SelectionFloatingPanelProps = Omit; export const SelectionFloatingPanel: React.FC = ({ onClearSelection, ...rest }) => { const { selectedDocumentIds, selectedFolderIds, clearSelection } = useWorkspaceSelectionContext(); const documentIds = Array.isArray(selectedDocumentIds) ? selectedDocumentIds : []; const folderIds = Array.isArray(selectedFolderIds) ? selectedFolderIds : []; const selectionCount = documentIds.length + folderIds.length; if (selectionCount === 0) { return null; } const handleClear = onClearSelection || clearSelection; return (
); }; export default SelectionFloatingActions;