feat(client): improve document details page

This commit is contained in:
perf3ct 2025-07-07 22:01:06 +00:00
parent 3a784d4ddf
commit c5827a3d20
No known key found for this signature in database
GPG Key ID: 569C4EEC436F5232
8 changed files with 2232 additions and 411 deletions

View File

@ -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<FileIntegrityDisplayProps> = ({
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: <InfoIcon />,
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: <CheckIcon />,
color: modernTokens.colors.success[500],
message: 'File integrity verified',
};
}
return {
status: 'warning',
icon: <WarningIcon />,
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 (
<Paper
sx={{
p: 2,
background: `linear-gradient(135deg, ${modernTokens.colors.neutral[50]} 0%, ${modernTokens.colors.primary[50]} 100%)`,
border: `1px solid ${modernTokens.colors.neutral[200]}`,
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1 }}>
<Box sx={{ display: 'flex', alignItems: 'center' }}>
<SecurityIcon
sx={{
fontSize: 18,
mr: 1,
color: integrityStatus.color
}}
/>
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
File Integrity
</Typography>
</Box>
<Chip
size="small"
label={integrityStatus.status}
sx={{
backgroundColor: `${integrityStatus.color}20`,
color: integrityStatus.color,
border: `1px solid ${integrityStatus.color}40`,
textTransform: 'capitalize',
}}
/>
</Box>
<Stack spacing={1}>
<Box sx={{ display: 'flex', justifyContent: 'space-between' }}>
<Typography variant="caption" color="text.secondary">
Hash (SHA256)
</Typography>
<Box sx={{ display: 'flex', alignItems: 'center' }}>
<Typography
variant="caption"
sx={{
fontFamily: 'monospace',
fontWeight: 500,
mr: 0.5,
}}
>
{formatHash(fileHash || '')}
</Typography>
{fileHash && (
<Tooltip title={copied ? 'Copied!' : 'Copy full hash'}>
<IconButton
size="small"
onClick={() => copyToClipboard(fileHash)}
sx={{ p: 0.25 }}
>
<CopyIcon sx={{ fontSize: 12 }} />
</IconButton>
</Tooltip>
)}
</Box>
</Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between' }}>
<Typography variant="caption" color="text.secondary">
Size
</Typography>
<Typography variant="caption" sx={{ fontWeight: 500 }}>
{formatFileSize(fileSize)}
</Typography>
</Box>
</Stack>
</Paper>
);
}
return (
<Paper
sx={{
p: 3,
background: `linear-gradient(135deg, ${modernTokens.colors.neutral[50]} 0%, ${modernTokens.colors.primary[50]} 100%)`,
border: `1px solid ${modernTokens.colors.neutral[200]}`,
}}
>
{/* Header */}
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 3 }}>
<Box sx={{ display: 'flex', alignItems: 'center' }}>
<SecurityIcon
sx={{
fontSize: 24,
mr: 1.5,
color: modernTokens.colors.primary[500]
}}
/>
<Typography variant="h6" sx={{ fontWeight: 600 }}>
File Integrity & Verification
</Typography>
</Box>
<Chip
icon={React.cloneElement(integrityStatus.icon, { sx: { fontSize: 18 } })}
label={integrityStatus.message}
sx={{
backgroundColor: `${integrityStatus.color}20`,
color: integrityStatus.color,
border: `1px solid ${integrityStatus.color}40`,
fontWeight: 500,
}}
/>
</Box>
{/* Hash Information */}
<Box sx={{ mb: 3 }}>
<Box sx={{ display: 'flex', alignItems: 'center', mb: 1 }}>
<FingerprintIcon
sx={{
fontSize: 18,
mr: 1,
color: modernTokens.colors.neutral[600]
}}
/>
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
SHA256 Hash
</Typography>
</Box>
{fileHash ? (
<Box
sx={{
display: 'flex',
alignItems: 'center',
p: 2,
backgroundColor: modernTokens.colors.neutral[100],
borderRadius: 1,
border: `1px solid ${modernTokens.colors.neutral[200]}`,
}}
>
<Typography
variant="body2"
sx={{
fontFamily: 'monospace',
flex: 1,
wordBreak: 'break-all',
fontSize: '0.8rem',
color: modernTokens.colors.neutral[700],
}}
>
{fileHash}
</Typography>
<Tooltip title={copied ? 'Copied!' : 'Copy hash'}>
<IconButton
size="small"
onClick={() => copyToClipboard(fileHash)}
sx={{ ml: 1 }}
>
<CopyIcon fontSize="small" />
</IconButton>
</Tooltip>
</Box>
) : (
<Alert severity="info" sx={{ mt: 1 }}>
File hash not available. Enable hash generation in upload settings.
</Alert>
)}
</Box>
{/* File Properties */}
<Box sx={{ mb: 2 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 2 }}>
File Properties
</Typography>
<Stack spacing={2}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Typography variant="body2" color="text.secondary">
File Size
</Typography>
<Typography variant="body2" sx={{ fontWeight: 500 }}>
{formatFileSize(fileSize)}
</Typography>
</Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Typography variant="body2" color="text.secondary">
MIME Type
</Typography>
<Chip
label={mimeType}
size="small"
sx={{
fontSize: '0.75rem',
backgroundColor: modernTokens.colors.neutral[100],
border: `1px solid ${modernTokens.colors.neutral[300]}`,
}}
/>
</Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Typography variant="body2" color="text.secondary">
Uploaded
</Typography>
<Typography variant="body2" sx={{ fontWeight: 500 }}>
{formatDate(createdAt)}
</Typography>
</Box>
{createdAt !== updatedAt && (
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Typography variant="body2" color="text.secondary">
Last Modified
</Typography>
<Typography variant="body2" sx={{ fontWeight: 500 }}>
{formatDate(updatedAt)}
</Typography>
</Box>
)}
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Typography variant="body2" color="text.secondary">
Uploaded By
</Typography>
<Chip
label={`User: ${userId.substring(0, 8)}...`}
size="small"
sx={{
fontSize: '0.75rem',
backgroundColor: modernTokens.colors.primary[50],
color: modernTokens.colors.primary[700],
border: `1px solid ${modernTokens.colors.primary[200]}`,
}}
/>
</Box>
</Stack>
</Box>
</Paper>
);
};
export default FileIntegrityDisplay;

