diff --git a/apps/backend-services/package.json b/apps/backend-services/package.json index d88baa23a..3d76c5b24 100644 --- a/apps/backend-services/package.json +++ b/apps/backend-services/package.json @@ -33,6 +33,7 @@ "@prisma/adapter-pg": "7.0.1", "@prisma/client": "7.0.1", "axios": "1.13.2", + "body-parser": "^1.20.3", "class-transformer": "^0.5.1", "class-validator": "^0.14.3", "cookie-parser": "^1.4.7", diff --git a/apps/backend-services/src/auth/auth.controller.ts b/apps/backend-services/src/auth/auth.controller.ts index 272e0225b..44bbf00f6 100644 --- a/apps/backend-services/src/auth/auth.controller.ts +++ b/apps/backend-services/src/auth/auth.controller.ts @@ -3,6 +3,7 @@ import { Controller, Get, HttpStatus, + Logger, Post, Query, Res, @@ -23,6 +24,7 @@ import { Public } from "./public.decorator"; */ @Controller("auth") export class AuthController { + private readonly logger = new Logger(AuthController.name); constructor(private readonly authService: AuthService) {} /** @@ -96,7 +98,7 @@ export class AuthController { const redirectUrl = this.authService.buildAuthResultRedirect(resultId); return res.redirect(redirectUrl); } catch (error) { - console.error("OAuth callback handling failed:", error); + this.logger.error("OAuth callback handling failed:", error); const redirectUrl = this.authService.buildErrorRedirect("callback_failed"); return res.redirect(redirectUrl); diff --git a/apps/backend-services/src/document/document.controller.ts b/apps/backend-services/src/document/document.controller.ts index e213b6f7b..67dd33454 100644 --- a/apps/backend-services/src/document/document.controller.ts +++ b/apps/backend-services/src/document/document.controller.ts @@ -7,8 +7,11 @@ import { NotFoundException, Param, Req, + Res, } from "@nestjs/common"; -import { Request } from "express"; +import { Request, Response } from "express"; +import { readFile } from "fs/promises"; +import { join } from "path"; import { OcrResult } from "@/generated/client"; import { Roles } from "../auth/roles.decorator"; import { DatabaseService, DocumentData } from "../database/database.service"; @@ -93,18 +96,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; @@ -122,4 +140,61 @@ export class DocumentController { ); } } + + @Get("documents/:documentId/download") + @HttpCode(HttpStatus.OK) + async downloadDocument( + @Param("documentId") documentId: string, + @Res() res: Response, + ): Promise { + this.logger.debug(`=== DocumentController.downloadDocument ===`); + this.logger.debug(`Document ID: ${documentId}`); + + try { + // Find the document + const document = await this.databaseService.findDocument(documentId); + if (!document) { + throw new NotFoundException(`Document not found: ${documentId}`); + } + + // Resolve stored relative path to absolute (we only store relative paths) + const filePath = join(process.cwd(), document.file_path); + + // Read file + const fileBuffer = await readFile(filePath); + + // Set appropriate headers + const fileName = document.original_filename || `document-${documentId}`; + const mimeType = + document.file_type === "pdf" + ? "application/pdf" + : document.file_type === "image" + ? "image/jpeg" + : "application/octet-stream"; + + res.setHeader("Content-Type", mimeType); + res.setHeader("Content-Disposition", `inline; filename="${fileName}"`); + res.setHeader("Content-Length", fileBuffer.length); + + this.logger.debug( + `Serving file: ${filePath} (${fileBuffer.length} bytes)`, + ); + this.logger.debug( + "=== DocumentController.downloadDocument completed ===", + ); + + res.send(fileBuffer); + } catch (error) { + this.logger.error(`Error downloading document: ${error.message}`); + this.logger.error(`Stack: ${error.stack}`); + + if (error instanceof NotFoundException) { + throw error; + } + + throw new NotFoundException( + error.message || `Failed to download document: ${documentId}`, + ); + } + } } diff --git a/apps/backend-services/src/document/document.service.ts b/apps/backend-services/src/document/document.service.ts index 6201c6d2a..0bd2bbf24 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 1786aa161..83575229d 100644 --- a/apps/backend-services/src/main.ts +++ b/apps/backend-services/src/main.ts @@ -1,5 +1,6 @@ import { Logger, ValidationPipe } from "@nestjs/common"; import { NestFactory } from "@nestjs/core"; +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 735be364c..d4a25dbf0 100644 --- a/apps/backend-services/src/ocr/ocr.service.ts +++ b/apps/backend-services/src/ocr/ocr.service.ts @@ -18,7 +18,6 @@ export interface OcrRequestResponse { export class OcrService { private readonly logger = new Logger(OcrService.name); private readonly azureModelId: string; - private readonly storagePath: string; private readonly azureEndpoint: string; private readonly azureApiKey: string; @@ -41,9 +40,6 @@ export class OcrService { this.logger.warn(azureConfigMessage); throw Error(azureConfigMessage); } - this.storagePath = - this.configService.get("STORAGE_PATH") || - join(process.cwd(), "storage", "documents"); } /** @@ -61,9 +57,9 @@ 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}`; + // Resolve stored relative path to absolute (we only store relative paths) + const filePath = join(process.cwd(), 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 +156,44 @@ 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..72bbe8a3f 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 { DatabaseModule } from "../database/database.module"; +import { OcrModule } from "../ocr/ocr.module"; import { QueueService } from "./queue.service"; @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..1574d87f7 100644 --- a/apps/backend-services/src/queue/queue.service.ts +++ b/apps/backend-services/src/queue/queue.service.ts @@ -1,5 +1,7 @@ import { Injectable, Logger } from "@nestjs/common"; -import { ConfigService } from "@nestjs/config"; +import { DatabaseService } from "../database/database.service"; +import { DocumentStatus } from "../generated/enums"; +import { OcrService } from "../ocr/ocr.service"; export interface QueueMessage { documentId: string; @@ -12,62 +14,104 @@ export interface QueueMessage { @Injectable() export class QueueService { private readonly logger = new Logger(QueueService.name); - private readonly rabbitmqUrl: string; - private readonly exchangeName: string; - private readonly routingKey: string; - constructor(private configService: ConfigService) { - this.rabbitmqUrl = - this.configService.get("RABBITMQ_URL") || "amqp://localhost:5672"; - this.exchangeName = - this.configService.get("RABBITMQ_EXCHANGE") || "document_upload"; - this.routingKey = - this.configService.get("RABBITMQ_ROUTING_KEY") || - "document.uploaded"; - this.logger.log(`RabbitMQ URL: ${this.rabbitmqUrl}`); - this.logger.log( - `Exchange: ${this.exchangeName}, Routing Key: ${this.routingKey}`, + constructor( + private ocrService: OcrService, + private databaseService: DatabaseService, + ) {} + + /** + * Process OCR for a document directly (simple implementation) + */ + async processOcrForDocument(message: QueueMessage): Promise { + this.logger.debug( + `=== Starting OCR processing for document ${message.documentId} ===`, ); - } - 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)}`); + 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}`, + ); - // 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; + if (ocrRequest.status === DocumentStatus.failed) { + throw new Error(`OCR request failed: ${ocrRequest.error}`); + } - this.logger.debug( - "=== QueueService.publishDocumentUploaded completed (stubbed) ===", - ); - return true; - } + // 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); - async connect(): Promise { - this.logger.debug("=== QueueService.connect (STUBBED) ==="); - this.logger.debug(`Would connect to RabbitMQ at: ${this.rabbitmqUrl}`); - // Stubbed - in real implementation would establish connection + 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; + } } - async disconnect(): Promise { - this.logger.debug("=== QueueService.disconnect (STUBBED) ==="); - this.logger.debug("Would disconnect from RabbitMQ"); - // Stubbed - in real implementation would close connection + /** + * 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)); + } + } 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)); + } else { + // Other error, fail immediately + throw error; + } + } + } + + throw new Error(`OCR processing timed out after ${maxAttempts} attempts`); } } diff --git a/apps/backend-services/src/upload/upload.controller.ts b/apps/backend-services/src/upload/upload.controller.ts index 1cb623087..b8369e1bc 100644 --- a/apps/backend-services/src/upload/upload.controller.ts +++ b/apps/backend-services/src/upload/upload.controller.ts @@ -81,22 +81,21 @@ export class UploadController { `Document uploaded successfully: ${uploadedDocument.id}`, ); - // Publish message to queue - try { - await this.queueService.publishDocumentUploaded({ + // Fire-and-forget OCR processing; log errors but don't block the response + void this.queueService + .processOcrForDocument({ documentId: uploadedDocument.id, filePath: uploadedDocument.file_path, fileType: uploadedDocument.file_type, metadata: uploadedDocument.metadata, timestamp: new Date(), + }) + .catch((error) => { + this.logger.error( + `Background OCR processing failed for document ${uploadedDocument.id}: ${error.message}`, + ); + this.logger.error(`Stack: ${error.stack}`); }); - 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 - } this.logger.debug("=== UploadController.uploadDocument completed ==="); diff --git a/apps/frontend/package.json b/apps/frontend/package.json index a85f4d5fc..1e35428cc 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.90.10", "axios": "1.13.2", "oidc-client-ts": "^3.4.1", diff --git a/apps/frontend/src/App.tsx b/apps/frontend/src/App.tsx index 2f1ba18ad..b5fabca46 100644 --- a/apps/frontend/src/App.tsx +++ b/apps/frontend/src/App.tsx @@ -1,107 +1,166 @@ -import { JSX, useState } from "react"; -import { useAuth } from "./auth/AuthContext"; -import "./App.css"; -import { DocumentsList, HelloWorld, Login } from "./components"; -import "@mantine/core/styles.css"; import { + AppShell, + Avatar, Badge, Button, - Card, Group, - MantineProvider, Stack, Text, Title, } from "@mantine/core"; +import { IconList, IconLogout, IconUpload } from "@tabler/icons-react"; +import { JSX, useMemo, useState } from "react"; +import { useAuth } from "./auth/AuthContext"; +import "./App.css"; +import { Login } from "./components"; +import { DocumentViewerModal } from "./components/document/DocumentViewerModal"; +import { ProcessingQueue } from "./components/queue/ProcessingQueue"; +import { DocumentUploadPanel } from "./components/upload/DocumentUploadPanel"; +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 [viewerOpened, setViewerOpened] = useState(false); + const [selectedDocument, setSelectedDocument] = useState( + null, + ); + + 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 openViewer = (doc: Document) => { + setSelectedDocument(doc); + setViewerOpened(true); + }; - // 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"} + + - + - + + + {navItems.map((item) => ( + + ))} + + - - - Interactive Counter - - Counter: {count} - - + + + + + + {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()} + + - - Click the button below to increment the counter - + {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. - - - - - - + setViewerOpened(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..e6a083a28 --- /dev/null +++ b/apps/frontend/src/components/details/DocumentDetailDrawer.tsx @@ -0,0 +1,164 @@ +import { + Alert, + Badge, + Button, + CopyButton, + Drawer, + Group, + Image, + ScrollArea, + SimpleGrid, + Skeleton, + Stack, + Tabs, + Text, + Tooltip, +} 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/document/DocumentViewer.tsx b/apps/frontend/src/components/document/DocumentViewer.tsx new file mode 100644 index 000000000..6579c7e1c --- /dev/null +++ b/apps/frontend/src/components/document/DocumentViewer.tsx @@ -0,0 +1,292 @@ +import { Button, Tooltip } from "@mantine/core"; +import { + IconEye, + IconEyeOff, + IconRotateClockwise, + IconZoomIn, + IconZoomOut, +} from "@tabler/icons-react"; +import { useEffect, useRef, useState } from "react"; +import { KeyValuePair } from "../../shared/types"; + +interface DocumentViewerProps { + imageUrl: string; + keyValuePairs?: KeyValuePair[]; + pageNumber?: number; + onZoomChange?: (zoom: number) => void; + showOverlays?: boolean; + onToggleOverlays?: () => void; +} + +export function DocumentViewer({ + imageUrl, + keyValuePairs = [], + pageNumber = 1, + onZoomChange, + showOverlays = true, + onToggleOverlays, +}: DocumentViewerProps) { + const [zoom, setZoom] = useState(1); + const [imageDimensions, setImageDimensions] = useState({ + width: 0, + height: 0, + }); + const [isImageLoaded, setIsImageLoaded] = useState(false); + const imageRef = useRef(null); + const containerRef = useRef(null); + + useEffect(() => { + if (onZoomChange) { + onZoomChange(zoom); + } + }, [zoom, onZoomChange]); + + const handleImageLoad = () => { + if (imageRef.current) { + setImageDimensions({ + width: imageRef.current.naturalWidth, + height: imageRef.current.naturalHeight, + }); + setIsImageLoaded(true); + } + }; + + const handleZoomIn = () => setZoom((prev) => Math.min(prev + 0.25, 3)); + const handleZoomOut = () => setZoom((prev) => Math.max(prev - 0.25, 0.5)); + const handleResetZoom = () => setZoom(1); + + const renderKeyValueOverlays = () => { + if ( + !showOverlays || + !isImageLoaded || + !imageRef.current || + keyValuePairs.length === 0 + ) { + return null; + } + + const img = imageRef.current; + const imgRect = img.getBoundingClientRect(); + + return keyValuePairs + .filter((kvp) => + kvp.key?.boundingRegions?.some((br) => br.pageNumber === pageNumber), + ) + .map((kvp, index) => { + // Use the bounding region for this page + const boundingRegion = kvp.key.boundingRegions.find( + (br) => br.pageNumber === pageNumber, + ); + if (!boundingRegion) return null; + + const polygon = boundingRegion.polygon; + if (!polygon || polygon.length < 8) return null; // Need at least 4 points (8 coordinates) + + // Convert polygon to bounding box for overlay + const xs = []; + const ys = []; + for (let i = 0; i < polygon.length; i += 2) { + xs.push(polygon[i]); + ys.push(polygon[i + 1]); + } + + const minX = Math.min(...xs); + const minY = Math.min(...ys); + const maxX = Math.max(...xs); + const maxY = Math.max(...ys); + + // Scale to display coordinates + const scaleX = imgRect.width / imageDimensions.width; + const scaleY = imgRect.height / imageDimensions.height; + + const left = minX * scaleX; + const top = minY * scaleY; + const width = (maxX - minX) * scaleX; + const height = (maxY - minY) * scaleY; + + // Color based on confidence + const confidenceColor = + kvp.confidence >= 0.9 + ? "rgba(34, 197, 94, 0.3)" // green + : kvp.confidence >= 0.7 + ? "rgba(251, 191, 36, 0.3)" // yellow + : "rgba(239, 68, 68, 0.3)"; // red + + const borderColor = confidenceColor.replace("0.3", "1"); + + const tooltipLabel = ( +
+
+ Key: "{kvp.key.content || "Unknown"}" +
+ {kvp.value?.content && ( +
+ Value: "{kvp.value.content}" +
+ )} +
+ Confidence: {Math.round(kvp.confidence * 100)}% +
+
+ ); + + return ( + +
+ + ); + }); + }; + + return ( +
+ {/* Controls */} +
+
+ + + {keyValuePairs.length} key-value pairs + +
+ +
+ + + {Math.round(zoom * 100)}% + + + +
+
+ + {/* Image Viewer */} +
+
+ Document page + {renderKeyValueOverlays()} +
+
+ + {/* Status Footer */} +
+
+ Page {pageNumber} +
+ + Image: {imageDimensions.width} × {imageDimensions.height} + + Green: ≥90% | Yellow: 70-89% | Red: <70% +
+
+
+
+ ); +} diff --git a/apps/frontend/src/components/document/DocumentViewerModal.tsx b/apps/frontend/src/components/document/DocumentViewerModal.tsx new file mode 100644 index 000000000..c78e26dd0 --- /dev/null +++ b/apps/frontend/src/components/document/DocumentViewerModal.tsx @@ -0,0 +1,181 @@ +import { Alert, Button, Loader, Modal } from "@mantine/core"; +import { IconAlertCircle, IconFileDownload } from "@tabler/icons-react"; +import { useEffect, useState } from "react"; +import { useAuth } from "../../auth/AuthContext"; +import { useDocumentOcr } from "../../data/hooks/useDocumentOcr"; +import { Document } from "../../shared/types"; +import { DocumentViewer } from "./DocumentViewer"; + +interface DocumentViewerModalProps { + document: Document | null; + opened: boolean; + onClose: () => void; +} + +export function DocumentViewerModal({ + document, + opened, + onClose, +}: DocumentViewerModalProps) { + const documentId = document?.id; + const { data: ocrResult } = useDocumentOcr(documentId); + const { getAccessToken } = useAuth(); + const [imageUrl, setImageUrl] = useState(""); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(""); + const [showOverlays, setShowOverlays] = useState(true); + + useEffect(() => { + if (opened && document) { + void loadDocumentImage(document); + } else if (!opened) { + // Clean up object URL when modal closes + if (imageUrl && imageUrl.startsWith("blob:")) { + URL.revokeObjectURL(imageUrl); + } + setImageUrl(""); + setError(""); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [opened, document]); + + const loadDocumentImage = async (doc: Document) => { + setLoading(true); + setError(""); + + try { + const token = getAccessToken?.() ?? null; + const headers: Record = {}; + if (token) { + headers.Authorization = `Bearer ${token}`; + } + + // Try to get the document file from the download endpoint + const response = await fetch(`/api/documents/${doc.id}/download`, { + headers, + }); + + if (!response.ok) { + throw new Error( + `Failed to load document: ${response.status} ${response.statusText}`, + ); + } + + const blob = await response.blob(); + + // Create object URL for the blob + const url = URL.createObjectURL(blob); + setImageUrl(url); + + // Clean up URL when component unmounts or modal closes + return () => { + URL.revokeObjectURL(url); + }; + } catch (err) { + console.error("Error loading document:", err); + setError(err instanceof Error ? err.message : "Failed to load document"); + + if (doc.file_url) { + setImageUrl(doc.file_url); + } + } finally { + setLoading(false); + } + }; + + const handleDownload = () => { + if (!imageUrl) { + return; + } + + const link = window.document.createElement("a"); + link.href = imageUrl; + link.download = document?.original_filename || `document-${document?.id}`; + window.document.body.appendChild(link); + link.click(); + window.document.body.removeChild(link); + }; + + const handleClose = () => { + if (imageUrl && imageUrl.startsWith("blob:")) { + URL.revokeObjectURL(imageUrl); + } + setImageUrl(""); + setError(""); + onClose(); + }; + + return ( + + {!document ? ( +
+

No document selected

+
+ ) : loading ? ( +
+
+ +

Loading document...

+
+
+ ) : error && !imageUrl ? ( +
+ }> + {error} + +
+ ) : ( +
+
+
+

{document.title}

+

+ {document.original_filename} +

+
+ +
+
+ {imageUrl ? ( + setShowOverlays(!showOverlays)} + /> + ) : ( +
+ }> + Document file is not available for preview. The backend may + not expose the raw file stream. + +
+ )} +
+
+ )} +
+ ); +} diff --git a/apps/frontend/src/components/queue/ProcessingQueue.tsx b/apps/frontend/src/components/queue/ProcessingQueue.tsx new file mode 100644 index 000000000..368aef909 --- /dev/null +++ b/apps/frontend/src/components/queue/ProcessingQueue.tsx @@ -0,0 +1,242 @@ +import { + ActionIcon, + Badge, + Center, + Group, + Loader, + Paper, + Select, + SimpleGrid, + Stack, + Table, + Text, + TextInput, + Title, + Tooltip, +} from "@mantine/core"; +import { IconEye, IconRefresh, IconSearch } from "@tabler/icons-react"; +import { useMemo, useState } from "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} + /> +