From 93d009b6a3149da94a68ca273eca162d4bb5cd18 Mon Sep 17 00:00:00 2001 From: Alex Struk Date: Tue, 25 Nov 2025 09:13:59 -0800 Subject: [PATCH 1/7] front end with upload functionality and backend with simple queue system --- apps/backend-services/package.json | 1 + .../src/document/document.controller.ts | 17 +- .../src/document/document.service.ts | 10 +- apps/backend-services/src/main.ts | 13 + apps/backend-services/src/ocr/azureTypes.ts | 2 +- apps/backend-services/src/ocr/ocr.service.ts | 36 +- .../src/queue/queue.module.ts | 3 + .../src/queue/queue.service.ts | 120 ++++-- .../src/upload/upload.controller.ts | 47 ++- apps/frontend/package.json | 3 + apps/frontend/src/App.tsx | 190 ++++++---- .../details/DocumentDetailDrawer.tsx | 133 +++++++ .../src/components/queue/ProcessingQueue.tsx | 206 +++++++++++ .../components/upload/DocumentUploadPanel.tsx | 346 ++++++++++++++++++ .../frontend/src/data/hooks/useDocumentOcr.ts | 19 + apps/frontend/src/data/hooks/useDocuments.ts | 10 +- apps/frontend/src/main.tsx | 9 +- apps/frontend/src/shared/types/index.ts | 40 +- package-lock.json | 232 +++++++++--- 19 files changed, 1265 insertions(+), 172 deletions(-) create mode 100644 apps/frontend/src/components/details/DocumentDetailDrawer.tsx create mode 100644 apps/frontend/src/components/queue/ProcessingQueue.tsx create mode 100644 apps/frontend/src/components/upload/DocumentUploadPanel.tsx create mode 100644 apps/frontend/src/data/hooks/useDocumentOcr.ts diff --git a/apps/backend-services/package.json b/apps/backend-services/package.json index e4561f32a..25a78554f 100644 --- a/apps/backend-services/package.json +++ b/apps/backend-services/package.json @@ -32,6 +32,7 @@ "@nestjs/platform-express": "^11.1.9", "@prisma/client": "^6.19.0", "axios": "1.13.2", + "body-parser": "^1.20.3", "class-transformer": "^0.5.1", "class-validator": "^0.14.0", "cookie-parser": "^1.4.7", diff --git a/apps/backend-services/src/document/document.controller.ts b/apps/backend-services/src/document/document.controller.ts index c802dfacd..b7fd61a26 100644 --- a/apps/backend-services/src/document/document.controller.ts +++ b/apps/backend-services/src/document/document.controller.ts @@ -93,18 +93,33 @@ export class DocumentController { this.logger.debug(`Document ID: ${documentId}`); try { + // First check if document exists and its status + const document = await this.databaseService.findDocument(documentId); + if (document) { + this.logger.debug(`Document status: ${document.status}`); + this.logger.debug(`Document created: ${document.created_at}`); + if (document.apim_request_id) { + this.logger.debug(`APIM Request ID: ${document.apim_request_id}`); + } + } else { + this.logger.warn(`Document not found: ${documentId}`); + throw new NotFoundException(`Document not found: ${documentId}`); + } + const ocrResult = await this.databaseService.findOcrResult(documentId); if (!ocrResult) { this.logger.warn(`OCR result not found for document: ${documentId}`); + this.logger.warn(`Document status is: ${document.status}`); throw new NotFoundException( - `OCR result not found for document: ${documentId}`, + `OCR result not found for document: ${documentId}. Current status: ${document.status}`, ); } this.logger.debug( `OCR result retrieved successfully for document: ${documentId}`, ); + this.logger.debug(`OCR processed at: ${ocrResult.processed_at}`); this.logger.debug("=== DocumentController.getOcrResult completed ==="); return ocrResult; diff --git a/apps/backend-services/src/document/document.service.ts b/apps/backend-services/src/document/document.service.ts index 3c29f78a1..4a62eef38 100644 --- a/apps/backend-services/src/document/document.service.ts +++ b/apps/backend-services/src/document/document.service.ts @@ -98,14 +98,16 @@ export class DocumentService { // Generate unique filename and path const fileName = this.generateFileName(originalFilename, fileType); - const filePath = join(this.storagePath, fileName); + const fullFilePath = join(this.storagePath, fileName); + // Store relative path in database (relative to storage root) + const relativePath = `storage/documents/${fileName}`; // Ensure storage directory exists await this.ensureStorageDirectory(); // Save file to filesystem - await writeFile(filePath, fileBuffer); - this.logger.debug(`File saved to: ${filePath}`); + await writeFile(fullFilePath, fileBuffer); + this.logger.debug(`File saved to: ${fullFilePath}`); // Store metadata in database via API const documentData: Omit< @@ -114,7 +116,7 @@ export class DocumentService { > = { title, original_filename: originalFilename, - file_path: filePath, + file_path: relativePath, file_type: fileType, file_size: fileSize, metadata: (metadata || {}) as JsonValue, diff --git a/apps/backend-services/src/main.ts b/apps/backend-services/src/main.ts index 9fc662bf6..0e46520bc 100644 --- a/apps/backend-services/src/main.ts +++ b/apps/backend-services/src/main.ts @@ -1,5 +1,6 @@ import { NestFactory } from "@nestjs/core"; import { ValidationPipe, Logger } from "@nestjs/common"; +import { json, urlencoded } from "body-parser"; import { AppModule } from "./app.module"; const logger = new Logger("Bootstrap"); @@ -7,6 +8,18 @@ const logger = new Logger("Bootstrap"); async function bootstrap(): Promise { const app = await NestFactory.create(AppModule); + app.use( + json({ + limit: process.env.BODY_LIMIT || "50mb", + }), + ); + app.use( + urlencoded({ + limit: process.env.BODY_LIMIT || "50mb", + extended: true, + }), + ); + // Enable CORS app.enableCors({ origin: process.env.FRONTEND_URL || "http://localhost:3000", diff --git a/apps/backend-services/src/ocr/azureTypes.ts b/apps/backend-services/src/ocr/azureTypes.ts index 7bc6ddd30..be6ee4b5f 100644 --- a/apps/backend-services/src/ocr/azureTypes.ts +++ b/apps/backend-services/src/ocr/azureTypes.ts @@ -2,7 +2,7 @@ export interface AnalysisResponse { status: string; createdDateTime: string; lastUpdatedDateTime: string; - analyzeResult: AnalysisResult; + analyzeResult?: AnalysisResult; // Optional when status is "running" } export interface AnalysisResult { diff --git a/apps/backend-services/src/ocr/ocr.service.ts b/apps/backend-services/src/ocr/ocr.service.ts index 47b0575b8..3a70f8b4a 100644 --- a/apps/backend-services/src/ocr/ocr.service.ts +++ b/apps/backend-services/src/ocr/ocr.service.ts @@ -63,7 +63,17 @@ export class OcrService { try { // Read file from filesystem // TODO: Where is this actually meant to come from? Suggest separating to file service. - const filePath = `${this.storagePath}/${document.file_path}`; + let filePath: string; + if (document.file_path.startsWith('/')) { + // Absolute path + filePath = document.file_path; + } else if (document.file_path.startsWith('storage/documents/')) { + // Relative path from project root + filePath = join(process.cwd(), document.file_path); + } else { + // Legacy relative path from storage directory + filePath = join(this.storagePath, document.file_path); + } const fileBuffer = await readFile(filePath); if (fileBuffer == null) throw Error("File not found."); this.logger.debug(`File size: ${fileBuffer.length} bytes`); @@ -160,12 +170,32 @@ export class OcrService { } const analysisResponse: AnalysisResponse = azureResponse.data; - const anaysisResult = analysisResponse.analyzeResult; + this.logger.debug(`Azure response status: ${analysisResponse.status}`); + this.logger.debug(`Azure response created: ${analysisResponse.createdDateTime}`); + this.logger.debug(`Azure response updated: ${analysisResponse.lastUpdatedDateTime}`); + + // Log the full response for debugging + // this.logger.debug(`Full Azure response: ${JSON.stringify(analysisResponse, null, 2)}`); + + // If status is "running", processing is not complete yet + if (analysisResponse.status === 'running') { + this.logger.debug(`OCR processing still running for document ${documentId}, will retry later`); + return null; // Indicate processing not complete + } + + const analysisResult = analysisResponse.analyzeResult; + if (!analysisResult) { + throw new Error(`No analyzeResult in Azure response for document ${documentId} (status: ${analysisResponse.status})`); + } + + this.logger.debug(`Analysis result content length: ${analysisResult.content?.length || 0}`); + this.logger.debug(`Analysis result pages: ${analysisResult.pages?.length || 0}`); + // Update OCR results table this.databaseService.upsertOcrResult({ documentId, analysisResponse, }); - return anaysisResult; + return analysisResult; } } diff --git a/apps/backend-services/src/queue/queue.module.ts b/apps/backend-services/src/queue/queue.module.ts index aab2ac7bf..72d1b1cb7 100644 --- a/apps/backend-services/src/queue/queue.module.ts +++ b/apps/backend-services/src/queue/queue.module.ts @@ -1,8 +1,11 @@ import { Module } from "@nestjs/common"; import { QueueService } from "./queue.service"; +import { OcrModule } from "../ocr/ocr.module"; +import { DatabaseModule } from "../database/database.module"; @Module({ providers: [QueueService], exports: [QueueService], + imports: [OcrModule, DatabaseModule], }) export class QueueModule {} diff --git a/apps/backend-services/src/queue/queue.service.ts b/apps/backend-services/src/queue/queue.service.ts index e032b5883..53e2bcf47 100644 --- a/apps/backend-services/src/queue/queue.service.ts +++ b/apps/backend-services/src/queue/queue.service.ts @@ -1,5 +1,8 @@ import { Injectable, Logger } from "@nestjs/common"; import { ConfigService } from "@nestjs/config"; +import { OcrService } from "../ocr/ocr.service"; +import { DatabaseService } from "../database/database.service"; +import { DocumentStatus } from "../generated/enums"; export interface QueueMessage { documentId: string; @@ -16,7 +19,11 @@ export class QueueService { private readonly exchangeName: string; private readonly routingKey: string; - constructor(private configService: ConfigService) { + constructor( + private configService: ConfigService, + private ocrService: OcrService, + private databaseService: DatabaseService, + ) { this.rabbitmqUrl = this.configService.get("RABBITMQ_URL") || "amqp://localhost:5672"; this.exchangeName = @@ -31,32 +38,91 @@ export class QueueService { } async publishDocumentUploaded(message: QueueMessage): Promise { - this.logger.debug("=== QueueService.publishDocumentUploaded (STUBBED) ==="); - this.logger.debug(`Would publish to RabbitMQ:`); - this.logger.debug(` URL: ${this.rabbitmqUrl}`); - this.logger.debug(` Exchange: ${this.exchangeName}`); - this.logger.debug(` Routing Key: ${this.routingKey}`); - this.logger.debug(` Message: ${JSON.stringify(message, null, 2)}`); - - // Stubbed implementation - logs the message - // In real implementation, this would connect to RabbitMQ and publish: - // const connection = await amqp.connect(this.rabbitmqUrl); - // const channel = await connection.createChannel(); - // await channel.assertExchange(this.exchangeName, 'topic', { durable: true }); - // const published = channel.publish( - // this.exchangeName, - // this.routingKey, - // Buffer.from(JSON.stringify(message)), - // { persistent: true } - // ); - // await channel.close(); - // await connection.close(); - // return published; - - this.logger.debug( - "=== QueueService.publishDocumentUploaded completed (stubbed) ===", - ); - return true; + this.logger.debug("=== QueueService.publishDocumentUploaded ==="); + this.logger.debug(`Processing document for OCR: ${message.documentId}`); + + try { + // Start OCR processing immediately instead of queuing + await this.processOcrForDocument(message); + this.logger.debug("=== QueueService.publishDocumentUploaded completed ==="); + return true; + } catch (error) { + this.logger.error(`Failed to process OCR for document ${message.documentId}: ${error.message}`); + this.logger.error(`Stack: ${error.stack}`); + throw error; + } + } + + /** + * Process OCR for a document directly (simple implementation) + */ + private async processOcrForDocument(message: QueueMessage): Promise { + this.logger.debug(`=== Starting OCR processing for document ${message.documentId} ===`); + + try { + // Step 1: Request OCR from Azure + this.logger.debug(`Requesting OCR from Azure for document ${message.documentId}`); + const ocrRequest = await this.ocrService.requestOcr(message.documentId); + this.logger.debug(`OCR request sent. Status: ${ocrRequest.status}, APIM Request ID: ${ocrRequest.apimRequestId}`); + + if (ocrRequest.status === DocumentStatus.failed) { + throw new Error(`OCR request failed: ${ocrRequest.error}`); + } + + // Step 2: Poll for OCR results (simplified - in production you'd want better polling logic) + this.logger.debug(`Waiting for OCR results...`); + await this.waitForOcrCompletion(message.documentId); + + this.logger.debug(`=== OCR processing completed for document ${message.documentId} ===`); + } catch (error) { + this.logger.error(`OCR processing failed for document ${message.documentId}: ${error.message}`); + // Update document status to failed + await this.databaseService.updateDocument(message.documentId, { + status: DocumentStatus.failed, + }); + throw error; + } + } + + /** + * Wait for OCR completion by polling Azure (simplified polling) + */ + private async waitForOcrCompletion(documentId: string, maxAttempts: number = 30, delayMs: number = 2000): Promise { + for (let attempt = 1; attempt <= maxAttempts; attempt++) { + try { + this.logger.debug(`Polling OCR results attempt ${attempt}/${maxAttempts} for document ${documentId}`); + + // Try to retrieve OCR results + const ocrResults = await this.ocrService.retrieveOcrResults(documentId); + + if (ocrResults && ocrResults.content) { + this.logger.debug(`OCR results retrieved successfully for document ${documentId}`); + this.logger.debug(`OCR content length: ${ocrResults.content.length}`); + // Update document status to completed + await this.databaseService.updateDocument(documentId, { + status: DocumentStatus.completed_ocr, + }); + return; + } else { + // Either null (processing running) or no valid results yet + this.logger.debug(`OCR results not ready yet for document ${documentId} (results: ${ocrResults}), will retry...`); + await new Promise(resolve => setTimeout(resolve, delayMs)); + continue; + } + } catch (error) { + if (error.message.includes('not yet been sent for OCR') || error.message.includes('Failed to retrieve OCR results')) { + // OCR still processing, wait and try again + this.logger.debug(`OCR still processing, waiting... (${error.message})`); + await new Promise(resolve => setTimeout(resolve, delayMs)); + continue; + } else { + // Other error, fail immediately + throw error; + } + } + } + + throw new Error(`OCR processing timed out after ${maxAttempts} attempts`); } async connect(): Promise { diff --git a/apps/backend-services/src/upload/upload.controller.ts b/apps/backend-services/src/upload/upload.controller.ts index fe713337a..e6970bb58 100644 --- a/apps/backend-services/src/upload/upload.controller.ts +++ b/apps/backend-services/src/upload/upload.controller.ts @@ -81,22 +81,14 @@ export class UploadController { `Document uploaded successfully: ${uploadedDocument.id}`, ); - // Publish message to queue - try { - await this.queueService.publishDocumentUploaded({ - documentId: uploadedDocument.id, - filePath: uploadedDocument.file_path, - fileType: uploadedDocument.file_type, - metadata: uploadedDocument.metadata, - timestamp: new Date(), - }); - this.logger.debug("Message published to queue"); - } catch (queueError) { - this.logger.error( - `Failed to publish message to queue: ${queueError.message}`, - ); - // Don't fail the upload if queue publish fails - log and continue - } + // Start OCR processing asynchronously (don't await) + this.startOcrProcessingAsync({ + documentId: uploadedDocument.id, + filePath: uploadedDocument.file_path, + fileType: uploadedDocument.file_type, + metadata: uploadedDocument.metadata, + timestamp: new Date(), + }); this.logger.debug("=== UploadController.uploadDocument completed ==="); @@ -125,4 +117,27 @@ export class UploadController { ); } } + + /** + * Start OCR processing asynchronously without blocking the upload response + */ + private startOcrProcessingAsync(message: { + documentId: string; + filePath: string; + fileType: string; + metadata?: Record; + timestamp: Date; + }): void { + // Run OCR processing in background + setImmediate(async () => { + try { + this.logger.debug(`Starting background OCR processing for document ${message.documentId}`); + await this.queueService.publishDocumentUploaded(message); + this.logger.debug(`Background OCR processing completed for document ${message.documentId}`); + } catch (error) { + this.logger.error(`Background OCR processing failed for document ${message.documentId}: ${error.message}`); + this.logger.error(`Stack: ${error.stack}`); + } + }); + } } diff --git a/apps/frontend/package.json b/apps/frontend/package.json index 3bd961873..03f046e92 100644 --- a/apps/frontend/package.json +++ b/apps/frontend/package.json @@ -15,7 +15,10 @@ }, "dependencies": { "@mantine/core": "^8.3.8", + "@mantine/dropzone": "^8.3.9", "@mantine/hooks": "^8.3.8", + "@mantine/notifications": "^8.3.8", + "@tabler/icons-react": "^3.14.0", "@tanstack/react-query": "^5.45.0", "axios": "^1.7.4", "oidc-client-ts": "^3.4.1", diff --git a/apps/frontend/src/App.tsx b/apps/frontend/src/App.tsx index 9163f3ec0..eb9cba5b0 100644 --- a/apps/frontend/src/App.tsx +++ b/apps/frontend/src/App.tsx @@ -1,95 +1,139 @@ -import { useState } from 'react' +import { useMemo, useState } from 'react' +import { AppShell, Button, Group, Text, Title, Stack, Badge, Avatar } from '@mantine/core' +import { IconLogout, IconUpload, IconList } from '@tabler/icons-react' import { useAuth } from './auth/AuthContext' import './App.css' -import { HelloWorld, DocumentsList, Login } from './components' -import '@mantine/core/styles.css' -import { MantineProvider, Title, Button, Card, Text, Badge, Group, Stack } from '@mantine/core' +import { Login } from './components' +import { DocumentUploadPanel } from './components/upload/DocumentUploadPanel' +import { ProcessingQueue } from './components/queue/ProcessingQueue' +import { DocumentDetailDrawer } from './components/details/DocumentDetailDrawer' +import type { Document } from './shared/types' + +type MainView = 'upload' | 'queue' function AppContent(): JSX.Element { - const [count, setCount] = useState(0) - const { isAuthenticated, isLoading, logout } = useAuth() + const { isAuthenticated, isLoading, logout, user } = useAuth() + const [activeView, setActiveView] = useState('upload') + const [selectedDocument, setSelectedDocument] = useState(null) + const [detailOpened, setDetailOpened] = useState(false) + + const navItems = useMemo( + () => [ + { value: 'upload' as MainView, label: 'Upload', description: 'Send new files', icon: IconUpload }, + { value: 'queue' as MainView, label: 'Processing queue', description: 'Track statuses', icon: IconList }, + ], + [], + ) + + const openDocument = (doc: Document) => { + setSelectedDocument(doc) + setDetailOpened(true) + if (activeView !== 'queue') { + setActiveView('queue') + } + } - // Show loading state while determining authentication status or refreshing tokens if (isLoading) { return ( - -
-

Loading...

-

Checking authentication status...

-
-
+ + Loading… + Checking authentication status + ) } if (!isAuthenticated) { - return ( - - - - ) + return } - // Token is set synchronously above, so API calls will have auth headers immediately - return ( - - - - AI OCR Frontend - - - Mantine UI - - + <> + + + + + Document intelligence + + Live OCR + + + + + + {user?.profile?.name ?? 'Authenticated user'} + + + {user?.profile?.email ?? 'Logged in'} + + + {user?.profile?.name?.[0] ?? 'U'} + + - + - - - - - Interactive Counter - - Counter: {count} - - + + + {navItems.map((item) => ( + + ))} + + - - Click the button below to increment the counter - + + + + + {activeView === 'upload' ? 'Upload documents' : 'Processing monitor'} + + {activeView === 'upload' + ? 'Add new images and track their ingestion progress.' + : 'View the OCR pipeline and drill into results.'} + + + + {new Date().toLocaleDateString()} + + - + {activeView === 'upload' ? ( + + ) : ( + + )} + + + - - Edit src/App.tsx and save to test HMR - - - - - Built with Modern Tools - - This application uses Vite, React, TypeScript, and now Mantine UI components for a beautiful and consistent design system. - - - - - - + setDetailOpened(false)} + /> + ) } diff --git a/apps/frontend/src/components/details/DocumentDetailDrawer.tsx b/apps/frontend/src/components/details/DocumentDetailDrawer.tsx new file mode 100644 index 000000000..aa5130f55 --- /dev/null +++ b/apps/frontend/src/components/details/DocumentDetailDrawer.tsx @@ -0,0 +1,133 @@ +import { + Drawer, + Tabs, + Image, + ScrollArea, + Text, + Stack, + Group, + Badge, + SimpleGrid, + Button, + Skeleton, + CopyButton, + Tooltip, + Alert, +} from '@mantine/core'; +import { IconAlertCircle, IconCopy, IconEye } from '@tabler/icons-react'; +import { useDocumentOcr } from '../../data/hooks/useDocumentOcr'; +import type { Document } from '../../shared/types'; +import { formatDate, formatFileSize } from '../../shared/utils'; + +interface DocumentDetailDrawerProps { + document: Document | null; + opened: boolean; + onClose: () => void; +} + +export function DocumentDetailDrawer({ document, opened, onClose }: DocumentDetailDrawerProps) { + const documentId = document?.id; + const { data: ocrResult, isLoading, isError, error } = useDocumentOcr(documentId); + + return ( + + {!document ? ( + + Select a document to inspect its OCR output. + + Use the queue on the left to choose a document. + + + ) : ( + + + +
+ {document.title} + + {document.original_filename} + +
+ {document.status ?? 'unknown'} +
+ + + + Created + + {formatDate(new Date(document.created_at))} + + + + File size + + {formatFileSize(document.file_size)} + + +
+ + + + }> + Document + + OCR text + + + + {document.file_url ? ( + {document.title} + ) : ( + } + variant="light" + > + The backend does not yet expose the raw file stream. Add a download endpoint to preview the exact + image here. + + )} + + + {isLoading ? ( + + + + + + ) : isError ? ( + }> + {error instanceof Error ? error.message : 'Failed to load OCR output'} + + ) : ocrResult ? ( + + + Extracted text + + {({ copied, copy }) => ( + + + + )} + + + + + {ocrResult.extracted_text || 'No text returned yet.'} + + + + ) : ( + No OCR output recorded for this document. + )} + + +
+ )} +
+ ); +} + diff --git a/apps/frontend/src/components/queue/ProcessingQueue.tsx b/apps/frontend/src/components/queue/ProcessingQueue.tsx new file mode 100644 index 000000000..591863669 --- /dev/null +++ b/apps/frontend/src/components/queue/ProcessingQueue.tsx @@ -0,0 +1,206 @@ +import { useMemo, useState } from 'react'; +import { + Paper, + Stack, + Group, + Title, + Text, + TextInput, + Select, + SimpleGrid, + Badge, + Table, + Loader, + Center, + ActionIcon, + Tooltip, +} from '@mantine/core'; +import { IconSearch, IconEye, IconRefresh } from '@tabler/icons-react'; +import { useDocuments } from '../../data/hooks/useDocuments'; +import type { Document, DocumentStatus } from '../../shared/types'; +import { formatDate, formatFileSize } from '../../shared/utils'; + +interface ProcessingQueueProps { + onSelectDocument?: (doc: Document) => void; +} + +const statusOptions: { value: DocumentStatus | 'all'; label: string }[] = [ + { value: 'all', label: 'All statuses' }, + { value: 'pre_ocr', label: 'Waiting' }, + { value: 'ongoing_ocr', label: 'Processing' }, + { value: 'completed_ocr', label: 'Completed' }, + { value: 'failed', label: 'Failed' }, +]; + +const statusStyles: Record = { + pre_ocr: { color: 'gray', label: 'Queued' }, + ongoing_ocr: { color: 'yellow', label: 'Processing' }, + completed_ocr: { color: 'green', label: 'Complete' }, + failed: { color: 'red', label: 'Failed' }, +}; + +export function ProcessingQueue({ onSelectDocument }: ProcessingQueueProps) { + const [search, setSearch] = useState(''); + const [statusFilter, setStatusFilter] = useState('all'); + const { data: documents, isLoading, isFetching, refetch } = useDocuments({ refetchInterval: 10000 }); + + const filteredDocuments = useMemo(() => { + if (!documents) return []; + const needle = search.toLowerCase(); + return documents.filter((doc) => { + const ministry = typeof doc.metadata?.ministry === 'string' ? (doc.metadata.ministry as string) : ''; + const matchesSearch = + doc.title.toLowerCase().includes(needle) || + doc.original_filename.toLowerCase().includes(needle) || + ministry.toLowerCase().includes(needle); + const matchesStatus = statusFilter === 'all' || doc.status === statusFilter; + return matchesSearch && matchesStatus; + }); + }, [documents, search, statusFilter]); + + const stats = useMemo(() => { + const base = { total: documents?.length ?? 0, completed: 0, processing: 0, failed: 0 }; + documents?.forEach((doc) => { + if (doc.status === 'completed_ocr') base.completed += 1; + if (doc.status === 'ongoing_ocr') base.processing += 1; + if (doc.status === 'failed') base.failed += 1; + }); + return base; + }, [documents]); + + return ( + + + +
+ Processing queue + + Track OCR progress and open any document to review. + +
+ + refetch()} loading={isFetching}> + + + +
+ + + + + Total + + + {stats.total} + + + + + Completed + + + {stats.completed} + + + + + Processing / Failed + + + {stats.processing} / {stats.failed} + + + + + + setSearch(event.currentTarget.value)} + leftSection={} + flex={1} + /> +