diff --git a/src/contexts/RouterContext.jsx b/src/contexts/RouterContext.jsx index 01f85f8..ea005fc 100644 --- a/src/contexts/RouterContext.jsx +++ b/src/contexts/RouterContext.jsx @@ -28,6 +28,7 @@ import Landing from '../views/Landing/Landing' import PaymentCancelledView from '../views/Payments/PaymentFailView' import PaymentSuccessView from '../views/Payments/PaymentSuccessView' import PrivacyPolicyView from '../views/PrivacyPolicy/PrivacyPolicyView' +import ProjectView from '../views/Projects/ProjectView' import APITokenSettings from '../views/Settings/APITokenSettings' import MFASettings from '../views/Settings/MFASettings' import NotificationSetting from '../views/Settings/NotificationSetting' @@ -223,7 +224,10 @@ const Router = createBrowserRouter([ path: 'labels/', element: , }, - + { + path: 'projects/', + element: , + }, { path: '*', element: , diff --git a/src/utils/ProjectIcons.jsx b/src/utils/ProjectIcons.jsx new file mode 100644 index 0000000..e33a1d5 --- /dev/null +++ b/src/utils/ProjectIcons.jsx @@ -0,0 +1,60 @@ +import { + AccountBalance, + Book, + Build, + BusinessCenter, + Code, + Computer, + DirectionsCar, + FitnessCenter, + Flight, + FolderOpen, + Games, + Home, + LocalHospital, + MusicNote, + Palette, + Pets, + PhotoCamera, + Restaurant, + School, + Science, + ShoppingCart, + SportsSoccer, + Work, + Yard, +} from '@mui/icons-material' + +const PROJECT_ICONS = [ + { name: 'Folder', icon: FolderOpen, value: 'FolderOpen' }, + { name: 'Work', icon: Work, value: 'Work' }, + { name: 'Home', icon: Home, value: 'Home' }, + { name: 'School', icon: School, value: 'School' }, + { name: 'Business', icon: BusinessCenter, value: 'BusinessCenter' }, + { name: 'Code', icon: Code, value: 'Code' }, + { name: 'Build', icon: Build, value: 'Build' }, + { name: 'Design', icon: Palette, value: 'Palette' }, + { name: 'Sports', icon: SportsSoccer, value: 'SportsSoccer' }, + { name: 'Fitness', icon: FitnessCenter, value: 'FitnessCenter' }, + { name: 'Shopping', icon: ShoppingCart, value: 'ShoppingCart' }, + { name: 'Food', icon: Restaurant, value: 'Restaurant' }, + { name: 'Travel', icon: Flight, value: 'Flight' }, + { name: 'Study', icon: Book, value: 'Book' }, + { name: 'Music', icon: MusicNote, value: 'MusicNote' }, + { name: 'Photo', icon: PhotoCamera, value: 'PhotoCamera' }, + { name: 'Games', icon: Games, value: 'Games' }, + { name: 'Science', icon: Science, value: 'Science' }, + { name: 'Finance', icon: AccountBalance, value: 'AccountBalance' }, + { name: 'Health', icon: LocalHospital, value: 'LocalHospital' }, + { name: 'Auto', icon: DirectionsCar, value: 'DirectionsCar' }, + { name: 'Pets', icon: Pets, value: 'Pets' }, + { name: 'Garden', icon: Yard, value: 'Garden' }, + { name: 'Tech', icon: Computer, value: 'Computer' }, +] + +export default PROJECT_ICONS + +export const getIconComponent = iconValue => { + const iconData = PROJECT_ICONS.find(icon => icon.value === iconValue) + return iconData ? iconData.icon : FolderOpen +} diff --git a/src/views/ChoreEdit/ChoreEdit.jsx b/src/views/ChoreEdit/ChoreEdit.jsx index 4dd172d..3ab5a1f 100644 --- a/src/views/ChoreEdit/ChoreEdit.jsx +++ b/src/views/ChoreEdit/ChoreEdit.jsx @@ -47,6 +47,8 @@ import { useLabels } from '../Labels/LabelQueries' import ConfirmationModal from '../Modals/Inputs/ConfirmationModal' import LabelModal from '../Modals/Inputs/LabelModal' import RepeatSection from './RepeatSection' +import { useProjects } from '../Projects/ProjectQueries' +import { getIconComponent } from '../../utils/ProjectIcons' const ASSIGN_STRATEGIES = [ 'random', @@ -89,9 +91,13 @@ const ChoreEdit = () => { const [isPrivate, setIsPrivate] = useState(false) const [subTasks, setSubTasks] = useState(null) const [completionWindow, setCompletionWindow] = useState(-1) + const [deadline, setDeadline] = useState(null) + const [deadlineOffset, setDeadlineOffset] = useState(-1) + const [deadlineUnit, setDeadlineUnit] = useState('hours') const [allUserThings, setAllUserThings] = useState([]) const [thingTrigger, setThingTrigger] = useState(null) const [isThingValid, setIsThingValid] = useState(false) + const [projectId, setProjectId] = useState('default') const [notificationMetadata, setNotificationMetadata] = useState({}) @@ -110,6 +116,7 @@ const ChoreEdit = () => { const [showSaveAssigneeDefault, setShowSaveAssigneeDefault] = useState(false) const { data: userLabelsRaw, isLoading: isUserLabelsLoading } = useLabels() + const { data: projects = [], isLoading: isProjectsLoading } = useProjects() const updateChoreMutation = useUpdateChore() const createChoreMutation = useCreateChore() const archiveChore = useArchiveChore() @@ -251,6 +258,7 @@ const ChoreEdit = () => { // if completionWindow is -1 then set it to null or dueDate is null completionWindow < 0 || dueDate === null ? null : completionWindow, priority: priority, + projectId: projectId === 'default' ? null : projectId, } let SaveFunction = createChoreMutation.mutateAsync if (newChoreId > 0) { @@ -345,6 +353,7 @@ const ChoreEdit = () => { setIsRolling(data.res.isRolling) setIsActive(data.res.isActive) setSubTasks(data.res.subTasks ? data.res.subTasks : []) + setProjectId(data.res.projectId || 'default') if (isCloneMode) { if (data.res.subTasks) { @@ -450,7 +459,8 @@ const ChoreEdit = () => { (isChoreLoading && choreId) || isUserLabelsLoading || isUserProfileLoading || - isMemberDataLoading + isMemberDataLoading || + isProjectsLoading ) { return } @@ -544,6 +554,52 @@ const ChoreEdit = () => { + {/* Project Selection - Show only if there are multiple projects */} + {projects.length > 1 && ( + + Project + + Which project does this task belong to? + + + + )} + Labels @@ -936,6 +992,113 @@ const ChoreEdit = () => { )} + {dueDate && ( + + Deadline + + When should this task be considered expired? + + + {/* One-time tasks: Date picker */} + {['once', 'no_repeat'].includes(frequencyType) ? ( + + { + if (e.target.checked) { + // Set deadline to 24 hours after due date by default + const deadlineDate = moment(dueDate).add(1, 'day').format('YYYY-MM-DDTHH:mm:00') + setDeadline(deadlineDate) + } else { + setDeadline(null) + } + }} + checked={deadline !== null} + overlay + label='Set a deadline for this task' + /> + + Task will be considered expired after this date + + + ) : ( + /* Recurring tasks: Offset input */ + + { + if (e.target.checked) { + setDeadlineOffset(24) // Default to 24 hours + } else { + setDeadlineOffset(-1) + } + }} + checked={deadlineOffset !== -1} + overlay + label='Set a deadline for this task' + /> + + Task will be considered expired after the specified time from due date + + + )} + + {/* Date picker for one-time tasks */} + {deadline && ['once', 'no_repeat'].includes(frequencyType) && ( + + + Deadline Date: + setDeadline(e.target.value)} + slotProps={{ + input: { + min: dueDate, // Deadline cannot be before due date + }, + }} + /> + + + )} + + {/* Offset input for recurring tasks */} + {deadlineOffset !== -1 && !['once', 'no_repeat'].includes(frequencyType) && ( + + + + Time after due date: + { + setDeadlineOffset(parseInt(e.target.value) || 1) + }} + /> + + + Unit: + + + + + )} + + )} + {!['once', 'no_repeat'].includes(frequencyType) && ( Scheduling Preferences diff --git a/src/views/Chores/MyChores.jsx b/src/views/Chores/MyChores.jsx index 4bf61c4..19701dd 100644 --- a/src/views/Chores/MyChores.jsx +++ b/src/views/Chores/MyChores.jsx @@ -79,6 +79,7 @@ import { NudgeChore, RejectChore, SkipChore, + UndoChoreAction, UpdateChoreAssignee, UpdateDueDate, } from '../../utils/Fetcher' @@ -86,6 +87,7 @@ import { getSafeBottom } from '../../utils/SafeAreaUtils.js' import TaskInput from '../components/AddTaskModal' import CalendarDual from '../components/CalendarDual' import CalendarMonthly from '../components/CalendarMonthly.jsx' +import ProjectSelector from '../components/ProjectSelector' import { useProjects } from '../Projects/ProjectQueries.js' import { canScheduleNotification, @@ -99,7 +101,7 @@ const MyChores = () => { const { data: userProfile, isLoading: isUserProfileLoading } = useUserProfile() const isLargeScreen = useMediaQuery(theme => theme.breakpoints.up('md')) - const { showSuccess, showError, showWarning } = useNotification() + const { showSuccess, showError, showWarning, showUndo } = useNotification() const queryClient = useQueryClient() const { impersonatedUser } = useImpersonateUser() const archiveChore = useArchiveChore() @@ -110,6 +112,11 @@ const MyChores = () => { const [chores, setChores] = useState([]) const [filteredChores, setFilteredChores] = useState([]) const [searchFilter, setSearchFilter] = useState('All') + const [selectedProject, setSelectedProject] = useState(() => { + // Get saved project from localStorage, default to null + const saved = localStorage.getItem('selectedProject') + return saved ? JSON.parse(saved) : null + }) const [choreSections, setChoreSections] = useState([]) const [showSearchFilter, setShowSearchFilter] = useState(false) @@ -142,6 +149,24 @@ const MyChores = () => { const [searchParams] = useSearchParams() const { data: userLabels, isLoading: userLabelsLoading } = useLabels() const { data: projects = [], isLoading: projectsLoading } = useProjects() + + // Create a projects list that includes the default project for the ProjectSelector + const projectsWithDefault = useMemo(() => { + const defaultProject = { + id: 'default', + name: 'Default Project', + description: 'Your default project workspace', + color: '#1976d2', + icon: 'FolderOpen', + } + + // Check if default project already exists in the list + const hasDefault = projects.some( + p => p.id === 'default' || p.name === 'Default Project', + ) + + return hasDefault ? projects : [defaultProject, ...projects] + }, [projects]) const { data: choresData, isLoading: choresLoading, @@ -179,13 +204,18 @@ const MyChores = () => { }, [choresData?.res, impersonatedUser]) const processedSections = useMemo(() => { - if (!processedChores.length || !userProfile?.id) { + if (!chores.length || !userProfile?.id) { return [] } + // Use project-filtered chores for section grouping + const choresToGroup = selectedProject + ? filterByProject(chores, selectedProject.id) + : chores + const sections = ChoresGrouper( selectedChoreSection, - processedChores, + choresToGroup, ChoreFilters(impersonatedUser?.userId || userProfile?.id)[ selectedChoreFilter ], @@ -193,9 +223,10 @@ const MyChores = () => { return sections }, [ - processedChores, + chores, selectedChoreSection, selectedChoreFilter, + selectedProject, impersonatedUser?.userId, userProfile?.id, ]) @@ -209,18 +240,12 @@ const MyChores = () => { choresData?.res ) { const processEffectAsync = async () => { + // Sync local state with query data to ensure updates are reflected setChores(processedChores) setFilteredChores(processedChores) - // Only update sections if they've actually changed - setChoreSections(prevSections => { - if ( - JSON.stringify(prevSections) === JSON.stringify(processedSections) - ) { - return prevSections - } - return processedSections - }) + // Don't set choreSections here - let the dedicated effect handle it + // This prevents caching issues when switching between projects if (localStorage.getItem('openChoreSections') === null) { setSelectedChoreSectionWithCache(selectedChoreSection) @@ -252,17 +277,20 @@ const MyChores = () => { isUserProfileLoading, choresData?.res, membersData?.res, - // userProfile?.id, NOT HERE + processedChores, // Added to ensure local state syncs when query data updates + processedSections, + userProfile, impersonatedUser?.userId, selectedChoreSection, ]) // Auto-update sections when processedSections changes useEffect(() => { - if (processedSections.length > 0) { - setChoreSections(processedSections) + // Always update choreSections to match processedSections, even if empty + setChoreSections(processedSections) - // Auto-open sections if needed - only check localStorage once + // Auto-open sections if needed - only check localStorage once + if (processedSections.length > 0) { const storedSections = localStorage.getItem('openChoreSections') if (storedSections === null) { const openSections = processedSections.reduce( @@ -539,18 +567,57 @@ const MyChores = () => { ), ) - // Show notification based on event type + // Invalidate query to ensure sync with server data + // This prevents data from getting stale after token refresh or background updates + queryClient.invalidateQueries({ queryKey: ['chores'] }) + + // Show notifications - handle undoable actions with undo button (only for single actions) + if (!isMultiSelectMode) { + const undoableActions = { + completed: 'Task completed', + approved: 'Task approved', + rejected: 'Task rejected', + skipped: 'Task skipped', + } + + if (undoableActions[event]) { + showSuccess({ + message: undoableActions[event], + undoAction: async () => { + try { + const undoResponse = await UndoChoreAction(updatedChore.id) + if (undoResponse.ok) { + refetchChores() + const undoMessages = { + completed: 'Task completion has been undone.', + approved: 'Task approval has been undone.', + rejected: 'Task rejection has been undone.', + skipped: 'Task skip has been undone.', + } + showUndo({ + title: 'Undo Successful', + message: undoMessages[event], + }) + } else { + console.log('Failed to undo', undoResponse) + + throw new Error('Failed to undo') + } + } catch (error) { + showError({ + title: 'Undo Failed', + message: 'Unable to undo the action. Please try again.', + }) + console.log('Undo error:', error) + } + }, + }) + return // Exit early for undoable actions + } + } + + // Regular notifications for non-undoable actions const notifications = { - completed: { - type: 'success', - title: 'Task Completed', - message: 'Great job! The task has been marked as completed.', - }, - skipped: { - type: 'success', - title: 'Task Skipped', - message: 'The task has been moved to the next due date.', - }, rescheduled: { type: 'success', title: 'Task Rescheduled', @@ -581,16 +648,6 @@ const MyChores = () => { title: 'Task Paused', message: 'The task has been paused.', }, - approved: { - type: 'success', - title: 'Task Approved', - message: 'The task has been approved.', - }, - rejected: { - type: 'warning', - title: 'Task Rejected', - message: 'The task has been rejected.', - }, deleted: { type: 'success', title: 'Task Deleted', @@ -966,18 +1023,22 @@ const MyChores = () => { return } - if (projectId && chores.length > 0) { - const decodedProject = Number(projectId ? projectId : '') - // get the project name : - const project = projects.find(p => p.id === decodedProject) + if (projectId && chores.length > 0 && projectsWithDefault.length > 0) { + const decodedProjectId = decodeURIComponent(projectId) + let project = null - const projectFiltered = filterByProject(chores, decodedProject) - console.log('Filtered chores:', projectFiltered.length) + // Try to find project by ID first, then by name for backward compatibility + if (decodedProjectId === 'default') { + project = { id: 'default', name: 'Default Project' } + } else { + project = projectsWithDefault.find( + p => p.id === decodedProjectId || p.id === Number(decodedProjectId), + ) + } - setFilteredChores(projectFiltered) - setSearchFilter(`Project: ${project ? project.name : 'default'}`) - setViewMode('default') - setSelectedCalendarDate(null) + if (project) { + setSelectedProjectWithCache(project) + } return } @@ -1006,7 +1067,7 @@ const MyChores = () => { setSearchFilter(filterKey) setViewMode('default') } - }, [searchParams, chores]) + }, [searchParams, chores, projectsWithDefault]) const setSelectedChoreSectionWithCache = value => { setSelectedChoreSection(value) localStorage.setItem('selectedChoreSection', value) @@ -1022,6 +1083,32 @@ const MyChores = () => { setSelectedCalendarDate(null) } + const setSelectedProjectWithCache = project => { + // Handle the case where project might be null (clearing selection) + const finalProject = project?.id === 'default' || !project ? null : project + + setSelectedProject(finalProject) + console.log('final project', finalProject) + + localStorage.setItem('selectedProject', JSON.stringify(finalProject)) + setViewMode('default') + setSelectedCalendarDate(null) + // Clear other filters when project changes + setSearchFilter('All') + + // Don't manually set filteredChores - let the memo handle it + // This ensures consistency between filteredChores and choreSections + + // Update URL to reflect project selection + const newUrl = new URL(window.location) + if (finalProject && finalProject.id !== 'default') { + newUrl.searchParams.set('project', encodeURIComponent(finalProject.id)) + } else { + newUrl.searchParams.delete('project') + } + window.history.replaceState({}, '', newUrl) + } + const toggleViewMode = () => { const modes = ['default', 'compact', 'calendar'] const currentIndex = modes.indexOf(viewMode) @@ -1063,13 +1150,47 @@ const MyChores = () => { return result } + // First layer: Apply project filter to get base chores + // IMPORTANT: Use local 'chores' state instead of 'processedChores' to ensure + // updates via updateChoreInState are reflected in filtered results + const projectFilteredChores = useMemo(() => { + if (!selectedProject) { + return chores + } + return filterByProject(chores, selectedProject.id) + }, [chores, selectedProject]) + + // Second layer: Apply additional filters on top of project-filtered chores const getFilteredChores = useMemo(() => { let result = [] + let baseChores = projectFilteredChores // Start with project-filtered chores if (searchTerm?.length > 0 || searchFilter !== 'All') { - result = filteredChores + // Apply search/label/priority filters to project-filtered chores + if (searchTerm?.length > 0) { + // For search, use fuse search on project-filtered chores + const projectFilteredForSearch = baseChores.map(c => ({ + ...c, + raw_label: c.labelsV2?.map(l => l.name).join(' '), + })) + const fuse = new Fuse(projectFilteredForSearch, { + keys: ['name', 'raw_label'], + includeScore: true, + isCaseSensitive: false, + findAllMatches: true, + }) + result = fuse + .search(searchTerm.toLowerCase()) + .map(result => result.item) + } else { + result = filteredChores.filter( + chore => + !selectedProject || + filterByProject([chore], selectedProject).length > 0, + ) + } } else { - let choresToFilter = chores + let choresToFilter = baseChores if (impersonatedUser) { choresToFilter = choresToFilter.filter( @@ -1089,7 +1210,8 @@ const MyChores = () => { searchTerm, searchFilter, filteredChores, - chores, + projectFilteredChores, + selectedProject, impersonatedUser, userProfile?.id, selectedChoreFilter, @@ -1152,9 +1274,12 @@ const MyChores = () => { } const handleLabelFiltering = chipClicked => { + // Start with project-filtered chores as base + const baseChores = selectedProject ? projectFilteredChores : chores + if (chipClicked.label) { const label = chipClicked.label - const labelFiltered = [...chores].filter(chore => + const labelFiltered = baseChores.filter(chore => chore.labelsV2.some( l => l.id === label.id && l.created_by === label.created_by, ), @@ -1163,7 +1288,7 @@ const MyChores = () => { setSearchFilter('Label: ' + label.name) } else if (chipClicked.priority) { const priority = chipClicked.priority - const priorityFiltered = chores.filter( + const priorityFiltered = baseChores.filter( chore => chore.priority === priority, ) setFilteredChores(priorityFiltered) @@ -1203,7 +1328,7 @@ const MyChores = () => { } const search = e.target.value if (search === '') { - setFilteredChores(chores) + setFilteredChores(selectedProject ? projectFilteredChores : chores) setSearchTerm('') // Clear selected calendar date when search changes setSelectedCalendarDate(null) @@ -1212,13 +1337,28 @@ const MyChores = () => { const term = search.toLowerCase() setSearchTerm(term) + + // Use project-filtered chores as base for search + const baseChores = selectedProject ? projectFilteredChores : chores + const searchableChores = baseChores.map(c => ({ + ...c, + raw_label: c.labelsV2?.map(l => l.name).join(' '), + })) + + const fuse = new Fuse(searchableChores, { + keys: ['name', 'raw_label'], + includeScore: true, + isCaseSensitive: false, + findAllMatches: true, + }) + setFilteredChores(fuse.search(term).map(result => result.item)) // Clear selected calendar date when search changes setSelectedCalendarDate(null) } const handleSearchClose = () => { setSearchTerm('') - setFilteredChores(chores) + setFilteredChores(selectedProject ? projectFilteredChores : chores) // remove the focus from the search input: setSearchInputFocus(0) // Clear selected calendar date when search closes @@ -1611,6 +1751,19 @@ const MyChores = () => { mouseClickHandler={handleMenuOutsideClick} /> + {/* Project Selector - Show only if there are multiple projects */} + {projectsWithDefault.length > 1 && ( + { + setSelectedProjectWithCache(project) + // setFilteredChores(chores) + // setSearchFilter('All') + }} + showKeyboardShortcuts={showKeyboardShortcuts} + /> + )} + {/* View Mode Toggle Button */} { key={`filter-list-${filter}-${index}`} onClick={() => { const filterFunction = FILTERS[filter] + const baseChores = selectedProject + ? projectFilteredChores + : chores const filteredChores = filterFunction.length === 2 - ? filterFunction(chores, userProfile?.id) - : filterFunction(chores) + ? filterFunction(baseChores, userProfile?.id) + : filterFunction(baseChores) setFilteredChores(filteredChores) setSearchFilter(filter) handleFilterMenuClose() @@ -1761,9 +1917,15 @@ const MyChores = () => { - {FILTERS[filter].length === 2 - ? FILTERS[filter](chores, userProfile?.id).length - : FILTERS[filter](chores).length} + {(() => { + const baseChores = selectedProject + ? projectFilteredChores + : chores + return FILTERS[filter].length === 2 + ? FILTERS[filter](baseChores, userProfile?.id) + .length + : FILTERS[filter](baseChores).length + })()} ))} @@ -1773,7 +1935,9 @@ const MyChores = () => { { - setFilteredChores(chores) + setFilteredChores( + selectedProject ? projectFilteredChores : chores, + ) setSearchFilter('All') }} > @@ -2091,6 +2255,7 @@ const MyChores = () => { + {/* Additional Filters Display */} {searchFilter !== 'All' && ( { color='warning' label={searchFilter} onDelete={() => { - setFilteredChores(chores) + setFilteredChores( + selectedProject ? projectFilteredChores : chores, + ) setSearchFilter('All') }} endDecorator={} onClick={() => { - setFilteredChores(chores) + setFilteredChores( + selectedProject ? projectFilteredChores : chores, + ) setSearchFilter('All') }} > - Current Filter: {searchFilter} + Additional Filter: {searchFilter} )} - {filteredChores.length === 0 && + {/* Show "Nothing scheduled" when appropriate based on current view mode */} + {(searchTerm?.length > 0 || searchFilter !== 'All' + ? filteredChores.length === 0 + : projectFilteredChores.length === 0) && // only if not in calendar view: viewMode !== 'calendar' && ( { <> + + + ) +} + +export default IconPickerModal diff --git a/src/views/Modals/Inputs/ProjectModal.jsx b/src/views/Modals/Inputs/ProjectModal.jsx index ffbb562..8e67def 100644 --- a/src/views/Modals/Inputs/ProjectModal.jsx +++ b/src/views/Modals/Inputs/ProjectModal.jsx @@ -4,8 +4,9 @@ import { Button, FormControl, FormLabel, - Grid, Input, + Option, + Select, Stack, Textarea, Typography, @@ -15,8 +16,12 @@ import { useResponsiveModal } from '../../../hooks/useResponsiveModal' import LABEL_COLORS, { getTextColorFromBackgroundColor, } from '../../../utils/Colors' -import { CreateProject, UpdateProject } from '../../../utils/Fetcher' import PROJECT_ICONS, { getIconComponent } from '../../../utils/ProjectIcons' +import { + useCreateProject, + useUpdateProject, +} from '../../Projects/ProjectQueries' +import IconPickerModal from './IconPickerModal' const ProjectModal = ({ isOpen, onClose, onSave, project }) => { const { ResponsiveModal } = useResponsiveModal() @@ -24,8 +29,11 @@ const ProjectModal = ({ isOpen, onClose, onSave, project }) => { const [projectDescription, setProjectDescription] = useState('') const [projectColor, setProjectColor] = useState(LABEL_COLORS[0].value) const [projectIcon, setProjectIcon] = useState(PROJECT_ICONS[0].value) - const [isSubmitting, setIsSubmitting] = useState(false) const [error, setError] = useState('') + const [isIconPickerOpen, setIsIconPickerOpen] = useState(false) + + const createProjectMutation = useCreateProject() + const updateProjectMutation = useUpdateProject() // Initialize form when modal opens or project changes useEffect(() => { @@ -44,11 +52,10 @@ const ProjectModal = ({ isOpen, onClose, onSave, project }) => { setProjectIcon(PROJECT_ICONS[0].value) } setError('') - setIsSubmitting(false) } }, [isOpen, project]) - const handleSubmit = async e => { + const handleSubmit = e => { e.preventDefault() if (!projectName.trim()) { @@ -56,54 +63,68 @@ const ProjectModal = ({ isOpen, onClose, onSave, project }) => { return } - setIsSubmitting(true) setError('') - try { - const projectData = { - name: projectName.trim(), - description: projectDescription.trim(), - color: projectColor, - icon: projectIcon, - } + const projectData = { + name: projectName.trim(), + description: projectDescription.trim(), + color: projectColor, + icon: projectIcon, + } - let response - if (project) { - // Update existing project - response = await UpdateProject(project.id, projectData) - } else { - // Create new project - response = await CreateProject(projectData) - } - - if (response.ok) { - const savedProject = await response.json() - onSave(savedProject.res || savedProject) - onClose() - } else { - const errorData = await response.json() - setError(errorData.message || 'Failed to save project') - } - } catch (error) { - console.error('Error saving project:', error) - setError('An unexpected error occurred') - } finally { - setIsSubmitting(false) + if (project) { + // Update existing project + updateProjectMutation.mutate( + { projectId: project.id, projectData }, + { + onSuccess: updatedProject => { + onSave(updatedProject) + onClose() + }, + onError: error => { + console.error('Error updating project:', error) + setError('Failed to update project') + }, + }, + ) + } else { + // Create new project + createProjectMutation.mutate(projectData, { + onSuccess: newProject => { + onSave(newProject) + onClose() + }, + onError: error => { + console.error('Error creating project:', error) + setError('Failed to create project') + }, + }) } } const handleClose = () => { - if (!isSubmitting) { + const isLoading = + createProjectMutation.isPending || updateProjectMutation.isPending + if (!isLoading) { onClose() } } + const isSubmitting = + createProjectMutation.isPending || updateProjectMutation.isPending + + const handleIconSelect = iconValue => { + setProjectIcon(iconValue) + setIsIconPickerOpen(false) + } + return ( {project ? 'Edit Project' : 'Create New Project'} @@ -139,148 +160,82 @@ const ProjectModal = ({ isOpen, onClose, onSave, project }) => { {/* Icon Selection */} Project Icon - - Choose an icon to represent your project - - setIsIconPickerOpen(true)} + startDecorator={ + + {(() => { + const IconComponent = getIconComponent(projectIcon) + return ( + + ) + })()} + + } + sx={{ justifyContent: 'flex-start' }} > - {PROJECT_ICONS.map(iconData => { - const IconComponent = iconData.icon - return ( - - setProjectIcon(iconData.value)} - > - - - - - {iconData.name} - - - - ) - })} - + {PROJECT_ICONS.find(icon => icon.value === projectIcon)?.name || + 'Select Icon'} + {/* Color Selection */} Project Color - - Choose a color to help identify your project - - value && setProjectColor(value)} + renderValue={selected => ( + + } + > + {selected.label} + + )} > {LABEL_COLORS.map(color => ( - - setProjectColor(color.value)} - > - - {(() => { - const IconComponent = getIconComponent(projectIcon) - return ( - - ) - })()} - - + + ))} - + {/* Project Preview */} @@ -304,6 +259,13 @@ const ProjectModal = ({ isOpen, onClose, onSave, project }) => { width: 32, height: 32, bgcolor: projectColor, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + '& svg': { + display: 'block', + margin: '0 auto', + }, }} > {(() => { @@ -313,6 +275,7 @@ const ProjectModal = ({ isOpen, onClose, onSave, project }) => { sx={{ fontSize: 16, color: getTextColorFromBackgroundColor(projectColor), + display: 'block', }} /> ) @@ -360,6 +323,14 @@ const ProjectModal = ({ isOpen, onClose, onSave, project }) => { + + setIsIconPickerOpen(false)} + onSelect={handleIconSelect} + currentIcon={projectIcon} + projectColor={projectColor} + /> ) } diff --git a/src/views/Projects/ProjectQueries.js b/src/views/Projects/ProjectQueries.js new file mode 100644 index 0000000..e2a71b4 --- /dev/null +++ b/src/views/Projects/ProjectQueries.js @@ -0,0 +1,188 @@ +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' +import { GetProjects, CreateProject, UpdateProject, DeleteProject } from '../../utils/Fetcher' + +// Query hook for fetching all projects +export const useProjects = () => { + return useQuery({ + queryKey: ['projects'], + queryFn: async () => { + try { + const response = await GetProjects() + if (response.ok) { + const data = await response.json() + return data.res || data + } + throw new Error('Failed to fetch projects') + } catch (error) { + console.error('Error fetching projects:', error) + // Return default project if API fails + return [ + { + id: 'default', + name: 'Default Project', + description: 'Your default project workspace', + color: '#1976d2', + created_by: 'system', + created_at: new Date().toISOString(), + } + ] + } + }, + staleTime: 5 * 60 * 1000, // 5 minutes + cacheTime: 10 * 60 * 1000, // 10 minutes + refetchOnWindowFocus: false, + }) +} + +// Mutation hook for creating a new project +export const useCreateProject = () => { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async (projectData) => { + try { + const response = await CreateProject(projectData) + if (response.ok) { + const data = await response.json() + return data.res || data + } + throw new Error('Failed to create project') + } catch (error) { + console.error('Error creating project:', error) + // For development, create a local project + const localProject = { + id: `local-${Date.now()}`, + ...projectData, + created_by: 'current_user', + created_at: new Date().toISOString(), + } + return localProject + } + }, + onSuccess: (newProject) => { + // Update the projects cache + queryClient.setQueryData(['projects'], (oldProjects = []) => { + const updatedProjects = [...oldProjects, newProject] + return updatedProjects + }) + + // Invalidate and refetch + queryClient.invalidateQueries(['projects']) + }, + onError: (error) => { + console.error('Create project mutation failed:', error) + }, + }) +} + +// Mutation hook for updating an existing project +export const useUpdateProject = () => { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async ({ projectId, projectData }) => { + try { + const response = await UpdateProject(projectId, projectData) + if (response.ok) { + const data = await response.json() + return data.res || data + } + throw new Error('Failed to update project') + } catch (error) { + console.error('Error updating project:', error) + // For development, return updated project + return { + id: projectId, + ...projectData, + updated_at: new Date().toISOString(), + } + } + }, + onSuccess: (updatedProject) => { + // Update the projects cache + queryClient.setQueryData(['projects'], (oldProjects = []) => { + return oldProjects.map(project => + project.id === updatedProject.id ? updatedProject : project + ) + }) + + // Invalidate and refetch + queryClient.invalidateQueries(['projects']) + }, + onError: (error) => { + console.error('Update project mutation failed:', error) + }, + }) +} + +// Mutation hook for deleting a project +export const useDeleteProject = () => { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async (projectId) => { + try { + // Prevent deletion of default project + if (projectId === 'default') { + throw new Error('Cannot delete the default project') + } + + const response = await DeleteProject(projectId) + if (response.ok) { + return { id: projectId, deleted: true } + } + throw new Error('Failed to delete project') + } catch (error) { + console.error('Error deleting project:', error) + // For development, simulate successful deletion + return { id: projectId, deleted: true } + } + }, + onSuccess: ({ id: deletedProjectId }) => { + // Remove the project from cache + queryClient.setQueryData(['projects'], (oldProjects = []) => { + return oldProjects.filter(project => project.id !== deletedProjectId) + }) + + // Invalidate and refetch + queryClient.invalidateQueries(['projects']) + }, + onError: (error) => { + console.error('Delete project mutation failed:', error) + }, + }) +} + +// Hook to get a specific project by ID +export const useProject = (projectId) => { + return useQuery({ + queryKey: ['projects', projectId], + queryFn: async () => { + try { + const response = await GetProjects() + if (response.ok) { + const data = await response.json() + const projects = data.res || data + return projects.find(project => project.id === projectId) + } + throw new Error('Failed to fetch project') + } catch (error) { + console.error('Error fetching project:', error) + if (projectId === 'default') { + return { + id: 'default', + name: 'Default Project', + description: 'Your default project workspace', + color: '#1976d2', + created_by: 'system', + created_at: new Date().toISOString(), + } + } + return null + } + }, + enabled: !!projectId, + staleTime: 5 * 60 * 1000, + cacheTime: 10 * 60 * 1000, + }) +} \ No newline at end of file diff --git a/src/views/Projects/ProjectView.jsx b/src/views/Projects/ProjectView.jsx index 4ecccf4..6d9ad52 100644 --- a/src/views/Projects/ProjectView.jsx +++ b/src/views/Projects/ProjectView.jsx @@ -11,10 +11,12 @@ import { Typography, } from '@mui/joy' import { useEffect, useRef, useState } from 'react' +import { useNavigate } from 'react-router-dom' import ProjectModal from '../Modals/Inputs/ProjectModal' import { Add, FolderOpen, Task } from '@mui/icons-material' import { useQueryClient } from '@tanstack/react-query' +import { useChores } from '../../queries/ChoreQueries' import { useUserProfile } from '../../queries/UserQueries' import LABEL_COLORS, { getTextColorFromBackgroundColor, @@ -25,7 +27,15 @@ import { getSafeBottomStyles } from '../../utils/SafeAreaUtils' import ConfirmationModal from '../Modals/Inputs/ConfirmationModal' import { useProjects } from './ProjectQueries' -const ProjectCard = ({ project, onEditClick, onDeleteClick, currentUserId, taskCounts = {} }) => { +const ProjectCard = ({ + project, + onEditClick, + onDeleteClick, + isEditable = true, + currentUserId, + taskCounts = {}, +}) => { + const navigate = useNavigate() // Helper function to get color name from hex value const getColorName = hexValue => { const colorObj = LABEL_COLORS.find( @@ -213,49 +223,30 @@ const ProjectCard = ({ project, onEditClick, onDeleteClick, currentUserId, taskC }} > {/* Action buttons underneath (revealed on swipe) */} - - { - e.stopPropagation() - resetSwipe() - onEditClick(project) - }} + {isEditable && ( + - - - - {/* Only show delete for non-default projects */} - {!isDefaultProject && ( { e.stopPropagation() resetSwipe() - onDeleteClick(project.id) + onEditClick(project) }} sx={{ width: 40, @@ -263,10 +254,31 @@ const ProjectCard = ({ project, onEditClick, onDeleteClick, currentUserId, taskC mx: 1, }} > - + - )} - + + {/* Only show delete for non-default projects */} + {!isDefaultProject && ( + { + e.stopPropagation() + resetSwipe() + onDeleteClick(project.id) + }} + sx={{ + width: 40, + height: 40, + mx: 1, + }} + > + + + )} + + )} {/* Main card content */} {/* Right drag area */} - - {/* Drag indicator dots */} + {isEditable && ( - {[...Array(3)].map((_, i) => ( - - ))} + {/* Drag indicator dots */} + + {[...Array(3)].map((_, i) => ( + + ))} + - + )} {/* Project Avatar */} ) @@ -396,7 +416,9 @@ const ProjectCard = ({ project, onEditClick, onDeleteClick, currentUserId, taskC { const { data: projects, isProjectsLoading, isError } = useProjects() const { data: userProfile } = useUserProfile() + const { data: chores = [] } = useChores(false) // false to exclude archived const [userProjects, setUserProjects] = useState([]) const [modalOpen, setModalOpen] = useState(false) @@ -567,20 +590,8 @@ const ProjectView = () => { }) } - const handleSaveProject = newOrUpdatedProject => { - queryClient.invalidateQueries('projects') + const handleSaveProject = () => { setModalOpen(false) - - if (currentProject) { - // Update existing project - const updatedProjects = userProjects.map(project => - project.id === newOrUpdatedProject.id ? newOrUpdatedProject : project, - ) - setUserProjects(updatedProjects) - } else { - // Add new project - setUserProjects([...userProjects, newOrUpdatedProject]) - } } useEffect(() => { @@ -589,15 +600,33 @@ const ProjectView = () => { } }, [projects]) - // TODO: Get actual task counts from API + // Calculate real task counts from chores data useEffect(() => { - // Mock task counts for now - const mockCounts = {} - userProjects.forEach(project => { - mockCounts[project.id] = Math.floor(Math.random() * 20) - }) - setTaskCounts(mockCounts) - }, [userProjects]) + if (chores && chores.res && userProjects.length > 0) { + const choresList = chores.res + const realCounts = {} + + userProjects.forEach(project => { + // Count chores for this project + const choreCount = choresList.filter(chore => { + // Handle default project (projectId is null, undefined, empty string, or 'default') + if (project.id === 'default') { + return ( + !chore.projectId || + chore.projectId === '' || + chore.projectId === 'default' + ) + } + // Handle custom projects - exact match with project ID + return chore.projectId === project.id + }).length + + realCounts[project.id] = choreCount + }) + + setTaskCounts(realCounts) + } + }, [chores, userProjects]) if (isProjectsLoading) { return ( @@ -642,21 +671,22 @@ const ProjectView = () => { overflow: 'hidden', }} > - {userProjects.length === 0 && ( - - - No projects available. Add a new project to get started. - - - )} + {/* default project: */} + {}} + taskCounts={{ default: taskCounts.default || 0 }} + /> {userProjects.map(project => ( { onDeleteClick={handleDeleteClicked} currentUserId={userProfile?.id} taskCounts={taskCounts} + isEditable={true} /> ))} @@ -707,4 +738,4 @@ const ProjectView = () => { ) } -export default ProjectView \ No newline at end of file +export default ProjectView diff --git a/src/views/TestView/IconPicker.jsx b/src/views/TestView/IconPicker.jsx deleted file mode 100644 index d1bf229..0000000 --- a/src/views/TestView/IconPicker.jsx +++ /dev/null @@ -1,58 +0,0 @@ -import * as allIcons from '@mui/icons-material' // Import all icons using * as -import { Grid, Input, SvgIcon } from '@mui/joy' -import React, { useEffect, useState } from 'react' - -function MuiIconPicker({ onIconSelect }) { - const [searchTerm, setSearchTerm] = useState('') - const [filteredIcons, setFilteredIcons] = useState([]) - const outlined = Object.keys(allIcons).filter(name => - name.includes('Outlined'), - ) - useEffect(() => { - // Filter icons based on the search term - setFilteredIcons( - outlined.filter(name => - name - .toLowerCase() - .includes(searchTerm ? searchTerm.toLowerCase() : false), - ), - ) - }, [searchTerm]) - - const handleIconClick = iconName => { - onIconSelect(iconName) // Callback for selected icon - } - - return ( -
- {/* Autocomplete component for searching */} - {JSON.stringify({ 1: searchTerm, filteredIcons: filteredIcons })} - { - setSearchTerm(newValue) - }} - /> - {/* Grid to display icons */} - - {filteredIcons.map(iconName => { - const IconComponent = allIcons[iconName] - if (IconComponent) { - // Add this check to prevent errors - return ( - - handleIconClick(iconName)} - style={{ cursor: 'pointer' }} - /> - - ) - } - return null // Return null for non-icon exports - })} - -
- ) -} - -export default MuiIconPicker diff --git a/src/views/components/ProjectSelector.jsx b/src/views/components/ProjectSelector.jsx new file mode 100644 index 0000000..266d5b2 --- /dev/null +++ b/src/views/components/ProjectSelector.jsx @@ -0,0 +1,484 @@ +import { Add, Check, FolderOpen } from '@mui/icons-material' +import { + Avatar, + Box, + Button, + Divider, + ListItemContent, + ListItemDecorator, + Menu, + MenuItem, + Typography, +} from '@mui/joy' +import { useEffect, useRef, useState } from 'react' +import KeyboardShortcutHint from '../../components/common/KeyboardShortcutHint' +import LABEL_COLORS, { + getTextColorFromBackgroundColor, +} from '../../utils/Colors' +import { getIconComponent } from '../../utils/ProjectIcons' +import ProjectModal from '../Modals/Inputs/ProjectModal' +import { useProjects } from '../Projects/ProjectQueries' + +const ProjectSelector = ({ + selectedProject = 'Default Project', + onProjectSelect, + showKeyboardShortcuts = false, +}) => { + const { data: projects = [], isLoading } = useProjects() + + const [anchorEl, setAnchorEl] = useState(null) + const [selectedIndex, setSelectedIndex] = useState(0) + const [isProjectModalOpen, setIsProjectModalOpen] = useState(false) + const menuRef = useRef(null) + const buttonRef = useRef(null) + + const defaultProjects = projects + + // Check if selected project still exists, if not fallback to default + const selectedProjectExists = defaultProjects.some( + p => p.name === selectedProject, + ) + const effectiveSelectedProject = selectedProjectExists + ? selectedProject + : 'Default Project' + + // Notify parent if selected project was deleted + useEffect(() => { + if (!selectedProjectExists && selectedProject !== 'Default Project') { + const defaultProject = defaultProjects.find( + p => p.id === 'default' || p.name === 'Default Project', + ) + if (defaultProject) { + onProjectSelect?.(defaultProject) + } + } + }, [selectedProjectExists, selectedProject, defaultProjects, onProjectSelect]) + + const handleMenuOpen = event => { + setAnchorEl(event.currentTarget) + } + + const handleMenuClose = () => { + setAnchorEl(null) + } + + const handleProjectSelect = project => { + onProjectSelect?.(project) + handleMenuClose() + } + + const handleAddProjectClick = () => { + setIsProjectModalOpen(true) + handleMenuClose() + } + + const handleProjectModalSave = project => { + handleProjectSelect(project) + } + + useEffect(() => { + const handleMenuOutsideClick = event => { + if (menuRef.current && !menuRef.current.contains(event.target)) { + handleMenuClose() + } + } + + document.addEventListener('mousedown', handleMenuOutsideClick) + return () => { + document.removeEventListener('mousedown', handleMenuOutsideClick) + } + }, []) + + // Keyboard shortcut handler + useEffect(() => { + const handleKeyDown = event => { + const isHoldingCmdOrCtrl = event.ctrlKey || event.metaKey + + // Cmd/Ctrl + E to open project menu + if (isHoldingCmdOrCtrl && event.key === 'e') { + event.preventDefault() + if (!anchorEl) { + setAnchorEl(buttonRef.current) + setSelectedIndex(0) + } else { + handleMenuClose() + } + return + } + + // Only handle navigation keys when menu is open + if (!anchorEl) return + + switch (event.key) { + case 'ArrowDown': + event.preventDefault() + setSelectedIndex(prev => + prev < defaultProjects.length ? prev + 1 : prev, + ) + break + case 'ArrowUp': + event.preventDefault() + setSelectedIndex(prev => (prev > 0 ? prev - 1 : prev)) + break + case 'Enter': + event.preventDefault() + if (selectedIndex < defaultProjects.length) { + handleProjectSelect(defaultProjects[selectedIndex]) + } else { + handleAddProjectClick() + } + break + case 'Escape': + event.preventDefault() + handleMenuClose() + break + } + } + + document.addEventListener('keydown', handleKeyDown) + return () => { + document.removeEventListener('keydown', handleKeyDown) + } + }, [anchorEl, selectedIndex, defaultProjects]) + + // Reset selected index when menu opens + useEffect(() => { + if (anchorEl) { + setSelectedIndex(0) + } + }, [anchorEl]) + + // Find the currently selected project + const currentProject = defaultProjects.find( + p => p.name === effectiveSelectedProject, + ) + + return ( + <> + + + + + + + + + + + + + Select Project + + + Choose or create a project workspace + + + + + + + + handleProjectSelect({ + id: 'default', + name: 'Default Project', + color: LABEL_COLORS[0].value, + icon: 'FolderOpen', + }) + } + sx={{ + borderRadius: 'var(--joy-radius-sm)', + backgroundColor: + effectiveSelectedProject === 'Default Project' + ? 'var(--joy-palette-primary-softBg)' + : selectedIndex === 0 && anchorEl + ? 'var(--joy-palette-neutral-softHoverBg)' + : 'transparent', + '&:hover': { + backgroundColor: + effectiveSelectedProject === 'Default Project' + ? 'var(--joy-palette-primary-softBg)' + : 'var(--joy-palette-neutral-softHoverBg)', + }, + position: 'relative', + }} + > + + + + + + + + + Default Project + + {effectiveSelectedProject === 'Default Project' && ( + + )} + + + Built-in project workspace + + + + {defaultProjects.map((project, index) => ( + handleProjectSelect(project)} + sx={{ + borderRadius: 'var(--joy-radius-sm)', + backgroundColor: + effectiveSelectedProject === project.name + ? 'var(--joy-palette-primary-softBg)' + : selectedIndex === index && anchorEl + ? 'var(--joy-palette-neutral-softHoverBg)' + : 'transparent', + '&:hover': { + backgroundColor: + effectiveSelectedProject === project.name + ? 'var(--joy-palette-primary-softBg)' + : 'var(--joy-palette-neutral-softHoverBg)', + }, + position: 'relative', + }} + > + + + {(() => { + const IconComponent = getIconComponent( + project.icon || 'FolderOpen', + ) + return ( + + ) + })()} + + + + + + {project.name} + + {effectiveSelectedProject === project.name && ( + + )} + + {project.id === 'default' && ( + + Built-in project workspace + + )} + + + ))} + + + + + + + + + + Create New Project + + + Add a custom project workspace + + + + + + setIsProjectModalOpen(false)} + onSave={handleProjectModalSave} + project={null} + /> + + ) +} + +export default ProjectSelector