diff --git a/frontend/src/app/AppLayout.jsx b/frontend/src/app/AppLayout.jsx index 016d43c..cc0fd2a 100644 --- a/frontend/src/app/AppLayout.jsx +++ b/frontend/src/app/AppLayout.jsx @@ -1859,7 +1859,7 @@ const AppLayout = () => { ); const handleCorrespondentAdd = useCallback( - async ({ document, name, input }) => { + async ({ document, name, input = null, option = null }) => { if (!document?.id) { throw new Error('Missing document for correspondent assignment.'); } @@ -1869,7 +1869,12 @@ const AppLayout = () => { return; } - let target = correspondentLookupByName.get(trimmed.toLowerCase()) || null; + let target = null; + if (option && option.id) { + target = correspondentLookupByName.get(trimmed.toLowerCase()) || option; + } else { + target = correspondentLookupByName.get(trimmed.toLowerCase()) || null; + } if (!target) { try { target = await handleCorrespondentCreate({ name: trimmed }); @@ -3585,6 +3590,35 @@ const AppLayout = () => { [notifyApiError, setStatusMessage, updateDocumentCaches, extractDocumentFromResponse], ); + const handleDocumentIssuedUpdate = useCallback( + async (documentId, nextIssuedDate) => { + setLoading(true); + const payload = { issued_at: nextIssuedDate || null }; + try { + const { data } = await api.patch(`/documents/${documentId}`, payload); + const updatedDocument = extractDocumentFromResponse(data); + + updateDocumentCaches(documentId, (doc) => { + if (updatedDocument) { + return { ...doc, ...updatedDocument }; + } + return { ...doc, issued_at: payload.issued_at }; + }); + + const message = payload.issued_at ? 'Issued date updated.' : 'Issued date cleared.'; + setStatusMessage(message, 'success'); + return true; + } catch (error) { + const message = error.response?.data?.error || 'Failed to update issued date.'; + notifyApiError(error, message); + return false; + } finally { + setLoading(false); + } + }, + [extractDocumentFromResponse, notifyApiError, setStatusMessage, updateDocumentCaches], + ); + const applyTagRemovalToCaches = useCallback( (documentId, tagId) => { if (!documentId || !tagId) { @@ -3631,10 +3665,21 @@ const AppLayout = () => { ); const handleTagAdd = useCallback( - async (document, label, input) => { + async (document, label, extras = null) => { const normalizedLabel = tagManager.normalizeLabel(label); - let tag = - tags.find((item) => item.label.toLowerCase() === normalizedLabel.toLowerCase()) || null; + const optionCandidate = + extras && typeof extras === 'object' && 'option' in extras ? extras.option : null; + const input = + extras && typeof extras === 'object' && 'input' in extras ? extras.input : null; + + let tag = null; + if (optionCandidate && optionCandidate.id) { + tag = tags.find((item) => item.id === optionCandidate.id) || optionCandidate; + } + if (!tag) { + tag = + tags.find((item) => item.label.toLowerCase() === normalizedLabel.toLowerCase()) || null; + } try { if (!tag) { const payload = tagManager.buildPayload({ label: normalizedLabel }); @@ -3644,7 +3689,9 @@ const AppLayout = () => { } await api.post(`/documents/${document.id}/tags`, { tag_ids: [tag.id] }); setStatusMessage('Tag assigned.', 'success'); - input.value = ''; + if (input && typeof input === 'object') { + input.value = ''; + } await refreshCurrentFolder(); } catch (error) { notifyApiError(error, 'Failed to assign tag.'); @@ -5104,6 +5151,7 @@ const AppLayout = () => { onPromoteSelection: promoteSelectionOrder, activePreviewId, onUpdateTitle: handleDocumentTitleUpdate, + onUpdateIssued: handleDocumentIssuedUpdate, ensureAssetUrl, getDocumentAsset, ensurePreviewData, @@ -5131,6 +5179,7 @@ const AppLayout = () => { handleCorrespondentRemove, handleDetailPanelClose, handleDocumentTitleUpdate, + handleDocumentIssuedUpdate, handleTagAdd, handleTagRemove, handleThumbnailRegeneration, diff --git a/frontend/src/app/useWorkspaceSurface.js b/frontend/src/app/useWorkspaceSurface.js index 2099103..70a04b9 100644 --- a/frontend/src/app/useWorkspaceSurface.js +++ b/frontend/src/app/useWorkspaceSurface.js @@ -68,6 +68,20 @@ export const useWorkspaceSurface = ({ if (!showPreviewWorkspace || !previewWorkspaceDocument) { return null; } + const detailExtras = detailPanelProps || {}; + const { + tagLookupById, + tags: tagOptions, + onTagAdd, + onTagRemove, + correspondents, + onCorrespondentAdd, + onCorrespondentRemove, + onUpdateTitle, + onUpdateIssued, + resolveFolderPath, + onFolderNavigate, + } = detailExtras; return createPreviewSurface({ document: previewWorkspaceDocument, previewEntry: previewWorkspaceEntry, @@ -79,6 +93,17 @@ export const useWorkspaceSurface = ({ onRegenerate: handleThumbnailRegeneration, onClose: closeDocumentPreview, renderSidebarToggle, + tagLookupById, + tagOptions, + onTagAdd, + onTagRemove, + correspondents, + onCorrespondentAdd, + onCorrespondentRemove, + onUpdateTitle, + onUpdateIssued, + resolveFolderPath, + onFolderNavigate, }); }, [ showPreviewWorkspace, @@ -92,6 +117,7 @@ export const useWorkspaceSurface = ({ handleThumbnailRegeneration, closeDocumentPreview, renderSidebarToggle, + detailPanelProps, ]); const workspaceSurface = useMemo(() => { diff --git a/frontend/src/detail/DetailPanel.jsx b/frontend/src/detail/DetailPanel.jsx index fe2635f..8da5dbe 100644 --- a/frontend/src/detail/DetailPanel.jsx +++ b/frontend/src/detail/DetailPanel.jsx @@ -1,7 +1,6 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { DownloadIcon, - EditIcon, ArrowLeftIcon, ArrowRightIcon, ChevronsRightIcon, @@ -10,13 +9,18 @@ import { TextScanIcon, } from '../ui/icons'; import PanelHeader from '../ui/PanelHeader'; -import { getTagColorStyle } from '../utils/colors'; import { formatFileSize } from '../utils/format'; import { resolveDocumentAssetUrl, createAssetView } from '../asset_manager'; import { useAssetNavigator } from '../hooks/useAssetNavigator'; import { describeDocumentSummary } from '../documents/documentSummary'; import { createDocumentActionState } from '../documents/documentActions'; import PreviewZoomOverlay from './PreviewZoomOverlay'; +import DocumentSummarySection, { + TagSection, + CorrespondentSection, + sortCorrespondents, + buildCorrespondentOptions, +} from '../documents/DocumentSummarySection'; const MAX_PREVIEW_STACK_ITEMS = 15; @@ -29,156 +33,6 @@ const derivePreviewOrientation = (metadata) => { return 'landscape'; }; -const sortCorrespondents = (entries = []) => - entries - .filter((entry) => entry && entry.name) - .map(({ id, name, count }) => ({ id, name, count })) - .sort((a, b) => a.name.localeCompare(b.name)); - -const CorrespondentPills = ({ entries = [], onRemove, showCount = false }) => ( -
- {entries.length ? ( - entries.map((entry, index) => { - const key = entry.id ?? `${entry.name}-${index}`; - return ( - - - - {entry.name} - {showCount && entry.count ? ` (${entry.count})` : ''} - - - {onRemove ? ( - - ) : null} - - ); - }) - ) : ( - No correspondents yet. - )} -
-); - -const TagSection = ({ - title, - tags = [], - onRemove, - onAdd, - emptyMessage = 'No tags yet.', - addPlaceholder = 'Add or create tag', - addButtonLabel = 'Add', - datalistId, - datalistOptions = [], - className, -}) => ( -
-
{title}
-
- {tags.length ? ( - tags.map((tag) => { - const key = tag.id ?? tag.label; - const style = getTagColorStyle(tag.color); - const removable = Boolean(onRemove); - const className = removable ? 'badge tag-chip tag-chip--removable' : 'badge tag-chip'; - return ( - - {tag.label} - {removable ? ( - - ) : null} - - ); - }) - ) : ( - {emptyMessage} - )} -
- {onAdd ? ( -
{ - event.preventDefault(); - const input = event.currentTarget.elements.tag; - const value = input.value.trim(); - if (!value) return; - onAdd({ value, input }); - }} - > - - - {datalistId ? ( - - {datalistOptions.map((option) => ( - - ) : null} -
- ) : null} -
-); - -const CorrespondentSection = ({ - title, - entries = [], - onRemove, - onAdd, - showCount = false, - addPlaceholder = 'Add or create correspondent', - addButtonLabel = 'Add', - datalistId, - datalistOptions = [], - className, -}) => ( -
-
{title}
- - {onAdd ? ( -
{ - event.preventDefault(); - const form = event.currentTarget; - const nameInput = form.elements.correspondent; - const value = nameInput.value.trim(); - if (!value) return; - onAdd({ name: value, input: nameInput }); - form.reset(); - }} - > - - - {datalistId ? ( - - {datalistOptions.map((name) => ( - - ) : null} -
- ) : null} -
-); - const computeStackAngle = (docId, index) => { if (index === 0) return 0; let hash = 0; @@ -294,6 +148,7 @@ const DetailPanel = ({ onBulkCorrespondentRemove, onPromoteSelection, onUpdateTitle = async () => false, + onUpdateIssued = async () => false, ensureAssetUrl = null, getDocumentAsset = () => null, ensurePreviewData = () => Promise.resolve(), @@ -337,10 +192,6 @@ const DetailPanel = ({ return `${selectedCount} document${selectedCount === 1 ? '' : 's'}`; }, [selectedCount, detailSummary]); - const [titleEditDocId, setTitleEditDocId] = useState(null); - const [titleDraft, setTitleDraft] = useState(''); - const [titleSaving, setTitleSaving] = useState(false); - const [titleError, setTitleError] = useState(null); const [zoomedPreview, setZoomedPreview] = useState(null); const bulkDocumentIds = useMemo( @@ -348,67 +199,10 @@ const DetailPanel = ({ [selectedDocuments], ); - useEffect(() => { - if (!singleDoc) { - setTitleEditDocId(null); - setTitleDraft(''); - setTitleError(null); - setTitleSaving(false); - return; - } - - if (titleEditDocId && titleEditDocId !== singleDoc.id) { - setTitleEditDocId(null); - setTitleDraft(''); - setTitleError(null); - setTitleSaving(false); - } - }, [singleDoc, titleEditDocId]); - useEffect(() => { setZoomedPreview(null); }, [selectionKey]); - const startTitleEdit = useCallback(() => { - if (!singleDoc) return; - setTitleEditDocId(singleDoc.id); - setTitleDraft(singleDoc.title || singleDoc.original_name); - setTitleError(null); - }, [singleDoc]); - - const cancelTitleEdit = useCallback(() => { - setTitleEditDocId(null); - setTitleDraft(''); - setTitleError(null); - setTitleSaving(false); - }, []); - - const submitTitleEdit = useCallback( - async (event) => { - event.preventDefault(); - if (!singleDoc) return; - const trimmed = titleDraft.trim(); - if (!trimmed) { - setTitleError('Title cannot be empty.'); - return; - } - setTitleSaving(true); - try { - const ok = await onUpdateTitle(singleDoc.id, trimmed); - if (ok) { - setTitleEditDocId(null); - setTitleDraft(''); - setTitleError(null); - } else { - setTitleError('Failed to update title.'); - } - } finally { - setTitleSaving(false); - } - }, - [singleDoc, titleDraft, onUpdateTitle], - ); - const handlePreviewActivate = useCallback( (docId) => { if (!docId) return; @@ -569,134 +363,16 @@ const DetailPanel = ({ }, 0); }, [stackPreviews, selectedDocuments]); - const availableCorrespondents = useMemo( - () => (Array.isArray(correspondents) ? correspondents : []), + const correspondentOptions = useMemo( + () => buildCorrespondentOptions(Array.isArray(correspondents) ? correspondents : []), [correspondents], ); - const correspondentOptions = useMemo(() => { - const seen = new Set(); - return availableCorrespondents.reduce((options, entry) => { - if (typeof entry?.name !== 'string') { - return options; - } - const name = entry.name.trim(); - if (!name) { - return options; - } - const lower = name.toLowerCase(); - if (seen.has(lower)) { - return options; - } - seen.add(lower); - options.push(name); - return options; - }, []); - }, [availableCorrespondents]); - const singleCorrespondents = useMemo(() => { if (!singleDoc) return []; return sortCorrespondents(singleDoc.correspondents || []); }, [singleDoc]); - const singleFolderPath = useMemo(() => { - if (!singleDoc?.folder_id) { - return null; - } - if (typeof resolveFolderPath !== 'function') { - return null; - } - const segments = resolveFolderPath(singleDoc.folder_id); - if (!Array.isArray(segments) || !segments.some((segment) => segment?.id && segment.id !== 'root')) { - return null; - } - return segments; - }, [singleDoc?.folder_id, resolveFolderPath]); - - const folderLabel = detailSummary.folderLabel; - - const folderDisplayNode = useMemo(() => { - if (!singleDoc) { - return folderLabel || '—'; - } - if (!singleFolderPath?.length) { - return folderLabel || '—'; - } - return ( - - {singleFolderPath.map((segment, index) => { - const label = segment?.name || '…'; - const targetId = segment?.id || null; - const key = `${targetId || label}-${index}`; - const isClickable = Boolean(targetId) && typeof onFolderNavigate === 'function'; - const href = !isClickable - ? null - : targetId === 'root' - ? '/documents' - : `/documents/folder/${targetId}`; - return ( - - {index > 0 ? / : null} - {isClickable ? ( - { - if ( - event.button !== 0 || - event.metaKey || - event.ctrlKey || - event.shiftKey || - event.altKey - ) { - return; - } - event.preventDefault(); - event.stopPropagation(); - onFolderNavigate(targetId); - }} - > - {label} - - ) : ( - {label} - )} - - ); - })} - - ); - }, [singleDoc, singleFolderPath, folderLabel, onFolderNavigate]); - - const detailInfoRows = useMemo(() => { - if (!singleDoc) { - return []; - } - const allowedKeys = new Set(['uploaded', 'size', 'type', 'issued', 'pages', 'created', 'updated', 'folder']); - const rows = detailSummary.summaryRows - .filter((row) => { - if (!allowedKeys.has(row.key)) { - return false; - } - if (row.key === 'pages') { - return Number.isFinite(detailSummary.pageCount); - } - if (row.key === 'folder') { - return Boolean(singleFolderPath?.length); - } - return true; - }) - .map((row) => (row.key === 'folder' ? { ...row, value: folderDisplayNode } : row)); - - rows.push({ - key: 'original-name', - label: 'Original filename', - value: singleDoc.original_name || '—', - }); - - return rows; - }, [singleDoc, detailSummary, folderDisplayNode, singleFolderPath]); - const bulkCorrespondents = useMemo(() => { if (selectedDocuments.length <= 1) { const doc = selectedDocuments[0]; @@ -971,15 +647,11 @@ const DetailPanel = ({ return

Select a document to view metadata, tags and actions.

; } - const displayName = singleDoc.title || singleDoc.original_name; - const isEditingTitle = titleEditDocId === singleDoc.id; - const tagsForDoc = Array.isArray(singleDoc.tags) ? singleDoc.tags : []; - const metadata = - singleDoc.metadata && Object.keys(singleDoc.metadata).length > 0 ? singleDoc.metadata : null; - const effectiveCardinality = singleEffectiveCardinality; + const effectiveCardinality = + singlePreviewNavigator.cardinality || (singlePreviewNavigator.currentUrl ? 1 : 0); const canGoPrev = singlePreviewNavigator.canGoPrev; const canGoNext = singlePreviewNavigator.canGoNext; - const hasPreviewImage = singleHasPreview; + const hasPreviewImage = Boolean(singlePreviewNavigator.currentUrl); const interceptNavPointer = (event) => { event.preventDefault(); event.stopPropagation(); @@ -997,142 +669,59 @@ const DetailPanel = ({ onZoomPreview={handleSingleZoom} /> {hasPreviewImage && (effectiveCardinality > 1 || canGoPrev || canGoNext) ? ( -
- +
+ className="preview-pane__nav-button preview-pane__nav-button--prev" + onClick={(event) => { + event.preventDefault(); + event.stopPropagation(); + singlePreviewNavigator.goPrev(); + }} + onPointerDown={interceptNavPointer} + onPointerUp={interceptNavPointer} + onMouseDown={interceptNavPointer} + onMouseUp={interceptNavPointer} + disabled={!canGoPrev} + aria-label="Previous preview" + > + + +
) : null}
-
- {isEditingTitle ? ( -
- { - setTitleDraft(event.target.value); - if (titleError) { - setTitleError(null); - } - }} - onKeyDown={(event) => { - if (event.key === 'Escape') { - event.preventDefault(); - cancelTitleEdit(); - } - }} - aria-label="Document title" - autoFocus - /> - - -
- ) : ( - <> -

{displayName}

- - - )} -
- {titleError ?
{titleError}
: null} -
- {detailInfoRows.map((row) => { - const rawValue = row.value; - const displayValue = - rawValue === null || rawValue === undefined || rawValue === '' ? '—' : rawValue; - return ( -
- {row.label}:{' '} - {displayValue} -
- ); - })} -
- ({ - id: tag.id, - label: tag.label, - color: tag.color || tagLookupById.get(tag.id)?.color, - }))} - onRemove={(tag) => onTagRemove(singleDoc.id, tag.id)} - onAdd={({ value, input }) => onTagAdd(singleDoc, value, input)} - datalistId="tag-catalog-single" - datalistOptions={tags} + onTagAdd(doc, value, extras)} + onTagRemove={(docId, tagId) => onTagRemove(docId, tagId)} + correspondents={singleCorrespondents} + correspondentOptions={correspondentOptions} + onCorrespondentAdd={onCorrespondentAdd} + onCorrespondentRemove={onCorrespondentRemove} + onUpdateTitle={onUpdateTitle} + onUpdateIssued={onUpdateIssued} + resolveFolderPath={resolveFolderPath} + onFolderNavigate={onFolderNavigate} /> - - onCorrespondentRemove?.({ - documentId: singleDoc.id, - correspondentId: entry.id, - }) - } - onAdd={({ name, input }) => - onCorrespondentAdd?.({ - document: singleDoc, - name, - input, - }) - } - datalistId="correspondent-catalog-single" - datalistOptions={correspondentOptions} - /> - {metadata && ( -
-
Metadata
-
{JSON.stringify(metadata, null, 2)}
-
- )} ); }; @@ -1140,6 +729,7 @@ const DetailPanel = ({ const renderBulk = () => { const countLabel = `${selectedCount} document${selectedCount === 1 ? '' : 's'}`; const sizeLabel = stackTotalSizeBytes ? formatFileSize(stackTotalSizeBytes) : '—'; + const headerLabel = `${countLabel}${sizeLabel ? ` (${sizeLabel})` : ''}`; const topDocIdLocal = topDocId; const topCardinalityLocal = topEffectiveCardinality; const topHasPreview = Boolean(stackPreviewNavigator.currentUrl); @@ -1200,35 +790,26 @@ const DetailPanel = ({ ) : null} -

{countLabel}

-
-
- Total size (stack): {sizeLabel} -
-
+

{headerLabel}

onBulkTagRemove?.({ label: tag.label, documentIds })} - onAdd={({ value, input }) => - onBulkTagAdd?.({ label: value, input, documentIds }) + onAdd={({ value }) => + onBulkTagAdd?.({ label: value, input: null, documentIds }) } addPlaceholder="Add tag to selection" addButtonLabel="Add tag" - datalistId="tag-catalog-bulk" datalistOptions={tags} className="bulk-tags" /> - onBulkCorrespondentAdd?.({ name, input, documentIds }) + onAdd={({ name }) => + onBulkCorrespondentAdd?.({ name, input: null, documentIds }) } addPlaceholder="Add correspondent to selection" - datalistId="correspondent-catalog-bulk" datalistOptions={correspondentOptions} showCount className="bulk-correspondents" diff --git a/frontend/src/documents/DocumentSummarySection.jsx b/frontend/src/documents/DocumentSummarySection.jsx new file mode 100644 index 0000000..a7f57cd --- /dev/null +++ b/frontend/src/documents/DocumentSummarySection.jsx @@ -0,0 +1,631 @@ +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { EditIcon, IconX, PlusIcon } from '../ui/icons'; +import QuickAddMenu from '../ui/QuickAddMenu'; +import { getTagColorStyle } from '../utils/colors'; +import { describeDocumentSummary } from './documentSummary'; + +const formatDate = (value) => { + if (!value) { + return null; + } + const date = new Date(value); + if (Number.isNaN(date.getTime())) { + return null; + } + return date.toLocaleDateString(); +}; + +const toDateInputValue = (value) => { + if (!value) { + return ''; + } + const date = new Date(value); + if (Number.isNaN(date.getTime())) { + return ''; + } + const timezoneOffset = date.getTimezoneOffset(); + const localDate = new Date(date.getTime() - timezoneOffset * 60000); + return localDate.toISOString().slice(0, 10); +}; + +const toIssuedTimestamp = (dateString, fallback) => { + if (!dateString) { + return null; + } + const base = fallback ? new Date(fallback) : new Date(); + if (Number.isNaN(base.getTime())) { + return null; + } + const [year, month, day] = dateString.split('-').map((part) => Number.parseInt(part, 10)); + if (!year || !month || !day) { + return null; + } + + const candidate = new Date(base); + candidate.setUTCFullYear(year, month - 1, day); + return candidate.toISOString(); +}; + +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 = typeof entry?.name === 'string' ? 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) => (Array.isArray(options) ? options : []); + +export const TagSection = ({ + tags = [], + onRemove, + onAdd, + emptyMessage = 'No tags yet.', + addPlaceholder = 'Add or create tag', + addButtonLabel = 'Add', + datalistOptions = [], + className, +}) => { + const handleCreate = useCallback( + (label) => onAdd?.({ value: label, input: null }), + [onAdd], + ); + + const handleSelect = useCallback( + (option) => { + if (!onAdd) return; + const label = + (option && typeof option === 'object' && option.label) || + (typeof option === 'string' ? option : ''); + if (!label) { + return; + } + onAdd({ value: label, option }); + }, + [onAdd], + ); + + const normalizedOptions = useMemo(() => normalizeOptions(datalistOptions), [datalistOptions]); + const containerClass = className ? `tag-list ${className}` : 'tag-list'; + const showQuickAdd = Boolean(onAdd); + + return ( +
+ {tags.map((tag) => { + const key = tag.id ?? tag.label; + const style = getTagColorStyle(tag.color); + return ( + + {tag.label} + {onRemove ? ( + + ) : null} + + ); + })} + {showQuickAdd ? ( + handleSelect(normalized || original)} + placeholder={addPlaceholder} + createLabel={addButtonLabel} + triggerAriaLabel="Add tag" + triggerTitle={addButtonLabel} + triggerClassName="quick-add__chip quick-add__trigger" + triggerContent={( + + + )} + /> + ) : null} + {!tags.length && !showQuickAdd ? {emptyMessage} : null} +
+ ); +}; + +export const CorrespondentSection = ({ + entries = [], + onRemove, + onAdd, + showCount = false, + addPlaceholder = 'Add or create correspondent', + addButtonLabel = 'Add', + datalistOptions = [], + className, +}) => { + const handleCreate = useCallback( + (name) => onAdd?.({ name, input: null }), + [onAdd], + ); + + const handleSelect = useCallback( + (original, normalized) => { + if (!onAdd) return; + const source = normalized && typeof normalized === 'object' ? normalized : original; + const resolvedName = + (source && typeof source.name === 'string' && source.name.trim()) || + (typeof source === 'string' ? source.trim() : '') || + (source && typeof source.label === 'string' ? source.label.trim() : ''); + if (!resolvedName) { + return; + } + const payload = + source && typeof source === 'object' + ? { ...source, name: resolvedName } + : { id: null, name: resolvedName }; + onAdd({ name: resolvedName, option: payload, input: null }); + }, + [onAdd], + ); + + const normalizedOptions = useMemo(() => normalizeOptions(datalistOptions), [datalistOptions]); + const hasEntries = entries && entries.length > 0; + const showQuickAdd = Boolean(onAdd); + const containerClass = className ? `correspondent-list ${className}` : 'correspondent-list'; + + 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 ? ( + handleSelect(original, normalized)} + placeholder={addPlaceholder} + createLabel={addButtonLabel} + triggerAriaLabel="Add correspondent" + triggerTitle={addButtonLabel} + triggerClassName="quick-add__chip quick-add__trigger" + triggerContent={( + + + )} + /> + ) : null} +
+ ); +}; + +const DocumentSummarySection = ({ + document, + tagLookupById = new Map(), + tagOptions = [], + onTagAdd, + onTagRemove, + correspondents, + correspondentOptions = [], + onCorrespondentAdd, + onCorrespondentRemove, + onUpdateTitle, + onUpdateIssued, + resolveFolderPath, + onFolderNavigate, +}) => { + const summary = useMemo(() => { + if (!document) { + return { + title: '', + originalName: '', + sizeLabel: '—', + pageCount: null, + }; + } + return describeDocumentSummary(document); + }, [document]); + const issuedDateLabel = useMemo(() => formatDate(document?.issued_at), [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 folderPath = useMemo(() => { + if (!document?.folder_id || typeof resolveFolderPath !== 'function') { + return null; + } + const segments = resolveFolderPath(document.folder_id); + const filtered = Array.isArray(segments) + ? segments.filter((segment) => segment?.id && segment.id !== 'root') + : null; + if (!filtered || !filtered.length) { + return null; + } + return filtered; + }, [document?.folder_id, resolveFolderPath]); + + const folderDisplayNode = useMemo(() => { + if (!folderPath || !folderPath.length) { + return null; + } + return ( + + {folderPath.map((segment, index) => { + const label = segment?.name || '…'; + const targetId = segment?.id || null; + const key = `${targetId || label}-${index}`; + const isClickable = Boolean(targetId) && typeof onFolderNavigate === 'function'; + const href = !isClickable + ? null + : targetId === 'root' + ? '/documents' + : `/documents/folder/${targetId}`; + return ( + + {index > 0 ? / : null} + {isClickable ? ( + { + if ( + event.button !== 0 || + event.metaKey || + event.ctrlKey || + event.shiftKey || + event.altKey + ) { + return; + } + event.preventDefault(); + event.stopPropagation(); + onFolderNavigate(targetId); + }} + > + {label} + + ) : ( + {label} + )} + + ); + })} + + ); + }, [folderPath, onFolderNavigate]); + + const metaRows = useMemo(() => { + const rows = []; + const currentVersionNumber = document?.current_version?.version_number; + if (Number.isFinite(currentVersionNumber)) { + rows.push({ + key: 'current-version', + label: 'Current version', + value: `#${currentVersionNumber}`, + }); + } + + if (summary.sizeLabel && summary.sizeLabel !== '—') { + rows.push({ key: 'size', label: 'Size', value: summary.sizeLabel }); + } + + if (Number.isFinite(summary.pageCount)) { + rows.push({ key: 'pages', label: 'Pages', value: String(summary.pageCount) }); + } + + return rows; + }, [document?.current_version?.version_number, summary]); + + 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 || document.original_name || ''); + setTitleError(null); + }, [document, editableTitle]); + + const cancelTitleEdit = useCallback(() => { + setIsTitleEditing(false); + setTitleDraft(''); + setTitleError(null); + setTitleSaving(false); + }, []); + + const submitTitleEdit = useCallback( + async (event) => { + event.preventDefault(); + if (!editableTitle || !document) 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) => { + event.preventDefault(); + if (!editableIssued || !document) 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], + ); + + const titleDisplay = summary.title || document?.original_name || 'Untitled document'; + + if (!document) { + return null; + } + + return ( +
+
+
+ {editableTitle && isTitleEditing ? ( +
+ { + setTitleDraft(event.target.value); + if (titleError) { + setTitleError(null); + } + }} + onKeyDown={(event) => { + if (event.key === 'Escape') { + event.preventDefault(); + cancelTitleEdit(); + } + }} + aria-label="Document title" + autoFocus + disabled={titleSaving} + /> + + +
+ ) : ( + <> +

{titleDisplay}

+ {editableTitle ? ( + + ) : null} + + )} +
+ {folderDisplayNode ? ( +
+ {folderDisplayNode} +
+ ) : null} +
+ {titleError ?
{titleError}
: null} + + onTagRemove(document.id, tag.id) + : undefined + } + onAdd={ + onTagAdd + ? ({ value, option }) => onTagAdd(document, value, { option }) + : undefined + } + datalistOptions={tagOptions} + /> + + + onCorrespondentRemove({ + documentId: document.id, + correspondentId: entry.id, + }) + : undefined + } + onAdd={ + onCorrespondentAdd + ? ({ name, option }) => + onCorrespondentAdd({ + document, + name, + option, + }) + : undefined + } + showCount + datalistOptions={correspondentOptions} + /> + +
+
+ Issued: + {editableIssued && isIssuedEditing ? ( +
+ { + setIssuedDraft(event.target.value); + if (issuedError) { + setIssuedError(null); + } + }} + aria-label="Issued on" + disabled={issuedSaving} + /> + + +
+ ) : ( + <> + {issuedDateLabel || 'Not set'} + {editableIssued ? ( + + ) : null} + + )} +
+ {issuedError ?
{issuedError}
: null} + + {metaRows.map((row) => ( +
+ {row.label}: + {row.value} +
+ ))} +
+
+ ); +}; + +export default DocumentSummarySection; diff --git a/frontend/src/documents/documentSummary.js b/frontend/src/documents/documentSummary.js index 66fb986..4630ea5 100644 --- a/frontend/src/documents/documentSummary.js +++ b/frontend/src/documents/documentSummary.js @@ -47,7 +47,7 @@ export const describeDocumentSummary = (document, options = {}) => { mimeTypeLabel: '—', sizeLabel: '—', createdAtLabel: '—', - issuedAtLabel: '—', + issuedLabel: '—', updatedAtLabel: '—', pageCount: null, pageCountLabel: '—', @@ -76,7 +76,7 @@ export const describeDocumentSummary = (document, options = {}) => { const pageCountLabel = Number.isFinite(pageCount) ? String(pageCount) : '—'; const createdAtLabel = formatDateTime(document.created_at); - const issuedAtLabel = formatDateTime(document.issued_at); + const issuedLabel = formatDateTime(document.issued_at); const updatedAtLabel = formatDateTime(document.updated_at); const folderLabel = document.folder_path || document.folder_name || null; @@ -96,7 +96,7 @@ export const describeDocumentSummary = (document, options = {}) => { { key: 'created', label: 'Created', value: createdAtLabel }, { key: 'size', label: 'Size', value: sizeLabel }, { key: 'type', label: 'Type', value: mimeTypeLabel }, - { key: 'issued', label: 'Issued', value: issuedAtLabel }, + { key: 'issued', label: 'Issued', value: issuedLabel }, { key: 'pages', label: 'Pages', value: pageCountLabel }, { key: 'updated', label: 'Updated', value: updatedAtLabel }, { key: 'folder', label: 'Folder', value: folderLabel || '—' }, @@ -110,7 +110,7 @@ export const describeDocumentSummary = (document, options = {}) => { mimeTypeLabel, sizeLabel, createdAtLabel, - issuedAtLabel, + issuedLabel, updatedAtLabel, pageCount, pageCountLabel, diff --git a/frontend/src/preview/PreviewWorkspace.jsx b/frontend/src/preview/PreviewWorkspace.jsx index eba2363..dee6597 100644 --- a/frontend/src/preview/PreviewWorkspace.jsx +++ b/frontend/src/preview/PreviewWorkspace.jsx @@ -1,92 +1,99 @@ -import React from 'react'; +import React, { useMemo } from 'react'; import { DownloadIcon, TextScanIcon, AnalyzeIcon, CloseIcon } from '../ui/icons'; -import { describeDocumentSummary } from '../documents/documentSummary'; +import DocumentSummarySection, { + buildCorrespondentOptions, + sortCorrespondents, +} from '../documents/DocumentSummarySection'; import { createDocumentActionState } from '../documents/documentActions'; +const formatDateTime = (value) => { + if (!value) { + return '—'; + } + const date = new Date(value); + return Number.isNaN(date.getTime()) ? '—' : date.toLocaleString(); +}; + const PreviewWorkspace = ({ document, previewEntry, + tagLookupById, + tagOptions, + onTagAdd, + onTagRemove, + correspondents, + onCorrespondentAdd, + onCorrespondentRemove, + onUpdateTitle, + onUpdateIssued, + resolveFolderPath, + onFolderNavigate, }) => { + const sortedCorrespondents = useMemo( + () => sortCorrespondents(document?.correspondents || []), + [document], + ); + + const correspondentOptions = useMemo( + () => buildCorrespondentOptions(Array.isArray(correspondents) ? correspondents : []), + [correspondents], + ); + + const metadataItems = useMemo(() => { + if (!document) { + return []; + } + return [ + { label: 'Created at', value: formatDateTime(document.created_at) }, + { label: 'Updated at', value: formatDateTime(document.updated_at) }, + { + label: 'Filename', + value: document.archive_path || document.filename || '—', + }, + { + label: 'Original filename', + value: document.original_name || '—', + }, + { + label: 'SHA-256 checksum', + value: document.current_version?.checksum || '—', + }, + { + label: 'Content type', + value: document.content_type || '—', + }, + ]; + }, [document]); + + const metadataPayload = useMemo(() => { + if (!document || !document.metadata || Object.keys(document.metadata).length === 0) { + return null; + } + return document.metadata; + }, [document]); + if (!document) { return null; } - const title = document.title; - const summary = describeDocumentSummary(document); - const correspondents = Array.isArray(document.correspondents) - ? document.correspondents.map((entry) => entry?.name).filter(Boolean).join(', ') - : ''; - const tags = Array.isArray(document.tags) - ? document.tags.map((tag) => tag?.label).filter(Boolean).join(', ') - : ''; - - const formatDateTime = (value) => { - if (!value) { - return '—'; - } - const date = new Date(value); - return Number.isNaN(date.getTime()) ? '—' : date.toLocaleString(); - }; - - const detailItems = [ - { label: 'Title', value: summary.title || '—' }, - { label: 'Archive Reference', value: document.archive_serial || '—' }, - { label: 'Issued On', value: formatDateTime(document.issued_at) }, - { label: 'Correspondent', value: correspondents || '—' }, - { - label: 'Filename', - value: document.archive_path || document.filename || '—', - }, - { - label: 'Original Filename', - value: document.original_name || '—', - }, - { label: 'Tags', value: tags || '—' }, - ]; - - const metadataItems = [ - { label: 'Modified At', value: formatDateTime(document.updated_at) }, - { label: 'Created At', value: formatDateTime(document.created_at) }, - { - label: 'Media Filename', - value: document.current_version?.filename || document.archive_path || '—', - }, - { - label: 'SHA-256 Checksum', - value: document.current_version?.checksum || '—', - }, - { - label: 'Original File Size', - value: summary.sizeLabel, - }, - { - label: 'Original MIME Type', - value: document.content_type || '—', - }, - ]; - const metadata = - document.metadata && Object.keys(document.metadata).length > 0 ? document.metadata : null; - return (
-
-

Details

-
- {detailItems.map(({ label, value }) => ( -
-
{label}
-
{value || '—'}
-
- ))} -
-
-
-

Content

-

- Full OCR text will appear here in a future update. Use the toolbar button to open the OCR view for now. -

-
+

Metadata

@@ -97,25 +104,13 @@ const PreviewWorkspace = ({
))} - {metadata ? ( + {metadataPayload ? (
Show metadata payload -
{JSON.stringify(metadata, null, 2)}
+
{JSON.stringify(metadataPayload, null, 2)}
) : null}
-
-

Notes

-

Custom notes will be editable here once the feature lands.

-
-
-

History

-

Change history will be displayed here in an upcoming release.

-
-
-

Permissions

-

Access control management is planned and will surface here.

-
{!previewEntry?.url ? ( @@ -123,7 +118,7 @@ const PreviewWorkspace = ({ ) : (