703 lines
20 KiB
JavaScript
703 lines
20 KiB
JavaScript
import '@meauxt/react-swipeable-list/dist/styles.css'
|
|
|
|
import {
|
|
SwipeableList,
|
|
SwipeableListItem,
|
|
SwipeAction,
|
|
TrailingActions,
|
|
Type as ListType,
|
|
} from '@meauxt/react-swipeable-list'
|
|
import {
|
|
Add,
|
|
Close,
|
|
MoreVert,
|
|
Search,
|
|
SearchOff,
|
|
Task,
|
|
} from '@mui/icons-material'
|
|
import DeleteIcon from '@mui/icons-material/Delete'
|
|
import EditIcon from '@mui/icons-material/Edit'
|
|
import {
|
|
Avatar,
|
|
Box,
|
|
Chip,
|
|
CircularProgress,
|
|
Container,
|
|
IconButton,
|
|
Input,
|
|
Stack,
|
|
Typography,
|
|
} from '@mui/joy'
|
|
import { useQueryClient } from '@tanstack/react-query'
|
|
import Fuse from 'fuse.js'
|
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
|
import { useTranslation } from 'react-i18next'
|
|
import { useNavigate, useSearchParams } from 'react-router-dom'
|
|
|
|
import EmptyState from '../../components/common/EmptyState'
|
|
import SortAndFilterMenu from '../../components/common/SortAndFilterMenu'
|
|
import { useChores } from '../../queries/ChoreQueries'
|
|
import { useUserProfile } from '../../queries/UserQueries'
|
|
import { getTextColorFromBackgroundColor } from '../../utils/Colors'
|
|
import { DeleteProject } from '../../utils/Fetcher'
|
|
import { getIconComponent } from '../../utils/ProjectIcons'
|
|
import { getSafeBottomStyles } from '../../utils/SafeAreaUtils'
|
|
import { useProjectFilter } from '../Chores/hooks/useProjectFilter'
|
|
import ConfirmationModal from '../Modals/Inputs/ConfirmationModal'
|
|
import ProjectModal from '../Modals/Inputs/ProjectModal'
|
|
import { useProjects } from './ProjectQueries'
|
|
const ProjectCardContent = ({
|
|
currentUserId,
|
|
onCardClick,
|
|
onToggleActions,
|
|
project,
|
|
taskCounts = {},
|
|
}) => {
|
|
const { t } = useTranslation('projects')
|
|
// Check if current user owns this project
|
|
const isOwnedByCurrentUser = project.created_by === currentUserId
|
|
const isDefaultProject = project.id === 'default'
|
|
const taskCount = taskCounts[project.id] || 0
|
|
|
|
return (
|
|
<Box
|
|
sx={{
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
minHeight: 64,
|
|
width: '100%',
|
|
px: 2,
|
|
py: 1.5,
|
|
bgcolor: 'background.body',
|
|
borderBottom: '1px solid',
|
|
borderColor: 'divider',
|
|
cursor: 'pointer',
|
|
}}
|
|
onClick={onCardClick}
|
|
>
|
|
{/* Project Avatar */}
|
|
<Box
|
|
sx={{
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
mr: 2,
|
|
flexShrink: 0,
|
|
}}
|
|
>
|
|
<Avatar
|
|
size='sm'
|
|
sx={{
|
|
width: 32,
|
|
height: 32,
|
|
bgcolor: project.color || 'primary.500',
|
|
border: '2px solid',
|
|
borderColor: isDefaultProject
|
|
? 'primary.300'
|
|
: isOwnedByCurrentUser
|
|
? 'background.surface'
|
|
: 'warning.300',
|
|
boxShadow: isDefaultProject
|
|
? '0 0 0 1px var(--joy-palette-primary-300)'
|
|
: isOwnedByCurrentUser
|
|
? 'sm'
|
|
: '0 0 0 1px var(--joy-palette-warning-300)',
|
|
}}
|
|
>
|
|
{project.icon ? (
|
|
(() => {
|
|
const IconComponent = getIconComponent(project.icon)
|
|
return (
|
|
<IconComponent
|
|
sx={{
|
|
fontSize: 16,
|
|
color: getTextColorFromBackgroundColor(
|
|
project.color || '#1976d2',
|
|
),
|
|
}}
|
|
/>
|
|
)
|
|
})()
|
|
) : (
|
|
<></>
|
|
)}
|
|
</Avatar>
|
|
</Box>
|
|
|
|
{/* Content - Center */}
|
|
<Box
|
|
sx={{
|
|
flex: 1,
|
|
minWidth: 0,
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
}}
|
|
>
|
|
{/* Project Name */}
|
|
<Typography
|
|
level='title-sm'
|
|
sx={{
|
|
fontWeight: 600,
|
|
fontSize: 14,
|
|
overflow: 'hidden',
|
|
textOverflow: 'ellipsis',
|
|
whiteSpace: 'nowrap',
|
|
mb: 0.25,
|
|
}}
|
|
>
|
|
{project.name}
|
|
{isDefaultProject && (
|
|
<Chip
|
|
size='sm'
|
|
variant='soft'
|
|
color='primary'
|
|
sx={{
|
|
fontSize: 9,
|
|
height: 16,
|
|
px: 0.5,
|
|
ml: 1,
|
|
fontWeight: 'md',
|
|
}}
|
|
>
|
|
{t('defaultChip')}
|
|
</Chip>
|
|
)}
|
|
</Typography>
|
|
|
|
{/* Project Info */}
|
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
|
{project.description && (
|
|
<Typography
|
|
level='body-xs'
|
|
sx={{
|
|
color: 'text.tertiary',
|
|
overflow: 'hidden',
|
|
textOverflow: 'ellipsis',
|
|
whiteSpace: 'nowrap',
|
|
maxWidth: '200px',
|
|
}}
|
|
>
|
|
{project.description}
|
|
</Typography>
|
|
)}
|
|
|
|
<Chip
|
|
size='sm'
|
|
variant='soft'
|
|
startDecorator={<Task />}
|
|
sx={{
|
|
fontSize: 10,
|
|
height: 18,
|
|
px: 0.75,
|
|
bgcolor: 'primary.softBg',
|
|
color: 'primary.500',
|
|
}}
|
|
>
|
|
{t('tasks', { count: taskCount })}
|
|
</Chip>
|
|
|
|
{!isOwnedByCurrentUser && !isDefaultProject && (
|
|
<Chip
|
|
size='sm'
|
|
variant='soft'
|
|
color='warning'
|
|
sx={{
|
|
fontSize: 9,
|
|
height: 16,
|
|
px: 0.5,
|
|
fontWeight: 'md',
|
|
}}
|
|
>
|
|
{t('shared')}
|
|
</Chip>
|
|
)}
|
|
</Box>
|
|
</Box>
|
|
<Box>
|
|
{onToggleActions && (
|
|
<IconButton
|
|
color='neutral'
|
|
variant='plain'
|
|
size='sm'
|
|
onClick={e => {
|
|
e.stopPropagation()
|
|
onToggleActions()
|
|
}}
|
|
>
|
|
<MoreVert sx={{ fontSize: 18 }} />
|
|
</IconButton>
|
|
)}
|
|
</Box>
|
|
</Box>
|
|
)
|
|
}
|
|
|
|
const ProjectView = () => {
|
|
const { t } = useTranslation('projects')
|
|
const { data: projects, isError, isProjectsLoading } = useProjects()
|
|
const { data: userProfile } = useUserProfile()
|
|
const { data: chores = { res: [] } } = useChores(false) // false to exclude archived
|
|
const { data: projectsData = [], isLoading: projectsLoading } = useProjects()
|
|
const { setSelectedProjectWithCache } = useProjectFilter(
|
|
projectsData,
|
|
!projectsLoading,
|
|
)
|
|
const navigate = useNavigate()
|
|
const [searchParams, setSearchParams] = useSearchParams()
|
|
|
|
const [userProjects, setUserProjects] = useState([])
|
|
const [modalOpen, setModalOpen] = useState(false)
|
|
const [currentProject, setCurrentProject] = useState(null)
|
|
const [taskCounts, setTaskCounts] = useState({})
|
|
const queryClient = useQueryClient()
|
|
const [confirmationModel, setConfirmationModel] = useState({})
|
|
const [showMoreInfoId, setShowMoreInfoId] = useState(null)
|
|
const [searchTerm, setSearchTerm] = useState('')
|
|
const [sortBy, setSortBy] = useState(
|
|
() => localStorage.getItem('projectsSortBy') || 'name',
|
|
)
|
|
const [sortDirection, setSortDirection] = useState(
|
|
() => localStorage.getItem('projectsSortDirection') || 'asc',
|
|
)
|
|
const [ownershipFilter, setOwnershipFilter] = useState('all')
|
|
const searchInputRef = useRef(null)
|
|
|
|
useEffect(() => {
|
|
localStorage.setItem('projectsSortBy', sortBy)
|
|
localStorage.setItem('projectsSortDirection', sortDirection)
|
|
}, [sortBy, sortDirection])
|
|
|
|
const visibleProjects = useMemo(() => {
|
|
if (ownershipFilter === 'mine') {
|
|
return userProjects.filter(
|
|
project => project.created_by === userProfile?.id,
|
|
)
|
|
}
|
|
if (ownershipFilter === 'shared') {
|
|
return userProjects.filter(
|
|
project => project.created_by !== userProfile?.id,
|
|
)
|
|
}
|
|
return userProjects
|
|
}, [ownershipFilter, userProjects, userProfile?.id])
|
|
|
|
const fuse = useMemo(
|
|
() =>
|
|
new Fuse(visibleProjects, {
|
|
keys: ['name', 'description'],
|
|
includeScore: true,
|
|
isCaseSensitive: false,
|
|
findAllMatches: true,
|
|
}),
|
|
[visibleProjects],
|
|
)
|
|
|
|
const filteredProjects = useMemo(() => {
|
|
const matched = searchTerm
|
|
? fuse.search(searchTerm).map(result => result.item)
|
|
: visibleProjects
|
|
|
|
const direction = sortDirection === 'desc' ? -1 : 1
|
|
return [...matched].sort((a, b) => {
|
|
switch (sortBy) {
|
|
case 'tasks':
|
|
return direction * ((taskCounts[a.id] || 0) - (taskCounts[b.id] || 0))
|
|
case 'created':
|
|
return direction * ((a.id || 0) - (b.id || 0))
|
|
case 'name':
|
|
default:
|
|
return direction * (a.name || '').localeCompare(b.name || '')
|
|
}
|
|
})
|
|
}, [fuse, searchTerm, visibleProjects, sortBy, sortDirection, taskCounts])
|
|
|
|
// The default project is pinned above the list, so it is matched separately.
|
|
const showDefaultProject = useMemo(() => {
|
|
if (ownershipFilter === 'shared') return false
|
|
if (!searchTerm) return true
|
|
return t('chores:toolbar.defaultProject')
|
|
.toLowerCase()
|
|
.includes(searchTerm.toLowerCase())
|
|
}, [ownershipFilter, searchTerm, t])
|
|
|
|
const handleSearchChange = e => {
|
|
setSearchTerm(e.target.value)
|
|
setShowMoreInfoId(null)
|
|
}
|
|
|
|
const handleSearchClose = () => {
|
|
setSearchTerm('')
|
|
searchInputRef.current?.blur()
|
|
}
|
|
|
|
const handleAddProject = () => {
|
|
setCurrentProject(null)
|
|
setModalOpen(true)
|
|
}
|
|
|
|
const handleEditProject = project => {
|
|
setCurrentProject(project)
|
|
setModalOpen(true)
|
|
}
|
|
|
|
const handleDeleteClicked = id => {
|
|
const project = userProjects.find(p => p.id === id)
|
|
setConfirmationModel({
|
|
isOpen: true,
|
|
title: t('delete.title'),
|
|
message: t('delete.message', { name: project?.name }),
|
|
confirmText: t('common:delete'),
|
|
color: 'danger',
|
|
cancelText: t('common:cancel'),
|
|
onClose: confirmed => {
|
|
if (confirmed === true) {
|
|
handleDeleteProject(id)
|
|
}
|
|
setConfirmationModel({})
|
|
},
|
|
})
|
|
}
|
|
|
|
const handleDeleteProject = id => {
|
|
DeleteProject(id).then(() => {
|
|
const updatedProjects = userProjects.filter(project => project.id !== id)
|
|
setUserProjects(updatedProjects)
|
|
queryClient.invalidateQueries('projects')
|
|
|
|
// If the deleted project was the active project, clear it
|
|
const saved = localStorage.getItem('selectedProject')
|
|
if (saved) {
|
|
const savedProject = JSON.parse(saved)
|
|
if (savedProject && savedProject.id === id) {
|
|
setSelectedProjectWithCache(null)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
|
|
const handleSaveProject = () => {
|
|
setModalOpen(false)
|
|
}
|
|
|
|
const handleCardClick = 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
|
|
setSelectedProjectWithCache(project)
|
|
navigate(`/chores?project=${encodeURIComponent(projectIdentifier)}`)
|
|
}
|
|
|
|
useEffect(() => {
|
|
if (projects) {
|
|
setUserProjects(projects)
|
|
}
|
|
}, [projects])
|
|
|
|
// ?create=1 lets other surfaces (global search quick actions) land here with
|
|
// the create modal already open.
|
|
useEffect(() => {
|
|
if (searchParams.get('create') !== '1') return
|
|
setCurrentProject(null)
|
|
setModalOpen(true)
|
|
setSearchParams(
|
|
params => {
|
|
params.delete('create')
|
|
return params
|
|
},
|
|
{ replace: true },
|
|
)
|
|
}, [searchParams, setSearchParams])
|
|
|
|
// Calculate real task counts from chores data
|
|
useEffect(() => {
|
|
if (chores && chores.res) {
|
|
const choresList = chores.res
|
|
const realCounts = {}
|
|
|
|
// First, count tasks for the default project (tasks without a projectId)
|
|
const defaultProjectCount = choresList.filter(chore => {
|
|
const choreProjectId = chore.projectId || chore.project_id
|
|
return (
|
|
!choreProjectId ||
|
|
choreProjectId === '' ||
|
|
choreProjectId === 'default' ||
|
|
choreProjectId === null
|
|
)
|
|
}).length
|
|
realCounts['default'] = defaultProjectCount
|
|
|
|
// Then count tasks for each user project
|
|
userProjects.forEach(project => {
|
|
const choreCount = choresList.filter(chore => {
|
|
const choreProjectId = chore.projectId || chore.project_id
|
|
return choreProjectId === project.id
|
|
}).length
|
|
|
|
realCounts[project.id] = choreCount
|
|
})
|
|
|
|
setTaskCounts(realCounts)
|
|
}
|
|
}, [chores, userProjects])
|
|
|
|
if (isProjectsLoading) {
|
|
return (
|
|
<Box
|
|
display='flex'
|
|
justifyContent='center'
|
|
alignItems='center'
|
|
height='100vh'
|
|
>
|
|
<CircularProgress />
|
|
</Box>
|
|
)
|
|
}
|
|
|
|
if (isError) {
|
|
return (
|
|
<Typography color='danger' textAlign='center'>
|
|
{t('loadError')}
|
|
</Typography>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<Container maxWidth='md' sx={{ px: 0 }}>
|
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 2, p: 2 }}>
|
|
<Stack sx={{ flex: 1 }}>
|
|
<Typography
|
|
level='h3'
|
|
sx={{ fontWeight: 'lg', color: 'text.primary' }}
|
|
>
|
|
{t('common:navigation.projects')}
|
|
</Typography>
|
|
<Typography level='body-sm' sx={{ color: 'text.secondary' }}>
|
|
{t('blurb')}
|
|
</Typography>
|
|
</Stack>
|
|
</Box>
|
|
|
|
<Box sx={{ px: 2, mb: 2, display: 'flex', alignItems: 'center', gap: 1 }}>
|
|
<Input
|
|
slotProps={{ input: { ref: searchInputRef } }}
|
|
placeholder={t('search.placeholder')}
|
|
value={searchTerm}
|
|
fullWidth
|
|
sx={{
|
|
borderRadius: 24,
|
|
height: 24,
|
|
borderColor: 'text.disabled',
|
|
padding: 1,
|
|
}}
|
|
onChange={handleSearchChange}
|
|
startDecorator={<Search />}
|
|
endDecorator={
|
|
searchTerm && (
|
|
<IconButton
|
|
variant='plain'
|
|
size='sm'
|
|
onClick={handleSearchClose}
|
|
sx={{ borderRadius: '50%' }}
|
|
>
|
|
<Close />
|
|
</IconButton>
|
|
)
|
|
}
|
|
/>
|
|
<SortAndFilterMenu
|
|
sortOptions={[
|
|
{ name: 'Name', value: 'name' },
|
|
{ name: 'Task count', value: 'tasks' },
|
|
{ name: 'Recently created', value: 'created' },
|
|
]}
|
|
selectedSort={sortBy}
|
|
onSortChange={setSortBy}
|
|
sortDirection={sortDirection}
|
|
onSortDirectionChange={setSortDirection}
|
|
filterTitle='Show'
|
|
filterOptions={[
|
|
{ name: 'All projects', value: 'all' },
|
|
{ name: 'Created by me', value: 'mine' },
|
|
{ name: 'Shared with me', value: 'shared' },
|
|
]}
|
|
selectedFilter={ownershipFilter}
|
|
onFilterChange={value => {
|
|
setOwnershipFilter(value)
|
|
setShowMoreInfoId(null)
|
|
}}
|
|
isActive={
|
|
ownershipFilter !== 'all' ||
|
|
sortBy !== 'name' ||
|
|
sortDirection !== 'asc'
|
|
}
|
|
/>
|
|
</Box>
|
|
|
|
<Box
|
|
sx={{
|
|
overflow: 'hidden',
|
|
}}
|
|
>
|
|
{!showDefaultProject && filteredProjects.length === 0 && (
|
|
<EmptyState
|
|
variant='no-results'
|
|
fullHeight
|
|
icon={<SearchOff />}
|
|
title={t('search.noResultsTitle')}
|
|
description={
|
|
searchTerm
|
|
? t('search.noResultsDescription', { searchTerm })
|
|
: t('search.noFilterResultsDescription')
|
|
}
|
|
primaryAction={{
|
|
label: searchTerm ? t('search.clear') : t('search.showAll'),
|
|
onClick: () => {
|
|
handleSearchClose()
|
|
setOwnershipFilter('all')
|
|
},
|
|
}}
|
|
/>
|
|
)}
|
|
{/* Default project - not swipeable */}
|
|
{showDefaultProject && (
|
|
<ProjectCardContent
|
|
project={{
|
|
id: 'default',
|
|
name: t('chores:toolbar.defaultProject'),
|
|
description: t('defaultDescription'),
|
|
icon: 'FolderOpen',
|
|
color: '#1976d2',
|
|
created_by: userProfile?.id,
|
|
}}
|
|
currentUserId={userProfile?.id}
|
|
taskCounts={{ default: taskCounts.default || 0 }}
|
|
onCardClick={() =>
|
|
handleCardClick({
|
|
id: 'default',
|
|
name: t('chores:toolbar.defaultProject'),
|
|
icon: 'FolderOpen',
|
|
color: '#1976d2',
|
|
})
|
|
}
|
|
/>
|
|
)}
|
|
|
|
{/* User projects - swipeable */}
|
|
<SwipeableList type={ListType.IOS} fullSwipe={false}>
|
|
{filteredProjects.map(project => (
|
|
<SwipeableListItem
|
|
onClick={() => handleCardClick(project)}
|
|
key={project.id}
|
|
swipeActionOpen={
|
|
showMoreInfoId === project.id ? 'trailing' : null
|
|
}
|
|
trailingActions={
|
|
<TrailingActions>
|
|
<Box
|
|
sx={{
|
|
display: 'flex',
|
|
boxShadow: 'inset 2px 0 4px rgba(0,0,0,0.06)',
|
|
zIndex: 0,
|
|
}}
|
|
>
|
|
<SwipeAction onClick={() => handleEditProject(project)}>
|
|
<Box
|
|
sx={{
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
bgcolor: 'neutral.softBg',
|
|
color: 'neutral.700',
|
|
px: 3,
|
|
height: '100%',
|
|
}}
|
|
>
|
|
<EditIcon sx={{ fontSize: 20 }} />
|
|
<Typography level='body-xs' sx={{ mt: 0.5 }}>
|
|
{t('common:edit')}
|
|
</Typography>
|
|
</Box>
|
|
</SwipeAction>
|
|
<SwipeAction
|
|
onClick={() => handleDeleteClicked(project.id)}
|
|
>
|
|
<Box
|
|
sx={{
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
bgcolor: 'danger.softBg',
|
|
color: 'danger.700',
|
|
px: 3,
|
|
height: '100%',
|
|
}}
|
|
>
|
|
<DeleteIcon sx={{ fontSize: 20 }} />
|
|
<Typography level='body-xs' sx={{ mt: 0.5 }}>
|
|
{t('common:delete')}
|
|
</Typography>
|
|
</Box>
|
|
</SwipeAction>
|
|
</Box>
|
|
</TrailingActions>
|
|
}
|
|
>
|
|
<ProjectCardContent
|
|
project={project}
|
|
currentUserId={userProfile?.id}
|
|
taskCounts={taskCounts}
|
|
onToggleActions={() => {
|
|
if (showMoreInfoId === project.id) {
|
|
setShowMoreInfoId(null)
|
|
} else {
|
|
setShowMoreInfoId(project.id)
|
|
}
|
|
}}
|
|
/>
|
|
</SwipeableListItem>
|
|
))}
|
|
</SwipeableList>
|
|
</Box>
|
|
|
|
{modalOpen && (
|
|
<ProjectModal
|
|
isOpen={modalOpen}
|
|
onClose={() => setModalOpen(false)}
|
|
onSave={handleSaveProject}
|
|
project={currentProject}
|
|
/>
|
|
)}
|
|
|
|
<Box
|
|
sx={{
|
|
...getSafeBottomStyles({ bottom: 0, padding: 16 }),
|
|
left: 10,
|
|
display: 'flex',
|
|
justifyContent: 'flex-end',
|
|
gap: 2,
|
|
'z-index': 1000,
|
|
}}
|
|
>
|
|
<IconButton
|
|
data-testid='open-add-project-modal'
|
|
color='primary'
|
|
variant='solid'
|
|
sx={{
|
|
borderRadius: '50%',
|
|
width: 50,
|
|
height: 50,
|
|
}}
|
|
onClick={handleAddProject}
|
|
>
|
|
<Add />
|
|
</IconButton>
|
|
</Box>
|
|
|
|
<ConfirmationModal config={confirmationModel} />
|
|
</Container>
|
|
)
|
|
}
|
|
|
|
export default ProjectView
|