/** * Main application layout and orchestration for document and settings workspaces. */ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import type { JSX } from 'react'; import { LogOut, User } from 'lucide-react'; import ActionModal from './components/ActionModal'; import DocumentGrid from './components/DocumentGrid'; import LoginScreen from './components/LoginScreen'; import DocumentViewer from './components/DocumentViewer'; import PathInput from './components/PathInput'; import ProcessingLogPanel from './components/ProcessingLogPanel'; import SearchFiltersBar from './components/SearchFiltersBar'; import SettingsScreen from './components/SettingsScreen'; import UploadSurface from './components/UploadSurface'; import { clearProcessingLogs, downloadBlobFile, deleteDocument, exportContentsMarkdown, getCurrentAuthSession, getAppSettings, listDocuments, listPaths, listProcessingLogs, listTags, listTypes, loginWithPassword, logoutCurrentSession, resetAppSettings, searchDocuments, trashDocument, updateAppSettings, uploadDocuments, } from './lib/api'; import type { AppSettings, AppSettingsUpdate, AuthUser, DmsDocument, ProcessingLogEntry } from './types'; type AppScreen = 'documents' | 'settings'; type DocumentView = 'active' | 'trash'; type AuthPhase = 'checking' | 'unauthenticated' | 'authenticated'; interface DialogOption { key: string; label: string; tone?: 'neutral' | 'primary' | 'warning' | 'danger'; } interface DialogState { title: string; message: string; options: DialogOption[]; } /** * Defines the root DMS frontend component. */ export default function App(): JSX.Element { const DEFAULT_PAGE_SIZE = 12; const [authPhase, setAuthPhase] = useState('checking'); const [authUser, setAuthUser] = useState(null); const [authError, setAuthError] = useState(null); const [isAuthenticating, setIsAuthenticating] = useState(false); const [screen, setScreen] = useState('documents'); const [documentView, setDocumentView] = useState('active'); const [documents, setDocuments] = useState([]); const [totalDocuments, setTotalDocuments] = useState(0); const [currentPage, setCurrentPage] = useState(1); const [isLoading, setIsLoading] = useState(false); const [isUploading, setIsUploading] = useState(false); const [searchText, setSearchText] = useState(''); const [activeSearchQuery, setActiveSearchQuery] = useState(''); const [selectedDocumentId, setSelectedDocumentId] = useState(null); const [selectedDocumentIds, setSelectedDocumentIds] = useState([]); const [exportPathInput, setExportPathInput] = useState(''); const [tagFilter, setTagFilter] = useState(''); const [typeFilter, setTypeFilter] = useState(''); const [pathFilter, setPathFilter] = useState(''); const [processedFrom, setProcessedFrom] = useState(''); const [processedTo, setProcessedTo] = useState(''); const [knownTags, setKnownTags] = useState([]); const [knownPaths, setKnownPaths] = useState([]); const [knownTypes, setKnownTypes] = useState([]); const [appSettings, setAppSettings] = useState(null); const [settingsSaveAction, setSettingsSaveAction] = useState<(() => Promise) | null>(null); const [processingLogs, setProcessingLogs] = useState([]); const [isLoadingLogs, setIsLoadingLogs] = useState(false); const [isClearingLogs, setIsClearingLogs] = useState(false); const [processingLogError, setProcessingLogError] = useState(null); const [isSavingSettings, setIsSavingSettings] = useState(false); const [isRunningBulkAction, setIsRunningBulkAction] = useState(false); const [error, setError] = useState(null); const [dialogState, setDialogState] = useState(null); const dialogResolverRef = useRef<((value: string) => void) | null>(null); const isAdmin = authUser?.role === 'admin'; const pageSize = useMemo(() => { const configured = appSettings?.display?.cards_per_page; if (!configured || Number.isNaN(configured)) { return DEFAULT_PAGE_SIZE; } return Math.max(1, Math.min(200, configured)); }, [appSettings]); const presentDialog = useCallback((title: string, message: string, options: DialogOption[]): Promise => { setDialogState({ title, message, options }); return new Promise((resolve) => { dialogResolverRef.current = resolve; }); }, []); const requestConfirmation = useCallback( async (title: string, message: string, confirmLabel = 'Confirm'): Promise => { const choice = await presentDialog(title, message, [ { key: 'cancel', label: 'Cancel', tone: 'neutral' }, { key: 'confirm', label: confirmLabel, tone: 'danger' }, ]); return choice === 'confirm'; }, [presentDialog], ); const closeDialog = useCallback((key: string): void => { const resolver = dialogResolverRef.current; dialogResolverRef.current = null; setDialogState(null); if (resolver) { resolver(key); } }, []); /** * Clears workspace state when authentication context changes or session is revoked. */ const resetApplicationState = useCallback((): void => { setScreen('documents'); setDocumentView('active'); setDocuments([]); setTotalDocuments(0); setCurrentPage(1); setSearchText(''); setActiveSearchQuery(''); setSelectedDocumentId(null); setSelectedDocumentIds([]); setExportPathInput(''); setTagFilter(''); setTypeFilter(''); setPathFilter(''); setProcessedFrom(''); setProcessedTo(''); setKnownTags([]); setKnownPaths([]); setKnownTypes([]); setAppSettings(null); setSettingsSaveAction(null); setProcessingLogs([]); setProcessingLogError(null); setError(null); }, []); /** * Exchanges submitted credentials for a server-issued session and activates the app shell. */ const handleLogin = useCallback(async (username: string, password: string): Promise => { setIsAuthenticating(true); setAuthError(null); try { const payload = await loginWithPassword(username, password); setAuthUser(payload.user); setAuthPhase('authenticated'); setError(null); } catch (caughtError) { const message = caughtError instanceof Error ? caughtError.message : 'Login failed'; setAuthError(message); setAuthUser(null); setAuthPhase('unauthenticated'); resetApplicationState(); } finally { setIsAuthenticating(false); } }, [resetApplicationState]); /** * Revokes current session server-side when possible and always clears local auth state. */ const handleLogout = useCallback(async (): Promise => { setError(null); try { await logoutCurrentSession(); } catch {} setAuthUser(null); setAuthError(null); setAuthPhase('unauthenticated'); resetApplicationState(); }, [resetApplicationState]); const loadCatalogs = useCallback(async (): Promise => { const [tags, paths, types] = await Promise.all([listTags(true), listPaths(true), listTypes(true)]); setKnownTags(tags); setKnownPaths(paths); setKnownTypes(types); }, []); const loadDocuments = useCallback(async (options?: { silent?: boolean }): Promise => { const silent = options?.silent ?? false; if (!silent) { setIsLoading(true); setError(null); } try { const offset = (currentPage - 1) * pageSize; const search = activeSearchQuery.trim(); const filters = { pathFilter, tagFilter, typeFilter, processedFrom, processedTo, }; const payload = search.length > 0 ? await searchDocuments(search, { limit: pageSize, offset, onlyTrashed: documentView === 'trash', ...filters, }) : await listDocuments({ limit: pageSize, offset, onlyTrashed: documentView === 'trash', ...filters, }); setDocuments(payload.items); setTotalDocuments(payload.total); if (payload.items.length === 0) { setSelectedDocumentId(null); } else if (!payload.items.some((item) => item.id === selectedDocumentId)) { setSelectedDocumentId(payload.items[0].id); } setSelectedDocumentIds((current) => current.filter((documentId) => payload.items.some((item) => item.id === documentId))); } catch (caughtError) { setError(caughtError instanceof Error ? caughtError.message : 'Failed to load documents'); } finally { if (!silent) { setIsLoading(false); } } }, [ activeSearchQuery, currentPage, documentView, pageSize, pathFilter, processedFrom, processedTo, selectedDocumentId, tagFilter, typeFilter, ]); const loadSettings = useCallback(async (): Promise => { if (!isAdmin) { setAppSettings(null); return; } setError(null); try { const payload = await getAppSettings(); setAppSettings(payload); } catch (caughtError) { setError(caughtError instanceof Error ? caughtError.message : 'Failed to load settings'); } }, [isAdmin]); const loadProcessingTimeline = useCallback(async (options?: { silent?: boolean }): Promise => { if (!isAdmin) { setProcessingLogs([]); setProcessingLogError(null); return; } const silent = options?.silent ?? false; if (!silent) { setIsLoadingLogs(true); } try { const payload = await listProcessingLogs({ limit: 180 }); setProcessingLogs(payload.items); setProcessingLogError(null); } catch (caughtError) { setProcessingLogError(caughtError instanceof Error ? caughtError.message : 'Failed to load processing logs'); } finally { if (!silent) { setIsLoadingLogs(false); } } }, [isAdmin]); useEffect(() => { const resolveSession = async (): Promise => { try { const sessionPayload = await getCurrentAuthSession(); setAuthUser(sessionPayload.user); setAuthError(null); setAuthPhase('authenticated'); } catch { setAuthUser(null); setAuthPhase('unauthenticated'); resetApplicationState(); } }; void resolveSession(); }, [resetApplicationState]); useEffect(() => { if (authPhase !== 'authenticated') { return; } const bootstrap = async (): Promise => { try { if (isAdmin) { await Promise.all([loadDocuments(), loadCatalogs(), loadSettings(), loadProcessingTimeline()]); return; } await Promise.all([loadDocuments(), loadCatalogs()]); setAppSettings(null); setProcessingLogs([]); setProcessingLogError(null); } catch (caughtError) { setError(caughtError instanceof Error ? caughtError.message : 'Failed to initialize application'); } }; void bootstrap(); }, [authPhase, isAdmin, loadCatalogs, loadDocuments, loadProcessingTimeline, loadSettings]); useEffect(() => { setSelectedDocumentIds([]); setCurrentPage(1); }, [documentView, pageSize]); useEffect(() => { if (!isAdmin && screen === 'settings') { setScreen('documents'); } }, [isAdmin, screen]); useEffect(() => { if (authPhase !== 'authenticated') { return; } if (screen !== 'documents') { return; } void loadDocuments(); }, [authPhase, loadDocuments, screen]); useEffect(() => { if (authPhase !== 'authenticated') { return; } if (screen !== 'documents') { return; } const pollInterval = window.setInterval(() => { void loadDocuments({ silent: true }); }, 3000); return () => window.clearInterval(pollInterval); }, [authPhase, loadDocuments, screen]); useEffect(() => { if (authPhase !== 'authenticated' || !isAdmin) { return; } if (screen !== 'documents') { return; } void loadProcessingTimeline(); const pollInterval = window.setInterval(() => { void loadProcessingTimeline({ silent: true }); }, 1500); return () => window.clearInterval(pollInterval); }, [authPhase, isAdmin, loadProcessingTimeline, screen]); const selectedDocument = useMemo( () => documents.find((document) => document.id === selectedDocumentId) ?? null, [documents, selectedDocumentId], ); const totalPages = useMemo(() => Math.max(1, Math.ceil(totalDocuments / pageSize)), [pageSize, totalDocuments]); const allVisibleSelected = useMemo(() => documents.length > 0 && documents.every((document) => selectedDocumentIds.includes(document.id)), [documents, selectedDocumentIds]); const isProcessingActive = useMemo(() => documents.some((document) => document.status === 'queued'), [documents]); const typingAnimationEnabled = appSettings?.display?.log_typing_animation_enabled ?? true; const hasActiveSearch = Boolean( activeSearchQuery.trim() || tagFilter || typeFilter || pathFilter || processedFrom || processedTo, ); const handleUpload = useCallback(async (files: File[]): Promise => { if (files.length === 0) { return; } setIsUploading(true); setError(null); try { const uploadDefaults = appSettings?.upload_defaults ?? { logical_path: 'Inbox', tags: [] }; const tagsCsv = uploadDefaults.tags.join(','); const firstAttempt = await uploadDocuments(files, { logicalPath: uploadDefaults.logical_path, tags: tagsCsv, conflictMode: 'ask', }); if (firstAttempt.conflicts.length > 0) { const choice = await presentDialog( 'Upload Conflicts Detected', `${firstAttempt.conflicts.length} file(s) already exist. Replace existing records or keep duplicates?`, [ { key: 'duplicate', label: 'Keep Duplicates', tone: 'neutral' }, { key: 'replace', label: 'Replace Existing', tone: 'warning' }, ], ); await uploadDocuments(files, { logicalPath: uploadDefaults.logical_path, tags: tagsCsv, conflictMode: choice === 'replace' ? 'replace' : 'duplicate', }); } if (isAdmin) { await Promise.all([loadDocuments(), loadCatalogs(), loadProcessingTimeline()]); } else { await Promise.all([loadDocuments(), loadCatalogs()]); } } catch (caughtError) { setError(caughtError instanceof Error ? caughtError.message : 'Upload failed'); } finally { setIsUploading(false); } }, [appSettings, isAdmin, loadCatalogs, loadDocuments, loadProcessingTimeline, presentDialog]); const handleSearch = useCallback(async (): Promise => { setSelectedDocumentIds([]); setCurrentPage(1); setActiveSearchQuery(searchText.trim()); }, [searchText]); const handleResetSearch = useCallback((): void => { setSearchText(''); setActiveSearchQuery(''); setTagFilter(''); setTypeFilter(''); setPathFilter(''); setProcessedFrom(''); setProcessedTo(''); setCurrentPage(1); setSelectedDocumentIds([]); }, []); const handleDocumentUpdated = useCallback((updated: DmsDocument): void => { setDocuments((current) => { const shouldAppear = documentView === 'trash' ? updated.status === 'trashed' : updated.status !== 'trashed'; if (!shouldAppear) { return current.filter((document) => document.id !== updated.id); } const exists = current.some((document) => document.id === updated.id); if (!exists) { return [updated, ...current]; } return current.map((document) => (document.id === updated.id ? updated : document)); }); if (documentView === 'trash' && updated.status !== 'trashed') { setSelectedDocumentIds((current) => current.filter((id) => id !== updated.id)); if (selectedDocumentId === updated.id) { setSelectedDocumentId(null); } } if (documentView === 'active' && updated.status === 'trashed') { setSelectedDocumentIds((current) => current.filter((id) => id !== updated.id)); if (selectedDocumentId === updated.id) { setSelectedDocumentId(null); } } void loadCatalogs(); }, [documentView, loadCatalogs, selectedDocumentId]); const handleDocumentDeleted = useCallback((documentId: string): void => { setDocuments((current) => current.filter((document) => document.id !== documentId)); setSelectedDocumentIds((current) => current.filter((id) => id !== documentId)); if (selectedDocumentId === documentId) { setSelectedDocumentId(null); } void loadCatalogs(); }, [loadCatalogs, selectedDocumentId]); const handleToggleChecked = useCallback((documentId: string, checked: boolean): void => { setSelectedDocumentIds((current) => { if (checked && !current.includes(documentId)) { return [...current, documentId]; } if (!checked) { return current.filter((item) => item !== documentId); } return current; }); }, []); const handleToggleSelectAllVisible = useCallback((): void => { if (documents.length === 0) { return; } if (allVisibleSelected) { setSelectedDocumentIds([]); return; } setSelectedDocumentIds(documents.map((document) => document.id)); }, [allVisibleSelected, documents]); const handleTrashSelected = useCallback(async (): Promise => { if (selectedDocumentIds.length === 0) { return; } setIsRunningBulkAction(true); setError(null); try { await Promise.all(selectedDocumentIds.map((documentId) => trashDocument(documentId))); setSelectedDocumentIds([]); await Promise.all([loadDocuments(), loadCatalogs()]); } catch (caughtError) { setError(caughtError instanceof Error ? caughtError.message : 'Failed to trash selected documents'); } finally { setIsRunningBulkAction(false); } }, [loadCatalogs, loadDocuments, selectedDocumentIds]); const handleTrashDocumentCard = useCallback(async (documentId: string): Promise => { if (documentView === 'trash') { return; } setError(null); try { await trashDocument(documentId); setSelectedDocumentIds((current) => current.filter((id) => id !== documentId)); if (selectedDocumentId === documentId) { setSelectedDocumentId(null); } await Promise.all([loadDocuments(), loadCatalogs()]); } catch (caughtError) { const message = caughtError instanceof Error ? caughtError.message : 'Failed to trash document'; setError(message); throw caughtError instanceof Error ? caughtError : new Error(message); } }, [documentView, loadCatalogs, loadDocuments, selectedDocumentId]); const handleDeleteSelected = useCallback(async (): Promise => { if (selectedDocumentIds.length === 0) { return; } const confirmed = await requestConfirmation( 'Delete Selected Documents Permanently', 'This removes selected documents and stored files permanently.', 'Delete Permanently', ); if (!confirmed) { return; } setIsRunningBulkAction(true); setError(null); try { await Promise.all(selectedDocumentIds.map((documentId) => deleteDocument(documentId))); setSelectedDocumentIds([]); await Promise.all([loadDocuments(), loadCatalogs()]); } catch (caughtError) { setError(caughtError instanceof Error ? caughtError.message : 'Failed to delete selected documents'); } finally { setIsRunningBulkAction(false); } }, [loadCatalogs, loadDocuments, requestConfirmation, selectedDocumentIds]); const handleExportSelected = useCallback(async (): Promise => { if (selectedDocumentIds.length === 0) { return; } setIsRunningBulkAction(true); setError(null); try { const result = await exportContentsMarkdown({ document_ids: selectedDocumentIds, only_trashed: documentView === 'trash', include_trashed: false, }); downloadBlobFile(result.blob, result.filename); } catch (caughtError) { setError(caughtError instanceof Error ? caughtError.message : 'Failed to export selected markdown files'); } finally { setIsRunningBulkAction(false); } }, [documentView, selectedDocumentIds]); const handleExportPath = useCallback(async (): Promise => { const trimmedPrefix = exportPathInput.trim(); if (!trimmedPrefix) { setError('Enter a path prefix before exporting by path'); return; } setIsRunningBulkAction(true); setError(null); try { const result = await exportContentsMarkdown({ path_prefix: trimmedPrefix, only_trashed: documentView === 'trash', include_trashed: false, }); downloadBlobFile(result.blob, result.filename); } catch (caughtError) { setError(caughtError instanceof Error ? caughtError.message : 'Failed to export path markdown files'); } finally { setIsRunningBulkAction(false); } }, [documentView, exportPathInput]); const handleSaveSettings = useCallback(async (payload: AppSettingsUpdate): Promise => { setIsSavingSettings(true); setError(null); try { const updated = await updateAppSettings(payload); setAppSettings(updated); await loadCatalogs(); } catch (caughtError) { setError(caughtError instanceof Error ? caughtError.message : 'Failed to save settings'); throw caughtError; } finally { setIsSavingSettings(false); } }, [loadCatalogs]); const handleSaveSettingsFromHeader = useCallback(async (): Promise => { if (!settingsSaveAction) { setError('Settings are still loading'); return; } await settingsSaveAction(); }, [settingsSaveAction]); const handleRegisterSettingsSaveAction = useCallback((action: (() => Promise) | null): void => { setSettingsSaveAction(() => action); }, []); const handleResetSettings = useCallback(async (): Promise => { const confirmed = await requestConfirmation( 'Reset Settings', 'This resets all settings to defaults and overwrites current values.', 'Reset Settings', ); if (!confirmed) { return; } setIsSavingSettings(true); setError(null); try { const updated = await resetAppSettings(); setAppSettings(updated); await loadCatalogs(); } catch (caughtError) { setError(caughtError instanceof Error ? caughtError.message : 'Failed to reset settings'); } finally { setIsSavingSettings(false); } }, [loadCatalogs, requestConfirmation]); const handleClearProcessingLogs = useCallback(async (): Promise => { const confirmed = await requestConfirmation( 'Clear Processing Log', 'This clears the full diagnostics timeline.', 'Clear Logs', ); if (!confirmed) { return; } setIsClearingLogs(true); try { await clearProcessingLogs(); await loadProcessingTimeline(); setProcessingLogError(null); } catch (caughtError) { setProcessingLogError(caughtError instanceof Error ? caughtError.message : 'Failed to clear processing logs'); } finally { setIsClearingLogs(false); } }, [loadProcessingTimeline, requestConfirmation]); const handleFilterPathFromCard = useCallback((pathValue: string): void => { setActiveSearchQuery(''); setSearchText(''); setTagFilter(''); setTypeFilter(''); setProcessedFrom(''); setProcessedTo(''); setPathFilter(pathValue); setCurrentPage(1); }, []); const handleFilterTagFromCard = useCallback((tagValue: string): void => { setActiveSearchQuery(''); setSearchText(''); setPathFilter(''); setTypeFilter(''); setProcessedFrom(''); setProcessedTo(''); setTagFilter(tagValue); setCurrentPage(1); }, []); if (authPhase === 'checking') { return (

LedgerDock

Checking current session...

); } if (authPhase !== 'authenticated') { return ; } return (

LedgerDock

Document command deck for OCR, routing intelligence, and controlled metadata ops.

{isAdmin && ( )}
{screen === 'documents' && (
)} {screen === 'settings' && isAdmin && (
)}
{error &&

{error}

} {screen === 'settings' && isAdmin && ( )} {screen === 'documents' && ( <>

{documentView === 'trash' ? 'Trashed Documents' : 'Documents'}

{isLoading ? 'Loading...' : `${totalDocuments} document(s)`}

void handleSearch()} onReset={handleResetSearch} hasActiveSearch={hasActiveSearch} knownTags={knownTags} knownPaths={knownPaths} knownTypes={knownTypes} tagFilter={tagFilter} onTagFilterChange={(value) => { setTagFilter(value); setCurrentPage(1); }} typeFilter={typeFilter} onTypeFilterChange={(value) => { setTypeFilter(value); setCurrentPage(1); }} pathFilter={pathFilter} onPathFilterChange={(value) => { setPathFilter(value); setCurrentPage(1); }} processedFrom={processedFrom} onProcessedFromChange={(value) => { setProcessedFrom(value); setCurrentPage(1); }} processedTo={processedTo} onProcessedToChange={(value) => { setProcessedTo(value); setCurrentPage(1); }} isLoading={isLoading} />
Select: Selected {selectedDocumentIds.length} {documentView !== 'trash' && ( )} {documentView === 'trash' && ( )}
Page {currentPage} / {totalPages}
setSelectedDocumentId(document.id)} onToggleChecked={handleToggleChecked} onTrashDocument={handleTrashDocumentCard} onFilterPath={handleFilterPathFromCard} onFilterTag={handleFilterTagFromCard} />
{isAdmin && processingLogError &&

{processingLogError}

} {isAdmin && ( void handleClearProcessingLogs()} /> )} )} closeDialog('cancel')} />
); }