Files
papercrate/frontend/src/documents/DocumentSummarySection.tsx
T
2025-11-21 15:41:55 +01:00

839 lines
24 KiB
TypeScript

import React, { useCallback, useEffect, useMemo, useState, type FormEvent } from 'react';
import { Link } from 'react-router-dom';
import { EditIcon, IconX, CheckIcon, PlusIcon } from '../ui/icons';
import SelectionAssignmentMenu, {
SelectionAssignmentMenuItem,
type NormalizedSelectionAssignmentItem,
} from './SelectionAssignmentMenu';
import { getTagColorStyle } from '../utils/colors';
import {
formatDate,
toDateInputValue,
toIssuedTimestamp,
} from '../utils/date';
import { describeDocumentSummary, type DocumentSummaryRow } from './documentSummary';
import { isPlainObject } from '../utils/typeGuards';
import { useFolderManager } from '../folders/FolderManagerContext';
type Identifier = string | number;
interface TagEntry {
id?: Identifier;
label?: string;
color?: string | null;
}
interface CorrespondentEntry {
id?: Identifier;
name?: string;
count?: number;
}
interface DocumentLike {
id?: Identifier;
title?: string;
issued_at?: string | null;
folder_id?: string | null;
current_version?: { version_number?: number } | null;
tags?: TagEntry[];
correspondents?: CorrespondentEntry[];
[key: string]: unknown;
}
interface TagSectionProps {
tags?: TagEntry[];
onRemove?: (tag: TagEntry) => void;
onAdd?: (payload: { value: string; option?: unknown; input?: unknown }) => void;
emptyMessage?: string;
addPlaceholder?: string;
addButtonLabel?: string;
datalistOptions?: Array<SelectionAssignmentMenuItem | string>;
className?: string;
}
interface CorrespondentSectionProps {
entries?: CorrespondentEntry[];
onRemove?: (entry: CorrespondentEntry) => void;
onAdd?: (payload: { name: string; option?: unknown; input?: unknown }) => void;
showCount?: boolean;
addPlaceholder?: string;
addButtonLabel?: string;
datalistOptions?: Array<SelectionAssignmentMenuItem | string>;
className?: string;
}
export interface DocumentSummarySectionProps {
document?: DocumentLike | null;
tagLookupById?: Map<Identifier, TagEntry>;
tagOptions?: SelectionAssignmentMenuItem[];
onTagAdd?: (doc: DocumentLike, value: string, context?: { option?: unknown }) => void;
onTagRemove?: (docId: Identifier | undefined, tagId: Identifier | undefined) => void;
correspondents?: CorrespondentEntry[];
correspondentOptions?: SelectionAssignmentMenuItem[];
onCorrespondentAdd?: (payload: { document: DocumentLike; name: string; option?: unknown }) => void;
onCorrespondentRemove?: (payload: { documentId: Identifier | undefined; correspondentId: Identifier | undefined }) => void;
onUpdateTitle?: (docId: Identifier | undefined, title: string) => Promise<boolean> | boolean;
onUpdateIssued?: (docId: Identifier | undefined, timestamp: number | null) => Promise<boolean> | boolean;
onFolderNavigate?: (folderId: string | null) => void;
layout?: 'default' | 'compact';
}
interface MetaItem {
key: string;
label: string;
valueContent?: React.ReactNode | null;
fallbackValue?: string | null;
error?: string | null;
}
export const sortCorrespondents = (entries = []) =>
entries
.filter((entry) => entry && entry.name)
.map(({ id, name, count }) => ({ id, name, count }))
.sort((a, b) => a.name.localeCompare(b.name));
export const buildCorrespondentOptions = (entries = []) => {
const seen = new Set();
return entries.reduce((options, entry) => {
const name = entry?.name?.trim?.() || '';
if (!name) {
return options;
}
const key = name.toLowerCase();
if (seen.has(key)) {
return options;
}
seen.add(key);
options.push(name);
return options;
}, []);
};
const normalizeOptions = <T,>(options?: T[] | null): T[] => (Array.isArray(options) ? options : []);
interface QuickAddOption {
id?: Identifier;
label?: string;
name?: string;
[key: string]: unknown;
}
interface QuickAddEntry {
id: Identifier | string;
label: string;
original: QuickAddOption | string;
}
const resolveOptionName = (source?: QuickAddOption | string | null): string => {
if (!source) {
return '';
}
if (isPlainObject(source)) {
const raw = source.name ?? source.label ?? '';
return `${raw}`.trim();
}
return `${source}`.trim();
};
const normalizeQuickAddOption = (option?: QuickAddOption | string | null): QuickAddEntry | null => {
if (option == null) {
return null;
}
const label = (() => {
if (isPlainObject(option)) {
const sourceLabel = option.label ?? option.name ?? '';
return `${sourceLabel}`.trim();
}
return `${option}`.trim();
})();
if (!label) {
return null;
}
return {
id: isPlainObject(option) && option.id ? option.id : label,
label,
original: option,
};
};
export const TagSection: React.FC<TagSectionProps> = ({
tags = [],
onRemove,
onAdd,
emptyMessage = 'No tags yet.',
addPlaceholder = 'Add or create tag',
addButtonLabel = 'Add',
datalistOptions = [],
className,
}) => {
const handleCreate = useCallback(
(label: string) => onAdd?.({ value: label, input: null }),
[onAdd],
);
const handleSelect = useCallback(
(option: { label?: string; name?: string } | string | null) => {
if (!onAdd) return;
const label = resolveOptionName(option as QuickAddOption | string | null);
if (!label) {
return;
}
onAdd({ value: label, option });
},
[onAdd],
);
const normalizedOptions = useMemo(
() =>
normalizeOptions(datalistOptions)
.map((option) => normalizeQuickAddOption(option))
.filter((option): option is QuickAddEntry => Boolean(option)),
[datalistOptions],
);
const containerClass = className ? `tag-list ${className}` : 'tag-list';
const showQuickAdd = Boolean(onAdd);
const assignmentItems = useMemo<SelectionAssignmentMenuItem[]>(() => {
const map = new Map<string, SelectionAssignmentMenuItem>();
normalizedOptions.forEach((option) => {
const label = option?.label?.trim();
if (!label) {
return;
}
const key = label.toLowerCase();
if (map.has(key)) {
return;
}
map.set(key, {
id: option.id ?? label,
label,
state: 'none',
payload: option.original ?? { label },
});
});
tags.forEach((tag) => {
const label = tag?.label?.trim?.() || '';
if (!label) {
return;
}
const key = label.toLowerCase();
const payload = { id: tag.id, label, color: tag.color ?? null };
if (map.has(key)) {
const entry = map.get(key);
if (entry) {
entry.state = 'all';
entry.payload = payload;
}
return;
}
map.set(key, {
id: tag.id ?? label,
label,
state: 'all',
payload,
});
});
return Array.from(map.values());
}, [normalizedOptions, tags]);
const handleAssignmentSelect = useCallback(
(item: NormalizedSelectionAssignmentItem) => {
if (!item) {
return;
}
if (item.state === 'all' && onRemove) {
const payload = isPlainObject(item.payload)
? (item.payload as TagEntry)
: tags.find((tag) => (tag.id ?? tag.label) === item.id) ?? { id: item.id, label: item.label };
onRemove(payload);
return;
}
const payload = item.payload ?? { label: item.label };
handleSelect(payload);
},
[handleSelect, onRemove, tags],
);
return (
<div className={containerClass}>
{tags.map((tag) => {
const key = tag.id ?? tag.label;
const style = getTagColorStyle(tag.color);
return (
<span key={key} className="badge tag-chip" style={style || undefined}>
<span className="tag-chip__label">{tag.label}</span>
{onRemove ? (
<button
type="button"
className="tag-chip__remove"
onClick={() => onRemove(tag)}
aria-label={`Remove tag ${tag.label}`}
>
<IconX className="icon-inline" aria-hidden="true" />
</button>
) : null}
</span>
);
})}
{showQuickAdd ? (
<SelectionAssignmentMenu
label="Add tag"
items={assignmentItems}
placeholder={addPlaceholder}
emptyMessage="No tags"
createLabel={addButtonLabel}
onToggle={handleAssignmentSelect}
onCreate={handleCreate}
showStateIndicators
showCounts={false}
positionStrategy="fixed"
triggerClassName="quick-add__chip quick-add__trigger"
triggerContent={<PlusIcon className="icon-inline" aria-hidden="true" />}
closeOnSelection={false}
freezeSortOnOpen
/>
) : null}
{!tags.length && !showQuickAdd ? <span className="tag-list__empty meta">{emptyMessage}</span> : null}
</div>
);
};
export const CorrespondentSection: React.FC<CorrespondentSectionProps> = ({
entries = [],
onRemove,
onAdd,
showCount = false,
addPlaceholder = 'Add or create correspondent',
addButtonLabel = 'Add',
datalistOptions = [],
className,
}) => {
const handleCreate = useCallback(
(name: string) => onAdd?.({ name, input: null }),
[onAdd],
);
const normalizedOptions = useMemo(
() =>
normalizeOptions(datalistOptions)
.map((option) => normalizeQuickAddOption(option))
.filter((option): option is QuickAddEntry => Boolean(option)),
[datalistOptions],
);
const hasEntries = entries && entries.length > 0;
const showQuickAdd = Boolean(onAdd);
const containerClass = className ? `correspondent-list ${className}` : 'correspondent-list';
const assignmentItems = useMemo<SelectionAssignmentMenuItem[]>(() => {
const map = new Map<string, SelectionAssignmentMenuItem>();
normalizedOptions.forEach((option) => {
const label = option?.label?.trim();
if (!label) {
return;
}
const key = label.toLowerCase();
if (map.has(key)) {
return;
}
map.set(key, {
id: option.id ?? label,
label,
state: 'none',
payload: option.original ?? { name: label },
});
});
entries.forEach((entry) => {
const label = entry?.name?.trim?.() || '';
if (!label) {
return;
}
const key = label.toLowerCase();
const payload = { id: entry.id, name: label };
if (map.has(key)) {
const item = map.get(key);
if (item) {
item.state = 'all';
item.payload = payload;
}
return;
}
map.set(key, {
id: entry.id ?? label,
label,
state: 'all',
payload,
});
});
return Array.from(map.values());
}, [normalizedOptions, entries]);
const handleAssignmentSelect = useCallback(
(item: NormalizedSelectionAssignmentItem) => {
if (!item) {
return;
}
if (item.state === 'all' && onRemove) {
const payload = isPlainObject(item.payload)
? (item.payload as CorrespondentEntry)
: entries.find((entry) => (entry.id ?? entry.name) === item.id) ?? { id: item.id, name: item.label };
onRemove(payload);
return;
}
if (!onAdd) {
return;
}
const source = (item.payload ?? item) as QuickAddOption | string | null;
const resolvedName = resolveOptionName(source);
if (!resolvedName) {
return;
}
const payload = isPlainObject(source)
? { ...source, name: resolvedName }
: { id: null, name: resolvedName };
onAdd({ name: resolvedName, option: payload, input: null });
},
[entries, onAdd, onRemove],
);
return (
<div className={containerClass}>
{hasEntries
? entries.map((entry) => {
const key = entry.id ?? entry.name;
return (
<span key={key} className="correspondent-pill">
<span className="correspondent-pill__label">
{entry.name}
{showCount && entry.count ? ` (${entry.count})` : ''}
</span>
{onRemove ? (
<button
type="button"
className="correspondent-pill__remove"
onClick={() => onRemove(entry)}
aria-label={`Remove ${entry.name}`}
>
<IconX className="icon-inline" aria-hidden="true" />
</button>
) : null}
</span>
);
})
: !showQuickAdd && <span className="meta">No correspondents yet.</span>}
{showQuickAdd ? (
<SelectionAssignmentMenu
label="Add correspondent"
items={assignmentItems}
placeholder={addPlaceholder}
emptyMessage="No correspondents"
createLabel={addButtonLabel}
onToggle={handleAssignmentSelect}
onCreate={handleCreate}
showStateIndicators
showCounts={false}
positionStrategy="fixed"
triggerClassName="quick-add__chip quick-add__trigger"
triggerContent={<PlusIcon className="icon-inline" aria-hidden="true" />}
closeOnSelection={false}
freezeSortOnOpen
/>
) : null}
</div>
);
};
const DocumentSummarySection: React.FC<DocumentSummarySectionProps> = ({
document,
tagLookupById = new Map(),
tagOptions = [],
onTagAdd,
onTagRemove,
correspondents,
correspondentOptions = [],
onCorrespondentAdd,
onCorrespondentRemove,
onUpdateTitle,
onUpdateIssued,
onFolderNavigate,
layout = 'default',
}) => {
const folderManager = useFolderManager();
const isCompactLayout = layout === 'compact';
const summaryRows = useMemo(() => describeDocumentSummary(document), [document]);
const issuedDateLabel = useMemo(
() => formatDate(document?.issued_at, { fallback: null }),
[document?.issued_at],
);
const editableTitle = Boolean(document && onUpdateTitle);
const editableIssued = Boolean(document && onUpdateIssued);
const resolvedTags = useMemo(() => {
if (!Array.isArray(document?.tags)) {
return [];
}
return document.tags.map((tag) => ({
id: tag.id,
label: tag.label,
color: tag.color ?? tagLookupById.get(tag.id)?.color ?? null,
}));
}, [document?.tags, tagLookupById]);
const resolvedCorrespondents = useMemo(() => {
if (Array.isArray(correspondents) && correspondents.length) {
return correspondents;
}
return sortCorrespondents(document?.correspondents || []);
}, [correspondents, document?.correspondents]);
const extraSummaryRows = useMemo(() => {
const rows: DocumentSummaryRow[] = [];
const currentVersionNumber = document?.current_version?.version_number;
if (Number.isFinite(currentVersionNumber)) {
rows.push({
key: 'current-version',
label: 'Current version',
value: `#${currentVersionNumber}`,
});
}
return rows;
}, [document?.current_version?.version_number]);
const resolvedFolderId = document?.folder_id ?? null;
const [folderName, setFolderName] = useState<string | null>(() => folderManager.getNameSync(resolvedFolderId));
useEffect(() => {
let active = true;
const cached = folderManager.getNameSync(resolvedFolderId);
setFolderName(cached);
if (!cached && resolvedFolderId != null) {
folderManager.resolveName(resolvedFolderId).then((name) => {
if (active) {
setFolderName(name);
}
}).catch(() => {});
}
return () => {
active = false;
};
}, [resolvedFolderId, folderManager]);
const folderHref = resolvedFolderId == null ? '/documents' : `/documents/folder/${resolvedFolderId}`;
const handleFolderClick = useCallback(
(event: React.MouseEvent) => {
if (!onFolderNavigate) {
return;
}
event.preventDefault();
onFolderNavigate(resolvedFolderId);
},
[onFolderNavigate, resolvedFolderId],
);
const [titleDraft, setTitleDraft] = useState('');
const [titleSaving, setTitleSaving] = useState(false);
const [titleError, setTitleError] = useState(null);
const [isTitleEditing, setIsTitleEditing] = useState(false);
const [issuedDraft, setIssuedDraft] = useState('');
const [issuedSaving, setIssuedSaving] = useState(false);
const [issuedError, setIssuedError] = useState(null);
const [isIssuedEditing, setIsIssuedEditing] = useState(false);
useEffect(() => {
setIsTitleEditing(false);
setTitleDraft('');
setTitleError(null);
setTitleSaving(false);
setIsIssuedEditing(false);
setIssuedDraft('');
setIssuedError(null);
setIssuedSaving(false);
}, [document?.id]);
const startTitleEdit = useCallback(() => {
if (!editableTitle || !document) return;
setIsTitleEditing(true);
setTitleDraft(document.title || '');
setTitleError(null);
}, [document, editableTitle]);
const cancelTitleEdit = useCallback(() => {
setIsTitleEditing(false);
setTitleDraft('');
setTitleError(null);
setTitleSaving(false);
}, []);
const submitTitleEdit = useCallback(
async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (!editableTitle || !document || !onUpdateTitle) return;
const trimmed = titleDraft.trim();
if (!trimmed) {
setTitleError('Title cannot be empty.');
return;
}
setTitleSaving(true);
try {
const ok = await onUpdateTitle(document.id, trimmed);
if (ok) {
cancelTitleEdit();
} else {
setTitleError('Failed to update title.');
}
} finally {
setTitleSaving(false);
}
},
[cancelTitleEdit, document, editableTitle, onUpdateTitle, titleDraft],
);
const startIssuedEdit = useCallback(() => {
if (!editableIssued || !document) return;
setIsIssuedEditing(true);
setIssuedDraft(toDateInputValue(document.issued_at));
setIssuedError(null);
}, [document, editableIssued]);
const cancelIssuedEdit = useCallback(() => {
setIsIssuedEditing(false);
setIssuedDraft('');
setIssuedError(null);
setIssuedSaving(false);
}, []);
const submitIssuedEdit = useCallback(
async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (!editableIssued || !document || !onUpdateIssued) return;
const normalizedValue = issuedDraft ? toIssuedTimestamp(issuedDraft, document.issued_at) : null;
setIssuedSaving(true);
try {
const ok = await onUpdateIssued(document.id, normalizedValue);
if (ok) {
cancelIssuedEdit();
} else {
setIssuedError('Failed to update issued date.');
}
} finally {
setIssuedSaving(false);
}
},
[cancelIssuedEdit, document, editableIssued, issuedDraft, onUpdateIssued],
);
if (!document) {
return null;
}
const renderTitleEditForm = (extraClassName?: string) => (
<form className={`doc-title-edit${extraClassName ? ` ${extraClassName}` : ''}`} onSubmit={submitTitleEdit}>
<input
value={titleDraft}
onChange={(event) => {
setTitleDraft(event.target.value);
if (titleError) {
setTitleError(null);
}
}}
onKeyDown={(event) => {
if (event.key === 'Escape') {
event.preventDefault();
cancelTitleEdit();
}
}}
aria-label="Document title"
autoFocus
disabled={titleSaving}
/>
<button type="submit" className="icon-button icon-button--accent" disabled={titleSaving} aria-label="Save title">
<CheckIcon size={16} />
</button>
<button
type="button"
className="icon-button"
onClick={cancelTitleEdit}
disabled={titleSaving}
aria-label="Cancel"
>
<IconX size={16} />
</button>
</form>
);
const titleMetaDisplay = editableTitle && isTitleEditing
? renderTitleEditForm('doc-title-edit--inline')
: (
<>
<span className="detail-meta__value">{document?.title}</span>
{editableTitle ? (
<button
type="button"
className="icon-button"
onClick={startTitleEdit}
aria-label="Edit title"
title="Edit title"
>
<EditIcon className="icon-inline" />
</button>
) : null}
</>
);
const issuedDisplay = editableIssued && isIssuedEditing ? (
<form className="doc-issued-edit" onSubmit={submitIssuedEdit}>
<input
type="date"
value={issuedDraft}
onChange={(event) => {
setIssuedDraft(event.target.value);
if (issuedError) {
setIssuedError(null);
}
}}
aria-label="Issued on"
disabled={issuedSaving}
/>
<button type="submit" className="icon-button icon-button--accent" disabled={issuedSaving} aria-label="Save issued date">
<CheckIcon size={16} />
</button>
<button
type="button"
className="icon-button"
onClick={cancelIssuedEdit}
disabled={issuedSaving}
aria-label="Cancel"
>
<IconX size={16} />
</button>
</form>
) : (
<>
<span className="detail-meta__value">{issuedDateLabel || 'Not set'}</span>
{editableIssued ? (
<button
type="button"
className="icon-button"
onClick={startIssuedEdit}
aria-label={issuedDateLabel ? 'Edit issued date' : 'Set issued date'}
title={issuedDateLabel ? 'Edit issued date' : 'Set issued date'}
>
<EditIcon className="icon-inline" />
</button>
) : null}
</>
);
const tagsValueContent = (
<TagSection
tags={resolvedTags}
onRemove={
onTagRemove
? (tag) => onTagRemove(document.id, tag.id)
: undefined
}
onAdd={
onTagAdd
? ({ value, option }) => onTagAdd(document, value, { option })
: undefined
}
datalistOptions={tagOptions}
className="document-summary__tags"
/>
);
const correspondentsValueContent = (
<CorrespondentSection
entries={resolvedCorrespondents}
onRemove={
onCorrespondentRemove
? (entry) =>
onCorrespondentRemove({
documentId: document.id,
correspondentId: entry.id,
})
: undefined
}
onAdd={
onCorrespondentAdd
? ({ name, option }) =>
onCorrespondentAdd({
document,
name,
option,
})
: undefined
}
showCount
datalistOptions={correspondentOptions}
className="document-summary__correspondents"
/>
);
const folderValueContent = (
<Link
className="document-summary__folder-link"
to={folderHref}
onClick={handleFolderClick}
>
{folderName}
</Link>
);
const summaryRowOverrides = {
title: { valueContent: titleMetaDisplay, error: titleError },
issued: { valueContent: issuedDisplay, error: issuedError },
tags: { valueContent: tagsValueContent },
correspondents: { valueContent: correspondentsValueContent },
folder: { valueContent: folderValueContent },
} as Record<string, { valueContent?: React.ReactNode | null; error?: string | null }>;
const baseRows: MetaItem[] = [...summaryRows, ...extraSummaryRows].map((row) => {
const overrides = summaryRowOverrides[row.key] || {};
return {
key: row.key,
label: row.label,
valueContent: overrides.valueContent ?? null,
fallbackValue: overrides.valueContent ? row.value : row.value,
error: overrides.error ?? null,
};
});
const allRows = baseRows;
const summaryClass = `document-summary${isCompactLayout ? ' document-summary--compact' : ''}`;
const sectionClass = `document-summary__section document-summary__meta${isCompactLayout ? ' document-summary__meta--compact' : ''}`;
const listClass = `document-summary__details-list${isCompactLayout ? ' document-summary__details-list--meta' : ''}`;
return (
<div className={summaryClass}>
<section className={sectionClass}>
<dl className={listClass}>
{allRows.map((item) => (
<div key={item.key} className="document-summary__details-row">
<dt>{item.label}</dt>
<dd>
{item.valueContent != null && item.valueContent !== ''
? item.valueContent
: item.fallbackValue || '—'}
</dd>
{item.error ? <div className="status-inline error">{item.error}</div> : null}
</div>
))}
</dl>
</section>
</div>
);
};
export default DocumentSummarySection;