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 PaymentCancelledView from '../views/Payments/PaymentFailView'
|
||||||
import PaymentSuccessView from '../views/Payments/PaymentSuccessView'
|
import PaymentSuccessView from '../views/Payments/PaymentSuccessView'
|
||||||
import PrivacyPolicyView from '../views/PrivacyPolicy/PrivacyPolicyView'
|
import PrivacyPolicyView from '../views/PrivacyPolicy/PrivacyPolicyView'
|
||||||
|
import ProjectView from '../views/Projects/ProjectView'
|
||||||
import APITokenSettings from '../views/Settings/APITokenSettings'
|
import APITokenSettings from '../views/Settings/APITokenSettings'
|
||||||
import MFASettings from '../views/Settings/MFASettings'
|
import MFASettings from '../views/Settings/MFASettings'
|
||||||
import NotificationSetting from '../views/Settings/NotificationSetting'
|
import NotificationSetting from '../views/Settings/NotificationSetting'
|
||||||
@@ -223,7 +224,10 @@ const Router = createBrowserRouter([
|
|||||||
path: 'labels/',
|
path: 'labels/',
|
||||||
element: <LabelView />,
|
element: <LabelView />,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'projects/',
|
||||||
|
element: <ProjectView />,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '*',
|
path: '*',
|
||||||
element: <NotFound />,
|
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 ConfirmationModal from '../Modals/Inputs/ConfirmationModal'
|
||||||
import LabelModal from '../Modals/Inputs/LabelModal'
|
import LabelModal from '../Modals/Inputs/LabelModal'
|
||||||
import RepeatSection from './RepeatSection'
|
import RepeatSection from './RepeatSection'
|
||||||
|
import { useProjects } from '../Projects/ProjectQueries'
|
||||||
|
import { getIconComponent } from '../../utils/ProjectIcons'
|
||||||
|
|
||||||
const ASSIGN_STRATEGIES = [
|
const ASSIGN_STRATEGIES = [
|
||||||
'random',
|
'random',
|
||||||
@@ -89,9 +91,13 @@ const ChoreEdit = () => {
|
|||||||
const [isPrivate, setIsPrivate] = useState(false)
|
const [isPrivate, setIsPrivate] = useState(false)
|
||||||
const [subTasks, setSubTasks] = useState(null)
|
const [subTasks, setSubTasks] = useState(null)
|
||||||
const [completionWindow, setCompletionWindow] = useState(-1)
|
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 [allUserThings, setAllUserThings] = useState([])
|
||||||
const [thingTrigger, setThingTrigger] = useState(null)
|
const [thingTrigger, setThingTrigger] = useState(null)
|
||||||
const [isThingValid, setIsThingValid] = useState(false)
|
const [isThingValid, setIsThingValid] = useState(false)
|
||||||
|
const [projectId, setProjectId] = useState('default')
|
||||||
|
|
||||||
const [notificationMetadata, setNotificationMetadata] = useState({})
|
const [notificationMetadata, setNotificationMetadata] = useState({})
|
||||||
|
|
||||||
@@ -110,6 +116,7 @@ const ChoreEdit = () => {
|
|||||||
const [showSaveAssigneeDefault, setShowSaveAssigneeDefault] = useState(false)
|
const [showSaveAssigneeDefault, setShowSaveAssigneeDefault] = useState(false)
|
||||||
|
|
||||||
const { data: userLabelsRaw, isLoading: isUserLabelsLoading } = useLabels()
|
const { data: userLabelsRaw, isLoading: isUserLabelsLoading } = useLabels()
|
||||||
|
const { data: projects = [], isLoading: isProjectsLoading } = useProjects()
|
||||||
const updateChoreMutation = useUpdateChore()
|
const updateChoreMutation = useUpdateChore()
|
||||||
const createChoreMutation = useCreateChore()
|
const createChoreMutation = useCreateChore()
|
||||||
const archiveChore = useArchiveChore()
|
const archiveChore = useArchiveChore()
|
||||||
@@ -251,6 +258,7 @@ const ChoreEdit = () => {
|
|||||||
// if completionWindow is -1 then set it to null or dueDate is null
|
// if completionWindow is -1 then set it to null or dueDate is null
|
||||||
completionWindow < 0 || dueDate === null ? null : completionWindow,
|
completionWindow < 0 || dueDate === null ? null : completionWindow,
|
||||||
priority: priority,
|
priority: priority,
|
||||||
|
projectId: projectId === 'default' ? null : projectId,
|
||||||
}
|
}
|
||||||
let SaveFunction = createChoreMutation.mutateAsync
|
let SaveFunction = createChoreMutation.mutateAsync
|
||||||
if (newChoreId > 0) {
|
if (newChoreId > 0) {
|
||||||
@@ -345,6 +353,7 @@ const ChoreEdit = () => {
|
|||||||
setIsRolling(data.res.isRolling)
|
setIsRolling(data.res.isRolling)
|
||||||
setIsActive(data.res.isActive)
|
setIsActive(data.res.isActive)
|
||||||
setSubTasks(data.res.subTasks ? data.res.subTasks : [])
|
setSubTasks(data.res.subTasks ? data.res.subTasks : [])
|
||||||
|
setProjectId(data.res.projectId || 'default')
|
||||||
|
|
||||||
if (isCloneMode) {
|
if (isCloneMode) {
|
||||||
if (data.res.subTasks) {
|
if (data.res.subTasks) {
|
||||||
@@ -450,7 +459,8 @@ const ChoreEdit = () => {
|
|||||||
(isChoreLoading && choreId) ||
|
(isChoreLoading && choreId) ||
|
||||||
isUserLabelsLoading ||
|
isUserLabelsLoading ||
|
||||||
isUserProfileLoading ||
|
isUserProfileLoading ||
|
||||||
isMemberDataLoading
|
isMemberDataLoading ||
|
||||||
|
isProjectsLoading
|
||||||
) {
|
) {
|
||||||
return <LoadingComponent />
|
return <LoadingComponent />
|
||||||
}
|
}
|
||||||
@@ -544,6 +554,52 @@ const ChoreEdit = () => {
|
|||||||
</Box>
|
</Box>
|
||||||
</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}>
|
<Box mb={3}>
|
||||||
<Typography level='h4'>Labels</Typography>
|
<Typography level='h4'>Labels</Typography>
|
||||||
<Typography level='body-md'>
|
<Typography level='body-md'>
|
||||||
@@ -936,6 +992,113 @@ const ChoreEdit = () => {
|
|||||||
</Box>
|
</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) && (
|
{!['once', 'no_repeat'].includes(frequencyType) && (
|
||||||
<Box>
|
<Box>
|
||||||
<Typography level='h4'>Scheduling Preferences</Typography>
|
<Typography level='h4'>Scheduling Preferences</Typography>
|
||||||
|
|||||||
@@ -79,6 +79,7 @@ import {
|
|||||||
NudgeChore,
|
NudgeChore,
|
||||||
RejectChore,
|
RejectChore,
|
||||||
SkipChore,
|
SkipChore,
|
||||||
|
UndoChoreAction,
|
||||||
UpdateChoreAssignee,
|
UpdateChoreAssignee,
|
||||||
UpdateDueDate,
|
UpdateDueDate,
|
||||||
} from '../../utils/Fetcher'
|
} from '../../utils/Fetcher'
|
||||||
@@ -86,6 +87,7 @@ import { getSafeBottom } from '../../utils/SafeAreaUtils.js'
|
|||||||
import TaskInput from '../components/AddTaskModal'
|
import TaskInput from '../components/AddTaskModal'
|
||||||
import CalendarDual from '../components/CalendarDual'
|
import CalendarDual from '../components/CalendarDual'
|
||||||
import CalendarMonthly from '../components/CalendarMonthly.jsx'
|
import CalendarMonthly from '../components/CalendarMonthly.jsx'
|
||||||
|
import ProjectSelector from '../components/ProjectSelector'
|
||||||
import { useProjects } from '../Projects/ProjectQueries.js'
|
import { useProjects } from '../Projects/ProjectQueries.js'
|
||||||
import {
|
import {
|
||||||
canScheduleNotification,
|
canScheduleNotification,
|
||||||
@@ -99,7 +101,7 @@ const MyChores = () => {
|
|||||||
const { data: userProfile, isLoading: isUserProfileLoading } =
|
const { data: userProfile, isLoading: isUserProfileLoading } =
|
||||||
useUserProfile()
|
useUserProfile()
|
||||||
const isLargeScreen = useMediaQuery(theme => theme.breakpoints.up('md'))
|
const isLargeScreen = useMediaQuery(theme => theme.breakpoints.up('md'))
|
||||||
const { showSuccess, showError, showWarning } = useNotification()
|
const { showSuccess, showError, showWarning, showUndo } = useNotification()
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
const { impersonatedUser } = useImpersonateUser()
|
const { impersonatedUser } = useImpersonateUser()
|
||||||
const archiveChore = useArchiveChore()
|
const archiveChore = useArchiveChore()
|
||||||
@@ -110,6 +112,11 @@ const MyChores = () => {
|
|||||||
const [chores, setChores] = useState([])
|
const [chores, setChores] = useState([])
|
||||||
const [filteredChores, setFilteredChores] = useState([])
|
const [filteredChores, setFilteredChores] = useState([])
|
||||||
const [searchFilter, setSearchFilter] = useState('All')
|
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 [choreSections, setChoreSections] = useState([])
|
||||||
|
|
||||||
const [showSearchFilter, setShowSearchFilter] = useState(false)
|
const [showSearchFilter, setShowSearchFilter] = useState(false)
|
||||||
@@ -142,6 +149,24 @@ const MyChores = () => {
|
|||||||
const [searchParams] = useSearchParams()
|
const [searchParams] = useSearchParams()
|
||||||
const { data: userLabels, isLoading: userLabelsLoading } = useLabels()
|
const { data: userLabels, isLoading: userLabelsLoading } = useLabels()
|
||||||
const { data: projects = [], isLoading: projectsLoading } = useProjects()
|
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 {
|
const {
|
||||||
data: choresData,
|
data: choresData,
|
||||||
isLoading: choresLoading,
|
isLoading: choresLoading,
|
||||||
@@ -179,13 +204,18 @@ const MyChores = () => {
|
|||||||
}, [choresData?.res, impersonatedUser])
|
}, [choresData?.res, impersonatedUser])
|
||||||
|
|
||||||
const processedSections = useMemo(() => {
|
const processedSections = useMemo(() => {
|
||||||
if (!processedChores.length || !userProfile?.id) {
|
if (!chores.length || !userProfile?.id) {
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Use project-filtered chores for section grouping
|
||||||
|
const choresToGroup = selectedProject
|
||||||
|
? filterByProject(chores, selectedProject.id)
|
||||||
|
: chores
|
||||||
|
|
||||||
const sections = ChoresGrouper(
|
const sections = ChoresGrouper(
|
||||||
selectedChoreSection,
|
selectedChoreSection,
|
||||||
processedChores,
|
choresToGroup,
|
||||||
ChoreFilters(impersonatedUser?.userId || userProfile?.id)[
|
ChoreFilters(impersonatedUser?.userId || userProfile?.id)[
|
||||||
selectedChoreFilter
|
selectedChoreFilter
|
||||||
],
|
],
|
||||||
@@ -193,9 +223,10 @@ const MyChores = () => {
|
|||||||
|
|
||||||
return sections
|
return sections
|
||||||
}, [
|
}, [
|
||||||
processedChores,
|
chores,
|
||||||
selectedChoreSection,
|
selectedChoreSection,
|
||||||
selectedChoreFilter,
|
selectedChoreFilter,
|
||||||
|
selectedProject,
|
||||||
impersonatedUser?.userId,
|
impersonatedUser?.userId,
|
||||||
userProfile?.id,
|
userProfile?.id,
|
||||||
])
|
])
|
||||||
@@ -209,18 +240,12 @@ const MyChores = () => {
|
|||||||
choresData?.res
|
choresData?.res
|
||||||
) {
|
) {
|
||||||
const processEffectAsync = async () => {
|
const processEffectAsync = async () => {
|
||||||
|
// Sync local state with query data to ensure updates are reflected
|
||||||
setChores(processedChores)
|
setChores(processedChores)
|
||||||
setFilteredChores(processedChores)
|
setFilteredChores(processedChores)
|
||||||
|
|
||||||
// Only update sections if they've actually changed
|
// Don't set choreSections here - let the dedicated effect handle it
|
||||||
setChoreSections(prevSections => {
|
// This prevents caching issues when switching between projects
|
||||||
if (
|
|
||||||
JSON.stringify(prevSections) === JSON.stringify(processedSections)
|
|
||||||
) {
|
|
||||||
return prevSections
|
|
||||||
}
|
|
||||||
return processedSections
|
|
||||||
})
|
|
||||||
|
|
||||||
if (localStorage.getItem('openChoreSections') === null) {
|
if (localStorage.getItem('openChoreSections') === null) {
|
||||||
setSelectedChoreSectionWithCache(selectedChoreSection)
|
setSelectedChoreSectionWithCache(selectedChoreSection)
|
||||||
@@ -252,17 +277,20 @@ const MyChores = () => {
|
|||||||
isUserProfileLoading,
|
isUserProfileLoading,
|
||||||
choresData?.res,
|
choresData?.res,
|
||||||
membersData?.res,
|
membersData?.res,
|
||||||
// userProfile?.id, NOT HERE
|
processedChores, // Added to ensure local state syncs when query data updates
|
||||||
|
processedSections,
|
||||||
|
userProfile,
|
||||||
impersonatedUser?.userId,
|
impersonatedUser?.userId,
|
||||||
selectedChoreSection,
|
selectedChoreSection,
|
||||||
])
|
])
|
||||||
|
|
||||||
// Auto-update sections when processedSections changes
|
// Auto-update sections when processedSections changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (processedSections.length > 0) {
|
// Always update choreSections to match processedSections, even if empty
|
||||||
setChoreSections(processedSections)
|
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')
|
const storedSections = localStorage.getItem('openChoreSections')
|
||||||
if (storedSections === null) {
|
if (storedSections === null) {
|
||||||
const openSections = processedSections.reduce(
|
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 = {
|
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: {
|
rescheduled: {
|
||||||
type: 'success',
|
type: 'success',
|
||||||
title: 'Task Rescheduled',
|
title: 'Task Rescheduled',
|
||||||
@@ -581,16 +648,6 @@ const MyChores = () => {
|
|||||||
title: 'Task Paused',
|
title: 'Task Paused',
|
||||||
message: 'The task has been 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: {
|
deleted: {
|
||||||
type: 'success',
|
type: 'success',
|
||||||
title: 'Task Deleted',
|
title: 'Task Deleted',
|
||||||
@@ -966,18 +1023,22 @@ const MyChores = () => {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (projectId && chores.length > 0) {
|
if (projectId && chores.length > 0 && projectsWithDefault.length > 0) {
|
||||||
const decodedProject = Number(projectId ? projectId : '')
|
const decodedProjectId = decodeURIComponent(projectId)
|
||||||
// get the project name :
|
let project = null
|
||||||
const project = projects.find(p => p.id === decodedProject)
|
|
||||||
|
|
||||||
const projectFiltered = filterByProject(chores, decodedProject)
|
// Try to find project by ID first, then by name for backward compatibility
|
||||||
console.log('Filtered chores:', projectFiltered.length)
|
if (decodedProjectId === 'default') {
|
||||||
|
project = { id: 'default', name: 'Default Project' }
|
||||||
|
} else {
|
||||||
|
project = projectsWithDefault.find(
|
||||||
|
p => p.id === decodedProjectId || p.id === Number(decodedProjectId),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
setFilteredChores(projectFiltered)
|
if (project) {
|
||||||
setSearchFilter(`Project: ${project ? project.name : 'default'}`)
|
setSelectedProjectWithCache(project)
|
||||||
setViewMode('default')
|
}
|
||||||
setSelectedCalendarDate(null)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1006,7 +1067,7 @@ const MyChores = () => {
|
|||||||
setSearchFilter(filterKey)
|
setSearchFilter(filterKey)
|
||||||
setViewMode('default')
|
setViewMode('default')
|
||||||
}
|
}
|
||||||
}, [searchParams, chores])
|
}, [searchParams, chores, projectsWithDefault])
|
||||||
const setSelectedChoreSectionWithCache = value => {
|
const setSelectedChoreSectionWithCache = value => {
|
||||||
setSelectedChoreSection(value)
|
setSelectedChoreSection(value)
|
||||||
localStorage.setItem('selectedChoreSection', value)
|
localStorage.setItem('selectedChoreSection', value)
|
||||||
@@ -1022,6 +1083,32 @@ const MyChores = () => {
|
|||||||
setSelectedCalendarDate(null)
|
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 toggleViewMode = () => {
|
||||||
const modes = ['default', 'compact', 'calendar']
|
const modes = ['default', 'compact', 'calendar']
|
||||||
const currentIndex = modes.indexOf(viewMode)
|
const currentIndex = modes.indexOf(viewMode)
|
||||||
@@ -1063,13 +1150,47 @@ const MyChores = () => {
|
|||||||
return result
|
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(() => {
|
const getFilteredChores = useMemo(() => {
|
||||||
let result = []
|
let result = []
|
||||||
|
let baseChores = projectFilteredChores // Start with project-filtered chores
|
||||||
|
|
||||||
if (searchTerm?.length > 0 || searchFilter !== 'All') {
|
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 {
|
} else {
|
||||||
let choresToFilter = chores
|
let choresToFilter = baseChores
|
||||||
|
|
||||||
if (impersonatedUser) {
|
if (impersonatedUser) {
|
||||||
choresToFilter = choresToFilter.filter(
|
choresToFilter = choresToFilter.filter(
|
||||||
@@ -1089,7 +1210,8 @@ const MyChores = () => {
|
|||||||
searchTerm,
|
searchTerm,
|
||||||
searchFilter,
|
searchFilter,
|
||||||
filteredChores,
|
filteredChores,
|
||||||
chores,
|
projectFilteredChores,
|
||||||
|
selectedProject,
|
||||||
impersonatedUser,
|
impersonatedUser,
|
||||||
userProfile?.id,
|
userProfile?.id,
|
||||||
selectedChoreFilter,
|
selectedChoreFilter,
|
||||||
@@ -1152,9 +1274,12 @@ const MyChores = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleLabelFiltering = chipClicked => {
|
const handleLabelFiltering = chipClicked => {
|
||||||
|
// Start with project-filtered chores as base
|
||||||
|
const baseChores = selectedProject ? projectFilteredChores : chores
|
||||||
|
|
||||||
if (chipClicked.label) {
|
if (chipClicked.label) {
|
||||||
const label = chipClicked.label
|
const label = chipClicked.label
|
||||||
const labelFiltered = [...chores].filter(chore =>
|
const labelFiltered = baseChores.filter(chore =>
|
||||||
chore.labelsV2.some(
|
chore.labelsV2.some(
|
||||||
l => l.id === label.id && l.created_by === label.created_by,
|
l => l.id === label.id && l.created_by === label.created_by,
|
||||||
),
|
),
|
||||||
@@ -1163,7 +1288,7 @@ const MyChores = () => {
|
|||||||
setSearchFilter('Label: ' + label.name)
|
setSearchFilter('Label: ' + label.name)
|
||||||
} else if (chipClicked.priority) {
|
} else if (chipClicked.priority) {
|
||||||
const priority = chipClicked.priority
|
const priority = chipClicked.priority
|
||||||
const priorityFiltered = chores.filter(
|
const priorityFiltered = baseChores.filter(
|
||||||
chore => chore.priority === priority,
|
chore => chore.priority === priority,
|
||||||
)
|
)
|
||||||
setFilteredChores(priorityFiltered)
|
setFilteredChores(priorityFiltered)
|
||||||
@@ -1203,7 +1328,7 @@ const MyChores = () => {
|
|||||||
}
|
}
|
||||||
const search = e.target.value
|
const search = e.target.value
|
||||||
if (search === '') {
|
if (search === '') {
|
||||||
setFilteredChores(chores)
|
setFilteredChores(selectedProject ? projectFilteredChores : chores)
|
||||||
setSearchTerm('')
|
setSearchTerm('')
|
||||||
// Clear selected calendar date when search changes
|
// Clear selected calendar date when search changes
|
||||||
setSelectedCalendarDate(null)
|
setSelectedCalendarDate(null)
|
||||||
@@ -1212,13 +1337,28 @@ const MyChores = () => {
|
|||||||
|
|
||||||
const term = search.toLowerCase()
|
const term = search.toLowerCase()
|
||||||
setSearchTerm(term)
|
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))
|
setFilteredChores(fuse.search(term).map(result => result.item))
|
||||||
// Clear selected calendar date when search changes
|
// Clear selected calendar date when search changes
|
||||||
setSelectedCalendarDate(null)
|
setSelectedCalendarDate(null)
|
||||||
}
|
}
|
||||||
const handleSearchClose = () => {
|
const handleSearchClose = () => {
|
||||||
setSearchTerm('')
|
setSearchTerm('')
|
||||||
setFilteredChores(chores)
|
setFilteredChores(selectedProject ? projectFilteredChores : chores)
|
||||||
// remove the focus from the search input:
|
// remove the focus from the search input:
|
||||||
setSearchInputFocus(0)
|
setSearchInputFocus(0)
|
||||||
// Clear selected calendar date when search closes
|
// Clear selected calendar date when search closes
|
||||||
@@ -1611,6 +1751,19 @@ const MyChores = () => {
|
|||||||
mouseClickHandler={handleMenuOutsideClick}
|
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 */}
|
{/* View Mode Toggle Button */}
|
||||||
<IconButton
|
<IconButton
|
||||||
variant='outlined'
|
variant='outlined'
|
||||||
@@ -1748,10 +1901,13 @@ const MyChores = () => {
|
|||||||
key={`filter-list-${filter}-${index}`}
|
key={`filter-list-${filter}-${index}`}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const filterFunction = FILTERS[filter]
|
const filterFunction = FILTERS[filter]
|
||||||
|
const baseChores = selectedProject
|
||||||
|
? projectFilteredChores
|
||||||
|
: chores
|
||||||
const filteredChores =
|
const filteredChores =
|
||||||
filterFunction.length === 2
|
filterFunction.length === 2
|
||||||
? filterFunction(chores, userProfile?.id)
|
? filterFunction(baseChores, userProfile?.id)
|
||||||
: filterFunction(chores)
|
: filterFunction(baseChores)
|
||||||
setFilteredChores(filteredChores)
|
setFilteredChores(filteredChores)
|
||||||
setSearchFilter(filter)
|
setSearchFilter(filter)
|
||||||
handleFilterMenuClose()
|
handleFilterMenuClose()
|
||||||
@@ -1761,9 +1917,15 @@ const MyChores = () => {
|
|||||||
<Chip
|
<Chip
|
||||||
color={searchFilter === filter ? 'primary' : 'neutral'}
|
color={searchFilter === filter ? 'primary' : 'neutral'}
|
||||||
>
|
>
|
||||||
{FILTERS[filter].length === 2
|
{(() => {
|
||||||
? FILTERS[filter](chores, userProfile?.id).length
|
const baseChores = selectedProject
|
||||||
: FILTERS[filter](chores).length}
|
? projectFilteredChores
|
||||||
|
: chores
|
||||||
|
return FILTERS[filter].length === 2
|
||||||
|
? FILTERS[filter](baseChores, userProfile?.id)
|
||||||
|
.length
|
||||||
|
: FILTERS[filter](baseChores).length
|
||||||
|
})()}
|
||||||
</Chip>
|
</Chip>
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
))}
|
))}
|
||||||
@@ -1773,7 +1935,9 @@ const MyChores = () => {
|
|||||||
<MenuItem
|
<MenuItem
|
||||||
key={`filter-list-cancel-all-filters`}
|
key={`filter-list-cancel-all-filters`}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setFilteredChores(chores)
|
setFilteredChores(
|
||||||
|
selectedProject ? projectFilteredChores : chores,
|
||||||
|
)
|
||||||
setSearchFilter('All')
|
setSearchFilter('All')
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -2091,6 +2255,7 @@ const MyChores = () => {
|
|||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
|
{/* Additional Filters Display */}
|
||||||
{searchFilter !== 'All' && (
|
{searchFilter !== 'All' && (
|
||||||
<Chip
|
<Chip
|
||||||
level='title-md'
|
level='title-md'
|
||||||
@@ -2098,19 +2263,26 @@ const MyChores = () => {
|
|||||||
color='warning'
|
color='warning'
|
||||||
label={searchFilter}
|
label={searchFilter}
|
||||||
onDelete={() => {
|
onDelete={() => {
|
||||||
setFilteredChores(chores)
|
setFilteredChores(
|
||||||
|
selectedProject ? projectFilteredChores : chores,
|
||||||
|
)
|
||||||
setSearchFilter('All')
|
setSearchFilter('All')
|
||||||
}}
|
}}
|
||||||
endDecorator={<CancelRounded />}
|
endDecorator={<CancelRounded />}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setFilteredChores(chores)
|
setFilteredChores(
|
||||||
|
selectedProject ? projectFilteredChores : chores,
|
||||||
|
)
|
||||||
setSearchFilter('All')
|
setSearchFilter('All')
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Current Filter: {searchFilter}
|
Additional Filter: {searchFilter}
|
||||||
</Chip>
|
</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:
|
// only if not in calendar view:
|
||||||
viewMode !== 'calendar' && (
|
viewMode !== 'calendar' && (
|
||||||
<Box
|
<Box
|
||||||
@@ -2136,8 +2308,10 @@ const MyChores = () => {
|
|||||||
<>
|
<>
|
||||||
<Button
|
<Button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setFilteredChores(chores)
|
// Reset search and filters to show all chores in current project
|
||||||
|
setSearchFilter('All')
|
||||||
setSearchTerm('')
|
setSearchTerm('')
|
||||||
|
// Clear any manual filteredChores and let the memo handle it
|
||||||
}}
|
}}
|
||||||
variant='outlined'
|
variant='outlined'
|
||||||
color='neutral'
|
color='neutral'
|
||||||
@@ -2150,7 +2324,7 @@ const MyChores = () => {
|
|||||||
)}
|
)}
|
||||||
{(searchTerm?.length > 0 || searchFilter !== 'All') &&
|
{(searchTerm?.length > 0 || searchFilter !== 'All') &&
|
||||||
viewMode !== 'calendar' &&
|
viewMode !== 'calendar' &&
|
||||||
filteredChores.map(chore =>
|
getFilteredChores.map(chore =>
|
||||||
renderChoreCard(chore, `filtered-${chore.id}`),
|
renderChoreCard(chore, `filtered-${chore.id}`),
|
||||||
)}
|
)}
|
||||||
{viewMode === 'calendar' && (
|
{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,
|
Button,
|
||||||
FormControl,
|
FormControl,
|
||||||
FormLabel,
|
FormLabel,
|
||||||
Grid,
|
|
||||||
Input,
|
Input,
|
||||||
|
Option,
|
||||||
|
Select,
|
||||||
Stack,
|
Stack,
|
||||||
Textarea,
|
Textarea,
|
||||||
Typography,
|
Typography,
|
||||||
@@ -15,8 +16,12 @@ import { useResponsiveModal } from '../../../hooks/useResponsiveModal'
|
|||||||
import LABEL_COLORS, {
|
import LABEL_COLORS, {
|
||||||
getTextColorFromBackgroundColor,
|
getTextColorFromBackgroundColor,
|
||||||
} from '../../../utils/Colors'
|
} from '../../../utils/Colors'
|
||||||
import { CreateProject, UpdateProject } from '../../../utils/Fetcher'
|
|
||||||
import PROJECT_ICONS, { getIconComponent } from '../../../utils/ProjectIcons'
|
import PROJECT_ICONS, { getIconComponent } from '../../../utils/ProjectIcons'
|
||||||
|
import {
|
||||||
|
useCreateProject,
|
||||||
|
useUpdateProject,
|
||||||
|
} from '../../Projects/ProjectQueries'
|
||||||
|
import IconPickerModal from './IconPickerModal'
|
||||||
|
|
||||||
const ProjectModal = ({ isOpen, onClose, onSave, project }) => {
|
const ProjectModal = ({ isOpen, onClose, onSave, project }) => {
|
||||||
const { ResponsiveModal } = useResponsiveModal()
|
const { ResponsiveModal } = useResponsiveModal()
|
||||||
@@ -24,8 +29,11 @@ const ProjectModal = ({ isOpen, onClose, onSave, project }) => {
|
|||||||
const [projectDescription, setProjectDescription] = useState('')
|
const [projectDescription, setProjectDescription] = useState('')
|
||||||
const [projectColor, setProjectColor] = useState(LABEL_COLORS[0].value)
|
const [projectColor, setProjectColor] = useState(LABEL_COLORS[0].value)
|
||||||
const [projectIcon, setProjectIcon] = useState(PROJECT_ICONS[0].value)
|
const [projectIcon, setProjectIcon] = useState(PROJECT_ICONS[0].value)
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
|
const [isIconPickerOpen, setIsIconPickerOpen] = useState(false)
|
||||||
|
|
||||||
|
const createProjectMutation = useCreateProject()
|
||||||
|
const updateProjectMutation = useUpdateProject()
|
||||||
|
|
||||||
// Initialize form when modal opens or project changes
|
// Initialize form when modal opens or project changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -44,11 +52,10 @@ const ProjectModal = ({ isOpen, onClose, onSave, project }) => {
|
|||||||
setProjectIcon(PROJECT_ICONS[0].value)
|
setProjectIcon(PROJECT_ICONS[0].value)
|
||||||
}
|
}
|
||||||
setError('')
|
setError('')
|
||||||
setIsSubmitting(false)
|
|
||||||
}
|
}
|
||||||
}, [isOpen, project])
|
}, [isOpen, project])
|
||||||
|
|
||||||
const handleSubmit = async e => {
|
const handleSubmit = e => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
|
|
||||||
if (!projectName.trim()) {
|
if (!projectName.trim()) {
|
||||||
@@ -56,54 +63,68 @@ const ProjectModal = ({ isOpen, onClose, onSave, project }) => {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
setIsSubmitting(true)
|
|
||||||
setError('')
|
setError('')
|
||||||
|
|
||||||
try {
|
const projectData = {
|
||||||
const projectData = {
|
name: projectName.trim(),
|
||||||
name: projectName.trim(),
|
description: projectDescription.trim(),
|
||||||
description: projectDescription.trim(),
|
color: projectColor,
|
||||||
color: projectColor,
|
icon: projectIcon,
|
||||||
icon: projectIcon,
|
}
|
||||||
}
|
|
||||||
|
|
||||||
let response
|
if (project) {
|
||||||
if (project) {
|
// Update existing project
|
||||||
// Update existing project
|
updateProjectMutation.mutate(
|
||||||
response = await UpdateProject(project.id, projectData)
|
{ projectId: project.id, projectData },
|
||||||
} else {
|
{
|
||||||
// Create new project
|
onSuccess: updatedProject => {
|
||||||
response = await CreateProject(projectData)
|
onSave(updatedProject)
|
||||||
}
|
onClose()
|
||||||
|
},
|
||||||
if (response.ok) {
|
onError: error => {
|
||||||
const savedProject = await response.json()
|
console.error('Error updating project:', error)
|
||||||
onSave(savedProject.res || savedProject)
|
setError('Failed to update project')
|
||||||
onClose()
|
},
|
||||||
} else {
|
},
|
||||||
const errorData = await response.json()
|
)
|
||||||
setError(errorData.message || 'Failed to save project')
|
} else {
|
||||||
}
|
// Create new project
|
||||||
} catch (error) {
|
createProjectMutation.mutate(projectData, {
|
||||||
console.error('Error saving project:', error)
|
onSuccess: newProject => {
|
||||||
setError('An unexpected error occurred')
|
onSave(newProject)
|
||||||
} finally {
|
onClose()
|
||||||
setIsSubmitting(false)
|
},
|
||||||
|
onError: error => {
|
||||||
|
console.error('Error creating project:', error)
|
||||||
|
setError('Failed to create project')
|
||||||
|
},
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleClose = () => {
|
const handleClose = () => {
|
||||||
if (!isSubmitting) {
|
const isLoading =
|
||||||
|
createProjectMutation.isPending || updateProjectMutation.isPending
|
||||||
|
if (!isLoading) {
|
||||||
onClose()
|
onClose()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isSubmitting =
|
||||||
|
createProjectMutation.isPending || updateProjectMutation.isPending
|
||||||
|
|
||||||
|
const handleIconSelect = iconValue => {
|
||||||
|
setProjectIcon(iconValue)
|
||||||
|
setIsIconPickerOpen(false)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ResponsiveModal
|
<ResponsiveModal
|
||||||
open={isOpen}
|
open={isOpen}
|
||||||
onClose={handleClose}
|
onClose={handleClose}
|
||||||
size='md'
|
size='md'
|
||||||
unmountDelay={250}
|
unmountDelay={250}
|
||||||
|
fullWidth={true}
|
||||||
>
|
>
|
||||||
<Typography level='h4' mb={2}>
|
<Typography level='h4' mb={2}>
|
||||||
{project ? 'Edit Project' : 'Create New Project'}
|
{project ? 'Edit Project' : 'Create New Project'}
|
||||||
@@ -139,148 +160,82 @@ const ProjectModal = ({ isOpen, onClose, onSave, project }) => {
|
|||||||
{/* Icon Selection */}
|
{/* Icon Selection */}
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<FormLabel>Project Icon</FormLabel>
|
<FormLabel>Project Icon</FormLabel>
|
||||||
<Typography level='body-sm' sx={{ mb: 1, color: 'text.tertiary' }}>
|
<Button
|
||||||
Choose an icon to represent your project
|
variant='outlined'
|
||||||
</Typography>
|
onClick={() => setIsIconPickerOpen(true)}
|
||||||
<Grid
|
startDecorator={
|
||||||
container
|
<Avatar
|
||||||
spacing={1}
|
size='sm'
|
||||||
sx={{ maxHeight: '200px', overflowY: 'auto', mb: 2 }}
|
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 => {
|
{PROJECT_ICONS.find(icon => icon.value === projectIcon)?.name ||
|
||||||
const IconComponent = iconData.icon
|
'Select Icon'}
|
||||||
return (
|
</Button>
|
||||||
<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>
|
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
|
||||||
{/* Color Selection */}
|
{/* Color Selection */}
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<FormLabel>Project Color</FormLabel>
|
<FormLabel>Project Color</FormLabel>
|
||||||
<Typography level='body-sm' sx={{ mb: 1, color: 'text.tertiary' }}>
|
<Select
|
||||||
Choose a color to help identify your project
|
value={projectColor}
|
||||||
</Typography>
|
onChange={(e, value) => value && setProjectColor(value)}
|
||||||
<Grid
|
renderValue={selected => (
|
||||||
container
|
<Typography
|
||||||
spacing={1}
|
startDecorator={
|
||||||
sx={{ maxHeight: '200px', overflowY: 'auto' }}
|
<Box
|
||||||
|
className='size-4'
|
||||||
|
borderRadius={10}
|
||||||
|
sx={{ background: selected.value }}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{selected.label}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
{LABEL_COLORS.map(color => (
|
{LABEL_COLORS.map(color => (
|
||||||
<Grid key={color.value} xs={3} sm={2}>
|
<Option key={color.value} value={color.value}>
|
||||||
<Box
|
<Box className='flex items-center justify-between'>
|
||||||
sx={{
|
<Box
|
||||||
display: 'flex',
|
width={20}
|
||||||
flexDirection: 'column',
|
height={20}
|
||||||
alignItems: 'center',
|
borderRadius={10}
|
||||||
cursor: 'pointer',
|
sx={{ background: color.value }}
|
||||||
p: 1,
|
/>
|
||||||
borderRadius: 'sm',
|
<Typography sx={{ ml: 1 }} variant='caption'>
|
||||||
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,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{color.name}
|
{color.name}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
</Grid>
|
</Option>
|
||||||
))}
|
))}
|
||||||
</Grid>
|
</Select>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
|
||||||
{/* Project Preview */}
|
{/* Project Preview */}
|
||||||
@@ -304,6 +259,13 @@ const ProjectModal = ({ isOpen, onClose, onSave, project }) => {
|
|||||||
width: 32,
|
width: 32,
|
||||||
height: 32,
|
height: 32,
|
||||||
bgcolor: projectColor,
|
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={{
|
sx={{
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
color: getTextColorFromBackgroundColor(projectColor),
|
color: getTextColorFromBackgroundColor(projectColor),
|
||||||
|
display: 'block',
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
@@ -360,6 +323,14 @@ const ProjectModal = ({ isOpen, onClose, onSave, project }) => {
|
|||||||
</Button>
|
</Button>
|
||||||
</Box>
|
</Box>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
<IconPickerModal
|
||||||
|
isOpen={isIconPickerOpen}
|
||||||
|
onClose={() => setIsIconPickerOpen(false)}
|
||||||
|
onSelect={handleIconSelect}
|
||||||
|
currentIcon={projectIcon}
|
||||||
|
projectColor={projectColor}
|
||||||
|
/>
|
||||||
</ResponsiveModal>
|
</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,
|
Typography,
|
||||||
} from '@mui/joy'
|
} from '@mui/joy'
|
||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
import ProjectModal from '../Modals/Inputs/ProjectModal'
|
import ProjectModal from '../Modals/Inputs/ProjectModal'
|
||||||
|
|
||||||
import { Add, FolderOpen, Task } from '@mui/icons-material'
|
import { Add, FolderOpen, Task } from '@mui/icons-material'
|
||||||
import { useQueryClient } from '@tanstack/react-query'
|
import { useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { useChores } from '../../queries/ChoreQueries'
|
||||||
import { useUserProfile } from '../../queries/UserQueries'
|
import { useUserProfile } from '../../queries/UserQueries'
|
||||||
import LABEL_COLORS, {
|
import LABEL_COLORS, {
|
||||||
getTextColorFromBackgroundColor,
|
getTextColorFromBackgroundColor,
|
||||||
@@ -25,7 +27,15 @@ import { getSafeBottomStyles } from '../../utils/SafeAreaUtils'
|
|||||||
import ConfirmationModal from '../Modals/Inputs/ConfirmationModal'
|
import ConfirmationModal from '../Modals/Inputs/ConfirmationModal'
|
||||||
import { useProjects } from './ProjectQueries'
|
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
|
// Helper function to get color name from hex value
|
||||||
const getColorName = hexValue => {
|
const getColorName = hexValue => {
|
||||||
const colorObj = LABEL_COLORS.find(
|
const colorObj = LABEL_COLORS.find(
|
||||||
@@ -213,49 +223,30 @@ const ProjectCard = ({ project, onEditClick, onDeleteClick, currentUserId, taskC
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Action buttons underneath (revealed on swipe) */}
|
{/* Action buttons underneath (revealed on swipe) */}
|
||||||
<Box
|
{isEditable && (
|
||||||
sx={{
|
<Box
|
||||||
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)
|
|
||||||
}}
|
|
||||||
sx={{
|
sx={{
|
||||||
width: 40,
|
position: 'absolute',
|
||||||
height: 40,
|
right: 0,
|
||||||
mx: 1,
|
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
|
<IconButton
|
||||||
variant='soft'
|
variant='soft'
|
||||||
color='danger'
|
color='neutral'
|
||||||
size='sm'
|
size='sm'
|
||||||
onClick={e => {
|
onClick={e => {
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
resetSwipe()
|
resetSwipe()
|
||||||
onDeleteClick(project.id)
|
onEditClick(project)
|
||||||
}}
|
}}
|
||||||
sx={{
|
sx={{
|
||||||
width: 40,
|
width: 40,
|
||||||
@@ -263,10 +254,31 @@ const ProjectCard = ({ project, onEditClick, onDeleteClick, currentUserId, taskC
|
|||||||
mx: 1,
|
mx: 1,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<DeleteIcon sx={{ fontSize: 16 }} />
|
<EditIcon sx={{ fontSize: 16 }} />
|
||||||
</IconButton>
|
</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 */}
|
{/* Main card content */}
|
||||||
<Box
|
<Box
|
||||||
@@ -295,62 +307,68 @@ const ProjectCard = ({ project, onEditClick, onDeleteClick, currentUserId, taskC
|
|||||||
resetSwipe()
|
resetSwipe()
|
||||||
return
|
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}
|
onTouchStart={isEditable ? handleTouchStart : undefined}
|
||||||
onTouchMove={handleTouchMove}
|
onTouchMove={isEditable ? handleTouchMove : undefined}
|
||||||
onTouchEnd={handleTouchEnd}
|
onTouchEnd={isEditable ? handleTouchEnd : undefined}
|
||||||
onMouseDown={handleMouseDown}
|
onMouseDown={isEditable ? handleMouseDown : undefined}
|
||||||
onMouseMove={handleMouseMove}
|
onMouseMove={isEditable ? handleMouseMove : undefined}
|
||||||
onMouseUp={handleMouseUp}
|
onMouseUp={isEditable ? handleMouseUp : undefined}
|
||||||
>
|
>
|
||||||
{/* Right drag area */}
|
{/* Right drag area */}
|
||||||
<Box
|
{isEditable && (
|
||||||
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 */}
|
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
|
position: 'absolute',
|
||||||
|
right: 0,
|
||||||
|
top: 0,
|
||||||
|
bottom: 0,
|
||||||
|
width: '20px',
|
||||||
|
cursor: 'grab',
|
||||||
|
zIndex: 2,
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'column',
|
alignItems: 'center',
|
||||||
gap: 0.25,
|
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) => (
|
{/* Drag indicator dots */}
|
||||||
<Box
|
<Box
|
||||||
key={i}
|
sx={{
|
||||||
sx={{
|
display: 'flex',
|
||||||
width: 3,
|
flexDirection: 'column',
|
||||||
height: 3,
|
gap: 0.25,
|
||||||
borderRadius: '50%',
|
}}
|
||||||
bgcolor: 'text.tertiary',
|
>
|
||||||
}}
|
{[...Array(3)].map((_, i) => (
|
||||||
/>
|
<Box
|
||||||
))}
|
key={i}
|
||||||
|
sx={{
|
||||||
|
width: 3,
|
||||||
|
height: 3,
|
||||||
|
borderRadius: '50%',
|
||||||
|
bgcolor: 'text.tertiary',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
)}
|
||||||
|
|
||||||
{/* Project Avatar */}
|
{/* Project Avatar */}
|
||||||
<Box
|
<Box
|
||||||
@@ -387,7 +405,9 @@ const ProjectCard = ({ project, onEditClick, onDeleteClick, currentUserId, taskC
|
|||||||
<IconComponent
|
<IconComponent
|
||||||
sx={{
|
sx={{
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
color: getTextColorFromBackgroundColor(project.color || '#1976d2'),
|
color: getTextColorFromBackgroundColor(
|
||||||
|
project.color || '#1976d2',
|
||||||
|
),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
@@ -396,7 +416,9 @@ const ProjectCard = ({ project, onEditClick, onDeleteClick, currentUserId, taskC
|
|||||||
<Typography
|
<Typography
|
||||||
level='body-xs'
|
level='body-xs'
|
||||||
sx={{
|
sx={{
|
||||||
color: getTextColorFromBackgroundColor(project.color || '#1976d2'),
|
color: getTextColorFromBackgroundColor(
|
||||||
|
project.color || '#1976d2',
|
||||||
|
),
|
||||||
fontWeight: 'bold',
|
fontWeight: 'bold',
|
||||||
fontSize: 10,
|
fontSize: 10,
|
||||||
}}
|
}}
|
||||||
@@ -523,6 +545,7 @@ const ProjectCard = ({ project, onEditClick, onDeleteClick, currentUserId, taskC
|
|||||||
const ProjectView = () => {
|
const ProjectView = () => {
|
||||||
const { data: projects, isProjectsLoading, isError } = useProjects()
|
const { data: projects, isProjectsLoading, isError } = useProjects()
|
||||||
const { data: userProfile } = useUserProfile()
|
const { data: userProfile } = useUserProfile()
|
||||||
|
const { data: chores = [] } = useChores(false) // false to exclude archived
|
||||||
|
|
||||||
const [userProjects, setUserProjects] = useState([])
|
const [userProjects, setUserProjects] = useState([])
|
||||||
const [modalOpen, setModalOpen] = useState(false)
|
const [modalOpen, setModalOpen] = useState(false)
|
||||||
@@ -567,20 +590,8 @@ const ProjectView = () => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSaveProject = newOrUpdatedProject => {
|
const handleSaveProject = () => {
|
||||||
queryClient.invalidateQueries('projects')
|
|
||||||
setModalOpen(false)
|
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(() => {
|
useEffect(() => {
|
||||||
@@ -589,15 +600,33 @@ const ProjectView = () => {
|
|||||||
}
|
}
|
||||||
}, [projects])
|
}, [projects])
|
||||||
|
|
||||||
// TODO: Get actual task counts from API
|
// Calculate real task counts from chores data
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Mock task counts for now
|
if (chores && chores.res && userProjects.length > 0) {
|
||||||
const mockCounts = {}
|
const choresList = chores.res
|
||||||
userProjects.forEach(project => {
|
const realCounts = {}
|
||||||
mockCounts[project.id] = Math.floor(Math.random() * 20)
|
|
||||||
})
|
userProjects.forEach(project => {
|
||||||
setTaskCounts(mockCounts)
|
// Count chores for this project
|
||||||
}, [userProjects])
|
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) {
|
if (isProjectsLoading) {
|
||||||
return (
|
return (
|
||||||
@@ -642,21 +671,22 @@ const ProjectView = () => {
|
|||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{userProjects.length === 0 && (
|
{/* default project: */}
|
||||||
<Box
|
<ProjectCard
|
||||||
sx={{
|
key='default-project-card'
|
||||||
display: 'flex',
|
project={{
|
||||||
justifyContent: 'center',
|
id: 'default',
|
||||||
alignItems: 'center',
|
name: 'Default Project',
|
||||||
flexDirection: 'column',
|
description: 'All uncategorized tasks',
|
||||||
height: '50vh',
|
color: '#1976d2',
|
||||||
}}
|
icon: 'FolderOpen',
|
||||||
>
|
created_by: userProfile?.id,
|
||||||
<Typography level='title-md' gutterBottom>
|
}}
|
||||||
No projects available. Add a new project to get started.
|
isEditable={false}
|
||||||
</Typography>
|
currentUserId={userProfile?.id}
|
||||||
</Box>
|
onEditClick={() => {}}
|
||||||
)}
|
taskCounts={{ default: taskCounts.default || 0 }}
|
||||||
|
/>
|
||||||
{userProjects.map(project => (
|
{userProjects.map(project => (
|
||||||
<ProjectCard
|
<ProjectCard
|
||||||
key={project.id}
|
key={project.id}
|
||||||
@@ -665,6 +695,7 @@ const ProjectView = () => {
|
|||||||
onDeleteClick={handleDeleteClicked}
|
onDeleteClick={handleDeleteClicked}
|
||||||
currentUserId={userProfile?.id}
|
currentUserId={userProfile?.id}
|
||||||
taskCounts={taskCounts}
|
taskCounts={taskCounts}
|
||||||
|
isEditable={true}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</Box>
|
</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