From d4dba36d03656d05e7500558363c9f6d16b07f88 Mon Sep 17 00:00:00 2001 From: perf3ct Date: Mon, 7 Jul 2025 22:01:06 +0000 Subject: [PATCH] feat(client): improve document details page --- .../src/components/FileIntegrityDisplay.tsx | 344 +++++++ frontend/src/components/MetadataParser.tsx | 440 ++++++++ .../src/components/ProcessingTimeline.tsx | 418 ++++++++ .../__tests__/FileIntegrityDisplay.test.tsx | 79 ++ .../__tests__/MetadataParser.test.tsx | 89 ++ .../__tests__/ProcessingTimeline.test.tsx | 90 ++ frontend/src/pages/DocumentDetailsPage.tsx | 958 ++++++++++-------- frontend/src/theme.ts | 225 +++- 8 files changed, 2232 insertions(+), 411 deletions(-) create mode 100644 frontend/src/components/FileIntegrityDisplay.tsx create mode 100644 frontend/src/components/MetadataParser.tsx create mode 100644 frontend/src/components/ProcessingTimeline.tsx create mode 100644 frontend/src/components/__tests__/FileIntegrityDisplay.test.tsx create mode 100644 frontend/src/components/__tests__/MetadataParser.test.tsx create mode 100644 frontend/src/components/__tests__/ProcessingTimeline.test.tsx diff --git a/frontend/src/components/FileIntegrityDisplay.tsx b/frontend/src/components/FileIntegrityDisplay.tsx new file mode 100644 index 0000000..634caaa --- /dev/null +++ b/frontend/src/components/FileIntegrityDisplay.tsx @@ -0,0 +1,344 @@ +import React, { useState } from 'react'; +import { + Box, + Typography, + Chip, + Paper, + IconButton, + Tooltip, + Stack, + CircularProgress, + Alert, +} from '@mui/material'; +import { + Security as SecurityIcon, + Fingerprint as FingerprintIcon, + ContentCopy as CopyIcon, + CheckCircle as CheckIcon, + Warning as WarningIcon, + Error as ErrorIcon, + Info as InfoIcon, +} from '@mui/icons-material'; +import { modernTokens } from '../theme'; + +interface FileIntegrityDisplayProps { + fileHash?: string; + fileName: string; + fileSize: number; + mimeType: string; + createdAt: string; + updatedAt: string; + userId: string; + compact?: boolean; +} + +const FileIntegrityDisplay: React.FC = ({ + fileHash, + fileName, + fileSize, + mimeType, + createdAt, + updatedAt, + userId, + compact = false, +}) => { + const [copied, setCopied] = useState(false); + + const copyToClipboard = (text: string) => { + navigator.clipboard.writeText(text); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }; + + const formatHash = (hash: string) => { + if (!hash) return 'Not available'; + return `${hash.substring(0, 8)}...${hash.substring(hash.length - 8)}`; + }; + + const getIntegrityStatus = () => { + if (!fileHash) { + return { + status: 'unknown', + icon: , + color: modernTokens.colors.neutral[500], + message: 'Hash not available', + }; + } + + // Simple validation - in real implementation you'd verify against stored hash + if (fileHash.length === 64) { // SHA256 length + return { + status: 'verified', + icon: , + color: modernTokens.colors.success[500], + message: 'File integrity verified', + }; + } + + return { + status: 'warning', + icon: , + color: modernTokens.colors.warning[500], + message: 'Hash format unusual', + }; + }; + + const integrityStatus = getIntegrityStatus(); + + const formatFileSize = (bytes: number): string => { + const sizes = ['Bytes', 'KB', 'MB', 'GB']; + if (bytes === 0) return '0 Bytes'; + const i = Math.floor(Math.log(bytes) / Math.log(1024)); + return Math.round(bytes / Math.pow(1024, i) * 100) / 100 + ' ' + sizes[i]; + }; + + const formatDate = (dateString: string): string => { + return new Date(dateString).toLocaleString('en-US', { + year: 'numeric', + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + }); + }; + + if (compact) { + return ( + + + + + + File Integrity + + + + + + + + + Hash (SHA256) + + + + {formatHash(fileHash || '')} + + {fileHash && ( + + copyToClipboard(fileHash)} + sx={{ p: 0.25 }} + > + + + + )} + + + + + + Size + + + {formatFileSize(fileSize)} + + + + + ); + } + + return ( + + {/* Header */} + + + + + File Integrity & Verification + + + + + + + {/* Hash Information */} + + + + + SHA256 Hash + + + + {fileHash ? ( + + + {fileHash} + + + copyToClipboard(fileHash)} + sx={{ ml: 1 }} + > + + + + + ) : ( + + File hash not available. Enable hash generation in upload settings. + + )} + + + {/* File Properties */} + + + File Properties + + + + + + File Size + + + {formatFileSize(fileSize)} + + + + + + MIME Type + + + + + + + Uploaded + + + {formatDate(createdAt)} + + + + {createdAt !== updatedAt && ( + + + Last Modified + + + {formatDate(updatedAt)} + + + )} + + + + Uploaded By + + + + + + + ); +}; + +export default FileIntegrityDisplay; \ No newline at end of file diff --git a/frontend/src/components/MetadataParser.tsx b/frontend/src/components/MetadataParser.tsx new file mode 100644 index 0000000..9d69e1e --- /dev/null +++ b/frontend/src/components/MetadataParser.tsx @@ -0,0 +1,440 @@ +import React from 'react'; +import { + Box, + Typography, + Chip, + Stack, + Paper, + Grid, + Accordion, + AccordionSummary, + AccordionDetails, + Divider, + IconButton, + Tooltip, +} from '@mui/material'; +import { + ExpandMore as ExpandMoreIcon, + PhotoCamera as CameraIcon, + LocationOn as LocationIcon, + DateRange as DateIcon, + Settings as SettingsIcon, + AspectRatio as AspectRatioIcon, + ColorLens as ColorIcon, + Copyright as CopyrightIcon, + Person as PersonIcon, + Business as BusinessIcon, + FileCopy as DocumentIcon, + ContentCopy as CopyIcon, +} from '@mui/icons-material'; +import { modernTokens } from '../theme'; + +// Define border radius values since they might not be in modernTokens +const borderRadius = { + sm: 4, + md: 8, + lg: 12, + xl: 16, +}; + +interface MetadataParserProps { + metadata: Record; + fileType: string; + compact?: boolean; +} + +interface ParsedMetadata { + category: string; + icon: React.ReactElement; + items: Array<{ + label: string; + value: any; + type: 'text' | 'date' | 'location' | 'technical' | 'copyable'; + unit?: string; + }>; +} + +const MetadataParser: React.FC = ({ + metadata, + fileType, + compact = false +}) => { + const copyToClipboard = (text: string) => { + navigator.clipboard.writeText(text); + }; + + const parseExifData = (exif: Record): ParsedMetadata[] => { + const sections: ParsedMetadata[] = []; + + // Camera Information + const cameraInfo = []; + if (exif.make) cameraInfo.push({ label: 'Camera Make', value: exif.make, type: 'text' as const }); + if (exif.model) cameraInfo.push({ label: 'Camera Model', value: exif.model, type: 'text' as const }); + if (exif.lens_make) cameraInfo.push({ label: 'Lens Make', value: exif.lens_make, type: 'text' as const }); + if (exif.lens_model) cameraInfo.push({ label: 'Lens Model', value: exif.lens_model, type: 'text' as const }); + + if (cameraInfo.length > 0) { + sections.push({ + category: 'Camera', + icon: , + items: cameraInfo, + }); + } + + // Technical Settings + const technicalInfo = []; + if (exif.focal_length) technicalInfo.push({ label: 'Focal Length', value: exif.focal_length, type: 'technical' as const, unit: 'mm' }); + if (exif.aperture) technicalInfo.push({ label: 'Aperture', value: `f/${exif.aperture}`, type: 'technical' as const }); + if (exif.exposure_time) technicalInfo.push({ label: 'Shutter Speed', value: exif.exposure_time, type: 'technical' as const, unit: 's' }); + if (exif.iso) technicalInfo.push({ label: 'ISO', value: exif.iso, type: 'technical' as const }); + if (exif.flash) technicalInfo.push({ label: 'Flash', value: exif.flash, type: 'text' as const }); + + if (technicalInfo.length > 0) { + sections.push({ + category: 'Camera Settings', + icon: , + items: technicalInfo, + }); + } + + // Image Properties + const imageInfo = []; + if (exif.width && exif.height) { + imageInfo.push({ + label: 'Dimensions', + value: `${exif.width} × ${exif.height}`, + type: 'technical' as const, + unit: 'px' + }); + } + if (exif.resolution_x && exif.resolution_y) { + imageInfo.push({ + label: 'Resolution', + value: `${exif.resolution_x} × ${exif.resolution_y}`, + type: 'technical' as const, + unit: 'dpi' + }); + } + if (exif.color_space) imageInfo.push({ label: 'Color Space', value: exif.color_space, type: 'text' as const }); + if (exif.orientation) imageInfo.push({ label: 'Orientation', value: exif.orientation, type: 'text' as const }); + + if (imageInfo.length > 0) { + sections.push({ + category: 'Image Properties', + icon: , + items: imageInfo, + }); + } + + // Location Data + if (exif.gps_latitude && exif.gps_longitude) { + sections.push({ + category: 'Location', + icon: , + items: [ + { + label: 'Coordinates', + value: `${exif.gps_latitude}, ${exif.gps_longitude}`, + type: 'location' as const + }, + ...(exif.gps_altitude ? [{ + label: 'Altitude', + value: exif.gps_altitude, + type: 'technical' as const, + unit: 'm' + }] : []), + ], + }); + } + + // Timestamps + const dateInfo = []; + if (exif.date_time_original) dateInfo.push({ label: 'Date Taken', value: exif.date_time_original, type: 'date' as const }); + if (exif.date_time_digitized) dateInfo.push({ label: 'Date Digitized', value: exif.date_time_digitized, type: 'date' as const }); + + if (dateInfo.length > 0) { + sections.push({ + category: 'Timestamps', + icon: , + items: dateInfo, + }); + } + + return sections; + }; + + const parsePdfMetadata = (pdf: Record): ParsedMetadata[] => { + const sections: ParsedMetadata[] = []; + + // Document Information + const docInfo = []; + if (pdf.title) docInfo.push({ label: 'Title', value: pdf.title, type: 'text' as const }); + if (pdf.author) docInfo.push({ label: 'Author', value: pdf.author, type: 'text' as const }); + if (pdf.subject) docInfo.push({ label: 'Subject', value: pdf.subject, type: 'text' as const }); + if (pdf.keywords) docInfo.push({ label: 'Keywords', value: pdf.keywords, type: 'text' as const }); + + if (docInfo.length > 0) { + sections.push({ + category: 'Document Info', + icon: , + items: docInfo, + }); + } + + // Technical Details + const techInfo = []; + if (pdf.creator) techInfo.push({ label: 'Created With', value: pdf.creator, type: 'text' as const }); + if (pdf.producer) techInfo.push({ label: 'PDF Producer', value: pdf.producer, type: 'text' as const }); + if (pdf.pdf_version) techInfo.push({ label: 'PDF Version', value: pdf.pdf_version, type: 'technical' as const }); + if (pdf.page_count) techInfo.push({ label: 'Pages', value: pdf.page_count, type: 'technical' as const }); + if (pdf.encrypted !== undefined) techInfo.push({ label: 'Encrypted', value: pdf.encrypted ? 'Yes' : 'No', type: 'text' as const }); + + if (techInfo.length > 0) { + sections.push({ + category: 'Technical', + icon: , + items: techInfo, + }); + } + + // Timestamps + const dateInfo = []; + if (pdf.creation_date) dateInfo.push({ label: 'Created', value: pdf.creation_date, type: 'date' as const }); + if (pdf.modification_date) dateInfo.push({ label: 'Modified', value: pdf.modification_date, type: 'date' as const }); + + if (dateInfo.length > 0) { + sections.push({ + category: 'Timestamps', + icon: , + items: dateInfo, + }); + } + + return sections; + }; + + const parseOfficeMetadata = (office: Record): ParsedMetadata[] => { + const sections: ParsedMetadata[] = []; + + // Document Properties + const docInfo = []; + if (office.title) docInfo.push({ label: 'Title', value: office.title, type: 'text' as const }); + if (office.author) docInfo.push({ label: 'Author', value: office.author, type: 'text' as const }); + if (office.company) docInfo.push({ label: 'Company', value: office.company, type: 'text' as const }); + if (office.manager) docInfo.push({ label: 'Manager', value: office.manager, type: 'text' as const }); + if (office.category) docInfo.push({ label: 'Category', value: office.category, type: 'text' as const }); + + if (docInfo.length > 0) { + sections.push({ + category: 'Document Properties', + icon: , + items: docInfo, + }); + } + + // Application Info + const appInfo = []; + if (office.application) appInfo.push({ label: 'Application', value: office.application, type: 'text' as const }); + if (office.app_version) appInfo.push({ label: 'Version', value: office.app_version, type: 'technical' as const }); + if (office.template) appInfo.push({ label: 'Template', value: office.template, type: 'text' as const }); + + if (appInfo.length > 0) { + sections.push({ + category: 'Application', + icon: , + items: appInfo, + }); + } + + return sections; + }; + + const parseGenericMetadata = (data: Record): ParsedMetadata[] => { + const sections: ParsedMetadata[] = []; + + // Group remaining metadata + const otherItems = Object.entries(data) + .filter(([key, value]) => value !== null && value !== undefined && value !== '') + .map(([key, value]) => ({ + label: key.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase()), + value: typeof value === 'object' ? JSON.stringify(value) : String(value), + type: 'text' as const, + })); + + if (otherItems.length > 0) { + sections.push({ + category: 'Additional Properties', + icon: , + items: otherItems, + }); + } + + return sections; + }; + + const formatValue = (item: any) => { + switch (item.type) { + case 'date': + try { + return new Date(item.value).toLocaleString(); + } catch { + return item.value; + } + case 'location': + return item.value; + case 'technical': + return `${item.value}${item.unit ? ` ${item.unit}` : ''}`; + case 'copyable': + return ( + + + {item.value} + + + copyToClipboard(item.value)}> + + + + + ); + default: + return item.value; + } + }; + + // Parse metadata based on file type + let parsedSections: ParsedMetadata[] = []; + + if (fileType.includes('image') && metadata.exif) { + parsedSections = [...parsedSections, ...parseExifData(metadata.exif)]; + } + + if (fileType.includes('pdf') && metadata.pdf) { + parsedSections = [...parsedSections, ...parsePdfMetadata(metadata.pdf)]; + } + + if ((fileType.includes('officedocument') || fileType.includes('msword')) && metadata.office) { + parsedSections = [...parsedSections, ...parseOfficeMetadata(metadata.office)]; + } + + // Add any remaining metadata + const remainingMetadata = { ...metadata }; + delete remainingMetadata.exif; + delete remainingMetadata.pdf; + delete remainingMetadata.office; + + if (Object.keys(remainingMetadata).length > 0) { + parsedSections = [...parsedSections, ...parseGenericMetadata(remainingMetadata)]; + } + + if (parsedSections.length === 0) { + return ( + + No detailed metadata available for this file type + + ); + } + + if (compact) { + return ( + + {parsedSections.slice(0, 2).map((section, index) => ( + + + {React.cloneElement(section.icon, { + sx: { fontSize: 16, mr: 1, color: modernTokens.colors.primary[500] } + })} + + {section.category} + + + + {section.items.slice(0, 3).map((item, itemIndex) => ( + + + {item.label} + + + {formatValue(item)} + + + ))} + + + ))} + {parsedSections.length > 2 && ( + + +{parsedSections.length - 2} more sections... + + )} + + ); + } + + return ( + + {parsedSections.map((section, index) => ( + + } + sx={{ + borderRadius: borderRadius.lg, + '& .MuiAccordionSummary-content': { + alignItems: 'center', + }, + }} + > + {React.cloneElement(section.icon, { + sx: { fontSize: 20, mr: 1, color: modernTokens.colors.primary[500] } + })} + + {section.category} + + + + + + {section.items.map((item, itemIndex) => ( + + + + {item.label} + + + {formatValue(item)} + + + + ))} + + + + ))} + + ); +}; + +export default MetadataParser; \ No newline at end of file diff --git a/frontend/src/components/ProcessingTimeline.tsx b/frontend/src/components/ProcessingTimeline.tsx new file mode 100644 index 0000000..2740dee --- /dev/null +++ b/frontend/src/components/ProcessingTimeline.tsx @@ -0,0 +1,418 @@ +import React, { useState, useEffect } from 'react'; +import { + Box, + Typography, + Paper, + Chip, + Stack, + Button, + Collapse, + CircularProgress, + Alert, +} from '@mui/material'; +import { + Timeline, + TimelineItem, + TimelineSeparator, + TimelineConnector, + TimelineContent, + TimelineDot, +} from '@mui/lab'; +import { + Timeline as TimelineIcon, + Upload as UploadIcon, + Psychology as OcrIcon, + CheckCircle as CheckIcon, + Error as ErrorIcon, + Refresh as RetryIcon, + ExpandMore as ExpandIcon, + Schedule as ScheduleIcon, + Person as PersonIcon, +} from '@mui/icons-material'; +import { modernTokens } from '../theme'; +import { documentService } from '../services/api'; + +interface ProcessingTimelineProps { + documentId: string; + fileName: string; + createdAt: string; + updatedAt: string; + userId: string; + ocrStatus?: string; + ocrCompletedAt?: string; + ocrRetryCount?: number; + ocrError?: string; + compact?: boolean; +} + +interface TimelineEvent { + id: string; + timestamp: string; + type: 'upload' | 'ocr_start' | 'ocr_complete' | 'ocr_retry' | 'ocr_error' | 'update'; + title: string; + description?: string; + status: 'success' | 'error' | 'warning' | 'info'; + metadata?: Record; +} + +const ProcessingTimeline: React.FC = ({ + documentId, + fileName, + createdAt, + updatedAt, + userId, + ocrStatus, + ocrCompletedAt, + ocrRetryCount = 0, + ocrError, + compact = false, +}) => { + const [expanded, setExpanded] = useState(!compact); + const [retryHistory, setRetryHistory] = useState([]); + const [loadingHistory, setLoadingHistory] = useState(false); + + const getStatusIcon = (type: string, status: string) => { + switch (type) { + case 'upload': + return ; + case 'ocr_start': + case 'ocr_complete': + return ; + case 'ocr_retry': + return ; + case 'ocr_error': + return ; + default: + return ; + } + }; + + const getStatusColor = (status: string) => { + switch (status) { + case 'success': + return modernTokens.colors.success[500]; + case 'error': + return modernTokens.colors.error[500]; + case 'warning': + return modernTokens.colors.warning[500]; + default: + return modernTokens.colors.info[500]; + } + }; + + const generateTimelineEvents = (): TimelineEvent[] => { + const events: TimelineEvent[] = []; + + // Upload event + events.push({ + id: 'upload', + timestamp: createdAt, + type: 'upload', + title: 'Document Uploaded', + description: `File "${fileName}" was uploaded successfully`, + status: 'success', + metadata: { userId }, + }); + + // OCR processing events + if (ocrStatus) { + if (ocrStatus === 'completed' && ocrCompletedAt) { + events.push({ + id: 'ocr_complete', + timestamp: ocrCompletedAt, + type: 'ocr_complete', + title: 'OCR Processing Completed', + description: 'Text extraction finished successfully', + status: 'success', + }); + } else if (ocrStatus === 'failed' && ocrError) { + events.push({ + id: 'ocr_error', + timestamp: updatedAt, + type: 'ocr_error', + title: 'OCR Processing Failed', + description: ocrError, + status: 'error', + }); + } else if (ocrStatus === 'processing') { + events.push({ + id: 'ocr_start', + timestamp: createdAt, + type: 'ocr_start', + title: 'OCR Processing Started', + description: 'Text extraction is in progress', + status: 'info', + }); + } + } + + // Retry events + if (ocrRetryCount && ocrRetryCount > 0) { + for (let i = 0; i < ocrRetryCount; i++) { + events.push({ + id: `retry_${i}`, + timestamp: updatedAt, // In real implementation, get actual retry timestamps + type: 'ocr_retry', + title: `OCR Retry Attempt ${i + 1}`, + description: 'Attempting to reprocess document', + status: 'warning', + }); + } + } + + // Sort by timestamp + return events.sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime()); + }; + + const loadRetryHistory = async () => { + if (loadingHistory) return; + + setLoadingHistory(true); + try { + // Note: This endpoint might not exist yet, it's for future implementation + const response = await documentService.getRetryHistory?.(documentId); + if (response?.data?.retry_history) { + setRetryHistory(response.data.retry_history); + } + } catch (error) { + console.error('Failed to load retry history:', error); + } finally { + setLoadingHistory(false); + } + }; + + useEffect(() => { + if (expanded && ocrRetryCount > 0) { + loadRetryHistory(); + } + }, [expanded, ocrRetryCount]); + + const formatTimestamp = (timestamp: string) => { + return new Date(timestamp).toLocaleString('en-US', { + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + }); + }; + + const formatDuration = (start: string, end: string) => { + const startTime = new Date(start).getTime(); + const endTime = new Date(end).getTime(); + const duration = endTime - startTime; + + if (duration < 1000) return `${duration}ms`; + if (duration < 60000) return `${Math.round(duration / 1000)}s`; + return `${Math.round(duration / 60000)}m`; + }; + + const events = generateTimelineEvents(); + + if (compact) { + return ( + + + + + + Processing Timeline + + + + {events.length} events + + + + + {events.slice(-2).map((event, index) => ( + + + + + {event.title} + + + + {formatTimestamp(event.timestamp)} + + + ))} + + + + + ); + } + + return ( + + {/* Header */} + + + + + Processing Timeline + + + + + + {ocrRetryCount > 0 && ( + + )} + + + + {/* Timeline */} + + {events.map((event, index) => ( + + + + {getStatusIcon(event.type, event.status)} + + {index < events.length - 1 && ( + + )} + + + + + + {event.title} + + + {formatTimestamp(event.timestamp)} + + + + {event.description && ( + + {event.description} + + )} + + {event.metadata?.userId && ( + + + + User: {event.metadata.userId.substring(0, 8)}... + + + )} + + {index > 0 && events[index - 1] && ( + + (+{formatDuration(events[index - 1].timestamp, event.timestamp)}) + + )} + + + ))} + + + {/* Retry History Section */} + {ocrRetryCount > 0 && ( + + + + + {loadingHistory ? ( + + + + Loading retry history... + + + ) : retryHistory.length > 0 ? ( + + {retryHistory.map((retry, index) => ( + + + Retry #{index + 1} + + + {retry.retry_reason || 'Manual retry'} + + + ))} + + ) : ( + + Detailed retry history not available. Enable detailed logging for future retries. + + )} + + + )} + + ); +}; + +export default ProcessingTimeline; \ No newline at end of file diff --git a/frontend/src/components/__tests__/FileIntegrityDisplay.test.tsx b/frontend/src/components/__tests__/FileIntegrityDisplay.test.tsx new file mode 100644 index 0000000..060ef67 --- /dev/null +++ b/frontend/src/components/__tests__/FileIntegrityDisplay.test.tsx @@ -0,0 +1,79 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import { ThemeProvider } from '@mui/material/styles'; +import theme from '../../theme'; +import FileIntegrityDisplay from '../FileIntegrityDisplay'; + +const renderWithTheme = (component: React.ReactElement) => { + return render( + + {component} + + ); +}; + +describe('FileIntegrityDisplay', () => { + const mockProps = { + fileHash: 'a1b2c3d4e5f6789012345678901234567890123456789012345678901234567890', + fileName: 'test-document.pdf', + fileSize: 1048576, // 1MB + mimeType: 'application/pdf', + createdAt: '2024-01-01T12:00:00Z', + updatedAt: '2024-01-01T12:00:00Z', + userId: 'user-123-456-789', + }; + + it('renders file integrity information', () => { + renderWithTheme( + + ); + + expect(screen.getByText('File Integrity & Verification')).toBeInTheDocument(); + expect(screen.getByText('SHA256 Hash')).toBeInTheDocument(); + expect(screen.getByText('File Properties')).toBeInTheDocument(); + }); + + it('displays file hash correctly', () => { + renderWithTheme( + + ); + + // Should show the full hash in expanded view + expect(screen.getByText(mockProps.fileHash)).toBeInTheDocument(); + }); + + it('shows compact view when compact prop is true', () => { + renderWithTheme( + + ); + + expect(screen.getByText('File Integrity')).toBeInTheDocument(); + // Should show abbreviated hash in compact view + expect(screen.getByText('a1b2c3d4...34567890')).toBeInTheDocument(); + }); + + it('handles missing file hash gracefully', () => { + renderWithTheme( + + ); + + expect(screen.getByText('Hash not available')).toBeInTheDocument(); + expect(screen.getByText('File hash not available. Enable hash generation in upload settings.')).toBeInTheDocument(); + }); + + it('formats file size correctly', () => { + renderWithTheme( + + ); + + expect(screen.getByText('1 MB')).toBeInTheDocument(); + }); + + it('displays user information', () => { + renderWithTheme( + + ); + + expect(screen.getByText('User: user-123...')).toBeInTheDocument(); + }); +}); \ No newline at end of file diff --git a/frontend/src/components/__tests__/MetadataParser.test.tsx b/frontend/src/components/__tests__/MetadataParser.test.tsx new file mode 100644 index 0000000..025dfe4 --- /dev/null +++ b/frontend/src/components/__tests__/MetadataParser.test.tsx @@ -0,0 +1,89 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import { ThemeProvider } from '@mui/material/styles'; +import theme from '../../theme'; +import MetadataParser from '../MetadataParser'; + +const renderWithTheme = (component: React.ReactElement) => { + return render( + + {component} + + ); +}; + +describe('MetadataParser', () => { + const mockImageMetadata = { + exif: { + make: 'Canon', + model: 'EOS R5', + focal_length: 85, + aperture: 2.8, + iso: 800, + width: 4096, + height: 2048, + date_time_original: '2024-01-01T12:00:00Z', + }, + }; + + const mockPdfMetadata = { + pdf: { + title: 'Sample Document', + author: 'Test Author', + creator: 'Adobe Acrobat', + page_count: 5, + creation_date: '2024-01-01T12:00:00Z', + }, + }; + + it('renders EXIF data for image files', () => { + renderWithTheme( + + ); + + expect(screen.getByText('Camera')).toBeInTheDocument(); + expect(screen.getByText('Canon')).toBeInTheDocument(); + expect(screen.getByText('EOS R5')).toBeInTheDocument(); + }); + + it('renders PDF metadata for PDF files', () => { + renderWithTheme( + + ); + + expect(screen.getByText('Document Info')).toBeInTheDocument(); + expect(screen.getByText('Sample Document')).toBeInTheDocument(); + expect(screen.getByText('Test Author')).toBeInTheDocument(); + }); + + it('renders compact view correctly', () => { + renderWithTheme( + + ); + + expect(screen.getByText('Camera')).toBeInTheDocument(); + // In compact mode, should show limited items + expect(screen.getByText('Canon')).toBeInTheDocument(); + }); + + it('shows message when no metadata available', () => { + renderWithTheme( + + ); + + expect(screen.getByText('No detailed metadata available for this file type')).toBeInTheDocument(); + }); +}); \ No newline at end of file diff --git a/frontend/src/components/__tests__/ProcessingTimeline.test.tsx b/frontend/src/components/__tests__/ProcessingTimeline.test.tsx new file mode 100644 index 0000000..38f8924 --- /dev/null +++ b/frontend/src/components/__tests__/ProcessingTimeline.test.tsx @@ -0,0 +1,90 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import { ThemeProvider } from '@mui/material/styles'; +import theme from '../../theme'; +import ProcessingTimeline from '../ProcessingTimeline'; + +const renderWithTheme = (component: React.ReactElement) => { + return render( + + {component} + + ); +}; + +describe('ProcessingTimeline', () => { + const mockProps = { + documentId: 'doc-123', + fileName: 'test-document.pdf', + createdAt: '2024-01-01T12:00:00Z', + updatedAt: '2024-01-01T12:30:00Z', + userId: 'user-123', + ocrStatus: 'completed', + ocrCompletedAt: '2024-01-01T12:15:00Z', + ocrRetryCount: 0, + }; + + it('renders processing timeline', () => { + renderWithTheme( + + ); + + expect(screen.getByText('Processing Timeline')).toBeInTheDocument(); + expect(screen.getByText('Document Uploaded')).toBeInTheDocument(); + expect(screen.getByText('OCR Processing Completed')).toBeInTheDocument(); + }); + + it('shows retry information when retries exist', () => { + renderWithTheme( + + ); + + expect(screen.getByText('2 retries')).toBeInTheDocument(); + expect(screen.getByText('Detailed Retry History')).toBeInTheDocument(); + }); + + it('renders compact view correctly', () => { + renderWithTheme( + + ); + + expect(screen.getByText('Processing Timeline')).toBeInTheDocument(); + expect(screen.getByText('View Full Timeline')).toBeInTheDocument(); + }); + + it('handles OCR error status', () => { + renderWithTheme( + + ); + + expect(screen.getByText('OCR Processing Failed')).toBeInTheDocument(); + }); + + it('shows pending OCR status', () => { + renderWithTheme( + + ); + + expect(screen.getByText('OCR Processing Started')).toBeInTheDocument(); + }); + + it('displays event count', () => { + renderWithTheme( + + ); + + // Should show at least 2 events (upload + OCR completion) + expect(screen.getByText(/\d+ events/)).toBeInTheDocument(); + }); +}); \ No newline at end of file diff --git a/frontend/src/pages/DocumentDetailsPage.tsx b/frontend/src/pages/DocumentDetailsPage.tsx index 2abb536..68fb230 100644 --- a/frontend/src/pages/DocumentDetailsPage.tsx +++ b/frontend/src/pages/DocumentDetailsPage.tsx @@ -18,6 +18,9 @@ import { DialogContent, DialogTitle, DialogActions, + Container, + Fade, + Skeleton, } from '@mui/material'; import Grid from '@mui/material/GridLegacy'; import { @@ -41,13 +44,19 @@ import { Info as InfoIcon, Refresh as RefreshIcon, History as HistoryIcon, + Speed as SpeedIcon, + MoreVert as MoreIcon, } from '@mui/icons-material'; import { documentService, OcrResponse } from '../services/api'; import DocumentViewer from '../components/DocumentViewer'; import LabelSelector from '../components/Labels/LabelSelector'; import { type LabelData } from '../components/Labels/Label'; import MetadataDisplay from '../components/MetadataDisplay'; +import MetadataParser from '../components/MetadataParser'; +import FileIntegrityDisplay from '../components/FileIntegrityDisplay'; +import ProcessingTimeline from '../components/ProcessingTimeline'; import { RetryHistoryModal } from '../components/RetryHistoryModal'; +import { modernTokens, glassEffect } from '../theme'; import api from '../services/api'; interface Document { @@ -57,6 +66,9 @@ interface Document { file_size: number; mime_type: string; created_at: string; + updated_at: string; + user_id: string; + file_hash?: string; has_ocr_text?: boolean; tags?: string[]; original_created_at?: string; @@ -337,487 +349,635 @@ const DocumentDetailsPage: React.FC = () => { } return ( - - {/* Header */} - - - - - Document Details - - - View and manage document information - - - - - {/* Document Preview */} - - - - + + {/* Modern Header */} + + + + + + - {thumbnailUrl ? ( - {document.original_filename} + + {/* Floating Action Menu */} + + + { - e.currentTarget.style.transform = 'scale(1.02)'; - e.currentTarget.style.boxShadow = '0 4px 12px rgba(0,0,0,0.15)'; - }} - onMouseLeave={(e) => { - e.currentTarget.style.transform = 'scale(1)'; - e.currentTarget.style.boxShadow = 'none'; - }} - /> - ) : ( - + + + + + + - {getFileIcon(document.mime_type)} - + + + + + {document?.has_ocr_text && ( + + + + + )} - - - {document.original_filename} - - - - - - {document.has_ocr_text && ( - - )} - {document.mime_type?.includes('image') && ( - - )} - - - - - {document.has_ocr_text && ( - } - /> - )} - - - - - {/* Document Information */} - - - - - Document Information - - - - - - - - - Filename - - - - {document.original_filename} - - - - - - - - - + {thumbnailUrl ? ( + {document.original_filename} { + e.currentTarget.style.transform = 'scale(1.05) rotateY(5deg)'; + e.currentTarget.style.boxShadow = modernTokens.shadows.xl; + }} + onMouseLeave={(e) => { + e.currentTarget.style.transform = 'scale(1) rotateY(0deg)'; + e.currentTarget.style.boxShadow = modernTokens.shadows.lg; + }} + /> + ) : ( + + {React.cloneElement(getFileIcon(document.mime_type), { + sx: { fontSize: 120, color: modernTokens.colors.primary[400] } + })} + + )} + + + {/* File Type Badge */} + + + + + {/* Quick Stats */} + + + File Size + + {formatFileSize(document.file_size)} + - - {formatFileSize(document.file_size)} - - - - - - - - - + + + Upload Date - - - {formatDate(document.created_at)} - - - - - - - - - - File Type + + {formatDate(document.created_at)} - - {document.mime_type} - - - - - {/* Source Metadata Section */} - {(document.original_created_at || document.original_modified_at || document.source_metadata) && ( - <> - {document.original_created_at && ( - - - - - - Original Created - - - - {formatDate(document.original_created_at)} - - - - )} - - {document.original_modified_at && ( - - - - - - Original Modified - - - - {formatDate(document.original_modified_at)} - - - - )} - - {document.source_metadata && Object.keys(document.source_metadata).length > 0 && ( - - - - - - )} - - )} - - {document.tags && document.tags.length > 0 && ( - - - - - - Tags + + {document.has_ocr_text && ( + + + OCR Status + } + /> - - {document.tags.map((tag, index) => ( - - ))} - - - - )} + )} + + + {/* Action Buttons */} + + {document.mime_type?.includes('image') && ( + + + {processedImageLoading ? ( + + ) : ( + + )} + + + )} + + + + {retryingOcr ? ( + + ) : ( + + )} + + + + + + + + + + + + - {/* Labels Section */} - - - - - - - Labels - - + {/* Main Content Area */} + + + {/* File Integrity Display */} + + + {/* Processing Timeline */} + + + {/* Enhanced Metadata Display */} + {document.source_metadata && Object.keys(document.source_metadata).length > 0 && ( + + + + 📊 Rich Metadata Analysis + + + + + )} + + {/* Tags and Labels */} + + + + + 🏷️ Tags & Labels + - {documentLabels.length > 0 ? ( - - {documentLabels.map((label) => ( - - ))} - - ) : ( - - No labels assigned to this document - + + {/* Tags */} + {document.tags && document.tags.length > 0 && ( + + + Tags + + + {document.tags.map((tag, index) => ( + + ))} + + )} - - - - - - - - Processing Status - - - - - - - - Document uploaded successfully - - - - - - - - {document.has_ocr_text ? 'OCR processing completed' : 'OCR processing pending'} - - - - - - - + + {/* Labels */} + + + Labels + + {documentLabels.length > 0 ? ( + + {documentLabels.map((label) => ( + + ))} + + ) : ( + + No labels assigned to this document + + )} + + + + + + + {/* OCR Text Section */} {document.has_ocr_text && ( - - - - - Extracted Text (OCR) - + + + + + + 🔍 Extracted Text (OCR) + + + {ocrLoading ? ( - - - - Loading OCR text... + + + + Loading OCR analysis... ) : ocrData ? ( <> - {/* OCR Stats */} - + {/* Enhanced OCR Stats */} + {ocrData.ocr_confidence && ( - + + + {Math.round(ocrData.ocr_confidence)}% + + + Confidence + + )} {ocrData.ocr_word_count && ( - + + + {ocrData.ocr_word_count.toLocaleString()} + + + Words + + )} {ocrData.ocr_processing_time_ms && ( - + + + {ocrData.ocr_processing_time_ms}ms + + + Processing Time + + )} {/* OCR Error Display */} {ocrData.ocr_error && ( - - OCR Error: {ocrData.ocr_error} + + + OCR Processing Error + + {ocrData.ocr_error} )} - {/* OCR Text Content */} + {/* OCR Text Preview */} theme.palette.mode === 'light' ? 'grey.50' : 'grey.900', - border: '1px solid', - borderColor: 'divider', - maxHeight: 400, + p: 4, + background: `linear-gradient(135deg, ${modernTokens.colors.neutral[50]} 0%, ${modernTokens.colors.neutral[100]} 100%)`, + border: `1px solid ${modernTokens.colors.neutral[300]}`, + borderRadius: 3, + maxHeight: 300, overflow: 'auto', position: 'relative', }} > {ocrData.ocr_text ? ( - {ocrData.ocr_text} + {ocrData.ocr_text.length > 500 + ? `${ocrData.ocr_text.substring(0, 500)}...` + : ocrData.ocr_text + } ) : ( - + No OCR text available for this document. )} + + {ocrData.ocr_text && ocrData.ocr_text.length > 500 && ( + + + + )} {/* Processing Info */} {ocrData.ocr_completed_at && ( - - - Processing completed: {new Date(ocrData.ocr_completed_at).toLocaleString()} + + + ✅ Processing completed: {new Date(ocrData.ocr_completed_at).toLocaleString()} )} ) : ( - - OCR text is available but failed to load. Try clicking the "View OCR" button above. + + OCR text is available but failed to load. Try clicking the "View Full Text" button above. )} - + )} - + {/* OCR Text Dialog */} ({ + background: `rgba(255, 255, 255, ${alphaValue})`, + backdropFilter: 'blur(10px)', + border: '1px solid rgba(255, 255, 255, 0.2)', + boxShadow: modernTokens.shadows.glass, +}); + +// Modern card style +export const modernCard = { + borderRadius: 16, + boxShadow: modernTokens.shadows.md, + border: `1px solid ${modernTokens.colors.neutral[200]}`, + background: modernTokens.colors.neutral[0], + transition: 'all 0.2s ease-in-out', + '&:hover': { + boxShadow: modernTokens.shadows.lg, + transform: 'translateY(-1px)', + }, +}; const theme = createTheme({ palette: { primary: { - main: '#667eea', - light: '#9bb5ff', - dark: '#304ffe', + main: modernTokens.colors.primary[500], + light: modernTokens.colors.primary[300], + dark: modernTokens.colors.primary[700], + 50: modernTokens.colors.primary[50], + 100: modernTokens.colors.primary[100], + 200: modernTokens.colors.primary[200], + 300: modernTokens.colors.primary[300], + 400: modernTokens.colors.primary[400], + 500: modernTokens.colors.primary[500], + 600: modernTokens.colors.primary[600], + 700: modernTokens.colors.primary[700], + 800: modernTokens.colors.primary[800], + 900: modernTokens.colors.primary[900], }, secondary: { - main: '#764ba2', - light: '#a777d9', - dark: '#4c1e74', + main: modernTokens.colors.secondary[500], + light: modernTokens.colors.secondary[300], + dark: modernTokens.colors.secondary[700], }, background: { - default: '#fafafa', + default: modernTokens.colors.neutral[50], + paper: modernTokens.colors.neutral[0], + }, + text: { + primary: modernTokens.colors.neutral[900], + secondary: modernTokens.colors.neutral[600], + }, + divider: modernTokens.colors.neutral[200], + success: { + main: modernTokens.colors.success[500], + light: modernTokens.colors.success[50], + dark: modernTokens.colors.success[600], + }, + warning: { + main: modernTokens.colors.warning[500], + light: modernTokens.colors.warning[50], + dark: modernTokens.colors.warning[600], + }, + error: { + main: modernTokens.colors.error[500], + light: modernTokens.colors.error[50], + dark: modernTokens.colors.error[600], + }, + info: { + main: modernTokens.colors.info[500], + light: modernTokens.colors.info[50], + dark: modernTokens.colors.info[600], }, }, typography: { fontFamily: [ + '"Inter"', '-apple-system', 'BlinkMacSystemFont', '"Segoe UI"', @@ -26,15 +156,54 @@ const theme = createTheme({ 'Arial', 'sans-serif', ].join(','), + h1: { + fontSize: '2.25rem', + fontWeight: 800, + lineHeight: 1.2, + }, + h2: { + fontSize: '1.875rem', + fontWeight: 700, + lineHeight: 1.2, + }, + h3: { + fontSize: '1.5rem', + fontWeight: 700, + lineHeight: 1.2, + }, h4: { + fontSize: '1.25rem', fontWeight: 600, + lineHeight: 1.5, }, h5: { + fontSize: '1.125rem', fontWeight: 600, + lineHeight: 1.5, }, h6: { + fontSize: '1rem', fontWeight: 600, + lineHeight: 1.5, }, + body1: { + fontSize: '1rem', + fontWeight: 400, + lineHeight: 1.75, + }, + body2: { + fontSize: '0.875rem', + fontWeight: 400, + lineHeight: 1.5, + }, + caption: { + fontSize: '0.75rem', + fontWeight: 400, + lineHeight: 1.5, + }, + }, + shape: { + borderRadius: 12, }, components: { MuiButton: { @@ -42,21 +211,53 @@ const theme = createTheme({ root: { textTransform: 'none', borderRadius: 8, + fontWeight: 500, + boxShadow: 'none', + '&:hover': { + boxShadow: modernTokens.shadows.sm, + }, + }, + contained: { + background: `linear-gradient(135deg, ${modernTokens.colors.primary[500]} 0%, ${modernTokens.colors.primary[600]} 100%)`, + '&:hover': { + background: `linear-gradient(135deg, ${modernTokens.colors.primary[600]} 0%, ${modernTokens.colors.primary[700]} 100%)`, + }, }, }, }, MuiCard: { styleOverrides: { - root: { - borderRadius: 12, - boxShadow: '0 2px 8px rgba(0,0,0,0.1)', - }, + root: modernCard, }, }, MuiPaper: { + styleOverrides: { + root: { + borderRadius: 12, + boxShadow: modernTokens.shadows.sm, + }, + }, + }, + MuiChip: { styleOverrides: { root: { borderRadius: 8, + fontWeight: 500, + }, + }, + }, + MuiAccordion: { + styleOverrides: { + root: { + boxShadow: 'none', + border: `1px solid ${modernTokens.colors.neutral[200]}`, + borderRadius: 8, + '&:before': { + display: 'none', + }, + '&.Mui-expanded': { + margin: 0, + }, }, }, },