Initial commit

This commit is contained in:
2026-02-21 09:44:18 -03:00
commit 5dfc2cbd85
65 changed files with 11989 additions and 0 deletions

View 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>
);
}