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 PanelHeader from '../components/PanelHeader';
|
||||||
import { CloseIcon } from '../components/icons';
|
import { CloseIcon } from '../components/icons';
|
||||||
import { CORRESPONDENTS_MODAL, TAGS_MODAL } from '../constants/app';
|
import { CORRESPONDENTS_MODAL, TAGS_MODAL } from '../constants/app';
|
||||||
|
import type { Tag, Correspondent } from '../types/documents';
|
||||||
interface TagRecord {
|
import type { CorrespondentManager } from '../documents/types/workspaceTypes';
|
||||||
id?: string;
|
import type { Identifier } from '../types/identifiers';
|
||||||
label?: string;
|
|
||||||
[key: string]: unknown;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface CorrespondentRecord {
|
|
||||||
id?: string;
|
|
||||||
name?: string;
|
|
||||||
[key: string]: unknown;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface UseManagementModalsArgs {
|
interface UseManagementModalsArgs {
|
||||||
locationPathname?: string;
|
locationPathname?: string;
|
||||||
tags?: TagRecord[];
|
tags?: Tag[];
|
||||||
refreshTags?: () => void | Promise<void>;
|
refreshTags?: () => void | Promise<void>;
|
||||||
onTagCreate?: (...args: any[]) => void | Promise<void>;
|
onTagCreate?: (...args: any[]) => void | Promise<void>;
|
||||||
onTagUpdate?: (...args: any[]) => void | Promise<void>;
|
onTagUpdate?: (...args: any[]) => void | Promise<void>;
|
||||||
onTagDelete?: (...args: any[]) => void | Promise<void>;
|
onTagDelete?: (...args: any[]) => void | Promise<void>;
|
||||||
correspondents?: CorrespondentRecord[];
|
correspondents?: Correspondent[];
|
||||||
|
correspondentLookupById?: Map<Identifier, Correspondent> | null;
|
||||||
refreshCorrespondents?: () => void | Promise<void>;
|
refreshCorrespondents?: () => void | Promise<void>;
|
||||||
onCorrespondentCreate?: (...args: any[]) => void | Promise<void>;
|
onCorrespondentCreate?: (...args: any[]) => void | Promise<void>;
|
||||||
onCorrespondentUpdate?: (...args: any[]) => void | Promise<void>;
|
onCorrespondentUpdate?: (...args: any[]) => void | Promise<void>;
|
||||||
onCorrespondentDelete?: (...args: any[]) => void | Promise<void>;
|
onCorrespondentDelete?: (...args: any[]) => void | Promise<void>;
|
||||||
|
correspondentManager?: CorrespondentManager | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface UseManagementModalsResult {
|
interface UseManagementModalsResult {
|
||||||
|
|||||||
@@ -167,6 +167,7 @@ export const useWorkspaceSurface = ({
|
|||||||
onTagAdd,
|
onTagAdd,
|
||||||
onTagRemove,
|
onTagRemove,
|
||||||
correspondents,
|
correspondents,
|
||||||
|
correspondentLookupById,
|
||||||
onCorrespondentAdd,
|
onCorrespondentAdd,
|
||||||
onCorrespondentRemove,
|
onCorrespondentRemove,
|
||||||
onUpdateTitle,
|
onUpdateTitle,
|
||||||
@@ -185,6 +186,7 @@ export const useWorkspaceSurface = ({
|
|||||||
onTagAdd={onTagAdd}
|
onTagAdd={onTagAdd}
|
||||||
onTagRemove={onTagRemove}
|
onTagRemove={onTagRemove}
|
||||||
correspondents={correspondents}
|
correspondents={correspondents}
|
||||||
|
correspondentLookupById={correspondentLookupById}
|
||||||
onCorrespondentAdd={onCorrespondentAdd}
|
onCorrespondentAdd={onCorrespondentAdd}
|
||||||
onCorrespondentRemove={onCorrespondentRemove}
|
onCorrespondentRemove={onCorrespondentRemove}
|
||||||
onUpdateTitle={onUpdateTitle}
|
onUpdateTitle={onUpdateTitle}
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import { LayoutCard } from '../logic/LayoutSystem';
|
|||||||
import { useCardPointer } from '../interactions/useCardPointer';
|
import { useCardPointer } from '../interactions/useCardPointer';
|
||||||
import DocumentTags from '../../documents/components/DocumentTags';
|
import DocumentTags from '../../documents/components/DocumentTags';
|
||||||
import { TagInteractionHandlers } from '../../documents/interactions/useTagInteractions';
|
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) => {
|
const preventAll = (event?: React.SyntheticEvent | Event | null) => {
|
||||||
if (!event) return;
|
if (!event) return;
|
||||||
@@ -38,8 +40,6 @@ const DesktopDocumentCard: React.FC<DesktopDocumentCardProps> = ({
|
|||||||
matchesFilter = true,
|
matchesFilter = true,
|
||||||
selected = false,
|
selected = false,
|
||||||
docTagTokens,
|
docTagTokens,
|
||||||
ensureAssetUrl,
|
|
||||||
getDocumentAsset,
|
|
||||||
onDocumentActivate,
|
onDocumentActivate,
|
||||||
onSelect,
|
onSelect,
|
||||||
onDeselect,
|
onDeselect,
|
||||||
@@ -48,6 +48,12 @@ const DesktopDocumentCard: React.FC<DesktopDocumentCardProps> = ({
|
|||||||
tagHandlers,
|
tagHandlers,
|
||||||
layoutCard,
|
layoutCard,
|
||||||
}) => {
|
}) => {
|
||||||
|
const {
|
||||||
|
ensureAssetUrl,
|
||||||
|
getDocumentAsset
|
||||||
|
} = useDocumentsAssetContext();
|
||||||
|
|
||||||
|
const { tagLookupById, correspondentLookupById } = useDocumentsViewStateContext();
|
||||||
const cardPointerHandlers = useCardPointer(
|
const cardPointerHandlers = useCardPointer(
|
||||||
layoutCard,
|
layoutCard,
|
||||||
!!selected,
|
!!selected,
|
||||||
@@ -58,7 +64,7 @@ const DesktopDocumentCard: React.FC<DesktopDocumentCardProps> = ({
|
|||||||
requestCanvasFocus
|
requestCanvasFocus
|
||||||
);
|
);
|
||||||
|
|
||||||
const correspondents = useMemo(() => resolveCorrespondents(doc), [doc]);
|
const correspondents = useMemo(() => resolveCorrespondents(doc, correspondentLookupById), [doc, correspondentLookupById]);
|
||||||
const tags = Array.isArray(doc?.tags) ? doc.tags : [];
|
const tags = Array.isArray(doc?.tags) ? doc.tags : [];
|
||||||
|
|
||||||
const itemClasses = ['desk-item'];
|
const itemClasses = ['desk-item'];
|
||||||
@@ -116,6 +122,7 @@ const DesktopDocumentCard: React.FC<DesktopDocumentCardProps> = ({
|
|||||||
<DocumentTags
|
<DocumentTags
|
||||||
doc={doc}
|
doc={doc}
|
||||||
tags={tags}
|
tags={tags}
|
||||||
|
tagLookupById={tagLookupById}
|
||||||
tagHandlers={tagHandlers}
|
tagHandlers={tagHandlers}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
import { shallowEqual } from 'react-redux';
|
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>;
|
type FetchDocument = (id: DocumentId) => Promise<unknown>;
|
||||||
|
|
||||||
@@ -11,6 +14,8 @@ class DocumentsManager<T extends ManagedDocument = ManagedDocument> {
|
|||||||
private inflight: Map<DocumentId, Promise<T | null>>;
|
private inflight: Map<DocumentId, Promise<T | null>>;
|
||||||
private listeners: Set<() => void>;
|
private listeners: Set<() => void>;
|
||||||
private emitScheduled: boolean;
|
private emitScheduled: boolean;
|
||||||
|
private tagManager?: TagManager;
|
||||||
|
private correspondentManager?: CorrespondentManager;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
fetchDocument?: FetchDocument,
|
fetchDocument?: FetchDocument,
|
||||||
@@ -22,6 +27,14 @@ class DocumentsManager<T extends ManagedDocument = ManagedDocument> {
|
|||||||
this.emitScheduled = false;
|
this.emitScheduled = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setTagManager(tagManager: TagManager) {
|
||||||
|
this.tagManager = tagManager;
|
||||||
|
}
|
||||||
|
|
||||||
|
setCorrespondentManager(correspondentManager: CorrespondentManager) {
|
||||||
|
this.correspondentManager = correspondentManager;
|
||||||
|
}
|
||||||
|
|
||||||
private emit() {
|
private emit() {
|
||||||
if (this.emitScheduled) {
|
if (this.emitScheduled) {
|
||||||
return;
|
return;
|
||||||
@@ -55,6 +68,43 @@ class DocumentsManager<T extends ManagedDocument = ManagedDocument> {
|
|||||||
return;
|
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 existing = nextById.get(id as DocumentId);
|
||||||
const merged = existing ? ({ ...existing, ...doc } as T) : ({ ...(doc as T) } as T);
|
const merged = existing ? ({ ...existing, ...doc } as T) : ({ ...(doc as T) } as T);
|
||||||
const useExisting = existing && shallowEqual(existing, merged);
|
const useExisting = existing && shallowEqual(existing, merged);
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import React, { useMemo } from 'react';
|
import React, { useMemo } from 'react';
|
||||||
import { getTagColorStyle } from '../../utils/colors';
|
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 { Identifier } from '../../types/identifiers';
|
||||||
import type { TagInteractionHandlers } from '../interactions/useTagInteractions';
|
import type { TagInteractionHandlers } from '../interactions/useTagInteractions';
|
||||||
|
|
||||||
interface DocumentTagsProps {
|
interface DocumentTagsProps {
|
||||||
tags: DocumentTag[];
|
tags: Identifier[];
|
||||||
tagLookupById?: Map<Identifier, DocumentTag> | null;
|
tagLookupById?: Map<Identifier, Tag> | null;
|
||||||
doc: Document;
|
doc: Document;
|
||||||
tagHandlers?: TagInteractionHandlers;
|
tagHandlers?: TagInteractionHandlers;
|
||||||
}
|
}
|
||||||
@@ -17,24 +17,29 @@ const DocumentTags: React.FC<DocumentTagsProps> = ({
|
|||||||
doc,
|
doc,
|
||||||
tagHandlers,
|
tagHandlers,
|
||||||
}) => {
|
}) => {
|
||||||
const sortedTags = useMemo(() => {
|
const resolvedTags = useMemo(() => {
|
||||||
return [...tags].sort((a, b) => {
|
if (!tags) return [];
|
||||||
const labelA = (a.label || '').toLowerCase();
|
return tags
|
||||||
const labelB = (b.label || '').toLowerCase();
|
.map(id => tagLookupById?.get(id))
|
||||||
return labelA.localeCompare(labelB);
|
.filter((tag): tag is Tag => Boolean(tag))
|
||||||
});
|
.sort((a, b) => {
|
||||||
}, [tags]);
|
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 null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{sortedTags.map((tag, index) => {
|
{resolvedTags.map((tag, index) => {
|
||||||
const colorSource = tag?.color || tagLookupById?.get(tag.id)?.color;
|
const { color, label, id } = tag;
|
||||||
const style = getTagColorStyle(colorSource);
|
const tagId = id;
|
||||||
const tagId = tag?.id ?? null;
|
|
||||||
|
const style = getTagColorStyle(color);
|
||||||
const clickable = tagId != null && typeof tagHandlers?.onTagClick === 'function';
|
const clickable = tagId != null && typeof tagHandlers?.onTagClick === 'function';
|
||||||
const draggable = !!tagId;
|
const draggable = !!tagId;
|
||||||
const key = tagId ?? `${doc.id}-tag-${index}`;
|
const key = tagId ?? `${doc.id}-tag-${index}`;
|
||||||
@@ -42,16 +47,16 @@ const DocumentTags: React.FC<DocumentTagsProps> = ({
|
|||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
key={key}
|
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}
|
style={style || undefined}
|
||||||
title={tag.label}
|
title={label || ''}
|
||||||
role={clickable ? 'button' : undefined}
|
role={clickable ? 'button' : undefined}
|
||||||
onClick={clickable ? (event) => {
|
onClick={clickable ? (event) => {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
if (tagId == null) return;
|
if (tagId == null) return;
|
||||||
tagHandlers?.onTagClick?.(tagId);
|
tagHandlers?.onTagClick?.(tagId);
|
||||||
} : undefined}
|
} : undefined}
|
||||||
draggable={!!tagId}
|
draggable={draggable}
|
||||||
onDragStart={(event) => tagId && tagHandlers?.onTagDragStart(event, doc, tag)}
|
onDragStart={(event) => tagId && tagHandlers?.onTagDragStart(event, doc, tag)}
|
||||||
onDragEnd={tagHandlers?.onTagDragEnd}
|
onDragEnd={tagHandlers?.onTagDragEnd}
|
||||||
onKeyDown={clickable ? (event) => {
|
onKeyDown={clickable ? (event) => {
|
||||||
@@ -63,7 +68,7 @@ const DocumentTags: React.FC<DocumentTagsProps> = ({
|
|||||||
}
|
}
|
||||||
} : undefined}
|
} : undefined}
|
||||||
>
|
>
|
||||||
{tag.label}
|
{label}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ interface DocumentsGridCardProps {
|
|||||||
const DocumentsGridCard: React.FC<DocumentsGridCardProps> = (props) => {
|
const DocumentsGridCard: React.FC<DocumentsGridCardProps> = (props) => {
|
||||||
const { entry, iconSize, tagHandlers } = props;
|
const { entry, iconSize, tagHandlers } = props;
|
||||||
const { ensureAssetUrl, getDocumentAsset } = useDocumentsAssetContext();
|
const { ensureAssetUrl, getDocumentAsset } = useDocumentsAssetContext();
|
||||||
const { scrollRef, activeCorrespondentIdSet, tagLookupById } = useDocumentsViewStateContext();
|
const { scrollRef, activeCorrespondentIdSet, tagLookupById, correspondentLookupById } = useDocumentsViewStateContext();
|
||||||
const {
|
const {
|
||||||
correspondents: { onClick: onCorrespondentClick },
|
correspondents: { onClick: onCorrespondentClick },
|
||||||
} = useDocumentsCommandContext();
|
} = useDocumentsCommandContext();
|
||||||
@@ -74,7 +74,7 @@ const DocumentsGridCard: React.FC<DocumentsGridCardProps> = (props) => {
|
|||||||
const doc = entry.document;
|
const doc = entry.document;
|
||||||
if (!doc) return null;
|
if (!doc) return null;
|
||||||
|
|
||||||
const correspondents = resolveCorrespondents(doc);
|
const correspondents = resolveCorrespondents(doc, correspondentLookupById);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DocumentEntry
|
<DocumentEntry
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ interface DocumentsListRowProps {
|
|||||||
const DocumentsListRow: React.FC<DocumentsListRowProps> = (props) => {
|
const DocumentsListRow: React.FC<DocumentsListRowProps> = (props) => {
|
||||||
const { entry, iconSize, tagHandlers } = props;
|
const { entry, iconSize, tagHandlers } = props;
|
||||||
const { ensureAssetUrl, getDocumentAsset } = useDocumentsAssetContext();
|
const { ensureAssetUrl, getDocumentAsset } = useDocumentsAssetContext();
|
||||||
const { scrollRef, activeCorrespondentIdSet, tagLookupById } = useDocumentsViewStateContext();
|
const { scrollRef, activeCorrespondentIdSet, tagLookupById, correspondentLookupById } = useDocumentsViewStateContext();
|
||||||
const {
|
const {
|
||||||
correspondents: { onClick: onCorrespondentClick },
|
correspondents: { onClick: onCorrespondentClick },
|
||||||
} = useDocumentsCommandContext();
|
} = useDocumentsCommandContext();
|
||||||
@@ -83,7 +83,7 @@ const DocumentsListRow: React.FC<DocumentsListRowProps> = (props) => {
|
|||||||
const doc = entry.document;
|
const doc = entry.document;
|
||||||
if (!doc) return null;
|
if (!doc) return null;
|
||||||
|
|
||||||
const correspondents = resolveCorrespondents(doc);
|
const correspondents = resolveCorrespondents(doc, correspondentLookupById);
|
||||||
const issuedLabel = formatDate(doc.issued_at);
|
const issuedLabel = formatDate(doc.issued_at);
|
||||||
const addedLabel = formatDate(doc.created_at || doc.uploaded_at);
|
const addedLabel = formatDate(doc.created_at || doc.uploaded_at);
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import { createContext, useContext, type RefObject } from 'react';
|
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';
|
import type { Identifier } from '../../types/identifiers';
|
||||||
|
|
||||||
interface DocumentsViewStateContextValue {
|
interface DocumentsViewStateContextValue {
|
||||||
viewId?: string | null;
|
viewId?: string | null;
|
||||||
scrollRef?: RefObject<HTMLElement | null>;
|
scrollRef?: RefObject<HTMLElement | null>;
|
||||||
tagLookupById?: Map<Identifier, DocumentTag> | null;
|
tagLookupById?: Map<Identifier, Tag> | null;
|
||||||
|
correspondentLookupById?: Map<Identifier, Correspondent> | null;
|
||||||
activeCorrespondentIdSet?: Set<Identifier> | null;
|
activeCorrespondentIdSet?: Set<Identifier> | null;
|
||||||
draggingDocumentIdsSet?: Set<Identifier> | null;
|
draggingDocumentIdsSet?: Set<Identifier> | null;
|
||||||
draggedFolderId?: Identifier | 'root' | 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 {
|
export const resolveCorrespondents = (
|
||||||
id?: string | null;
|
doc?: Document | null,
|
||||||
name: string;
|
lookup?: Map<Identifier, Correspondent> | null
|
||||||
key: string;
|
): Correspondent[] => {
|
||||||
}
|
|
||||||
|
|
||||||
export const resolveCorrespondents = (doc?: Document | null): ResolvedCorrespondent[] => {
|
|
||||||
if (!doc || !Array.isArray(doc.correspondents)) {
|
if (!doc || !Array.isArray(doc.correspondents)) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
const seen = new Set<string>();
|
const seen = new Set<Identifier>();
|
||||||
const results: ResolvedCorrespondent[] = [];
|
const results: Correspondent[] = [];
|
||||||
|
|
||||||
doc.correspondents.forEach((entry = {}, index) => {
|
doc.correspondents.forEach((id) => {
|
||||||
const { id, name } = entry;
|
if (!id) return;
|
||||||
const trimmedName = name?.trim?.();
|
if (seen.has(id)) return;
|
||||||
if (!trimmedName) {
|
seen.add(id);
|
||||||
return;
|
|
||||||
|
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) {
|
if (target.id) {
|
||||||
const targetSet = new Set(targets);
|
const targetSet = new Set(targets);
|
||||||
let targetId = target.id;
|
|
||||||
let targetName = (target as any).name;
|
|
||||||
|
|
||||||
documentsManager.map((doc) => {
|
documentsManager.map((doc) => {
|
||||||
if (!targetSet.has(doc.id as Identifier)) return undefined;
|
if (!targetSet.has(doc.id as Identifier)) return undefined;
|
||||||
|
|
||||||
const current = Array.isArray((doc as any).correspondents) ? (doc as any).correspondents : [];
|
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;
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
...(doc as any),
|
...(doc as any),
|
||||||
correspondents: [...current, { id: targetId, name: targetName }],
|
correspondents: [...current, target.id],
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -158,8 +156,8 @@ const useBulkDocumentActions = ({
|
|||||||
return doc;
|
return doc;
|
||||||
}
|
}
|
||||||
const filtered = (doc as any).correspondents.filter(
|
const filtered = (doc as any).correspondents.filter(
|
||||||
(entry: any) =>
|
(id: Identifier) =>
|
||||||
entry && !normalizedAssignments.some((assignment) => assignment.correspondent_id === entry.id),
|
!normalizedAssignments.some((assignment) => assignment.correspondent_id === id),
|
||||||
);
|
);
|
||||||
return filtered.length === (doc as any).correspondents.length
|
return filtered.length === (doc as any).correspondents.length
|
||||||
? doc
|
? doc
|
||||||
|
|||||||
@@ -1,53 +1,50 @@
|
|||||||
import { MutableRefObject, useCallback, useState } from 'react';
|
import { useCallback, useSyncExternalStore } from 'react';
|
||||||
import { useStatusToast } from '../../lib/context/StatusToastContext';
|
import { useStatusToast } from '../../lib/context/StatusToastContext';
|
||||||
import type { Correspondent } from '../../types/documents';
|
import type { Correspondent } from '../../types/documents';
|
||||||
|
import type { Identifier } from '../../types/identifiers';
|
||||||
import { listCorrespondents, createCorrespondent, updateCorrespondent, deleteCorrespondent } from '../../lib/api/apiClient';
|
import type CorrespondentManager from '../../lib/assets/CorrespondentManager';
|
||||||
|
|
||||||
import useNotifyApiError from '../../hooks/useNotifyApiError';
|
import useNotifyApiError from '../../hooks/useNotifyApiError';
|
||||||
|
|
||||||
interface UseCorrespondentsOptions {
|
interface UseCorrespondentsOptions {
|
||||||
tenantIdRef: MutableRefObject<string | null>;
|
correspondentManager: CorrespondentManager;
|
||||||
documentsManager?: { map: (mapper: (doc: any) => any) => void };
|
documentsManager?: { map: (mapper: (doc: any) => any) => void };
|
||||||
}
|
}
|
||||||
|
|
||||||
const useCorrespondents = ({
|
const useCorrespondents = ({
|
||||||
tenantIdRef,
|
correspondentManager,
|
||||||
documentsManager,
|
documentsManager,
|
||||||
}: UseCorrespondentsOptions) => {
|
}: UseCorrespondentsOptions) => {
|
||||||
const [correspondents, setCorrespondents] = useState<Correspondent[]>([]);
|
|
||||||
const { showToast } = useStatusToast();
|
const { showToast } = useStatusToast();
|
||||||
const notifyApiError = useNotifyApiError();
|
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 refreshCorrespondents = useCallback(async () => {
|
||||||
const requestTenantId = tenantIdRef.current;
|
|
||||||
try {
|
try {
|
||||||
const data = await listCorrespondents();
|
await correspondentManager.ensureAll(true);
|
||||||
if (tenantIdRef.current !== requestTenantId) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setCorrespondents(data || []);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (tenantIdRef.current !== requestTenantId) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
notifyApiError(error, 'Unable to load correspondents.');
|
notifyApiError(error, 'Unable to load correspondents.');
|
||||||
}
|
}
|
||||||
}, [notifyApiError, tenantIdRef]);
|
}, [notifyApiError, correspondentManager]);
|
||||||
|
|
||||||
const handleCorrespondentUpdate = useCallback(
|
const handleCorrespondentUpdate = useCallback(
|
||||||
async (correspondentId: string, changes: { name?: string }) => {
|
async (correspondentId: Identifier, changes: { name?: string }) => {
|
||||||
if (correspondentId == null) {
|
if (correspondentId == null) {
|
||||||
throw new Error('Missing correspondent identifier.');
|
throw new Error('Missing correspondent identifier.');
|
||||||
}
|
}
|
||||||
|
|
||||||
const payload: Record<string, unknown> = {};
|
const payload: Record<string, unknown> = {};
|
||||||
if (changes?.name != null) {
|
if (changes?.name != null) {
|
||||||
const trimmed = changes.name.trim();
|
payload.name = changes.name;
|
||||||
if (!trimmed) {
|
|
||||||
throw new Error('Correspondent name cannot be empty.');
|
|
||||||
}
|
|
||||||
payload.name = trimmed;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Object.keys(payload).length === 0) {
|
if (Object.keys(payload).length === 0) {
|
||||||
@@ -55,8 +52,7 @@ const useCorrespondents = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await updateCorrespondent(correspondentId, payload);
|
await correspondentManager.update(correspondentId, payload);
|
||||||
await refreshCorrespondents();
|
|
||||||
showToast('Correspondent updated.', 'success');
|
showToast('Correspondent updated.', 'success');
|
||||||
return true;
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -65,18 +61,14 @@ const useCorrespondents = ({
|
|||||||
throw new Error(message);
|
throw new Error(message);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[notifyApiError, refreshCorrespondents, showToast],
|
[notifyApiError, correspondentManager, showToast],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleCorrespondentCreate = useCallback(
|
const handleCorrespondentCreate = useCallback(
|
||||||
async ({ name }: { name?: string }) => {
|
async ({ name }: { name?: string }) => {
|
||||||
const trimmed = name?.trim?.() || '';
|
|
||||||
if (!trimmed) {
|
|
||||||
throw new Error('Correspondent name is required.');
|
|
||||||
}
|
|
||||||
try {
|
try {
|
||||||
const data = await createCorrespondent({ name: trimmed });
|
const payload = correspondentManager.buildPayload({ name });
|
||||||
await refreshCorrespondents();
|
const data = await correspondentManager.create(payload);
|
||||||
showToast('Correspondent created.', 'success');
|
showToast('Correspondent created.', 'success');
|
||||||
return data;
|
return data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -85,29 +77,29 @@ const useCorrespondents = ({
|
|||||||
throw new Error(message);
|
throw new Error(message);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[notifyApiError, refreshCorrespondents, showToast],
|
[notifyApiError, correspondentManager, showToast],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleCorrespondentDelete = useCallback(
|
const handleCorrespondentDelete = useCallback(
|
||||||
async (correspondentId: string) => {
|
async (correspondentId: Identifier) => {
|
||||||
if (correspondentId == null) {
|
if (correspondentId == null) {
|
||||||
throw new Error('Missing correspondent identifier.');
|
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 {
|
try {
|
||||||
await deleteCorrespondent(correspondentId);
|
await correspondentManager.delete(correspondentId);
|
||||||
await refreshCorrespondents();
|
|
||||||
|
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);
|
documentsManager?.map(stripFromDoc);
|
||||||
|
|
||||||
@@ -119,16 +111,16 @@ const useCorrespondents = ({
|
|||||||
throw new Error(message);
|
throw new Error(message);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[documentsManager, notifyApiError, refreshCorrespondents, showToast],
|
[documentsManager, notifyApiError, correspondentManager, showToast],
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
correspondents,
|
correspondents,
|
||||||
|
correspondentLookupById: correspondentsSnapshot,
|
||||||
refreshCorrespondents,
|
refreshCorrespondents,
|
||||||
handleCorrespondentCreate,
|
handleCorrespondentCreate,
|
||||||
handleCorrespondentUpdate,
|
handleCorrespondentUpdate,
|
||||||
handleCorrespondentDelete,
|
handleCorrespondentDelete,
|
||||||
setCorrespondents,
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
+53
-43
@@ -1,35 +1,34 @@
|
|||||||
import { useCallback, useMemo } from 'react';
|
import { useCallback, useMemo } from 'react';
|
||||||
import { useStatusToast } from '../../../lib/context/StatusToastContext';
|
import { useStatusToast } from '../../lib/context/StatusToastContext';
|
||||||
import type { Identifier } from '../../../types/identifiers';
|
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 {
|
interface UseDocumentCorrespondentMutationsArgs {
|
||||||
id?: string;
|
correspondentsState: CorrespondentsState;
|
||||||
name?: string;
|
documentsState: Pick<DocumentsState, 'documentsManager'>;
|
||||||
[key: string]: unknown;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
import useNotifyApiError from '../../../hooks/useNotifyApiError';
|
const useDocumentCorrespondentMutations = ({
|
||||||
import type { DocumentsManagerInterface } from '../../types/workspaceTypes';
|
correspondentsState,
|
||||||
|
documentsState,
|
||||||
interface UseDocumentCorrespondentActionsArgs {
|
}: UseDocumentCorrespondentMutationsArgs) => {
|
||||||
correspondents: CorrespondentOption[];
|
|
||||||
handleCorrespondentCreate: (payload: { name: string }) => Promise<CorrespondentOption | null>;
|
|
||||||
documentsManager: DocumentsManagerInterface;
|
|
||||||
}
|
|
||||||
|
|
||||||
const useDocumentCorrespondentActions = ({
|
|
||||||
correspondents,
|
|
||||||
handleCorrespondentCreate,
|
|
||||||
documentsManager,
|
|
||||||
}: UseDocumentCorrespondentActionsArgs) => {
|
|
||||||
const { showToast } = useStatusToast();
|
const { showToast } = useStatusToast();
|
||||||
const notifyApiError = useNotifyApiError();
|
const notifyApiError = useNotifyApiError();
|
||||||
|
|
||||||
|
const {
|
||||||
|
correspondents,
|
||||||
|
correspondentManager,
|
||||||
|
} = correspondentsState;
|
||||||
|
|
||||||
|
const { documentsManager } = documentsState;
|
||||||
|
|
||||||
const correspondentLookupByName = useMemo(() => {
|
const correspondentLookupByName = useMemo(() => {
|
||||||
const map = new Map<string, CorrespondentOption>();
|
const map = new Map<string, Correspondent>();
|
||||||
correspondents.forEach((correspondent) => {
|
correspondents.forEach((correspondent) => {
|
||||||
if (correspondent?.name) {
|
if (correspondent?.name) {
|
||||||
map.set(correspondent.name.toLowerCase(), correspondent);
|
map.set(correspondent.name.toLowerCase(), correspondent);
|
||||||
@@ -43,8 +42,7 @@ const useDocumentCorrespondentActions = ({
|
|||||||
{
|
{
|
||||||
documentId,
|
documentId,
|
||||||
correspondentId,
|
correspondentId,
|
||||||
correspondent,
|
}: { documentId: Identifier; correspondentId: Identifier; correspondent?: Correspondent | Partial<Correspondent> | null },
|
||||||
}: { documentId: Identifier; correspondentId: Identifier; correspondent?: CorrespondentOption | null },
|
|
||||||
{ notify = true }: { notify?: boolean } = {},
|
{ notify = true }: { notify?: boolean } = {},
|
||||||
) => {
|
) => {
|
||||||
if (documentId == null || correspondentId == null) {
|
if (documentId == null || correspondentId == null) {
|
||||||
@@ -53,21 +51,14 @@ const useDocumentCorrespondentActions = ({
|
|||||||
try {
|
try {
|
||||||
await addDocumentCorrespondent(documentId, correspondentId);
|
await addDocumentCorrespondent(documentId, correspondentId);
|
||||||
|
|
||||||
const resolved = correspondent
|
|
||||||
|| correspondents.find((entry) => entry?.id === correspondentId)
|
|
||||||
|| null;
|
|
||||||
|
|
||||||
documentsManager.map((doc) => {
|
documentsManager.map((doc) => {
|
||||||
if (doc.id !== documentId) return undefined;
|
if (doc.id !== documentId) return undefined;
|
||||||
|
|
||||||
const current = Array.isArray(doc.correspondents) ? doc.correspondents : [];
|
const current = Array.isArray(doc.correspondents) ? doc.correspondents : [];
|
||||||
if (current.some((entry) => entry?.id === correspondentId)) {
|
if (current.includes(correspondentId)) {
|
||||||
return doc;
|
return doc;
|
||||||
}
|
}
|
||||||
const nextEntry = resolved?.name
|
return { ...doc, correspondents: [...current, correspondentId] };
|
||||||
? { id: resolved.id ?? correspondentId, name: resolved.name }
|
|
||||||
: { id: correspondentId };
|
|
||||||
return { ...doc, correspondents: [...current, nextEntry] };
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (notify) {
|
if (notify) {
|
||||||
@@ -80,10 +71,10 @@ const useDocumentCorrespondentActions = ({
|
|||||||
throw new Error(message);
|
throw new Error(message);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[correspondents, notifyApiError, showToast, documentsManager],
|
[notifyApiError, showToast, documentsManager],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleCorrespondentRemove = useCallback(
|
const handleDocumentCorrespondentDetach = useCallback(
|
||||||
async (
|
async (
|
||||||
{ documentId, correspondentId }: { documentId: Identifier; correspondentId: Identifier },
|
{ documentId, correspondentId }: { documentId: Identifier; correspondentId: Identifier },
|
||||||
{ notify = true }: { notify?: boolean } = {},
|
{ notify = true }: { notify?: boolean } = {},
|
||||||
@@ -99,7 +90,8 @@ const useDocumentCorrespondentActions = ({
|
|||||||
if (!doc || !Array.isArray(doc.correspondents)) {
|
if (!doc || !Array.isArray(doc.correspondents)) {
|
||||||
return doc;
|
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 };
|
return filtered.length === doc.correspondents.length ? doc : { ...doc, correspondents: filtered };
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -117,8 +109,8 @@ const useDocumentCorrespondentActions = ({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const normalizeOption = (
|
const normalizeOption = (
|
||||||
option: CorrespondentOption | string | null,
|
option: Correspondent | Partial<Correspondent> | string | null,
|
||||||
): CorrespondentOption | null => {
|
): Correspondent | Partial<Correspondent> | null => {
|
||||||
if (!option) {
|
if (!option) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -132,8 +124,18 @@ const useDocumentCorrespondentActions = ({
|
|||||||
return option;
|
return option;
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCorrespondentAdd = useCallback(
|
const handleCorrespondentCreate = useCallback(
|
||||||
async ({ document, name, input = null, option = null }: { document?: { id?: string }; name?: string; input?: HTMLInputElement | null; option?: CorrespondentOption | string | null }) => {
|
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) {
|
if (!document?.id) {
|
||||||
throw new Error('Missing document for correspondent assignment.');
|
throw new Error('Missing document for correspondent assignment.');
|
||||||
}
|
}
|
||||||
@@ -147,7 +149,15 @@ const useDocumentCorrespondentActions = ({
|
|||||||
if (!target) {
|
if (!target) {
|
||||||
try {
|
try {
|
||||||
target = await handleCorrespondentCreate({ name: trimmed });
|
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 {
|
} catch {
|
||||||
|
showToast('Failed to create correspondent.', 'error');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -182,9 +192,9 @@ const useDocumentCorrespondentActions = ({
|
|||||||
return {
|
return {
|
||||||
correspondentLookupByName,
|
correspondentLookupByName,
|
||||||
handleDocumentCorrespondentAttach,
|
handleDocumentCorrespondentAttach,
|
||||||
handleCorrespondentRemove,
|
handleDocumentCorrespondentDetach, // Renamed from handleCorrespondentRemove
|
||||||
handleCorrespondentAdd,
|
handleDocumentCorrespondentAdd, // Renamed from handleCorrespondentAdd
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export default useDocumentCorrespondentActions;
|
export default useDocumentCorrespondentMutations;
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useCallback } from 'react';
|
import { useCallback } from 'react';
|
||||||
import { useStatusToast } from '../../lib/context/StatusToastContext';
|
import { useStatusToast } from '../../lib/context/StatusToastContext';
|
||||||
|
import useNotifyApiError from '../../hooks/useNotifyApiError';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
queueDocumentReanalysis,
|
queueDocumentReanalysis,
|
||||||
@@ -15,26 +16,26 @@ import type {
|
|||||||
FolderState,
|
FolderState,
|
||||||
SelectionState,
|
SelectionState,
|
||||||
TagsState,
|
TagsState,
|
||||||
|
CorrespondentsState,
|
||||||
ActionsState,
|
ActionsState,
|
||||||
Tag,
|
|
||||||
} from '../types/workspaceTypes';
|
} from '../types/workspaceTypes';
|
||||||
|
import type { Tag, Correspondent } from '../../types/documents';
|
||||||
|
import useDocumentCorrespondentMutations from './useDocumentCorrespondentMutations';
|
||||||
|
|
||||||
type FolderId = FolderIdentifier | 'root';
|
type FolderId = FolderIdentifier | 'root';
|
||||||
type NullableFolderId = FolderId | null;
|
type NullableFolderId = FolderId | null;
|
||||||
|
|
||||||
|
|
||||||
interface DocumentTagExtras {
|
interface DocumentTagExtras {
|
||||||
option?: Tag | null;
|
option?: Tag | null;
|
||||||
input?: { value?: string } | null;
|
input?: { value?: string } | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
import useNotifyApiError from '../../hooks/useNotifyApiError';
|
|
||||||
|
|
||||||
interface UseDocumentMutationsArgs {
|
interface UseDocumentMutationsArgs {
|
||||||
documentsState: DocumentsState;
|
documentsState: DocumentsState;
|
||||||
folderState: FolderState;
|
folderState: FolderState;
|
||||||
selectionState: SelectionState;
|
selectionState: SelectionState;
|
||||||
tagsState: TagsState;
|
tagsState: TagsState;
|
||||||
|
correspondentsState: CorrespondentsState;
|
||||||
actions: ActionsState;
|
actions: ActionsState;
|
||||||
previewDocumentId?: DocumentId | null;
|
previewDocumentId?: DocumentId | null;
|
||||||
}
|
}
|
||||||
@@ -64,6 +65,10 @@ interface UseDocumentMutationsResult {
|
|||||||
documentId?: DocumentId,
|
documentId?: DocumentId,
|
||||||
tagId?: DocumentId,
|
tagId?: DocumentId,
|
||||||
) => Promise<boolean>;
|
) => 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 = ({
|
const useDocumentMutations = ({
|
||||||
@@ -71,6 +76,7 @@ const useDocumentMutations = ({
|
|||||||
folderState,
|
folderState,
|
||||||
selectionState,
|
selectionState,
|
||||||
tagsState,
|
tagsState,
|
||||||
|
correspondentsState,
|
||||||
actions,
|
actions,
|
||||||
previewDocumentId,
|
previewDocumentId,
|
||||||
}: UseDocumentMutationsArgs): UseDocumentMutationsResult => {
|
}: UseDocumentMutationsArgs): UseDocumentMutationsResult => {
|
||||||
@@ -92,6 +98,16 @@ const useDocumentMutations = ({
|
|||||||
documentsState: { documentsManager: documentsState.documentsManager },
|
documentsState: { documentsManager: documentsState.documentsManager },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const {
|
||||||
|
handleDocumentCorrespondentAttach,
|
||||||
|
handleDocumentCorrespondentDetach,
|
||||||
|
handleDocumentCorrespondentAdd,
|
||||||
|
correspondentLookupByName,
|
||||||
|
} = useDocumentCorrespondentMutations({
|
||||||
|
correspondentsState,
|
||||||
|
documentsState: { documentsManager: documentsState.documentsManager },
|
||||||
|
});
|
||||||
|
|
||||||
const handleThumbnailRegeneration = useCallback(
|
const handleThumbnailRegeneration = useCallback(
|
||||||
async (documentId: DocumentId) => {
|
async (documentId: DocumentId) => {
|
||||||
try {
|
try {
|
||||||
@@ -221,6 +237,10 @@ const useDocumentMutations = ({
|
|||||||
handleDocumentTitleUpdate,
|
handleDocumentTitleUpdate,
|
||||||
handleDocumentIssuedUpdate,
|
handleDocumentIssuedUpdate,
|
||||||
handleDocumentTagDetach,
|
handleDocumentTagDetach,
|
||||||
|
handleDocumentCorrespondentAttach,
|
||||||
|
handleDocumentCorrespondentDetach,
|
||||||
|
handleDocumentCorrespondentAdd,
|
||||||
|
correspondentLookupByName,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useCallback } from 'react';
|
import { useCallback } from 'react';
|
||||||
import type { DocumentId } from '../../types/identifiers';
|
import type { DocumentId } from '../../types/identifiers';
|
||||||
import type { Document } from '../../types/documents';
|
import type { Document, Tag } from '../../types/documents';
|
||||||
import {
|
import {
|
||||||
addDocumentTags,
|
addDocumentTags,
|
||||||
createTag,
|
createTag,
|
||||||
@@ -8,11 +8,7 @@ import {
|
|||||||
} from '../../lib/api/apiClient';
|
} from '../../lib/api/apiClient';
|
||||||
import useNotifyApiError from '../../hooks/useNotifyApiError';
|
import useNotifyApiError from '../../hooks/useNotifyApiError';
|
||||||
import { useStatusToast } from '../../lib/context/StatusToastContext';
|
import { useStatusToast } from '../../lib/context/StatusToastContext';
|
||||||
import type {
|
import type { TagsState, DocumentsState } from '../types/workspaceTypes';
|
||||||
TagsState,
|
|
||||||
DocumentsState,
|
|
||||||
Tag,
|
|
||||||
} from '../types/workspaceTypes';
|
|
||||||
|
|
||||||
interface DocumentTagExtras {
|
interface DocumentTagExtras {
|
||||||
option?: Tag | null;
|
option?: Tag | null;
|
||||||
@@ -43,23 +39,18 @@ export const useDocumentTagMutations = ({
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const cachedTag: Tag = {
|
|
||||||
id: tag.id,
|
|
||||||
label: tag.label,
|
|
||||||
color: Object.prototype.hasOwnProperty.call(tag, 'color') ? tag.color ?? null : null,
|
|
||||||
};
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await addDocumentTags(documentId, [cachedTag.id]);
|
await addDocumentTags(documentId, [tag.id]);
|
||||||
documentsState.documentsManager.map((doc) => {
|
documentsState.documentsManager.map((doc) => {
|
||||||
if (doc.id !== documentId) {
|
if (doc.id !== documentId) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
const currentTags = Array.isArray(doc.tags) ? doc.tags : [];
|
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;
|
||||||
}
|
}
|
||||||
return { ...doc, tags: [...currentTags, cachedTag] };
|
return { ...doc, tags: [...currentTags, tag.id] };
|
||||||
});
|
});
|
||||||
showToast('Tag assigned.', 'success');
|
showToast('Tag assigned.', 'success');
|
||||||
return true;
|
return true;
|
||||||
@@ -79,17 +70,22 @@ export const useDocumentTagMutations = ({
|
|||||||
const input = extras?.input ?? null;
|
const input = extras?.input ?? null;
|
||||||
|
|
||||||
let tag: Tag | null = null;
|
let tag: Tag | null = null;
|
||||||
|
// Lookup via ID
|
||||||
if (optionCandidate && optionCandidate.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) {
|
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 {
|
try {
|
||||||
if (!tag) {
|
if (!tag) {
|
||||||
const payload = tagsState.tagManager.buildPayload({ label: normalizedLabel }) as { label: string; color?: string | null };
|
const payload = tagsState.tagManager.buildPayload({ label: normalizedLabel }) as { label: string; color?: string | null };
|
||||||
const data = await createTag(payload);
|
const data = await createTag(payload);
|
||||||
tag = data as Tag;
|
tag = data as Tag;
|
||||||
|
// Ingest new tag into manager to ensure it's available
|
||||||
|
tagsState.tagManager.ingest([tag]);
|
||||||
await tagsState.refreshTags();
|
await tagsState.refreshTags();
|
||||||
}
|
}
|
||||||
await attachTagToDocument({
|
await attachTagToDocument({
|
||||||
@@ -117,15 +113,8 @@ export const useDocumentTagMutations = ({
|
|||||||
if (!lookupTag || lookupTag.id == null) {
|
if (!lookupTag || lookupTag.id == null) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const labelText = `${lookupTag.label ?? ''} `.trim();
|
|
||||||
if (!labelText) {
|
return lookupTag;
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
id: lookupTag.id,
|
|
||||||
label: labelText,
|
|
||||||
color: Object.prototype.hasOwnProperty.call(lookupTag, 'color') ? (lookupTag as Tag).color ?? null : null,
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const resolvedTag = resolveTagForCache();
|
const resolvedTag = resolveTagForCache();
|
||||||
@@ -156,7 +145,8 @@ export const useDocumentTagMutations = ({
|
|||||||
if (!doc || !Array.isArray(doc.tags)) {
|
if (!doc || !Array.isArray(doc.tags)) {
|
||||||
return doc;
|
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) {
|
if (nextTags.length === doc.tags.length) {
|
||||||
return doc;
|
return doc;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
import AssetManager, { getAssetFromVersion } from '../../lib/assets/AssetManager';
|
import AssetManager, { getAssetFromVersion } from '../../lib/assets/AssetManager';
|
||||||
import useNotifyApiError from '../../hooks/useNotifyApiError';
|
import useNotifyApiError from '../../hooks/useNotifyApiError';
|
||||||
import TagManager from '../../lib/assets/TagManager';
|
import TagManager from '../../lib/assets/TagManager';
|
||||||
|
import CorrespondentManager from '../../lib/assets/CorrespondentManager';
|
||||||
import { fetchAsset } from '../../lib/api/apiClient';
|
import { fetchAsset } from '../../lib/api/apiClient';
|
||||||
import { useEntryPointer as useEntryPointerCore } from '../features/selection/useEntryPointer';
|
import { useEntryPointer as useEntryPointerCore } from '../features/selection/useEntryPointer';
|
||||||
import useDocumentsSelection from '../features/selection/useDocumentsSelection';
|
import useDocumentsSelection from '../features/selection/useDocumentsSelection';
|
||||||
@@ -46,7 +47,6 @@ import useDocumentMutations from './useDocumentMutations';
|
|||||||
import useDetailWorkspace from '../../viewer/logic/useDetailWorkspace';
|
import useDetailWorkspace from '../../viewer/logic/useDetailWorkspace';
|
||||||
import useTags from './useTags';
|
import useTags from './useTags';
|
||||||
import useCorrespondents from './useCorrespondents';
|
import useCorrespondents from './useCorrespondents';
|
||||||
import useDocumentCorrespondentActions from '../features/correspondents/useDocumentCorrespondentActions';
|
|
||||||
import usePasskeys from '../../settings/usePasskeys';
|
import usePasskeys from '../../settings/usePasskeys';
|
||||||
import { resolveBreadcrumbs } from '../logic/breadcrumbs';
|
import { resolveBreadcrumbs } from '../logic/breadcrumbs';
|
||||||
import useWorkspaceSelectionSync from '../features/selection/useWorkspaceSelectionSync';
|
import useWorkspaceSelectionSync from '../features/selection/useWorkspaceSelectionSync';
|
||||||
@@ -58,6 +58,7 @@ import { useApi } from '../../lib/context/ApiContext';
|
|||||||
import { useWorkspaceSelection } from '../../app/useWorkspaceSelection';
|
import { useWorkspaceSelection } from '../../app/useWorkspaceSelection';
|
||||||
import useDocumentPreview from '../../app/useDocumentPreview';
|
import useDocumentPreview from '../../app/useDocumentPreview';
|
||||||
import type { DocumentId, FolderNodeId, Identifier } from '../../types/identifiers';
|
import type { DocumentId, FolderNodeId, Identifier } from '../../types/identifiers';
|
||||||
|
import type { Document, Tag } from '../../types/documents';
|
||||||
|
|
||||||
const EntryType = Object.freeze({
|
const EntryType = Object.freeze({
|
||||||
document: 'document',
|
document: 'document',
|
||||||
@@ -66,8 +67,6 @@ const EntryType = Object.freeze({
|
|||||||
|
|
||||||
const noop = () => { };
|
const noop = () => { };
|
||||||
|
|
||||||
import type { Document } from '../../types/documents';
|
|
||||||
|
|
||||||
interface TenantOption {
|
interface TenantOption {
|
||||||
id?: Identifier | null;
|
id?: Identifier | null;
|
||||||
name?: string | null;
|
name?: string | null;
|
||||||
@@ -198,6 +197,12 @@ const useDocumentsWorkspace = ({
|
|||||||
}
|
}
|
||||||
const tagManager = tagManagerRef.current;
|
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 selectionState = useWorkspaceSelection();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@@ -228,6 +233,15 @@ const useDocumentsWorkspace = ({
|
|||||||
fetchDocumentById,
|
fetchDocumentById,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (tagManager) {
|
||||||
|
documentsManager.setTagManager(tagManager);
|
||||||
|
}
|
||||||
|
if (correspondentManager) {
|
||||||
|
documentsManager.setCorrespondentManager(correspondentManager);
|
||||||
|
}
|
||||||
|
}, [documentsManager, tagManager, correspondentManager]);
|
||||||
|
|
||||||
const documentLookup = useSyncExternalStore(
|
const documentLookup = useSyncExternalStore(
|
||||||
(onStoreChange) => documentsManager.subscribe(onStoreChange),
|
(onStoreChange) => documentsManager.subscribe(onStoreChange),
|
||||||
() => documentsManager.getSnapshot(),
|
() => documentsManager.getSnapshot(),
|
||||||
@@ -476,13 +490,17 @@ const useDocumentsWorkspace = ({
|
|||||||
setActiveTagFilters,
|
setActiveTagFilters,
|
||||||
documentsManager,
|
documentsManager,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
tagManager.ensureAll().catch((err) => console.warn('Failed to bootstrap tags', err));
|
||||||
|
}, [tagManager]);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
tags,
|
tags,
|
||||||
refreshTags,
|
refreshTags,
|
||||||
handleTagCreate,
|
handleTagCreate,
|
||||||
handleTagUpdate,
|
handleTagUpdate,
|
||||||
handleTagDelete,
|
handleTagDelete,
|
||||||
setTags,
|
|
||||||
} = tagsStateRaw;
|
} = tagsStateRaw;
|
||||||
|
|
||||||
// tagLookupById is derived locally
|
// tagLookupById is derived locally
|
||||||
@@ -490,7 +508,7 @@ const useDocumentsWorkspace = ({
|
|||||||
tenantIdRef.current = currentTenantId;
|
tenantIdRef.current = currentTenantId;
|
||||||
}, [currentTenantId, tenantIdRef]);
|
}, [currentTenantId, tenantIdRef]);
|
||||||
|
|
||||||
const tagLookupById = new Map();
|
const tagLookupById = new Map<Identifier, Tag>();
|
||||||
tags.forEach((tag) => {
|
tags.forEach((tag) => {
|
||||||
if (tag?.id) {
|
if (tag?.id) {
|
||||||
tagLookupById.set(tag.id, tag);
|
tagLookupById.set(tag.id, tag);
|
||||||
@@ -499,33 +517,30 @@ const useDocumentsWorkspace = ({
|
|||||||
|
|
||||||
const tagsState = {
|
const tagsState = {
|
||||||
...tagsStateRaw,
|
...tagsStateRaw,
|
||||||
tagLookupById, // Add derived lookup
|
tags,
|
||||||
|
tagLookupById,
|
||||||
tagManager,
|
tagManager,
|
||||||
};
|
};
|
||||||
|
|
||||||
const correspondentsStateRaw = useCorrespondents({
|
const correspondentsStateRaw = useCorrespondents({
|
||||||
tenantIdRef,
|
correspondentManager,
|
||||||
documentsManager,
|
documentsManager,
|
||||||
});
|
});
|
||||||
|
|
||||||
const {
|
const {
|
||||||
correspondents,
|
correspondents,
|
||||||
|
correspondentLookupById,
|
||||||
refreshCorrespondents,
|
refreshCorrespondents,
|
||||||
handleCorrespondentCreate,
|
handleCorrespondentCreate,
|
||||||
handleCorrespondentUpdate,
|
handleCorrespondentUpdate,
|
||||||
handleCorrespondentDelete,
|
handleCorrespondentDelete,
|
||||||
setCorrespondents,
|
|
||||||
} = correspondentsStateRaw;
|
} = correspondentsStateRaw;
|
||||||
|
|
||||||
const {
|
// Prefetch tags/correspondents when tenant changes
|
||||||
correspondentLookupByName,
|
useEffect(() => {
|
||||||
handleDocumentCorrespondentAttach,
|
refreshTags();
|
||||||
handleCorrespondentRemove,
|
refreshCorrespondents();
|
||||||
handleCorrespondentAdd,
|
}, [refreshTags, refreshCorrespondents, currentTenantId]);
|
||||||
} = useDocumentCorrespondentActions({
|
|
||||||
correspondents,
|
|
||||||
handleCorrespondentCreate,
|
|
||||||
documentsManager,
|
|
||||||
});
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
passkeys,
|
passkeys,
|
||||||
@@ -623,8 +638,6 @@ const useDocumentsWorkspace = ({
|
|||||||
setDraggedDocumentIds([]);
|
setDraggedDocumentIds([]);
|
||||||
setDraggedFolderId(null);
|
setDraggedFolderId(null);
|
||||||
setSearchResultIds(null);
|
setSearchResultIds(null);
|
||||||
setTags([]);
|
|
||||||
setCorrespondents([]);
|
|
||||||
setSearchQuery('');
|
setSearchQuery('');
|
||||||
setActiveTagFilters([]);
|
setActiveTagFilters([]);
|
||||||
setActiveCorrespondentFilters([]);
|
setActiveCorrespondentFilters([]);
|
||||||
@@ -653,8 +666,6 @@ const useDocumentsWorkspace = ({
|
|||||||
setDraggedDocumentIds,
|
setDraggedDocumentIds,
|
||||||
setDraggedFolderId,
|
setDraggedFolderId,
|
||||||
setSearchResultIds,
|
setSearchResultIds,
|
||||||
setTags,
|
|
||||||
setCorrespondents,
|
|
||||||
setSearchQuery,
|
setSearchQuery,
|
||||||
setActiveTagFilters,
|
setActiveTagFilters,
|
||||||
setActiveCorrespondentFilters,
|
setActiveCorrespondentFilters,
|
||||||
@@ -693,11 +704,21 @@ const useDocumentsWorkspace = ({
|
|||||||
handleDocumentTitleUpdate,
|
handleDocumentTitleUpdate,
|
||||||
handleDocumentIssuedUpdate,
|
handleDocumentIssuedUpdate,
|
||||||
handleDocumentTagDetach,
|
handleDocumentTagDetach,
|
||||||
|
handleDocumentCorrespondentAttach,
|
||||||
|
handleDocumentCorrespondentDetach,
|
||||||
|
handleDocumentCorrespondentAdd,
|
||||||
|
correspondentLookupByName,
|
||||||
} = useDocumentMutations({
|
} = useDocumentMutations({
|
||||||
documentsState,
|
documentsState,
|
||||||
folderState,
|
folderState,
|
||||||
selectionState,
|
selectionState,
|
||||||
tagsState,
|
tagsState,
|
||||||
|
correspondentsState: {
|
||||||
|
correspondents,
|
||||||
|
correspondentLookupById,
|
||||||
|
refreshCorrespondents,
|
||||||
|
correspondentManager,
|
||||||
|
},
|
||||||
actions: actionsState,
|
actions: actionsState,
|
||||||
previewDocumentId,
|
previewDocumentId,
|
||||||
});
|
});
|
||||||
@@ -895,13 +916,15 @@ const useDocumentsWorkspace = ({
|
|||||||
tags,
|
tags,
|
||||||
refreshTags,
|
refreshTags,
|
||||||
onTagCreate: handleTagCreate,
|
onTagCreate: handleTagCreate,
|
||||||
onTagUpdate: handleTagUpdate,
|
onTagUpdate: async (tagId: string, changes: any) => { await handleTagUpdate(tagId, changes); },
|
||||||
onTagDelete: handleTagDelete,
|
onTagDelete: async (tagId: string) => { await handleTagDelete(tagId); },
|
||||||
correspondents,
|
correspondents,
|
||||||
|
correspondentLookupById,
|
||||||
refreshCorrespondents,
|
refreshCorrespondents,
|
||||||
onCorrespondentCreate: handleCorrespondentCreate,
|
onCorrespondentCreate: handleCorrespondentCreate,
|
||||||
onCorrespondentUpdate: handleCorrespondentUpdate,
|
onCorrespondentUpdate: handleCorrespondentUpdate,
|
||||||
onCorrespondentDelete: handleCorrespondentDelete,
|
onCorrespondentDelete: handleCorrespondentDelete,
|
||||||
|
correspondentManager,
|
||||||
});
|
});
|
||||||
|
|
||||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||||
@@ -951,11 +974,12 @@ const useDocumentsWorkspace = ({
|
|||||||
ensureAssetUrl,
|
ensureAssetUrl,
|
||||||
getAsset: getDocumentAsset,
|
getAsset: getDocumentAsset,
|
||||||
correspondents,
|
correspondents,
|
||||||
handleCorrespondentAdd,
|
handleCorrespondentAdd: handleDocumentCorrespondentAdd,
|
||||||
handleCorrespondentRemove,
|
handleCorrespondentRemove: handleDocumentCorrespondentDetach,
|
||||||
selectFolder,
|
selectFolder,
|
||||||
tags,
|
tags,
|
||||||
tagLookupById,
|
tagLookupById,
|
||||||
|
correspondentLookupById,
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleEntryPointerCore = useEntryPointerCore({
|
const handleEntryPointerCore = useEntryPointerCore({
|
||||||
@@ -1019,6 +1043,7 @@ const useDocumentsWorkspace = ({
|
|||||||
tagLookupById,
|
tagLookupById,
|
||||||
activeTagFilters,
|
activeTagFilters,
|
||||||
handleTagUpdate,
|
handleTagUpdate,
|
||||||
|
handleTagCreate,
|
||||||
handleTagDelete,
|
handleTagDelete,
|
||||||
handleDocumentTagAttach,
|
handleDocumentTagAttach,
|
||||||
handleDocumentTagDetach,
|
handleDocumentTagDetach,
|
||||||
@@ -1029,14 +1054,15 @@ const useDocumentsWorkspace = ({
|
|||||||
|
|
||||||
const correspondentsContext = {
|
const correspondentsContext = {
|
||||||
correspondents,
|
correspondents,
|
||||||
|
correspondentLookupById,
|
||||||
refreshCorrespondents,
|
refreshCorrespondents,
|
||||||
activeCorrespondentFilters,
|
activeCorrespondentFilters,
|
||||||
handleCorrespondentUpdate,
|
handleCorrespondentUpdate,
|
||||||
handleCorrespondentCreate,
|
handleCorrespondentCreate,
|
||||||
handleCorrespondentDelete,
|
handleCorrespondentDelete,
|
||||||
handleDocumentCorrespondentAttach,
|
handleDocumentCorrespondentAttach,
|
||||||
handleCorrespondentRemove,
|
handleDocumentCorrespondentDetach,
|
||||||
handleCorrespondentAdd,
|
handleDocumentCorrespondentAdd,
|
||||||
handleBulkCorrespondentAdd,
|
handleBulkCorrespondentAdd,
|
||||||
handleBulkCorrespondentRemove,
|
handleBulkCorrespondentRemove,
|
||||||
openCorrespondentsModal,
|
openCorrespondentsModal,
|
||||||
|
|||||||
@@ -1,50 +1,52 @@
|
|||||||
import { MutableRefObject, useCallback, useState } from 'react';
|
import { MutableRefObject, useCallback, useSyncExternalStore } from 'react';
|
||||||
import { useStatusToast } from '../../lib/context/StatusToastContext';
|
import { useStatusToast } from '../../lib/context/StatusToastContext';
|
||||||
import type { TagId, TenantId } from '../../types/identifiers';
|
import type { TagId, TenantId } from '../../types/identifiers';
|
||||||
import type { Tag } from '../../types/documents';
|
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 useNotifyApiError from '../../hooks/useNotifyApiError';
|
||||||
|
import TagManager from '../../lib/assets/TagManager';
|
||||||
|
|
||||||
interface UseTagsOptions {
|
interface UseTagsOptions {
|
||||||
// apiClient removed
|
tagManager: TagManager;
|
||||||
tagManager: TagManagerInterface;
|
|
||||||
tenantIdRef: MutableRefObject<TenantId | null>;
|
tenantIdRef: MutableRefObject<TenantId | null>;
|
||||||
setActiveTagFilters: (updater: (prev: Array<TagId>) => Array<TagId>) => void;
|
setActiveTagFilters: (updater: (prev: Array<TagId>) => Array<TagId>) => void;
|
||||||
documentsManager?: { map: (mapper: (doc: any) => any) => 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 = ({
|
const useTags = ({
|
||||||
// apiClient removed
|
|
||||||
tagManager,
|
tagManager,
|
||||||
tenantIdRef,
|
|
||||||
setActiveTagFilters,
|
setActiveTagFilters,
|
||||||
documentsManager,
|
documentsManager,
|
||||||
}: UseTagsOptions) => {
|
}: UseTagsOptions): UseTagsResult => {
|
||||||
const [tags, setTags] = useState<Tag[]>([]);
|
|
||||||
const { showToast } = useStatusToast();
|
const { showToast } = useStatusToast();
|
||||||
const notifyApiError = useNotifyApiError();
|
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 refreshTags = useCallback(async () => {
|
||||||
const requestTenantId = tenantIdRef.current;
|
|
||||||
try {
|
try {
|
||||||
const data = await listTags();
|
await tagManager.ensureAll(true);
|
||||||
if (tenantIdRef.current !== requestTenantId) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setTags(data || []);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (tenantIdRef.current !== requestTenantId) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
notifyApiError(error, 'Unable to load tags.');
|
notifyApiError(error, 'Unable to load tags.');
|
||||||
}
|
}
|
||||||
}, [notifyApiError, tenantIdRef]);
|
}, [notifyApiError, tagManager]);
|
||||||
|
|
||||||
const handleTagUpdate = useCallback(
|
const handleTagUpdate = useCallback(
|
||||||
async (tagId: TagId, changes: { label?: string; color?: string | null }) => {
|
async (tagId: TagId, changes: { label?: string; color?: string | null }) => {
|
||||||
@@ -52,12 +54,12 @@ const useTags = ({
|
|||||||
throw new Error('Missing tag identifier.');
|
throw new Error('Missing tag identifier.');
|
||||||
}
|
}
|
||||||
|
|
||||||
const payload: Record<string, unknown> = {};
|
const payload: Record<string, string> = {};
|
||||||
if (changes?.label != null) {
|
if (changes?.label != null) {
|
||||||
payload.label = changes.label;
|
payload.label = changes.label;
|
||||||
}
|
}
|
||||||
if (Object.prototype.hasOwnProperty.call(changes, 'color')) {
|
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) {
|
if (Object.keys(payload).length === 0) {
|
||||||
@@ -65,8 +67,7 @@ const useTags = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await updateTag(tagId, payload);
|
await tagManager.update(tagId, payload as any);
|
||||||
await refreshTags();
|
|
||||||
showToast('Tag updated.', 'success');
|
showToast('Tag updated.', 'success');
|
||||||
return true;
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -75,23 +76,23 @@ const useTags = ({
|
|||||||
throw new Error(message);
|
throw new Error(message);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[notifyApiError, refreshTags, showToast],
|
[notifyApiError, tagManager, showToast],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleTagCreate = useCallback(
|
const handleTagCreate = useCallback(
|
||||||
async ({ label, color }: { label?: string; color?: string | null } = {}) => {
|
async ({ label, color }: { label?: string; color?: string | null } = {}) => {
|
||||||
const payload = tagManager.buildPayload({ label, color });
|
const payload = tagManager.buildPayload({ label, color });
|
||||||
try {
|
try {
|
||||||
await createTag(payload);
|
const newTag = await tagManager.create(payload);
|
||||||
await refreshTags();
|
|
||||||
showToast('Tag created.', 'success');
|
showToast('Tag created.', 'success');
|
||||||
|
return newTag;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error.response?.data?.error || 'Failed to create tag.';
|
const message = error.response?.data?.error || 'Failed to create tag.';
|
||||||
notifyApiError(error, message);
|
notifyApiError(error, message);
|
||||||
throw new Error(message);
|
throw new Error(message);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[notifyApiError, refreshTags, showToast, tagManager],
|
[notifyApiError, showToast, tagManager],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleTagDelete = useCallback(
|
const handleTagDelete = useCallback(
|
||||||
@@ -101,14 +102,14 @@ const useTags = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await deleteTag(tagId);
|
await tagManager.delete(tagId);
|
||||||
setActiveTagFilters((prev) => prev.filter((id) => id !== tagId));
|
setActiveTagFilters((prev) => prev.filter((id) => id !== tagId));
|
||||||
|
|
||||||
const stripTagFromDoc = (doc: any) => {
|
const stripTagFromDoc = (doc: any) => {
|
||||||
if (!doc || !Array.isArray(doc.tags)) {
|
if (!doc || !Array.isArray(doc.tags)) {
|
||||||
return doc;
|
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) {
|
if (nextTags.length === doc.tags.length) {
|
||||||
return doc;
|
return doc;
|
||||||
}
|
}
|
||||||
@@ -116,8 +117,6 @@ const useTags = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
documentsManager?.map(stripTagFromDoc);
|
documentsManager?.map(stripTagFromDoc);
|
||||||
|
|
||||||
await refreshTags();
|
|
||||||
showToast('Tag deleted.', 'success');
|
showToast('Tag deleted.', 'success');
|
||||||
return true;
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -126,7 +125,7 @@ const useTags = ({
|
|||||||
throw new Error(message);
|
throw new Error(message);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[documentsManager, notifyApiError, refreshTags, setActiveTagFilters, showToast],
|
[documentsManager, notifyApiError, setActiveTagFilters, showToast, tagManager],
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -135,7 +134,6 @@ const useTags = ({
|
|||||||
handleTagUpdate,
|
handleTagUpdate,
|
||||||
handleTagCreate,
|
handleTagCreate,
|
||||||
handleTagDelete,
|
handleTagDelete,
|
||||||
setTags,
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ interface SelectionFloatingActionsProps {
|
|||||||
tags?: TagOption[] | null;
|
tags?: TagOption[] | null;
|
||||||
tagLookupById?: Map<DocumentId, TagOption> | null;
|
tagLookupById?: Map<DocumentId, TagOption> | null;
|
||||||
correspondents?: CorrespondentOption[] | null;
|
correspondents?: CorrespondentOption[] | null;
|
||||||
|
correspondentLookupById?: Map<DocumentId, CorrespondentOption> | null;
|
||||||
onBulkTagAdd?: (args: BulkTagMutationArgs) => Promise<void> | void;
|
onBulkTagAdd?: (args: BulkTagMutationArgs) => Promise<void> | void;
|
||||||
onBulkTagRemove?: (args: BulkTagMutationArgs) => Promise<void> | void;
|
onBulkTagRemove?: (args: BulkTagMutationArgs) => Promise<void> | void;
|
||||||
onBulkCorrespondentAdd?: (args: BulkCorrespondentAddArgs) => Promise<void> | void;
|
onBulkCorrespondentAdd?: (args: BulkCorrespondentAddArgs) => Promise<void> | void;
|
||||||
@@ -114,9 +115,12 @@ const buildTagAssignments = (
|
|||||||
};
|
};
|
||||||
|
|
||||||
selectedDocuments.forEach((doc) => {
|
selectedDocuments.forEach((doc) => {
|
||||||
(doc?.tags || []).forEach((tag) => {
|
(doc?.tags || []).forEach((tagId) => {
|
||||||
const lookupColor = tag?.id && tagLookupById instanceof Map ? tagLookupById.get(tag.id)?.color : null;
|
const tag = tagLookupById instanceof Map ? tagLookupById.get(tagId) : null;
|
||||||
const entry = ensureEntry(tag?.id, tag?.label, tag?.color ?? lookupColor ?? null);
|
const lookupColor = tag?.color ?? null;
|
||||||
|
const label = tag?.label;
|
||||||
|
|
||||||
|
const entry = ensureEntry(tagId, label, lookupColor);
|
||||||
if (entry) {
|
if (entry) {
|
||||||
entry.count += 1;
|
entry.count += 1;
|
||||||
}
|
}
|
||||||
@@ -146,6 +150,7 @@ const buildTagAssignments = (
|
|||||||
const buildCorrespondentAssignments = (
|
const buildCorrespondentAssignments = (
|
||||||
selectedDocuments: Document[],
|
selectedDocuments: Document[],
|
||||||
correspondents: CorrespondentOption[] | null,
|
correspondents: CorrespondentOption[] | null,
|
||||||
|
correspondentLookupById: Map<DocumentId, CorrespondentOption> | null,
|
||||||
total: number,
|
total: number,
|
||||||
): SelectionAssignmentMenuItem[] => {
|
): SelectionAssignmentMenuItem[] => {
|
||||||
if (!total) {
|
if (!total) {
|
||||||
@@ -176,8 +181,11 @@ const buildCorrespondentAssignments = (
|
|||||||
};
|
};
|
||||||
|
|
||||||
selectedDocuments.forEach((doc) => {
|
selectedDocuments.forEach((doc) => {
|
||||||
(doc?.correspondents || []).forEach((entry) => {
|
(doc?.correspondents || []).forEach((correspondentId) => {
|
||||||
const target = ensureEntry(entry?.id, entry?.name);
|
const resolved = correspondentLookupById instanceof Map ? correspondentLookupById.get(correspondentId) : null;
|
||||||
|
const name = resolved?.name;
|
||||||
|
|
||||||
|
const target = ensureEntry(correspondentId, name);
|
||||||
if (target) {
|
if (target) {
|
||||||
target.count += 1;
|
target.count += 1;
|
||||||
}
|
}
|
||||||
@@ -210,6 +218,7 @@ const SelectionFloatingActions: React.FC<SelectionFloatingActionsProps> = ({
|
|||||||
tags = [],
|
tags = [],
|
||||||
tagLookupById,
|
tagLookupById,
|
||||||
correspondents = [],
|
correspondents = [],
|
||||||
|
correspondentLookupById,
|
||||||
onBulkTagAdd,
|
onBulkTagAdd,
|
||||||
onBulkTagRemove,
|
onBulkTagRemove,
|
||||||
onBulkCorrespondentAdd,
|
onBulkCorrespondentAdd,
|
||||||
@@ -303,8 +312,8 @@ const SelectionFloatingActions: React.FC<SelectionFloatingActionsProps> = ({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const correspondentAssignments = useMemo(
|
const correspondentAssignments = useMemo(
|
||||||
() => buildCorrespondentAssignments(selectedDocuments, correspondents, selectedDocCount),
|
() => buildCorrespondentAssignments(selectedDocuments, correspondents, correspondentLookupById, selectedDocCount),
|
||||||
[selectedDocuments, correspondents, selectedDocCount],
|
[selectedDocuments, correspondents, correspondentLookupById, selectedDocCount],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleToggleTagAssignment = useCallback(
|
const handleToggleTagAssignment = useCallback(
|
||||||
|
|||||||
@@ -3,23 +3,14 @@ import { useStatusToast } from '../../../lib/context/StatusToastContext';
|
|||||||
|
|
||||||
import type { Identifier } from '../../../types/identifiers';
|
import type { Identifier } from '../../../types/identifiers';
|
||||||
|
|
||||||
interface TagRecord {
|
|
||||||
id?: Identifier;
|
|
||||||
label: string;
|
|
||||||
[key: string]: unknown;
|
|
||||||
}
|
|
||||||
|
|
||||||
import { createTag, bulkTagDocuments, bulkReanalyzeDocuments } from '../../../lib/api/apiClient';
|
import { createTag, bulkTagDocuments, bulkReanalyzeDocuments } from '../../../lib/api/apiClient';
|
||||||
|
|
||||||
interface TagManager {
|
|
||||||
buildPayload: (input: { label: string }) => Record<string, unknown>;
|
|
||||||
}
|
|
||||||
|
|
||||||
import useNotifyApiError from '../../../hooks/useNotifyApiError';
|
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 {
|
interface UseDocumentTaggingArgs {
|
||||||
tags: TagRecord[];
|
tags: Tag[];
|
||||||
tagManager: TagManager;
|
tagManager: TagManager;
|
||||||
refreshTags: () => Promise<void> | void;
|
refreshTags: () => Promise<void> | void;
|
||||||
resolveTargetDocumentIds: (ids?: Identifier[] | null) => Identifier[];
|
resolveTargetDocumentIds: (ids?: Identifier[] | null) => Identifier[];
|
||||||
@@ -80,13 +71,21 @@ const useDocumentTagActions = ({
|
|||||||
try {
|
try {
|
||||||
if (action === 'add') {
|
if (action === 'add') {
|
||||||
const createdIds: Identifier[] = [];
|
const createdIds: Identifier[] = [];
|
||||||
const createdTags: TagRecord[] = [];
|
const createdTags: Tag[] = [];
|
||||||
for (const label of normalized) {
|
for (const label of normalized) {
|
||||||
let tag = tags.find((item) => item.label.toLowerCase() === label.toLowerCase()) || null;
|
let tag = tags.find((item) => item.label.toLowerCase() === label.toLowerCase()) || null;
|
||||||
if (!tag) {
|
if (!tag) {
|
||||||
const payload = tagManager.buildPayload({ label }) as { label: string; color?: string | null };
|
const payload = tagManager.buildPayload({ label }) as { label: string; color?: string | null };
|
||||||
const response = await createTag(payload);
|
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();
|
await refreshTags();
|
||||||
}
|
}
|
||||||
createdIds.push(tag.id);
|
createdIds.push(tag.id);
|
||||||
@@ -94,7 +93,7 @@ const useDocumentTagActions = ({
|
|||||||
}
|
}
|
||||||
tagIds = Array.from(new Set(createdIds));
|
tagIds = Array.from(new Set(createdIds));
|
||||||
|
|
||||||
const tagById = new Map<Identifier, TagRecord>();
|
const tagById = new Map<Identifier, Tag>();
|
||||||
tags.forEach((tag) => {
|
tags.forEach((tag) => {
|
||||||
if (tag?.id != null) {
|
if (tag?.id != null) {
|
||||||
tagById.set(tag.id, tag);
|
tagById.set(tag.id, tag);
|
||||||
@@ -111,22 +110,20 @@ const useDocumentTagActions = ({
|
|||||||
documentsManager.map((doc) => {
|
documentsManager.map((doc) => {
|
||||||
if (!targetSet.has(doc.id as Identifier)) return undefined;
|
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 nextTags = [...currentTags];
|
||||||
let changed = false;
|
let changed = false;
|
||||||
|
|
||||||
tagIds.forEach((tagId) => {
|
tagIds.forEach((tagId) => {
|
||||||
if (nextTags.some((entry: any) => entry?.id === tagId)) {
|
if (nextTags.includes(tagId)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const cachedTag = tagById.get(tagId);
|
|
||||||
if (cachedTag) {
|
nextTags.push(tagId);
|
||||||
nextTags.push({ ...cachedTag });
|
changed = true;
|
||||||
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) => {
|
documentsManager.map((doc) => {
|
||||||
if (!targetSet.has(doc.id as Identifier)) return undefined;
|
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;
|
return doc;
|
||||||
}
|
}
|
||||||
const currentTags = (doc as any).tags;
|
const currentTags = doc.tags as Identifier[];
|
||||||
const filtered = currentTags.filter((entry: any) => !removeSet.has(entry?.id));
|
const filtered = currentTags.filter((id) => !removeSet.has(id));
|
||||||
return filtered.length === currentTags.length ? doc : { ...(doc as any), tags: filtered };
|
return filtered.length === currentTags.length ? doc : { ...doc, tags: filtered };
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import {
|
|||||||
clearTagTransferData,
|
clearTagTransferData,
|
||||||
} from '../../documents/features/tagging/tagTransfer';
|
} from '../../documents/features/tagging/tagTransfer';
|
||||||
import type { Identifier } from '../../types/identifiers';
|
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) => {
|
const preventAll = (event?: React.SyntheticEvent | Event | null) => {
|
||||||
if (!event) return;
|
if (!event) return;
|
||||||
@@ -60,7 +60,7 @@ export interface TagInteractionHandlers {
|
|||||||
onTagDragOver: (event: React.DragEvent<HTMLDivElement>, doc: Document) => void;
|
onTagDragOver: (event: React.DragEvent<HTMLDivElement>, doc: Document) => void;
|
||||||
onTagDragLeave: (event: React.DragEvent<HTMLDivElement>, docId: Identifier) => void;
|
onTagDragLeave: (event: React.DragEvent<HTMLDivElement>, docId: Identifier) => void;
|
||||||
onTagDrop: (event: React.DragEvent<HTMLDivElement>, doc: Document) => 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;
|
onTagDragEnd: (event: React.DragEvent<HTMLElement>) => void;
|
||||||
onTagClick?: (tagId: Identifier) => void;
|
onTagClick?: (tagId: Identifier) => void;
|
||||||
}
|
}
|
||||||
@@ -92,7 +92,7 @@ export const useTagInteractions = ({
|
|||||||
// Use shared state for all logic (Single Source of Truth)
|
// Use shared state for all logic (Single Source of Truth)
|
||||||
const { tagId: draggedTagId, sourceDocId: draggedSourceId } = getActiveDragState();
|
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) {
|
if (event.dataTransfer) {
|
||||||
const isSource = draggedSourceId === doc.id;
|
const isSource = draggedSourceId === doc.id;
|
||||||
@@ -154,7 +154,7 @@ export const useTagInteractions = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Double-check assignment (even though cursor logic tries to prevent it)
|
// 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 (isAssigned) return;
|
||||||
|
|
||||||
if (onAssignTagToDocument && doc.id) {
|
if (onAssignTagToDocument && doc.id) {
|
||||||
@@ -166,7 +166,7 @@ export const useTagInteractions = ({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const onTagDragStart = useCallback(
|
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) {
|
if (!event?.dataTransfer || !doc?.id || !tag?.id) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ interface UseDocumentsPanelPropsArgs {
|
|||||||
handleEntryPointerCore?: (...args: unknown[]) => void;
|
handleEntryPointerCore?: (...args: unknown[]) => void;
|
||||||
tags?: unknown[];
|
tags?: unknown[];
|
||||||
correspondents?: unknown[];
|
correspondents?: unknown[];
|
||||||
documentLookup?: unknown;
|
correspondentLookupById?: unknown;
|
||||||
handleBulkTagAddFromDetail?: (...args: unknown[]) => void;
|
handleBulkTagAddFromDetail?: (...args: unknown[]) => void;
|
||||||
handleBulkTagRemoveFromDetail?: (...args: unknown[]) => void;
|
handleBulkTagRemoveFromDetail?: (...args: unknown[]) => void;
|
||||||
handleBulkCorrespondentAdd?: (...args: unknown[]) => void;
|
handleBulkCorrespondentAdd?: (...args: unknown[]) => void;
|
||||||
@@ -61,6 +61,7 @@ interface UseDocumentsPanelPropsArgs {
|
|||||||
handleBulkSelectionReanalyze?: (...args: unknown[]) => void;
|
handleBulkSelectionReanalyze?: (...args: unknown[]) => void;
|
||||||
folderOptions?: unknown[];
|
folderOptions?: unknown[];
|
||||||
moveDocumentsToFolder?: (...args: unknown[]) => void;
|
moveDocumentsToFolder?: (...args: unknown[]) => void;
|
||||||
|
documentLookup?: unknown;
|
||||||
selectionValue: WorkspaceSelectionValue;
|
selectionValue: WorkspaceSelectionValue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -103,6 +104,7 @@ const useDocumentsPanelProps = (props: UseDocumentsPanelPropsArgs) => {
|
|||||||
handleEntryPointerCore,
|
handleEntryPointerCore,
|
||||||
tags,
|
tags,
|
||||||
correspondents,
|
correspondents,
|
||||||
|
correspondentLookupById,
|
||||||
documentLookup,
|
documentLookup,
|
||||||
handleBulkTagAddFromDetail,
|
handleBulkTagAddFromDetail,
|
||||||
handleBulkTagRemoveFromDetail,
|
handleBulkTagRemoveFromDetail,
|
||||||
@@ -155,6 +157,7 @@ const useDocumentsPanelProps = (props: UseDocumentsPanelPropsArgs) => {
|
|||||||
onEntryPointer: handleEntryPointerCore,
|
onEntryPointer: handleEntryPointerCore,
|
||||||
tags,
|
tags,
|
||||||
correspondents,
|
correspondents,
|
||||||
|
correspondentLookupById,
|
||||||
documentLookup,
|
documentLookup,
|
||||||
onBulkTagAdd: handleBulkTagAddFromDetail,
|
onBulkTagAdd: handleBulkTagAddFromDetail,
|
||||||
onBulkTagRemove: handleBulkTagRemoveFromDetail,
|
onBulkTagRemove: handleBulkTagRemoveFromDetail,
|
||||||
|
|||||||
@@ -10,7 +10,9 @@ import { DocumentsList, DocumentsGrid } from '../DocumentsView';
|
|||||||
import type { ReactNode } from 'react';
|
import type { ReactNode } from 'react';
|
||||||
import type {
|
import type {
|
||||||
DocumentsListEntry,
|
DocumentsListEntry,
|
||||||
|
Correspondent,
|
||||||
} from '../../types/documents';
|
} from '../../types/documents';
|
||||||
|
import type { Identifier } from '../../types/identifiers';
|
||||||
import DesktopWorkspace from '../../desktop/components/DesktopWorkspace';
|
import DesktopWorkspace from '../../desktop/components/DesktopWorkspace';
|
||||||
import {
|
import {
|
||||||
WorkspaceSelectionProvider,
|
WorkspaceSelectionProvider,
|
||||||
@@ -47,6 +49,7 @@ export interface DocumentsPanelInnerProps {
|
|||||||
|
|
||||||
interface DocumentsPanelProps extends DocumentsPanelInnerProps {
|
interface DocumentsPanelProps extends DocumentsPanelInnerProps {
|
||||||
selectionValue: WorkspaceSelectionValue;
|
selectionValue: WorkspaceSelectionValue;
|
||||||
|
correspondentLookupById?: Map<Identifier, Correspondent>;
|
||||||
}
|
}
|
||||||
|
|
||||||
import type { TagInteractionHandlers } from '../interactions/useTagInteractions';
|
import type { TagInteractionHandlers } from '../interactions/useTagInteractions';
|
||||||
@@ -153,6 +156,7 @@ const DocumentsPanelInner: React.FC<DocumentsPanelInnerProps> = (props) => {
|
|||||||
tags={tags}
|
tags={tags}
|
||||||
tagLookupById={props.tagLookupById}
|
tagLookupById={props.tagLookupById}
|
||||||
correspondents={correspondents}
|
correspondents={correspondents}
|
||||||
|
correspondentLookupById={props.correspondentLookupById}
|
||||||
onBulkTagAdd={onBulkTagAdd}
|
onBulkTagAdd={onBulkTagAdd}
|
||||||
onBulkTagRemove={onBulkTagRemove}
|
onBulkTagRemove={onBulkTagRemove}
|
||||||
onBulkCorrespondentAdd={onBulkCorrespondentAdd}
|
onBulkCorrespondentAdd={onBulkCorrespondentAdd}
|
||||||
@@ -168,6 +172,7 @@ const DocumentsPanelInner: React.FC<DocumentsPanelInnerProps> = (props) => {
|
|||||||
tags,
|
tags,
|
||||||
props.tagLookupById,
|
props.tagLookupById,
|
||||||
correspondents,
|
correspondents,
|
||||||
|
props.correspondentLookupById,
|
||||||
onBulkTagAdd,
|
onBulkTagAdd,
|
||||||
onBulkTagRemove,
|
onBulkTagRemove,
|
||||||
onBulkCorrespondentAdd,
|
onBulkCorrespondentAdd,
|
||||||
|
|||||||
@@ -129,6 +129,7 @@ export const useDocumentsContextValues = (props: DocumentsPanelInnerProps) => {
|
|||||||
viewId,
|
viewId,
|
||||||
scrollRef,
|
scrollRef,
|
||||||
tagLookupById,
|
tagLookupById,
|
||||||
|
correspondentLookupById: props.correspondentLookupById,
|
||||||
activeCorrespondentIdSet,
|
activeCorrespondentIdSet,
|
||||||
draggingDocumentIdsSet,
|
draggingDocumentIdsSet,
|
||||||
draggedFolderId: props.draggedFolderId,
|
draggedFolderId: props.draggedFolderId,
|
||||||
@@ -139,6 +140,7 @@ export const useDocumentsContextValues = (props: DocumentsPanelInnerProps) => {
|
|||||||
activeCorrespondentIdSet,
|
activeCorrespondentIdSet,
|
||||||
draggingDocumentIdsSet,
|
draggingDocumentIdsSet,
|
||||||
props.draggedFolderId,
|
props.draggedFolderId,
|
||||||
|
props.correspondentLookupById,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Use refs to stabilize handlers and avoid massive dependency arrays
|
// Use refs to stabilize handlers and avoid massive dependency arrays
|
||||||
|
|||||||
@@ -1,20 +1,20 @@
|
|||||||
import type { Dispatch, MutableRefObject, SetStateAction } from 'react';
|
import type { Dispatch, MutableRefObject, SetStateAction } from 'react';
|
||||||
import type { DocumentId, FolderId as FolderIdentifier } from '../../types/identifiers';
|
import type { DocumentId, FolderId, Identifier } from '../../types/identifiers';
|
||||||
import type { Document, FolderNode } from '../../types/documents';
|
import type { Document, FolderNode, Tag, Correspondent } from '../../types/documents';
|
||||||
|
|
||||||
type FolderId = FolderIdentifier | 'root';
|
export interface TagManager {
|
||||||
|
|
||||||
export interface Tag {
|
|
||||||
id: DocumentId;
|
|
||||||
label: string;
|
|
||||||
color?: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface TagManager {
|
|
||||||
normalizeLabel: (label: string) => string;
|
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 {
|
export interface DocumentsManagerInterface {
|
||||||
map(mapper: (doc: Document) => Document | undefined): boolean;
|
map(mapper: (doc: Document) => Document | undefined): boolean;
|
||||||
@@ -59,6 +59,13 @@ export interface TagsState {
|
|||||||
tagManager: TagManager;
|
tagManager: TagManager;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface CorrespondentsState {
|
||||||
|
correspondents: Correspondent[];
|
||||||
|
correspondentLookupById: Map<Identifier, Correspondent>;
|
||||||
|
refreshCorrespondents: () => Promise<void>;
|
||||||
|
correspondentManager: CorrespondentManager;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ActionsState {
|
export interface ActionsState {
|
||||||
closeDocumentPreview: CloseDocumentPreview;
|
closeDocumentPreview: CloseDocumentPreview;
|
||||||
handleFileDrop?: (dataTransfer: DataTransfer, folderId: FolderId) => Promise<void> | void;
|
handleFileDrop?: (dataTransfer: DataTransfer, folderId: FolderId) => Promise<void> | void;
|
||||||
@@ -66,8 +73,8 @@ export interface ActionsState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface DragState {
|
export interface DragState {
|
||||||
draggedDocumentIds: FolderId[];
|
draggedDocumentIds: DocumentId[];
|
||||||
draggedFolderId: FolderId | null;
|
draggedFolderId: FolderId | null;
|
||||||
setDraggedDocumentIds: (ids: FolderId[]) => void;
|
setDraggedDocumentIds: (ids: DocumentId[]) => void;
|
||||||
setDraggedFolderId: (id: FolderId | null) => 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 { 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;
|
type ColorGenerator = () => string;
|
||||||
|
|
||||||
@@ -11,13 +14,133 @@ interface TagPayload {
|
|||||||
color: string;
|
color: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type Listener = () => void;
|
||||||
|
|
||||||
class TagManager {
|
class TagManager {
|
||||||
private readonly colorGenerator: ColorGenerator;
|
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 = {}) {
|
constructor({ colorGenerator = generateRandomTagColor }: TagManagerOptions = {}) {
|
||||||
this.colorGenerator = colorGenerator;
|
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 {
|
normalizeLabel(label?: string | null): string {
|
||||||
return label?.trim?.() || '';
|
return label?.trim?.() || '';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ const Sidebar: React.FC = () => {
|
|||||||
sidebarSuppressed,
|
sidebarSuppressed,
|
||||||
openTagsModal,
|
openTagsModal,
|
||||||
openCorrespondentsModal,
|
openCorrespondentsModal,
|
||||||
|
handleTagCreate,
|
||||||
|
handleCorrespondentCreate,
|
||||||
handleLogout,
|
handleLogout,
|
||||||
tags = [],
|
tags = [],
|
||||||
correspondents = [],
|
correspondents = [],
|
||||||
@@ -21,8 +23,7 @@ const Sidebar: React.FC = () => {
|
|||||||
tenantOptions,
|
tenantOptions,
|
||||||
handleTenantSelect,
|
handleTenantSelect,
|
||||||
openSettings,
|
openSettings,
|
||||||
handleFileSelection, // Used for upload
|
handleFileSelection,
|
||||||
// Folder Tree Context Props
|
|
||||||
folderClickHandlers = {},
|
folderClickHandlers = {},
|
||||||
handleFolderDelete,
|
handleFolderDelete,
|
||||||
handleFolderRename,
|
handleFolderRename,
|
||||||
@@ -107,11 +108,13 @@ const Sidebar: React.FC = () => {
|
|||||||
<SidebarTagList
|
<SidebarTagList
|
||||||
tags={tags}
|
tags={tags}
|
||||||
untaggedFilterId={null}
|
untaggedFilterId={null}
|
||||||
|
onCreateTag={handleTagCreate}
|
||||||
onManageTags={onManageTags}
|
onManageTags={onManageTags}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<SidebarCorrespondentList
|
<SidebarCorrespondentList
|
||||||
correspondents={correspondents}
|
correspondents={correspondents}
|
||||||
|
onCreateCorrespondent={handleCorrespondentCreate}
|
||||||
onManageCorrespondents={onManageCorrespondents}
|
onManageCorrespondents={onManageCorrespondents}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,14 +3,11 @@ import { PlusIcon, SettingsIcon } from '../../components/icons';
|
|||||||
import type { Identifier } from '../../types/identifiers';
|
import type { Identifier } from '../../types/identifiers';
|
||||||
import { useDocumentsFilter } from '../../documents/context/DocumentsFilterContext';
|
import { useDocumentsFilter } from '../../documents/context/DocumentsFilterContext';
|
||||||
|
|
||||||
interface CorrespondentEntry {
|
import type { Correspondent } from '../../types/documents';
|
||||||
id: Identifier;
|
|
||||||
name: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SidebarCorrespondentListProps {
|
interface SidebarCorrespondentListProps {
|
||||||
correspondents: CorrespondentEntry[];
|
correspondents: Correspondent[];
|
||||||
onCreateCorrespondent?: (name: string) => Promise<void> | void;
|
onCreateCorrespondent?: (payload: { name: string }) => Promise<void> | void;
|
||||||
onManageCorrespondents?: () => void;
|
onManageCorrespondents?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -24,12 +21,12 @@ const SidebarCorrespondentList: React.FC<SidebarCorrespondentListProps> = ({
|
|||||||
toggleCorrespondent: toggleCorrespondentFilter,
|
toggleCorrespondent: toggleCorrespondentFilter,
|
||||||
} = useDocumentsFilter();
|
} = useDocumentsFilter();
|
||||||
|
|
||||||
const sortedCorrespondents = useMemo<CorrespondentEntry[]>(() => {
|
const sortedCorrespondents = useMemo<Correspondent[]>(() => {
|
||||||
if (!Array.isArray(correspondents)) {
|
if (!Array.isArray(correspondents)) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
return correspondents
|
return correspondents
|
||||||
.filter((entry): entry is CorrespondentEntry & { name: string } => Boolean(entry?.name))
|
.filter((entry): entry is Correspondent & { name: string } => Boolean(entry?.name))
|
||||||
.slice()
|
.slice()
|
||||||
.sort((a, b) => a.name!.localeCompare(b.name!, undefined, { sensitivity: 'base' }));
|
.sort((a, b) => a.name!.localeCompare(b.name!, undefined, { sensitivity: 'base' }));
|
||||||
}, [correspondents]);
|
}, [correspondents]);
|
||||||
@@ -49,7 +46,7 @@ const SidebarCorrespondentList: React.FC<SidebarCorrespondentListProps> = ({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
await onCreateCorrespondent?.(trimmed);
|
await onCreateCorrespondent?.({ name: trimmed });
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
console.error('[sidebar] failed to create correspondent', error);
|
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 type { Identifier } from '../../types/identifiers';
|
||||||
import { useDocumentsFilter } from '../../documents/context/DocumentsFilterContext';
|
import { useDocumentsFilter } from '../../documents/context/DocumentsFilterContext';
|
||||||
|
|
||||||
interface TagEntry {
|
import type { Tag } from '../../types/documents';
|
||||||
id: Identifier;
|
|
||||||
label: string;
|
|
||||||
color?: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SidebarTagListProps {
|
interface SidebarTagListProps {
|
||||||
tags: TagEntry[];
|
tags: Tag[];
|
||||||
untaggedFilterId: Identifier | null;
|
untaggedFilterId: Identifier | null;
|
||||||
onCreateTag?: (label: string) => Promise<void> | void;
|
onCreateTag?: (payload: { label: string }) => Promise<void> | void;
|
||||||
onManageTags?: () => void;
|
onManageTags?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,7 +50,7 @@ const SidebarTagList: React.FC<SidebarTagListProps> = ({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
await onCreateTag?.(trimmed);
|
await onCreateTag?.({ label: trimmed });
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
console.error('[sidebar] failed to create tag', error);
|
console.error('[sidebar] failed to create tag', error);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,39 +2,17 @@ import type { Identifier } from './identifiers';
|
|||||||
import type { Asset } from './assets';
|
import type { Asset } from './assets';
|
||||||
import type { Download } from './common';
|
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 {
|
export interface Tag {
|
||||||
id?: Identifier;
|
id: Identifier;
|
||||||
label?: string;
|
label: string;
|
||||||
color?: string | null;
|
color: string | null;
|
||||||
usage_count?: number;
|
usage_count: number;
|
||||||
[key: string]: unknown;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* A correspondent entity as returned by the API.
|
|
||||||
* Use DocumentCorrespondent for the embedded version on documents.
|
|
||||||
*/
|
|
||||||
export interface Correspondent {
|
export interface Correspondent {
|
||||||
id?: Identifier;
|
id: Identifier;
|
||||||
name?: string;
|
name: string;
|
||||||
usage_count?: number;
|
usage_count: number;
|
||||||
[key: string]: unknown;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DocumentVersion {
|
export interface DocumentVersion {
|
||||||
@@ -43,7 +21,6 @@ export interface DocumentVersion {
|
|||||||
size_bytes?: number | null;
|
size_bytes?: number | null;
|
||||||
checksum?: string | null;
|
checksum?: string | null;
|
||||||
download?: Download | null;
|
download?: Download | null;
|
||||||
[key: string]: unknown;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MessageOptions {
|
export interface MessageOptions {
|
||||||
@@ -66,8 +43,8 @@ export interface Document {
|
|||||||
folder_name?: string;
|
folder_name?: string;
|
||||||
folder_path?: string;
|
folder_path?: string;
|
||||||
|
|
||||||
tags?: DocumentTag[] | null;
|
tags?: Identifier[] | null;
|
||||||
correspondents?: DocumentCorrespondent[] | null;
|
correspondents?: Identifier[] | null;
|
||||||
|
|
||||||
current_version?: DocumentVersion | null;
|
current_version?: DocumentVersion | null;
|
||||||
|
|
||||||
|
|||||||
@@ -67,6 +67,7 @@ const DocumentViewerPanel: React.FC<DocumentViewerPanelProps> = ({
|
|||||||
onTagAdd,
|
onTagAdd,
|
||||||
onTagRemove,
|
onTagRemove,
|
||||||
correspondents,
|
correspondents,
|
||||||
|
correspondentLookupById,
|
||||||
onCorrespondentAdd,
|
onCorrespondentAdd,
|
||||||
onCorrespondentRemove,
|
onCorrespondentRemove,
|
||||||
onUpdateTitle,
|
onUpdateTitle,
|
||||||
@@ -134,6 +135,7 @@ const DocumentViewerPanel: React.FC<DocumentViewerPanelProps> = ({
|
|||||||
onTagAdd,
|
onTagAdd,
|
||||||
onTagRemove,
|
onTagRemove,
|
||||||
correspondents: sortedCorrespondents,
|
correspondents: sortedCorrespondents,
|
||||||
|
correspondentLookupById,
|
||||||
correspondentOptions,
|
correspondentOptions,
|
||||||
onCorrespondentAdd,
|
onCorrespondentAdd,
|
||||||
onCorrespondentRemove,
|
onCorrespondentRemove,
|
||||||
@@ -147,6 +149,7 @@ const DocumentViewerPanel: React.FC<DocumentViewerPanelProps> = ({
|
|||||||
onTagAdd,
|
onTagAdd,
|
||||||
onTagRemove,
|
onTagRemove,
|
||||||
sortedCorrespondents,
|
sortedCorrespondents,
|
||||||
|
correspondentLookupById,
|
||||||
correspondentOptions,
|
correspondentOptions,
|
||||||
onCorrespondentAdd,
|
onCorrespondentAdd,
|
||||||
onCorrespondentRemove,
|
onCorrespondentRemove,
|
||||||
@@ -156,6 +159,11 @@ const DocumentViewerPanel: React.FC<DocumentViewerPanelProps> = ({
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const infoPanelProps = useMemo(() => ({
|
||||||
|
tagLookupById,
|
||||||
|
correspondentLookupById,
|
||||||
|
}), [tagLookupById, correspondentLookupById]);
|
||||||
|
|
||||||
const loadOcrContent = useCallback(async ({ signal }: { signal?: AbortSignal } = {}) => {
|
const loadOcrContent = useCallback(async ({ signal }: { signal?: AbortSignal } = {}) => {
|
||||||
if (!document || !hasOcr || !getDocumentAsset) {
|
if (!document || !hasOcr || !getDocumentAsset) {
|
||||||
return '';
|
return '';
|
||||||
@@ -358,6 +366,7 @@ const DocumentViewerPanel: React.FC<DocumentViewerPanelProps> = ({
|
|||||||
<DocumentViewerLayout
|
<DocumentViewerLayout
|
||||||
document={document}
|
document={document}
|
||||||
summaryProps={summaryProps}
|
summaryProps={summaryProps}
|
||||||
|
infoPanelProps={infoPanelProps}
|
||||||
metadataPayload={metadataPayload}
|
metadataPayload={metadataPayload}
|
||||||
contentTabConfig={contentTabConfig}
|
contentTabConfig={contentTabConfig}
|
||||||
previewLoadingMessage="Loading preview…"
|
previewLoadingMessage="Loading preview…"
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
|||||||
import type { ReactNode } from 'react';
|
import type { ReactNode } from 'react';
|
||||||
import DocumentSummarySection, { DocumentSummarySectionProps } from './DocumentSummarySection';
|
import DocumentSummarySection, { DocumentSummarySectionProps } from './DocumentSummarySection';
|
||||||
import { describeDocumentSummary, extractDocumentMetadataPayload, type DocumentSummaryRow } from '../logic/documentSummary';
|
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 };
|
type PanelTab = { id: string; label: string; render: (context?: Record<string, unknown>) => ReactNode };
|
||||||
|
|
||||||
@@ -14,7 +16,8 @@ type ContentState =
|
|||||||
| { status: 'error'; data: null; error: unknown };
|
| { status: 'error'; data: null; error: unknown };
|
||||||
|
|
||||||
export interface DocumentInfoPanelProps {
|
export interface DocumentInfoPanelProps {
|
||||||
document: DocumentSummarySectionProps['document'];
|
tagLookupById?: Map<TagId, Tag>;
|
||||||
|
correspondentLookupById?: Map<Identifier, Correspondent>;
|
||||||
summaryProps?: Omit<DocumentSummarySectionProps, 'document' | 'layout'>;
|
summaryProps?: Omit<DocumentSummarySectionProps, 'document' | 'layout'>;
|
||||||
metadataItems?: DocumentSummaryRow[];
|
metadataItems?: DocumentSummaryRow[];
|
||||||
metadataPayload?: Record<string, unknown>;
|
metadataPayload?: Record<string, unknown>;
|
||||||
@@ -50,6 +53,8 @@ export interface DocumentInfoPanelProps {
|
|||||||
|
|
||||||
const DocumentInfoPanel: React.FC<DocumentInfoPanelProps> = ({
|
const DocumentInfoPanel: React.FC<DocumentInfoPanelProps> = ({
|
||||||
document,
|
document,
|
||||||
|
tagLookupById,
|
||||||
|
correspondentLookupById,
|
||||||
summaryProps = {},
|
summaryProps = {},
|
||||||
metadataItems: metadataItemsProp,
|
metadataItems: metadataItemsProp,
|
||||||
metadataPayload: metadataPayloadProp,
|
metadataPayload: metadataPayloadProp,
|
||||||
@@ -76,8 +81,9 @@ const DocumentInfoPanel: React.FC<DocumentInfoPanelProps> = ({
|
|||||||
if (Array.isArray(metadataItemsProp) && metadataItemsProp.length) {
|
if (Array.isArray(metadataItemsProp) && metadataItemsProp.length) {
|
||||||
return metadataItemsProp;
|
return metadataItemsProp;
|
||||||
}
|
}
|
||||||
return describeDocumentSummary(document);
|
|
||||||
}, [metadataItemsProp, document]);
|
return describeDocumentSummary(document, { tagLookupById, correspondentLookupById });
|
||||||
|
}, [metadataItemsProp, document, tagLookupById, correspondentLookupById]);
|
||||||
|
|
||||||
const metadataPayload = useMemo(() => {
|
const metadataPayload = useMemo(() => {
|
||||||
if (metadataPayloadProp !== undefined) {
|
if (metadataPayloadProp !== undefined) {
|
||||||
@@ -107,9 +113,10 @@ const DocumentInfoPanel: React.FC<DocumentInfoPanelProps> = ({
|
|||||||
<DocumentSummarySection
|
<DocumentSummarySection
|
||||||
document={document}
|
document={document}
|
||||||
layout={summaryLayout}
|
layout={summaryLayout}
|
||||||
|
correspondentLookupById={correspondentLookupById}
|
||||||
{...summaryProps}
|
{...summaryProps}
|
||||||
/>
|
/>
|
||||||
), [document, summaryLayout, summaryProps]);
|
), [document, summaryLayout, summaryProps, correspondentLookupById]);
|
||||||
|
|
||||||
const renderDetailsSection = useCallback(() => (
|
const renderDetailsSection = useCallback(() => (
|
||||||
<section className={`${base}__section`}>
|
<section className={`${base}__section`}>
|
||||||
|
|||||||
@@ -15,25 +15,12 @@ import {
|
|||||||
import { describeDocumentSummary, type DocumentSummaryRow } from '../logic/documentSummary';
|
import { describeDocumentSummary, type DocumentSummaryRow } from '../logic/documentSummary';
|
||||||
|
|
||||||
import { useFolderManager } from '../../folders/FolderManagerContext';
|
import { useFolderManager } from '../../folders/FolderManagerContext';
|
||||||
|
import type { Document, Tag, Correspondent } from '../../types/documents';
|
||||||
import type { FolderId, Identifier, TagId } from '../../types/identifiers';
|
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 {
|
interface TagSectionProps {
|
||||||
tags?: TagEntry[];
|
tags?: Tag[];
|
||||||
onRemove?: (tag: TagEntry) => void;
|
onRemove?: (tag: Tag) => void;
|
||||||
onAdd?: (payload: { value: string; option?: unknown; input?: unknown }) => void;
|
onAdd?: (payload: { value: string; option?: unknown; input?: unknown }) => void;
|
||||||
emptyMessage?: string;
|
emptyMessage?: string;
|
||||||
addPlaceholder?: string;
|
addPlaceholder?: string;
|
||||||
@@ -43,8 +30,8 @@ interface TagSectionProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface CorrespondentSectionProps {
|
interface CorrespondentSectionProps {
|
||||||
entries?: CorrespondentEntry[];
|
entries?: Correspondent[];
|
||||||
onRemove?: (entry: CorrespondentEntry) => void;
|
onRemove?: (entry: Correspondent) => void;
|
||||||
onAdd?: (payload: { name: string; option?: unknown; input?: unknown }) => void;
|
onAdd?: (payload: { name: string; option?: unknown; input?: unknown }) => void;
|
||||||
showCount?: boolean;
|
showCount?: boolean;
|
||||||
addPlaceholder?: string;
|
addPlaceholder?: string;
|
||||||
@@ -55,11 +42,12 @@ interface CorrespondentSectionProps {
|
|||||||
|
|
||||||
export interface DocumentSummarySectionProps {
|
export interface DocumentSummarySectionProps {
|
||||||
document?: Document | null;
|
document?: Document | null;
|
||||||
tagLookupById?: Map<TagId, TagEntry>;
|
tagLookupById?: Map<TagId, Tag>;
|
||||||
tagOptions?: SelectionAssignmentMenuItem[];
|
tagOptions?: SelectionAssignmentMenuItem[];
|
||||||
onTagAdd?: (doc: Document, value: string, context?: { option?: unknown }) => void;
|
onTagAdd?: (doc: Document, value: string, context?: { option?: unknown }) => void;
|
||||||
onTagRemove?: (docId: Identifier | undefined, tagId: TagId | undefined) => void;
|
onTagRemove?: (docId: Identifier | undefined, tagId: TagId | undefined) => void;
|
||||||
correspondents?: CorrespondentEntry[];
|
correspondents?: Correspondent[];
|
||||||
|
correspondentLookupById?: Map<Identifier, Correspondent>;
|
||||||
correspondentOptions?: SelectionAssignmentMenuItem[];
|
correspondentOptions?: SelectionAssignmentMenuItem[];
|
||||||
onCorrespondentAdd?: (payload: { document: Document; name: string; option?: unknown }) => void;
|
onCorrespondentAdd?: (payload: { document: Document; name: string; option?: unknown }) => void;
|
||||||
onCorrespondentRemove?: (payload: { documentId: Identifier | undefined; correspondentId: Identifier | undefined }) => void;
|
onCorrespondentRemove?: (payload: { documentId: Identifier | undefined; correspondentId: Identifier | undefined }) => void;
|
||||||
@@ -77,10 +65,9 @@ interface MetaItem {
|
|||||||
error?: string | null;
|
error?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const sortCorrespondents = (entries = []) =>
|
export const sortCorrespondents = (entries: Correspondent[] = []) =>
|
||||||
entries
|
entries
|
||||||
.filter((entry) => entry && entry.name)
|
.filter((entry) => entry && entry.name)
|
||||||
.map(({ id, name, count }) => ({ id, name, count }))
|
|
||||||
.sort((a, b) => a.name.localeCompare(b.name));
|
.sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
|
||||||
export const buildCorrespondentOptions = (entries = []) => {
|
export const buildCorrespondentOptions = (entries = []) => {
|
||||||
@@ -237,8 +224,8 @@ const TagSection: React.FC<TagSectionProps> = ({
|
|||||||
}
|
}
|
||||||
if (item.state === 'all' && onRemove) {
|
if (item.state === 'all' && onRemove) {
|
||||||
const payload = item.payload && typeof item.payload === 'object' && 'id' in item.payload
|
const payload = item.payload && typeof item.payload === 'object' && 'id' in item.payload
|
||||||
? (item.payload as TagEntry)
|
? (item.payload as Tag)
|
||||||
: tags.find((tag) => (tag.id ?? tag.label) === item.id) ?? { id: item.id, label: item.label };
|
: tags.find((tag) => (tag.id ?? tag.label) === item.id) ?? { id: item.id, label: item.label } as unknown as Tag;
|
||||||
onRemove(payload);
|
onRemove(payload);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -344,7 +331,7 @@ const CorrespondentSection: React.FC<CorrespondentSectionProps> = ({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const key = label.toLowerCase();
|
const key = label.toLowerCase();
|
||||||
const payload = { id: entry.id, name: label };
|
const payload = entry;
|
||||||
if (map.has(key)) {
|
if (map.has(key)) {
|
||||||
const item = map.get(key);
|
const item = map.get(key);
|
||||||
if (item) {
|
if (item) {
|
||||||
@@ -370,9 +357,7 @@ const CorrespondentSection: React.FC<CorrespondentSectionProps> = ({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (item.state === 'all' && onRemove) {
|
if (item.state === 'all' && onRemove) {
|
||||||
const payload = item.payload && typeof item.payload === 'object' && 'id' in item.payload
|
const payload = (item.payload || { id: item.id, name: item.label }) as Correspondent;
|
||||||
? (item.payload as CorrespondentEntry)
|
|
||||||
: entries.find((entry) => (entry.id ?? entry.name) === item.id) ?? { id: item.id, name: item.label };
|
|
||||||
onRemove(payload);
|
onRemove(payload);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -389,7 +374,7 @@ const CorrespondentSection: React.FC<CorrespondentSectionProps> = ({
|
|||||||
: { id: null, name: resolvedName };
|
: { id: null, name: resolvedName };
|
||||||
onAdd({ name: resolvedName, option: payload, input: null });
|
onAdd({ name: resolvedName, option: payload, input: null });
|
||||||
},
|
},
|
||||||
[entries, onAdd, onRemove],
|
[onAdd, onRemove],
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -401,7 +386,7 @@ const CorrespondentSection: React.FC<CorrespondentSectionProps> = ({
|
|||||||
<span key={key} className="correspondent-pill">
|
<span key={key} className="correspondent-pill">
|
||||||
<span className="correspondent-pill__label">
|
<span className="correspondent-pill__label">
|
||||||
{entry.name}
|
{entry.name}
|
||||||
{showCount && entry.count ? ` (${entry.count})` : ''}
|
{showCount && entry.usage_count ? ` (${entry.usage_count})` : ''}
|
||||||
</span>
|
</span>
|
||||||
{onRemove ? (
|
{onRemove ? (
|
||||||
<button
|
<button
|
||||||
@@ -446,6 +431,7 @@ const DocumentSummarySection: React.FC<DocumentSummarySectionProps> = ({
|
|||||||
onTagAdd,
|
onTagAdd,
|
||||||
onTagRemove,
|
onTagRemove,
|
||||||
correspondents,
|
correspondents,
|
||||||
|
correspondentLookupById,
|
||||||
correspondentOptions = [],
|
correspondentOptions = [],
|
||||||
onCorrespondentAdd,
|
onCorrespondentAdd,
|
||||||
onCorrespondentRemove,
|
onCorrespondentRemove,
|
||||||
@@ -456,7 +442,7 @@ const DocumentSummarySection: React.FC<DocumentSummarySectionProps> = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const folderManager = useFolderManager();
|
const folderManager = useFolderManager();
|
||||||
const isCompactLayout = layout === 'compact';
|
const isCompactLayout = layout === 'compact';
|
||||||
const summaryRows = useMemo(() => describeDocumentSummary(document), [document]);
|
const summaryRows = useMemo(() => describeDocumentSummary(document, { tagLookupById }), [document, tagLookupById]);
|
||||||
const issuedDateLabel = useMemo(
|
const issuedDateLabel = useMemo(
|
||||||
() => formatDate(document?.issued_at, { fallback: null }),
|
() => formatDate(document?.issued_at, { fallback: null }),
|
||||||
[document?.issued_at],
|
[document?.issued_at],
|
||||||
@@ -469,23 +455,27 @@ const DocumentSummarySection: React.FC<DocumentSummarySectionProps> = ({
|
|||||||
if (!Array.isArray(document?.tags)) {
|
if (!Array.isArray(document?.tags)) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
return document.tags.map((tag) => ({
|
|
||||||
id: tag.id,
|
return document.tags.map((tagId) => tagLookupById.get(tagId))
|
||||||
label: tag.label,
|
.filter((t): t is Tag => Boolean(t))
|
||||||
color: tag.color ?? tagLookupById.get(tag.id)?.color ?? null,
|
.sort((a, b) => {
|
||||||
})).sort((a, b) => {
|
const labelA = (a.label || '').toLowerCase();
|
||||||
const labelA = (a.label || '').toLowerCase();
|
const labelB = (b.label || '').toLowerCase();
|
||||||
const labelB = (b.label || '').toLowerCase();
|
return labelA.localeCompare(labelB);
|
||||||
return labelA.localeCompare(labelB);
|
});
|
||||||
});
|
|
||||||
}, [document?.tags, tagLookupById]);
|
}, [document?.tags, tagLookupById]);
|
||||||
|
|
||||||
const resolvedCorrespondents = useMemo(() => {
|
const resolvedCorrespondents = useMemo(() => {
|
||||||
if (Array.isArray(correspondents) && correspondents.length) {
|
if (Array.isArray(correspondents) && correspondents.length) {
|
||||||
return correspondents;
|
return correspondents;
|
||||||
}
|
}
|
||||||
return sortCorrespondents(document?.correspondents || []);
|
if (!document?.correspondents) return [];
|
||||||
}, [correspondents, document?.correspondents]);
|
|
||||||
|
return document.correspondents
|
||||||
|
.map((id) => correspondentLookupById?.get(id))
|
||||||
|
.sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
|
||||||
|
}, [correspondents, document?.correspondents, correspondentLookupById]);
|
||||||
|
|
||||||
const extraSummaryRows = useMemo(() => {
|
const extraSummaryRows = useMemo(() => {
|
||||||
const rows: DocumentSummaryRow[] = [];
|
const rows: DocumentSummaryRow[] = [];
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
import { formatFileSize } from '../../utils/format';
|
import { formatFileSize } from '../../utils/format';
|
||||||
import { formatDateTime as defaultFormatDateTime } from '../../utils/date';
|
import { formatDateTime as defaultFormatDateTime } from '../../utils/date';
|
||||||
import { DEFAULT_FOLDER_NAME } from '../../app/workspaceUtils';
|
import { DEFAULT_FOLDER_NAME } from '../../app/workspaceUtils';
|
||||||
|
import type { Identifier } from '../../types/identifiers';
|
||||||
import type { DocumentTag, DocumentCorrespondent, Document } from '../../types/documents';
|
import type { Correspondent, Document, Tag } from '../../types/documents';
|
||||||
|
|
||||||
interface DescribeSummaryOptions {
|
interface DescribeSummaryOptions {
|
||||||
formatDateTime?: typeof defaultFormatDateTime;
|
formatDateTime?: typeof defaultFormatDateTime;
|
||||||
|
tagLookupById?: Map<Identifier, Tag> | null;
|
||||||
|
correspondentLookupById?: Map<Identifier, Correspondent> | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
type DocumentSummaryRowType = 'text' | 'editable-title' | 'editable-issued' | 'tags' | 'correspondents' | 'folder';
|
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 => {
|
export const describeDocumentSummary = (document?: Document | null, options: DescribeSummaryOptions = {}): DocumentSummary => {
|
||||||
const {
|
const {
|
||||||
formatDateTime = defaultFormatDateTime,
|
formatDateTime = defaultFormatDateTime,
|
||||||
|
tagLookupById,
|
||||||
|
correspondentLookupById,
|
||||||
} = options;
|
} = options;
|
||||||
|
|
||||||
const formatDateLabel = (value?: string | number | null) => {
|
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 metadata = doc.current_version?.metadata || null;
|
||||||
const pageCount = coercePageCount(metadata);
|
const pageCount = coercePageCount(metadata);
|
||||||
const pageCountLabel = pageCount !== null ? String(pageCount) : '—';
|
const pageCountLabel = pageCount !== null ? String(pageCount) : '—';
|
||||||
const folderLabel = doc.folder_id == null ? DEFAULT_FOLDER_NAME : `Folder ${doc.folder_id}`;
|
const folderLabel = doc.folder_id == null ? DEFAULT_FOLDER_NAME : `Folder ${doc.folder_id} `;
|
||||||
const tags = sanitizeArray<DocumentTag>(doc.tags);
|
const tags = sanitizeArray<Identifier>(doc.tags);
|
||||||
const correspondents = sanitizeArray<DocumentCorrespondent>(doc.correspondents);
|
const correspondents = sanitizeArray<Identifier>(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 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 tagsSummary = tagLabels.length ? tagLabels.join(', ') : '—';
|
||||||
const correspondentsSummary = correspondentLabels.length ? correspondentLabels.join(', ') : '—';
|
const correspondentsSummary = correspondentLabels.length ? correspondentLabels.join(', ') : '—';
|
||||||
return [
|
return [
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import type { EnsureAssetUrl, GetAsset } from '../../lib/assets/AssetManager';
|
|||||||
import type { Identifier } from '../../types/identifiers';
|
import type { Identifier } from '../../types/identifiers';
|
||||||
import type { Document } from '../../types/documents';
|
import type { Document } from '../../types/documents';
|
||||||
import { resolveBreadcrumbs } from '../../documents/logic/breadcrumbs';
|
import { resolveBreadcrumbs } from '../../documents/logic/breadcrumbs';
|
||||||
|
import type { Tag, Correspondent } from '../../types/documents';
|
||||||
|
|
||||||
interface FolderNode {
|
interface FolderNode {
|
||||||
id: Identifier | 'root';
|
id: Identifier | 'root';
|
||||||
@@ -34,7 +35,8 @@ interface UseDetailWorkspaceArgs {
|
|||||||
handleCorrespondentRemove?: (...args: unknown[]) => void;
|
handleCorrespondentRemove?: (...args: unknown[]) => void;
|
||||||
selectFolder?: (folderId?: Identifier | 'root') => void;
|
selectFolder?: (folderId?: Identifier | 'root') => void;
|
||||||
tags?: unknown[];
|
tags?: unknown[];
|
||||||
tagLookupById?: Map<Identifier, unknown> | null;
|
tagLookupById?: Map<Identifier, Tag> | null;
|
||||||
|
correspondentLookupById?: Map<Identifier, Correspondent> | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface UseDetailWorkspaceResult {
|
interface UseDetailWorkspaceResult {
|
||||||
@@ -73,6 +75,7 @@ const useDetailWorkspace = ({
|
|||||||
selectFolder,
|
selectFolder,
|
||||||
tags,
|
tags,
|
||||||
tagLookupById,
|
tagLookupById,
|
||||||
|
correspondentLookupById,
|
||||||
}: UseDetailWorkspaceArgs): UseDetailWorkspaceResult => {
|
}: UseDetailWorkspaceArgs): UseDetailWorkspaceResult => {
|
||||||
const {
|
const {
|
||||||
detailPanelOpen,
|
detailPanelOpen,
|
||||||
@@ -182,6 +185,7 @@ const useDetailWorkspace = ({
|
|||||||
document: detailPanelDocument,
|
document: detailPanelDocument,
|
||||||
tags,
|
tags,
|
||||||
tagLookupById,
|
tagLookupById,
|
||||||
|
correspondentLookupById,
|
||||||
onTagAdd: handleDocumentTagAdd,
|
onTagAdd: handleDocumentTagAdd,
|
||||||
onTagRemove: handleDocumentTagDetach,
|
onTagRemove: handleDocumentTagDetach,
|
||||||
onOpenPreview: openDocumentPreview,
|
onOpenPreview: openDocumentPreview,
|
||||||
|
|||||||
Reference in New Issue
Block a user