feat: Refactor tag and correspondent management to use canonical types and dedicated managers.

This commit is contained in:
2025-12-09 18:56:11 +01:00
parent ce821c1817
commit 3ccc1f6698
34 changed files with 790 additions and 427 deletions
+7 -14
View File
@@ -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<void>;
onTagCreate?: (...args: any[]) => void | Promise<void>;
onTagUpdate?: (...args: any[]) => void | Promise<void>;
onTagDelete?: (...args: any[]) => void | Promise<void>;
correspondents?: CorrespondentRecord[];
correspondents?: Correspondent[];
correspondentLookupById?: Map<Identifier, Correspondent> | null;
refreshCorrespondents?: () => void | Promise<void>;
onCorrespondentCreate?: (...args: any[]) => void | Promise<void>;
onCorrespondentUpdate?: (...args: any[]) => void | Promise<void>;
onCorrespondentDelete?: (...args: any[]) => void | Promise<void>;
correspondentManager?: CorrespondentManager | null;
}
interface UseManagementModalsResult {
+2
View File
@@ -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}
@@ -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<DesktopDocumentCardProps> = ({
matchesFilter = true,
selected = false,
docTagTokens,
ensureAssetUrl,
getDocumentAsset,
onDocumentActivate,
onSelect,
onDeselect,
@@ -48,6 +48,12 @@ const DesktopDocumentCard: React.FC<DesktopDocumentCardProps> = ({
tagHandlers,
layoutCard,
}) => {
const {
ensureAssetUrl,
getDocumentAsset
} = useDocumentsAssetContext();
const { tagLookupById, correspondentLookupById } = useDocumentsViewStateContext();
const cardPointerHandlers = useCardPointer(
layoutCard,
!!selected,
@@ -58,7 +64,7 @@ const DesktopDocumentCard: React.FC<DesktopDocumentCardProps> = ({
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<DesktopDocumentCardProps> = ({
<DocumentTags
doc={doc}
tags={tags}
tagLookupById={tagLookupById}
tagHandlers={tagHandlers}
/>
</div>
+52 -2
View File
@@ -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<string, unknown>;
type ManagedDocument = { id?: DocumentId | null; tags?: Identifier[] | null; correspondents?: Identifier[] | null } & Record<string, unknown>;
type FetchDocument = (id: DocumentId) => Promise<unknown>;
@@ -11,6 +14,8 @@ class DocumentsManager<T extends ManagedDocument = ManagedDocument> {
private inflight: Map<DocumentId, Promise<T | null>>;
private listeners: Set<() => void>;
private emitScheduled: boolean;
private tagManager?: TagManager;
private correspondentManager?: CorrespondentManager;
constructor(
fetchDocument?: FetchDocument,
@@ -22,6 +27,14 @@ class DocumentsManager<T extends ManagedDocument = ManagedDocument> {
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<T extends ManagedDocument = ManagedDocument> {
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);
@@ -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<Identifier, DocumentTag> | null;
tags: Identifier[];
tagLookupById?: Map<Identifier, Tag> | null;
doc: Document;
tagHandlers?: TagInteractionHandlers;
}
@@ -17,24 +17,29 @@ const DocumentTags: React.FC<DocumentTagsProps> = ({
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<DocumentTagsProps> = ({
return (
<span
key={key}
className={`badge tag-chip${draggable ? ' tag-chip--draggable' : ''}`}
className={`badge tag-chip${draggable ? ' tag-chip--draggable' : ''}${clickable ? ' tag-chip--clickable' : ''}`}
style={style || undefined}
title={tag.label}
title={label || ''}
role={clickable ? 'button' : undefined}
onClick={clickable ? (event) => {
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<DocumentTagsProps> = ({
}
} : undefined}
>
{tag.label}
{label}
</span>
);
})}
@@ -24,7 +24,7 @@ interface DocumentsGridCardProps {
const DocumentsGridCard: React.FC<DocumentsGridCardProps> = (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<DocumentsGridCardProps> = (props) => {
const doc = entry.document;
if (!doc) return null;
const correspondents = resolveCorrespondents(doc);
const correspondents = resolveCorrespondents(doc, correspondentLookupById);
return (
<DocumentEntry
@@ -26,7 +26,7 @@ interface DocumentsListRowProps {
const DocumentsListRow: React.FC<DocumentsListRowProps> = (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<DocumentsListRowProps> = (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);
@@ -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<HTMLElement | null>;
tagLookupById?: Map<Identifier, DocumentTag> | null;
tagLookupById?: Map<Identifier, Tag> | null;
correspondentLookupById?: Map<Identifier, Correspondent> | null;
activeCorrespondentIdSet?: Set<Identifier> | null;
draggingDocumentIdsSet?: Set<Identifier> | null;
draggedFolderId?: Identifier | 'root' | null;
+17 -30
View File
@@ -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<Identifier, Correspondent> | null
): Correspondent[] => {
if (!doc || !Array.isArray(doc.correspondents)) {
return [];
}
const seen = new Set<string>();
const results: ResolvedCorrespondent[] = [];
const seen = new Set<Identifier>();
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 || ''));
};
@@ -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
@@ -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<string | null>;
correspondentManager: CorrespondentManager;
documentsManager?: { map: (mapper: (doc: any) => any) => void };
}
const useCorrespondents = ({
tenantIdRef,
correspondentManager,
documentsManager,
}: UseCorrespondentsOptions) => {
const [correspondents, setCorrespondents] = useState<Correspondent[]>([]);
const { showToast } = useStatusToast();
const notifyApiError = useNotifyApiError();
const correspondentsSnapshot = useSyncExternalStore<Map<Identifier, Correspondent>>(
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<string, unknown> = {};
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,
};
};
@@ -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<DocumentsState, 'documentsManager'>;
}
import useNotifyApiError from '../../../hooks/useNotifyApiError';
import type { DocumentsManagerInterface } from '../../types/workspaceTypes';
interface UseDocumentCorrespondentActionsArgs {
correspondents: CorrespondentOption[];
handleCorrespondentCreate: (payload: { name: string }) => Promise<CorrespondentOption | null>;
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<string, CorrespondentOption>();
const map = new Map<string, Correspondent>();
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<Correspondent> | 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<Correspondent> | string | null,
): Correspondent | Partial<Correspondent> | 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<Correspondent> | 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;
@@ -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<boolean>;
handleDocumentCorrespondentAttach: (args: { documentId: DocumentId; correspondentId: DocumentId; correspondent?: Correspondent | Partial<Correspondent> | null }) => Promise<boolean>;
handleDocumentCorrespondentDetach: (args: { documentId: DocumentId; correspondentId: DocumentId }) => Promise<boolean>;
handleDocumentCorrespondentAdd: (args: { document: { id?: string }; name?: string; input?: HTMLInputElement | null; option?: Correspondent | Partial<Correspondent> | string | null }) => Promise<void>;
correspondentLookupByName: Map<string, Correspondent>;
}
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,
};
};
@@ -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;
}
@@ -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<CorrespondentManager | null>(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<Identifier, Tag>();
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,
+36 -38
View File
@@ -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<TenantId | null>;
setActiveTagFilters: (updater: (prev: Array<TagId>) => Array<TagId>) => void;
documentsManager?: { map: (mapper: (doc: any) => any) => void };
}
interface UseTagsResult {
tags: Tag[];
refreshTags: () => Promise<void>;
handleTagUpdate: (tagId: TagId, changes: { label?: string; color?: string | null }) => Promise<boolean>;
handleTagCreate: (payload?: { label?: string; color?: string | null }) => Promise<void>;
handleTagDelete: (tagId: TagId) => Promise<boolean>;
}
const useTags = ({
// apiClient removed
tagManager,
tenantIdRef,
setActiveTagFilters,
documentsManager,
}: UseTagsOptions) => {
const [tags, setTags] = useState<Tag[]>([]);
}: UseTagsOptions): UseTagsResult => {
const { showToast } = useStatusToast();
const notifyApiError = useNotifyApiError();
const tagsSnapshot = useSyncExternalStore<Map<TagId, Tag>>(
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<string, unknown> = {};
const payload: Record<string, string> = {};
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,
};
};
@@ -63,6 +63,7 @@ interface SelectionFloatingActionsProps {
tags?: TagOption[] | null;
tagLookupById?: Map<DocumentId, TagOption> | null;
correspondents?: CorrespondentOption[] | null;
correspondentLookupById?: Map<DocumentId, CorrespondentOption> | null;
onBulkTagAdd?: (args: BulkTagMutationArgs) => Promise<void> | void;
onBulkTagRemove?: (args: BulkTagMutationArgs) => Promise<void> | void;
onBulkCorrespondentAdd?: (args: BulkCorrespondentAddArgs) => Promise<void> | 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<DocumentId, CorrespondentOption> | 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<SelectionFloatingActionsProps> = ({
tags = [],
tagLookupById,
correspondents = [],
correspondentLookupById,
onBulkTagAdd,
onBulkTagRemove,
onBulkCorrespondentAdd,
@@ -303,8 +312,8 @@ const SelectionFloatingActions: React.FC<SelectionFloatingActionsProps> = ({
);
const correspondentAssignments = useMemo(
() => buildCorrespondentAssignments(selectedDocuments, correspondents, selectedDocCount),
[selectedDocuments, correspondents, selectedDocCount],
() => buildCorrespondentAssignments(selectedDocuments, correspondents, correspondentLookupById, selectedDocCount),
[selectedDocuments, correspondents, correspondentLookupById, selectedDocCount],
);
const handleToggleTagAssignment = useCallback(
@@ -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<string, unknown>;
}
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> | 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<Identifier, TagRecord>();
const tagById = new Map<Identifier, Tag>();
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 };
});
}
@@ -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<HTMLDivElement>, doc: Document) => void;
onTagDragLeave: (event: React.DragEvent<HTMLDivElement>, docId: Identifier) => void;
onTagDrop: (event: React.DragEvent<HTMLDivElement>, doc: Document) => void;
onTagDragStart: (event: React.DragEvent<HTMLElement>, doc: Document, tag: DocumentTag) => void;
onTagDragStart: (event: React.DragEvent<HTMLElement>, doc: Document, tag: Tag) => void;
onTagDragEnd: (event: React.DragEvent<HTMLElement>) => 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<HTMLElement>, doc: Document, tag: DocumentTag) => {
(event: React.DragEvent<HTMLElement>, doc: Document, tag: Tag) => {
if (!event?.dataTransfer || !doc?.id || !tag?.id) {
return;
}
@@ -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,
@@ -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<Identifier, Correspondent>;
}
import type { TagInteractionHandlers } from '../interactions/useTagInteractions';
@@ -153,6 +156,7 @@ const DocumentsPanelInner: React.FC<DocumentsPanelInnerProps> = (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<DocumentsPanelInnerProps> = (props) => {
tags,
props.tagLookupById,
correspondents,
props.correspondentLookupById,
onBulkTagAdd,
onBulkTagRemove,
onBulkCorrespondentAdd,
@@ -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
+21 -14
View File
@@ -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<string, unknown>;
buildPayload: (args: { label: string; color?: string | null }) => Record<string, unknown>;
ingest: (tags: Tag[]) => void;
create: (payload: Record<string, unknown>) => Promise<Tag>;
}
export interface CorrespondentManager {
normalizeName: (name: string) => string;
buildPayload: (args: { name: string }) => Record<string, unknown>;
ingest: (correspondents: Correspondent[]) => void;
create: (payload: Record<string, unknown>) => Promise<Correspondent>;
}
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<Identifier, Correspondent>;
refreshCorrespondents: () => Promise<void>;
correspondentManager: CorrespondentManager;
}
export interface ActionsState {
closeDocumentPreview: CloseDocumentPreview;
handleFileDrop?: (dataTransfer: DataTransfer, folderId: FolderId) => Promise<void> | 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;
}
@@ -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<Identifier, Correspondent> = new Map();
private listeners: Set<Listener> = new Set();
private correspondentsPromise: Promise<Correspondent[]> | 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<Identifier, Correspondent> {
return this.byId;
}
ingest(correspondents: Correspondent[]): void {
let changed = false;
let nextMap: Map<Identifier, Correspondent> | 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<Identifier, Correspondent> | 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<Correspondent[]> {
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<Correspondent[]> {
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<Correspondent> {
const response = await createCorrespondent(payload);
const newEntry = response as unknown as Correspondent;
this.ingest([newEntry]);
return newEntry;
}
async update(id: Identifier, changes: Partial<CorrespondentPayload>): Promise<void> {
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<void> {
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;
+123
View File
@@ -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<TagId, Tag> = new Map();
private listeners: Set<Listener> = new Set();
private tagsPromise: Promise<Tag[]> | 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<TagId, Tag> {
return this.byId;
}
ingest(tags: Tag[]): void {
let changed = false;
let nextMap: Map<TagId, Tag> | 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<TagId, Tag> | 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<Tag[]> {
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<Tag[]> {
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<Tag> {
const response = await createTag(payload);
const newTag = response as unknown as Tag;
this.ingest([newTag]);
return newTag;
}
async update(tagId: TagId, changes: Partial<TagPayload>): Promise<void> {
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<void> {
await deleteTag(tagId);
this.remove([tagId]);
}
normalizeLabel(label?: string | null): string {
return label?.trim?.() || '';
}
+5 -2
View File
@@ -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 = () => {
<SidebarTagList
tags={tags}
untaggedFilterId={null}
onCreateTag={handleTagCreate}
onManageTags={onManageTags}
/>
<SidebarCorrespondentList
correspondents={correspondents}
onCreateCorrespondent={handleCorrespondentCreate}
onManageCorrespondents={onManageCorrespondents}
/>
</div>
@@ -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> | void;
correspondents: Correspondent[];
onCreateCorrespondent?: (payload: { name: string }) => Promise<void> | void;
onManageCorrespondents?: () => void;
}
@@ -24,12 +21,12 @@ const SidebarCorrespondentList: React.FC<SidebarCorrespondentListProps> = ({
toggleCorrespondent: toggleCorrespondentFilter,
} = useDocumentsFilter();
const sortedCorrespondents = useMemo<CorrespondentEntry[]>(() => {
const sortedCorrespondents = useMemo<Correspondent[]>(() => {
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<SidebarCorrespondentListProps> = ({
return;
}
try {
await onCreateCorrespondent?.(trimmed);
await onCreateCorrespondent?.({ name: trimmed });
} catch (error: unknown) {
console.error('[sidebar] failed to create correspondent', error);
}
@@ -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> | void;
onCreateTag?: (payload: { label: string }) => Promise<void> | void;
onManageTags?: () => void;
}
@@ -54,7 +50,7 @@ const SidebarTagList: React.FC<SidebarTagListProps> = ({
return;
}
try {
await onCreateTag?.(trimmed);
await onCreateTag?.({ label: trimmed });
} catch (error: unknown) {
console.error('[sidebar] failed to create tag', error);
}
+9 -32
View File
@@ -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;
@@ -67,6 +67,7 @@ const DocumentViewerPanel: React.FC<DocumentViewerPanelProps> = ({
onTagAdd,
onTagRemove,
correspondents,
correspondentLookupById,
onCorrespondentAdd,
onCorrespondentRemove,
onUpdateTitle,
@@ -134,6 +135,7 @@ const DocumentViewerPanel: React.FC<DocumentViewerPanelProps> = ({
onTagAdd,
onTagRemove,
correspondents: sortedCorrespondents,
correspondentLookupById,
correspondentOptions,
onCorrespondentAdd,
onCorrespondentRemove,
@@ -147,6 +149,7 @@ const DocumentViewerPanel: React.FC<DocumentViewerPanelProps> = ({
onTagAdd,
onTagRemove,
sortedCorrespondents,
correspondentLookupById,
correspondentOptions,
onCorrespondentAdd,
onCorrespondentRemove,
@@ -156,6 +159,11 @@ const DocumentViewerPanel: React.FC<DocumentViewerPanelProps> = ({
],
);
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<DocumentViewerPanelProps> = ({
<DocumentViewerLayout
document={document}
summaryProps={summaryProps}
infoPanelProps={infoPanelProps}
metadataPayload={metadataPayload}
contentTabConfig={contentTabConfig}
previewLoadingMessage="Loading preview…"
@@ -2,6 +2,8 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react';
import type { ReactNode } from 'react';
import DocumentSummarySection, { DocumentSummarySectionProps } from './DocumentSummarySection';
import { describeDocumentSummary, extractDocumentMetadataPayload, type DocumentSummaryRow } from '../logic/documentSummary';
import type { Tag, Correspondent } from '../../types/documents';
import type { TagId, Identifier } from '../../types/identifiers';
type PanelTab = { id: string; label: string; render: (context?: Record<string, unknown>) => ReactNode };
@@ -14,7 +16,8 @@ type ContentState =
| { status: 'error'; data: null; error: unknown };
export interface DocumentInfoPanelProps {
document: DocumentSummarySectionProps['document'];
tagLookupById?: Map<TagId, Tag>;
correspondentLookupById?: Map<Identifier, Correspondent>;
summaryProps?: Omit<DocumentSummarySectionProps, 'document' | 'layout'>;
metadataItems?: DocumentSummaryRow[];
metadataPayload?: Record<string, unknown>;
@@ -50,6 +53,8 @@ export interface DocumentInfoPanelProps {
const DocumentInfoPanel: React.FC<DocumentInfoPanelProps> = ({
document,
tagLookupById,
correspondentLookupById,
summaryProps = {},
metadataItems: metadataItemsProp,
metadataPayload: metadataPayloadProp,
@@ -76,8 +81,9 @@ const DocumentInfoPanel: React.FC<DocumentInfoPanelProps> = ({
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<DocumentInfoPanelProps> = ({
<DocumentSummarySection
document={document}
layout={summaryLayout}
correspondentLookupById={correspondentLookupById}
{...summaryProps}
/>
), [document, summaryLayout, summaryProps]);
), [document, summaryLayout, summaryProps, correspondentLookupById]);
const renderDetailsSection = useCallback(() => (
<section className={`${base}__section`}>
@@ -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<TagId, TagEntry>;
tagLookupById?: Map<TagId, Tag>;
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<Identifier, Correspondent>;
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<TagSectionProps> = ({
}
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<CorrespondentSectionProps> = ({
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<CorrespondentSectionProps> = ({
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<CorrespondentSectionProps> = ({
: { 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<CorrespondentSectionProps> = ({
<span key={key} className="correspondent-pill">
<span className="correspondent-pill__label">
{entry.name}
{showCount && entry.count ? ` (${entry.count})` : ''}
{showCount && entry.usage_count ? ` (${entry.usage_count})` : ''}
</span>
{onRemove ? (
<button
@@ -446,6 +431,7 @@ const DocumentSummarySection: React.FC<DocumentSummarySectionProps> = ({
onTagAdd,
onTagRemove,
correspondents,
correspondentLookupById,
correspondentOptions = [],
onCorrespondentAdd,
onCorrespondentRemove,
@@ -456,7 +442,7 @@ const DocumentSummarySection: React.FC<DocumentSummarySectionProps> = ({
}) => {
const folderManager = useFolderManager();
const isCompactLayout = layout === 'compact';
const summaryRows = useMemo(() => describeDocumentSummary(document), [document]);
const summaryRows = useMemo(() => describeDocumentSummary(document, { tagLookupById }), [document, tagLookupById]);
const issuedDateLabel = useMemo(
() => formatDate(document?.issued_at, { fallback: null }),
[document?.issued_at],
@@ -469,23 +455,27 @@ const DocumentSummarySection: React.FC<DocumentSummarySectionProps> = ({
if (!Array.isArray(document?.tags)) {
return [];
}
return document.tags.map((tag) => ({
id: tag.id,
label: tag.label,
color: tag.color ?? tagLookupById.get(tag.id)?.color ?? null,
})).sort((a, b) => {
const labelA = (a.label || '').toLowerCase();
const labelB = (b.label || '').toLowerCase();
return labelA.localeCompare(labelB);
});
return document.tags.map((tagId) => tagLookupById.get(tagId))
.filter((t): t is Tag => Boolean(t))
.sort((a, b) => {
const labelA = (a.label || '').toLowerCase();
const labelB = (b.label || '').toLowerCase();
return labelA.localeCompare(labelB);
});
}, [document?.tags, tagLookupById]);
const resolvedCorrespondents = useMemo(() => {
if (Array.isArray(correspondents) && correspondents.length) {
return correspondents;
}
return sortCorrespondents(document?.correspondents || []);
}, [correspondents, document?.correspondents]);
if (!document?.correspondents) return [];
return document.correspondents
.map((id) => correspondentLookupById?.get(id))
.sort((a, b) => a.name.localeCompare(b.name));
}, [correspondents, document?.correspondents, correspondentLookupById]);
const extraSummaryRows = useMemo(() => {
const rows: DocumentSummaryRow[] = [];
+18 -7
View File
@@ -1,11 +1,13 @@
import { formatFileSize } from '../../utils/format';
import { formatDateTime as defaultFormatDateTime } from '../../utils/date';
import { DEFAULT_FOLDER_NAME } from '../../app/workspaceUtils';
import type { DocumentTag, DocumentCorrespondent, Document } from '../../types/documents';
import type { Identifier } from '../../types/identifiers';
import type { Correspondent, Document, Tag } from '../../types/documents';
interface DescribeSummaryOptions {
formatDateTime?: typeof defaultFormatDateTime;
tagLookupById?: Map<Identifier, Tag> | null;
correspondentLookupById?: Map<Identifier, Correspondent> | null;
}
type DocumentSummaryRowType = 'text' | 'editable-title' | 'editable-issued' | 'tags' | 'correspondents' | 'folder';
@@ -38,6 +40,8 @@ interface DocumentMetadataPayload {
export const describeDocumentSummary = (document?: Document | null, options: DescribeSummaryOptions = {}): DocumentSummary => {
const {
formatDateTime = defaultFormatDateTime,
tagLookupById,
correspondentLookupById,
} = options;
const formatDateLabel = (value?: string | number | null) => {
@@ -52,11 +56,18 @@ export const describeDocumentSummary = (document?: Document | null, options: Des
const metadata = doc.current_version?.metadata || null;
const pageCount = coercePageCount(metadata);
const pageCountLabel = pageCount !== null ? String(pageCount) : '—';
const folderLabel = doc.folder_id == null ? DEFAULT_FOLDER_NAME : `Folder ${doc.folder_id}`;
const tags = sanitizeArray<DocumentTag>(doc.tags);
const correspondents = sanitizeArray<DocumentCorrespondent>(doc.correspondents);
const tagLabels = tags.map((tag) => tag.label).filter(Boolean) as string[];
const correspondentLabels = correspondents.map((entry) => entry.name).filter(Boolean) as string[];
const folderLabel = doc.folder_id == null ? DEFAULT_FOLDER_NAME : `Folder ${doc.folder_id} `;
const tags = sanitizeArray<Identifier>(doc.tags);
const correspondents = sanitizeArray<Identifier>(doc.correspondents);
const tagLabels = tags
.map((tagId) => tagLookupById?.get(tagId)?.label)
.filter(Boolean) as string[];
const correspondentLabels = correspondents
.map((id) => correspondentLookupById?.get(id)?.name)
.filter(Boolean) as string[];
const tagsSummary = tagLabels.length ? tagLabels.join(', ') : '—';
const correspondentsSummary = correspondentLabels.length ? correspondentLabels.join(', ') : '—';
return [
@@ -7,6 +7,7 @@ import type { EnsureAssetUrl, GetAsset } from '../../lib/assets/AssetManager';
import type { Identifier } from '../../types/identifiers';
import type { Document } from '../../types/documents';
import { resolveBreadcrumbs } from '../../documents/logic/breadcrumbs';
import type { Tag, Correspondent } from '../../types/documents';
interface FolderNode {
id: Identifier | 'root';
@@ -34,7 +35,8 @@ interface UseDetailWorkspaceArgs {
handleCorrespondentRemove?: (...args: unknown[]) => void;
selectFolder?: (folderId?: Identifier | 'root') => void;
tags?: unknown[];
tagLookupById?: Map<Identifier, unknown> | null;
tagLookupById?: Map<Identifier, Tag> | null;
correspondentLookupById?: Map<Identifier, Correspondent> | null;
}
interface UseDetailWorkspaceResult {
@@ -73,6 +75,7 @@ const useDetailWorkspace = ({
selectFolder,
tags,
tagLookupById,
correspondentLookupById,
}: UseDetailWorkspaceArgs): UseDetailWorkspaceResult => {
const {
detailPanelOpen,
@@ -182,6 +185,7 @@ const useDetailWorkspace = ({
document: detailPanelDocument,
tags,
tagLookupById,
correspondentLookupById,
onTagAdd: handleDocumentTagAdd,
onTagRemove: handleDocumentTagDetach,
onOpenPreview: openDocumentPreview,