Initial commit
This commit is contained in:
585
frontend/src/components/DocumentViewer.tsx
Normal file
585
frontend/src/components/DocumentViewer.tsx
Normal file
@@ -0,0 +1,585 @@
|
||||
/**
|
||||
* 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user