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; 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; className?: string; } export interface DocumentSummarySectionProps { document?: DocumentLike | null; tagLookupById?: Map; 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; onUpdateIssued?: (docId: Identifier | undefined, timestamp: number | null) => Promise | 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 = (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 = ({ 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(() => { const map = new Map(); 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 (
{tags.map((tag) => { const key = tag.id ?? tag.label; const style = getTagColorStyle(tag.color); return ( {tag.label} {onRemove ? ( ) : null} ); })} {showQuickAdd ? (
); }; export const CorrespondentSection: React.FC = ({ 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(() => { const map = new Map(); 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 (
{hasEntries ? entries.map((entry) => { const key = entry.id ?? entry.name; return ( {entry.name} {showCount && entry.count ? ` (${entry.count})` : ''} {onRemove ? ( ) : null} ); }) : !showQuickAdd && No correspondents yet.} {showQuickAdd ? (
); }; const DocumentSummarySection: React.FC = ({ 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(() => 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) => { 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) => { 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) => (
{ setTitleDraft(event.target.value); if (titleError) { setTitleError(null); } }} onKeyDown={(event) => { if (event.key === 'Escape') { event.preventDefault(); cancelTitleEdit(); } }} aria-label="Document title" autoFocus disabled={titleSaving} />
); const titleMetaDisplay = editableTitle && isTitleEditing ? renderTitleEditForm('doc-title-edit--inline') : ( <> {document?.title} {editableTitle ? ( ) : null} ); const issuedDisplay = editableIssued && isIssuedEditing ? (
{ setIssuedDraft(event.target.value); if (issuedError) { setIssuedError(null); } }} aria-label="Issued on" disabled={issuedSaving} />
) : ( <> {issuedDateLabel || 'Not set'} {editableIssued ? ( ) : null} ); const tagsValueContent = ( onTagRemove(document.id, tag.id) : undefined } onAdd={ onTagAdd ? ({ value, option }) => onTagAdd(document, value, { option }) : undefined } datalistOptions={tagOptions} className="document-summary__tags" /> ); const correspondentsValueContent = ( 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 = ( {folderName} ); const summaryRowOverrides = { title: { valueContent: titleMetaDisplay, error: titleError }, issued: { valueContent: issuedDisplay, error: issuedError }, tags: { valueContent: tagsValueContent }, correspondents: { valueContent: correspondentsValueContent }, folder: { valueContent: folderValueContent }, } as Record; 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 (
{allRows.map((item) => (
{item.label}
{item.valueContent != null && item.valueContent !== '' ? item.valueContent : item.fallbackValue || '—'}
{item.error ?
{item.error}
: null}
))}
); }; export default DocumentSummarySection;