View File

@ -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<string, any>;
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<MetadataParserProps> = ({
metadata,
fileType,
compact = false
}) => {
const copyToClipboard = (text: string) => {
navigator.clipboard.writeText(text);
};
const parseExifData = (exif: Record<string, any>): 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: <CameraIcon />,
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: <SettingsIcon />,
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: <AspectRatioIcon />,
items: imageInfo,
});
}
// Location Data
if (exif.gps_latitude && exif.gps_longitude) {
sections.push({
category: 'Location',
icon: <LocationIcon />,
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: <DateIcon />,
items: dateInfo,
});
}
return sections;
};
const parsePdfMetadata = (pdf: Record<string, any>): 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: <DocumentIcon />,
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: <SettingsIcon />,
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: <DateIcon />,
items: dateInfo,
});
}
return sections;
};
const parseOfficeMetadata = (office: Record<string, any>): 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: <PersonIcon />,
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: <BusinessIcon />,
items: appInfo,
});
}
return sections;
};
const parseGenericMetadata = (data: Record<string, any>): 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: <SettingsIcon />,
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 (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography variant="body2" sx={{ fontFamily: 'monospace', flex: 1 }}>
{item.value}
</Typography>
<Tooltip title="Copy to clipboard">
<IconButton size="small" onClick={() => copyToClipboard(item.value)}>
<CopyIcon fontSize="small" />
</IconButton>
</Tooltip>
</Box>
);
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 (
<Typography variant="body2" color="text.secondary" sx={{ fontStyle: 'italic' }}>
No detailed metadata available for this file type
</Typography>
);
}
if (compact) {
return (
<Box>
{parsedSections.slice(0, 2).map((section, index) => (
<Box key={index} sx={{ mb: 2 }}>
<Box sx={{ display: 'flex', alignItems: 'center', mb: 1 }}>
{React.cloneElement(section.icon, {
sx: { fontSize: 16, mr: 1, color: modernTokens.colors.primary[500] }
})}
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
{section.category}
</Typography>
</Box>
<Stack spacing={1}>
{section.items.slice(0, 3).map((item, itemIndex) => (
<Box key={itemIndex} sx={{ display: 'flex', justifyContent: 'space-between' }}>
<Typography variant="caption" color="text.secondary">
{item.label}
</Typography>
<Typography variant="caption" sx={{ fontWeight: 500 }}>
{formatValue(item)}
</Typography>
</Box>
))}
</Stack>
</Box>
))}
{parsedSections.length > 2 && (
<Typography variant="caption" color="text.secondary">
+{parsedSections.length - 2} more sections...
</Typography>
)}
</Box>
);
}
return (
<Box>
{parsedSections.map((section, index) => (
<Accordion
key={index}
sx={{
boxShadow: 'none',
border: `1px solid ${modernTokens.colors.neutral[200]}`,
borderRadius: borderRadius.lg,
mb: 1,
'&:before': { display: 'none' },
}}
>
<AccordionSummary
expandIcon={<ExpandMoreIcon />}
sx={{
borderRadius: borderRadius.lg,
'& .MuiAccordionSummary-content': {
alignItems: 'center',
},
}}
>
{React.cloneElement(section.icon, {
sx: { fontSize: 20, mr: 1, color: modernTokens.colors.primary[500] }
})}
<Typography variant="subtitle1" sx={{ fontWeight: 600 }}>
{section.category}
</Typography>
<Chip
label={section.items.length}
size="small"
sx={{ ml: 'auto', mr: 1 }}
/>
</AccordionSummary>
<AccordionDetails>
<Grid container spacing={2}>
{section.items.map((item, itemIndex) => (
<Grid item xs={12} sm={6} key={itemIndex}>
<Paper
sx={{
p: 2,
backgroundColor: modernTokens.colors.neutral[50],
border: `1px solid ${modernTokens.colors.neutral[200]}`,
}}
>
<Typography
variant="caption"
color="text.secondary"
sx={{ display: 'block', mb: 0.5, fontWeight: 500 }}
>
{item.label}
</Typography>
<Typography variant="body2" sx={{ fontWeight: 500 }}>
{formatValue(item)}
</Typography>
</Paper>
</Grid>
))}
</Grid>
</AccordionDetails>
</Accordion>
))}
</Box>
);
};
export default MetadataParser;

