From 3ccc1f66984817d1c40aea4d6509310c42f7d3c5 Mon Sep 17 00:00:00 2001 From: Nils Schneider Date: Tue, 9 Dec 2025 18:54:30 +0100 Subject: [PATCH] feat: Refactor tag and correspondent management to use canonical types and dedicated managers. --- frontend/src/app/useManagementModals.tsx | 21 +-- frontend/src/app/useWorkspaceSurface.tsx | 2 + .../components/DesktopDocumentCard.tsx | 13 +- frontend/src/documents/DocumentsManager.ts | 54 ++++++- .../src/documents/components/DocumentTags.tsx | 43 +++--- .../components/DocumentsGridCard.tsx | 4 +- .../documents/components/DocumentsListRow.tsx | 4 +- .../context/DocumentsViewStateContext.tsx | 5 +- frontend/src/documents/correspondents.ts | 47 +++--- .../documents/data/useBulkDocumentActions.ts | 10 +- .../src/documents/data/useCorrespondents.ts | 88 +++++------ .../useDocumentCorrespondentMutations.ts} | 96 ++++++------ .../documents/data/useDocumentMutations.ts | 28 +++- .../documents/data/useDocumentTagMutations.ts | 44 +++--- .../documents/data/useDocumentsWorkspace.ts | 82 ++++++---- frontend/src/documents/data/useTags.ts | 74 +++++---- .../selection/SelectionFloatingActions.tsx | 23 ++- .../features/tagging/useDocumentTagActions.ts | 51 +++---- .../interactions/useTagInteractions.ts | 10 +- .../documents/logic/useDocumentsPanelProps.ts | 5 +- .../src/documents/panel/DocumentsPanel.tsx | 5 + .../panel/useDocumentsContextValues.ts | 2 + .../src/documents/types/workspaceTypes.ts | 35 +++-- .../src/lib/assets/CorrespondentManager.ts | 144 ++++++++++++++++++ frontend/src/lib/assets/TagManager.ts | 123 +++++++++++++++ frontend/src/sidebar/Sidebar.tsx | 7 +- .../components/SidebarCorrespondentList.tsx | 15 +- .../src/sidebar/components/SidebarTagList.tsx | 12 +- frontend/src/types/documents.ts | 41 ++--- frontend/src/viewer/DocumentViewerPanel.tsx | 9 ++ .../viewer/components/DocumentInfoPanel.tsx | 15 +- .../components/DocumentSummarySection.tsx | 74 ++++----- frontend/src/viewer/logic/documentSummary.ts | 25 ++- .../src/viewer/logic/useDetailWorkspace.ts | 6 +- 34 files changed, 790 insertions(+), 427 deletions(-) rename frontend/src/documents/{features/correspondents/useDocumentCorrespondentActions.ts => data/useDocumentCorrespondentMutations.ts} (65%) create mode 100644 frontend/src/lib/assets/CorrespondentManager.ts diff --git a/frontend/src/app/useManagementModals.tsx b/frontend/src/app/useManagementModals.tsx index 2ab3476..76b0126 100644 --- a/frontend/src/app/useManagementModals.tsx +++ b/frontend/src/app/useManagementModals.tsx @@ -6,31 +6,24 @@ import CorrespondentsPanel, { CorrespondentsPanelProps } from '../correspondents import PanelHeader from '../components/PanelHeader'; import { CloseIcon } from '../components/icons'; import { CORRESPONDENTS_MODAL, TAGS_MODAL } from '../constants/app'; - -interface TagRecord { - id?: string; - label?: string; - [key: string]: unknown; -} - -interface CorrespondentRecord { - id?: string; - name?: string; - [key: string]: unknown; -} +import type { Tag, Correspondent } from '../types/documents'; +import type { CorrespondentManager } from '../documents/types/workspaceTypes'; +import type { Identifier } from '../types/identifiers'; interface UseManagementModalsArgs { locationPathname?: string; - tags?: TagRecord[]; + tags?: Tag[]; refreshTags?: () => void | Promise; onTagCreate?: (...args: any[]) => void | Promise; onTagUpdate?: (...args: any[]) => void | Promise; onTagDelete?: (...args: any[]) => void | Promise; - correspondents?: CorrespondentRecord[]; + correspondents?: Correspondent[]; + correspondentLookupById?: Map | null; refreshCorrespondents?: () => void | Promise; onCorrespondentCreate?: (...args: any[]) => void | Promise; onCorrespondentUpdate?: (...args: any[]) => void | Promise; onCorrespondentDelete?: (...args: any[]) => void | Promise; + correspondentManager?: CorrespondentManager | null; } interface UseManagementModalsResult { diff --git a/frontend/src/app/useWorkspaceSurface.tsx b/frontend/src/app/useWorkspaceSurface.tsx index b26a0b7..96e668c 100644 --- a/frontend/src/app/useWorkspaceSurface.tsx +++ b/frontend/src/app/useWorkspaceSurface.tsx @@ -167,6 +167,7 @@ export const useWorkspaceSurface = ({ onTagAdd, onTagRemove, correspondents, + correspondentLookupById, onCorrespondentAdd, onCorrespondentRemove, onUpdateTitle, @@ -185,6 +186,7 @@ export const useWorkspaceSurface = ({ onTagAdd={onTagAdd} onTagRemove={onTagRemove} correspondents={correspondents} + correspondentLookupById={correspondentLookupById} onCorrespondentAdd={onCorrespondentAdd} onCorrespondentRemove={onCorrespondentRemove} onUpdateTitle={onUpdateTitle} diff --git a/frontend/src/desktop/components/DesktopDocumentCard.tsx b/frontend/src/desktop/components/DesktopDocumentCard.tsx index 74978d8..ff8d391 100644 --- a/frontend/src/desktop/components/DesktopDocumentCard.tsx +++ b/frontend/src/desktop/components/DesktopDocumentCard.tsx @@ -6,6 +6,8 @@ import { LayoutCard } from '../logic/LayoutSystem'; import { useCardPointer } from '../interactions/useCardPointer'; import DocumentTags from '../../documents/components/DocumentTags'; import { TagInteractionHandlers } from '../../documents/interactions/useTagInteractions'; +import { useDocumentsAssetContext } from '../../documents/context/DocumentsAssetContext'; +import { useDocumentsViewStateContext } from '../../documents/context/DocumentsViewStateContext'; const preventAll = (event?: React.SyntheticEvent | Event | null) => { if (!event) return; @@ -38,8 +40,6 @@ const DesktopDocumentCard: React.FC = ({ matchesFilter = true, selected = false, docTagTokens, - ensureAssetUrl, - getDocumentAsset, onDocumentActivate, onSelect, onDeselect, @@ -48,6 +48,12 @@ const DesktopDocumentCard: React.FC = ({ tagHandlers, layoutCard, }) => { + const { + ensureAssetUrl, + getDocumentAsset + } = useDocumentsAssetContext(); + + const { tagLookupById, correspondentLookupById } = useDocumentsViewStateContext(); const cardPointerHandlers = useCardPointer( layoutCard, !!selected, @@ -58,7 +64,7 @@ const DesktopDocumentCard: React.FC = ({ requestCanvasFocus ); - const correspondents = useMemo(() => resolveCorrespondents(doc), [doc]); + const correspondents = useMemo(() => resolveCorrespondents(doc, correspondentLookupById), [doc, correspondentLookupById]); const tags = Array.isArray(doc?.tags) ? doc.tags : []; const itemClasses = ['desk-item']; @@ -116,6 +122,7 @@ const DesktopDocumentCard: React.FC = ({ diff --git a/frontend/src/documents/DocumentsManager.ts b/frontend/src/documents/DocumentsManager.ts index 3430962..aaa5b8f 100644 --- a/frontend/src/documents/DocumentsManager.ts +++ b/frontend/src/documents/DocumentsManager.ts @@ -1,7 +1,10 @@ import { shallowEqual } from 'react-redux'; -import type { DocumentId } from '../types/identifiers'; +import type { DocumentId, Identifier, TagId } from '../types/identifiers'; +import type { Tag, Correspondent } from '../types/documents'; +import type TagManager from '../lib/assets/TagManager'; +import type CorrespondentManager from '../lib/assets/CorrespondentManager'; -type ManagedDocument = { id?: DocumentId | null } & Record; +type ManagedDocument = { id?: DocumentId | null; tags?: Identifier[] | null; correspondents?: Identifier[] | null } & Record; type FetchDocument = (id: DocumentId) => Promise; @@ -11,6 +14,8 @@ class DocumentsManager { private inflight: Map>; private listeners: Set<() => void>; private emitScheduled: boolean; + private tagManager?: TagManager; + private correspondentManager?: CorrespondentManager; constructor( fetchDocument?: FetchDocument, @@ -22,6 +27,14 @@ class DocumentsManager { this.emitScheduled = false; } + setTagManager(tagManager: TagManager) { + this.tagManager = tagManager; + } + + setCorrespondentManager(correspondentManager: CorrespondentManager) { + this.correspondentManager = correspondentManager; + } + private emit() { if (this.emitScheduled) { return; @@ -55,6 +68,43 @@ class DocumentsManager { return; } + if (this.tagManager && Array.isArray((doc as any).tags)) { + const rawTags = (doc as any).tags as any[]; + const validTags: Tag[] = []; + const tagIds: TagId[] = []; + + rawTags.forEach(tag => { + if (tag.id) { + tagIds.push(tag.id); + validTags.push(tag as Tag); + } + }); + + if (validTags.length > 0) { + this.tagManager.ingest(validTags); + } + + (doc as any).tags = tagIds; + } + + if (this.correspondentManager && Array.isArray((doc as any).correspondents)) { + const rawCorrespondents = (doc as any).correspondents as any[]; + const validCorrespondents: Correspondent[] = []; + const correspondentIds: Identifier[] = []; + + rawCorrespondents.forEach(corr => { + if (corr.id) { + correspondentIds.push(corr.id); + validCorrespondents.push(corr as Correspondent); + } + }); + + if (validCorrespondents.length > 0) { + this.correspondentManager.ingest(validCorrespondents); + } + (doc as any).correspondents = correspondentIds; + } + const existing = nextById.get(id as DocumentId); const merged = existing ? ({ ...existing, ...doc } as T) : ({ ...(doc as T) } as T); const useExisting = existing && shallowEqual(existing, merged); diff --git a/frontend/src/documents/components/DocumentTags.tsx b/frontend/src/documents/components/DocumentTags.tsx index 412e132..84b36fd 100644 --- a/frontend/src/documents/components/DocumentTags.tsx +++ b/frontend/src/documents/components/DocumentTags.tsx @@ -1,12 +1,12 @@ import React, { useMemo } from 'react'; import { getTagColorStyle } from '../../utils/colors'; -import type { Document, DocumentTag } from '../../types/documents'; +import type { Document, Tag } from '../../types/documents'; import type { Identifier } from '../../types/identifiers'; import type { TagInteractionHandlers } from '../interactions/useTagInteractions'; interface DocumentTagsProps { - tags: DocumentTag[]; - tagLookupById?: Map | null; + tags: Identifier[]; + tagLookupById?: Map | null; doc: Document; tagHandlers?: TagInteractionHandlers; } @@ -17,24 +17,29 @@ const DocumentTags: React.FC = ({ doc, tagHandlers, }) => { - const sortedTags = useMemo(() => { - return [...tags].sort((a, b) => { - const labelA = (a.label || '').toLowerCase(); - const labelB = (b.label || '').toLowerCase(); - return labelA.localeCompare(labelB); - }); - }, [tags]); + const resolvedTags = useMemo(() => { + if (!tags) return []; + return tags + .map(id => tagLookupById?.get(id)) + .filter((tag): tag is Tag => Boolean(tag)) + .sort((a, b) => { + const labelA = a.label.toLowerCase(); + const labelB = b.label.toLowerCase(); + return labelA.localeCompare(labelB); + }); + }, [tags, tagLookupById]); - if (tags.length === 0) { + if (resolvedTags.length === 0) { return null; } return ( <> - {sortedTags.map((tag, index) => { - const colorSource = tag?.color || tagLookupById?.get(tag.id)?.color; - const style = getTagColorStyle(colorSource); - const tagId = tag?.id ?? null; + {resolvedTags.map((tag, index) => { + const { color, label, id } = tag; + const tagId = id; + + const style = getTagColorStyle(color); const clickable = tagId != null && typeof tagHandlers?.onTagClick === 'function'; const draggable = !!tagId; const key = tagId ?? `${doc.id}-tag-${index}`; @@ -42,16 +47,16 @@ const DocumentTags: React.FC = ({ return ( { event.stopPropagation(); if (tagId == null) return; tagHandlers?.onTagClick?.(tagId); } : undefined} - draggable={!!tagId} + draggable={draggable} onDragStart={(event) => tagId && tagHandlers?.onTagDragStart(event, doc, tag)} onDragEnd={tagHandlers?.onTagDragEnd} onKeyDown={clickable ? (event) => { @@ -63,7 +68,7 @@ const DocumentTags: React.FC = ({ } } : undefined} > - {tag.label} + {label} ); })} diff --git a/frontend/src/documents/components/DocumentsGridCard.tsx b/frontend/src/documents/components/DocumentsGridCard.tsx index e93ee0b..cc04579 100644 --- a/frontend/src/documents/components/DocumentsGridCard.tsx +++ b/frontend/src/documents/components/DocumentsGridCard.tsx @@ -24,7 +24,7 @@ interface DocumentsGridCardProps { const DocumentsGridCard: React.FC = (props) => { const { entry, iconSize, tagHandlers } = props; const { ensureAssetUrl, getDocumentAsset } = useDocumentsAssetContext(); - const { scrollRef, activeCorrespondentIdSet, tagLookupById } = useDocumentsViewStateContext(); + const { scrollRef, activeCorrespondentIdSet, tagLookupById, correspondentLookupById } = useDocumentsViewStateContext(); const { correspondents: { onClick: onCorrespondentClick }, } = useDocumentsCommandContext(); @@ -74,7 +74,7 @@ const DocumentsGridCard: React.FC = (props) => { const doc = entry.document; if (!doc) return null; - const correspondents = resolveCorrespondents(doc); + const correspondents = resolveCorrespondents(doc, correspondentLookupById); return ( = (props) => { const { entry, iconSize, tagHandlers } = props; const { ensureAssetUrl, getDocumentAsset } = useDocumentsAssetContext(); - const { scrollRef, activeCorrespondentIdSet, tagLookupById } = useDocumentsViewStateContext(); + const { scrollRef, activeCorrespondentIdSet, tagLookupById, correspondentLookupById } = useDocumentsViewStateContext(); const { correspondents: { onClick: onCorrespondentClick }, } = useDocumentsCommandContext(); @@ -83,7 +83,7 @@ const DocumentsListRow: React.FC = (props) => { const doc = entry.document; if (!doc) return null; - const correspondents = resolveCorrespondents(doc); + const correspondents = resolveCorrespondents(doc, correspondentLookupById); const issuedLabel = formatDate(doc.issued_at); const addedLabel = formatDate(doc.created_at || doc.uploaded_at); diff --git a/frontend/src/documents/context/DocumentsViewStateContext.tsx b/frontend/src/documents/context/DocumentsViewStateContext.tsx index 3428ec8..e9faefd 100644 --- a/frontend/src/documents/context/DocumentsViewStateContext.tsx +++ b/frontend/src/documents/context/DocumentsViewStateContext.tsx @@ -1,11 +1,12 @@ import { createContext, useContext, type RefObject } from 'react'; -import type { DocumentTag } from '../../types/documents'; +import type { Tag, Correspondent } from '../../types/documents'; import type { Identifier } from '../../types/identifiers'; interface DocumentsViewStateContextValue { viewId?: string | null; scrollRef?: RefObject; - tagLookupById?: Map | null; + tagLookupById?: Map | null; + correspondentLookupById?: Map | null; activeCorrespondentIdSet?: Set | null; draggingDocumentIdsSet?: Set | null; draggedFolderId?: Identifier | 'root' | null; diff --git a/frontend/src/documents/correspondents.ts b/frontend/src/documents/correspondents.ts index 6d81ca4..297a879 100644 --- a/frontend/src/documents/correspondents.ts +++ b/frontend/src/documents/correspondents.ts @@ -1,40 +1,27 @@ -import type { Document } from '../types/documents'; +import type { Identifier } from '../types/identifiers'; +import type { Document, Correspondent } from '../types/documents'; -interface ResolvedCorrespondent { - id?: string | null; - name: string; - key: string; -} - -export const resolveCorrespondents = (doc?: Document | null): ResolvedCorrespondent[] => { +export const resolveCorrespondents = ( + doc?: Document | null, + lookup?: Map | null +): Correspondent[] => { if (!doc || !Array.isArray(doc.correspondents)) { return []; } - const seen = new Set(); - const results: ResolvedCorrespondent[] = []; + const seen = new Set(); + const results: Correspondent[] = []; - doc.correspondents.forEach((entry = {}, index) => { - const { id, name } = entry; - const trimmedName = name?.trim?.(); - if (!trimmedName) { - return; + doc.correspondents.forEach((id) => { + if (!id) return; + if (seen.has(id)) return; + seen.add(id); + + const resolved = lookup?.get(id); + if (resolved) { + results.push(resolved); } - - if (id != null && seen.has(id)) { - return; - } - - if (id != null) { - seen.add(id); - } - - results.push({ - id, - name: trimmedName, - key: id ?? `${trimmedName}-${index}`, - }); }); - return results; + return results.sort((a, b) => (a.name || '').localeCompare(b.name || '')); }; diff --git a/frontend/src/documents/data/useBulkDocumentActions.ts b/frontend/src/documents/data/useBulkDocumentActions.ts index b5b4806..c054400 100644 --- a/frontend/src/documents/data/useBulkDocumentActions.ts +++ b/frontend/src/documents/data/useBulkDocumentActions.ts @@ -81,19 +81,17 @@ const useBulkDocumentActions = ({ if (target.id) { const targetSet = new Set(targets); - let targetId = target.id; - let targetName = (target as any).name; documentsManager.map((doc) => { if (!targetSet.has(doc.id as Identifier)) return undefined; const current = Array.isArray((doc as any).correspondents) ? (doc as any).correspondents : []; - if (current.some((entry: any) => entry?.id === targetId)) { + if (current.includes(target.id)) { return doc; } return { ...(doc as any), - correspondents: [...current, { id: targetId, name: targetName }], + correspondents: [...current, target.id], }; }); } @@ -158,8 +156,8 @@ const useBulkDocumentActions = ({ return doc; } const filtered = (doc as any).correspondents.filter( - (entry: any) => - entry && !normalizedAssignments.some((assignment) => assignment.correspondent_id === entry.id), + (id: Identifier) => + !normalizedAssignments.some((assignment) => assignment.correspondent_id === id), ); return filtered.length === (doc as any).correspondents.length ? doc diff --git a/frontend/src/documents/data/useCorrespondents.ts b/frontend/src/documents/data/useCorrespondents.ts index 60fdb5e..fdd0806 100644 --- a/frontend/src/documents/data/useCorrespondents.ts +++ b/frontend/src/documents/data/useCorrespondents.ts @@ -1,53 +1,50 @@ -import { MutableRefObject, useCallback, useState } from 'react'; +import { useCallback, useSyncExternalStore } from 'react'; import { useStatusToast } from '../../lib/context/StatusToastContext'; import type { Correspondent } from '../../types/documents'; - -import { listCorrespondents, createCorrespondent, updateCorrespondent, deleteCorrespondent } from '../../lib/api/apiClient'; +import type { Identifier } from '../../types/identifiers'; +import type CorrespondentManager from '../../lib/assets/CorrespondentManager'; import useNotifyApiError from '../../hooks/useNotifyApiError'; interface UseCorrespondentsOptions { - tenantIdRef: MutableRefObject; + correspondentManager: CorrespondentManager; documentsManager?: { map: (mapper: (doc: any) => any) => void }; } const useCorrespondents = ({ - tenantIdRef, + correspondentManager, documentsManager, }: UseCorrespondentsOptions) => { - const [correspondents, setCorrespondents] = useState([]); const { showToast } = useStatusToast(); const notifyApiError = useNotifyApiError(); + const correspondentsSnapshot = useSyncExternalStore>( + useCallback((cb) => correspondentManager.subscribe(cb), [correspondentManager]), + () => correspondentManager.getSnapshot(), + () => correspondentManager.getSnapshot(), + ); + + const correspondents = Array.from(correspondentsSnapshot.values()) + .filter((corr): corr is Correspondent => (corr as any).id != null && (corr as any).name != null) + .sort((a, b) => (a.name || '').localeCompare(b.name || '')); + const refreshCorrespondents = useCallback(async () => { - const requestTenantId = tenantIdRef.current; try { - const data = await listCorrespondents(); - if (tenantIdRef.current !== requestTenantId) { - return; - } - setCorrespondents(data || []); + await correspondentManager.ensureAll(true); } catch (error) { - if (tenantIdRef.current !== requestTenantId) { - return; - } notifyApiError(error, 'Unable to load correspondents.'); } - }, [notifyApiError, tenantIdRef]); + }, [notifyApiError, correspondentManager]); const handleCorrespondentUpdate = useCallback( - async (correspondentId: string, changes: { name?: string }) => { + async (correspondentId: Identifier, changes: { name?: string }) => { if (correspondentId == null) { throw new Error('Missing correspondent identifier.'); } const payload: Record = {}; if (changes?.name != null) { - const trimmed = changes.name.trim(); - if (!trimmed) { - throw new Error('Correspondent name cannot be empty.'); - } - payload.name = trimmed; + payload.name = changes.name; } if (Object.keys(payload).length === 0) { @@ -55,8 +52,7 @@ const useCorrespondents = ({ } try { - await updateCorrespondent(correspondentId, payload); - await refreshCorrespondents(); + await correspondentManager.update(correspondentId, payload); showToast('Correspondent updated.', 'success'); return true; } catch (error) { @@ -65,18 +61,14 @@ const useCorrespondents = ({ throw new Error(message); } }, - [notifyApiError, refreshCorrespondents, showToast], + [notifyApiError, correspondentManager, showToast], ); const handleCorrespondentCreate = useCallback( async ({ name }: { name?: string }) => { - const trimmed = name?.trim?.() || ''; - if (!trimmed) { - throw new Error('Correspondent name is required.'); - } try { - const data = await createCorrespondent({ name: trimmed }); - await refreshCorrespondents(); + const payload = correspondentManager.buildPayload({ name }); + const data = await correspondentManager.create(payload); showToast('Correspondent created.', 'success'); return data; } catch (error) { @@ -85,29 +77,29 @@ const useCorrespondents = ({ throw new Error(message); } }, - [notifyApiError, refreshCorrespondents, showToast], + [notifyApiError, correspondentManager, showToast], ); const handleCorrespondentDelete = useCallback( - async (correspondentId: string) => { + async (correspondentId: Identifier) => { if (correspondentId == null) { throw new Error('Missing correspondent identifier.'); } - const stripFromDoc = (doc: any) => { - 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 deleteCorrespondent(correspondentId); - await refreshCorrespondents(); + await correspondentManager.delete(correspondentId); + + const stripFromDoc = (doc: any) => { + if (!doc || !Array.isArray(doc.correspondents)) { + return doc; + } + // doc.correspondents is allowed to be Identifier[] now + const next = doc.correspondents.filter((id: Identifier) => id !== correspondentId); + if (next.length === doc.correspondents.length) { + return doc; + } + return { ...doc, correspondents: next }; + }; documentsManager?.map(stripFromDoc); @@ -119,16 +111,16 @@ const useCorrespondents = ({ throw new Error(message); } }, - [documentsManager, notifyApiError, refreshCorrespondents, showToast], + [documentsManager, notifyApiError, correspondentManager, showToast], ); return { correspondents, + correspondentLookupById: correspondentsSnapshot, refreshCorrespondents, handleCorrespondentCreate, handleCorrespondentUpdate, handleCorrespondentDelete, - setCorrespondents, }; }; diff --git a/frontend/src/documents/features/correspondents/useDocumentCorrespondentActions.ts b/frontend/src/documents/data/useDocumentCorrespondentMutations.ts similarity index 65% rename from frontend/src/documents/features/correspondents/useDocumentCorrespondentActions.ts rename to frontend/src/documents/data/useDocumentCorrespondentMutations.ts index 9c9e275..c056af0 100644 --- a/frontend/src/documents/features/correspondents/useDocumentCorrespondentActions.ts +++ b/frontend/src/documents/data/useDocumentCorrespondentMutations.ts @@ -1,35 +1,34 @@ import { useCallback, useMemo } from 'react'; -import { useStatusToast } from '../../../lib/context/StatusToastContext'; -import type { Identifier } from '../../../types/identifiers'; +import { useStatusToast } from '../../lib/context/StatusToastContext'; +import type { Identifier } from '../../types/identifiers'; +import type { Correspondent } from '../../types/documents'; +import { addDocumentCorrespondent, removeDocumentCorrespondent } from '../../lib/api/apiClient'; -import { addDocumentCorrespondent, removeDocumentCorrespondent } from '../../../lib/api/apiClient'; +import useNotifyApiError from '../../hooks/useNotifyApiError'; +import type { CorrespondentsState, DocumentsState } from '../types/workspaceTypes'; -interface CorrespondentOption { - id?: string; - name?: string; - [key: string]: unknown; +interface UseDocumentCorrespondentMutationsArgs { + correspondentsState: CorrespondentsState; + documentsState: Pick; } -import useNotifyApiError from '../../../hooks/useNotifyApiError'; -import type { DocumentsManagerInterface } from '../../types/workspaceTypes'; - -interface UseDocumentCorrespondentActionsArgs { - correspondents: CorrespondentOption[]; - handleCorrespondentCreate: (payload: { name: string }) => Promise; - documentsManager: DocumentsManagerInterface; -} - -const useDocumentCorrespondentActions = ({ - correspondents, - handleCorrespondentCreate, - documentsManager, -}: UseDocumentCorrespondentActionsArgs) => { +const useDocumentCorrespondentMutations = ({ + correspondentsState, + documentsState, +}: UseDocumentCorrespondentMutationsArgs) => { const { showToast } = useStatusToast(); const notifyApiError = useNotifyApiError(); + const { + correspondents, + correspondentManager, + } = correspondentsState; + + const { documentsManager } = documentsState; + const correspondentLookupByName = useMemo(() => { - const map = new Map(); + const map = new Map(); correspondents.forEach((correspondent) => { if (correspondent?.name) { map.set(correspondent.name.toLowerCase(), correspondent); @@ -43,8 +42,7 @@ const useDocumentCorrespondentActions = ({ { documentId, correspondentId, - correspondent, - }: { documentId: Identifier; correspondentId: Identifier; correspondent?: CorrespondentOption | null }, + }: { documentId: Identifier; correspondentId: Identifier; correspondent?: Correspondent | Partial | null }, { notify = true }: { notify?: boolean } = {}, ) => { if (documentId == null || correspondentId == null) { @@ -53,21 +51,14 @@ const useDocumentCorrespondentActions = ({ try { await addDocumentCorrespondent(documentId, correspondentId); - const resolved = correspondent - || correspondents.find((entry) => entry?.id === correspondentId) - || null; - documentsManager.map((doc) => { if (doc.id !== documentId) return undefined; const current = Array.isArray(doc.correspondents) ? doc.correspondents : []; - if (current.some((entry) => entry?.id === correspondentId)) { + if (current.includes(correspondentId)) { return doc; } - const nextEntry = resolved?.name - ? { id: resolved.id ?? correspondentId, name: resolved.name } - : { id: correspondentId }; - return { ...doc, correspondents: [...current, nextEntry] }; + return { ...doc, correspondents: [...current, correspondentId] }; }); if (notify) { @@ -80,10 +71,10 @@ const useDocumentCorrespondentActions = ({ throw new Error(message); } }, - [correspondents, notifyApiError, showToast, documentsManager], + [notifyApiError, showToast, documentsManager], ); - const handleCorrespondentRemove = useCallback( + const handleDocumentCorrespondentDetach = useCallback( async ( { documentId, correspondentId }: { documentId: Identifier; correspondentId: Identifier }, { notify = true }: { notify?: boolean } = {}, @@ -99,7 +90,8 @@ const useDocumentCorrespondentActions = ({ if (!doc || !Array.isArray(doc.correspondents)) { return doc; } - const filtered = doc.correspondents.filter((entry) => entry?.id !== correspondentId); + + const filtered = doc.correspondents.filter((id) => id !== correspondentId); return filtered.length === doc.correspondents.length ? doc : { ...doc, correspondents: filtered }; }); @@ -117,8 +109,8 @@ const useDocumentCorrespondentActions = ({ ); const normalizeOption = ( - option: CorrespondentOption | string | null, - ): CorrespondentOption | null => { + option: Correspondent | Partial | string | null, + ): Correspondent | Partial | null => { if (!option) { return null; } @@ -132,8 +124,18 @@ const useDocumentCorrespondentActions = ({ return option; }; - const handleCorrespondentAdd = useCallback( - async ({ document, name, input = null, option = null }: { document?: { id?: string }; name?: string; input?: HTMLInputElement | null; option?: CorrespondentOption | string | null }) => { + const handleCorrespondentCreate = useCallback( + async ({ name }: { name: string }) => { + const payload = correspondentManager.buildPayload({ name }); + const data = await correspondentManager.create(payload); + + return data; + }, + [correspondentManager] + ); + + const handleDocumentCorrespondentAdd = useCallback( + async ({ document, name, input = null, option = null }: { document?: { id?: string }; name?: string; input?: HTMLInputElement | null; option?: Correspondent | Partial | string | null }) => { if (!document?.id) { throw new Error('Missing document for correspondent assignment.'); } @@ -147,7 +149,15 @@ const useDocumentCorrespondentActions = ({ if (!target) { try { target = await handleCorrespondentCreate({ name: trimmed }); + // Force refresh or ingest? + if (target) { + const asCorr = target as Correspondent; + if (asCorr.id) { + // Creating often yields an object we can use immediately + } + } } catch { + showToast('Failed to create correspondent.', 'error'); return; } } @@ -182,9 +192,9 @@ const useDocumentCorrespondentActions = ({ return { correspondentLookupByName, handleDocumentCorrespondentAttach, - handleCorrespondentRemove, - handleCorrespondentAdd, + handleDocumentCorrespondentDetach, // Renamed from handleCorrespondentRemove + handleDocumentCorrespondentAdd, // Renamed from handleCorrespondentAdd }; }; -export default useDocumentCorrespondentActions; +export default useDocumentCorrespondentMutations; diff --git a/frontend/src/documents/data/useDocumentMutations.ts b/frontend/src/documents/data/useDocumentMutations.ts index 5e5598b..a13c65c 100644 --- a/frontend/src/documents/data/useDocumentMutations.ts +++ b/frontend/src/documents/data/useDocumentMutations.ts @@ -1,5 +1,6 @@ import { useCallback } from 'react'; import { useStatusToast } from '../../lib/context/StatusToastContext'; +import useNotifyApiError from '../../hooks/useNotifyApiError'; import { queueDocumentReanalysis, @@ -15,26 +16,26 @@ import type { FolderState, SelectionState, TagsState, + CorrespondentsState, ActionsState, - Tag, } from '../types/workspaceTypes'; +import type { Tag, Correspondent } from '../../types/documents'; +import useDocumentCorrespondentMutations from './useDocumentCorrespondentMutations'; type FolderId = FolderIdentifier | 'root'; type NullableFolderId = FolderId | null; - interface DocumentTagExtras { option?: Tag | null; input?: { value?: string } | null; } -import useNotifyApiError from '../../hooks/useNotifyApiError'; - interface UseDocumentMutationsArgs { documentsState: DocumentsState; folderState: FolderState; selectionState: SelectionState; tagsState: TagsState; + correspondentsState: CorrespondentsState; actions: ActionsState; previewDocumentId?: DocumentId | null; } @@ -64,6 +65,10 @@ interface UseDocumentMutationsResult { documentId?: DocumentId, tagId?: DocumentId, ) => Promise; + handleDocumentCorrespondentAttach: (args: { documentId: DocumentId; correspondentId: DocumentId; correspondent?: Correspondent | Partial | null }) => Promise; + handleDocumentCorrespondentDetach: (args: { documentId: DocumentId; correspondentId: DocumentId }) => Promise; + handleDocumentCorrespondentAdd: (args: { document: { id?: string }; name?: string; input?: HTMLInputElement | null; option?: Correspondent | Partial | string | null }) => Promise; + correspondentLookupByName: Map; } const useDocumentMutations = ({ @@ -71,6 +76,7 @@ const useDocumentMutations = ({ folderState, selectionState, tagsState, + correspondentsState, actions, previewDocumentId, }: UseDocumentMutationsArgs): UseDocumentMutationsResult => { @@ -92,6 +98,16 @@ const useDocumentMutations = ({ documentsState: { documentsManager: documentsState.documentsManager }, }); + const { + handleDocumentCorrespondentAttach, + handleDocumentCorrespondentDetach, + handleDocumentCorrespondentAdd, + correspondentLookupByName, + } = useDocumentCorrespondentMutations({ + correspondentsState, + documentsState: { documentsManager: documentsState.documentsManager }, + }); + const handleThumbnailRegeneration = useCallback( async (documentId: DocumentId) => { try { @@ -221,6 +237,10 @@ const useDocumentMutations = ({ handleDocumentTitleUpdate, handleDocumentIssuedUpdate, handleDocumentTagDetach, + handleDocumentCorrespondentAttach, + handleDocumentCorrespondentDetach, + handleDocumentCorrespondentAdd, + correspondentLookupByName, }; }; diff --git a/frontend/src/documents/data/useDocumentTagMutations.ts b/frontend/src/documents/data/useDocumentTagMutations.ts index b3495ad..a155464 100644 --- a/frontend/src/documents/data/useDocumentTagMutations.ts +++ b/frontend/src/documents/data/useDocumentTagMutations.ts @@ -1,6 +1,6 @@ import { useCallback } from 'react'; import type { DocumentId } from '../../types/identifiers'; -import type { Document } from '../../types/documents'; +import type { Document, Tag } from '../../types/documents'; import { addDocumentTags, createTag, @@ -8,11 +8,7 @@ import { } from '../../lib/api/apiClient'; import useNotifyApiError from '../../hooks/useNotifyApiError'; import { useStatusToast } from '../../lib/context/StatusToastContext'; -import type { - TagsState, - DocumentsState, - Tag, -} from '../types/workspaceTypes'; +import type { TagsState, DocumentsState } from '../types/workspaceTypes'; interface DocumentTagExtras { option?: Tag | null; @@ -43,23 +39,18 @@ export const useDocumentTagMutations = ({ return false; } - const cachedTag: Tag = { - id: tag.id, - label: tag.label, - color: Object.prototype.hasOwnProperty.call(tag, 'color') ? tag.color ?? null : null, - }; - try { - await addDocumentTags(documentId, [cachedTag.id]); + await addDocumentTags(documentId, [tag.id]); documentsState.documentsManager.map((doc) => { if (doc.id !== documentId) { return undefined; } const currentTags = Array.isArray(doc.tags) ? doc.tags : []; - if (currentTags.some((entry) => entry?.id === cachedTag.id)) { + + if (currentTags.includes(tag.id)) { return doc; } - return { ...doc, tags: [...currentTags, cachedTag] }; + return { ...doc, tags: [...currentTags, tag.id] }; }); showToast('Tag assigned.', 'success'); return true; @@ -79,17 +70,22 @@ export const useDocumentTagMutations = ({ const input = extras?.input ?? null; let tag: Tag | null = null; + // Lookup via ID if (optionCandidate && optionCandidate.id) { - tag = tagsState.tags.find((item) => item.id === optionCandidate.id) || optionCandidate; + tag = tagsState.tagLookupById.get(optionCandidate.id) || (optionCandidate as Tag); } + // Lookup via Label if not found if (!tag) { - tag = tagsState.tags.find((item) => item.label.toLowerCase() === normalizedLabel.toLowerCase()) || null; + const knownTags = Array.from(tagsState.tagLookupById.values()); + tag = knownTags.find((item) => item.label?.toLowerCase() === normalizedLabel.toLowerCase()) || null; } try { if (!tag) { const payload = tagsState.tagManager.buildPayload({ label: normalizedLabel }) as { label: string; color?: string | null }; const data = await createTag(payload); tag = data as Tag; + // Ingest new tag into manager to ensure it's available + tagsState.tagManager.ingest([tag]); await tagsState.refreshTags(); } await attachTagToDocument({ @@ -117,15 +113,8 @@ export const useDocumentTagMutations = ({ if (!lookupTag || lookupTag.id == null) { return null; } - const labelText = `${lookupTag.label ?? ''} `.trim(); - if (!labelText) { - return null; - } - return { - id: lookupTag.id, - label: labelText, - color: Object.prototype.hasOwnProperty.call(lookupTag, 'color') ? (lookupTag as Tag).color ?? null : null, - }; + + return lookupTag; }; const resolvedTag = resolveTagForCache(); @@ -156,7 +145,8 @@ export const useDocumentTagMutations = ({ if (!doc || !Array.isArray(doc.tags)) { return doc; } - const nextTags = doc.tags.filter((tagEntry) => tagEntry.id !== tagId); + // Filter IDs + const nextTags = doc.tags.filter((id) => id !== tagId); if (nextTags.length === doc.tags.length) { return doc; } diff --git a/frontend/src/documents/data/useDocumentsWorkspace.ts b/frontend/src/documents/data/useDocumentsWorkspace.ts index 203630d..d385e90 100644 --- a/frontend/src/documents/data/useDocumentsWorkspace.ts +++ b/frontend/src/documents/data/useDocumentsWorkspace.ts @@ -15,6 +15,7 @@ import { import AssetManager, { getAssetFromVersion } from '../../lib/assets/AssetManager'; import useNotifyApiError from '../../hooks/useNotifyApiError'; import TagManager from '../../lib/assets/TagManager'; +import CorrespondentManager from '../../lib/assets/CorrespondentManager'; import { fetchAsset } from '../../lib/api/apiClient'; import { useEntryPointer as useEntryPointerCore } from '../features/selection/useEntryPointer'; import useDocumentsSelection from '../features/selection/useDocumentsSelection'; @@ -46,7 +47,6 @@ import useDocumentMutations from './useDocumentMutations'; import useDetailWorkspace from '../../viewer/logic/useDetailWorkspace'; import useTags from './useTags'; import useCorrespondents from './useCorrespondents'; -import useDocumentCorrespondentActions from '../features/correspondents/useDocumentCorrespondentActions'; import usePasskeys from '../../settings/usePasskeys'; import { resolveBreadcrumbs } from '../logic/breadcrumbs'; import useWorkspaceSelectionSync from '../features/selection/useWorkspaceSelectionSync'; @@ -58,6 +58,7 @@ import { useApi } from '../../lib/context/ApiContext'; import { useWorkspaceSelection } from '../../app/useWorkspaceSelection'; import useDocumentPreview from '../../app/useDocumentPreview'; import type { DocumentId, FolderNodeId, Identifier } from '../../types/identifiers'; +import type { Document, Tag } from '../../types/documents'; const EntryType = Object.freeze({ document: 'document', @@ -66,8 +67,6 @@ const EntryType = Object.freeze({ const noop = () => { }; -import type { Document } from '../../types/documents'; - interface TenantOption { id?: Identifier | null; name?: string | null; @@ -198,6 +197,12 @@ const useDocumentsWorkspace = ({ } const tagManager = tagManagerRef.current; + const correspondentManagerRef = useRef(null); + if (!correspondentManagerRef.current) { + correspondentManagerRef.current = new CorrespondentManager(); + } + const correspondentManager = correspondentManagerRef.current; + const selectionState = useWorkspaceSelection(); const { @@ -228,6 +233,15 @@ const useDocumentsWorkspace = ({ fetchDocumentById, }); + useEffect(() => { + if (tagManager) { + documentsManager.setTagManager(tagManager); + } + if (correspondentManager) { + documentsManager.setCorrespondentManager(correspondentManager); + } + }, [documentsManager, tagManager, correspondentManager]); + const documentLookup = useSyncExternalStore( (onStoreChange) => documentsManager.subscribe(onStoreChange), () => documentsManager.getSnapshot(), @@ -476,13 +490,17 @@ const useDocumentsWorkspace = ({ setActiveTagFilters, documentsManager, }); + + useEffect(() => { + tagManager.ensureAll().catch((err) => console.warn('Failed to bootstrap tags', err)); + }, [tagManager]); + const { tags, refreshTags, handleTagCreate, handleTagUpdate, handleTagDelete, - setTags, } = tagsStateRaw; // tagLookupById is derived locally @@ -490,7 +508,7 @@ const useDocumentsWorkspace = ({ tenantIdRef.current = currentTenantId; }, [currentTenantId, tenantIdRef]); - const tagLookupById = new Map(); + const tagLookupById = new Map(); tags.forEach((tag) => { if (tag?.id) { tagLookupById.set(tag.id, tag); @@ -499,33 +517,30 @@ const useDocumentsWorkspace = ({ const tagsState = { ...tagsStateRaw, - tagLookupById, // Add derived lookup + tags, + tagLookupById, tagManager, }; const correspondentsStateRaw = useCorrespondents({ - tenantIdRef, + correspondentManager, documentsManager, }); + const { correspondents, + correspondentLookupById, refreshCorrespondents, handleCorrespondentCreate, handleCorrespondentUpdate, handleCorrespondentDelete, - setCorrespondents, } = correspondentsStateRaw; - const { - correspondentLookupByName, - handleDocumentCorrespondentAttach, - handleCorrespondentRemove, - handleCorrespondentAdd, - } = useDocumentCorrespondentActions({ - correspondents, - handleCorrespondentCreate, - documentsManager, - }); + // Prefetch tags/correspondents when tenant changes + useEffect(() => { + refreshTags(); + refreshCorrespondents(); + }, [refreshTags, refreshCorrespondents, currentTenantId]); const { passkeys, @@ -623,8 +638,6 @@ const useDocumentsWorkspace = ({ setDraggedDocumentIds([]); setDraggedFolderId(null); setSearchResultIds(null); - setTags([]); - setCorrespondents([]); setSearchQuery(''); setActiveTagFilters([]); setActiveCorrespondentFilters([]); @@ -653,8 +666,6 @@ const useDocumentsWorkspace = ({ setDraggedDocumentIds, setDraggedFolderId, setSearchResultIds, - setTags, - setCorrespondents, setSearchQuery, setActiveTagFilters, setActiveCorrespondentFilters, @@ -693,11 +704,21 @@ const useDocumentsWorkspace = ({ handleDocumentTitleUpdate, handleDocumentIssuedUpdate, handleDocumentTagDetach, + handleDocumentCorrespondentAttach, + handleDocumentCorrespondentDetach, + handleDocumentCorrespondentAdd, + correspondentLookupByName, } = useDocumentMutations({ documentsState, folderState, selectionState, tagsState, + correspondentsState: { + correspondents, + correspondentLookupById, + refreshCorrespondents, + correspondentManager, + }, actions: actionsState, previewDocumentId, }); @@ -895,13 +916,15 @@ const useDocumentsWorkspace = ({ tags, refreshTags, onTagCreate: handleTagCreate, - onTagUpdate: handleTagUpdate, - onTagDelete: handleTagDelete, + onTagUpdate: async (tagId: string, changes: any) => { await handleTagUpdate(tagId, changes); }, + onTagDelete: async (tagId: string) => { await handleTagDelete(tagId); }, correspondents, + correspondentLookupById, refreshCorrespondents, onCorrespondentCreate: handleCorrespondentCreate, onCorrespondentUpdate: handleCorrespondentUpdate, onCorrespondentDelete: handleCorrespondentDelete, + correspondentManager, }); const [settingsOpen, setSettingsOpen] = useState(false); @@ -951,11 +974,12 @@ const useDocumentsWorkspace = ({ ensureAssetUrl, getAsset: getDocumentAsset, correspondents, - handleCorrespondentAdd, - handleCorrespondentRemove, + handleCorrespondentAdd: handleDocumentCorrespondentAdd, + handleCorrespondentRemove: handleDocumentCorrespondentDetach, selectFolder, tags, tagLookupById, + correspondentLookupById, }); const handleEntryPointerCore = useEntryPointerCore({ @@ -1019,6 +1043,7 @@ const useDocumentsWorkspace = ({ tagLookupById, activeTagFilters, handleTagUpdate, + handleTagCreate, handleTagDelete, handleDocumentTagAttach, handleDocumentTagDetach, @@ -1029,14 +1054,15 @@ const useDocumentsWorkspace = ({ const correspondentsContext = { correspondents, + correspondentLookupById, refreshCorrespondents, activeCorrespondentFilters, handleCorrespondentUpdate, handleCorrespondentCreate, handleCorrespondentDelete, handleDocumentCorrespondentAttach, - handleCorrespondentRemove, - handleCorrespondentAdd, + handleDocumentCorrespondentDetach, + handleDocumentCorrespondentAdd, handleBulkCorrespondentAdd, handleBulkCorrespondentRemove, openCorrespondentsModal, diff --git a/frontend/src/documents/data/useTags.ts b/frontend/src/documents/data/useTags.ts index 7b48f15..9181d69 100644 --- a/frontend/src/documents/data/useTags.ts +++ b/frontend/src/documents/data/useTags.ts @@ -1,50 +1,52 @@ -import { MutableRefObject, useCallback, useState } from 'react'; +import { MutableRefObject, useCallback, useSyncExternalStore } from 'react'; import { useStatusToast } from '../../lib/context/StatusToastContext'; import type { TagId, TenantId } from '../../types/identifiers'; import type { Tag } from '../../types/documents'; - -import { listTags, updateTag, createTag, deleteTag } from '../../lib/api/apiClient'; - -interface TagManagerInterface { - buildPayload: (input: { label?: string; color?: string | null }) => { label: string; color: string | null }; -} - import useNotifyApiError from '../../hooks/useNotifyApiError'; +import TagManager from '../../lib/assets/TagManager'; interface UseTagsOptions { - // apiClient removed - tagManager: TagManagerInterface; + tagManager: TagManager; tenantIdRef: MutableRefObject; setActiveTagFilters: (updater: (prev: Array) => Array) => void; documentsManager?: { map: (mapper: (doc: any) => any) => void }; } +interface UseTagsResult { + tags: Tag[]; + refreshTags: () => Promise; + handleTagUpdate: (tagId: TagId, changes: { label?: string; color?: string | null }) => Promise; + handleTagCreate: (payload?: { label?: string; color?: string | null }) => Promise; + handleTagDelete: (tagId: TagId) => Promise; +} + const useTags = ({ - // apiClient removed tagManager, - tenantIdRef, setActiveTagFilters, documentsManager, -}: UseTagsOptions) => { - const [tags, setTags] = useState([]); +}: UseTagsOptions): UseTagsResult => { const { showToast } = useStatusToast(); const notifyApiError = useNotifyApiError(); + const tagsSnapshot = useSyncExternalStore>( + useCallback((cb) => tagManager.subscribe(cb), [tagManager]), + () => tagManager.getSnapshot(), + () => tagManager.getSnapshot(), + ); + + const tags = Array.from(tagsSnapshot.values()) + .filter((tag): tag is Tag => (tag as any).id != null && (tag as any).label != null) // Ensure strict adherence + .sort((a, b) => + (a.label || '').localeCompare(b.label || '') + ); + const refreshTags = useCallback(async () => { - const requestTenantId = tenantIdRef.current; try { - const data = await listTags(); - if (tenantIdRef.current !== requestTenantId) { - return; - } - setTags(data || []); + await tagManager.ensureAll(true); } catch (error) { - if (tenantIdRef.current !== requestTenantId) { - return; - } notifyApiError(error, 'Unable to load tags.'); } - }, [notifyApiError, tenantIdRef]); + }, [notifyApiError, tagManager]); const handleTagUpdate = useCallback( async (tagId: TagId, changes: { label?: string; color?: string | null }) => { @@ -52,12 +54,12 @@ const useTags = ({ throw new Error('Missing tag identifier.'); } - const payload: Record = {}; + const payload: Record = {}; if (changes?.label != null) { payload.label = changes.label; } if (Object.prototype.hasOwnProperty.call(changes, 'color')) { - payload.color = changes.color; + payload.color = changes.color || ''; // API might behave differently if color is literally null, usually string expected } if (Object.keys(payload).length === 0) { @@ -65,8 +67,7 @@ const useTags = ({ } try { - await updateTag(tagId, payload); - await refreshTags(); + await tagManager.update(tagId, payload as any); showToast('Tag updated.', 'success'); return true; } catch (error) { @@ -75,23 +76,23 @@ const useTags = ({ throw new Error(message); } }, - [notifyApiError, refreshTags, showToast], + [notifyApiError, tagManager, showToast], ); const handleTagCreate = useCallback( async ({ label, color }: { label?: string; color?: string | null } = {}) => { const payload = tagManager.buildPayload({ label, color }); try { - await createTag(payload); - await refreshTags(); + const newTag = await tagManager.create(payload); showToast('Tag created.', 'success'); + return newTag; } catch (error) { const message = error.response?.data?.error || 'Failed to create tag.'; notifyApiError(error, message); throw new Error(message); } }, - [notifyApiError, refreshTags, showToast, tagManager], + [notifyApiError, showToast, tagManager], ); const handleTagDelete = useCallback( @@ -101,14 +102,14 @@ const useTags = ({ } try { - await deleteTag(tagId); + await tagManager.delete(tagId); setActiveTagFilters((prev) => prev.filter((id) => id !== tagId)); const stripTagFromDoc = (doc: any) => { if (!doc || !Array.isArray(doc.tags)) { return doc; } - const nextTags = doc.tags.filter((tag) => tag.id !== tagId); + const nextTags = doc.tags.filter((tag: Tag) => tag.id !== tagId); if (nextTags.length === doc.tags.length) { return doc; } @@ -116,8 +117,6 @@ const useTags = ({ }; documentsManager?.map(stripTagFromDoc); - - await refreshTags(); showToast('Tag deleted.', 'success'); return true; } catch (error) { @@ -126,7 +125,7 @@ const useTags = ({ throw new Error(message); } }, - [documentsManager, notifyApiError, refreshTags, setActiveTagFilters, showToast], + [documentsManager, notifyApiError, setActiveTagFilters, showToast, tagManager], ); return { @@ -135,7 +134,6 @@ const useTags = ({ handleTagUpdate, handleTagCreate, handleTagDelete, - setTags, }; }; diff --git a/frontend/src/documents/features/selection/SelectionFloatingActions.tsx b/frontend/src/documents/features/selection/SelectionFloatingActions.tsx index f1771cf..8e0a4c2 100644 --- a/frontend/src/documents/features/selection/SelectionFloatingActions.tsx +++ b/frontend/src/documents/features/selection/SelectionFloatingActions.tsx @@ -63,6 +63,7 @@ interface SelectionFloatingActionsProps { tags?: TagOption[] | null; tagLookupById?: Map | null; correspondents?: CorrespondentOption[] | null; + correspondentLookupById?: Map | null; onBulkTagAdd?: (args: BulkTagMutationArgs) => Promise | void; onBulkTagRemove?: (args: BulkTagMutationArgs) => Promise | void; onBulkCorrespondentAdd?: (args: BulkCorrespondentAddArgs) => Promise | void; @@ -114,9 +115,12 @@ const buildTagAssignments = ( }; 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); + (doc?.tags || []).forEach((tagId) => { + const tag = tagLookupById instanceof Map ? tagLookupById.get(tagId) : null; + const lookupColor = tag?.color ?? null; + const label = tag?.label; + + const entry = ensureEntry(tagId, label, lookupColor); if (entry) { entry.count += 1; } @@ -146,6 +150,7 @@ const buildTagAssignments = ( const buildCorrespondentAssignments = ( selectedDocuments: Document[], correspondents: CorrespondentOption[] | null, + correspondentLookupById: Map | null, total: number, ): SelectionAssignmentMenuItem[] => { if (!total) { @@ -176,8 +181,11 @@ const buildCorrespondentAssignments = ( }; selectedDocuments.forEach((doc) => { - (doc?.correspondents || []).forEach((entry) => { - const target = ensureEntry(entry?.id, entry?.name); + (doc?.correspondents || []).forEach((correspondentId) => { + const resolved = correspondentLookupById instanceof Map ? correspondentLookupById.get(correspondentId) : null; + const name = resolved?.name; + + const target = ensureEntry(correspondentId, name); if (target) { target.count += 1; } @@ -210,6 +218,7 @@ const SelectionFloatingActions: React.FC = ({ tags = [], tagLookupById, correspondents = [], + correspondentLookupById, onBulkTagAdd, onBulkTagRemove, onBulkCorrespondentAdd, @@ -303,8 +312,8 @@ const SelectionFloatingActions: React.FC = ({ ); const correspondentAssignments = useMemo( - () => buildCorrespondentAssignments(selectedDocuments, correspondents, selectedDocCount), - [selectedDocuments, correspondents, selectedDocCount], + () => buildCorrespondentAssignments(selectedDocuments, correspondents, correspondentLookupById, selectedDocCount), + [selectedDocuments, correspondents, correspondentLookupById, selectedDocCount], ); const handleToggleTagAssignment = useCallback( diff --git a/frontend/src/documents/features/tagging/useDocumentTagActions.ts b/frontend/src/documents/features/tagging/useDocumentTagActions.ts index 8950f7b..c268d5a 100644 --- a/frontend/src/documents/features/tagging/useDocumentTagActions.ts +++ b/frontend/src/documents/features/tagging/useDocumentTagActions.ts @@ -3,23 +3,14 @@ import { useStatusToast } from '../../../lib/context/StatusToastContext'; import type { Identifier } from '../../../types/identifiers'; -interface TagRecord { - id?: Identifier; - label: string; - [key: string]: unknown; -} - import { createTag, bulkTagDocuments, bulkReanalyzeDocuments } from '../../../lib/api/apiClient'; -interface TagManager { - buildPayload: (input: { label: string }) => Record; -} - import useNotifyApiError from '../../../hooks/useNotifyApiError'; -import type { DocumentsManagerInterface } from '../../types/workspaceTypes'; +import type { DocumentsManagerInterface, TagManager } from '../../types/workspaceTypes'; +import type { Tag } from '../../../types/documents'; interface UseDocumentTaggingArgs { - tags: TagRecord[]; + tags: Tag[]; tagManager: TagManager; refreshTags: () => Promise | void; resolveTargetDocumentIds: (ids?: Identifier[] | null) => Identifier[]; @@ -80,13 +71,21 @@ const useDocumentTagActions = ({ try { if (action === 'add') { const createdIds: Identifier[] = []; - const createdTags: TagRecord[] = []; + const createdTags: Tag[] = []; for (const label of normalized) { let tag = tags.find((item) => item.label.toLowerCase() === label.toLowerCase()) || null; if (!tag) { const payload = tagManager.buildPayload({ label }) as { label: string; color?: string | null }; const response = await createTag(payload); - tag = response as TagRecord; + const newTagRaw = response as any; + if (!newTagRaw.id) throw new Error('Created tag missing ID'); + + tag = { + id: newTagRaw.id, + label: newTagRaw.label, + color: newTagRaw.color + } as Tag; + await refreshTags(); } createdIds.push(tag.id); @@ -94,7 +93,7 @@ const useDocumentTagActions = ({ } tagIds = Array.from(new Set(createdIds)); - const tagById = new Map(); + const tagById = new Map(); tags.forEach((tag) => { if (tag?.id != null) { tagById.set(tag.id, tag); @@ -111,22 +110,20 @@ const useDocumentTagActions = ({ documentsManager.map((doc) => { if (!targetSet.has(doc.id as Identifier)) return undefined; - const currentTags = Array.isArray((doc as any).tags) ? (doc as any).tags : []; + const currentTags: Identifier[] = Array.isArray(doc.tags) ? doc.tags : []; let nextTags = [...currentTags]; let changed = false; tagIds.forEach((tagId) => { - if (nextTags.some((entry: any) => entry?.id === tagId)) { + if (nextTags.includes(tagId)) { return; } - const cachedTag = tagById.get(tagId); - if (cachedTag) { - nextTags.push({ ...cachedTag }); - changed = true; - } + + nextTags.push(tagId); + changed = true; }); - return changed ? { ...(doc as any), tags: nextTags } : doc; + return changed ? { ...doc, tags: nextTags } : doc; }); } } @@ -149,12 +146,12 @@ const useDocumentTagActions = ({ documentsManager.map((doc) => { if (!targetSet.has(doc.id as Identifier)) return undefined; - if (!doc || !Array.isArray((doc as any).tags)) { + if (!doc || !Array.isArray(doc.tags)) { return doc; } - const currentTags = (doc as any).tags; - const filtered = currentTags.filter((entry: any) => !removeSet.has(entry?.id)); - return filtered.length === currentTags.length ? doc : { ...(doc as any), tags: filtered }; + const currentTags = doc.tags as Identifier[]; + const filtered = currentTags.filter((id) => !removeSet.has(id)); + return filtered.length === currentTags.length ? doc : { ...doc, tags: filtered }; }); } diff --git a/frontend/src/documents/interactions/useTagInteractions.ts b/frontend/src/documents/interactions/useTagInteractions.ts index 5c2fcfd..8115d6c 100644 --- a/frontend/src/documents/interactions/useTagInteractions.ts +++ b/frontend/src/documents/interactions/useTagInteractions.ts @@ -12,7 +12,7 @@ import { clearTagTransferData, } from '../../documents/features/tagging/tagTransfer'; import type { Identifier } from '../../types/identifiers'; -import type { Document, DocumentTag } from '../../types/documents'; +import type { Document, Tag } from '../../types/documents'; const preventAll = (event?: React.SyntheticEvent | Event | null) => { if (!event) return; @@ -60,7 +60,7 @@ export interface TagInteractionHandlers { onTagDragOver: (event: React.DragEvent, doc: Document) => void; onTagDragLeave: (event: React.DragEvent, docId: Identifier) => void; onTagDrop: (event: React.DragEvent, doc: Document) => void; - onTagDragStart: (event: React.DragEvent, doc: Document, tag: DocumentTag) => void; + onTagDragStart: (event: React.DragEvent, doc: Document, tag: Tag) => void; onTagDragEnd: (event: React.DragEvent) => void; onTagClick?: (tagId: Identifier) => void; } @@ -92,7 +92,7 @@ export const useTagInteractions = ({ // Use shared state for all logic (Single Source of Truth) const { tagId: draggedTagId, sourceDocId: draggedSourceId } = getActiveDragState(); - const isAssigned = doc.tags?.some((t) => t.id === draggedTagId); + const isAssigned = doc.tags?.some((t) => t === draggedTagId); if (event.dataTransfer) { const isSource = draggedSourceId === doc.id; @@ -154,7 +154,7 @@ export const useTagInteractions = ({ } // Double-check assignment (even though cursor logic tries to prevent it) - const isAssigned = doc.tags?.some((t) => t.id === payload.id); + const isAssigned = doc.tags?.some((t) => t === payload.id); if (isAssigned) return; if (onAssignTagToDocument && doc.id) { @@ -166,7 +166,7 @@ export const useTagInteractions = ({ ); const onTagDragStart = useCallback( - (event: React.DragEvent, doc: Document, tag: DocumentTag) => { + (event: React.DragEvent, doc: Document, tag: Tag) => { if (!event?.dataTransfer || !doc?.id || !tag?.id) { return; } diff --git a/frontend/src/documents/logic/useDocumentsPanelProps.ts b/frontend/src/documents/logic/useDocumentsPanelProps.ts index eed4cf4..5389fb6 100644 --- a/frontend/src/documents/logic/useDocumentsPanelProps.ts +++ b/frontend/src/documents/logic/useDocumentsPanelProps.ts @@ -53,7 +53,7 @@ interface UseDocumentsPanelPropsArgs { handleEntryPointerCore?: (...args: unknown[]) => void; tags?: unknown[]; correspondents?: unknown[]; - documentLookup?: unknown; + correspondentLookupById?: unknown; handleBulkTagAddFromDetail?: (...args: unknown[]) => void; handleBulkTagRemoveFromDetail?: (...args: unknown[]) => void; handleBulkCorrespondentAdd?: (...args: unknown[]) => void; @@ -61,6 +61,7 @@ interface UseDocumentsPanelPropsArgs { handleBulkSelectionReanalyze?: (...args: unknown[]) => void; folderOptions?: unknown[]; moveDocumentsToFolder?: (...args: unknown[]) => void; + documentLookup?: unknown; selectionValue: WorkspaceSelectionValue; } @@ -103,6 +104,7 @@ const useDocumentsPanelProps = (props: UseDocumentsPanelPropsArgs) => { handleEntryPointerCore, tags, correspondents, + correspondentLookupById, documentLookup, handleBulkTagAddFromDetail, handleBulkTagRemoveFromDetail, @@ -155,6 +157,7 @@ const useDocumentsPanelProps = (props: UseDocumentsPanelPropsArgs) => { onEntryPointer: handleEntryPointerCore, tags, correspondents, + correspondentLookupById, documentLookup, onBulkTagAdd: handleBulkTagAddFromDetail, onBulkTagRemove: handleBulkTagRemoveFromDetail, diff --git a/frontend/src/documents/panel/DocumentsPanel.tsx b/frontend/src/documents/panel/DocumentsPanel.tsx index 1c35069..d76f40a 100644 --- a/frontend/src/documents/panel/DocumentsPanel.tsx +++ b/frontend/src/documents/panel/DocumentsPanel.tsx @@ -10,7 +10,9 @@ import { DocumentsList, DocumentsGrid } from '../DocumentsView'; import type { ReactNode } from 'react'; import type { DocumentsListEntry, + Correspondent, } from '../../types/documents'; +import type { Identifier } from '../../types/identifiers'; import DesktopWorkspace from '../../desktop/components/DesktopWorkspace'; import { WorkspaceSelectionProvider, @@ -47,6 +49,7 @@ export interface DocumentsPanelInnerProps { interface DocumentsPanelProps extends DocumentsPanelInnerProps { selectionValue: WorkspaceSelectionValue; + correspondentLookupById?: Map; } import type { TagInteractionHandlers } from '../interactions/useTagInteractions'; @@ -153,6 +156,7 @@ const DocumentsPanelInner: React.FC = (props) => { tags={tags} tagLookupById={props.tagLookupById} correspondents={correspondents} + correspondentLookupById={props.correspondentLookupById} onBulkTagAdd={onBulkTagAdd} onBulkTagRemove={onBulkTagRemove} onBulkCorrespondentAdd={onBulkCorrespondentAdd} @@ -168,6 +172,7 @@ const DocumentsPanelInner: React.FC = (props) => { tags, props.tagLookupById, correspondents, + props.correspondentLookupById, onBulkTagAdd, onBulkTagRemove, onBulkCorrespondentAdd, diff --git a/frontend/src/documents/panel/useDocumentsContextValues.ts b/frontend/src/documents/panel/useDocumentsContextValues.ts index 815fb93..7d5ef65 100644 --- a/frontend/src/documents/panel/useDocumentsContextValues.ts +++ b/frontend/src/documents/panel/useDocumentsContextValues.ts @@ -129,6 +129,7 @@ export const useDocumentsContextValues = (props: DocumentsPanelInnerProps) => { viewId, scrollRef, tagLookupById, + correspondentLookupById: props.correspondentLookupById, activeCorrespondentIdSet, draggingDocumentIdsSet, draggedFolderId: props.draggedFolderId, @@ -139,6 +140,7 @@ export const useDocumentsContextValues = (props: DocumentsPanelInnerProps) => { activeCorrespondentIdSet, draggingDocumentIdsSet, props.draggedFolderId, + props.correspondentLookupById, ]); // Use refs to stabilize handlers and avoid massive dependency arrays diff --git a/frontend/src/documents/types/workspaceTypes.ts b/frontend/src/documents/types/workspaceTypes.ts index 66d089e..46f0148 100644 --- a/frontend/src/documents/types/workspaceTypes.ts +++ b/frontend/src/documents/types/workspaceTypes.ts @@ -1,20 +1,20 @@ import type { Dispatch, MutableRefObject, SetStateAction } from 'react'; -import type { DocumentId, FolderId as FolderIdentifier } from '../../types/identifiers'; -import type { Document, FolderNode } from '../../types/documents'; +import type { DocumentId, FolderId, Identifier } from '../../types/identifiers'; +import type { Document, FolderNode, Tag, Correspondent } from '../../types/documents'; -type FolderId = FolderIdentifier | 'root'; - -export interface Tag { - id: DocumentId; - label: string; - color?: string | null; -} - -interface TagManager { +export interface TagManager { normalizeLabel: (label: string) => string; - buildPayload: (args: { label: string }) => Record; + buildPayload: (args: { label: string; color?: string | null }) => Record; + ingest: (tags: Tag[]) => void; + create: (payload: Record) => Promise; } +export interface CorrespondentManager { + normalizeName: (name: string) => string; + buildPayload: (args: { name: string }) => Record; + ingest: (correspondents: Correspondent[]) => void; + create: (payload: Record) => Promise; +} export interface DocumentsManagerInterface { map(mapper: (doc: Document) => Document | undefined): boolean; @@ -59,6 +59,13 @@ export interface TagsState { tagManager: TagManager; } +export interface CorrespondentsState { + correspondents: Correspondent[]; + correspondentLookupById: Map; + refreshCorrespondents: () => Promise; + correspondentManager: CorrespondentManager; +} + export interface ActionsState { closeDocumentPreview: CloseDocumentPreview; handleFileDrop?: (dataTransfer: DataTransfer, folderId: FolderId) => Promise | void; @@ -66,8 +73,8 @@ export interface ActionsState { } export interface DragState { - draggedDocumentIds: FolderId[]; + draggedDocumentIds: DocumentId[]; draggedFolderId: FolderId | null; - setDraggedDocumentIds: (ids: FolderId[]) => void; + setDraggedDocumentIds: (ids: DocumentId[]) => void; setDraggedFolderId: (id: FolderId | null) => void; } diff --git a/frontend/src/lib/assets/CorrespondentManager.ts b/frontend/src/lib/assets/CorrespondentManager.ts new file mode 100644 index 0000000..dbaf656 --- /dev/null +++ b/frontend/src/lib/assets/CorrespondentManager.ts @@ -0,0 +1,144 @@ +import { listCorrespondents, createCorrespondent, updateCorrespondent, deleteCorrespondent } from '../api/apiClient'; +import type { Identifier } from '../../types/identifiers'; +import type { Correspondent } from '../../types/documents'; + +interface CorrespondentPayload { + name: string; +} + +type Listener = () => void; + +class CorrespondentManager { + private byId: Map = new Map(); + private listeners: Set = new Set(); + private correspondentsPromise: Promise | null = null; + private loaded = false; + + constructor() { + // No specific options for now + } + + subscribe(listener: Listener): () => void { + this.listeners.add(listener); + return () => { + this.listeners.delete(listener); + }; + } + + private emit() { + this.listeners.forEach((listener) => listener()); + } + + getSnapshot(): Map { + return this.byId; + } + + ingest(correspondents: Correspondent[]): void { + let changed = false; + let nextMap: Map | null = null; + + correspondents.forEach((corr) => { + if (!corr.id) return; + const existing = this.byId.get(corr.id); + if (JSON.stringify(existing) !== JSON.stringify(corr)) { + if (!nextMap) nextMap = new Map(this.byId); + nextMap.set(corr.id, corr); + changed = true; + } + }); + + if (changed && nextMap) { + this.byId = nextMap; + this.emit(); + } + } + + remove(ids: Identifier[]): void { + let changed = false; + let nextMap: Map | null = null; + + ids.forEach((id) => { + if (this.byId.has(id)) { + if (!nextMap) nextMap = new Map(this.byId); + nextMap.delete(id); + changed = true; + } + }); + if (changed && nextMap) { + this.byId = nextMap; + this.emit(); + } + } + + async ensureAll(force = false): Promise { + if (this.loaded && !force && this.byId.size > 0) { + return Array.from(this.byId.values()); + } + + if (this.correspondentsPromise && !force) { + return this.correspondentsPromise; + } + + this.correspondentsPromise = this.fetchCorrespondentsInternal(); + return this.correspondentsPromise; + } + + private async fetchCorrespondentsInternal(): Promise { + try { + const results = await listCorrespondents(); + const castResults = (results || []) as unknown as Correspondent[]; + this.byId = new Map(); // Reset + castResults.forEach(item => { + if (item.id) this.byId.set(item.id, item); + }); + // Emit needed for full refresh + this.emit(); + this.loaded = true; + return castResults; + } catch (error) { + console.warn('Failed to fetch correspondents', error); + return []; + } finally { + this.correspondentsPromise = null; + } + } + + async create(payload: CorrespondentPayload): Promise { + const response = await createCorrespondent(payload); + const newEntry = response as unknown as Correspondent; + this.ingest([newEntry]); + return newEntry; + } + + async update(id: Identifier, changes: Partial): Promise { + await updateCorrespondent(id, changes); + const existing = this.byId.get(id); + if (existing) { + const updated = { ...existing, ...changes }; + this.ingest([updated as Correspondent]); + } else { + this.ensureAll(true); + } + } + + async delete(id: Identifier): Promise { + await deleteCorrespondent(id); + this.remove([id]); + } + + normalizeName(name?: string | null): string { + return name?.trim?.() || ''; + } + + buildPayload({ name }: { name?: string | null } = {}): CorrespondentPayload { + const normalizedName = this.normalizeName(name); + if (!normalizedName) { + throw new Error('Correspondent name is required.'); + } + return { + name: normalizedName, + }; + } +} + +export default CorrespondentManager; diff --git a/frontend/src/lib/assets/TagManager.ts b/frontend/src/lib/assets/TagManager.ts index 3a84bbe..d8d7021 100644 --- a/frontend/src/lib/assets/TagManager.ts +++ b/frontend/src/lib/assets/TagManager.ts @@ -1,4 +1,7 @@ import { generateRandomTagColor } from '../../utils/colors'; +import { listTags, createTag, updateTag, deleteTag } from '../api/apiClient'; +import type { TagId } from '../../types/identifiers'; +import type { Tag } from '../../types/documents'; type ColorGenerator = () => string; @@ -11,13 +14,133 @@ interface TagPayload { color: string; } +type Listener = () => void; + class TagManager { private readonly colorGenerator: ColorGenerator; + private byId: Map = new Map(); + private listeners: Set = new Set(); + private tagsPromise: Promise | null = null; + private loaded = false; constructor({ colorGenerator = generateRandomTagColor }: TagManagerOptions = {}) { this.colorGenerator = colorGenerator; } + subscribe(listener: Listener): () => void { + this.listeners.add(listener); + return () => { + this.listeners.delete(listener); + }; + } + + private emit() { + this.listeners.forEach((listener) => listener()); + } + + getSnapshot(): Map { + return this.byId; + } + + ingest(tags: Tag[]): void { + let changed = false; + let nextMap: Map | null = null; + + tags.forEach((tag) => { + if (!tag.id) return; + const existing = this.byId.get(tag.id); + if (JSON.stringify(existing) !== JSON.stringify(tag)) { + if (!nextMap) nextMap = new Map(this.byId); + nextMap.set(tag.id, tag); + changed = true; + } + }); + + if (changed && nextMap) { + this.byId = nextMap; + this.emit(); + } + } + + remove(ids: TagId[]): void { + let changed = false; + let nextMap: Map | null = null; + + ids.forEach((id) => { + if (this.byId.has(id)) { + if (!nextMap) nextMap = new Map(this.byId); + nextMap.delete(id); + changed = true; + } + }); + if (changed && nextMap) { + this.byId = nextMap; + this.emit(); + } + } + + async ensureAll(force = false): Promise { + if (this.loaded && !force && this.byId.size > 0) { + return Array.from(this.byId.values()); + } + + if (this.tagsPromise && !force) { + return this.tagsPromise; + } + + this.tagsPromise = this.fetchTagsInternal(); + return this.tagsPromise; + } + + private async fetchTagsInternal(): Promise { + try { + const tags = await listTags(); + const castTags = (tags || []) as unknown as Tag[]; + this.byId = new Map(); // Reset + castTags.forEach(tag => { + if (tag.id) this.byId.set(tag.id, tag); + }); + // Emit strictly needed? Usually ingest handles this but here we doing full reset + this.emit(); + this.loaded = true; + return castTags; + } catch (error) { + console.warn('Failed to fetch tags', error); + return []; + } finally { + this.tagsPromise = null; + } + } + + async create(payload: TagPayload): Promise { + const response = await createTag(payload); + const newTag = response as unknown as Tag; + this.ingest([newTag]); + return newTag; + } + + async update(tagId: TagId, changes: Partial): Promise { + await updateTag(tagId, changes); + // Optimistic update or re-fetch? + // Since updateTag doesn't return the full tag, we can optimistically update + const existing = this.byId.get(tagId); + if (existing) { + const updated = { ...existing, ...changes }; + this.ingest([updated]); + } else { + // Fallback: fetch specific tag or refresh all? + // For now, let's refresh all to be safe, or just ignore if we don't have it. + // But if we are updating it, we probably should have it. + // Let's trigger a refresh in background to be safe. + this.ensureAll(true); + } + } + + async delete(tagId: TagId): Promise { + await deleteTag(tagId); + this.remove([tagId]); + } + normalizeLabel(label?: string | null): string { return label?.trim?.() || ''; } diff --git a/frontend/src/sidebar/Sidebar.tsx b/frontend/src/sidebar/Sidebar.tsx index 109b20a..e43e44e 100644 --- a/frontend/src/sidebar/Sidebar.tsx +++ b/frontend/src/sidebar/Sidebar.tsx @@ -13,6 +13,8 @@ const Sidebar: React.FC = () => { sidebarSuppressed, openTagsModal, openCorrespondentsModal, + handleTagCreate, + handleCorrespondentCreate, handleLogout, tags = [], correspondents = [], @@ -21,8 +23,7 @@ const Sidebar: React.FC = () => { tenantOptions, handleTenantSelect, openSettings, - handleFileSelection, // Used for upload - // Folder Tree Context Props + handleFileSelection, folderClickHandlers = {}, handleFolderDelete, handleFolderRename, @@ -107,11 +108,13 @@ const Sidebar: React.FC = () => { diff --git a/frontend/src/sidebar/components/SidebarCorrespondentList.tsx b/frontend/src/sidebar/components/SidebarCorrespondentList.tsx index d165c6a..cf182ca 100644 --- a/frontend/src/sidebar/components/SidebarCorrespondentList.tsx +++ b/frontend/src/sidebar/components/SidebarCorrespondentList.tsx @@ -3,14 +3,11 @@ import { PlusIcon, SettingsIcon } from '../../components/icons'; import type { Identifier } from '../../types/identifiers'; import { useDocumentsFilter } from '../../documents/context/DocumentsFilterContext'; -interface CorrespondentEntry { - id: Identifier; - name: string; -} +import type { Correspondent } from '../../types/documents'; interface SidebarCorrespondentListProps { - correspondents: CorrespondentEntry[]; - onCreateCorrespondent?: (name: string) => Promise | void; + correspondents: Correspondent[]; + onCreateCorrespondent?: (payload: { name: string }) => Promise | void; onManageCorrespondents?: () => void; } @@ -24,12 +21,12 @@ const SidebarCorrespondentList: React.FC = ({ toggleCorrespondent: toggleCorrespondentFilter, } = useDocumentsFilter(); - const sortedCorrespondents = useMemo(() => { + const sortedCorrespondents = useMemo(() => { if (!Array.isArray(correspondents)) { return []; } return correspondents - .filter((entry): entry is CorrespondentEntry & { name: string } => Boolean(entry?.name)) + .filter((entry): entry is Correspondent & { name: string } => Boolean(entry?.name)) .slice() .sort((a, b) => a.name!.localeCompare(b.name!, undefined, { sensitivity: 'base' })); }, [correspondents]); @@ -49,7 +46,7 @@ const SidebarCorrespondentList: React.FC = ({ return; } try { - await onCreateCorrespondent?.(trimmed); + await onCreateCorrespondent?.({ name: trimmed }); } catch (error: unknown) { console.error('[sidebar] failed to create correspondent', error); } diff --git a/frontend/src/sidebar/components/SidebarTagList.tsx b/frontend/src/sidebar/components/SidebarTagList.tsx index 0a3c8c2..deb163c 100644 --- a/frontend/src/sidebar/components/SidebarTagList.tsx +++ b/frontend/src/sidebar/components/SidebarTagList.tsx @@ -5,16 +5,12 @@ import { writeTagTransferData, clearTagTransferData } from '../../documents/feat import type { Identifier } from '../../types/identifiers'; import { useDocumentsFilter } from '../../documents/context/DocumentsFilterContext'; -interface TagEntry { - id: Identifier; - label: string; - color?: string | null; -} +import type { Tag } from '../../types/documents'; interface SidebarTagListProps { - tags: TagEntry[]; + tags: Tag[]; untaggedFilterId: Identifier | null; - onCreateTag?: (label: string) => Promise | void; + onCreateTag?: (payload: { label: string }) => Promise | void; onManageTags?: () => void; } @@ -54,7 +50,7 @@ const SidebarTagList: React.FC = ({ return; } try { - await onCreateTag?.(trimmed); + await onCreateTag?.({ label: trimmed }); } catch (error: unknown) { console.error('[sidebar] failed to create tag', error); } diff --git a/frontend/src/types/documents.ts b/frontend/src/types/documents.ts index e43d48a..2d02a50 100644 --- a/frontend/src/types/documents.ts +++ b/frontend/src/types/documents.ts @@ -2,39 +2,17 @@ import type { Identifier } from './identifiers'; import type { Asset } from './assets'; import type { Download } from './common'; -export interface DocumentTag { - id?: Identifier; - label?: string | null; - color?: string | null; -} - -export interface DocumentCorrespondent { - id?: Identifier; - name?: string | null; - count?: number; -} - -/** - * A tag entity as returned by the API (includes usage_count). - * Use DocumentTag for the embedded version on documents. - */ export interface Tag { - id?: Identifier; - label?: string; - color?: string | null; - usage_count?: number; - [key: string]: unknown; + id: Identifier; + label: string; + color: string | null; + usage_count: number; } -/** - * A correspondent entity as returned by the API. - * Use DocumentCorrespondent for the embedded version on documents. - */ export interface Correspondent { - id?: Identifier; - name?: string; - usage_count?: number; - [key: string]: unknown; + id: Identifier; + name: string; + usage_count: number; } export interface DocumentVersion { @@ -43,7 +21,6 @@ export interface DocumentVersion { size_bytes?: number | null; checksum?: string | null; download?: Download | null; - [key: string]: unknown; } export interface MessageOptions { @@ -66,8 +43,8 @@ export interface Document { folder_name?: string; folder_path?: string; - tags?: DocumentTag[] | null; - correspondents?: DocumentCorrespondent[] | null; + tags?: Identifier[] | null; + correspondents?: Identifier[] | null; current_version?: DocumentVersion | null; diff --git a/frontend/src/viewer/DocumentViewerPanel.tsx b/frontend/src/viewer/DocumentViewerPanel.tsx index e4c0ac9..ce94a93 100644 --- a/frontend/src/viewer/DocumentViewerPanel.tsx +++ b/frontend/src/viewer/DocumentViewerPanel.tsx @@ -67,6 +67,7 @@ const DocumentViewerPanel: React.FC = ({ onTagAdd, onTagRemove, correspondents, + correspondentLookupById, onCorrespondentAdd, onCorrespondentRemove, onUpdateTitle, @@ -134,6 +135,7 @@ const DocumentViewerPanel: React.FC = ({ onTagAdd, onTagRemove, correspondents: sortedCorrespondents, + correspondentLookupById, correspondentOptions, onCorrespondentAdd, onCorrespondentRemove, @@ -147,6 +149,7 @@ const DocumentViewerPanel: React.FC = ({ onTagAdd, onTagRemove, sortedCorrespondents, + correspondentLookupById, correspondentOptions, onCorrespondentAdd, onCorrespondentRemove, @@ -156,6 +159,11 @@ const DocumentViewerPanel: React.FC = ({ ], ); + const infoPanelProps = useMemo(() => ({ + tagLookupById, + correspondentLookupById, + }), [tagLookupById, correspondentLookupById]); + const loadOcrContent = useCallback(async ({ signal }: { signal?: AbortSignal } = {}) => { if (!document || !hasOcr || !getDocumentAsset) { return ''; @@ -358,6 +366,7 @@ const DocumentViewerPanel: React.FC = ({ ) => ReactNode }; @@ -14,7 +16,8 @@ type ContentState = | { status: 'error'; data: null; error: unknown }; export interface DocumentInfoPanelProps { - document: DocumentSummarySectionProps['document']; + tagLookupById?: Map; + correspondentLookupById?: Map; summaryProps?: Omit; metadataItems?: DocumentSummaryRow[]; metadataPayload?: Record; @@ -50,6 +53,8 @@ export interface DocumentInfoPanelProps { const DocumentInfoPanel: React.FC = ({ document, + tagLookupById, + correspondentLookupById, summaryProps = {}, metadataItems: metadataItemsProp, metadataPayload: metadataPayloadProp, @@ -76,8 +81,9 @@ const DocumentInfoPanel: React.FC = ({ if (Array.isArray(metadataItemsProp) && metadataItemsProp.length) { return metadataItemsProp; } - return describeDocumentSummary(document); - }, [metadataItemsProp, document]); + + return describeDocumentSummary(document, { tagLookupById, correspondentLookupById }); + }, [metadataItemsProp, document, tagLookupById, correspondentLookupById]); const metadataPayload = useMemo(() => { if (metadataPayloadProp !== undefined) { @@ -107,9 +113,10 @@ const DocumentInfoPanel: React.FC = ({ - ), [document, summaryLayout, summaryProps]); + ), [document, summaryLayout, summaryProps, correspondentLookupById]); const renderDetailsSection = useCallback(() => (
diff --git a/frontend/src/viewer/components/DocumentSummarySection.tsx b/frontend/src/viewer/components/DocumentSummarySection.tsx index 09bc89d..74ee14f 100644 --- a/frontend/src/viewer/components/DocumentSummarySection.tsx +++ b/frontend/src/viewer/components/DocumentSummarySection.tsx @@ -15,25 +15,12 @@ import { import { describeDocumentSummary, type DocumentSummaryRow } from '../logic/documentSummary'; import { useFolderManager } from '../../folders/FolderManagerContext'; +import type { Document, Tag, Correspondent } from '../../types/documents'; import type { FolderId, Identifier, TagId } from '../../types/identifiers'; -interface TagEntry { - id?: TagId; - label?: string; - color?: string | null; -} - -interface CorrespondentEntry { - id?: Identifier; - name?: string; - count?: number; -} - -import type { Document } from '../../types/documents'; - interface TagSectionProps { - tags?: TagEntry[]; - onRemove?: (tag: TagEntry) => void; + tags?: Tag[]; + onRemove?: (tag: Tag) => void; onAdd?: (payload: { value: string; option?: unknown; input?: unknown }) => void; emptyMessage?: string; addPlaceholder?: string; @@ -43,8 +30,8 @@ interface TagSectionProps { } interface CorrespondentSectionProps { - entries?: CorrespondentEntry[]; - onRemove?: (entry: CorrespondentEntry) => void; + entries?: Correspondent[]; + onRemove?: (entry: Correspondent) => void; onAdd?: (payload: { name: string; option?: unknown; input?: unknown }) => void; showCount?: boolean; addPlaceholder?: string; @@ -55,11 +42,12 @@ interface CorrespondentSectionProps { export interface DocumentSummarySectionProps { document?: Document | null; - tagLookupById?: Map; + tagLookupById?: Map; tagOptions?: SelectionAssignmentMenuItem[]; onTagAdd?: (doc: Document, value: string, context?: { option?: unknown }) => void; onTagRemove?: (docId: Identifier | undefined, tagId: TagId | undefined) => void; - correspondents?: CorrespondentEntry[]; + correspondents?: Correspondent[]; + correspondentLookupById?: Map; correspondentOptions?: SelectionAssignmentMenuItem[]; onCorrespondentAdd?: (payload: { document: Document; name: string; option?: unknown }) => void; onCorrespondentRemove?: (payload: { documentId: Identifier | undefined; correspondentId: Identifier | undefined }) => void; @@ -77,10 +65,9 @@ interface MetaItem { error?: string | null; } -export const sortCorrespondents = (entries = []) => +export const sortCorrespondents = (entries: Correspondent[] = []) => entries .filter((entry) => entry && entry.name) - .map(({ id, name, count }) => ({ id, name, count })) .sort((a, b) => a.name.localeCompare(b.name)); export const buildCorrespondentOptions = (entries = []) => { @@ -237,8 +224,8 @@ const TagSection: React.FC = ({ } if (item.state === 'all' && onRemove) { const payload = item.payload && typeof item.payload === 'object' && 'id' in item.payload - ? (item.payload as TagEntry) - : tags.find((tag) => (tag.id ?? tag.label) === item.id) ?? { id: item.id, label: item.label }; + ? (item.payload as Tag) + : tags.find((tag) => (tag.id ?? tag.label) === item.id) ?? { id: item.id, label: item.label } as unknown as Tag; onRemove(payload); return; } @@ -344,7 +331,7 @@ const CorrespondentSection: React.FC = ({ return; } const key = label.toLowerCase(); - const payload = { id: entry.id, name: label }; + const payload = entry; if (map.has(key)) { const item = map.get(key); if (item) { @@ -370,9 +357,7 @@ const CorrespondentSection: React.FC = ({ return; } if (item.state === 'all' && onRemove) { - const payload = item.payload && typeof item.payload === 'object' && 'id' in item.payload - ? (item.payload as CorrespondentEntry) - : entries.find((entry) => (entry.id ?? entry.name) === item.id) ?? { id: item.id, name: item.label }; + const payload = (item.payload || { id: item.id, name: item.label }) as Correspondent; onRemove(payload); return; } @@ -389,7 +374,7 @@ const CorrespondentSection: React.FC = ({ : { id: null, name: resolvedName }; onAdd({ name: resolvedName, option: payload, input: null }); }, - [entries, onAdd, onRemove], + [onAdd, onRemove], ); return ( @@ -401,7 +386,7 @@ const CorrespondentSection: React.FC = ({ {entry.name} - {showCount && entry.count ? ` (${entry.count})` : ''} + {showCount && entry.usage_count ? ` (${entry.usage_count})` : ''} {onRemove ? (