feat: Refactor tag and correspondent management to use canonical types and dedicated managers.
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
+53
-43
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
@@ -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?.() || '';
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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[] = [];
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user