View File

@ -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<string, any>;
}
const ProcessingTimeline: React.FC<ProcessingTimelineProps> = ({
documentId,
fileName,
createdAt,
updatedAt,
userId,
ocrStatus,
ocrCompletedAt,
ocrRetryCount = 0,
ocrError,
compact = false,
}) => {
const [expanded, setExpanded] = useState(!compact);
const [retryHistory, setRetryHistory] = useState<any[]>([]);
const [loadingHistory, setLoadingHistory] = useState(false);
const getStatusIcon = (type: string, status: string) => {
switch (type) {
case 'upload':
return <UploadIcon />;
case 'ocr_start':
case 'ocr_complete':
return <OcrIcon />;
case 'ocr_retry':
return <RetryIcon />;
case 'ocr_error':
return <ErrorIcon />;
default:
return <CheckIcon />;
}
};
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 (
<Paper
sx={{
p: 2,
background: `linear-gradient(135deg, ${modernTokens.colors.neutral[50]} 0%, ${modernTokens.colors.info[50]} 100%)`,
border: `1px solid ${modernTokens.colors.neutral[200]}`,
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 2 }}>
<Box sx={{ display: 'flex', alignItems: 'center' }}>
<TimelineIcon
sx={{
fontSize: 18,
mr: 1,
color: modernTokens.colors.primary[500]
}}
/>
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
Processing Timeline
</Typography>
</Box>
<Typography variant="caption" color="text.secondary">
{events.length} events
</Typography>
</Box>
<Stack spacing={1}>
{events.slice(-2).map((event, index) => (
<Box key={event.id} sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<Box sx={{ display: 'flex', alignItems: 'center' }}>
<Box
sx={{
width: 8,
height: 8,
borderRadius: '50%',
backgroundColor: getStatusColor(event.status),
mr: 1,
}}
/>
<Typography variant="caption" sx={{ fontWeight: 500 }}>
{event.title}
</Typography>
</Box>
<Typography variant="caption" color="text.secondary">
{formatTimestamp(event.timestamp)}
</Typography>
</Box>
))}
</Stack>
<Button
size="small"
onClick={() => setExpanded(true)}
sx={{ mt: 1, fontSize: '0.75rem' }}
>
View Full Timeline
</Button>
</Paper>
);
}
return (
<Paper
sx={{
p: 3,
background: `linear-gradient(135deg, ${modernTokens.colors.neutral[50]} 0%, ${modernTokens.colors.info[50]} 100%)`,
border: `1px solid ${modernTokens.colors.neutral[200]}`,
}}
>
{/* Header */}
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 3 }}>
<Box sx={{ display: 'flex', alignItems: 'center' }}>
<TimelineIcon
sx={{
fontSize: 24,
mr: 1.5,
color: modernTokens.colors.primary[500]
}}
/>
<Typography variant="h6" sx={{ fontWeight: 600 }}>
Processing Timeline
</Typography>
</Box>
<Stack direction="row" spacing={1}>
<Chip
label={`${events.length} events`}
size="small"
sx={{ backgroundColor: modernTokens.colors.neutral[100] }}
/>
{ocrRetryCount > 0 && (
<Chip
label={`${ocrRetryCount} retries`}
size="small"
color="warning"
/>
)}
</Stack>
</Box>
{/* Timeline */}
<Timeline sx={{ p: 0 }}>
{events.map((event, index) => (
<TimelineItem key={event.id}>
<TimelineSeparator>
<TimelineDot
sx={{
backgroundColor: getStatusColor(event.status),
color: 'white',
}}
>
{getStatusIcon(event.type, event.status)}
</TimelineDot>
{index < events.length - 1 && (
<TimelineConnector
sx={{ backgroundColor: modernTokens.colors.neutral[300] }}
/>
)}
</TimelineSeparator>
<TimelineContent sx={{ py: 1 }}>
<Box sx={{ mb: 1 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
{event.title}
</Typography>
<Typography variant="caption" color="text.secondary">
{formatTimestamp(event.timestamp)}
</Typography>
</Box>
{event.description && (
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
{event.description}
</Typography>
)}
{event.metadata?.userId && (
<Box sx={{ display: 'flex', alignItems: 'center', mt: 1 }}>
<PersonIcon sx={{ fontSize: 14, mr: 0.5, color: modernTokens.colors.neutral[500] }} />
<Typography variant="caption" color="text.secondary">
User: {event.metadata.userId.substring(0, 8)}...
</Typography>
</Box>
)}
{index > 0 && events[index - 1] && (
<Typography variant="caption" color="text.secondary" sx={{ fontStyle: 'italic' }}>
(+{formatDuration(events[index - 1].timestamp, event.timestamp)})
</Typography>
)}
</TimelineContent>
</TimelineItem>
))}
</Timeline>
{/* Retry History Section */}
{ocrRetryCount > 0 && (
<Box sx={{ mt: 3, pt: 2, borderTop: `1px solid ${modernTokens.colors.neutral[200]}` }}>
<Button
onClick={() => setExpanded(!expanded)}
endIcon={<ExpandIcon sx={{ transform: expanded ? 'rotate(180deg)' : 'none' }} />}
sx={{ mb: 2 }}
>
Detailed Retry History
</Button>
<Collapse in={expanded}>
{loadingHistory ? (
<Box sx={{ display: 'flex', alignItems: 'center', py: 2 }}>
<CircularProgress size={20} sx={{ mr: 1 }} />
<Typography variant="body2" color="text.secondary">
Loading retry history...
</Typography>
</Box>
) : retryHistory.length > 0 ? (
<Stack spacing={1}>
{retryHistory.map((retry, index) => (
<Paper
key={retry.id}
sx={{
p: 2,
backgroundColor: modernTokens.colors.neutral[50],
border: `1px solid ${modernTokens.colors.neutral[200]}`,
}}
>
<Typography variant="subtitle2">
Retry #{index + 1}
</Typography>
<Typography variant="caption" color="text.secondary">
{retry.retry_reason || 'Manual retry'}
</Typography>
</Paper>
))}
</Stack>
) : (
<Alert severity="info">
Detailed retry history not available. Enable detailed logging for future retries.
</Alert>
)}
</Collapse>
</Box>
)}
</Paper>
);
};
export default ProcessingTimeline;

