559 lines
17 KiB
React
559 lines
17 KiB
React
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
|
|
}) => {
|
|
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 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);
|
|
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],
|
|
);
|
|
|
|
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">{summary.title}</h3>
|
|
{editableTitle ? (
|
|
<button
|
|
type="button"
|
|
className="icon-button"
|
|
onClick={startTitleEdit}
|
|
aria-label="Edit title"
|
|
title="Edit title"
|
|
>
|
|
<EditIcon className="icon-inline" />
|
|
</button>
|
|
) : null}
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
{titleError ? <div className="status-inline error">{titleError}</div> : null}
|
|
|
|
<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>
|
|
|
|
<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>
|
|
);
|
|
};
|
|
|
|
export default DocumentSummarySection;
|