typescript
This commit is contained in:
@@ -0,0 +1,865 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import type { FormEvent } from 'react';
|
||||
import { EditIcon, IconX, PlusIcon } from '../ui/icons';
|
||||
import SelectionAssignmentMenu, { SelectionAssignmentMenuItem } from './SelectionAssignmentMenu';
|
||||
import { getTagColorStyle } from '../utils/colors';
|
||||
import {
|
||||
formatDate,
|
||||
toDateInputValue,
|
||||
toIssuedTimestamp,
|
||||
} from '../utils/date';
|
||||
import { describeDocumentSummary } from './documentSummary';
|
||||
|
||||
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;
|
||||
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;
|
||||
layout?: 'default' | 'compact';
|
||||
detailItems?: Array<{ label?: string; value?: string }>;
|
||||
}
|
||||
|
||||
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 normalizeQuickAddOption = (option: QuickAddOption | string | null | undefined): QuickAddEntry | null => {
|
||||
if (option == null) {
|
||||
return null;
|
||||
}
|
||||
const isObject = typeof option === 'object';
|
||||
const labelSource = isObject ? option.label ?? option.name ?? '' : option;
|
||||
const label = typeof labelSource === 'string' ? labelSource.trim() : '';
|
||||
if (!label) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
id: isObject && 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 labelSource = option && typeof option === 'object'
|
||||
? option.label ?? option.name ?? ''
|
||||
: option;
|
||||
const label = typeof labelSource === 'string' ? labelSource.trim() : '';
|
||||
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: SelectionAssignmentMenuItem | null) => {
|
||||
if (!item) {
|
||||
return;
|
||||
}
|
||||
const payload = item.payload ?? { label: item.label };
|
||||
handleSelect(payload);
|
||||
},
|
||||
[handleSelect],
|
||||
);
|
||||
|
||||
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={(
|
||||
<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: 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: SelectionAssignmentMenuItem | null) => {
|
||||
if (!onAdd || !item) {
|
||||
return;
|
||||
}
|
||||
const source = item.payload ?? item;
|
||||
const resolvedName =
|
||||
source?.name?.trim?.()
|
||||
|| source?.label?.trim?.()
|
||||
|| source?.trim?.()
|
||||
|| '';
|
||||
if (!resolvedName) {
|
||||
return;
|
||||
}
|
||||
const payload = typeof source === 'object'
|
||||
? { ...source, name: resolvedName }
|
||||
: { id: null, name: resolvedName };
|
||||
onAdd({ name: resolvedName, option: payload, input: null });
|
||||
},
|
||||
[onAdd],
|
||||
);
|
||||
|
||||
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={(
|
||||
<span className="quick-add__chip-label">
|
||||
<PlusIcon className="icon-inline" aria-hidden="true" /> Add correspondent
|
||||
</span>
|
||||
)}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const DocumentSummarySection: React.FC<DocumentSummarySectionProps> = ({
|
||||
document,
|
||||
tagLookupById = new Map(),
|
||||
tagOptions = [],
|
||||
onTagAdd,
|
||||
onTagRemove,
|
||||
correspondents,
|
||||
correspondentOptions = [],
|
||||
onCorrespondentAdd,
|
||||
onCorrespondentRemove,
|
||||
onUpdateTitle,
|
||||
onUpdateIssued,
|
||||
layout = 'default',
|
||||
detailItems = [],
|
||||
}) => {
|
||||
const isCompactLayout = layout === 'compact';
|
||||
const summary = useMemo(() => {
|
||||
if (!document) {
|
||||
return {
|
||||
title: '',
|
||||
originalName: '',
|
||||
sizeLabel: '—',
|
||||
pageCount: null,
|
||||
};
|
||||
}
|
||||
return 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 metaRows = useMemo(() => {
|
||||
const rows: { key: string; label: string; value: string | null | undefined }[] = [];
|
||||
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: 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 TitleSection = () => (
|
||||
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}
|
||||
</>
|
||||
)
|
||||
);
|
||||
|
||||
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" 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}
|
||||
</>
|
||||
);
|
||||
|
||||
const metaItems = [
|
||||
{
|
||||
key: 'issued',
|
||||
label: 'Issued',
|
||||
valueContent: issuedDisplay,
|
||||
error: issuedError,
|
||||
},
|
||||
...metaRows.map((row) => ({
|
||||
key: row.key,
|
||||
label: row.label,
|
||||
fallbackValue: row.value,
|
||||
})),
|
||||
];
|
||||
|
||||
const detailRows = Array.isArray(detailItems)
|
||||
? detailItems.map((item, index) => ({
|
||||
key: `detail-${item?.label || index}`,
|
||||
label: item?.label || '—',
|
||||
fallbackValue: item?.value,
|
||||
}))
|
||||
: [];
|
||||
|
||||
const compactRows = [...metaItems, ...detailRows];
|
||||
|
||||
const renderTags = () => (
|
||||
<section className="document-summary__section document-summary__section--tags">
|
||||
<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"
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
|
||||
const renderCorrespondents = () => (
|
||||
<section className="document-summary__section document-summary__section--correspondents">
|
||||
<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"
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
|
||||
if (isCompactLayout) {
|
||||
return (
|
||||
<div className="document-summary document-summary--compact">
|
||||
<section className="document-summary__section document-summary__title-row">
|
||||
<TitleSection />
|
||||
</section>
|
||||
{titleError ? <div className="status-inline error">{titleError}</div> : null}
|
||||
{renderTags()}
|
||||
{renderCorrespondents()}
|
||||
{compactRows.length ? (
|
||||
<section className="document-summary__section document-summary__meta document-summary__meta--compact">
|
||||
<dl className="document-summary__details-list document-summary__details-list--meta">
|
||||
{compactRows.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>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
{issuedDisplay}
|
||||
</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>
|
||||
|
||||
{renderTags()}
|
||||
{renderCorrespondents()}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DocumentSummarySection;
|
||||
Reference in New Issue
Block a user