View File

@ -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(
<ThemeProvider theme={theme}>
{component}
</ThemeProvider>
);
};
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(
<FileIntegrityDisplay {...mockProps} />
);
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(
<FileIntegrityDisplay {...mockProps} />
);
// Should show the full hash in expanded view
expect(screen.getByText(mockProps.fileHash)).toBeInTheDocument();
});
it('shows compact view when compact prop is true', () => {
renderWithTheme(
<FileIntegrityDisplay {...mockProps} compact={true} />
);
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(
<FileIntegrityDisplay {...mockProps} fileHash={undefined} />
);
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(
<FileIntegrityDisplay {...mockProps} />
);
expect(screen.getByText('1 MB')).toBeInTheDocument();
});
it('displays user information', () => {
renderWithTheme(
<FileIntegrityDisplay {...mockProps} />
);
expect(screen.getByText('User: user-123...')).toBeInTheDocument();
});
});

View File

@ -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(
<ThemeProvider theme={theme}>
{component}
</ThemeProvider>
);
};
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(
<MetadataParser
metadata={mockImageMetadata}
fileType="image/jpeg"
/>
);
expect(screen.getByText('Camera')).toBeInTheDocument();
expect(screen.getByText('Canon')).toBeInTheDocument();
expect(screen.getByText('EOS R5')).toBeInTheDocument();
});
it('renders PDF metadata for PDF files', () => {
renderWithTheme(
<MetadataParser
metadata={mockPdfMetadata}
fileType="application/pdf"
/>
);
expect(screen.getByText('Document Info')).toBeInTheDocument();
expect(screen.getByText('Sample Document')).toBeInTheDocument();
expect(screen.getByText('Test Author')).toBeInTheDocument();
});
it('renders compact view correctly', () => {
renderWithTheme(
<MetadataParser
metadata={mockImageMetadata}
fileType="image/jpeg"
compact={true}
/>
);
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(
<MetadataParser
metadata={{}}
fileType="text/plain"
/>
);
expect(screen.getByText('No detailed metadata available for this file type')).toBeInTheDocument();
});
});

