From 73d3a3415b397d184120e089f16ee398c5e986c6 Mon Sep 17 00:00:00 2001 From: Simsat Date: Fri, 24 Apr 2026 08:18:35 +0200 Subject: [PATCH 1/3] feat(1050): hightlight usage for food deletion --- .../src/pages/Diary/Diary.tsx | 7 +- .../src/pages/Diary/MealCard.tsx | 12 +- .../src/pages/Foods/Foods.tsx | 147 +++++++++++--- SparkyFitnessFrontend/src/types/food.ts | 6 + SparkyFitnessServer/models/food.ts | 187 +++++++++--------- 5 files changed, 236 insertions(+), 123 deletions(-) diff --git a/SparkyFitnessFrontend/src/pages/Diary/Diary.tsx b/SparkyFitnessFrontend/src/pages/Diary/Diary.tsx index 9dda7d78a..5ebc0fe80 100644 --- a/SparkyFitnessFrontend/src/pages/Diary/Diary.tsx +++ b/SparkyFitnessFrontend/src/pages/Diary/Diary.tsx @@ -1,6 +1,6 @@ import { useState, useEffect } from 'react'; import { useTranslation } from 'react-i18next'; -import { useLocation, useNavigate } from 'react-router-dom'; +import { useLocation, useNavigate, useSearchParams } from 'react-router-dom'; import { Card, CardHeader, CardTitle } from '@/components/ui/card'; import { Button } from '@/components/ui/button'; import { Calendar } from '@/components/ui/calendar'; @@ -63,8 +63,11 @@ const Diary = () => { const [editingEntry, setEditingEntry] = useState(null); const [editingFoodEntryMeal, setEditingFoodEntryMeal] = useState(null); // State for editing logged meal entry + const [searchParams] = useSearchParams(); + const [selectedDate, setSelectedDate] = useState( - formatDateInUserTimezone(new Date(), 'yyyy-MM-dd') + searchParams.get('date') ?? + formatDateInUserTimezone(new Date(), 'yyyy-MM-dd') ); const [date, setDate] = useState(parseDateInUserTimezone(selectedDate)); debug(loggingLevel, 'FoodDiary component rendered for date:', selectedDate); diff --git a/SparkyFitnessFrontend/src/pages/Diary/MealCard.tsx b/SparkyFitnessFrontend/src/pages/Diary/MealCard.tsx index 49ee1b2be..0cb257166 100644 --- a/SparkyFitnessFrontend/src/pages/Diary/MealCard.tsx +++ b/SparkyFitnessFrontend/src/pages/Diary/MealCard.tsx @@ -56,6 +56,8 @@ interface MealTotals { import type { UserCustomNutrient } from '@/types/customNutrient'; import { DEFAULT_NUTRIENTS } from '@/constants/nutrients'; +import { useSearchParams } from 'react-router-dom'; +import { cn } from '@/lib/utils'; interface MealCardProps { meal: { @@ -119,7 +121,8 @@ const MealCard = ({ onFoodSearchClose(); } }; - + const [searchParams] = useSearchParams(); + const highlightFoodId = searchParams.get('highlight') ?? null; const { mutate: copyFoodEntriesFromYesterday } = useCopyFoodEntriesFromYesterdayMutation(); const getEnergyUnitString = (unit: 'kcal' | 'kJ'): string => { @@ -350,7 +353,12 @@ const MealCard = ({ return (
diff --git a/SparkyFitnessFrontend/src/pages/Foods/Foods.tsx b/SparkyFitnessFrontend/src/pages/Foods/Foods.tsx index 219316aa6..be4d985b9 100644 --- a/SparkyFitnessFrontend/src/pages/Foods/Foods.tsx +++ b/SparkyFitnessFrontend/src/pages/Foods/Foods.tsx @@ -61,6 +61,13 @@ import { import CustomFoodForm from '@/components/FoodSearch/CustomFoodForm'; import { MealFilter } from '@/types/meal'; import type { Meal } from '@/types/meal'; +import { useMealTypes } from '@/hooks/Diary/useMealTypes'; +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from '@/components/ui/collapsible'; +import { Link } from 'react-router-dom'; const FoodDatabaseManager: React.FC = () => { const { t } = useTranslation(); @@ -70,6 +77,15 @@ const FoodDatabaseManager: React.FC = () => { const isMobile = useIsMobile(); const platform = isMobile ? 'mobile' : 'desktop'; + const formatEntryDate = (date: Date | string) => + new Date(date).toLocaleDateString(undefined, { + year: 'numeric', + month: 'short', + day: 'numeric', + }); + + const toISODate = (date: Date | string) => + new Date(date).toISOString().split('T')[0]; const quickInfoPreferences = nutrientDisplayPreferences.find( (p) => p.view_group === 'quick_info' && p.platform === platform @@ -109,6 +125,8 @@ const FoodDatabaseManager: React.FC = () => { const { mutateAsync: deleteFood } = useDeleteFoodMutation(); const { mutateAsync: createFoodEntry } = useCreateFoodMutation(); + const { data: mealTypes } = useMealTypes(); + const handleDeleteRequest = async (food: Food) => { if (!user || !activeUserId) return; const impact = await queryClient.fetchQuery( @@ -702,7 +720,7 @@ const FoodDatabaseManager: React.FC = () => { open={showDeleteConfirmation} onOpenChange={setShowDeleteConfirmation} > - + {t('foodDatabaseManager.deleteFoodConfirmTitle', { @@ -711,42 +729,110 @@ const FoodDatabaseManager: React.FC = () => { })} -
-

+ +

+

{t('foodDatabaseManager.foodUsedIn', 'This food is used in:')}

-
    -
  • - {t('foodDatabaseManager.diaryEntries', { - count: deletionImpact.foodEntriesCount, - defaultValue: `${deletionImpact.foodEntriesCount} diary entries`, - })} -
  • -
  • - {t('foodDatabaseManager.mealComponents', { + +
    + {/* Diary entries — collapsible */} + {deletionImpact.foodEntries.length > 0 ? ( + + + + {t('foodDatabaseManager.diaryEntries', { + count: deletionImpact.foodEntriesCount, + defaultValue: `${deletionImpact.foodEntriesCount} diary entries`, + })} + + + + + +
    + {deletionImpact.foodEntries.map((entry) => ( +
    + + {formatEntryDate(entry.entry_date)} + + + {mealTypes?.find( + (mt) => mt.id === entry.meal_type_id + )?.name ?? '—'} + + {entry.isCurrentUser ? ( + + )} + {[ + { + key: 'mealComponents', count: deletionImpact.mealFoodsCount, - defaultValue: `${deletionImpact.mealFoodsCount} meal components`, - })} -
  • -
  • - {t('foodDatabaseManager.mealPlanEntries', { + label: 'meal components', + }, + { + key: 'mealPlanEntries', count: deletionImpact.mealPlansCount, - defaultValue: `${deletionImpact.mealPlansCount} meal plan entries`, - })} -
  • -
  • - {t('foodDatabaseManager.mealPlanTemplateEntries', { + label: 'meal plan entries', + }, + { + key: 'mealPlanTemplateEntries', count: deletionImpact.mealPlanTemplateAssignmentsCount, - defaultValue: `${deletionImpact.mealPlanTemplateAssignmentsCount} meal plan template entries`, - })} -
  • -
+ label: 'meal plan template entries', + }, + ] + .filter(({ count }) => count > 0) + .map(({ key, count }) => ( +
+ {t('foodDatabaseManager.' + key, { count })} +
+ ))} +
+ {deletionImpact.otherUserReferences > 0 && ( -
-

+

+

{t('foodDatabaseManager.warning', 'Warning!')}

-

+

{t( 'foodDatabaseManager.foodUsedByOtherUsersWarning', 'This food is used by other users. You can only hide it. Hiding will prevent other users from adding this food in the future, but it will not affect their existing history, meals, or meal plans.' @@ -755,7 +841,8 @@ const FoodDatabaseManager: React.FC = () => {

)}
-
+ +
+ {impact.totalReferences === 0 ? ( + + ) : impact.otherUserReferences > 0 ? ( + + ) : ( + <> + + + + )} +
+ + + ); +}; + +export default DeleteFoodDialog; diff --git a/SparkyFitnessFrontend/src/pages/Foods/FoodCard.tsx b/SparkyFitnessFrontend/src/pages/Foods/FoodCard.tsx new file mode 100644 index 000000000..a0fa067f6 --- /dev/null +++ b/SparkyFitnessFrontend/src/pages/Foods/FoodCard.tsx @@ -0,0 +1,227 @@ +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from '@/components/ui/tooltip'; +import { + getNutrientMetadata, + formatNutrientValue, +} from '@/utils/nutrientUtils'; + +import { Badge } from '@/components/ui/badge'; +import type { Food, FoodVariant } from '@/types/food'; +import { useTranslation } from 'react-i18next'; +import { Edit, Trash2, Share2, Lock } from 'lucide-react'; +import { Button } from '@/components/ui/button'; + +interface FoodCardProps { + food: Food; + isMobile: boolean; + visibleNutrients: string[]; + userId: string | undefined; + canEdit: (food: Food) => boolean; + onEdit: (food: Food) => void; + onDelete: (food: Food) => void; + onTogglePublic: (args: { foodId: string; currentState: boolean }) => void; +} + +export const FoodCard = ({ + food, + isMobile, + userId, + visibleNutrients, + canEdit, + onEdit, + onDelete, + onTogglePublic, +}: FoodCardProps) => { + const { t } = useTranslation(); + const getFoodSourceBadge = (food: Food) => { + if (!food.user_id) { + return ( + + {t('foodDatabaseManager.system', 'System')} + + ); + } + if (food.user_id === userId) { + return ( + + {t('foodDatabaseManager.private', 'Private')} + + ); + } + if (food.user_id !== userId && !food.shared_with_public) { + return ( + + {t('foodDatabaseManager.family', 'Family')} + + ); + } + return null; // No badge from getFoodSourceBadge if it's public and not owned by user + }; + + return ( +
+
+
+
+ {food.name} + {food.brand && ( + + {food.brand} + + )} + {getFoodSourceBadge(food)} + {food.shared_with_public && ( + + + {t('foodDatabaseManager.public', 'Public')} + + )} +
+
+ {/* Action Buttons */} +
+ {/* Share/Lock Button */} + + + + + + +

+ {canEdit(food) + ? food.shared_with_public + ? t('foodDatabaseManager.makePrivate', 'Make private') + : t( + 'foodDatabaseManager.shareWithPublic', + 'Share with public' + ) + : t('foodDatabaseManager.notEditable', 'Not editable')} +

+
+
+
+ + {/* Edit Button */} + + + + + + +

+ {canEdit(food) + ? t('foodDatabaseManager.editFood', 'Edit food') + : t('foodDatabaseManager.notEditable', 'Not editable')} +

+
+
+
+ + {/* Delete Button */} + + + + + + +

+ {canEdit(food) + ? t('foodDatabaseManager.deleteFood', 'Delete food') + : t('foodDatabaseManager.notEditable', 'Not editable')} +

+
+
+
+
+
+ {t('foodDatabaseManager.perServing', { + servingSize: food.default_variant?.serving_size || 0, + servingUnit: food.default_variant?.serving_unit || '', + defaultValue: `Per ${food.default_variant?.serving_size || 0} ${food.default_variant?.serving_unit || ''}`, + })} +
+
+
+
+ {visibleNutrients.map((nutrient) => { + const meta = getNutrientMetadata(nutrient); + const value = + (food.default_variant?.[ + nutrient as keyof FoodVariant + ] as number) || + (food.default_variant?.custom_nutrients?.[nutrient] as number) || + 0; + + return ( +
+ + {formatNutrientValue(nutrient, value, [])} + + {meta.unit} + + + + {t(meta.label, meta.defaultLabel)} + +
+ ); + })} +
+
+
+ ); +}; diff --git a/SparkyFitnessFrontend/src/pages/Foods/Foods.tsx b/SparkyFitnessFrontend/src/pages/Foods/Foods.tsx index be4d985b9..0b01e3937 100644 --- a/SparkyFitnessFrontend/src/pages/Foods/Foods.tsx +++ b/SparkyFitnessFrontend/src/pages/Foods/Foods.tsx @@ -1,11 +1,7 @@ -import type React from 'react'; -import { useState } from 'react'; -import { formatDateToYYYYMMDD } from '@/lib/utils'; import { useTranslation } from 'react-i18next'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; -import { Badge } from '@/components/ui/badge'; import { Dialog, DialogContent, @@ -13,12 +9,6 @@ import { DialogTitle, DialogDescription, } from '@/components/ui/dialog'; -import { - Tooltip, - TooltipContent, - TooltipProvider, - TooltipTrigger, -} from '@/components/ui/tooltip'; import { Select, SelectContent, @@ -34,249 +24,61 @@ import { PaginationNext, PaginationPrevious, } from '@/components/ui/pagination'; -import { Search, Edit, Trash2, Plus, Share2, Filter, Lock } from 'lucide-react'; -import { useActiveUser } from '@/contexts/ActiveUserContext'; -import { useAuth } from '@/hooks/useAuth'; -import { usePreferences } from '@/contexts/PreferencesContext'; -import { useIsMobile } from '@/hooks/use-mobile'; -import { toast } from '@/hooks/use-toast'; -import { info } from '@/utils/logging'; +import { Search, Plus, Filter } from 'lucide-react'; import FoodSearchDialog from '@/components/FoodSearch/FoodSearchDialog'; import FoodUnitSelector from '@/components/FoodUnitSelector'; -import type { Food, FoodVariant, FoodDeletionImpact } from '@/types/food'; import MealManagement from './MealManagement'; import MealPlanCalendar from './MealPlanCalendar'; -import { - foodDeletionImpactOptions, - useCreateFoodMutation, - useDeleteFoodMutation, - useFoods, - useToggleFoodPublicMutation, -} from '@/hooks/Foods/useFoods'; -import { useQueryClient } from '@tanstack/react-query'; -import { - getNutrientMetadata, - formatNutrientValue, -} from '@/utils/nutrientUtils'; import CustomFoodForm from '@/components/FoodSearch/CustomFoodForm'; import { MealFilter } from '@/types/meal'; -import type { Meal } from '@/types/meal'; -import { useMealTypes } from '@/hooks/Diary/useMealTypes'; -import { - Collapsible, - CollapsibleContent, - CollapsibleTrigger, -} from '@/components/ui/collapsible'; -import { Link } from 'react-router-dom'; +import { useFoodDatabaseManager } from '@/hooks/Foods/useFoodDatabaseManager'; +import { FoodCard } from './FoodCard'; -const FoodDatabaseManager: React.FC = () => { +const FoodDatabaseManager = () => { const { t } = useTranslation(); - const { user } = useAuth(); - const { activeUserId } = useActiveUser(); - const { nutrientDisplayPreferences, loggingLevel } = usePreferences(); - const isMobile = useIsMobile(); - const platform = isMobile ? 'mobile' : 'desktop'; - - const formatEntryDate = (date: Date | string) => - new Date(date).toLocaleDateString(undefined, { - year: 'numeric', - month: 'short', - day: 'numeric', - }); - - const toISODate = (date: Date | string) => - new Date(date).toISOString().split('T')[0]; - const quickInfoPreferences = - nutrientDisplayPreferences.find( - (p) => p.view_group === 'quick_info' && p.platform === platform - ) || - nutrientDisplayPreferences.find( - (p) => p.view_group === 'quick_info' && p.platform === 'desktop' - ); - const visibleNutrients = quickInfoPreferences - ? quickInfoPreferences.visible_nutrients - : ['calories', 'protein', 'carbs', 'fat']; - - const [searchTerm, setSearchTerm] = useState(''); - const [editingFood, setEditingFood] = useState(null); - const [showFoodSearchDialog, setShowFoodSearchDialog] = useState(false); - const [showEditDialog, setShowEditDialog] = useState(false); - const [itemsPerPage, setItemsPerPage] = useState(10); - const [currentPage, setCurrentPage] = useState(1); - const [foodFilter, setFoodFilter] = useState('all'); - const [sortOrder, setSortOrder] = useState('name:asc'); - const [showFoodUnitSelectorDialog, setShowFoodUnitSelectorDialog] = - useState(false); - const [foodToAddToMeal, setFoodToAddToMeal] = useState(null); - const [showDeleteConfirmation, setShowDeleteConfirmation] = useState(false); - const [deletionImpact, setDeletionImpact] = - useState(null); - const [foodToDelete, setFoodToDelete] = useState(null); - - const queryClient = useQueryClient(); - const { data: foodData, isLoading: loading } = useFoods( + const { + user, + isAuthenticated, + isMobile, + visibleNutrients, searchTerm, - foodFilter, - currentPage, + setSearchTerm, itemsPerPage, - sortOrder - ); - const { mutate: togglePublicSharing } = useToggleFoodPublicMutation(); - const { mutateAsync: deleteFood } = useDeleteFoodMutation(); - const { mutateAsync: createFoodEntry } = useCreateFoodMutation(); - - const { data: mealTypes } = useMealTypes(); - - const handleDeleteRequest = async (food: Food) => { - if (!user || !activeUserId) return; - const impact = await queryClient.fetchQuery( - foodDeletionImpactOptions(food.id) - ); - - setDeletionImpact(impact); - setFoodToDelete(food); - setShowDeleteConfirmation(true); - }; - - const confirmDelete = async (force: boolean = false) => { - if (!foodToDelete || !activeUserId) return; - info(loggingLevel, `confirmDelete called with force: ${force}`); - await deleteFood({ foodId: foodToDelete.id, force }); - setShowDeleteConfirmation(false); - setFoodToDelete(null); - setDeletionImpact(null); - }; - - const handleEdit = (food: Food) => { - setEditingFood(food); - setShowEditDialog(true); - }; - - const handleSaveComplete = () => { - setShowEditDialog(false); - setEditingFood(null); - }; - - const handleFoodSelected = (item: Food | Meal, type: 'food' | 'meal') => { - setShowFoodSearchDialog(false); - if (type === 'food') { - setFoodToAddToMeal(item as Food); - setShowFoodUnitSelectorDialog(true); - } - }; - - const handleAddFoodToMeal = async ( - food: Food, - quantity: number, - unit: string, - selectedVariant: FoodVariant - ) => { - if (!user || !activeUserId) { - toast({ - title: t('common.error', 'Error'), - description: t( - 'foodDatabaseManager.userNotAuthenticated', - 'User not authenticated.' - ), - variant: 'destructive', - }); - return; - } - - await createFoodEntry({ - foodData: { - food_id: food.id!, - meal_type: 'breakfast', // Default to breakfast for now, or make dynamic - quantity: quantity, - unit: unit, - entry_date: formatDateToYYYYMMDD(new Date()), - variant_id: selectedVariant.id || null, - }, - }); - - setShowFoodUnitSelectorDialog(false); - setFoodToAddToMeal(null); - }; - - const handlePageChange = (page: number) => { - setCurrentPage(page); - }; - - const canEdit = (food: Food) => { - // Only allow editing if the user owns the food - return food.user_id === user?.id; - }; - - const getFoodSourceBadge = (food: Food) => { - if (!food.user_id) { - return ( - - {t('foodDatabaseManager.system', 'System')} - - ); - } - - if (food.user_id === user?.id) { - return ( - - {t('foodDatabaseManager.private', 'Private')} - - ); - } - - if (food.user_id !== user?.id && !food.shared_with_public) { - return ( - - {t('foodDatabaseManager.family', 'Family')} - - ); - } - return null; // No badge from getFoodSourceBadge if it's public and not owned by user - }; - - const getEmptyMessage = () => { - switch (foodFilter) { - case 'all': - return t('foodDatabaseManager.noFoodsFound', 'No foods found'); - case 'mine': - return t( - 'foodDatabaseManager.noFoodsCreatedByYouFound', - 'No foods created by you found' - ); - case 'family': - return t( - 'foodDatabaseManager.noFamilyFoodsFound', - 'No family foods found' - ); - case 'public': - return t( - 'foodDatabaseManager.noPublicFoodsFound', - 'No public foods found' - ); - case 'needs-review': - return t( - 'foodDatabaseManager.noFoodsNeedYourReview', - 'No foods need your review' - ); - default: - return t('foodDatabaseManager.noFoodsFound', 'No foods found'); - } - }; - - const totalPages = foodData - ? Math.ceil(foodData.totalCount / itemsPerPage) - : 0; - - if (!user || !activeUserId) { + setItemsPerPage, + currentPage, + foodFilter, + setFoodFilter, + sortOrder, + setSortOrder, + foodData, + loading, + totalPages, + pendingDeletion, + handleConfirmDelete, + handleCancelDelete, + showFoodSearchDialog, + setShowFoodSearchDialog, + showEditDialog, + setShowEditDialog, + editingFood, + showFoodUnitSelectorDialog, + setShowFoodUnitSelectorDialog, + getPageNumbers, + foodToAddToMeal, + togglePublicSharing, + canEdit, + getEmptyMessage, + handlePageChange, + handleEdit, + handleSaveComplete, + handleFoodSelected, + handleAddFoodToMeal, + handleDeleteRequest, + } = useFoodDatabaseManager(); + if (!isAuthenticated) { return (
- {t( - 'foodDatabaseManager.pleaseSignInToManageFoodDatabase', - 'Please sign in to manage your food database.' - )} + {t('foodDatabaseManager.pleaseSignInToManageFoodDatabase', '...')}
); } @@ -406,6 +208,8 @@ const FoodDatabaseManager: React.FC = () => { 10 15 25 + 50 + 100
@@ -425,193 +229,16 @@ const FoodDatabaseManager: React.FC = () => { ) : (
{foodData?.foods.map((food) => ( -
-
-
-
- - {food.name} - - {food.brand && ( - - {food.brand} - - )} - {getFoodSourceBadge(food)} - {food.shared_with_public && ( - - - {t('foodDatabaseManager.public', 'Public')} - - )} -
-
- {/* Action Buttons */} -
- {/* Share/Lock Button */} - - - - - - -

- {canEdit(food) - ? food.shared_with_public - ? t( - 'foodDatabaseManager.makePrivate', - 'Make private' - ) - : t( - 'foodDatabaseManager.shareWithPublic', - 'Share with public' - ) - : t( - 'foodDatabaseManager.notEditable', - 'Not editable' - )} -

-
-
-
- - {/* Edit Button */} - - - - - - -

- {canEdit(food) - ? t( - 'foodDatabaseManager.editFood', - 'Edit food' - ) - : t( - 'foodDatabaseManager.notEditable', - 'Not editable' - )} -

-
-
-
- - {/* Delete Button */} - - - - - - -

- {canEdit(food) - ? t( - 'foodDatabaseManager.deleteFood', - 'Delete food' - ) - : t( - 'foodDatabaseManager.notEditable', - 'Not editable' - )} -

-
-
-
-
-
- {t('foodDatabaseManager.perServing', { - servingSize: - food.default_variant?.serving_size || 0, - servingUnit: - food.default_variant?.serving_unit || '', - defaultValue: `Per ${food.default_variant?.serving_size || 0} ${food.default_variant?.serving_unit || ''}`, - })} -
-
-
-
- {visibleNutrients.map((nutrient) => { - const meta = getNutrientMetadata(nutrient); - const value = - (food.default_variant?.[ - nutrient as keyof FoodVariant - ] as number) || - (food.default_variant?.custom_nutrients?.[ - nutrient - ] as number) || - 0; - - return ( -
- - {formatNutrientValue(nutrient, value, [])} - - {meta.unit} - - - - {t(meta.label, meta.defaultLabel)} - -
- ); - })} -
-
-
+ ))}
)} @@ -634,30 +261,17 @@ const FoodDatabaseManager: React.FC = () => { /> - {Array.from({ length: Math.min(5, totalPages) }, (_, i) => { - let pageNumber: number; - if (totalPages <= 5) { - pageNumber = i + 1; - } else if (currentPage <= 3) { - pageNumber = i + 1; - } else if (currentPage >= totalPages - 2) { - pageNumber = totalPages - 4 + i; - } else { - pageNumber = currentPage - 2 + i; - } - - return ( - - handlePageChange(pageNumber)} - isActive={currentPage === pageNumber} - className="cursor-pointer" - > - {pageNumber} - - - ); - })} + {getPageNumbers(currentPage, totalPages).map((pageNumber) => ( + + handlePageChange(pageNumber)} + isActive={currentPage === pageNumber} + className="cursor-pointer" + > + {pageNumber} + + + ))} { /> )} - {deletionImpact && foodToDelete && ( - - + {pendingDeletion && ( + + {t('foodDatabaseManager.deleteFoodConfirmTitle', { - foodName: foodToDelete.name, - defaultValue: `Delete ${foodToDelete.name}?`, + foodName: pendingDeletion.food.name, + defaultValue: `Delete ${pendingDeletion.food.name}?`, })} @@ -734,143 +345,73 @@ const FoodDatabaseManager: React.FC = () => {

{t('foodDatabaseManager.foodUsedIn', 'This food is used in:')}

- -
- {/* Diary entries — collapsible */} - {deletionImpact.foodEntries.length > 0 ? ( - - - - {t('foodDatabaseManager.diaryEntries', { - count: deletionImpact.foodEntriesCount, - defaultValue: `${deletionImpact.foodEntriesCount} diary entries`, - })} - - - - - -
- {deletionImpact.foodEntries.map((entry) => ( -
- - {formatEntryDate(entry.entry_date)} - - - {mealTypes?.find( - (mt) => mt.id === entry.meal_type_id - )?.name ?? '—'} - - {entry.isCurrentUser ? ( - - )} - {[ - { - key: 'mealComponents', - count: deletionImpact.mealFoodsCount, - label: 'meal components', - }, - { - key: 'mealPlanEntries', - count: deletionImpact.mealPlansCount, - label: 'meal plan entries', - }, - { - key: 'mealPlanTemplateEntries', - count: deletionImpact.mealPlanTemplateAssignmentsCount, - label: 'meal plan template entries', - }, - ] - .filter(({ count }) => count > 0) - .map(({ key, count }) => ( -
- {t('foodDatabaseManager.' + key, { count })} -
- ))} -
- - {deletionImpact.otherUserReferences > 0 && ( -
-

+

    +
  • + {t('foodDatabaseManager.diaryEntries', { + count: pendingDeletion.impact.foodEntriesCount, + defaultValue: `${pendingDeletion.impact.foodEntriesCount} diary entries`, + })} +
  • +
  • + {t('foodDatabaseManager.mealComponents', { + count: pendingDeletion.impact.mealFoodsCount, + defaultValue: `${pendingDeletion.impact.mealFoodsCount} meal components`, + })} +
  • +
  • + {t('foodDatabaseManager.mealPlanEntries', { + count: pendingDeletion.impact.mealPlansCount, + defaultValue: `${pendingDeletion.impact.mealPlansCount} meal plan entries`, + })} +
  • +
  • + {t('foodDatabaseManager.mealPlanTemplateEntries', { + count: + pendingDeletion.impact.mealPlanTemplateAssignmentsCount, + defaultValue: `${pendingDeletion.impact.mealPlanTemplateAssignmentsCount} meal plan template entries`, + })} +
  • +
+ {pendingDeletion.impact.otherUserReferences > 0 && ( +
+

{t('foodDatabaseManager.warning', 'Warning!')}

{t( 'foodDatabaseManager.foodUsedByOtherUsersWarning', - 'This food is used by other users. You can only hide it. Hiding will prevent other users from adding this food in the future, but it will not affect their existing history, meals, or meal plans.' + 'This food is used by other users...' )}

)}
- -
- - {deletionImpact.totalReferences === 0 ? ( + {pendingDeletion.impact.totalReferences === 0 ? ( - ) : deletionImpact.otherUserReferences > 0 ? ( - ) : ( <> From 9e98344561ab6d6dd7bfc1a74736295216b1b9bf Mon Sep 17 00:00:00 2001 From: Simsat Date: Fri, 24 Apr 2026 16:52:41 +0200 Subject: [PATCH 3/3] feat, refactor: standardize all tables with pagination and sorting --- SparkyFitnessFrontend/package.json | 2 + .../src/api/Exercises/exerciseService.ts | 4 +- .../src/api/keys/exercises.ts | 5 +- .../src/components/BulkActionToolbar.tsx | 77 ++ .../src/components/BulkDeleteDialog.tsx | 63 ++ .../src/components/ui/DataTable.tsx | 439 +++++++++ .../src/components/ui/DataTablePagination.tsx | 112 +++ .../src/components/ui/dropdown-menu.tsx | 198 ++++ .../src/components/ui/pagination.tsx | 117 --- .../src/hooks/Exercises/useDeleteExercise.ts | 1 + .../src/hooks/Exercises/useExerciseFilter.ts | 4 +- .../src/hooks/Exercises/useExercises.ts | 9 +- .../src/hooks/Foods/useFoodDatabaseManager.ts | 48 +- .../src/hooks/use-mobile.tsx | 6 +- .../src/hooks/useBulkSelection.ts | 99 ++ .../src/pages/Exercises/ExerciseListItem.tsx | 237 ----- .../src/pages/Exercises/Exercises.tsx | 843 +++++++++++------- .../pages/Exercises/WorkoutPlansManager.tsx | 588 +++++++----- .../pages/Exercises/WorkoutPresetsManager.tsx | 628 +++++++------ .../src/pages/Foods/DeleteFoodDialog.tsx | 2 +- .../src/pages/Foods/FoodCard.tsx | 227 ----- .../src/pages/Foods/Foods.tsx | 779 ++++++++++------ .../src/pages/Foods/MealManagement.tsx | 792 +++++++++------- .../src/pages/Foods/MealPlanCalendar.tsx | 535 +++++++---- .../tests/components/MealManagement.test.tsx | 4 +- .../components/MealPlanCalendar.test.tsx | 4 +- SparkyFitnessServer/models/food.ts | 6 +- pnpm-lock.yaml | 29 +- 28 files changed, 3653 insertions(+), 2205 deletions(-) create mode 100644 SparkyFitnessFrontend/src/components/BulkActionToolbar.tsx create mode 100644 SparkyFitnessFrontend/src/components/BulkDeleteDialog.tsx create mode 100644 SparkyFitnessFrontend/src/components/ui/DataTable.tsx create mode 100644 SparkyFitnessFrontend/src/components/ui/DataTablePagination.tsx create mode 100644 SparkyFitnessFrontend/src/components/ui/dropdown-menu.tsx delete mode 100644 SparkyFitnessFrontend/src/components/ui/pagination.tsx create mode 100644 SparkyFitnessFrontend/src/hooks/useBulkSelection.ts delete mode 100644 SparkyFitnessFrontend/src/pages/Exercises/ExerciseListItem.tsx delete mode 100644 SparkyFitnessFrontend/src/pages/Foods/FoodCard.tsx diff --git a/SparkyFitnessFrontend/package.json b/SparkyFitnessFrontend/package.json index 37ff4daf4..519b9917b 100644 --- a/SparkyFitnessFrontend/package.json +++ b/SparkyFitnessFrontend/package.json @@ -68,6 +68,7 @@ "@radix-ui/react-checkbox": "^1.3.3", "@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-dialog": "^1.1.15", + "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-label": "^2.1.8", "@radix-ui/react-popover": "^1.1.15", "@radix-ui/react-progress": "^1.1.8", @@ -83,6 +84,7 @@ "@radix-ui/react-toggle": "^1.1.10", "@radix-ui/react-tooltip": "^1.2.8", "@tanstack/react-query": "^5.90.19", + "@tanstack/react-table": "^8.21.3", "@types/leaflet": "^1.9.21", "@zxing/library": "^0.21.3", "better-auth": "^1.5.6", diff --git a/SparkyFitnessFrontend/src/api/Exercises/exerciseService.ts b/SparkyFitnessFrontend/src/api/Exercises/exerciseService.ts index 43f770ea8..b2238130e 100644 --- a/SparkyFitnessFrontend/src/api/Exercises/exerciseService.ts +++ b/SparkyFitnessFrontend/src/api/Exercises/exerciseService.ts @@ -86,7 +86,8 @@ export const loadExercises = async ( categoryFilter: string = 'all', ownershipFilter: ExerciseOwnershipFilter = 'all', currentPage: number = 1, - itemsPerPage: number = 10 + itemsPerPage: number = 10, + sortBy: string = 'name:asc' ): Promise<{ exercises: Exercise[]; totalCount: number }> => { const queryParams = new URLSearchParams({ searchTerm, @@ -94,6 +95,7 @@ export const loadExercises = async ( ownershipFilter, currentPage: currentPage.toString(), itemsPerPage: itemsPerPage.toString(), + sortBy, }).toString(); const response = await apiCall(`/exercises?${queryParams}`, { diff --git a/SparkyFitnessFrontend/src/api/keys/exercises.ts b/SparkyFitnessFrontend/src/api/keys/exercises.ts index c3b84ce4d..cb8dd2586 100644 --- a/SparkyFitnessFrontend/src/api/keys/exercises.ts +++ b/SparkyFitnessFrontend/src/api/keys/exercises.ts @@ -8,11 +8,12 @@ export const exerciseKeys = { categoryFilter: string, ownershipFilter: ExerciseOwnershipFilter, page: number, - limit: number + limit: number, + sortOrder: string = 'name:asc' ) => [ ...exerciseKeys.lists(), - { searchTerm, categoryFilter, ownershipFilter, page, limit }, + { searchTerm, categoryFilter, ownershipFilter, page, limit, sortOrder }, ] as const, details: () => [...exerciseKeys.all, 'detail'] as const, detail: (id: string) => [...exerciseKeys.details(), id] as const, diff --git a/SparkyFitnessFrontend/src/components/BulkActionToolbar.tsx b/SparkyFitnessFrontend/src/components/BulkActionToolbar.tsx new file mode 100644 index 000000000..89cfac81c --- /dev/null +++ b/SparkyFitnessFrontend/src/components/BulkActionToolbar.tsx @@ -0,0 +1,77 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { Button } from '@/components/ui/button'; +import { Checkbox } from '@/components/ui/checkbox'; +import { Trash2, X } from 'lucide-react'; + +interface BulkActionToolbarProps { + selectedCount: number; + totalCount: number; + onDelete: () => void; + onClear: () => void; + onSelectAll: (checked: boolean) => void; + allSelected: boolean; +} + +const BulkActionToolbar: React.FC = ({ + selectedCount, + totalCount, + onDelete, + onClear, + onSelectAll, + allSelected, +}) => { + const { t } = useTranslation(); + + if (selectedCount === 0) return null; + + return ( +
+
+ +
+ + {t('common.selectedCount', { + count: selectedCount, + defaultValue: `${selectedCount} selected`, + })} + + + {t('common.outOfTotal', { + total: totalCount, + defaultValue: `out of ${totalCount} items`, + })} + +
+
+ +
+ + +
+
+ ); +}; + +export default BulkActionToolbar; diff --git a/SparkyFitnessFrontend/src/components/BulkDeleteDialog.tsx b/SparkyFitnessFrontend/src/components/BulkDeleteDialog.tsx new file mode 100644 index 000000000..c1075b92a --- /dev/null +++ b/SparkyFitnessFrontend/src/components/BulkDeleteDialog.tsx @@ -0,0 +1,63 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@/components/ui/alert-dialog'; + +interface BulkDeleteDialogProps { + isOpen: boolean; + onOpenChange: (open: boolean) => void; + onConfirm: () => void; + selectedCount: number; + entityName?: string; +} + +const BulkDeleteDialog: React.FC = ({ + isOpen, + onOpenChange, + onConfirm, + selectedCount, + entityName, +}) => { + const { t } = useTranslation(); + + return ( + + + + + {t('common.bulkDeleteTitle', { + count: selectedCount, + entity: entityName || t('common.items', 'items'), + defaultValue: `Delete ${selectedCount} ${entityName || 'items'}?`, + })} + + + {t('common.bulkDeleteDescription', { + count: selectedCount, + defaultValue: `Are you sure you want to delete these ${selectedCount} items? This action cannot be undone.`, + })} + + + + {t('common.cancel', 'Cancel')} + + {t('common.delete', 'Delete')} + + + + + ); +}; + +export default BulkDeleteDialog; diff --git a/SparkyFitnessFrontend/src/components/ui/DataTable.tsx b/SparkyFitnessFrontend/src/components/ui/DataTable.tsx new file mode 100644 index 000000000..821d487b3 --- /dev/null +++ b/SparkyFitnessFrontend/src/components/ui/DataTable.tsx @@ -0,0 +1,439 @@ +import { useMemo, useState } from 'react'; +import { + ColumnDef, + flexRender, + getCoreRowModel, + useReactTable, + getPaginationRowModel, + SortingState, + getSortedRowModel, + ColumnFiltersState, + getFilteredRowModel, + RowSelectionState, +} from '@tanstack/react-table'; + +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table'; +import { Input } from '@/components/ui/input'; +import { Loader2, ArrowDown, ArrowUp, ArrowUpDown, Search } from 'lucide-react'; +import { Card, CardContent } from '@/components/ui/card'; +import { cn } from '@/lib/utils'; +import { DataTablePagination } from './DataTablePagination'; + +interface DataTableProps { + columns: ColumnDef[]; + data: TData[]; + pageCount?: number; + onPaginationChange?: (pageIndex: number, pageSize: number) => void; + onSortingChange?: (sorting: SortingState) => void; + onRowSelectionChange?: (selection: RowSelectionState) => void; + onRowDoubleClick?: (row: TData) => void; + getRowId?: (row: TData) => string; + manualPagination?: boolean; + manualSorting?: boolean; + /** Current selection state (controlled) */ + rowSelection?: RowSelectionState; + /** Current sorting state (controlled) */ + sorting?: SortingState; + /** Current pagination state (controlled) */ + pagination?: { + pageIndex: number; + pageSize: number; + }; + /** @deprecated Use rowSelection, sorting, pagination props directly */ + initialState?: { + pagination?: { + pageIndex: number; + pageSize: number; + }; + sorting?: SortingState; + rowSelection?: RowSelectionState; + }; + isLoading?: boolean; + searchPlaceholder?: string; + onSearchChange?: (value: string) => void; + /** Identifies which column to show as the title in mobile cards */ + titleColumnId?: string; +} + +export function DataTable({ + columns, + data, + pageCount, + onPaginationChange, + onSortingChange, + onRowSelectionChange, + onRowDoubleClick, + getRowId, + manualPagination = false, + manualSorting = false, + rowSelection: externalRowSelection, + sorting: externalSorting, + pagination: externalPagination, + initialState, + isLoading, + searchPlaceholder, + onSearchChange, + titleColumnId, +}: DataTableProps) { + const [internalSorting, setInternalSorting] = useState( + initialState?.sorting || [] + ); + const [columnFilters, setColumnFilters] = useState([]); + const [internalRowSelection, setInternalRowSelection] = + useState(initialState?.rowSelection || {}); + const [internalPagination, setInternalPagination] = useState( + initialState?.pagination || { + pageIndex: 0, + pageSize: 10, + } + ); + + const rowSelection = externalRowSelection ?? internalRowSelection; + const sorting = externalSorting ?? internalSorting; + const pagination = externalPagination ?? internalPagination; + + // eslint-disable-next-line react-hooks/incompatible-library + const table = useReactTable({ + data, + columns, + getRowId, + getCoreRowModel: getCoreRowModel(), + getPaginationRowModel: getPaginationRowModel(), + onSortingChange: (updater) => { + const next = typeof updater === 'function' ? updater(sorting) : updater; + if (externalSorting === undefined) setInternalSorting(next); + onSortingChange?.(next); + }, + getSortedRowModel: getSortedRowModel(), + onColumnFiltersChange: setColumnFilters, + getFilteredRowModel: getFilteredRowModel(), + onRowSelectionChange: (updater) => { + const next = + typeof updater === 'function' ? updater(rowSelection) : updater; + if (externalRowSelection === undefined) setInternalRowSelection(next); + onRowSelectionChange?.(next); + }, + onPaginationChange: (updater) => { + const next = + typeof updater === 'function' ? updater(pagination) : updater; + if (externalPagination === undefined) setInternalPagination(next); + onPaginationChange?.(next.pageIndex, next.pageSize); + }, + manualPagination, + manualSorting, + pageCount: pageCount, + state: { + sorting, + columnFilters, + rowSelection, + pagination, + }, + }); + + const resolvedTitleColumnId = useMemo(() => { + const visibleColumns = table.getVisibleFlatColumns(); + + if (titleColumnId && visibleColumns.some((c) => c.id === titleColumnId)) { + return titleColumnId; + } + + if (visibleColumns.some((c) => c.id === 'name')) return 'name'; + if (visibleColumns.some((c) => c.id === 'plan_name')) return 'plan_name'; + + return visibleColumns.find((c) => c.id !== 'select' && c.id !== 'actions') + ?.id; + }, [table, titleColumnId]); + + return ( +
+ {onSearchChange && ( +
+ + onSearchChange(event.target.value)} + className="pl-10 max-w-sm" + /> +
+ )} + + {/* Desktop Table View */} +
+ + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => { + const canSort = header.column.getCanSort(); + return ( + +
+ {header.isPlaceholder + ? null + : flexRender( + header.column.columnDef.header, + header.getContext() + )} + {canSort && ( +
+ {{ + asc: , + desc: , + }[header.column.getIsSorted() as string] ?? ( + + )} +
+ )} +
+
+ ); + })} +
+ ))} +
+ + {isLoading && !table.getRowModel().rows?.length ? ( + + +
+ + Loading... +
+
+
+ ) : table.getRowModel().rows?.length ? ( + <> + {isLoading && ( + + +
+
+ + + )} + {table.getRowModel().rows.map((row) => ( + onRowDoubleClick?.(row.original)} + className={cn( + onRowDoubleClick && + 'cursor-pointer select-none transition-colors hover:bg-muted/50', + isLoading && 'opacity-70 grayscale-[0.3]' + )} + > + {row.getVisibleCells().map((cell) => ( + + {flexRender( + cell.column.columnDef.cell, + cell.getContext() + )} + + ))} + + ))} + + ) : ( + + + No results. + + + )} + +
+
+ + {/* Mobile Row View (Super Clean) */} +
+ {isLoading && !table.getRowModel().rows?.length ? ( +
+ + Loading... +
+ ) : table.getRowModel().rows?.length ? ( + <> + {isLoading && ( +
+
+
+ )} + {table.getRowModel().rows.map((row) => ( + onRowDoubleClick?.(row.original)} + className={`transition-all duration-200 border-2 overflow-hidden shadow-sm ${ + row.getIsSelected() + ? 'border-blue-500 bg-blue-50/30 dark:bg-blue-900/10' + : 'border-gray-100 dark:border-gray-800 bg-white dark:bg-gray-900' + } ${onRowDoubleClick ? 'active:scale-[0.98]' : ''} ${ + isLoading ? 'opacity-70 grayscale-[0.3]' : '' + }`} + > + +
+
+ {(() => { + const selectCell = row + .getVisibleCells() + .find((c) => c.column.id === 'select'); + return selectCell + ? flexRender( + selectCell.column.columnDef.cell, + selectCell.getContext() + ) + : null; + })()} + +
onRowDoubleClick?.(row.original)} + > + {(() => { + const titleCell = row + .getVisibleCells() + .find((c) => c.column.id === resolvedTitleColumnId); + + return titleCell + ? flexRender( + titleCell.column.columnDef.cell, + titleCell.getContext() + ) + : 'Item'; + })()} +
+
+
+ {(() => { + const actionsCell = row + .getVisibleCells() + .find((c) => c.column.id === 'actions'); + return actionsCell + ? flexRender( + actionsCell.column.columnDef.cell, + actionsCell.getContext() + ) + : null; + })()} +
+
+ +
+ {row.getVisibleCells().map((cell) => { + const isHiddenOnMobile = ( + cell.column.columnDef as ColumnDef & { + meta?: { hideOnMobile?: boolean }; + } + ).meta?.hideOnMobile; + + if ( + cell.column.id === 'select' || + cell.column.id === 'actions' || + cell.column.id === resolvedTitleColumnId || + isHiddenOnMobile + ) + return null; + + const header = cell.column.columnDef.header; + const tableHeader = table + .getHeaderGroups() + .flatMap((g) => g.headers) + .find((h) => h.column.id === cell.column.id); + + return ( +
& { + meta?: { colSpan?: number }; + } + ).meta?.colSpan === 2 && 'col-span-2', + ( + cell.column.columnDef as ColumnDef< + TData, + TValue + > & { + meta?: { colSpan?: number }; + } + ).meta?.colSpan === 3 && 'col-span-3', + ( + cell.column.columnDef as ColumnDef< + TData, + TValue + > & { + meta?: { colSpan?: number }; + } + ).meta?.colSpan === 4 && 'col-span-4' + )} + > + + {tableHeader + ? flexRender(header, tableHeader.getContext()) + : typeof header === 'string' + ? header + : cell.column.id} + +
+ {flexRender( + cell.column.columnDef.cell, + cell.getContext() + )} +
+
+ ); + })} +
+
+
+ ))} + + ) : ( +
+ No results found. +
+ )} +
+ + +
+ ); +} diff --git a/SparkyFitnessFrontend/src/components/ui/DataTablePagination.tsx b/SparkyFitnessFrontend/src/components/ui/DataTablePagination.tsx new file mode 100644 index 000000000..6a2f2f7b1 --- /dev/null +++ b/SparkyFitnessFrontend/src/components/ui/DataTablePagination.tsx @@ -0,0 +1,112 @@ +import { type Table } from '@tanstack/react-table'; +import { + ChevronLeft, + ChevronRight, + ChevronsLeft, + ChevronsRight, +} from 'lucide-react'; + +import { Button } from '@/components/ui/button'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { useIsMobile } from '@/hooks/use-mobile'; + +interface DataTablePaginationProps { + table: Table; +} + +export function DataTablePagination({ + table, +}: DataTablePaginationProps) { + const isMobile = useIsMobile(); + return ( +
+
+ {table.getFilteredSelectedRowModel().rows.length} of{' '} + {table.getFilteredRowModel().rows.length} row(s) selected. +
+
+
+

Rows per page

+ +
+
+ {isMobile ? ( + + {table.getState().pagination.pageIndex + 1} /{' '} + {table.getPageCount()} + + ) : ( + + Page {table.getState().pagination.pageIndex + 1} of{' '} + {table.getPageCount()} + + )} +
+
+ + + + +
+
+
+ ); +} diff --git a/SparkyFitnessFrontend/src/components/ui/dropdown-menu.tsx b/SparkyFitnessFrontend/src/components/ui/dropdown-menu.tsx new file mode 100644 index 000000000..1738b829c --- /dev/null +++ b/SparkyFitnessFrontend/src/components/ui/dropdown-menu.tsx @@ -0,0 +1,198 @@ +import * as React from 'react'; +import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu'; +import { Check, ChevronRight, Circle } from 'lucide-react'; + +import { cn } from '@/lib/utils'; + +const DropdownMenu = DropdownMenuPrimitive.Root; + +const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger; + +const DropdownMenuGroup = DropdownMenuPrimitive.Group; + +const DropdownMenuPortal = DropdownMenuPrimitive.Portal; + +const DropdownMenuSub = DropdownMenuPrimitive.Sub; + +const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup; + +const DropdownMenuSubTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean; + } +>(({ className, inset, children, ...props }, ref) => ( + + {children} + + +)); +DropdownMenuSubTrigger.displayName = + DropdownMenuPrimitive.SubTrigger.displayName; + +const DropdownMenuSubContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DropdownMenuSubContent.displayName = + DropdownMenuPrimitive.SubContent.displayName; + +const DropdownMenuContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, sideOffset = 4, ...props }, ref) => ( + + + +)); +DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName; + +const DropdownMenuItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean; + } +>(({ className, inset, ...props }, ref) => ( + +)); +DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName; + +const DropdownMenuCheckboxItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, checked, ...props }, ref) => ( + + + + + + + {children} + +)); +DropdownMenuCheckboxItem.displayName = + DropdownMenuPrimitive.CheckboxItem.displayName; + +const DropdownMenuRadioItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + + + + {children} + +)); +DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName; + +const DropdownMenuLabel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean; + } +>(({ className, inset, ...props }, ref) => ( + +)); +DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName; + +const DropdownMenuSeparator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName; + +const DropdownMenuShortcut = ({ + className, + ...props +}: React.HTMLAttributes) => { + return ( + + ); +}; +DropdownMenuShortcut.displayName = 'DropdownMenuShortcut'; + +export { + DropdownMenu, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuCheckboxItem, + DropdownMenuRadioItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuGroup, + DropdownMenuPortal, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuRadioGroup, +}; diff --git a/SparkyFitnessFrontend/src/components/ui/pagination.tsx b/SparkyFitnessFrontend/src/components/ui/pagination.tsx deleted file mode 100644 index e624e14c9..000000000 --- a/SparkyFitnessFrontend/src/components/ui/pagination.tsx +++ /dev/null @@ -1,117 +0,0 @@ -import * as React from 'react'; -import { ChevronLeft, ChevronRight, MoreHorizontal } from 'lucide-react'; - -import { cn } from '@/lib/utils'; -import { type ButtonProps, buttonVariants } from '@/components/ui/button'; - -const Pagination = ({ className, ...props }: React.ComponentProps<'nav'>) => ( -
-
- {currentExercises.map((exercise) => ( - - updateExerciseShareStatus({ - id, - sharedWithPublic: !current, - }) + row.id} + onRowDoubleClick={(ex) => { + if (ex.user_id === user?.id) editForm.openEditDialog(ex); + }} + rowSelection={rowSelection} + onRowSelectionChange={setRowSelection} + onSortingChange={(sorting) => { + if (sorting.length > 0) { + const sort = sorting[0]; + if (sort) { + setSortOrder(`${sort.id}:${sort.desc ? 'desc' : 'asc'}`); } - /> - ))} -
+ } else { + setSortOrder('name:asc'); + } + }} + manualSorting + pagination={{ + pageIndex: currentPage - 1, + pageSize: itemsPerPage, + }} + sorting={[ + { + id: sortOrder.split(':')[0] || 'name', + desc: sortOrder.split(':')[1] === 'desc', + }, + ]} + columns={displayColumns} + data={currentExercises} + isLoading={!data} + manualPagination + pageCount={totalPages} + onPaginationChange={(pageIndex, pageSize) => { + if (pageSize !== itemsPerPage) { + setItemsPerPage(pageSize); + } else { + setCurrentPage(pageIndex + 1); + } + }} + />
- - {totalPages > 1 && ( -
- - - - - setCurrentPage(Math.max(1, currentPage - 1)) - } - className={ - currentPage === 1 - ? 'pointer-events-none opacity-50' - : 'cursor-pointer' - } - /> - - - {Array.from({ length: Math.min(5, totalPages) }, (_, i) => { - let pageNumber: number; - if (totalPages <= 5) { - pageNumber = i + 1; - } else if (currentPage <= 3) { - pageNumber = i + 1; - } else if (currentPage >= totalPages - 2) { - pageNumber = totalPages - 4 + i; - } else { - pageNumber = currentPage - 2 + i; - } - - return ( - - setCurrentPage(pageNumber)} - isActive={pageNumber === currentPage} - className="cursor-pointer" - > - {pageNumber} - - - ); - })} - - - - setCurrentPage(Math.min(totalPages, currentPage + 1)) - } - className={ - currentPage === totalPages - ? 'pointer-events-none opacity-50' - : 'cursor-pointer' - } - /> - - - -
- )} + { + clearSelection(); + setRowSelection({}); + }} + onDelete={() => setShowBulkDeleteDialog(true)} + onSelectAll={(checked) => { + if (checked) { + selectAll(editableExerciseIds); + // Sync with table + const newSelection: RowSelectionState = {}; + currentExercises.forEach((ex) => { + if (ex.user_id === user?.id) newSelection[ex.id] = true; + }); + setRowSelection(newSelection); + } else { + clearSelection(); + setRowSelection({}); + } + }} + /> + + + {/* Workout Presets Section */} - - - - {t( - 'exercise.databaseManager.workoutPresetsCardTitle', - 'Workout Presets' - )} - - - - - - + {/* Workout Plans Section */} - - - - {t( - 'exercise.databaseManager.workoutPlansCardTitle', - 'Workout Plans' - )} - - - - - - + {}} mode="database-manager" - onExerciseAdded={() => invalidateExercises()} /> - {/* Edit Exercise Dialog */} - - {deletionImpact && exerciseToDelete && ( + {showDeleteConfirmation && ( - {deletionImpact.isUsedByOthers ? ( - <> -

- {t( - 'exercise.databaseManager.deleteUsedByOthersDescription', - 'This exercise is used by other users. Deleting it will affect their data and is not allowed; it will be hidden instead.' - )} -

-
    -
  • - {t('exercise.databaseManager.deleteUsedByOthersEntries', { - exerciseEntriesCount: - deletionImpact.exerciseEntriesCount, - defaultValue: `${deletionImpact.exerciseEntriesCount} diary entries (across users)`, - })} -
  • -
- - ) : ( - <> -

- {t( - 'exercise.databaseManager.deletePermanentDescription', - 'This will permanently delete the exercise and all associated data for your account.' - )} -

-
    -
  • - {t('exercise.databaseManager.deletePermanentEntries', { - exerciseEntriesCount: - deletionImpact.exerciseEntriesCount, - defaultValue: `${deletionImpact.exerciseEntriesCount} diary entries`, - })} -
  • -
- - )} -
- } - warning={ - deletionImpact.isUsedByOthers - ? t( - 'exercise.databaseManager.deleteUsedByOthersWarning', - 'This exercise is used in workouts or diaries by other users. Deleting it will affect their data. It will be hidden instead.' - ) - : undefined - } - variant={ - deletionImpact.isUsedByOthers ? 'destructive' : 'destructive' - } - confirmLabel={ - !deletionImpact.isUsedByOthers && - deletionImpact.exerciseEntriesCount > 0 - ? t( - 'exercise.databaseManager.forceDeleteConfirmLabel', - 'Force Delete' - ) - : t('common.confirm', 'Confirm') + deletionImpact?.isUsedByOthers + ? t('exercise.databaseManager.deleteImpactDescription') + : t('exercise.databaseManager.deleteConfirmationDescription') } /> )} + + + {showSyncConfirmation && ( { const { t } = useTranslation(); const { user } = useAuth(); const { loggingLevel } = usePreferences(); + const isMobile = useIsMobile(); + const [isAddPlanDialogOpen, setIsAddPlanDialogOpen] = useState(false); const [isEditDialogOpen, setIsEditDialogOpen] = useState(false); const [selectedPlan, setSelectedPlan] = useState( @@ -49,6 +62,47 @@ const WorkoutPlansManager = () => { const { mutateAsync: deleteWorkoutPlanTemplate } = useDeleteWorkoutPlanTemplateMutation(); + const [rowSelection, setRowSelection] = useState({}); + + const selectedIdsFromTable = React.useMemo(() => { + const selected = new Set(); + Object.keys(rowSelection).forEach((index) => { + const plan = plans?.[parseInt(index)]; + if (plan) selected.add(plan.id); + }); + return selected; + }, [rowSelection, plans]); + + const { + selectedIds, + selectAll, + clearSelection, + selectedCount, + isEditMode, + toggleEditMode, + } = useBulkSelection(selectedIdsFromTable); + + const [showBulkDeleteDialog, setShowBulkDeleteDialog] = useState(false); + + const editablePlanIds = (plans || []).map((p) => p.id); + + const allSelected = + editablePlanIds.length > 0 && selectedCount === editablePlanIds.length; + + const handleBulkDeleteConfirm = async () => { + try { + await Promise.all( + Array.from(selectedIds).map((id) => deleteWorkoutPlanTemplate(id)) + ); + } catch (err) { + // Error handling is handled by mutation + } finally { + clearSelection(); + setRowSelection({}); + setShowBulkDeleteDialog(false); + } + }; + const handleCreatePlan = async ( newPlanData: Omit< WorkoutPlanTemplate, @@ -78,78 +132,298 @@ const WorkoutPlansManager = () => { } }; - const handleDeletePlan = async (planId: string) => { - if (!user?.id) return; - try { - await deleteWorkoutPlanTemplate(planId); - } catch (err) { - error(loggingLevel, 'Error deleting workout plan:', err); - } - }; + const handleDeletePlan = React.useCallback( + async (planId: string) => { + if (!user?.id) return; + try { + await deleteWorkoutPlanTemplate(planId); + } catch (err) { + error(loggingLevel, 'Error deleting workout plan:', err); + } + }, + [user?.id, deleteWorkoutPlanTemplate, loggingLevel] + ); - const handleTogglePlanActive = async (planId: string, isActive: boolean) => { - if (!user?.id) return; - try { - const planToUpdate = plans?.find((p) => p.id === planId); - if (!planToUpdate) { - toast({ - title: t('common.error'), - description: t( - 'workoutPlansManager.updateStatusError', - 'Could not find the plan to update.' - ), - variant: 'destructive', + const handleTogglePlanActive = React.useCallback( + async (planId: string, isActive: boolean) => { + if (!user?.id) return; + try { + const planToUpdate = plans?.find((p) => p.id === planId); + if (!planToUpdate) { + toast({ + title: t('common.error'), + description: t( + 'workoutPlansManager.updateStatusError', + 'Could not find the plan to update.' + ), + variant: 'destructive', + }); + return; + } + await updateWorkoutPlanTemplate({ + id: planId, + data: { ...planToUpdate, is_active: isActive }, }); - return; + } catch (err) { + error(loggingLevel, 'Error toggling workout plan active status:', err); } - await updateWorkoutPlanTemplate({ - id: planId, - data: { ...planToUpdate, is_active: isActive }, - }); - } catch (err) { - error(loggingLevel, 'Error toggling workout plan active status:', err); - } - }; + }, + [user?.id, plans, t, updateWorkoutPlanTemplate, loggingLevel] + ); + + const columns = React.useMemo[]>( + () => [ + { + id: 'select', + header: ({ table }) => ( + + table.toggleAllPageRowsSelected(!!value) + } + aria-label="Select all" + /> + ), + cell: ({ row }) => ( + row.toggleSelected(!!value)} + aria-label="Select row" + /> + ), + enableSorting: false, + enableHiding: false, + }, + { + accessorKey: 'plan_name', + header: t('workoutPlansManager.planName', 'Plan Name'), + cell: ({ row }) => { + const plan = row.original; + return ( +
+ {plan.plan_name} + {plan.description && ( + + {plan.description} + + )} +
+ ); + }, + }, + { + accessorKey: 'is_active', + header: t('workoutPlansManager.status', 'Status'), + cell: ({ row }) => { + const plan = row.original; + return ( + { + e.stopPropagation(); + handleTogglePlanActive(plan.id, !plan.is_active); + }} + > + {plan.is_active + ? t('workoutPlansManager.activeStatus') + : t('workoutPlansManager.inactiveStatus')} + + ); + }, + }, + { + id: 'dates', + header: t('workoutPlansManager.duration', 'Duration'), + cell: ({ row }) => { + const plan = row.original; + return ( +
+ + {new Date(plan.start_date!).toLocaleDateString()} + - + + {plan.end_date + ? new Date(plan.end_date).toLocaleDateString() + : t('workoutPlansManager.ongoingStatus', 'Ongoing')} + +
+ ); + }, + meta: { colSpan: 2 }, + }, + { + id: 'actions', + header: t('common.actions', 'Actions'), + cell: ({ row }) => { + const plan = row.original; + return ( + + + + + + + {t('common.actions', 'Actions')} + + { + setSelectedPlan(plan); + setIsEditDialogOpen(true); + }} + > + + {t('common.edit', 'Edit')} + + + handleTogglePlanActive(plan.id, !plan.is_active) + } + > + {plan.is_active ? ( + <> + + {t('workoutPlansManager.deactivate', 'Deactivate')} + + ) : ( + <> + + {t('workoutPlansManager.activate', 'Activate')} + + )} + + + handleDeletePlan(plan.id)} + > + + {t('common.delete', 'Delete')} + + + + ); + }, + }, + ], + [t, handleTogglePlanActive, handleDeletePlan] + ); if (!plans) return null; return (
-
- -
- - {plans.length === 0 ? ( -

- {t( - 'workoutPlansManager.noPlansFound', - 'No workout plans found. Create one to get started!' - )} -

- ) : ( -
- {plans.map((plan) => ( - { + + + + {t( + 'exercise.databaseManager.workoutPlansCardTitle', + 'Workout Plans' + )} + +
+ + +
+
+ + {plans.length === 0 ? ( +

+ {t( + 'workoutPlansManager.noPlansFound', + 'No workout plans found. Create one to get started!' + )} +

+ ) : ( + { setSelectedPlan(plan); setIsEditDialogOpen(true); }} - onDelete={() => handleDeletePlan(plan.id)} - onToggleActive={(active) => - handleTogglePlanActive(plan.id, active) + rowSelection={rowSelection} + onRowSelectionChange={setRowSelection} + columns={ + isEditMode ? columns : columns.filter((c) => c.id !== 'select') } + data={plans} /> - ))} -
- )} + )} + + + + { + clearSelection(); + setRowSelection({}); + }} + onDelete={() => setShowBulkDeleteDialog(true)} + onSelectAll={(checked) => { + if (checked) { + selectAll(editablePlanIds); + const newSelection: RowSelectionState = {}; + plans.forEach((_, index) => { + newSelection[index] = true; + }); + setRowSelection(newSelection); + } else { + clearSelection(); + setRowSelection({}); + } + }} + /> + + { ); }; -const WorkoutPlanItem: React.FC<{ - plan: WorkoutPlanTemplate; - onEdit: () => void; - onDelete: () => void; - onToggleActive: (active: boolean) => void; -}> = ({ plan, onEdit, onDelete, onToggleActive }) => { - const { t } = useTranslation(); - - return ( - -
- {/* Left accent stripe - color changes based on active status */} -
- -
-
-
-
- - {plan.plan_name} - - - {plan.is_active - ? t('workoutPlansManager.activeStatus').toUpperCase() - : t('workoutPlansManager.inactiveStatus').toUpperCase()} - -
- - {plan.description && ( -

- {plan.description} -

- )} - -
-
- - {new Date(plan.start_date!).toLocaleDateString()} - - - - {plan.end_date - ? new Date(plan.end_date).toLocaleDateString() - : t('workoutPlansManager.ongoingStatus', 'Ongoing')} - -
-
-
- -
- - - -
- -
-
- -

- {plan.is_active - ? t('workoutPlansManager.deactivatePlanTooltip') - : t('workoutPlansManager.activatePlanTooltip')} -

-
-
-
- - } - label={t('workoutPlansManager.editPlanTooltip')} - onClick={onEdit} - colorClass="hover:text-indigo-600 hover:bg-indigo-50 dark:hover:bg-indigo-950/50" - /> - } - label={t('workoutPlansManager.deletePlanTooltip')} - onClick={onDelete} - colorClass="hover:text-red-500 hover:bg-red-50 dark:hover:bg-red-950/50" - /> -
-
- - {/* Stats strip for a Plan */} -
- } - value={plan.is_active ? 'LIVE' : 'IDLE'} - label="Status" - color={ - plan.is_active - ? 'text-emerald-600 dark:text-emerald-400' - : 'text-gray-400' - } - /> - } - value={ - plan.end_date - ? new Date(plan.end_date).toLocaleDateString(undefined, { - month: 'short', - day: 'numeric', - }) - : '∞' - } - label="End Date" - color="text-blue-600 dark:text-blue-400" - /> -
-
-
- - ); -}; - -const ActionButton: React.FC<{ - icon: React.ReactNode; - label: string; - onClick: () => void; - colorClass: string; -}> = ({ icon, label, onClick, colorClass }) => ( - - - - - - -

{label}

-
-
-
-); - -const StatCell: React.FC<{ - icon: React.ReactNode; - value: string; - label: string; - color: string; -}> = ({ icon, value, label, color }) => ( -
- - {icon} - - {value} - - - - {label} - -
-); - export default WorkoutPlansManager; diff --git a/SparkyFitnessFrontend/src/pages/Exercises/WorkoutPresetsManager.tsx b/SparkyFitnessFrontend/src/pages/Exercises/WorkoutPresetsManager.tsx index 9f3b834fa..f1a0f06a1 100644 --- a/SparkyFitnessFrontend/src/pages/Exercises/WorkoutPresetsManager.tsx +++ b/SparkyFitnessFrontend/src/pages/Exercises/WorkoutPresetsManager.tsx @@ -1,25 +1,28 @@ -import type React from 'react'; -import { useState } from 'react'; +import React, { useState } from 'react'; import { useTranslation } from 'react-i18next'; import { formatDateToYYYYMMDD } from '@/lib/utils'; -import { Card, CardContent } from '@/components/ui/card'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Button } from '@/components/ui/button'; -import { - Tooltip, - TooltipContent, - TooltipProvider, - TooltipTrigger, -} from '@/components/ui/tooltip'; import { Plus, Edit, Trash2, CalendarPlus, Loader2, - ChevronDown, Layers, Dumbbell, + CheckSquare, + X, + MoreHorizontal, } from 'lucide-react'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, + DropdownMenuSeparator, + DropdownMenuLabel, +} from '@/components/ui/dropdown-menu'; import { toast } from '@/hooks/use-toast'; import { useAuth } from '@/hooks/useAuth'; import type { WorkoutPreset } from '@/types/workout'; @@ -33,9 +36,20 @@ import { import { useLogWorkoutPresetMutation } from '@/hooks/Exercises/useExerciseEntries'; import { usePreferences } from '@/contexts/PreferencesContext'; +import { useBulkSelection } from '@/hooks/useBulkSelection'; +import BulkActionToolbar from '@/components/BulkActionToolbar'; +import BulkDeleteDialog from '@/components/BulkDeleteDialog'; +import { Checkbox } from '@/components/ui/checkbox'; +import { DataTable } from '@/components/ui/DataTable'; +import { ColumnDef, RowSelectionState } from '@tanstack/react-table'; +import { useIsMobile } from '@/hooks/use-mobile'; +import { Badge } from '@/components/ui/badge'; + const WorkoutPresetsManager = () => { const { t } = useTranslation(); const { user } = useAuth(); + const isMobile = useIsMobile(); + const { weightUnit } = usePreferences(); const [isAddPresetDialogOpen, setIsAddPresetDialogOpen] = useState(false); const [isEditDialogOpen, setIsEditDialogOpen] = useState(false); @@ -51,7 +65,46 @@ const WorkoutPresetsManager = () => { const { mutateAsync: deletePreset } = useDeleteWorkoutPresetMutation(); const { mutateAsync: logWorkoutPreset } = useLogWorkoutPresetMutation(); - const presets = data?.pages.flatMap((page) => page.presets) ?? []; + const presets = React.useMemo( + () => data?.pages.flatMap((page) => page.presets) ?? [], + [data] + ); + + const [rowSelection, setRowSelection] = useState({}); + + const selectedIdsFromTable = React.useMemo(() => { + return new Set(Object.keys(rowSelection)); + }, [rowSelection]); + + const { + selectedIds, + selectAll, + clearSelection, + selectedCount, + isEditMode, + toggleEditMode, + } = useBulkSelection(selectedIdsFromTable); + + const [showBulkDeleteDialog, setShowBulkDeleteDialog] = useState(false); + + const editablePresetIds = presets + .filter((p) => p.user_id === user?.id) + .map((p) => p.id.toString()); + + const allSelected = + editablePresetIds.length > 0 && selectedCount === editablePresetIds.length; + + const handleBulkDeleteConfirm = async () => { + try { + await Promise.all(Array.from(selectedIds).map((id) => deletePreset(id))); + } catch (err) { + // Error handling is handled by mutation + } finally { + clearSelection(); + setRowSelection({}); + setShowBulkDeleteDialog(false); + } + }; const handleCreatePreset = async ( newPresetData: Omit< @@ -73,84 +126,314 @@ const WorkoutPresetsManager = () => { setSelectedPreset(null); }; - const handleDeletePreset = async (presetId: string) => { - await deletePreset(presetId); - }; + const handleDeletePreset = React.useCallback( + async (presetId: string) => { + await deletePreset(presetId); + }, + [deletePreset] + ); - const handleLogPresetToDiary = async (preset: WorkoutPreset) => { - try { - const today = formatDateToYYYYMMDD(new Date()); - await logWorkoutPreset({ presetId: preset.id, date: today }); - toast({ - title: t('common.success', 'Success'), - description: t('workoutPresetsManager.logSuccess', { - presetName: preset.name, - }), - }); - } catch (err) { - toast({ - title: t('common.error', 'Error'), - description: t('workoutPresetsManager.logError', { - presetName: preset.name, - }), - variant: 'destructive', - }); - } - }; + const handleLogPresetToDiary = React.useCallback( + async (preset: WorkoutPreset) => { + try { + const today = formatDateToYYYYMMDD(new Date()); + await logWorkoutPreset({ presetId: preset.id, date: today }); + toast({ + title: t('common.success', 'Success'), + description: t('workoutPresetsManager.logSuccess', { + presetName: preset.name, + }), + }); + } catch (err) { + toast({ + title: t('common.error', 'Error'), + description: t('workoutPresetsManager.logError', { + presetName: preset.name, + }), + variant: 'destructive', + }); + } + }, + [logWorkoutPreset, t] + ); + + const columns = React.useMemo[]>( + () => [ + { + id: 'select', + header: ({ table }) => ( + + table.toggleAllPageRowsSelected(!!value) + } + aria-label="Select all" + /> + ), + cell: ({ row }) => ( + row.toggleSelected(!!value)} + aria-label="Select row" + disabled={row.original.user_id !== user?.id} + /> + ), + enableSorting: false, + enableHiding: false, + }, + { + accessorKey: 'name', + header: t('workoutPresetsManager.name', 'Name'), + cell: ({ row }) => { + const preset = row.original; + return ( +
+ {preset.name} + {preset.description && ( + + {preset.description} + + )} +
+ ); + }, + }, + { + id: 'exercises', + header: t('workoutPresetsManager.exercises', 'Exercises'), + cell: ({ row }) => { + const count = row.original.exercises?.length || 0; + return ( + + {count} {count === 1 ? 'exercise' : 'exercises'} + + ); + }, + }, + { + id: 'stats', + header: t('workoutPresetsManager.stats', 'Stats'), + cell: ({ row }) => { + const preset = row.original; + const totalSets = + preset.exercises?.reduce( + (sum, ex) => sum + (ex.sets?.length || 0), + 0 + ) ?? 0; + const totalWeight = + preset.exercises?.reduce((sum, ex) => { + const vol = + ex.sets?.reduce( + (s, set) => s + (set.weight || 0) * (set.reps || 0), + 0 + ) ?? 0; + return sum + vol; + }, 0) ?? 0; + return ( +
+
+ + {totalSets} sets +
+
+ + + {totalWeight} + {weightUnit} + +
+
+ ); + }, + meta: { hideOnMobile: true, colSpan: 2 }, + }, + { + id: 'actions', + header: t('common.actions', 'Actions'), + cell: ({ row }) => { + const preset = row.original; + const isOwned = preset.user_id === user?.id; + return ( + + + + + + + {t('common.actions', 'Actions')} + + handleLogPresetToDiary(preset)} + > + + {t('workoutPresetsManager.logToDiary', 'Log to Diary')} + + { + setSelectedPreset(preset); + setIsEditDialogOpen(true); + }} + > + + {t('common.edit', 'Edit')} + + + handleDeletePreset(preset.id.toString())} + > + + {t('common.delete', 'Delete')} + + + + ); + }, + }, + ], + [t, user?.id, weightUnit, handleLogPresetToDiary, handleDeletePreset] + ); return (
-
- -
- - {presets.length === 0 && !isLoading ? ( -

- {t( - 'workoutPresetsManager.noPresetsFound', - 'No workout presets found.' - )} -

- ) : ( -
- {presets.map((preset) => ( - handleLogPresetToDiary(preset)} - onEdit={() => { - setSelectedPreset(preset); - setIsEditDialogOpen(true); + + + + {t( + 'exercise.databaseManager.workoutPresetsCardTitle', + 'Workout Presets' + )} + +
+ + +
+
+ + {presets.length === 0 && !isLoading ? ( +

+ {t( + 'workoutPresetsManager.noPresetsFound', + 'No workout presets found.' + )} +

+ ) : ( + row.id.toString()} + onRowDoubleClick={(preset) => { + if (preset.user_id === user?.id) { + setSelectedPreset(preset); + setIsEditDialogOpen(true); + } }} - onDelete={() => handleDeletePreset(preset.id.toString())} + rowSelection={rowSelection} + onRowSelectionChange={setRowSelection} + columns={ + isEditMode ? columns : columns.filter((c) => c.id !== 'select') + } + data={presets} + isLoading={isLoading} /> - ))} -
- )} + )} - {hasNextPage && ( -
- -
- )} + {hasNextPage && ( +
+ +
+ )} + + + + { + clearSelection(); + setRowSelection({}); + }} + onDelete={() => setShowBulkDeleteDialog(true)} + onSelectAll={(checked) => { + if (checked) { + selectAll(editablePresetIds); + // Sync with table + const newSelection: RowSelectionState = {}; + presets.forEach((p) => { + if (p.user_id === user?.id) newSelection[p.id.toString()] = true; + }); + setRowSelection(newSelection); + } else { + clearSelection(); + setRowSelection({}); + } + }} + /> + + { ); }; -const WorkoutPresetItem: React.FC<{ - preset: WorkoutPreset; - userId: string | undefined; - onLog: () => void; - onEdit: () => void; - onDelete: () => void; -}> = ({ preset, userId, onLog, onEdit, onDelete }) => { - const { t } = useTranslation(); - const [isExpanded, setIsExpanded] = useState(false); - const { weightUnit } = usePreferences(); - - const totalSets = - preset.exercises?.reduce((sum, ex) => sum + (ex.sets?.length || 0), 0) ?? 0; - const totalWeight = - preset.exercises?.reduce((sum, ex) => { - const exerciseVolume = - ex.sets?.reduce((setSum, set) => { - return setSum + (set.weight || 0) * (set.reps || 0); - }, 0) ?? 0; - return sum + exerciseVolume; - }, 0) ?? 0; - - return ( - -
-
- -
-
- - -
- } - label={t('workoutPresetsManager.logToDiary', 'Log to Diary')} - onClick={onLog} - colorClass="hover:text-green-600 hover:bg-green-50 dark:hover:bg-green-950/50" - /> - {preset.user_id === userId && ( - <> - } - label={t('common.edit', 'Edit')} - onClick={onEdit} - colorClass="hover:text-indigo-600 hover:bg-indigo-50 dark:hover:bg-indigo-950/50" - /> - } - label={t('common.delete', 'Delete')} - onClick={onDelete} - colorClass="hover:text-red-500 hover:bg-red-50 dark:hover:bg-red-950/50" - /> - - )} -
-
- -
- } - value={totalSets.toString()} - label={t('common.totalSets', 'Sets')} - color="text-blue-600 dark:text-blue-400" - /> - } - value={totalWeight.toString() + weightUnit} - label={t('common.totalWeight', 'Total Weight')} - color="text-indigo-600 dark:text-indigo-400" - /> -
-
-
- - {isExpanded && ( - -
- {preset.exercises?.map((ex, idx) => ( -
- - {ex.exercise_name} - - {ex.sets && ( - - {ex.sets.length} {t('common.sets', 'Sets').toUpperCase()} - - )} -
- ))} -
-
- )} - - ); -}; - -const ActionButton: React.FC<{ - icon: React.ReactNode; - label: string; - onClick: () => void; - colorClass: string; -}> = ({ icon, label, onClick, colorClass }) => ( - - - - - - -

{label}

-
-
-
-); - -const StatCell: React.FC<{ - icon: React.ReactNode; - value: string; - label: string; - color: string; -}> = ({ icon, value, label, color }) => ( -
- - {icon} - - {value} - - - - {label} - -
-); - export default WorkoutPresetsManager; diff --git a/SparkyFitnessFrontend/src/pages/Foods/DeleteFoodDialog.tsx b/SparkyFitnessFrontend/src/pages/Foods/DeleteFoodDialog.tsx index e48314562..dc71a522e 100644 --- a/SparkyFitnessFrontend/src/pages/Foods/DeleteFoodDialog.tsx +++ b/SparkyFitnessFrontend/src/pages/Foods/DeleteFoodDialog.tsx @@ -15,7 +15,7 @@ import { } from '@/components/ui/collapsible'; import type { Food, FoodDeletionImpact } from '@/types/food'; -interface PendingDeletion { +export interface PendingDeletion { food: Food; impact: FoodDeletionImpact; } diff --git a/SparkyFitnessFrontend/src/pages/Foods/FoodCard.tsx b/SparkyFitnessFrontend/src/pages/Foods/FoodCard.tsx deleted file mode 100644 index a0fa067f6..000000000 --- a/SparkyFitnessFrontend/src/pages/Foods/FoodCard.tsx +++ /dev/null @@ -1,227 +0,0 @@ -import { - Tooltip, - TooltipContent, - TooltipProvider, - TooltipTrigger, -} from '@/components/ui/tooltip'; -import { - getNutrientMetadata, - formatNutrientValue, -} from '@/utils/nutrientUtils'; - -import { Badge } from '@/components/ui/badge'; -import type { Food, FoodVariant } from '@/types/food'; -import { useTranslation } from 'react-i18next'; -import { Edit, Trash2, Share2, Lock } from 'lucide-react'; -import { Button } from '@/components/ui/button'; - -interface FoodCardProps { - food: Food; - isMobile: boolean; - visibleNutrients: string[]; - userId: string | undefined; - canEdit: (food: Food) => boolean; - onEdit: (food: Food) => void; - onDelete: (food: Food) => void; - onTogglePublic: (args: { foodId: string; currentState: boolean }) => void; -} - -export const FoodCard = ({ - food, - isMobile, - userId, - visibleNutrients, - canEdit, - onEdit, - onDelete, - onTogglePublic, -}: FoodCardProps) => { - const { t } = useTranslation(); - const getFoodSourceBadge = (food: Food) => { - if (!food.user_id) { - return ( - - {t('foodDatabaseManager.system', 'System')} - - ); - } - if (food.user_id === userId) { - return ( - - {t('foodDatabaseManager.private', 'Private')} - - ); - } - if (food.user_id !== userId && !food.shared_with_public) { - return ( - - {t('foodDatabaseManager.family', 'Family')} - - ); - } - return null; // No badge from getFoodSourceBadge if it's public and not owned by user - }; - - return ( -
-
-
-
- {food.name} - {food.brand && ( - - {food.brand} - - )} - {getFoodSourceBadge(food)} - {food.shared_with_public && ( - - - {t('foodDatabaseManager.public', 'Public')} - - )} -
-
- {/* Action Buttons */} -
- {/* Share/Lock Button */} - - - - - - -

- {canEdit(food) - ? food.shared_with_public - ? t('foodDatabaseManager.makePrivate', 'Make private') - : t( - 'foodDatabaseManager.shareWithPublic', - 'Share with public' - ) - : t('foodDatabaseManager.notEditable', 'Not editable')} -

-
-
-
- - {/* Edit Button */} - - - - - - -

- {canEdit(food) - ? t('foodDatabaseManager.editFood', 'Edit food') - : t('foodDatabaseManager.notEditable', 'Not editable')} -

-
-
-
- - {/* Delete Button */} - - - - - - -

- {canEdit(food) - ? t('foodDatabaseManager.deleteFood', 'Delete food') - : t('foodDatabaseManager.notEditable', 'Not editable')} -

-
-
-
-
-
- {t('foodDatabaseManager.perServing', { - servingSize: food.default_variant?.serving_size || 0, - servingUnit: food.default_variant?.serving_unit || '', - defaultValue: `Per ${food.default_variant?.serving_size || 0} ${food.default_variant?.serving_unit || ''}`, - })} -
-
-
-
- {visibleNutrients.map((nutrient) => { - const meta = getNutrientMetadata(nutrient); - const value = - (food.default_variant?.[ - nutrient as keyof FoodVariant - ] as number) || - (food.default_variant?.custom_nutrients?.[nutrient] as number) || - 0; - - return ( -
- - {formatNutrientValue(nutrient, value, [])} - - {meta.unit} - - - - {t(meta.label, meta.defaultLabel)} - -
- ); - })} -
-
-
- ); -}; diff --git a/SparkyFitnessFrontend/src/pages/Foods/Foods.tsx b/SparkyFitnessFrontend/src/pages/Foods/Foods.tsx index 0b01e3937..b7bddba47 100644 --- a/SparkyFitnessFrontend/src/pages/Foods/Foods.tsx +++ b/SparkyFitnessFrontend/src/pages/Foods/Foods.tsx @@ -17,34 +17,69 @@ import { SelectValue, } from '@/components/ui/select'; import { - Pagination, - PaginationContent, - PaginationItem, - PaginationLink, - PaginationNext, - PaginationPrevious, -} from '@/components/ui/pagination'; -import { Search, Plus, Filter } from 'lucide-react'; -import FoodSearchDialog from '@/components/FoodSearch/FoodSearchDialog'; + Search, + Plus, + Filter, + CheckSquare, + X, + Share2, + Lock, + Eye, + MoreHorizontal, + Edit, + Trash2, +} from 'lucide-react'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, + DropdownMenuSeparator, + DropdownMenuLabel, +} from '@/components/ui/dropdown-menu'; import FoodUnitSelector from '@/components/FoodUnitSelector'; import MealManagement from './MealManagement'; import MealPlanCalendar from './MealPlanCalendar'; import CustomFoodForm from '@/components/FoodSearch/CustomFoodForm'; -import { MealFilter } from '@/types/meal'; +import { Meal, MealFilter } from '@/types/meal'; import { useFoodDatabaseManager } from '@/hooks/Foods/useFoodDatabaseManager'; -import { FoodCard } from './FoodCard'; +import DeleteFoodDialog, { PendingDeletion } from './DeleteFoodDialog'; +import FoodSearchDialog from '@/components/FoodSearch/FoodSearchDialog'; + +import { useBulkSelection } from '@/hooks/useBulkSelection'; +import BulkActionToolbar from '@/components/BulkActionToolbar'; +import BulkDeleteDialog from '@/components/BulkDeleteDialog'; +import { DataTable } from '@/components/ui/DataTable'; +import { + ColumnDef, + RowSelectionState, + CellContext, +} from '@tanstack/react-table'; +import { Checkbox } from '@/components/ui/checkbox'; +import { + getNutrientMetadata, + formatNutrientValue, +} from '@/utils/nutrientUtils'; +import { Badge } from '@/components/ui/badge'; +import type { Food, FoodVariant } from '@/types/food'; +import { useIsMobile } from '@/hooks/use-mobile'; +import { cn } from '@/lib/utils'; +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useCustomNutrients } from '@/hooks/Foods/useCustomNutrients'; const FoodDatabaseManager = () => { const { t } = useTranslation(); + const isMobile = useIsMobile(); + const [viewingFood, setViewingFood] = useState(null); + const { data: customNutrients = [] } = useCustomNutrients(); + const { user, isAuthenticated, - isMobile, visibleNutrients, searchTerm, setSearchTerm, itemsPerPage, - setItemsPerPage, currentPage, foodFilter, setFoodFilter, @@ -63,18 +98,289 @@ const FoodDatabaseManager = () => { editingFood, showFoodUnitSelectorDialog, setShowFoodUnitSelectorDialog, - getPageNumbers, + handleFoodSelected, foodToAddToMeal, togglePublicSharing, canEdit, - getEmptyMessage, handlePageChange, handleEdit, handleSaveComplete, - handleFoodSelected, handleAddFoodToMeal, handleDeleteRequest, + deleteFood, } = useFoodDatabaseManager(); + + const [rowSelection, setRowSelection] = useState({}); + + // Sync rowSelection with useBulkSelection + // Since we use getRowId={(row) => row.id}, rowSelection keys are food IDs. + const selectedIdsFromTable = useMemo(() => { + return new Set(Object.keys(rowSelection)); + }, [rowSelection]); + + const { + selectedIds, + selectAll, + clearSelection, + selectedCount, + isEditMode, + toggleEditMode, + } = useBulkSelection(selectedIdsFromTable); + + // Clear table selection when exiting edit mode + useEffect(() => { + if (!isEditMode) { + setRowSelection({}); + } + }, [isEditMode]); + + const [showBulkDeleteDialog, setShowBulkDeleteDialog] = useState(false); + + const editableFoodIds = useMemo(() => { + return foodData?.foods.filter((f) => canEdit(f)).map((f) => f.id) || []; + }, [foodData, canEdit]); + + const allSelected = + editableFoodIds.length > 0 && selectedCount === editableFoodIds.length; + + const handleBulkDeleteConfirm = async () => { + try { + await Promise.all( + Array.from(selectedIds).map((id) => + deleteFood({ foodId: id, force: true }) + ) + ); + } catch (err) { + // Error handling is handled by mutation + } finally { + clearSelection(); + setRowSelection({}); + setShowBulkDeleteDialog(false); + } + }; + + const getFoodSourceBadge = useCallback( + (food: Food) => { + if (!food.user_id) { + return ( + + {t('foodDatabaseManager.system', 'System')} + + ); + } + if (food.user_id === user?.id && !food.shared_with_public) { + return ( + + {t('foodDatabaseManager.private', 'Private')} + + ); + } + return ( + + {t('foodDatabaseManager.family', 'Family')} + + ); + }, + [user?.id, t] + ); + + const columns = useMemo[]>( + () => [ + { + id: 'select', + header: ({ table }) => ( + + table.toggleAllPageRowsSelected(!!value) + } + aria-label="Select all" + /> + ), + cell: ({ row }) => ( + row.toggleSelected(!!value)} + aria-label="Select row" + disabled={!canEdit(row.original)} + /> + ), + enableSorting: false, + enableHiding: false, + }, + { + accessorKey: 'name', + id: 'name', + header: t('foodDatabaseManager.name', 'Name'), + enableSorting: true, + cell: ({ row }) => { + const food = row.original; + return ( +
+
+ + {food.name} + + {food.brand && ( + + {food.brand} + + )} + {getFoodSourceBadge(food)} + {food.shared_with_public && ( + + + {t('foodDatabaseManager.public', 'Public')} + + )} +
+ + {t('foodDatabaseManager.perServing', { + servingSize: food.default_variant?.serving_size || 0, + servingUnit: food.default_variant?.serving_unit || '', + defaultValue: `Per ${food.default_variant?.serving_size || 0} ${food.default_variant?.serving_unit || ''}`, + })} + +
+ ); + }, + }, + ...visibleNutrients.map((nutrient) => { + const meta = getNutrientMetadata(nutrient, customNutrients); + const isCustom = meta.group === 'custom'; + + return { + id: nutrient, + header: () => ( +
+ {t(meta.label, meta.defaultLabel)} + + ({meta.unit}) + +
+ ), + accessorFn: (row: Food) => { + if (isCustom) { + return row.default_variant?.custom_nutrients?.[nutrient] || 0; + } + return ( + (row.default_variant?.[ + nutrient as keyof FoodVariant + ] as number) || 0 + ); + }, + cell: (info: CellContext) => ( +
+ + {formatNutrientValue( + nutrient, + info.getValue() as number, + customNutrients + )} + +
+ ), + meta: { + hideOnMobile: false, + }, + // Sorting is disabled for dietary_fiber and custom nutrients as requested + enableSorting: !isCustom && nutrient !== 'dietary_fiber', + }; + }), + { + id: 'actions', + header: t('common.actions', 'Actions'), + cell: ({ row }) => { + const food = row.original; + const isEditable = canEdit(food); + return ( + + + + + + + {t('common.actions', 'Actions')} + + setViewingFood(food)}> + + {t('common.view', 'View details')} + + handleEdit(food)} + > + + {t('foodDatabaseManager.editFood', 'Edit food')} + + + togglePublicSharing({ + foodId: food.id, + currentState: food.shared_with_public || false, + }) + } + > + {food.shared_with_public ? ( + <> + + {t('foodDatabaseManager.makePrivate', 'Make private')} + + ) : ( + <> + + {t( + 'foodDatabaseManager.shareWithPublic', + 'Share with public' + )} + + )} + + + handleDeleteRequest(food)} + > + + {t('foodDatabaseManager.deleteFood', 'Delete food')} + + + + ); + }, + }, + ], + [ + visibleNutrients, + t, + canEdit, + handleEdit, + handleDeleteRequest, + togglePublicSharing, + getFoodSourceBadge, + customNutrients, + ] + ); + + const displayColumns = useMemo( + () => (isEditMode ? columns : columns.filter((c) => c.id !== 'select')), + [isEditMode, columns] + ); + if (!isAuthenticated) { return (
@@ -87,27 +393,15 @@ const FoodDatabaseManager = () => {
{/* Food Database Section */} - + {t('foodDatabaseManager.foodDatabase', 'Food Database')} - - {/* Controls in a single row: Search, Filter, Items per page, Add button */} + {/* Controls */}
- {/* Search box */}
{
-
- -
- {/* Sort by dropdown */} -
- - {t('foodDatabaseManager.sortBy', 'Sort by:')} - - -
- {/* Items per page selector */} -
- - {t('foodDatabaseManager.itemsPerPage', 'Items per page:')} - - + {isEditMode ? ( + isMobile ? ( + + ) : ( + t('common.cancel', 'Cancel') + ) + ) : isMobile ? ( + + ) : ( + t('common.select', 'Select') + )} + +
- {loading ? ( -
- {t('foodDatabaseManager.loadingFoods', 'Loading foods...')} -
- ) : ( - <> - {foodData?.foods.length === 0 ? ( -
- {getEmptyMessage()} -
- ) : ( -
- {foodData?.foods.map((food) => ( - - ))} -
- )} - - )} - {/* Pagination */} - {totalPages > 1 && ( - - - - - handlePageChange(Math.max(1, currentPage - 1)) - } - className={ - currentPage === 1 - ? 'pointer-events-none opacity-50' - : 'cursor-pointer' - } - /> - - - {getPageNumbers(currentPage, totalPages).map((pageNumber) => ( - - handlePageChange(pageNumber)} - isActive={currentPage === pageNumber} - className="cursor-pointer" - > - {pageNumber} - - - ))} - - - - handlePageChange(Math.min(totalPages, currentPage + 1)) - } - className={ - currentPage === totalPages - ? 'pointer-events-none opacity-50' - : 'cursor-pointer' - } - /> - - - - )} + row.id} + onRowDoubleClick={setViewingFood} + onSortingChange={(sorting) => { + if (sorting.length > 0) { + const sort = sorting[0]; + if (sort) { + setSortOrder(`${sort.id}:${sort.desc ? 'desc' : 'asc'}`); + } + } else { + setSortOrder('name:asc'); + } + }} + manualSorting + rowSelection={rowSelection} + onRowSelectionChange={setRowSelection} + pagination={{ + pageIndex: currentPage - 1, + pageSize: itemsPerPage, + }} + sorting={[ + { + id: sortOrder.split(':')[0] || 'name', + desc: sortOrder.split(':')[1] === 'desc', + }, + ]} + columns={displayColumns} + data={foodData?.foods || []} + isLoading={loading} + manualPagination + pageCount={totalPages} + onPaginationChange={(pageIndex, pageSize) => { + handlePageChange(pageIndex + 1, pageSize); + }} + />
+ { + clearSelection(); + setRowSelection({}); + }} + onDelete={() => setShowBulkDeleteDialog(true)} + onSelectAll={(checked) => { + if (checked) { + selectAll(editableFoodIds); + // Sync with table + const newSelection: RowSelectionState = {}; + foodData?.foods.forEach((food) => { + if (canEdit(food)) newSelection[food.id] = true; + }); + setRowSelection(newSelection); + } else { + clearSelection(); + setRowSelection({}); + } + }} + /> + + + {/* Meal Management Section */} @@ -329,103 +606,12 @@ const FoodDatabaseManager = () => { /> )} - {pendingDeletion && ( - - - - - {t('foodDatabaseManager.deleteFoodConfirmTitle', { - foodName: pendingDeletion.food.name, - defaultValue: `Delete ${pendingDeletion.food.name}?`, - })} - - - -
-

- {t('foodDatabaseManager.foodUsedIn', 'This food is used in:')} -

-
    -
  • - {t('foodDatabaseManager.diaryEntries', { - count: pendingDeletion.impact.foodEntriesCount, - defaultValue: `${pendingDeletion.impact.foodEntriesCount} diary entries`, - })} -
  • -
  • - {t('foodDatabaseManager.mealComponents', { - count: pendingDeletion.impact.mealFoodsCount, - defaultValue: `${pendingDeletion.impact.mealFoodsCount} meal components`, - })} -
  • -
  • - {t('foodDatabaseManager.mealPlanEntries', { - count: pendingDeletion.impact.mealPlansCount, - defaultValue: `${pendingDeletion.impact.mealPlansCount} meal plan entries`, - })} -
  • -
  • - {t('foodDatabaseManager.mealPlanTemplateEntries', { - count: - pendingDeletion.impact.mealPlanTemplateAssignmentsCount, - defaultValue: `${pendingDeletion.impact.mealPlanTemplateAssignmentsCount} meal plan template entries`, - })} -
  • -
- {pendingDeletion.impact.otherUserReferences > 0 && ( -
-

- {t('foodDatabaseManager.warning', 'Warning!')} -

-

- {t( - 'foodDatabaseManager.foodUsedByOtherUsersWarning', - 'This food is used by other users...' - )} -

-
- )} -
-
- - {pendingDeletion.impact.totalReferences === 0 ? ( - - ) : pendingDeletion.impact.otherUserReferences > 0 ? ( - - ) : ( - <> - - - - )} -
-
-
- )} - + handleFoodSelected(item, type) + } title={t( 'foodDatabaseManager.addFoodToDatabaseTitle', 'Add Food to Database' @@ -437,6 +623,89 @@ const FoodDatabaseManager = () => { hideDatabaseTab={true} hideMealTab={true} /> + + + + !open && setViewingFood(null)} + > + + + + {viewingFood?.name} + {viewingFood?.brand && ( + + {viewingFood.brand} + + )} + + + {viewingFood && getFoodSourceBadge(viewingFood)} +
+ {t('foodDatabaseManager.perServing', { + servingSize: viewingFood?.default_variant?.serving_size || 0, + servingUnit: viewingFood?.default_variant?.serving_unit || '', + })} +
+
+
+ +
+ {Array.from( + new Set([ + ...visibleNutrients, + ...Object.keys( + viewingFood?.default_variant?.custom_nutrients || {} + ), + ]) + ).map((nutrient) => { + const meta = getNutrientMetadata(nutrient, customNutrients); + const val = + (viewingFood?.default_variant?.[ + nutrient as keyof FoodVariant + ] as number) || + Number( + viewingFood?.default_variant?.custom_nutrients?.[nutrient] + ) || + 0; + + if (val === 0 && !visibleNutrients.includes(nutrient)) + return null; + + return ( +
+ + {t(meta.label, meta.defaultLabel)} + + + {formatNutrientValue(nutrient, val, customNutrients)} + + {meta.unit} + + +
+ ); + })} +
+ +
+ +
+
+
); }; diff --git a/SparkyFitnessFrontend/src/pages/Foods/MealManagement.tsx b/SparkyFitnessFrontend/src/pages/Foods/MealManagement.tsx index 1b5f956b2..58a0100bb 100644 --- a/SparkyFitnessFrontend/src/pages/Foods/MealManagement.tsx +++ b/SparkyFitnessFrontend/src/pages/Foods/MealManagement.tsx @@ -1,5 +1,4 @@ -import type React from 'react'; -import { useState } from 'react'; +import React, { useState } from 'react'; import { useTranslation } from 'react-i18next'; import { Button } from '@/components/ui/button'; import { @@ -9,15 +8,28 @@ import { DialogHeader, DialogTitle, } from '@/components/ui/dialog'; -import { - Tooltip, - TooltipContent, - TooltipProvider, - TooltipTrigger, -} from '@/components/ui/tooltip'; import { Input } from '@/components/ui/input'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; -import { Plus, Edit, Trash2, Eye, Filter, Share2, Lock } from 'lucide-react'; +import { + Plus, + Edit, + Trash2, + Eye, + Filter, + Share2, + Lock, + CheckSquare, + X, + MoreHorizontal, +} from 'lucide-react'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, + DropdownMenuSeparator, + DropdownMenuLabel, +} from '@/components/ui/dropdown-menu'; import { usePreferences } from '@/contexts/PreferencesContext'; import { error } from '@/utils/logging'; import type { Meal, MealFilter, MealFood, MealPayload } from '@/types/meal'; @@ -45,6 +57,18 @@ import { formatNutrientValue, } from '@/utils/nutrientUtils'; import { useMealInvalidation } from '@/hooks/useInvalidateKeys'; +import { useCustomNutrients } from '@/hooks/Foods/useCustomNutrients'; + +import { useBulkSelection } from '@/hooks/useBulkSelection'; +import BulkActionToolbar from '@/components/BulkActionToolbar'; +import BulkDeleteDialog from '@/components/BulkDeleteDialog'; +import { Checkbox } from '@/components/ui/checkbox'; +import { DataTable } from '@/components/ui/DataTable'; +import { + ColumnDef, + RowSelectionState, + CellContext, +} from '@tanstack/react-table'; // This component is now a standalone library for managing meal templates. // Interactions with the meal plan calendar are handled by the calendar itself. @@ -67,12 +91,50 @@ const MealManagement: React.FC = () => { const platform = isMobile ? 'mobile' : 'desktop'; const { nutrientDisplayPreferences, energyUnit, convertEnergy } = usePreferences(); + const { data: customNutrients = [] } = useCustomNutrients(); - const getEnergyUnitString = (unit: 'kcal' | 'kJ'): string => { - return unit === 'kcal' - ? t('common.kcalUnit', 'kcal') - : t('common.kJUnit', 'kJ'); - }; + const [rowSelection, setRowSelection] = useState({}); + + const { data: meals } = useMeals(filter); + + const filteredMeals = React.useMemo( + () => + meals + ? meals.filter((meal) => + meal.name.toLowerCase().includes(searchTerm.toLowerCase()) + ) + : [], + [meals, searchTerm] + ); + + const selectedIdsFromTable = React.useMemo(() => { + const selected = new Set(); + Object.keys(rowSelection).forEach((index) => { + const meal = filteredMeals[parseInt(index)]; + if (meal && meal.id) selected.add(meal.id); + }); + return selected; + }, [rowSelection, filteredMeals]); + + const { + selectedIds, + selectAll, + clearSelection, + selectedCount, + isEditMode, + toggleEditMode, + } = useBulkSelection(selectedIdsFromTable); + + const [showBulkDeleteDialog, setShowBulkDeleteDialog] = useState(false); + + const getEnergyUnitString = React.useCallback( + (unit: 'kcal' | 'kJ'): string => { + return unit === 'kcal' + ? t('common.kcalUnit', 'kcal') + : t('common.kJUnit', 'kJ'); + }, + [t] + ); const quickInfoPreferences = nutrientDisplayPreferences.find( @@ -82,25 +144,49 @@ const MealManagement: React.FC = () => { (p) => p.view_group === 'quick_info' && p.platform === 'desktop' ); - const visibleNutrients = quickInfoPreferences - ? quickInfoPreferences.visible_nutrients - : ['calories', 'protein', 'carbs', 'fat']; + const visibleNutrients = React.useMemo( + () => + quickInfoPreferences + ? quickInfoPreferences.visible_nutrients + : ['calories', 'protein', 'carbs', 'fat'], + [quickInfoPreferences] + ); - const { data: meals } = useMeals(filter); const { mutateAsync: deleteMeal } = useDeleteMealMutation(); const { mutateAsync: updateMeal } = useUpdateMealMutation(); const queryClient = useQueryClient(); const invalidateMeals = useMealInvalidation(); + const editableMealIds = (filteredMeals || []).map((m) => m.id!); + + const allSelected = + editableMealIds.length > 0 && selectedCount === editableMealIds.length; + + const handleBulkDeleteConfirm = async () => { + try { + await Promise.all( + Array.from(selectedIds).map((id) => + deleteMeal({ mealId: id, force: true }) + ) + ); + } catch (err) { + // Error handling is handled by mutation + } finally { + clearSelection(); + setRowSelection({}); + setShowBulkDeleteDialog(false); + } + }; + const handleCreateNewMeal = () => { setEditingMealId(undefined); setShowMealBuilderDialog(true); }; - const handleEditMeal = (mealId: string) => { + const handleEditMeal = React.useCallback((mealId: string) => { setEditingMealId(mealId); setShowMealBuilderDialog(true); - }; + }, []); const handleDeleteMeal = async (mealId: string, force: boolean = false) => { try { @@ -113,17 +199,20 @@ const MealManagement: React.FC = () => { } }; - const openDeleteConfirmation = async (mealId: string) => { - try { - const impact = await queryClient.fetchQuery( - mealDeletionImpactOptions(mealId) - ); - setDeletionImpact(impact); - setMealToDelete(mealId); - } catch (err) { - error(loggingLevel, 'Failed to get meal deletion impact:', err); - } - }; + const openDeleteConfirmation = React.useCallback( + async (mealId: string) => { + try { + const impact = await queryClient.fetchQuery( + mealDeletionImpactOptions(mealId) + ); + setDeletionImpact(impact); + setMealToDelete(mealId); + } catch (err) { + error(loggingLevel, 'Failed to get meal deletion impact:', err); + } + }, + [queryClient, loggingLevel] + ); const handleMealSave = () => { setShowMealBuilderDialog(false); @@ -134,108 +223,314 @@ const MealManagement: React.FC = () => { setShowMealBuilderDialog(false); }; - const handleViewDetails = async (meal: Meal) => { - try { - // Fetch full meal details including foods - const fullMeal = await queryClient.fetchQuery(mealViewOptions(meal.id)); - setViewingMeal(fullMeal); - } catch (err) { - error(loggingLevel, 'Failed to fetch meal details:', err); - } - }; + const handleViewDetails = React.useCallback( + async (meal: Meal) => { + try { + // Fetch full meal details including foods + const fullMeal = await queryClient.fetchQuery(mealViewOptions(meal.id)); + setViewingMeal(fullMeal); + } catch (err) { + error(loggingLevel, 'Failed to fetch meal details:', err); + } + }, + [queryClient, loggingLevel] + ); - const handleShareMeal = async (mealId: string) => { - try { - const mealToUpdate = await queryClient.fetchQuery( - mealViewOptions(mealId) - ); - if (!mealToUpdate) { - throw new Error('Meal not found.'); + const handleShareMeal = React.useCallback( + async (mealId: string) => { + try { + const mealToUpdate = await queryClient.fetchQuery( + mealViewOptions(mealId) + ); + if (!mealToUpdate) { + throw new Error('Meal not found.'); + } + const mealPayload: MealPayload = { + name: mealToUpdate.name, + description: mealToUpdate.description, + is_public: true, + foods: + mealToUpdate.foods?.map((food) => ({ + food_id: food.food_id, + food_name: food.food_name, + variant_id: food.variant_id, + quantity: food.quantity, + unit: food.unit, + calories: food.calories, + protein: food.protein, + carbs: food.carbs, + fat: food.fat, + serving_size: food.serving_size, + serving_unit: food.serving_unit, + })) || [], + }; + await updateMeal({ mealId, mealPayload }); + } catch (err) { + error(loggingLevel, 'Failed to share meal:', err); } - const mealPayload: MealPayload = { - name: mealToUpdate.name, - description: mealToUpdate.description, - is_public: true, - foods: - mealToUpdate.foods?.map((food) => ({ - food_id: food.food_id, - food_name: food.food_name, - variant_id: food.variant_id, - quantity: food.quantity, - unit: food.unit, - calories: food.calories, - protein: food.protein, - carbs: food.carbs, - fat: food.fat, - serving_size: food.serving_size, - serving_unit: food.serving_unit, - })) || [], - }; - await updateMeal({ mealId, mealPayload }); - } catch (err) { - error(loggingLevel, 'Failed to share meal:', err); - } - }; + }, + [queryClient, updateMeal, loggingLevel] + ); - const handleUnshareMeal = async (mealId: string) => { - try { - const mealToUpdate = await queryClient.fetchQuery( - mealViewOptions(mealId) - ); - if (!mealToUpdate) { - throw new Error('Meal not found.'); + const handleUnshareMeal = React.useCallback( + async (mealId: string) => { + try { + const mealToUpdate = await queryClient.fetchQuery( + mealViewOptions(mealId) + ); + if (!mealToUpdate) { + throw new Error('Meal not found.'); + } + const mealPayload: MealPayload = { + name: mealToUpdate.name, + description: mealToUpdate.description, + is_public: false, + foods: + mealToUpdate.foods?.map((food) => ({ + food_id: food.food_id, + food_name: food.food_name, + variant_id: food.variant_id, + quantity: food.quantity, + unit: food.unit, + calories: food.calories, + protein: food.protein, + carbs: food.carbs, + fat: food.fat, + serving_size: food.serving_size, + serving_unit: food.serving_unit, + })) || [], + }; + await updateMeal({ mealId, mealPayload }); + } catch (err) { + error(loggingLevel, 'Failed to unshare meal:', err); } - const mealPayload: MealPayload = { - name: mealToUpdate.name, - description: mealToUpdate.description, - is_public: false, - foods: - mealToUpdate.foods?.map((food) => ({ - food_id: food.food_id, - food_name: food.food_name, - variant_id: food.variant_id, - quantity: food.quantity, - unit: food.unit, - calories: food.calories, - protein: food.protein, - carbs: food.carbs, - fat: food.fat, - serving_size: food.serving_size, - serving_unit: food.serving_unit, - })) || [], - }; - await updateMeal({ mealId, mealPayload }); - } catch (err) { - error(loggingLevel, 'Failed to unshare meal:', err); - } - }; + }, + [queryClient, updateMeal, loggingLevel] + ); - const filteredMeals = meals - ? meals.filter((meal) => - meal.name.toLowerCase().includes(searchTerm.toLowerCase()) - ) - : []; + const columns = React.useMemo[]>( + () => [ + { + id: 'select', + header: ({ table }) => ( + + table.toggleAllPageRowsSelected(!!value) + } + aria-label="Select all" + /> + ), + cell: ({ row }) => ( + row.toggleSelected(!!value)} + aria-label="Select row" + /> + ), + enableSorting: false, + enableHiding: false, + }, + { + accessorKey: 'name', + header: t('mealManagement.name', 'Name'), + cell: ({ row }) => { + const meal = row.original; + return ( +
+
+ {meal.name} + {meal.is_public && ( + + + {t('mealManagement.public', 'Public')} + + )} +
+ + {meal.description || t('mealManagement.noDescription')} + +
+ ); + }, + }, + ...visibleNutrients.map((nutrient) => ({ + id: nutrient, + header: () => { + const meta = getNutrientMetadata(nutrient, customNutrients); + return ( +
+ {t(meta.label, meta.defaultLabel)} + + ( + {nutrient === 'calories' + ? getEnergyUnitString(energyUnit) + : meta.unit} + ) + +
+ ); + }, + accessorFn: (meal: Meal) => { + let total = 0; + meal.foods?.forEach((f) => { + const scale = f.quantity / (f.serving_size || 1); + let val = 0; + if ( + nutrient in f && + typeof f[nutrient as keyof typeof f] === 'number' + ) { + val = f[nutrient as keyof typeof f] as number; + } else if (f.custom_nutrients && nutrient in f.custom_nutrients) { + val = Number(f.custom_nutrients[nutrient]) || 0; + } + total += val * scale; + }); + return nutrient === 'calories' + ? Math.round(convertEnergy(total, 'kcal', energyUnit)) + : total; + }, + cell: (info: CellContext) => { + const meta = getNutrientMetadata(nutrient, customNutrients); + return ( + + {nutrient === 'calories' + ? (info.getValue() as number) + : formatNutrientValue( + nutrient, + info.getValue() as number, + customNutrients + )} + + ); + }, + meta: { + hideOnMobile: false, + }, + enableSorting: true, + })), + { + id: 'actions', + header: t('common.actions', 'Actions'), + cell: ({ row }) => { + const meal = row.original; + return ( + + + + + + + {t('common.actions', 'Actions')} + + handleViewDetails(meal)}> + + {t('mealManagement.viewMealDetails', 'View Details')} + + handleEditMeal(meal.id!)}> + + {t('mealManagement.editMeal', 'Edit Meal')} + + + meal.is_public + ? handleUnshareMeal(meal.id!) + : handleShareMeal(meal.id!) + } + > + {meal.is_public ? ( + <> + + {t('mealManagement.unshareMeal', 'Make Private')} + + ) : ( + <> + + {t('mealManagement.shareMeal', 'Share Public')} + + )} + + + openDeleteConfirmation(meal.id!)} + > + + {t('mealManagement.deleteMeal', 'Delete Meal')} + + + + ); + }, + }, + ], + [ + t, + visibleNutrients, + energyUnit, + convertEnergy, + handleEditMeal, + openDeleteConfirmation, + handleViewDetails, + handleUnshareMeal, + handleShareMeal, + getEnergyUnitString, + customNutrients, + ] + ); return ( - + <> {t('mealManagement.manageMeals', 'Meal Management')} - +
+ + +
@@ -245,14 +540,22 @@ const MealManagement: React.FC = () => { 'Search meals...' )} value={searchTerm} - onChange={(e) => setSearchTerm(e.target.value)} + onChange={(e) => { + setSearchTerm(e.target.value); + clearSelection(); + setRowSelection({}); + }} className="flex-1 min-w-[200px]" />