/** * 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; } /** * 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(null); const [isLoadingDetails, setIsLoadingDetails] = useState(false); const [originalFilename, setOriginalFilename] = useState(''); const [logicalPath, setLogicalPath] = useState(''); const [tags, setTags] = useState([]); const [isSaving, setIsSaving] = useState(false); const [isReprocessing, setIsReprocessing] = useState(false); const [isTrashing, setIsTrashing] = useState(false); const [isRestoring, setIsRestoring] = useState(false); const [isDeleting, setIsDeleting] = useState(false); const [isMetadataDirty, setIsMetadataDirty] = useState(false); const [error, setError] = useState(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 => { 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; 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; 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 => { 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 => { 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 => { 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 => { 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 => { 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 ( ); } const isTrashed = document.status === 'trashed' || isTrashView; const metadataDisabled = isTrashed || isSaving || isTrashing || isRestoring || isDeleting; return (