View File

@ -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(
<ThemeProvider theme={theme}>
{component}
</ThemeProvider>
);
};
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(
<ProcessingTimeline {...mockProps} />
);
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(
<ProcessingTimeline
{...mockProps}
ocrRetryCount={2}
/>
);
expect(screen.getByText('2 retries')).toBeInTheDocument();
expect(screen.getByText('Detailed Retry History')).toBeInTheDocument();
});
it('renders compact view correctly', () => {
renderWithTheme(
<ProcessingTimeline {...mockProps} compact={true} />
);
expect(screen.getByText('Processing Timeline')).toBeInTheDocument();
expect(screen.getByText('View Full Timeline')).toBeInTheDocument();
});
it('handles OCR error status', () => {
renderWithTheme(
<ProcessingTimeline
{...mockProps}
ocrStatus="failed"
ocrError="OCR processing failed due to low image quality"
/>
);
expect(screen.getByText('OCR Processing Failed')).toBeInTheDocument();
});
it('shows pending OCR status', () => {
renderWithTheme(
<ProcessingTimeline
{...mockProps}
ocrStatus="processing"
ocrCompletedAt={undefined}
/>
);
expect(screen.getByText('OCR Processing Started')).toBeInTheDocument();
});
it('displays event count', () => {
renderWithTheme(
<ProcessingTimeline {...mockProps} />
);
// Should show at least 2 events (upload + OCR completion)
expect(screen.getByText(/\d+ events/)).toBeInTheDocument();
});
});

File diff suppressed because it is too large Load Diff

View File

@ -1,23 +1,153 @@
import { createTheme } from '@mui/material/styles';
import { createTheme, alpha } from '@mui/material/styles';
// Modern 2026 design tokens
export const modernTokens = {
colors: {
primary: {
50: '#f0f4ff',
100: '#e0eaff',
200: '#c7d7fe',
300: '#a5b8fc',
400: '#8b93f8',
500: '#6366f1',
600: '#4f46e5',
700: '#4338ca',
800: '#3730a3',
900: '#312e81',
},
secondary: {
50: '#fdf4ff',
100: '#fae8ff',
200: '#f5d0fe',
300: '#f0abfc',
400: '#e879f9',
500: '#d946ef',
600: '#c026d3',
700: '#a21caf',
800: '#86198f',
900: '#701a75',
},
neutral: {
0: '#ffffff',
50: '#fafafa',
100: '#f5f5f5',
200: '#e5e5e5',
300: '#d4d4d4',
400: '#a3a3a3',
500: '#737373',
600: '#525252',
700: '#404040',
800: '#262626',
900: '#171717',
950: '#0a0a0a',
},
success: {
50: '#f0fdf4',
500: '#22c55e',
600: '#16a34a',
},
warning: {
50: '#fffbeb',
500: '#f59e0b',
600: '#d97706',
},
error: {
50: '#fef2f2',
500: '#ef4444',
600: '#dc2626',
},
info: {
50: '#eff6ff',
500: '#3b82f6',
600: '#2563eb',
},
},
shadows: {
xs: '0 1px 2px 0 rgb(0 0 0 / 0.05)',
sm: '0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1)',
md: '0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1)',
lg: '0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1)',
xl: '0 20px 25px -5px rgb(0 0 0 / 0.1), 0 8px 10px -6px rgb(0 0 0 / 0.1)',
glass: '0 8px 32px 0 rgba(31, 38, 135, 0.37)',
},
};
// Glassmorphism effect helper
export const glassEffect = (alphaValue: number = 0.1) => ({
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,
},
},
},
},