Add Support Project
This commit is contained in:
@@ -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: <LabelView />,
|
||||
},
|
||||
|
||||
{
|
||||
path: 'projects/',
|
||||
element: <ProjectView />,
|
||||
},
|
||||
{
|
||||
path: '*',
|
||||
element: <NotFound />,
|
||||
|
||||
60
src/utils/ProjectIcons.jsx
Normal file
60
src/utils/ProjectIcons.jsx
Normal file
@@ -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
|
||||
}
|
||||
@@ -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 <LoadingComponent />
|
||||
}
|
||||
@@ -544,6 +554,52 @@ const ChoreEdit = () => {
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Project Selection - Show only if there are multiple projects */}
|
||||
{projects.length > 1 && (
|
||||
<Box mb={3}>
|
||||
<Typography level='h4'>Project</Typography>
|
||||
<Typography level='body-md'>
|
||||
Which project does this task belong to?
|
||||
</Typography>
|
||||
<Select
|
||||
value={projectId}
|
||||
onChange={(event, newValue) => setProjectId(newValue)}
|
||||
sx={{ minWidth: '15rem' }}
|
||||
>
|
||||
{projects.map(project => (
|
||||
<Option key={project.id} value={project.id}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
{project.icon &&
|
||||
(() => {
|
||||
const IconComponent = getIconComponent(project.icon)
|
||||
return (
|
||||
<IconComponent
|
||||
sx={{
|
||||
fontSize: 16,
|
||||
color: getTextColorFromBackgroundColor(
|
||||
project.color || '#1976d2',
|
||||
),
|
||||
}}
|
||||
/>
|
||||
)
|
||||
})()}
|
||||
<Box
|
||||
sx={{
|
||||
width: 12,
|
||||
height: 12,
|
||||
borderRadius: '50%',
|
||||
backgroundColor: project.color || '#1976d2',
|
||||
mr: 1,
|
||||
}}
|
||||
/>
|
||||
{project.name}
|
||||
</Box>
|
||||
</Option>
|
||||
))}
|
||||
</Select>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Box mb={3}>
|
||||
<Typography level='h4'>Labels</Typography>
|
||||
<Typography level='body-md'>
|
||||
@@ -936,6 +992,113 @@ const ChoreEdit = () => {
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{dueDate && (
|
||||
<Box mb={3}>
|
||||
<Typography level='h4'>Deadline</Typography>
|
||||
<Typography level='body-md'>
|
||||
When should this task be considered expired?
|
||||
</Typography>
|
||||
|
||||
{/* One-time tasks: Date picker */}
|
||||
{['once', 'no_repeat'].includes(frequencyType) ? (
|
||||
<FormControl sx={{ mt: 1 }}>
|
||||
<Checkbox
|
||||
onChange={e => {
|
||||
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'
|
||||
/>
|
||||
<FormHelperText>
|
||||
Task will be considered expired after this date
|
||||
</FormHelperText>
|
||||
</FormControl>
|
||||
) : (
|
||||
/* Recurring tasks: Offset input */
|
||||
<FormControl sx={{ mt: 1 }}>
|
||||
<Checkbox
|
||||
onChange={e => {
|
||||
if (e.target.checked) {
|
||||
setDeadlineOffset(24) // Default to 24 hours
|
||||
} else {
|
||||
setDeadlineOffset(-1)
|
||||
}
|
||||
}}
|
||||
checked={deadlineOffset !== -1}
|
||||
overlay
|
||||
label='Set a deadline for this task'
|
||||
/>
|
||||
<FormHelperText>
|
||||
Task will be considered expired after the specified time from due date
|
||||
</FormHelperText>
|
||||
</FormControl>
|
||||
)}
|
||||
|
||||
{/* Date picker for one-time tasks */}
|
||||
{deadline && ['once', 'no_repeat'].includes(frequencyType) && (
|
||||
<Card variant='outlined' sx={{ mt: 2 }}>
|
||||
<Box sx={{ p: 2 }}>
|
||||
<Typography level='body-sm' mb={1}>Deadline Date:</Typography>
|
||||
<Input
|
||||
type='datetime-local'
|
||||
value={deadline}
|
||||
onChange={e => setDeadline(e.target.value)}
|
||||
slotProps={{
|
||||
input: {
|
||||
min: dueDate, // Deadline cannot be before due date
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Offset input for recurring tasks */}
|
||||
{deadlineOffset !== -1 && !['once', 'no_repeat'].includes(frequencyType) && (
|
||||
<Card variant='outlined' sx={{ mt: 2 }}>
|
||||
<Box sx={{ p: 2, display: 'flex', gap: 2, alignItems: 'end' }}>
|
||||
<Box>
|
||||
<Typography level='body-sm' mb={1}>Time after due date:</Typography>
|
||||
<Input
|
||||
type='number'
|
||||
value={deadlineOffset}
|
||||
sx={{ maxWidth: 100 }}
|
||||
slotProps={{
|
||||
input: {
|
||||
min: 1,
|
||||
max: 720, // Max 30 days in hours
|
||||
},
|
||||
}}
|
||||
placeholder='Time'
|
||||
onChange={e => {
|
||||
setDeadlineOffset(parseInt(e.target.value) || 1)
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography level='body-sm' mb={1}>Unit:</Typography>
|
||||
<Select
|
||||
value={deadlineUnit}
|
||||
onChange={(event, newValue) => setDeadlineUnit(newValue)}
|
||||
sx={{ minWidth: 100 }}
|
||||
>
|
||||
<Option value='hours'>Hours</Option>
|
||||
<Option value='days'>Days</Option>
|
||||
</Select>
|
||||
</Box>
|
||||
</Box>
|
||||
</Card>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{!['once', 'no_repeat'].includes(frequencyType) && (
|
||||
<Box>
|
||||
<Typography level='h4'>Scheduling Preferences</Typography>
|
||||
|
||||
@@ -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 && (
|
||||
<ProjectSelector
|
||||
selectedProject={selectedProject?.name || 'Default Project'}
|
||||
onProjectSelect={project => {
|
||||
setSelectedProjectWithCache(project)
|
||||
// setFilteredChores(chores)
|
||||
// setSearchFilter('All')
|
||||
}}
|
||||
showKeyboardShortcuts={showKeyboardShortcuts}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* View Mode Toggle Button */}
|
||||
<IconButton
|
||||
variant='outlined'
|
||||
@@ -1748,10 +1901,13 @@ const MyChores = () => {
|
||||
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 = () => {
|
||||
<Chip
|
||||
color={searchFilter === filter ? 'primary' : 'neutral'}
|
||||
>
|
||||
{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
|
||||
})()}
|
||||
</Chip>
|
||||
</MenuItem>
|
||||
))}
|
||||
@@ -1773,7 +1935,9 @@ const MyChores = () => {
|
||||
<MenuItem
|
||||
key={`filter-list-cancel-all-filters`}
|
||||
onClick={() => {
|
||||
setFilteredChores(chores)
|
||||
setFilteredChores(
|
||||
selectedProject ? projectFilteredChores : chores,
|
||||
)
|
||||
setSearchFilter('All')
|
||||
}}
|
||||
>
|
||||
@@ -2091,6 +2255,7 @@ const MyChores = () => {
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Additional Filters Display */}
|
||||
{searchFilter !== 'All' && (
|
||||
<Chip
|
||||
level='title-md'
|
||||
@@ -2098,19 +2263,26 @@ const MyChores = () => {
|
||||
color='warning'
|
||||
label={searchFilter}
|
||||
onDelete={() => {
|
||||
setFilteredChores(chores)
|
||||
setFilteredChores(
|
||||
selectedProject ? projectFilteredChores : chores,
|
||||
)
|
||||
setSearchFilter('All')
|
||||
}}
|
||||
endDecorator={<CancelRounded />}
|
||||
onClick={() => {
|
||||
setFilteredChores(chores)
|
||||
setFilteredChores(
|
||||
selectedProject ? projectFilteredChores : chores,
|
||||
)
|
||||
setSearchFilter('All')
|
||||
}}
|
||||
>
|
||||
Current Filter: {searchFilter}
|
||||
Additional Filter: {searchFilter}
|
||||
</Chip>
|
||||
)}
|
||||
{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' && (
|
||||
<Box
|
||||
@@ -2136,8 +2308,10 @@ const MyChores = () => {
|
||||
<>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setFilteredChores(chores)
|
||||
// Reset search and filters to show all chores in current project
|
||||
setSearchFilter('All')
|
||||
setSearchTerm('')
|
||||
// Clear any manual filteredChores and let the memo handle it
|
||||
}}
|
||||
variant='outlined'
|
||||
color='neutral'
|
||||
@@ -2150,7 +2324,7 @@ const MyChores = () => {
|
||||
)}
|
||||
{(searchTerm?.length > 0 || searchFilter !== 'All') &&
|
||||
viewMode !== 'calendar' &&
|
||||
filteredChores.map(chore =>
|
||||
getFilteredChores.map(chore =>
|
||||
renderChoreCard(chore, `filtered-${chore.id}`),
|
||||
)}
|
||||
{viewMode === 'calendar' && (
|
||||
|
||||
110
src/views/Modals/Inputs/IconPickerModal.jsx
Normal file
110
src/views/Modals/Inputs/IconPickerModal.jsx
Normal file
@@ -0,0 +1,110 @@
|
||||
import {
|
||||
Avatar,
|
||||
Box,
|
||||
Button,
|
||||
FormControl,
|
||||
FormLabel,
|
||||
Grid,
|
||||
Typography,
|
||||
} from '@mui/joy'
|
||||
import { useResponsiveModal } from '../../../hooks/useResponsiveModal'
|
||||
import { getTextColorFromBackgroundColor } from '../../../utils/Colors'
|
||||
import PROJECT_ICONS from '../../../utils/ProjectIcons'
|
||||
|
||||
const IconPickerModal = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
onSelect,
|
||||
currentIcon,
|
||||
projectColor,
|
||||
}) => {
|
||||
const { ResponsiveModal } = useResponsiveModal()
|
||||
|
||||
const handleIconClick = iconValue => {
|
||||
onSelect(iconValue)
|
||||
onClose()
|
||||
}
|
||||
|
||||
return (
|
||||
<ResponsiveModal
|
||||
open={isOpen}
|
||||
onClose={onClose}
|
||||
size='sm'
|
||||
unmountDelay={250}
|
||||
>
|
||||
<Typography level='h4' mb={2}>
|
||||
Choose Project Icon
|
||||
</Typography>
|
||||
|
||||
<FormControl>
|
||||
<FormLabel>Available Icons</FormLabel>
|
||||
<Grid
|
||||
container
|
||||
spacing={1}
|
||||
sx={{ maxHeight: '300px', overflowY: 'auto', mb: 2 }}
|
||||
>
|
||||
{PROJECT_ICONS.map(iconData => {
|
||||
const IconComponent = iconData.icon
|
||||
const isCurrentIcon = currentIcon === iconData.value
|
||||
return (
|
||||
<Grid key={iconData.value} xs={3} sm={2}>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
cursor: 'pointer',
|
||||
p: 1,
|
||||
borderRadius: 'sm',
|
||||
border: '2px solid',
|
||||
borderColor: isCurrentIcon ? 'primary.500' : 'transparent',
|
||||
'&:hover': {
|
||||
borderColor: isCurrentIcon ? 'primary.600' : 'neutral.300',
|
||||
},
|
||||
transition: 'border-color 0.2s',
|
||||
}}
|
||||
onClick={() => handleIconClick(iconData.value)}
|
||||
>
|
||||
<Avatar
|
||||
size='sm'
|
||||
sx={{
|
||||
width: 32,
|
||||
height: 32,
|
||||
bgcolor: projectColor,
|
||||
mb: 0.5,
|
||||
}}
|
||||
>
|
||||
<IconComponent
|
||||
sx={{
|
||||
fontSize: 16,
|
||||
color: getTextColorFromBackgroundColor(projectColor),
|
||||
}}
|
||||
/>
|
||||
</Avatar>
|
||||
<Typography
|
||||
level='body-xs'
|
||||
sx={{
|
||||
textAlign: 'center',
|
||||
fontSize: 10,
|
||||
lineHeight: 1.2,
|
||||
}}
|
||||
>
|
||||
{iconData.name}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Grid>
|
||||
)
|
||||
})}
|
||||
</Grid>
|
||||
</FormControl>
|
||||
|
||||
<Box display='flex' justifyContent='center' mt={3}>
|
||||
<Button variant='outlined' onClick={onClose} fullWidth size='lg'>
|
||||
Cancel
|
||||
</Button>
|
||||
</Box>
|
||||
</ResponsiveModal>
|
||||
)
|
||||
}
|
||||
|
||||
export default IconPickerModal
|
||||
@@ -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 (
|
||||
<ResponsiveModal
|
||||
open={isOpen}
|
||||
onClose={handleClose}
|
||||
size='md'
|
||||
unmountDelay={250}
|
||||
fullWidth={true}
|
||||
>
|
||||
<Typography level='h4' mb={2}>
|
||||
{project ? 'Edit Project' : 'Create New Project'}
|
||||
@@ -139,148 +160,82 @@ const ProjectModal = ({ isOpen, onClose, onSave, project }) => {
|
||||
{/* Icon Selection */}
|
||||
<FormControl>
|
||||
<FormLabel>Project Icon</FormLabel>
|
||||
<Typography level='body-sm' sx={{ mb: 1, color: 'text.tertiary' }}>
|
||||
Choose an icon to represent your project
|
||||
</Typography>
|
||||
<Grid
|
||||
container
|
||||
spacing={1}
|
||||
sx={{ maxHeight: '200px', overflowY: 'auto', mb: 2 }}
|
||||
<Button
|
||||
variant='outlined'
|
||||
onClick={() => setIsIconPickerOpen(true)}
|
||||
startDecorator={
|
||||
<Avatar
|
||||
size='sm'
|
||||
sx={{
|
||||
width: 24,
|
||||
height: 24,
|
||||
bgcolor: projectColor,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
'& svg': {
|
||||
display: 'block',
|
||||
margin: '0 auto',
|
||||
},
|
||||
}}
|
||||
>
|
||||
{(() => {
|
||||
const IconComponent = getIconComponent(projectIcon)
|
||||
return (
|
||||
<IconComponent
|
||||
sx={{
|
||||
fontSize: 14,
|
||||
color: getTextColorFromBackgroundColor(projectColor),
|
||||
display: 'block',
|
||||
}}
|
||||
/>
|
||||
)
|
||||
})()}
|
||||
</Avatar>
|
||||
}
|
||||
sx={{ justifyContent: 'flex-start' }}
|
||||
>
|
||||
{PROJECT_ICONS.map(iconData => {
|
||||
const IconComponent = iconData.icon
|
||||
return (
|
||||
<Grid key={iconData.value} xs={3} sm={2}>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
cursor: 'pointer',
|
||||
p: 1,
|
||||
borderRadius: 'sm',
|
||||
border: '2px solid',
|
||||
borderColor:
|
||||
projectIcon === iconData.value
|
||||
? 'primary.500'
|
||||
: 'transparent',
|
||||
'&:hover': {
|
||||
borderColor:
|
||||
projectIcon === iconData.value
|
||||
? 'primary.600'
|
||||
: 'neutral.300',
|
||||
},
|
||||
transition: 'border-color 0.2s',
|
||||
}}
|
||||
onClick={() => setProjectIcon(iconData.value)}
|
||||
>
|
||||
<Avatar
|
||||
size='sm'
|
||||
sx={{
|
||||
width: 24,
|
||||
height: 24,
|
||||
bgcolor: projectColor,
|
||||
mb: 0.5,
|
||||
}}
|
||||
>
|
||||
<IconComponent
|
||||
sx={{
|
||||
fontSize: 12,
|
||||
color:
|
||||
getTextColorFromBackgroundColor(projectColor),
|
||||
}}
|
||||
/>
|
||||
</Avatar>
|
||||
<Typography
|
||||
level='body-xs'
|
||||
sx={{
|
||||
textAlign: 'center',
|
||||
fontSize: 9,
|
||||
lineHeight: 1,
|
||||
}}
|
||||
>
|
||||
{iconData.name}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Grid>
|
||||
)
|
||||
})}
|
||||
</Grid>
|
||||
{PROJECT_ICONS.find(icon => icon.value === projectIcon)?.name ||
|
||||
'Select Icon'}
|
||||
</Button>
|
||||
</FormControl>
|
||||
|
||||
{/* Color Selection */}
|
||||
<FormControl>
|
||||
<FormLabel>Project Color</FormLabel>
|
||||
<Typography level='body-sm' sx={{ mb: 1, color: 'text.tertiary' }}>
|
||||
Choose a color to help identify your project
|
||||
</Typography>
|
||||
<Grid
|
||||
container
|
||||
spacing={1}
|
||||
sx={{ maxHeight: '200px', overflowY: 'auto' }}
|
||||
<Select
|
||||
value={projectColor}
|
||||
onChange={(e, value) => value && setProjectColor(value)}
|
||||
renderValue={selected => (
|
||||
<Typography
|
||||
startDecorator={
|
||||
<Box
|
||||
className='size-4'
|
||||
borderRadius={10}
|
||||
sx={{ background: selected.value }}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{selected.label}
|
||||
</Typography>
|
||||
)}
|
||||
>
|
||||
{LABEL_COLORS.map(color => (
|
||||
<Grid key={color.value} xs={3} sm={2}>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
cursor: 'pointer',
|
||||
p: 1,
|
||||
borderRadius: 'sm',
|
||||
border: '2px solid',
|
||||
borderColor:
|
||||
projectColor === color.value
|
||||
? 'primary.500'
|
||||
: 'transparent',
|
||||
'&:hover': {
|
||||
borderColor:
|
||||
projectColor === color.value
|
||||
? 'primary.600'
|
||||
: 'neutral.300',
|
||||
},
|
||||
transition: 'border-color 0.2s',
|
||||
}}
|
||||
onClick={() => setProjectColor(color.value)}
|
||||
>
|
||||
<Avatar
|
||||
size='sm'
|
||||
sx={{
|
||||
width: 24,
|
||||
height: 24,
|
||||
bgcolor: color.value,
|
||||
mb: 0.5,
|
||||
}}
|
||||
>
|
||||
{(() => {
|
||||
const IconComponent = getIconComponent(projectIcon)
|
||||
return (
|
||||
<IconComponent
|
||||
sx={{
|
||||
fontSize: 12,
|
||||
color: getTextColorFromBackgroundColor(
|
||||
color.value,
|
||||
),
|
||||
}}
|
||||
/>
|
||||
)
|
||||
})()}
|
||||
</Avatar>
|
||||
<Typography
|
||||
level='body-xs'
|
||||
sx={{
|
||||
textAlign: 'center',
|
||||
fontSize: 9,
|
||||
lineHeight: 1,
|
||||
}}
|
||||
>
|
||||
<Option key={color.value} value={color.value}>
|
||||
<Box className='flex items-center justify-between'>
|
||||
<Box
|
||||
width={20}
|
||||
height={20}
|
||||
borderRadius={10}
|
||||
sx={{ background: color.value }}
|
||||
/>
|
||||
<Typography sx={{ ml: 1 }} variant='caption'>
|
||||
{color.name}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Grid>
|
||||
</Option>
|
||||
))}
|
||||
</Grid>
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
{/* 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 }) => {
|
||||
</Button>
|
||||
</Box>
|
||||
</form>
|
||||
|
||||
<IconPickerModal
|
||||
isOpen={isIconPickerOpen}
|
||||
onClose={() => setIsIconPickerOpen(false)}
|
||||
onSelect={handleIconSelect}
|
||||
currentIcon={projectIcon}
|
||||
projectColor={projectColor}
|
||||
/>
|
||||
</ResponsiveModal>
|
||||
)
|
||||
}
|
||||
|
||||
188
src/views/Projects/ProjectQueries.js
Normal file
188
src/views/Projects/ProjectQueries.js
Normal file
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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) */}
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
right: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
width: maxSwipeDistance,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
boxShadow: 'inset 2px 0 4px rgba(0,0,0,0.06)',
|
||||
zIndex: 0,
|
||||
}}
|
||||
onMouseEnter={handleActionAreaMouseEnter}
|
||||
onMouseLeave={handleActionAreaMouseLeave}
|
||||
>
|
||||
<IconButton
|
||||
variant='soft'
|
||||
color='neutral'
|
||||
size='sm'
|
||||
onClick={e => {
|
||||
e.stopPropagation()
|
||||
resetSwipe()
|
||||
onEditClick(project)
|
||||
}}
|
||||
{isEditable && (
|
||||
<Box
|
||||
sx={{
|
||||
width: 40,
|
||||
height: 40,
|
||||
mx: 1,
|
||||
position: 'absolute',
|
||||
right: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
width: maxSwipeDistance,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
boxShadow: 'inset 2px 0 4px rgba(0,0,0,0.06)',
|
||||
zIndex: 0,
|
||||
}}
|
||||
onMouseEnter={handleActionAreaMouseEnter}
|
||||
onMouseLeave={handleActionAreaMouseLeave}
|
||||
>
|
||||
<EditIcon sx={{ fontSize: 16 }} />
|
||||
</IconButton>
|
||||
|
||||
{/* Only show delete for non-default projects */}
|
||||
{!isDefaultProject && (
|
||||
<IconButton
|
||||
variant='soft'
|
||||
color='danger'
|
||||
color='neutral'
|
||||
size='sm'
|
||||
onClick={e => {
|
||||
e.stopPropagation()
|
||||
resetSwipe()
|
||||
onDeleteClick(project.id)
|
||||
onEditClick(project)
|
||||
}}
|
||||
sx={{
|
||||
width: 40,
|
||||
@@ -263,10 +254,31 @@ const ProjectCard = ({ project, onEditClick, onDeleteClick, currentUserId, taskC
|
||||
mx: 1,
|
||||
}}
|
||||
>
|
||||
<DeleteIcon sx={{ fontSize: 16 }} />
|
||||
<EditIcon sx={{ fontSize: 16 }} />
|
||||
</IconButton>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Only show delete for non-default projects */}
|
||||
{!isDefaultProject && (
|
||||
<IconButton
|
||||
variant='soft'
|
||||
color='danger'
|
||||
size='sm'
|
||||
onClick={e => {
|
||||
e.stopPropagation()
|
||||
resetSwipe()
|
||||
onDeleteClick(project.id)
|
||||
}}
|
||||
sx={{
|
||||
width: 40,
|
||||
height: 40,
|
||||
mx: 1,
|
||||
}}
|
||||
>
|
||||
<DeleteIcon sx={{ fontSize: 16 }} />
|
||||
</IconButton>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Main card content */}
|
||||
<Box
|
||||
@@ -295,62 +307,68 @@ const ProjectCard = ({ project, onEditClick, onDeleteClick, currentUserId, taskC
|
||||
resetSwipe()
|
||||
return
|
||||
}
|
||||
onEditClick(project)
|
||||
// Always navigate to MyChores with project filter when clicking on the card
|
||||
// For default project, use 'default', for others use project ID
|
||||
const projectIdentifier =
|
||||
project.id === 'default' ? 'default' : project.id
|
||||
navigate(`/chores?project=${encodeURIComponent(projectIdentifier)}`)
|
||||
}}
|
||||
onTouchStart={handleTouchStart}
|
||||
onTouchMove={handleTouchMove}
|
||||
onTouchEnd={handleTouchEnd}
|
||||
onMouseDown={handleMouseDown}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseUp={handleMouseUp}
|
||||
onTouchStart={isEditable ? handleTouchStart : undefined}
|
||||
onTouchMove={isEditable ? handleTouchMove : undefined}
|
||||
onTouchEnd={isEditable ? handleTouchEnd : undefined}
|
||||
onMouseDown={isEditable ? handleMouseDown : undefined}
|
||||
onMouseMove={isEditable ? handleMouseMove : undefined}
|
||||
onMouseUp={isEditable ? handleMouseUp : undefined}
|
||||
>
|
||||
{/* Right drag area */}
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
right: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
width: '20px',
|
||||
cursor: 'grab',
|
||||
zIndex: 2,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
opacity: isSwipeRevealed ? 0 : 0.3,
|
||||
transition: 'opacity 0.2s ease',
|
||||
pointerEvents: isSwipeRevealed ? 'none' : 'auto',
|
||||
'&:hover': {
|
||||
opacity: isSwipeRevealed ? 0 : 0.7,
|
||||
},
|
||||
'&:active': {
|
||||
cursor: 'grabbing',
|
||||
},
|
||||
}}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
>
|
||||
{/* Drag indicator dots */}
|
||||
{isEditable && (
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
right: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
width: '20px',
|
||||
cursor: 'grab',
|
||||
zIndex: 2,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 0.25,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
opacity: isSwipeRevealed ? 0 : 0.3,
|
||||
transition: 'opacity 0.2s ease',
|
||||
pointerEvents: isSwipeRevealed ? 'none' : 'auto',
|
||||
'&:hover': {
|
||||
opacity: isSwipeRevealed ? 0 : 0.7,
|
||||
},
|
||||
'&:active': {
|
||||
cursor: 'grabbing',
|
||||
},
|
||||
}}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
>
|
||||
{[...Array(3)].map((_, i) => (
|
||||
<Box
|
||||
key={i}
|
||||
sx={{
|
||||
width: 3,
|
||||
height: 3,
|
||||
borderRadius: '50%',
|
||||
bgcolor: 'text.tertiary',
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
{/* Drag indicator dots */}
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 0.25,
|
||||
}}
|
||||
>
|
||||
{[...Array(3)].map((_, i) => (
|
||||
<Box
|
||||
key={i}
|
||||
sx={{
|
||||
width: 3,
|
||||
height: 3,
|
||||
borderRadius: '50%',
|
||||
bgcolor: 'text.tertiary',
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Project Avatar */}
|
||||
<Box
|
||||
@@ -387,7 +405,9 @@ const ProjectCard = ({ project, onEditClick, onDeleteClick, currentUserId, taskC
|
||||
<IconComponent
|
||||
sx={{
|
||||
fontSize: 16,
|
||||
color: getTextColorFromBackgroundColor(project.color || '#1976d2'),
|
||||
color: getTextColorFromBackgroundColor(
|
||||
project.color || '#1976d2',
|
||||
),
|
||||
}}
|
||||
/>
|
||||
)
|
||||
@@ -396,7 +416,9 @@ const ProjectCard = ({ project, onEditClick, onDeleteClick, currentUserId, taskC
|
||||
<Typography
|
||||
level='body-xs'
|
||||
sx={{
|
||||
color: getTextColorFromBackgroundColor(project.color || '#1976d2'),
|
||||
color: getTextColorFromBackgroundColor(
|
||||
project.color || '#1976d2',
|
||||
),
|
||||
fontWeight: 'bold',
|
||||
fontSize: 10,
|
||||
}}
|
||||
@@ -523,6 +545,7 @@ const ProjectCard = ({ project, onEditClick, onDeleteClick, currentUserId, taskC
|
||||
const ProjectView = () => {
|
||||
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 && (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
flexDirection: 'column',
|
||||
height: '50vh',
|
||||
}}
|
||||
>
|
||||
<Typography level='title-md' gutterBottom>
|
||||
No projects available. Add a new project to get started.
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
{/* default project: */}
|
||||
<ProjectCard
|
||||
key='default-project-card'
|
||||
project={{
|
||||
id: 'default',
|
||||
name: 'Default Project',
|
||||
description: 'All uncategorized tasks',
|
||||
color: '#1976d2',
|
||||
icon: 'FolderOpen',
|
||||
created_by: userProfile?.id,
|
||||
}}
|
||||
isEditable={false}
|
||||
currentUserId={userProfile?.id}
|
||||
onEditClick={() => {}}
|
||||
taskCounts={{ default: taskCounts.default || 0 }}
|
||||
/>
|
||||
{userProjects.map(project => (
|
||||
<ProjectCard
|
||||
key={project.id}
|
||||
@@ -665,6 +695,7 @@ const ProjectView = () => {
|
||||
onDeleteClick={handleDeleteClicked}
|
||||
currentUserId={userProfile?.id}
|
||||
taskCounts={taskCounts}
|
||||
isEditable={true}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
@@ -707,4 +738,4 @@ const ProjectView = () => {
|
||||
)
|
||||
}
|
||||
|
||||
export default ProjectView
|
||||
export default ProjectView
|
||||
|
||||
@@ -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 (
|
||||
<div>
|
||||
{/* Autocomplete component for searching */}
|
||||
{JSON.stringify({ 1: searchTerm, filteredIcons: filteredIcons })}
|
||||
<Input
|
||||
onChange={(event, newValue) => {
|
||||
setSearchTerm(newValue)
|
||||
}}
|
||||
/>
|
||||
{/* Grid to display icons */}
|
||||
<Grid container spacing={2}>
|
||||
{filteredIcons.map(iconName => {
|
||||
const IconComponent = allIcons[iconName]
|
||||
if (IconComponent) {
|
||||
// Add this check to prevent errors
|
||||
return (
|
||||
<Grid item key={iconName} xs={3} sm={2} md={1}>
|
||||
<SvgIcon
|
||||
component={IconComponent}
|
||||
onClick={() => handleIconClick(iconName)}
|
||||
style={{ cursor: 'pointer' }}
|
||||
/>
|
||||
</Grid>
|
||||
)
|
||||
}
|
||||
return null // Return null for non-icon exports
|
||||
})}
|
||||
</Grid>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default MuiIconPicker
|
||||
484
src/views/components/ProjectSelector.jsx
Normal file
484
src/views/components/ProjectSelector.jsx
Normal file
@@ -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 (
|
||||
<>
|
||||
<Box sx={{ position: 'relative', display: 'inline-flex' }}>
|
||||
<Button
|
||||
ref={buttonRef}
|
||||
onClick={handleMenuOpen}
|
||||
variant='outlined'
|
||||
color='neutral'
|
||||
size='sm'
|
||||
sx={{
|
||||
height: 24,
|
||||
borderRadius: 24,
|
||||
minWidth: 'auto',
|
||||
maxWidth: '200px',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
backgroundColor: currentProject?.color || LABEL_COLORS[0].value,
|
||||
}}
|
||||
title={`Current project: ${effectiveSelectedProject} (Ctrl+E)`}
|
||||
>
|
||||
{(() => {
|
||||
const IconComponent = getIconComponent(
|
||||
currentProject?.icon || 'FolderOpen',
|
||||
)
|
||||
return (
|
||||
// <Avatar
|
||||
// size='sm'
|
||||
// sx={{
|
||||
// width: 24,
|
||||
// height: 24,
|
||||
// backgroundColor: project.color || LABEL_COLORS[0].value,
|
||||
// display: 'flex',
|
||||
// alignItems: 'center',
|
||||
// justifyContent: 'center',
|
||||
// }}
|
||||
// >
|
||||
<IconComponent
|
||||
sx={{
|
||||
fontSize: 16,
|
||||
color: getTextColorFromBackgroundColor(
|
||||
currentProject?.color || LABEL_COLORS[0].value,
|
||||
),
|
||||
}}
|
||||
/>
|
||||
// </Avatar>
|
||||
)
|
||||
})()}
|
||||
</Button>
|
||||
<KeyboardShortcutHint
|
||||
shortcut='E'
|
||||
show={showKeyboardShortcuts}
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: -8,
|
||||
right: -8,
|
||||
zIndex: 1000,
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Menu
|
||||
ref={menuRef}
|
||||
anchorEl={anchorEl}
|
||||
open={Boolean(anchorEl)}
|
||||
onClose={handleMenuClose}
|
||||
placement='bottom-start'
|
||||
sx={{
|
||||
minWidth: 280,
|
||||
p: 1,
|
||||
'--List-gap': '4px',
|
||||
boxShadow: 'var(--joy-shadow-lg)',
|
||||
border: '1px solid var(--joy-palette-divider)',
|
||||
borderRadius: 'var(--joy-radius-md)',
|
||||
}}
|
||||
>
|
||||
<MenuItem
|
||||
disabled
|
||||
sx={{
|
||||
borderRadius: 'var(--joy-radius-sm)',
|
||||
mb: 1,
|
||||
cursor: 'default',
|
||||
opacity: 1,
|
||||
}}
|
||||
>
|
||||
<ListItemDecorator sx={{ color: 'var(--joy-palette-primary-500)' }}>
|
||||
<FolderOpen />
|
||||
</ListItemDecorator>
|
||||
<ListItemContent>
|
||||
<Typography level='title-sm' sx={{ fontWeight: 600 }}>
|
||||
Select Project
|
||||
</Typography>
|
||||
<Typography
|
||||
level='body-xs'
|
||||
sx={{ color: 'var(--joy-palette-text-tertiary)' }}
|
||||
>
|
||||
Choose or create a project workspace
|
||||
</Typography>
|
||||
</ListItemContent>
|
||||
</MenuItem>
|
||||
|
||||
<Divider sx={{ my: 1 }} />
|
||||
|
||||
<MenuItem
|
||||
key={'default-project'}
|
||||
onClick={() =>
|
||||
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',
|
||||
}}
|
||||
>
|
||||
<ListItemDecorator>
|
||||
<Avatar
|
||||
size='sm'
|
||||
sx={{
|
||||
width: 24,
|
||||
height: 24,
|
||||
backgroundColor: LABEL_COLORS[0].value,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<FolderOpen
|
||||
sx={{
|
||||
fontSize: 14,
|
||||
color: getTextColorFromBackgroundColor(LABEL_COLORS[0].value),
|
||||
}}
|
||||
/>
|
||||
</Avatar>
|
||||
</ListItemDecorator>
|
||||
<ListItemContent>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
level='body-sm'
|
||||
sx={{
|
||||
fontWeight:
|
||||
effectiveSelectedProject === 'Default Project' ? 600 : 400,
|
||||
color:
|
||||
effectiveSelectedProject === 'Default Project'
|
||||
? 'var(--joy-palette-primary-600)'
|
||||
: 'var(--joy-palette-text-primary)',
|
||||
}}
|
||||
>
|
||||
Default Project
|
||||
</Typography>
|
||||
{effectiveSelectedProject === 'Default Project' && (
|
||||
<Check
|
||||
sx={{
|
||||
fontSize: '16px',
|
||||
color: 'var(--joy-palette-primary-500)',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
<Typography
|
||||
level='body-xs'
|
||||
sx={{ color: 'var(--joy-palette-text-tertiary)' }}
|
||||
>
|
||||
Built-in project workspace
|
||||
</Typography>
|
||||
</ListItemContent>
|
||||
</MenuItem>
|
||||
{defaultProjects.map((project, index) => (
|
||||
<MenuItem
|
||||
key={project.id}
|
||||
onClick={() => 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',
|
||||
}}
|
||||
>
|
||||
<ListItemDecorator>
|
||||
<Avatar
|
||||
size='sm'
|
||||
sx={{
|
||||
width: 24,
|
||||
height: 24,
|
||||
backgroundColor: project.color || LABEL_COLORS[0].value,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
{(() => {
|
||||
const IconComponent = getIconComponent(
|
||||
project.icon || 'FolderOpen',
|
||||
)
|
||||
return (
|
||||
<IconComponent
|
||||
sx={{
|
||||
fontSize: 14,
|
||||
color: getTextColorFromBackgroundColor(
|
||||
project.color || LABEL_COLORS[0].value,
|
||||
),
|
||||
}}
|
||||
/>
|
||||
)
|
||||
})()}
|
||||
</Avatar>
|
||||
</ListItemDecorator>
|
||||
<ListItemContent>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
level='body-sm'
|
||||
sx={{
|
||||
fontWeight:
|
||||
effectiveSelectedProject === project.name ? 600 : 400,
|
||||
color:
|
||||
effectiveSelectedProject === project.name
|
||||
? 'var(--joy-palette-primary-600)'
|
||||
: 'var(--joy-palette-text-primary)',
|
||||
}}
|
||||
>
|
||||
{project.name}
|
||||
</Typography>
|
||||
{effectiveSelectedProject === project.name && (
|
||||
<Check
|
||||
sx={{
|
||||
fontSize: '16px',
|
||||
color: 'var(--joy-palette-primary-500)',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
{project.id === 'default' && (
|
||||
<Typography
|
||||
level='body-xs'
|
||||
sx={{ color: 'var(--joy-palette-text-tertiary)' }}
|
||||
>
|
||||
Built-in project workspace
|
||||
</Typography>
|
||||
)}
|
||||
</ListItemContent>
|
||||
</MenuItem>
|
||||
))}
|
||||
|
||||
<Divider sx={{ my: 1 }} />
|
||||
|
||||
<MenuItem
|
||||
onClick={handleAddProjectClick}
|
||||
sx={{
|
||||
borderRadius: 'var(--joy-radius-sm)',
|
||||
backgroundColor:
|
||||
selectedIndex === defaultProjects.length && anchorEl
|
||||
? 'var(--joy-palette-success-softHoverBg)'
|
||||
: 'transparent',
|
||||
'&:hover': {
|
||||
backgroundColor: 'var(--joy-palette-success-softHoverBg)',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<ListItemDecorator sx={{ color: 'var(--joy-palette-success-500)' }}>
|
||||
<Add />
|
||||
</ListItemDecorator>
|
||||
<ListItemContent>
|
||||
<Typography
|
||||
level='body-sm'
|
||||
sx={{
|
||||
fontWeight: 500,
|
||||
color: 'var(--joy-palette-success-600)',
|
||||
}}
|
||||
>
|
||||
Create New Project
|
||||
</Typography>
|
||||
<Typography
|
||||
level='body-xs'
|
||||
sx={{ color: 'var(--joy-palette-text-tertiary)' }}
|
||||
>
|
||||
Add a custom project workspace
|
||||
</Typography>
|
||||
</ListItemContent>
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
|
||||
<ProjectModal
|
||||
isOpen={isProjectModalOpen}
|
||||
onClose={() => setIsProjectModalOpen(false)}
|
||||
onSave={handleProjectModalSave}
|
||||
project={null}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default ProjectSelector
|
||||
Reference in New Issue
Block a user