detailpanel

This commit is contained in:
2025-11-01 04:16:31 +01:00
parent fd13edae6d
commit 303ece0529
10 changed files with 1334 additions and 625 deletions
@@ -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 (
<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 ? (
<QuickAddMenu
options={normalizedOptions}
onCreate={handleCreate}
onSelectOption={(original, normalized) => handleSelect(normalized || original)}
placeholder={addPlaceholder}
createLabel={addButtonLabel}
triggerAriaLabel="Add tag"
triggerTitle={addButtonLabel}
triggerClassName="quick-add__chip quick-add__trigger"
triggerContent={(
<span className="quick-add__chip-label">
<PlusIcon className="icon-inline" aria-hidden="true" /> Add tag
</span>
)}
/>
) : null}
{!tags.length && !showQuickAdd ? <span className="tag-list__empty meta">{emptyMessage}</span> : null}
</div>
);
};
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 (
<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 ? (
<QuickAddMenu
options={normalizedOptions}
onCreate={handleCreate}
onSelectOption={(original, normalized) => handleSelect(original, normalized)}
placeholder={addPlaceholder}
createLabel={addButtonLabel}
triggerAriaLabel="Add correspondent"
triggerTitle={addButtonLabel}
triggerClassName="quick-add__chip quick-add__trigger"
triggerContent={(
<span className="quick-add__chip-label">
<PlusIcon className="icon-inline" aria-hidden="true" /> Add correspondent
</span>
)}
/>
) : null}
</div>
);
};
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 (
<span className="detail-folder-path">
{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 (
<React.Fragment key={key}>
{index > 0 ? <span className="detail-folder-path__separator">/</span> : null}
{isClickable ? (
<a
href={href}
className="detail-folder-path__link"
onClick={(event) => {
if (
event.button !== 0 ||
event.metaKey ||
event.ctrlKey ||
event.shiftKey ||
event.altKey
) {
return;
}
event.preventDefault();
event.stopPropagation();
onFolderNavigate(targetId);
}}
>
{label}
</a>
) : (
<span className="detail-folder-path__segment">{label}</span>
)}
</React.Fragment>
);
})}
</span>
);
}, [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 (
<div className="document-summary">
<div className="doc-title-row">
<div className="doc-title-row__primary">
{editableTitle && isTitleEditing ? (
<form className="doc-title-edit" 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" disabled={titleSaving}>
Save
</button>
<button
type="button"
className="secondary"
onClick={cancelTitleEdit}
disabled={titleSaving}
>
Cancel
</button>
</form>
) : (
<>
<h3 className="doc-title-row__title">{titleDisplay}</h3>
{editableTitle ? (
<button
type="button"
className="icon-button"
onClick={startTitleEdit}
aria-label="Edit title"
title="Edit title"
>
<EditIcon className="icon-inline" />
</button>
) : null}
</>
)}
</div>
{folderDisplayNode ? (
<div className="doc-title-row__path" aria-label="Folder path">
{folderDisplayNode}
</div>
) : null}
</div>
{titleError ? <div className="status-inline error">{titleError}</div> : null}
<TagSection
tags={resolvedTags}
onRemove={
onTagRemove
? (tag) => onTagRemove(document.id, tag.id)
: undefined
}
onAdd={
onTagAdd
? ({ value, option }) => onTagAdd(document, value, { option })
: undefined
}
datalistOptions={tagOptions}
/>
<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}
/>
<div className="detail-meta">
<div className="detail-meta__row">
<span className="detail-meta__label">Issued:</span>
{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" disabled={issuedSaving}>
Save
</button>
<button
type="button"
className="secondary"
onClick={cancelIssuedEdit}
disabled={issuedSaving}
>
Cancel
</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}
</>
)}
</div>
{issuedError ? <div className="status-inline error">{issuedError}</div> : null}
{metaRows.map((row) => (
<div key={row.key} className="detail-meta__row">
<span className="detail-meta__label">{row.label}:</span>
<span className="detail-meta__value">{row.value}</span>
</div>
))}
</div>
</div>
);
};
export default DocumentSummarySection;
+4 -4
View File
@@ -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,