586 lines
18 KiB
TypeScript
586 lines
18 KiB
TypeScript
/**
|
|
* Embedded document viewer panel for preview, metadata updates, and lifecycle actions.
|
|
*/
|
|
import { useEffect, useMemo, useState } from 'react';
|
|
|
|
import {
|
|
contentMarkdownUrl,
|
|
deleteDocument,
|
|
getDocumentDetails,
|
|
previewUrl,
|
|
reprocessDocument,
|
|
restoreDocument,
|
|
trashDocument,
|
|
updateDocumentMetadata,
|
|
} from '../lib/api';
|
|
import type { DmsDocument, DmsDocumentDetail } from '../types';
|
|
import PathInput from './PathInput';
|
|
import TagInput from './TagInput';
|
|
|
|
/**
|
|
* Defines props for the selected document viewer panel.
|
|
*/
|
|
interface DocumentViewerProps {
|
|
document: DmsDocument | null;
|
|
isTrashView: boolean;
|
|
existingTags: string[];
|
|
existingPaths: string[];
|
|
onDocumentUpdated: (document: DmsDocument) => void;
|
|
onDocumentDeleted: (documentId: string) => void;
|
|
requestConfirmation: (title: string, message: string, confirmLabel?: string) => Promise<boolean>;
|
|
}
|
|
|
|
/**
|
|
* Renders selected document preview with editable metadata and lifecycle controls.
|
|
*/
|
|
export default function DocumentViewer({
|
|
document,
|
|
isTrashView,
|
|
existingTags,
|
|
existingPaths,
|
|
onDocumentUpdated,
|
|
onDocumentDeleted,
|
|
requestConfirmation,
|
|
}: DocumentViewerProps): JSX.Element {
|
|
const [documentDetail, setDocumentDetail] = useState<DmsDocumentDetail | null>(null);
|
|
const [isLoadingDetails, setIsLoadingDetails] = useState<boolean>(false);
|
|
const [originalFilename, setOriginalFilename] = useState<string>('');
|
|
const [logicalPath, setLogicalPath] = useState<string>('');
|
|
const [tags, setTags] = useState<string[]>([]);
|
|
const [isSaving, setIsSaving] = useState<boolean>(false);
|
|
const [isReprocessing, setIsReprocessing] = useState<boolean>(false);
|
|
const [isTrashing, setIsTrashing] = useState<boolean>(false);
|
|
const [isRestoring, setIsRestoring] = useState<boolean>(false);
|
|
const [isDeleting, setIsDeleting] = useState<boolean>(false);
|
|
const [isMetadataDirty, setIsMetadataDirty] = useState<boolean>(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
/**
|
|
* Syncs editable metadata fields whenever selection changes.
|
|
*/
|
|
useEffect(() => {
|
|
if (!document) {
|
|
setDocumentDetail(null);
|
|
setIsMetadataDirty(false);
|
|
return;
|
|
}
|
|
setOriginalFilename(document.original_filename);
|
|
setLogicalPath(document.logical_path);
|
|
setTags(document.tags);
|
|
setIsMetadataDirty(false);
|
|
setError(null);
|
|
}, [document?.id]);
|
|
|
|
/**
|
|
* Refreshes editable metadata from list updates only while form is clean.
|
|
*/
|
|
useEffect(() => {
|
|
if (!document || isMetadataDirty) {
|
|
return;
|
|
}
|
|
setOriginalFilename(document.original_filename);
|
|
setLogicalPath(document.logical_path);
|
|
setTags(document.tags);
|
|
}, [
|
|
document?.id,
|
|
document?.original_filename,
|
|
document?.logical_path,
|
|
document?.tags,
|
|
isMetadataDirty,
|
|
]);
|
|
|
|
/**
|
|
* Loads full selected-document details for extracted text and metadata display.
|
|
*/
|
|
useEffect(() => {
|
|
if (!document) {
|
|
return;
|
|
}
|
|
let cancelled = false;
|
|
const loadDocumentDetails = async (): Promise<void> => {
|
|
setIsLoadingDetails(true);
|
|
try {
|
|
const payload = await getDocumentDetails(document.id);
|
|
if (!cancelled) {
|
|
setDocumentDetail(payload);
|
|
}
|
|
} catch (caughtError) {
|
|
if (!cancelled) {
|
|
setError(caughtError instanceof Error ? caughtError.message : 'Failed to load document details');
|
|
}
|
|
} finally {
|
|
if (!cancelled) {
|
|
setIsLoadingDetails(false);
|
|
}
|
|
}
|
|
};
|
|
|
|
void loadDocumentDetails();
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, [document?.id]);
|
|
|
|
/**
|
|
* Resolves whether selected document should render as an image element in preview.
|
|
*/
|
|
const isImageDocument = useMemo(() => {
|
|
if (!document) {
|
|
return false;
|
|
}
|
|
return document.mime_type.startsWith('image/');
|
|
}, [document]);
|
|
|
|
/**
|
|
* Extracts provider/transcription errors from document metadata for user visibility.
|
|
*/
|
|
const transcriptionError = useMemo(() => {
|
|
const value = documentDetail?.metadata_json?.transcription_error;
|
|
return typeof value === 'string' ? value : '';
|
|
}, [documentDetail]);
|
|
|
|
/**
|
|
* Extracts routing errors from metadata to surface classification issues.
|
|
*/
|
|
const routingError = useMemo(() => {
|
|
const value = documentDetail?.metadata_json?.routing_error;
|
|
return typeof value === 'string' ? value : '';
|
|
}, [documentDetail]);
|
|
|
|
/**
|
|
* Builds a compact routing status summary for user visibility.
|
|
*/
|
|
const routingSummary = useMemo(() => {
|
|
const value = documentDetail?.metadata_json?.routing;
|
|
if (!value || typeof value !== 'object') {
|
|
return '';
|
|
}
|
|
|
|
const routing = value as Record<string, unknown>;
|
|
const confidence = typeof routing.confidence === 'number' ? routing.confidence : null;
|
|
const similarity = typeof routing.neighbor_similarity === 'number' ? routing.neighbor_similarity : null;
|
|
const confidenceThreshold =
|
|
typeof routing.auto_apply_confidence_threshold === 'number'
|
|
? routing.auto_apply_confidence_threshold
|
|
: null;
|
|
const autoApplied = typeof routing.auto_applied === 'boolean' ? routing.auto_applied : null;
|
|
const autoAppliedPath = typeof routing.auto_applied_path === 'boolean' ? routing.auto_applied_path : null;
|
|
const autoAppliedTags = typeof routing.auto_applied_tags === 'boolean' ? routing.auto_applied_tags : null;
|
|
const blockedReasonsRaw = routing.auto_apply_blocked_reasons;
|
|
const blockedReasons =
|
|
Array.isArray(blockedReasonsRaw) && blockedReasonsRaw.length > 0
|
|
? blockedReasonsRaw
|
|
.map((reason) => String(reason))
|
|
.map((reason) => {
|
|
if (reason === 'missing_chosen_path') {
|
|
return 'no chosen path';
|
|
}
|
|
if (reason === 'confidence_below_threshold') {
|
|
return 'confidence below threshold';
|
|
}
|
|
if (reason === 'neighbor_similarity_below_threshold') {
|
|
return 'neighbor similarity below threshold';
|
|
}
|
|
return reason;
|
|
})
|
|
: [];
|
|
|
|
const parts: string[] = [];
|
|
if (autoApplied !== null) {
|
|
parts.push(`Auto Applied: ${autoApplied ? 'yes' : 'no'}`);
|
|
}
|
|
if (autoApplied) {
|
|
const appliedTargets: string[] = [];
|
|
if (autoAppliedPath) {
|
|
appliedTargets.push('path');
|
|
}
|
|
if (autoAppliedTags) {
|
|
appliedTargets.push('tags');
|
|
}
|
|
if (appliedTargets.length > 0) {
|
|
parts.push(`Applied: ${appliedTargets.join(' + ')}`);
|
|
}
|
|
}
|
|
if (confidence !== null) {
|
|
if (confidenceThreshold !== null) {
|
|
parts.push(`Confidence: ${confidence.toFixed(2)} / ${confidenceThreshold.toFixed(2)}`);
|
|
} else {
|
|
parts.push(`Confidence: ${confidence.toFixed(2)}`);
|
|
}
|
|
}
|
|
if (similarity !== null) {
|
|
parts.push(`Neighbor Similarity (info): ${similarity.toFixed(2)}`);
|
|
}
|
|
if (autoApplied === false && blockedReasons.length > 0) {
|
|
parts.push(`Blocked: ${blockedReasons.join(', ')}`);
|
|
}
|
|
return parts.join(' | ');
|
|
}, [documentDetail]);
|
|
|
|
/**
|
|
* Resolves whether routing already auto-applied path and tags.
|
|
*/
|
|
const routingAutoApplyState = useMemo(() => {
|
|
const value = documentDetail?.metadata_json?.routing;
|
|
if (!value || typeof value !== 'object') {
|
|
return {
|
|
autoAppliedPath: false,
|
|
autoAppliedTags: false,
|
|
};
|
|
}
|
|
|
|
const routing = value as Record<string, unknown>;
|
|
return {
|
|
autoAppliedPath: routing.auto_applied_path === true,
|
|
autoAppliedTags: routing.auto_applied_tags === true,
|
|
};
|
|
}, [documentDetail]);
|
|
|
|
/**
|
|
* Resolves whether any routing suggestion still needs manual application.
|
|
*/
|
|
const hasPathSuggestion = Boolean(document?.suggested_path) && !routingAutoApplyState.autoAppliedPath;
|
|
const hasTagSuggestions = (document?.suggested_tags.length ?? 0) > 0 && !routingAutoApplyState.autoAppliedTags;
|
|
const canApplyAllSuggestions = hasPathSuggestion || hasTagSuggestions;
|
|
|
|
/**
|
|
* Applies suggested path value to editable metadata field.
|
|
*/
|
|
const applySuggestedPath = (): void => {
|
|
if (!hasPathSuggestion || !document?.suggested_path) {
|
|
return;
|
|
}
|
|
setLogicalPath(document.suggested_path);
|
|
setIsMetadataDirty(true);
|
|
};
|
|
|
|
/**
|
|
* Applies one suggested tag to editable metadata field.
|
|
*/
|
|
const applySuggestedTag = (tag: string): void => {
|
|
if (!hasTagSuggestions || tags.includes(tag)) {
|
|
return;
|
|
}
|
|
setTags([...tags, tag]);
|
|
setIsMetadataDirty(true);
|
|
};
|
|
|
|
/**
|
|
* Applies all suggested routing values into editable metadata fields.
|
|
*/
|
|
const applyAllSuggestions = (): void => {
|
|
if (hasPathSuggestion && document?.suggested_path) {
|
|
setLogicalPath(document.suggested_path);
|
|
}
|
|
if (hasTagSuggestions && document?.suggested_tags.length) {
|
|
const nextTags = [...tags];
|
|
for (const tag of document.suggested_tags) {
|
|
if (!nextTags.includes(tag)) {
|
|
nextTags.push(tag);
|
|
}
|
|
}
|
|
setTags(nextTags);
|
|
}
|
|
setIsMetadataDirty(true);
|
|
};
|
|
|
|
/**
|
|
* Persists metadata changes to backend.
|
|
*/
|
|
const handleSave = async (): Promise<void> => {
|
|
if (!document) {
|
|
return;
|
|
}
|
|
setIsSaving(true);
|
|
setError(null);
|
|
try {
|
|
const updated = await updateDocumentMetadata(document.id, {
|
|
original_filename: originalFilename,
|
|
logical_path: logicalPath,
|
|
tags,
|
|
});
|
|
setOriginalFilename(updated.original_filename);
|
|
setLogicalPath(updated.logical_path);
|
|
setTags(updated.tags);
|
|
setIsMetadataDirty(false);
|
|
onDocumentUpdated(updated);
|
|
const payload = await getDocumentDetails(document.id);
|
|
setDocumentDetail(payload);
|
|
} catch (caughtError) {
|
|
setError(caughtError instanceof Error ? caughtError.message : 'Failed to save metadata');
|
|
} finally {
|
|
setIsSaving(false);
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Re-runs extraction and routing logic for the currently selected document.
|
|
*/
|
|
const handleReprocess = async (): Promise<void> => {
|
|
if (!document) {
|
|
return;
|
|
}
|
|
setIsReprocessing(true);
|
|
setError(null);
|
|
try {
|
|
const updated = await reprocessDocument(document.id);
|
|
onDocumentUpdated(updated);
|
|
const payload = await getDocumentDetails(document.id);
|
|
setDocumentDetail(payload);
|
|
} catch (caughtError) {
|
|
setError(caughtError instanceof Error ? caughtError.message : 'Failed to reprocess document');
|
|
} finally {
|
|
setIsReprocessing(false);
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Moves the selected document to trash state.
|
|
*/
|
|
const handleTrash = async (): Promise<void> => {
|
|
if (!document) {
|
|
return;
|
|
}
|
|
setIsTrashing(true);
|
|
setError(null);
|
|
try {
|
|
const updated = await trashDocument(document.id);
|
|
onDocumentUpdated(updated);
|
|
} catch (caughtError) {
|
|
setError(caughtError instanceof Error ? caughtError.message : 'Failed to trash document');
|
|
} finally {
|
|
setIsTrashing(false);
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Restores the selected document from trash.
|
|
*/
|
|
const handleRestore = async (): Promise<void> => {
|
|
if (!document) {
|
|
return;
|
|
}
|
|
setIsRestoring(true);
|
|
setError(null);
|
|
try {
|
|
const updated = await restoreDocument(document.id);
|
|
onDocumentUpdated(updated);
|
|
} catch (caughtError) {
|
|
setError(caughtError instanceof Error ? caughtError.message : 'Failed to restore document');
|
|
} finally {
|
|
setIsRestoring(false);
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Permanently deletes the selected document and associated files.
|
|
*/
|
|
const handleDelete = async (): Promise<void> => {
|
|
if (!document) {
|
|
return;
|
|
}
|
|
const confirmed = await requestConfirmation(
|
|
'Delete Document Permanently',
|
|
'This removes the document record and stored file from the system.',
|
|
'Delete Permanently',
|
|
);
|
|
if (!confirmed) {
|
|
return;
|
|
}
|
|
|
|
setIsDeleting(true);
|
|
setError(null);
|
|
try {
|
|
await deleteDocument(document.id);
|
|
onDocumentDeleted(document.id);
|
|
} catch (caughtError) {
|
|
setError(caughtError instanceof Error ? caughtError.message : 'Failed to delete document');
|
|
} finally {
|
|
setIsDeleting(false);
|
|
}
|
|
};
|
|
|
|
if (!document) {
|
|
return (
|
|
<aside className="document-viewer empty">
|
|
<h2>Document Details</h2>
|
|
<p>Select a document to preview and manage metadata.</p>
|
|
</aside>
|
|
);
|
|
}
|
|
|
|
const isTrashed = document.status === 'trashed' || isTrashView;
|
|
const metadataDisabled = isTrashed || isSaving || isTrashing || isRestoring || isDeleting;
|
|
|
|
return (
|
|
<aside className="document-viewer">
|
|
<h2>{document.original_filename}</h2>
|
|
<p className="small">Status: {document.status}</p>
|
|
<div className="viewer-preview">
|
|
{isImageDocument ? (
|
|
<img src={previewUrl(document.id)} alt={document.original_filename} />
|
|
) : (
|
|
<iframe src={previewUrl(document.id)} title={document.original_filename} />
|
|
)}
|
|
</div>
|
|
<label>
|
|
File Name
|
|
<input
|
|
value={originalFilename}
|
|
onChange={(event) => {
|
|
setOriginalFilename(event.target.value);
|
|
setIsMetadataDirty(true);
|
|
}}
|
|
disabled={metadataDisabled}
|
|
/>
|
|
</label>
|
|
<label>
|
|
Destination Path
|
|
<PathInput
|
|
value={logicalPath}
|
|
onChange={(value) => {
|
|
setLogicalPath(value);
|
|
setIsMetadataDirty(true);
|
|
}}
|
|
suggestions={existingPaths}
|
|
disabled={metadataDisabled}
|
|
/>
|
|
</label>
|
|
<label>
|
|
Tags
|
|
<TagInput
|
|
value={tags}
|
|
onChange={(value) => {
|
|
setTags(value);
|
|
setIsMetadataDirty(true);
|
|
}}
|
|
suggestions={existingTags}
|
|
disabled={metadataDisabled}
|
|
/>
|
|
</label>
|
|
{(document.suggested_path || document.suggested_tags.length > 0 || routingSummary || routingError) && (
|
|
<section className="routing-suggestions-panel">
|
|
<div className="routing-suggestions-header">
|
|
<h3>Routing Suggestions</h3>
|
|
{canApplyAllSuggestions && (
|
|
<button
|
|
type="button"
|
|
className="secondary-action"
|
|
onClick={applyAllSuggestions}
|
|
disabled={metadataDisabled}
|
|
>
|
|
Apply All
|
|
</button>
|
|
)}
|
|
</div>
|
|
{routingError && <p className="small error">{routingError}</p>}
|
|
{routingSummary && <p className="small">{routingSummary}</p>}
|
|
{hasPathSuggestion && document.suggested_path && (
|
|
<div className="routing-suggestion-group">
|
|
<p className="small">Suggested Path</p>
|
|
<button
|
|
type="button"
|
|
className="routing-pill"
|
|
onClick={applySuggestedPath}
|
|
disabled={metadataDisabled}
|
|
>
|
|
{document.suggested_path}
|
|
</button>
|
|
</div>
|
|
)}
|
|
{hasTagSuggestions && document.suggested_tags.length > 0 && (
|
|
<div className="routing-suggestion-group">
|
|
<p className="small">Suggested Tags</p>
|
|
<div className="routing-pill-row">
|
|
{document.suggested_tags.map((tag) => (
|
|
<button
|
|
key={tag}
|
|
type="button"
|
|
className="routing-pill"
|
|
onClick={() => applySuggestedTag(tag)}
|
|
disabled={metadataDisabled}
|
|
>
|
|
{tag}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</section>
|
|
)}
|
|
<section className="extracted-text-panel">
|
|
<h3>Extracted Text</h3>
|
|
{transcriptionError && <p className="small error">{transcriptionError}</p>}
|
|
{isLoadingDetails ? (
|
|
<p className="small">Loading extracted text...</p>
|
|
) : documentDetail?.extracted_text.trim() ? (
|
|
<pre>{documentDetail.extracted_text}</pre>
|
|
) : (
|
|
<p className="small">No extracted text available for this document yet.</p>
|
|
)}
|
|
</section>
|
|
{error && <p className="error">{error}</p>}
|
|
<div className="viewer-actions">
|
|
{!isTrashed && (
|
|
<button type="button" onClick={handleSave} disabled={metadataDisabled}>
|
|
{isSaving ? 'Saving...' : 'Save Metadata'}
|
|
</button>
|
|
)}
|
|
{!isTrashed && (
|
|
<button
|
|
type="button"
|
|
className="secondary-action"
|
|
onClick={handleReprocess}
|
|
disabled={metadataDisabled || isReprocessing}
|
|
title="Re-runs OCR/extraction, summary generation, routing suggestion, and indexing for this document."
|
|
>
|
|
{isReprocessing ? 'Reprocessing...' : 'Reprocess Document'}
|
|
</button>
|
|
)}
|
|
{!isTrashed && (
|
|
<button
|
|
type="button"
|
|
className="warning-action"
|
|
onClick={handleTrash}
|
|
disabled={metadataDisabled || isTrashing}
|
|
>
|
|
{isTrashing ? 'Trashing...' : 'Move To Trash'}
|
|
</button>
|
|
)}
|
|
{isTrashed && (
|
|
<button
|
|
type="button"
|
|
className="secondary-action"
|
|
onClick={handleRestore}
|
|
disabled={isRestoring || isDeleting}
|
|
>
|
|
{isRestoring ? 'Restoring...' : 'Restore Document'}
|
|
</button>
|
|
)}
|
|
<button
|
|
type="button"
|
|
className="secondary-action"
|
|
onClick={() => window.open(contentMarkdownUrl(document.id), '_blank', 'noopener,noreferrer')}
|
|
disabled={isDeleting}
|
|
title="Downloads recognized/extracted content as markdown for this document."
|
|
>
|
|
Download Recognized MD
|
|
</button>
|
|
{isTrashed && (
|
|
<button
|
|
type="button"
|
|
className="danger-action"
|
|
onClick={handleDelete}
|
|
disabled={isDeleting || isRestoring}
|
|
>
|
|
{isDeleting ? 'Deleting...' : 'Delete Permanently'}
|
|
</button>
|
|
)}
|
|
</div>
|
|
<p className="viewer-inline-help">
|
|
Reprocess runs OCR/extraction, updates summary, refreshes routing suggestions, and re-indexes search.
|
|
</p>
|
|
</aside>
|
|
);
|
|
}
|