diff --git a/frontend/src/common/utils/i18n/resources/en/report.json b/frontend/src/common/utils/i18n/resources/en/report.json index d98c022d..0e4cf661 100644 --- a/frontend/src/common/utils/i18n/resources/en/report.json +++ b/frontend/src/common/utils/i18n/resources/en/report.json @@ -49,10 +49,20 @@ "filterBookmarked": "Bookmarked", "noBookmarksTitle": "No Bookmarked Reports", "noBookmarksMessage": "Bookmark reports to find them quickly here", + "noMatchesTitle": "No Matching Reports", + "noMatchesMessage": "No reports match your current filters. Try changing or clearing your filters.", + "clearFilters": "Clear Filters", "sortButton": "Sort reports", - "filterButton": "Filter reports", - "generalCategory": "General", - "brainCategory": "Brain", - "heartCategory": "Heart" + "filterButton": "Filter reports" + }, + "filter": { + "title": "Filter", + "category": "Category", + "apply": "Apply" + }, + "category": { + "general": "General", + "heart": "Heart", + "brain": "Brain" } } diff --git a/frontend/src/common/utils/i18n/resources/es/report.json b/frontend/src/common/utils/i18n/resources/es/report.json index d1cf7e88..c2dc29b8 100644 --- a/frontend/src/common/utils/i18n/resources/es/report.json +++ b/frontend/src/common/utils/i18n/resources/es/report.json @@ -48,6 +48,21 @@ "filterAll": "Todos", "filterBookmarked": "Guardados", "noBookmarksTitle": "No Hay Informes Guardados", - "noBookmarksMessage": "Guarda informes como favoritos para encontrarlos rápidamente aquí" + "noBookmarksMessage": "Guarda informes como favoritos para encontrarlos rápidamente aquí", + "noMatchesTitle": "No Hay Informes Coincidentes", + "noMatchesMessage": "Ningún informe coincide con los filtros actuales. Intenta cambiar o eliminar los filtros.", + "clearFilters": "Eliminar Filtros", + "sortButton": "Ordenar informes", + "filterButton": "Filtrar informes" + }, + "filter": { + "title": "Filtrar", + "category": "Categoría", + "apply": "Aplicar" + }, + "category": { + "general": "General", + "heart": "Corazón", + "brain": "Cerebro" } } diff --git a/frontend/src/common/utils/i18n/resources/fr/report.json b/frontend/src/common/utils/i18n/resources/fr/report.json index e86d7a1f..1e034d34 100644 --- a/frontend/src/common/utils/i18n/resources/fr/report.json +++ b/frontend/src/common/utils/i18n/resources/fr/report.json @@ -48,6 +48,21 @@ "filterAll": "Tous", "filterBookmarked": "Favoris", "noBookmarksTitle": "Aucun Rapport en Favoris", - "noBookmarksMessage": "Marquez des rapports comme favoris pour les trouver rapidement ici" + "noBookmarksMessage": "Marquez des rapports comme favoris pour les trouver rapidement ici", + "noMatchesTitle": "Aucun Rapport Correspondant", + "noMatchesMessage": "Aucun rapport ne correspond à vos filtres actuels. Essayez de modifier ou de supprimer vos filtres.", + "clearFilters": "Effacer les Filtres", + "sortButton": "Trier les rapports", + "filterButton": "Filtrer les rapports" + }, + "filter": { + "title": "Filtrer", + "category": "Catégorie", + "apply": "Appliquer" + }, + "category": { + "general": "Général", + "heart": "Cœur", + "brain": "Cerveau" } } diff --git a/frontend/src/pages/Home/HomePage.tsx b/frontend/src/pages/Home/HomePage.tsx index 57bc4fde..8adc6431 100644 --- a/frontend/src/pages/Home/HomePage.tsx +++ b/frontend/src/pages/Home/HomePage.tsx @@ -14,6 +14,9 @@ import { useHistory } from 'react-router-dom'; import { useState } from 'react'; import { useGetLatestReports, useMarkReportAsRead } from 'common/hooks/useReports'; import { useCurrentUser } from 'common/hooks/useAuth'; +import { toggleReportBookmark } from 'common/api/reportService'; +import { useQueryClient } from '@tanstack/react-query'; +import { MedicalReport } from 'common/models/medicalReport'; import Avatar from 'common/components/Icon/Avatar'; import ReportItem from './components/ReportItem/ReportItem'; import NoReportsMessage from './components/NoReportsMessage/NoReportsMessage'; @@ -27,6 +30,7 @@ import './HomePage.scss'; const HomePage: React.FC = () => { const { t } = useTranslation('home'); const history = useHistory(); + const queryClient = useQueryClient(); const { data: reports, isLoading, isError } = useGetLatestReports(3); const { mutate: markAsRead } = useMarkReportAsRead(); const currentUser = useCurrentUser(); @@ -43,6 +47,31 @@ const HomePage: React.FC = () => { history.push(`/tabs/reports/${reportId}`); }; + const handleToggleBookmark = async (reportId: string, isCurrentlyBookmarked: boolean) => { + try { + // Toggle the bookmark status + const updatedReport = await toggleReportBookmark(reportId, !isCurrentlyBookmarked); + + // Update the reports in the cache + queryClient.setQueryData(['reports'], (oldReports) => { + if (!oldReports) return []; + return oldReports.map((report) => + report.id === updatedReport.id ? updatedReport : report, + ); + }); + + // Update the latest reports cache with the correct query key including the limit + queryClient.setQueryData(['latestReports', 3], (oldReports) => { + if (!oldReports) return []; + return oldReports.map((report) => + report.id === updatedReport.id ? updatedReport : report, + ); + }); + } catch (error) { + console.error('Failed to toggle bookmark:', error); + } + }; + const handleUpload = () => { history.push('/upload'); }; @@ -92,6 +121,7 @@ const HomePage: React.FC = () => { key={report.id} report={report} onClick={() => handleReportClick(report.id)} + onToggleBookmark={() => handleToggleBookmark(report.id, report.bookmarked)} showBookmarkButton={true} /> )); diff --git a/frontend/src/pages/Home/components/ReportItem/ReportItem.scss b/frontend/src/pages/Home/components/ReportItem/ReportItem.scss index 822c850b..32eea10f 100644 --- a/frontend/src/pages/Home/components/ReportItem/ReportItem.scss +++ b/frontend/src/pages/Home/components/ReportItem/ReportItem.scss @@ -3,7 +3,6 @@ padding: 16px; border-radius: 12px; margin-bottom: 12px; - box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05); position: relative; cursor: pointer; @@ -67,6 +66,7 @@ &--active { color: white; background-color: #4355b9; + box-shadow: none; } } } @@ -81,7 +81,6 @@ flex-shrink: 0; background-color: white; box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08); - border: 1px solid rgba(0, 0, 0, 0.03); &--general, &--brain, diff --git a/frontend/src/pages/Home/components/ReportItem/ReportItem.tsx b/frontend/src/pages/Home/components/ReportItem/ReportItem.tsx index a7f5da50..9c9975e2 100644 --- a/frontend/src/pages/Home/components/ReportItem/ReportItem.tsx +++ b/frontend/src/pages/Home/components/ReportItem/ReportItem.tsx @@ -39,13 +39,13 @@ const ReportItem: React.FC = ({ // Get category translation key based on category value const getCategoryTranslationKey = () => { if (categoryStr === ReportCategory.GENERAL.toLowerCase()) { - return 'list.generalCategory'; + return 'category.general'; } else if (categoryStr === ReportCategory.BRAIN.toLowerCase()) { - return 'list.brainCategory'; + return 'category.brain'; } else if (categoryStr === ReportCategory.HEART.toLowerCase()) { - return 'list.heartCategory'; + return 'category.heart'; } - return 'list.generalCategory'; // Default to general if not found + return 'category.general'; // Default to general if not found }; // Get the appropriate icon for the category diff --git a/frontend/src/pages/Reports/ReportsListPage.scss b/frontend/src/pages/Reports/ReportsListPage.scss index d0e3fea3..aa694bc8 100644 --- a/frontend/src/pages/Reports/ReportsListPage.scss +++ b/frontend/src/pages/Reports/ReportsListPage.scss @@ -19,150 +19,116 @@ } &__title-icon { - margin-right: 12px; - color: #333; - padding: 8px; - background-color: #f0f2f5; - border-radius: 50%; - display: flex; - align-items: center; - justify-content: center; - width: 18px; - height: 18px; + font-size: 1.5rem; + margin-right: 0.5rem; + color: var(--ion-color-primary); } &__title { - font-size: 18px; + font-size: 1.25rem; font-weight: 600; margin: 0; - color: #333; } &__actions { display: flex; align-items: center; - justify-content: flex-end; } &__sort-button, &__filter-button { - --padding-start: 0; - --padding-end: 0; + --padding-start: 0.5rem; + --padding-end: 0.5rem; + margin: 0; height: 36px; - width: 36px; - --background: transparent; - --color: #4355b9; - --box-shadow: none; - --ripple-color: transparent; - margin-left: 10px; - - .custom-icon-wrapper { - width: 36px; - height: 36px; - display: flex; - align-items: center; - justify-content: center; - border-radius: 50%; - box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08); - border: 1px solid rgba(0, 0, 0, 0.03); - } + } - .custom-icon { - width: 22px; - height: 22px; - } + .custom-icon-wrapper { + display: flex; + align-items: center; + justify-content: center; } - &__content-container { - --padding-top: 0; + .custom-icon { + width: 20px; + height: 20px; } &__filter { - padding: 8px 16px; + margin-bottom: 1rem; } &__segment-wrapper { - ion-segment { - --background: #ebeef9; - border-radius: 50px; - height: 40px; - overflow: hidden; - --border-radius: 50px; - - ion-segment-button { - --color: #777; - --color-checked: #000; - --indicator-color: white; - --background-checked: white; - --border-radius: 50px; - --border-color: transparent; - text-transform: none; - font-weight: 500; - letter-spacing: normal; - font-size: 14px; - min-height: 36px; - - &::part(indicator) { - border-radius: 50px; - box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); - } - } - } + padding: 0 1rem; + } + + &__category-tags { + display: flex; + flex-wrap: wrap; + padding: 0.75rem 1rem; + margin-bottom: 0.5rem; + background-color: #f9f9f9; + border-bottom: 1px solid var(--ion-color-light); + } + + &__content-container { + --padding-top: 0; + --background: var(--ion-background-color); } &__content { - padding: 0; + padding-bottom: 1rem; } &__list { - background-color: transparent; - padding: 12px; - margin: 0; - - ion-item { - --padding-start: 0; - --inner-padding-end: 0; - --background: transparent; - } + padding: 0; + background: transparent; } &__empty-state { - padding: 2rem; + height: 100%; display: flex; flex-direction: column; align-items: center; justify-content: center; + padding: 2rem; text-align: center; } - &__no-bookmarks { - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; + &__no-bookmarks, + &__no-matches { text-align: center; - padding: 2rem 1rem; + padding: 2rem; h3 { font-size: 1.2rem; - margin-bottom: 0.5rem; - color: #333; font-weight: 600; + margin-bottom: 0.5rem; } p { - font-size: 0.9rem; - color: #666; - margin: 0; + color: var(--ion-color-medium); + margin-bottom: 1.5rem; + } + } + + &__filter-modal { + --height: auto; + --width: 100%; + --border-radius: 20px 20px 0 0; + --box-shadow: 0 -4px 16px rgba(0, 0, 0, 0.1); + + &::part(content) { + border-radius: 20px 20px 0 0; } } - // Skeleton loading styles .skeleton { - width: 48px; - height: 48px; - border-radius: 50%; - background-color: #ebeef9; - margin-right: 16px; + background-color: rgba(var(--ion-color-medium-rgb), 0.2); + border-radius: 4px; + height: 40px; + width: 40px; + margin-right: 1rem; } } + diff --git a/frontend/src/pages/Reports/ReportsListPage.tsx b/frontend/src/pages/Reports/ReportsListPage.tsx index 400b3120..5e7d75ee 100644 --- a/frontend/src/pages/Reports/ReportsListPage.tsx +++ b/frontend/src/pages/Reports/ReportsListPage.tsx @@ -12,6 +12,7 @@ import { IonIcon, IonButton, IonToast, + IonModal, } from '@ionic/react'; import { useTranslation } from 'react-i18next'; import { useHistory } from 'react-router-dom'; @@ -20,15 +21,18 @@ import { fetchAllReports, toggleReportBookmark } from 'common/api/reportService' import { useMarkReportAsRead } from 'common/hooks/useReports'; import ReportItem from 'pages/Home/components/ReportItem/ReportItem'; import NoReportsMessage from 'pages/Home/components/NoReportsMessage/NoReportsMessage'; -import { useState, useMemo, useEffect } from 'react'; +import { useState, useMemo, useEffect, useRef } from 'react'; import { MedicalReport } from 'common/models/medicalReport'; import { documentTextOutline } from 'ionicons/icons'; import sortSvg from 'assets/icons/sort.svg'; import filterOutlineIcon from 'assets/icons/filter-outline.svg'; +import FilterPanel, { CategoryOption } from './components/FilterPanel/FilterPanel'; +import CategoryTag from './components/CategoryTag/CategoryTag'; import './ReportsListPage.scss'; type FilterOption = 'all' | 'bookmarked'; +type SortDirection = 'desc' | 'asc'; /** * Page component for displaying a list of all medical reports. @@ -38,8 +42,20 @@ const ReportsListPage: React.FC = () => { const history = useHistory(); const queryClient = useQueryClient(); const [filter, setFilter] = useState('all'); + const [sortDirection, setSortDirection] = useState('desc'); // Default sort by newest first const [showToast, setShowToast] = useState(false); - const [toastMessage, setToastMessage] = useState(''); + const [toastMessage] = useState(''); + const [showFilterModal, setShowFilterModal] = useState(false); + const [selectedCategories, setSelectedCategories] = useState([]); + const filterModalRef = useRef(null); + + // Define available categories + const categories: CategoryOption[] = [ + { id: 'general', label: t('category.general', { ns: 'report' }) }, + { id: 'heart', label: t('category.heart', { ns: 'report' }) }, + { id: 'brain', label: t('category.brain', { ns: 'report' }) }, + // Add more categories as needed + ]; const { data: reports = [], @@ -52,11 +68,25 @@ const ReportsListPage: React.FC = () => { const { mutate: markAsRead } = useMarkReportAsRead(); - // Filter reports based on selected filter + // Filter and sort reports based on selected filter, categories, and sort direction const filteredReports = useMemo(() => { - if (filter === 'all') return reports; - return reports.filter((report) => report.bookmarked); - }, [reports, filter]); + // First, filter the reports by bookmark status + let filtered = filter === 'all' ? reports : reports.filter(report => report.bookmarked); + + // Then, filter by selected categories if any are selected + if (selectedCategories.length > 0) { + filtered = filtered.filter(report => + selectedCategories.includes(report.category.toLowerCase()) + ); + } + + // Finally, sort the filtered reports by date + return [...filtered].sort((a, b) => { + const dateA = new Date(a.createdAt).getTime(); + const dateB = new Date(b.createdAt).getTime(); + return sortDirection === 'desc' ? dateB - dateA : dateA - dateB; + }); + }, [reports, filter, sortDirection, selectedCategories]); // Check if there are any bookmarked reports const hasBookmarkedReports = useMemo(() => { @@ -108,13 +138,49 @@ const ReportsListPage: React.FC = () => { }; const handleSortClick = () => { - setToastMessage(t('list.sortButton', { ns: 'report' })); - setShowToast(true); + const newSortDirection = sortDirection === 'desc' ? 'asc' : 'desc'; + setSortDirection(newSortDirection); }; const handleFilterClick = () => { - setToastMessage(t('list.filterButton', { ns: 'report' })); - setShowToast(true); + setShowFilterModal(true); + }; + + const handleCloseFilterModal = () => { + filterModalRef.current?.dismiss(); + }; + + const handleApplyFilters = (categories: string[]) => { + setSelectedCategories(categories); + }; + + const handleRemoveCategory = (categoryId: string) => { + setSelectedCategories(prev => prev.filter(id => id !== categoryId)); + }; + + const handleClearAllCategories = () => { + setSelectedCategories([]); + }; + + const getCategoryLabel = (categoryId: string): string => { + const category = categories.find(cat => cat.id === categoryId); + return category ? category.label : categoryId; + }; + + const renderCategoryTags = () => { + if (selectedCategories.length === 0) return null; + + return ( +
+ {selectedCategories.map(categoryId => ( + handleRemoveCategory(categoryId)} + /> + ))} +
+ ); }; const renderReportsList = () => { @@ -149,6 +215,14 @@ const ReportsListPage: React.FC = () => {

{t('list.noBookmarksTitle', { ns: 'report' })}

{t('list.noBookmarksMessage', { ns: 'report' })}

+ ) : selectedCategories.length > 0 ? ( +
+

{t('list.noMatchesTitle', { ns: 'report' })}

+

{t('list.noMatchesMessage', { ns: 'report' })}

+ + {t('list.clearFilters', { ns: 'report' })} + +
) : ( )} @@ -231,6 +305,10 @@ const ReportsListPage: React.FC = () => { )} + + {/* Display selected category tags */} + {renderCategoryTags()} +
{renderReportsList()} @@ -238,6 +316,23 @@ const ReportsListPage: React.FC = () => {
+ {/* Filter Modal */} + setShowFilterModal(false)} + className="reports-list-page__filter-modal" + initialBreakpoint={0.9} + breakpoints={[0, 0.9]} + > + + + setShowToast(false)} diff --git a/frontend/src/pages/Reports/components/CategoryTag/CategoryTag.scss b/frontend/src/pages/Reports/components/CategoryTag/CategoryTag.scss new file mode 100644 index 00000000..caa86c50 --- /dev/null +++ b/frontend/src/pages/Reports/components/CategoryTag/CategoryTag.scss @@ -0,0 +1,38 @@ +.category-tag { + display: inline-flex; + align-items: center; + background-color: white; + border-radius: 16px; + padding: 4px 6px 4px 10px; + margin-right: 8px; + margin-bottom: 8px; + border: 1px solid #e0e0e0; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.03); + + &__label { + font-size: 13px; + color: var(--ion-color-medium); + font-weight: 500; + } + + &__remove-button { + display: flex; + align-items: center; + justify-content: center; + background: none; + border: none; + padding: 4px; + margin-left: 2px; + cursor: pointer; + border-radius: 50%; + + &:active { + background-color: rgba(0, 0, 0, 0.05); + } + } + + &__remove-icon { + color: var(--ion-color-medium); + font-size: 14px; + } +} diff --git a/frontend/src/pages/Reports/components/CategoryTag/CategoryTag.tsx b/frontend/src/pages/Reports/components/CategoryTag/CategoryTag.tsx new file mode 100644 index 00000000..c08224dd --- /dev/null +++ b/frontend/src/pages/Reports/components/CategoryTag/CategoryTag.tsx @@ -0,0 +1,26 @@ +import React from 'react'; +import { IonIcon } from '@ionic/react'; +import { close } from 'ionicons/icons'; +import './CategoryTag.scss'; + +interface CategoryTagProps { + label: string; + onRemove: () => void; +} + +const CategoryTag: React.FC = ({ label, onRemove }) => { + return ( +
+ {label} + +
+ ); +}; + +export default CategoryTag; diff --git a/frontend/src/pages/Reports/components/FilterPanel/FilterPanel.scss b/frontend/src/pages/Reports/components/FilterPanel/FilterPanel.scss new file mode 100644 index 00000000..79a26556 --- /dev/null +++ b/frontend/src/pages/Reports/components/FilterPanel/FilterPanel.scss @@ -0,0 +1,65 @@ +.filter-panel { + padding: 1.5rem 1rem; + display: flex; + flex-direction: column; + height: 100%; + background-color: white; + + &__title { + font-size: 1.5rem; + font-weight: 600; + margin: 0 0 1.5rem; + color: var(--ion-text-color); + } + + &__category-section { + flex: 1; + margin-bottom: 1.5rem; + } + + &__category-title { + font-size: 1rem; + font-weight: 500; + margin: 0 0 1rem; + color: var(--ion-text-color); + } + + &__category-container { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; + } + + &__category-button { + background-color: white; + border: 1px solid #e0e0e0; + border-radius: 8px; + padding: 0.75rem 1.25rem; + font-size: 14px; + font-weight: 500; + color: var(--ion-color-medium); + transition: all 0.2s ease; + cursor: pointer; + + &--selected { + background-color: #313E4C; + color: white; + border-color: #313E4C; + } + } + + &__actions { + padding-bottom: env(safe-area-inset-bottom); + } + + &__apply-button { + --background: #4355B9; + --color: white; + height: 48px; + font-weight: 600; + margin: 0; + --border-radius: 8px; + text-transform: none; + font-size: 16px; + } +} diff --git a/frontend/src/pages/Reports/components/FilterPanel/FilterPanel.tsx b/frontend/src/pages/Reports/components/FilterPanel/FilterPanel.tsx new file mode 100644 index 00000000..10c06d59 --- /dev/null +++ b/frontend/src/pages/Reports/components/FilterPanel/FilterPanel.tsx @@ -0,0 +1,76 @@ +import React, { useState } from 'react'; +import { IonButton } from '@ionic/react'; +import { useTranslation } from 'react-i18next'; +import './FilterPanel.scss'; + +export interface CategoryOption { + id: string; + label: string; +} + +interface FilterPanelProps { + categories: CategoryOption[]; + selectedCategories: string[]; + onApply: (selectedCategories: string[]) => void; + onClose: () => void; +} + +const FilterPanel: React.FC = ({ + categories, + selectedCategories: initialSelectedCategories, + onApply, + onClose, +}) => { + const { t } = useTranslation(['report', 'common']); + const [selectedCategories, setSelectedCategories] = useState(initialSelectedCategories); + + const handleCategoryToggle = (categoryId: string) => { + setSelectedCategories((prev) => { + if (prev.includes(categoryId)) { + return prev.filter((id) => id !== categoryId); + } else { + return [...prev, categoryId]; + } + }); + }; + + const handleApply = () => { + onApply(selectedCategories); + onClose(); + }; + + return ( +
+

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

+ +
+

{t('filter.category', { ns: 'report' })}

+
+ {categories.map((category) => ( + + ))} +
+
+ +
+ + {t('filter.apply', { ns: 'report' })} + +
+
+ ); +}; + +export default FilterPanel;