diff --git a/backend/src/reports/reports.controller.ts b/backend/src/reports/reports.controller.ts index f3c21a6f..709b7377 100644 --- a/backend/src/reports/reports.controller.ts +++ b/backend/src/reports/reports.controller.ts @@ -92,6 +92,14 @@ export class ReportsController { throw new NotFoundException('Processing failed'); } + if (report.processingStatus === ProcessingStatus.IN_PROGRESS) { + throw new NotFoundException('Processing in progress'); + } + + if (report.processingStatus === ProcessingStatus.UNPROCESSED) { + throw new NotFoundException('Processing pending'); + } + return report; } diff --git a/backend/src/reports/reports.service.ts b/backend/src/reports/reports.service.ts index 889e02db..2c3c2b21 100644 --- a/backend/src/reports/reports.service.ts +++ b/backend/src/reports/reports.service.ts @@ -55,23 +55,23 @@ export class ReportsService { this.tableName = this.configService.get('dynamodbReportsTable')!; } - async findAll(userId: string, withFailed = false): Promise { + async findAll(userId: string, onlyProcessed = true): Promise { if (!userId) { throw new ForbiddenException('User ID is required'); } try { const expressionAttributeValues: any = { ':userId': userId }; - const processingStatusFilter = 'processingStatus <> :failedStatus'; + const processingStatusFilter = 'processingStatus = :processedStatus'; - if (!withFailed) { - expressionAttributeValues[':failedStatus'] = ProcessingStatus.FAILED; + if (onlyProcessed) { + expressionAttributeValues[':processedStatus'] = ProcessingStatus.PROCESSED; } const command = new QueryCommand({ TableName: this.tableName, KeyConditionExpression: 'userId = :userId', - FilterExpression: !withFailed ? processingStatusFilter : undefined, + FilterExpression: onlyProcessed ? processingStatusFilter : undefined, ExpressionAttributeValues: marshall(expressionAttributeValues), }); @@ -100,7 +100,7 @@ export class ReportsService { async findLatest( queryDto: GetReportsQueryDto, userId: string, - withFailed = false, + onlyProcessed = true, ): Promise { this.logger.log( `Running findLatest with params: ${JSON.stringify(queryDto)} for user ${userId}`, @@ -116,17 +116,17 @@ export class ReportsService { const expressionAttributeValues: any = { ':userId': userId }; try { - const processingStatusFilter = 'processingStatus <> :failedStatus'; + const processingStatusFilter = 'processingStatus = :processedStatus'; - if (!withFailed) { - expressionAttributeValues[':failedStatus'] = ProcessingStatus.FAILED; + if (onlyProcessed) { + expressionAttributeValues[':processedStatus'] = ProcessingStatus.PROCESSED; } const command = new QueryCommand({ TableName: this.tableName, IndexName: 'userIdCreatedAtIndex', KeyConditionExpression: 'userId = :userId', - FilterExpression: !withFailed ? processingStatusFilter : undefined, + FilterExpression: onlyProcessed ? processingStatusFilter : undefined, ExpressionAttributeValues: marshall(expressionAttributeValues), ScanIndexForward: false, Limit: limit, diff --git a/frontend/src/common/api/__tests__/reportService.test.ts b/frontend/src/common/api/__tests__/reportService.test.ts index fcc8e350..7734aac5 100644 --- a/frontend/src/common/api/__tests__/reportService.test.ts +++ b/frontend/src/common/api/__tests__/reportService.test.ts @@ -22,6 +22,17 @@ vi.mock('axios', () => ({ }, })); +// Mock auth +vi.mock('@aws-amplify/auth', () => ({ + fetchAuthSession: vi.fn().mockResolvedValue({ + tokens: { + idToken: { + toString: () => 'mock-id-token', + }, + }, + }), +})); + // Mock dynamic imports to handle the service functions vi.mock('../reportService', async (importOriginal) => { const actual = (await importOriginal()) as typeof ReportServiceModule; @@ -31,6 +42,15 @@ vi.mock('../reportService', async (importOriginal) => { // Keep the ReportError class ReportError: actual.ReportError, + // Mock getAuthConfig to avoid authentication issues in tests + getAuthConfig: async () => ({ + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + Authorization: 'Bearer mock-id-token', + }, + }), + // Mock the API functions uploadReport: async (file: File, onProgress?: (progress: number) => void) => { try { @@ -80,23 +100,24 @@ vi.mock('../reportService', async (importOriginal) => { } }, - // Keep other functions as is - markReportAsRead: actual.markReportAsRead, - getAuthConfig: actual.getAuthConfig, + // Mock markReportAsRead to avoid the dependency on getAuthConfig + markReportAsRead: async (reportId: string) => { + try { + const response = await axios.patch(`/api/reports/${reportId}`, { + status: 'READ', + }); + return response.data; + } catch (error) { + throw new actual.ReportError( + error instanceof Error + ? `Failed to mark report as read: ${error.message || 'Unknown error'}` + : 'Failed to mark report as read', + ); + } + }, }; }); -// Mock auth -vi.mock('@aws-amplify/auth', () => ({ - fetchAuthSession: vi.fn().mockResolvedValue({ - tokens: { - idToken: { - toString: () => 'mock-id-token', - }, - }, - }), -})); - // Mock response data const mockReports = [ { diff --git a/frontend/src/common/api/reportService.ts b/frontend/src/common/api/reportService.ts index 4b86f90f..5b31a4e4 100644 --- a/frontend/src/common/api/reportService.ts +++ b/frontend/src/common/api/reportService.ts @@ -141,14 +141,18 @@ export const fetchAllReports = async (): Promise => { */ export const markReportAsRead = async (reportId: string): Promise => { try { - const response = await axios.patch(`${API_URL}/api/reports/${reportId}`, { - status: 'READ', - }); + const response = await axios.patch( + `${API_URL}/api/reports/${reportId}`, + { + status: 'READ', + }, + await getAuthConfig(), + ); return response.data; } catch (error) { if (axios.isAxiosError(error)) { - throw new ReportError(`Failed to mark report as read: ${error.message}`); + throw new ReportError(`Failed to mark report as read: ${error.message || 'Unknown error'}`); } throw new ReportError('Failed to mark report as read'); } @@ -176,7 +180,9 @@ export const toggleReportBookmark = async ( return response.data; } catch (error) { if (axios.isAxiosError(error)) { - throw new ReportError(`Failed to toggle bookmark status: ${error.message}`); + throw new ReportError( + `Failed to toggle bookmark status: ${error.message || 'Unknown error'}`, + ); } throw new ReportError('Failed to toggle bookmark status'); } diff --git a/frontend/src/common/components/Upload/UploadModal.tsx b/frontend/src/common/components/Upload/UploadModal.tsx index f2f5b3ee..1d07029e 100644 --- a/frontend/src/common/components/Upload/UploadModal.tsx +++ b/frontend/src/common/components/Upload/UploadModal.tsx @@ -59,10 +59,11 @@ const UploadModal = ({ isOpen, onClose, onUploadComplete }: UploadModalProps): J // Automatically redirect to processing screen after 2 seconds setTimeout(() => { reset(); - onClose(); + if (onUploadComplete) { onUploadComplete(result); } + // Navigate to the processing tab with reportId in state if (file) { history.push('/tabs/processing', { @@ -115,6 +116,7 @@ const UploadModal = ({ isOpen, onClose, onUploadComplete }: UploadModalProps): J // call onUploadComplete now before closing the modal if (status === UploadStatus.SUCCESS && uploadResult && onUploadComplete) { onUploadComplete(uploadResult); + return; } // Reset state diff --git a/frontend/src/common/hooks/useReports.ts b/frontend/src/common/hooks/useReports.ts index d0b949c0..8d0d96ba 100644 --- a/frontend/src/common/hooks/useReports.ts +++ b/frontend/src/common/hooks/useReports.ts @@ -15,6 +15,9 @@ export const useGetLatestReports = (limit = 3) => { return useQuery({ queryKey: [LATEST_REPORTS_KEY, limit], queryFn: () => fetchLatestReports(limit), + refetchOnMount: true, + refetchOnWindowFocus: true, + staleTime: 0, // Consider data immediately stale so it always refreshes }); }; diff --git a/frontend/src/pages/Processing/ProcessingPage.tsx b/frontend/src/pages/Processing/ProcessingPage.tsx index da28b02b..dccefa95 100644 --- a/frontend/src/pages/Processing/ProcessingPage.tsx +++ b/frontend/src/pages/Processing/ProcessingPage.tsx @@ -82,12 +82,21 @@ const ProcessingPage: React.FC = () => { try { // Send POST request to backend API - await axios.post( + const response = await axios.post( `${API_URL}/api/document-processor/process-file`, { reportId }, await getAuthConfig(), ); + const data = response.data; + + if (data.status === 'processed') { + setIsProcessing(false); + + // Redirect to report detail page + history.push(`/tabs/reports/${reportId}`); + } + // Start checking the status every 2 seconds statusCheckIntervalRef.current = window.setInterval(checkReportStatus, 2000); } catch (error) { diff --git a/frontend/src/pages/Reports/ReportDetailPage.scss b/frontend/src/pages/Reports/ReportDetailPage.scss index 5e1eacce..50e3f74b 100644 --- a/frontend/src/pages/Reports/ReportDetailPage.scss +++ b/frontend/src/pages/Reports/ReportDetailPage.scss @@ -239,103 +239,110 @@ &__item { background-color: #ffffff; - border-radius: 8px; + border-radius: 16px; padding: 0; - margin-top: 12px; - margin-bottom: 8px; - border-top: 1px solid #ebeef8; + margin: 16px; overflow: hidden; + box-shadow: 0 1px 4px rgba(0, 0, 0, 0.05); + border: 1px solid #ebeef8; } &__item-header { display: flex; flex-wrap: wrap; align-items: center; - padding: 12px 16px; + padding: 16px; background-color: #fff; &--high { background-color: rgba(201, 58, 84, 0.08); + border-bottom: 1px solid rgba(201, 58, 84, 0.15); } &--low { - background-color: rgba(108, 99, 255, 0.08); + background-color: rgba(250, 173, 113, 0.08); + border-bottom: 1px solid rgba(250, 173, 113, 0.15); } } &__item-name { - font-weight: 500; - font-size: 15px; + font-weight: 600; + font-size: 16px; margin-right: 12px; flex: 1; letter-spacing: 0.26px; - line-height: 15.7px; + line-height: 20px; color: #313e4c; } &__item-level { - font-size: 12px; - padding: 2px 6px; - border-radius: 4px; + font-size: 13px; + padding: 4px 12px; + border-radius: 12px; margin-right: 16px; font-weight: 600; - text-transform: uppercase; + text-transform: capitalize; &--high { - background-color: rgba(201, 58, 84, 0.1); - color: #c93a54; + background-color: #c93a54; + color: white; } &--low { - background-color: rgba(108, 99, 255, 0.1); - color: #435ff0; + background-color: #ffcf99; + color: white; } } &__item-value { font-weight: 600; - font-size: 15px; + font-size: 16px; white-space: nowrap; color: #313e4c; } &__item-details { color: #444; - padding: 10px 16px 16px; - background-color: rgba(235, 238, 248, 0.3); + padding: 16px; + background-color: rgba(248, 249, 251, 0.6); } &__item-section { - margin-bottom: 12px; + margin-bottom: 20px; &:last-child { margin-bottom: 0; } h4 { - font-size: 14px; + font-size: 15px; color: #313e4c; - margin: 0 0 4px 0; + margin: 0 0 8px 0; font-weight: 600; } p { margin: 0; font-size: 14px; - line-height: 1.4; + line-height: 1.5; color: #5c6d80; } } &__item-list { margin: 0; - padding-left: 18px; - font-size: 13px; + padding-left: 20px; + font-size: 14px; line-height: 1.5; + color: #5c6d80; li { - margin-bottom: 6px; + margin-bottom: 10px; padding-left: 4px; + + &:last-child { + margin-bottom: 0; + } } } diff --git a/frontend/src/pages/Reports/ReportsListPage.tsx b/frontend/src/pages/Reports/ReportsListPage.tsx index 5e7d75ee..309ff817 100644 --- a/frontend/src/pages/Reports/ReportsListPage.tsx +++ b/frontend/src/pages/Reports/ReportsListPage.tsx @@ -15,7 +15,7 @@ import { IonModal, } from '@ionic/react'; import { useTranslation } from 'react-i18next'; -import { useHistory } from 'react-router-dom'; +import { useHistory, useLocation } from 'react-router-dom'; import { useQuery, useQueryClient } from '@tanstack/react-query'; import { fetchAllReports, toggleReportBookmark } from 'common/api/reportService'; import { useMarkReportAsRead } from 'common/hooks/useReports'; @@ -40,6 +40,7 @@ type SortDirection = 'desc' | 'asc'; const ReportsListPage: React.FC = () => { const { t } = useTranslation(['report', 'common']); const history = useHistory(); + const location = useLocation(); const queryClient = useQueryClient(); const [filter, setFilter] = useState('all'); const [sortDirection, setSortDirection] = useState('desc'); // Default sort by newest first @@ -61,11 +62,19 @@ const ReportsListPage: React.FC = () => { data: reports = [], isLoading, isError, + refetch, } = useQuery({ queryKey: ['reports'], queryFn: fetchAllReports, + refetchOnMount: true, }); + // Refetch reports when navigating to this page + useEffect(() => { + // This will run when the component mounts or when the location changes + refetch(); + }, [location.pathname, refetch]); + const { mutate: markAsRead } = useMarkReportAsRead(); // Filter and sort reports based on selected filter, categories, and sort direction diff --git a/frontend/src/pages/Reports/components/LabValueItem.tsx b/frontend/src/pages/Reports/components/LabValueItem.tsx index 8746a273..fb8ef44e 100644 --- a/frontend/src/pages/Reports/components/LabValueItem.tsx +++ b/frontend/src/pages/Reports/components/LabValueItem.tsx @@ -9,6 +9,69 @@ interface LabValueItemProps { const LabValueItem: React.FC = ({ item }) => { const { t } = useTranslation(); + // Parse suggestions into bullet points more intelligently + const getSuggestionsList = (suggestions: string): string[] => { + if (!suggestions) return []; + + // Handle case where the text is already separated by bullet points + if (suggestions.includes('•')) { + return suggestions + .split('•') + .filter(Boolean) + .map((item) => item.trim()); + } + + // Handle case where items are separated by hyphens + if (suggestions.includes('-')) { + return suggestions + .split('-') + .filter(Boolean) + .map((item) => item.trim()); + } + + // Handle case with numbered lists (1., 2., etc.) + if (/\d+\.\s/.test(suggestions)) { + return suggestions + .split(/\d+\.\s/) + .filter(Boolean) + .map((item) => item.trim()); + } + + // Split by periods if it seems like sentences + if (suggestions.includes('.')) { + // Don't split on decimal points in numbers (e.g. "10.5") + const sentences = suggestions + .replace(/(\d+)\.(\d+)/g, '$1@$2') + .split('.') + .map((s) => s.replace(/@/g, '.').trim()) + .filter(Boolean); + return sentences; + } + + // If we can't detect a pattern, return the whole string as one item + return [suggestions]; + }; + + const suggestionItems = getSuggestionsList(item.suggestions); + + // Determine classes and text for status label based on status + const getStatusInfo = () => { + if (item.status === 'high') { + return { + className: 'report-detail-page__item-level--high', + text: t('report.high', { ns: 'reportDetail', defaultValue: 'High' }), + }; + } else if (item.status === 'low') { + return { + className: 'report-detail-page__item-level--low', + text: t('report.low', { ns: 'reportDetail', defaultValue: 'Low' }), + }; + } + return { className: '', text: '' }; + }; + + const statusInfo = getStatusInfo(); + return (
= ({ item }) => { >
{item.name}
{item.status !== 'normal' && ( -
- {item.status} +
+ {statusInfo.text}
)}
@@ -32,12 +93,24 @@ const LabValueItem: React.FC = ({ item }) => {
-

{t('report.conclusion.title', { ns: 'reportDetail' })}:

+

+ {t('report.conclusion.title', { ns: 'reportDetail', defaultValue: 'Conclusion' })}: +

{item.conclusion}

-

{t('report.suggestions.title', { ns: 'reportDetail' })}:

-

{item.suggestions}

+

+ {t('report.suggestions.title', { ns: 'reportDetail', defaultValue: 'Suggestions' })}: +

+ {suggestionItems.length > 0 ? ( +
    + {suggestionItems.map((suggestion, index) => ( +
  • {suggestion}
  • + ))} +
+ ) : ( +

{item.suggestions}

+ )}
diff --git a/frontend/src/pages/Upload/UploadPage.tsx b/frontend/src/pages/Upload/UploadPage.tsx index f5d2651a..52f8b99e 100644 --- a/frontend/src/pages/Upload/UploadPage.tsx +++ b/frontend/src/pages/Upload/UploadPage.tsx @@ -1,6 +1,6 @@ -import { IonContent, IonHeader, IonPage, IonTitle, IonToolbar } from '@ionic/react'; +import { IonContent, IonHeader, IonPage, IonTitle, IonToolbar, IonButton } from '@ionic/react'; import { useTranslation } from 'react-i18next'; -import { useEffect, useState } from 'react'; +import { useState } from 'react'; import { useHistory } from 'react-router-dom'; import UploadModal from 'common/components/Upload/UploadModal'; @@ -10,7 +10,7 @@ import UploadModal from 'common/components/Upload/UploadModal'; */ const UploadPage = (): JSX.Element => { const { t } = useTranslation(); - const [isModalOpen, setIsModalOpen] = useState(true); + const [isModalOpen, setIsModalOpen] = useState(false); const history = useHistory(); const handleUploadComplete = () => { @@ -21,16 +21,6 @@ const UploadPage = (): JSX.Element => { history.push('/tabs/home'); }; - useEffect(() => { - // Automatically open the upload modal when the component mounts - setIsModalOpen(true); - - // Cleanup function to close the modal when the component unmounts - return () => { - setIsModalOpen(false); - }; - }, []); - return ( @@ -39,6 +29,15 @@ const UploadPage = (): JSX.Element => { +
+

{t('pages.upload.subtitle')}

+

{t('pages.upload.description')}

+ + setIsModalOpen(true)}> + {t('upload.selectFile')} + +
+ setIsModalOpen(false)}