feat: Refactor Filter Section and Add Filter View
- Removed unused props and components from FilterSection. - Introduced FilterView component to manage and display saved filters. - Implemented swipe functionality for filter actions (edit, delete, pin). - Enhanced AdvancedFilterBuilder with description and color selection. - Deleted obsolete SaveFilterModal component. - Updated NavBar to include a link to the new Filters page. - Adjusted chore filter hooks for improved functionality and organization.
This commit is contained in:
@@ -30,6 +30,7 @@ import PaymentCancelledView from '../views/Payments/PaymentFailView'
|
||||
import PaymentSuccessView from '../views/Payments/PaymentSuccessView'
|
||||
import PrivacyPolicyView from '../views/PrivacyPolicy/PrivacyPolicyView'
|
||||
import ProjectView from '../views/Projects/ProjectView'
|
||||
import FilterView from '../views/Filters/FilterView'
|
||||
import APITokenSettings from '../views/Settings/APITokenSettings'
|
||||
import MFASettings from '../views/Settings/MFASettings'
|
||||
import NotificationSetting from '../views/Settings/NotificationSetting'
|
||||
@@ -233,6 +234,10 @@ const Router = createBrowserRouter([
|
||||
path: 'projects/',
|
||||
element: <ProjectView />,
|
||||
},
|
||||
{
|
||||
path: 'filters/',
|
||||
element: <FilterView />,
|
||||
},
|
||||
{
|
||||
path: '*',
|
||||
element: <NotFound />,
|
||||
|
||||
@@ -26,6 +26,10 @@ const LABEL_COLORS = [
|
||||
{ name: 'Sand', value: '#d7ccc8' },
|
||||
]
|
||||
|
||||
export const FILTER_COLORS = [
|
||||
...LABEL_COLORS.filter(color => color.name !== 'Default'),
|
||||
]
|
||||
|
||||
export const COLORS = {
|
||||
salmon: '#ff7961',
|
||||
teal: '#26a69a',
|
||||
|
||||
@@ -52,6 +52,8 @@ export const saveFilter = (filter) => {
|
||||
const newFilter = {
|
||||
id: generateFilterId(),
|
||||
name: filter.name,
|
||||
description: filter.description || '',
|
||||
color: filter.color || null,
|
||||
icon: filter.icon || null,
|
||||
conditions: filter.conditions,
|
||||
operator: filter.operator || 'AND',
|
||||
|
||||
@@ -34,7 +34,6 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom'
|
||||
import { useChores } from '../../queries/ChoreQueries'
|
||||
import { useNotification } from '../../service/NotificationProvider'
|
||||
import { TASK_COLOR } from '../../utils/Colors'
|
||||
import Priorities from '../../utils/Priorities'
|
||||
import LoadingComponent from '../components/Loading'
|
||||
import { useLabels } from '../Labels/LabelQueries'
|
||||
@@ -60,7 +59,6 @@ import CalendarDual from '../components/CalendarDual'
|
||||
import CalendarMonthly from '../components/CalendarMonthly.jsx'
|
||||
import ProjectSelector from '../components/ProjectSelector'
|
||||
import AdvancedFilterBuilder from '../Modals/Inputs/AdvancedFilterBuilder'
|
||||
import SaveFilterModal from '../Modals/Inputs/SaveFilterModal'
|
||||
import { useProjects } from '../Projects/ProjectQueries.js'
|
||||
import ChoreModals from './components/ChoreModals'
|
||||
import FilterSection from './components/FilterSection'
|
||||
@@ -179,7 +177,6 @@ const MyChores = () => {
|
||||
projectsWithDefault,
|
||||
)
|
||||
|
||||
const [showSaveFilterModal, setShowSaveFilterModal] = useState(false)
|
||||
const [showAdvancedFilterBuilder, setShowAdvancedFilterBuilder] =
|
||||
useState(false)
|
||||
const [editingFilter, setEditingFilter] = useState(null)
|
||||
@@ -736,6 +733,10 @@ const MyChores = () => {
|
||||
setFilteredChores(chores)
|
||||
setSearchFilter('All')
|
||||
}}
|
||||
onCreateNewFilter={() => {
|
||||
setShowAdvancedFilterBuilder(true)
|
||||
setEditingFilter(null)
|
||||
}}
|
||||
mouseClickHandler={handleMenuOutsideClick}
|
||||
/>
|
||||
|
||||
@@ -1100,7 +1101,7 @@ const MyChores = () => {
|
||||
{viewMode === 'calendar' && (
|
||||
<>
|
||||
{/* Summary Chips when no date selected */}
|
||||
<Box
|
||||
{/* <Box
|
||||
sx={{
|
||||
mt: 1,
|
||||
mb: 1,
|
||||
@@ -1213,7 +1214,7 @@ const MyChores = () => {
|
||||
Pending Approval
|
||||
</Chip>
|
||||
)}
|
||||
</Box>
|
||||
</Box> */}
|
||||
{/* Calendar Monthly View */}
|
||||
<Box sx={{ mb: 2 }}>
|
||||
{isLargeScreen ? (
|
||||
@@ -1467,38 +1468,6 @@ const MyChores = () => {
|
||||
onClose={closeModal}
|
||||
/>
|
||||
|
||||
{/* Save Filter Modal */}
|
||||
<SaveFilterModal
|
||||
isOpen={showSaveFilterModal}
|
||||
onClose={() => setShowSaveFilterModal(false)}
|
||||
onSave={filter => {
|
||||
saveFilter(filter)
|
||||
showSuccess({
|
||||
title: 'Filter Saved',
|
||||
message: `"${filter.name}" has been saved successfully`,
|
||||
})
|
||||
}}
|
||||
filterData={createFilterFromCurrentState({
|
||||
selectedProject,
|
||||
selectedChoreFilter,
|
||||
searchFilter,
|
||||
})}
|
||||
previewChores={
|
||||
activeFilterId ? customFilteredChores : searchFilteredChores
|
||||
}
|
||||
previewCount={
|
||||
activeFilterId
|
||||
? customFilteredChores.length
|
||||
: searchFilteredChores.length
|
||||
}
|
||||
previewOverdueCount={
|
||||
(activeFilterId ? customFilteredChores : searchFilteredChores).filter(
|
||||
chore =>
|
||||
chore.nextDueDate && new Date(chore.nextDueDate) < new Date(),
|
||||
).length
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Advanced Filter Builder */}
|
||||
<AdvancedFilterBuilder
|
||||
isOpen={showAdvancedFilterBuilder}
|
||||
@@ -1511,6 +1480,8 @@ const MyChores = () => {
|
||||
// Update existing filter
|
||||
updateFilter(filter.id, {
|
||||
name: filter.name,
|
||||
description: filter.description,
|
||||
color: filter.color,
|
||||
conditions: filter.conditions,
|
||||
operator: filter.operator,
|
||||
})
|
||||
|
||||
@@ -23,6 +23,7 @@ const SortAndGrouping = ({
|
||||
isActive,
|
||||
useChips,
|
||||
title,
|
||||
onCreateNewFilter,
|
||||
}) => {
|
||||
const [anchorEl, setAnchorEl] = useState(null)
|
||||
const menuRef = useRef(null)
|
||||
@@ -201,6 +202,22 @@ const SortAndGrouping = ({
|
||||
/>
|
||||
<Typography level='body-sm'>Assigned to others</Typography>
|
||||
</MenuItem>
|
||||
|
||||
<Divider />
|
||||
|
||||
<MenuItem
|
||||
key={`${k}-custom-filter`}
|
||||
onClick={() => {
|
||||
onCreateNewFilter()
|
||||
handleMenuClose()
|
||||
// TODO: Open advanced filter builder
|
||||
}}
|
||||
>
|
||||
<Typography level='body-sm' fontWeight='md' color='primary'>
|
||||
Create Custom Filter...
|
||||
</Typography>
|
||||
</MenuItem>
|
||||
|
||||
{/*
|
||||
// i need this but i think it have a bad UX and confusing so commenting it for now
|
||||
<MenuItem
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
import { Delete, Edit, Star, StarBorder, Warning } from '@mui/icons-material'
|
||||
import {
|
||||
Delete,
|
||||
Edit,
|
||||
Settings,
|
||||
Star,
|
||||
StarBorder,
|
||||
Warning,
|
||||
} from '@mui/icons-material'
|
||||
import { Box, Chip, Menu, MenuItem, Tooltip, Typography } from '@mui/joy'
|
||||
import { useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { getTextColorFromBackgroundColor } from '../../../utils/Colors'
|
||||
|
||||
const CustomFilterChips = ({
|
||||
filters = [],
|
||||
@@ -10,6 +19,7 @@ const CustomFilterChips = ({
|
||||
onFilterPin,
|
||||
onFilterEdit,
|
||||
}) => {
|
||||
const navigate = useNavigate()
|
||||
const [menuAnchor, setMenuAnchor] = useState(null)
|
||||
const [selectedFilter, setSelectedFilter] = useState(null)
|
||||
|
||||
@@ -73,6 +83,10 @@ const CustomFilterChips = ({
|
||||
{sortedFilters.map(filter => {
|
||||
const isActive = activeFilterId === filter.id
|
||||
const hasWarning = !filter.isValid
|
||||
const hasCustomColor = !!filter.color && !hasWarning
|
||||
const textColor = hasCustomColor
|
||||
? getTextColorFromBackgroundColor(filter.color)
|
||||
: undefined
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
@@ -80,67 +94,129 @@ const CustomFilterChips = ({
|
||||
title={
|
||||
hasWarning
|
||||
? `Filter has issues: ${filter.validationIssues?.join(', ')}`
|
||||
: `${filter.count} tasks${filter.overdueCount > 0 ? ` (${filter.overdueCount} overdue)` : ''}`
|
||||
: `${filter.description ? filter.description + ' - ' : ''}${filter.count} tasks${filter.overdueCount > 0 ? ` (${filter.overdueCount} overdue)` : ''}`
|
||||
}
|
||||
placement='bottom'
|
||||
>
|
||||
<Chip
|
||||
variant={isActive ? 'solid' : 'soft'}
|
||||
color={hasWarning ? 'warning' : isActive ? 'primary' : 'neutral'}
|
||||
size='lg'
|
||||
onClick={() => !hasWarning && onFilterClick(filter.id)}
|
||||
onContextMenu={e => handleContextMenu(e, filter)}
|
||||
sx={{
|
||||
cursor: hasWarning ? 'not-allowed' : 'pointer',
|
||||
transition: 'all 0.2s ease',
|
||||
px: 1.5,
|
||||
py: 0.5,
|
||||
opacity: hasWarning ? 0.7 : 1,
|
||||
}}
|
||||
startDecorator={
|
||||
<Box sx={{ display: 'flex', gap: 0.5, alignItems: 'center' }}>
|
||||
{filter.isPinned && (
|
||||
<Star sx={{ fontSize: '0.9rem', color: 'warning.500' }} />
|
||||
)}
|
||||
<Chip
|
||||
size='sm'
|
||||
variant='solid'
|
||||
color={
|
||||
hasWarning ? 'warning' : isActive ? 'primary' : 'neutral'
|
||||
}
|
||||
>
|
||||
{filter.count}
|
||||
</Chip>
|
||||
</Box>
|
||||
}
|
||||
endDecorator={
|
||||
<Box sx={{ display: 'flex', gap: 0.5, alignItems: 'center' }}>
|
||||
{hasWarning && <Warning sx={{ fontSize: '1rem' }} />}
|
||||
{!hasWarning && filter.overdueCount > 0 && (
|
||||
<Chip size='sm' variant='solid' color='danger'>
|
||||
{filter.overdueCount}
|
||||
</Chip>
|
||||
)}
|
||||
</Box>
|
||||
}
|
||||
>
|
||||
<Typography
|
||||
level='body-sm'
|
||||
fontWeight={isActive ? 'md' : 'normal'}
|
||||
<div onClick={() => !hasWarning && onFilterClick(filter.id)}>
|
||||
<Chip
|
||||
variant='solid'
|
||||
size='lg'
|
||||
onContextMenu={e => handleContextMenu(e, filter)}
|
||||
sx={{
|
||||
whiteSpace: 'nowrap',
|
||||
maxWidth: 200,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
cursor: hasWarning ? 'not-allowed' : 'pointer',
|
||||
transition: 'all 0.2s ease',
|
||||
px: 1.5,
|
||||
py: 0.5,
|
||||
opacity: hasWarning ? 0.7 : isActive ? 1 : 0.85,
|
||||
...(hasCustomColor && {
|
||||
backgroundColor: `${filter.color} !important`,
|
||||
color: `${textColor} !important`,
|
||||
'&:hover': {
|
||||
backgroundColor: `${filter.color} !important`,
|
||||
filter: 'brightness(0.95)',
|
||||
opacity: 1,
|
||||
},
|
||||
}),
|
||||
}}
|
||||
startDecorator={
|
||||
<Box sx={{ display: 'flex', gap: 0.5, alignItems: 'center' }}>
|
||||
{filter.isPinned && (
|
||||
<Star
|
||||
sx={{
|
||||
fontSize: '0.9rem',
|
||||
color: hasCustomColor ? textColor : 'warning.500',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<Chip
|
||||
size='sm'
|
||||
variant='solid'
|
||||
sx={{
|
||||
...(hasCustomColor
|
||||
? {
|
||||
bgcolor:
|
||||
textColor === '#FFFFFF'
|
||||
? '#00000040'
|
||||
: '#FFFFFF40',
|
||||
color: textColor,
|
||||
border: `1px solid ${textColor}30`,
|
||||
}
|
||||
: {}),
|
||||
}}
|
||||
color={
|
||||
hasCustomColor
|
||||
? undefined
|
||||
: hasWarning
|
||||
? 'warning'
|
||||
: isActive
|
||||
? 'primary'
|
||||
: 'neutral'
|
||||
}
|
||||
>
|
||||
{filter.count}
|
||||
</Chip>
|
||||
</Box>
|
||||
}
|
||||
endDecorator={
|
||||
<Box sx={{ display: 'flex', gap: 0.5, alignItems: 'center' }}>
|
||||
{hasWarning && <Warning sx={{ fontSize: '1rem' }} />}
|
||||
{!hasWarning && filter.overdueCount > 0 && (
|
||||
<Chip
|
||||
size='sm'
|
||||
variant='solid'
|
||||
sx={{
|
||||
...(hasCustomColor
|
||||
? {
|
||||
bgcolor: '#ff4444',
|
||||
color: '#FFFFFF',
|
||||
border: `1px solid ${textColor}30`,
|
||||
}
|
||||
: {}),
|
||||
}}
|
||||
color={hasCustomColor ? undefined : 'danger'}
|
||||
>
|
||||
{filter.overdueCount}
|
||||
</Chip>
|
||||
)}
|
||||
</Box>
|
||||
}
|
||||
>
|
||||
{filter.name}
|
||||
</Typography>
|
||||
</Chip>
|
||||
<Typography
|
||||
level='body-sm'
|
||||
fontWeight={isActive ? 'md' : 'normal'}
|
||||
sx={{
|
||||
whiteSpace: 'nowrap',
|
||||
maxWidth: 200,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
...(hasCustomColor && {
|
||||
color: textColor,
|
||||
}),
|
||||
}}
|
||||
>
|
||||
{filter.name}
|
||||
</Typography>
|
||||
</Chip>
|
||||
</div>
|
||||
</Tooltip>
|
||||
)
|
||||
})}
|
||||
|
||||
<Chip
|
||||
variant='outlined'
|
||||
size='lg'
|
||||
startDecorator={<Settings />}
|
||||
sx={{ cursor: 'pointer' }}
|
||||
onClick={e => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
navigate('/filters')
|
||||
}}
|
||||
>
|
||||
Manage Filters
|
||||
</Chip>
|
||||
|
||||
<Menu
|
||||
anchorEl={menuAnchor}
|
||||
open={Boolean(menuAnchor)}
|
||||
|
||||
@@ -1,19 +1,14 @@
|
||||
import { Add, CancelRounded } from '@mui/icons-material'
|
||||
import { Box, Button, Chip, IconButton } from '@mui/joy'
|
||||
import { Box } from '@mui/joy'
|
||||
import CustomFilterChips from './CustomFilterChips'
|
||||
|
||||
const FilterSection = ({
|
||||
savedFilters,
|
||||
activeFilterId,
|
||||
activeFilter,
|
||||
hasProjectConditions,
|
||||
|
||||
onFilterClick,
|
||||
onFilterDelete,
|
||||
onFilterPin,
|
||||
onFilterEdit,
|
||||
onClearActiveFilter,
|
||||
onCreateAdvancedFilter,
|
||||
updateFilterUrl,
|
||||
}) => {
|
||||
return (
|
||||
<>
|
||||
@@ -31,53 +26,7 @@ const FilterSection = ({
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Create Advanced Filter Button */}
|
||||
{!activeFilterId && (
|
||||
<Box
|
||||
sx={{
|
||||
mt: 1,
|
||||
display: 'flex',
|
||||
gap: 1,
|
||||
justifyContent: 'flex-start',
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
size='sm'
|
||||
variant='outlined'
|
||||
color='primary'
|
||||
startDecorator={<Add />}
|
||||
onClick={onCreateAdvancedFilter}
|
||||
>
|
||||
Create Advanced Filter
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Active Custom Filter Display */}
|
||||
{activeFilter && (
|
||||
<Box sx={{ mt: 1 }}>
|
||||
<Chip
|
||||
color='primary'
|
||||
variant='soft'
|
||||
size='lg'
|
||||
endDecorator={
|
||||
<IconButton size='sm' onClick={onClearActiveFilter}>
|
||||
<CancelRounded />
|
||||
</IconButton>
|
||||
}
|
||||
>
|
||||
Filter: {activeFilter.name} ({activeFilter.count} tasks
|
||||
{activeFilter.overdueCount > 0 &&
|
||||
`, ${activeFilter.overdueCount} overdue`}
|
||||
)
|
||||
{hasProjectConditions && (
|
||||
<Chip size='sm' sx={{ ml: 1 }} variant='solid' color='primary'>
|
||||
Cross-Project
|
||||
</Chip>
|
||||
)}
|
||||
</Chip>
|
||||
</Box>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useMemo, useCallback } from 'react'
|
||||
import Fuse from 'fuse.js'
|
||||
import { filterByProject, ChoreFilters } from '../../../utils/Chores'
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import { ChoreFilters, filterByProject } from '../../../utils/Chores'
|
||||
|
||||
export const useChoreFilters = ({
|
||||
chores,
|
||||
|
||||
@@ -1,20 +1,19 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { useUserProfile } from '../../../queries/UserQueries'
|
||||
import {
|
||||
applyFilter,
|
||||
getFilterCount,
|
||||
getFilterOverdueCount,
|
||||
validateFilter,
|
||||
} from '../../../utils/FilterEngine'
|
||||
import {
|
||||
deleteFilter as deleteFilterStorage,
|
||||
getFilterById,
|
||||
getSavedFilters,
|
||||
saveFilter as saveFilterStorage,
|
||||
toggleFilterPin,
|
||||
trackFilterUsage,
|
||||
updateFilter as updateFilterStorage,
|
||||
} from '../../../utils/CustomFilterStorage'
|
||||
import {
|
||||
applyFilter,
|
||||
getFilterCount,
|
||||
getFilterOverdueCount,
|
||||
validateFilter,
|
||||
} from '../../../utils/FilterEngine'
|
||||
|
||||
export const useCustomFilters = (chores, membersData, labels, projects) => {
|
||||
const { data: userProfile } = useUserProfile()
|
||||
@@ -52,13 +51,15 @@ export const useCustomFilters = (chores, membersData, labels, projects) => {
|
||||
? getFilterOverdueCount(chores, filter, context)
|
||||
: 0
|
||||
|
||||
return {
|
||||
const result = {
|
||||
...filter,
|
||||
count,
|
||||
overdueCount,
|
||||
isValid: validation.isValid,
|
||||
validationIssues: validation.issues,
|
||||
}
|
||||
|
||||
return result
|
||||
})
|
||||
}, [savedFilters, chores, context])
|
||||
|
||||
|
||||
823
src/views/Filters/FilterView.jsx
Normal file
823
src/views/Filters/FilterView.jsx
Normal file
@@ -0,0 +1,823 @@
|
||||
import DeleteIcon from '@mui/icons-material/Delete'
|
||||
import EditIcon from '@mui/icons-material/Edit'
|
||||
import {
|
||||
Avatar,
|
||||
Box,
|
||||
Chip,
|
||||
CircularProgress,
|
||||
Container,
|
||||
IconButton,
|
||||
Stack,
|
||||
Typography,
|
||||
} from '@mui/joy'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
|
||||
import { Add, FilterAlt, Star, StarBorder, Task } from '@mui/icons-material'
|
||||
import { useChores } from '../../queries/ChoreQueries'
|
||||
import { useCircleMembers, useUserProfile } from '../../queries/UserQueries'
|
||||
import {
|
||||
deleteFilter,
|
||||
getSavedFilters,
|
||||
saveFilter,
|
||||
toggleFilterPin,
|
||||
updateFilter,
|
||||
} from '../../utils/CustomFilterStorage'
|
||||
import { getFilterCount, getFilterOverdueCount } from '../../utils/FilterEngine'
|
||||
import { getSafeBottomStyles } from '../../utils/SafeAreaUtils'
|
||||
|
||||
import { useLabels } from '../Labels/LabelQueries'
|
||||
import AdvancedFilterBuilder from '../Modals/Inputs/AdvancedFilterBuilder'
|
||||
import ConfirmationModal from '../Modals/Inputs/ConfirmationModal'
|
||||
import { useProjects } from '../Projects/ProjectQueries'
|
||||
|
||||
const FilterCard = ({
|
||||
filter,
|
||||
onEditClick,
|
||||
onDeleteClick,
|
||||
onPinClick,
|
||||
taskCount = 0,
|
||||
overdueCount = 0,
|
||||
}) => {
|
||||
const navigate = useNavigate()
|
||||
|
||||
// Swipe functionality state
|
||||
const [swipeTranslateX, setSwipeTranslateX] = useState(0)
|
||||
const [isDragging, setIsDragging] = useState(false)
|
||||
const [isSwipeRevealed, setIsSwipeRevealed] = useState(false)
|
||||
const [hoverTimer, setHoverTimer] = useState(null)
|
||||
const swipeThreshold = 80
|
||||
const maxSwipeDistance = 200 // Increased to fit pin + edit + delete
|
||||
const dragStartX = useRef(0)
|
||||
const cardRef = useRef(null)
|
||||
|
||||
// Swipe gesture handlers
|
||||
const handleTouchStart = e => {
|
||||
dragStartX.current = e.touches[0].clientX
|
||||
setIsDragging(true)
|
||||
}
|
||||
|
||||
const handleTouchMove = e => {
|
||||
if (!isDragging) return
|
||||
|
||||
const currentX = e.touches[0].clientX
|
||||
const deltaX = currentX - dragStartX.current
|
||||
|
||||
if (isSwipeRevealed) {
|
||||
if (deltaX > 0) {
|
||||
const clampedDelta = Math.min(deltaX - maxSwipeDistance, 0)
|
||||
setSwipeTranslateX(clampedDelta)
|
||||
}
|
||||
} else {
|
||||
if (deltaX < 0) {
|
||||
const clampedDelta = Math.max(deltaX, -maxSwipeDistance)
|
||||
setSwipeTranslateX(clampedDelta)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleTouchEnd = () => {
|
||||
if (!isDragging) return
|
||||
setIsDragging(false)
|
||||
|
||||
if (isSwipeRevealed) {
|
||||
if (swipeTranslateX > -swipeThreshold) {
|
||||
setSwipeTranslateX(0)
|
||||
setIsSwipeRevealed(false)
|
||||
} else {
|
||||
setSwipeTranslateX(-maxSwipeDistance)
|
||||
}
|
||||
} else {
|
||||
if (Math.abs(swipeTranslateX) > swipeThreshold) {
|
||||
setSwipeTranslateX(-maxSwipeDistance)
|
||||
setIsSwipeRevealed(true)
|
||||
} else {
|
||||
setSwipeTranslateX(0)
|
||||
setIsSwipeRevealed(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleMouseDown = e => {
|
||||
dragStartX.current = e.clientX
|
||||
setIsDragging(true)
|
||||
}
|
||||
|
||||
const handleMouseMove = e => {
|
||||
if (!isDragging) return
|
||||
|
||||
const currentX = e.clientX
|
||||
const deltaX = currentX - dragStartX.current
|
||||
|
||||
if (isSwipeRevealed) {
|
||||
if (deltaX > 0) {
|
||||
const clampedDelta = Math.min(deltaX - maxSwipeDistance, 0)
|
||||
setSwipeTranslateX(clampedDelta)
|
||||
}
|
||||
} else {
|
||||
if (deltaX < 0) {
|
||||
const clampedDelta = Math.max(deltaX, -maxSwipeDistance)
|
||||
setSwipeTranslateX(clampedDelta)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleMouseUp = () => {
|
||||
if (!isDragging) return
|
||||
setIsDragging(false)
|
||||
|
||||
if (isSwipeRevealed) {
|
||||
if (swipeTranslateX > -swipeThreshold) {
|
||||
setSwipeTranslateX(0)
|
||||
setIsSwipeRevealed(false)
|
||||
} else {
|
||||
setSwipeTranslateX(-maxSwipeDistance)
|
||||
}
|
||||
} else {
|
||||
if (Math.abs(swipeTranslateX) > swipeThreshold) {
|
||||
setSwipeTranslateX(-maxSwipeDistance)
|
||||
setIsSwipeRevealed(true)
|
||||
} else {
|
||||
setSwipeTranslateX(0)
|
||||
setIsSwipeRevealed(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const resetSwipe = () => {
|
||||
setSwipeTranslateX(0)
|
||||
setIsSwipeRevealed(false)
|
||||
}
|
||||
|
||||
// Hover functionality for desktop
|
||||
const handleMouseEnter = () => {
|
||||
if (isSwipeRevealed) return
|
||||
const timer = setTimeout(() => {
|
||||
setSwipeTranslateX(-maxSwipeDistance)
|
||||
setIsSwipeRevealed(true)
|
||||
setHoverTimer(null)
|
||||
}, 800)
|
||||
setHoverTimer(timer)
|
||||
}
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
if (hoverTimer) {
|
||||
clearTimeout(hoverTimer)
|
||||
setHoverTimer(null)
|
||||
}
|
||||
if (!isSwipeRevealed) {
|
||||
const hideTimer = setTimeout(() => {
|
||||
resetSwipe()
|
||||
}, 300)
|
||||
setHoverTimer(hideTimer)
|
||||
}
|
||||
}
|
||||
|
||||
const handleActionAreaMouseEnter = () => {
|
||||
if (hoverTimer) {
|
||||
clearTimeout(hoverTimer)
|
||||
setHoverTimer(null)
|
||||
}
|
||||
}
|
||||
|
||||
const handleActionAreaMouseLeave = () => {
|
||||
if (isSwipeRevealed) {
|
||||
resetSwipe()
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up timer on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (hoverTimer) {
|
||||
clearTimeout(hoverTimer)
|
||||
}
|
||||
}
|
||||
}, [hoverTimer])
|
||||
|
||||
// Get condition labels for display
|
||||
const getConditionSummary = () => {
|
||||
if (!filter.conditions || filter.conditions.length === 0) {
|
||||
return 'No conditions'
|
||||
}
|
||||
if (filter.conditions.length === 1) {
|
||||
return '1 condition'
|
||||
}
|
||||
return `${filter.conditions.length} conditions`
|
||||
}
|
||||
|
||||
return (
|
||||
<Box key={filter.id + '-filter-box'}>
|
||||
<Box
|
||||
sx={{
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
borderBottom: '1px solid',
|
||||
borderColor: 'divider',
|
||||
'&:last-child': {
|
||||
borderBottom: 'none',
|
||||
},
|
||||
}}
|
||||
onMouseLeave={() => {
|
||||
if (hoverTimer) {
|
||||
clearTimeout(hoverTimer)
|
||||
setHoverTimer(null)
|
||||
}
|
||||
}}
|
||||
>
|
||||
{/* Action buttons underneath (revealed on swipe) */}
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
right: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
width: maxSwipeDistance,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
boxShadow: 'inset 2px 0 4px rgba(0,0,0,0.06)',
|
||||
zIndex: 0,
|
||||
}}
|
||||
onMouseEnter={handleActionAreaMouseEnter}
|
||||
onMouseLeave={handleActionAreaMouseLeave}
|
||||
>
|
||||
<IconButton
|
||||
variant='soft'
|
||||
color='warning'
|
||||
size='sm'
|
||||
onClick={e => {
|
||||
e.stopPropagation()
|
||||
resetSwipe()
|
||||
onPinClick(filter.id)
|
||||
}}
|
||||
sx={{
|
||||
width: 40,
|
||||
height: 40,
|
||||
mx: 0.5,
|
||||
}}
|
||||
>
|
||||
{filter.isPinned ? (
|
||||
<Star sx={{ fontSize: 16 }} />
|
||||
) : (
|
||||
<StarBorder sx={{ fontSize: 16 }} />
|
||||
)}
|
||||
</IconButton>
|
||||
|
||||
<IconButton
|
||||
variant='soft'
|
||||
color='neutral'
|
||||
size='sm'
|
||||
onClick={e => {
|
||||
e.stopPropagation()
|
||||
resetSwipe()
|
||||
onEditClick(filter)
|
||||
}}
|
||||
sx={{
|
||||
width: 40,
|
||||
height: 40,
|
||||
mx: 0.5,
|
||||
}}
|
||||
>
|
||||
<EditIcon sx={{ fontSize: 16 }} />
|
||||
</IconButton>
|
||||
|
||||
<IconButton
|
||||
variant='soft'
|
||||
color='danger'
|
||||
size='sm'
|
||||
onClick={e => {
|
||||
e.stopPropagation()
|
||||
resetSwipe()
|
||||
onDeleteClick(filter.id)
|
||||
}}
|
||||
sx={{
|
||||
width: 40,
|
||||
height: 40,
|
||||
mx: 0.5,
|
||||
}}
|
||||
>
|
||||
<DeleteIcon sx={{ fontSize: 16 }} />
|
||||
</IconButton>
|
||||
</Box>
|
||||
|
||||
{/* Main card content */}
|
||||
<Box
|
||||
ref={cardRef}
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
minHeight: 64,
|
||||
cursor: 'pointer',
|
||||
position: 'relative',
|
||||
px: 2,
|
||||
py: 1.5,
|
||||
bgcolor: 'background.body',
|
||||
transform: `translateX(${swipeTranslateX}px)`,
|
||||
transition: isDragging ? 'none' : 'transform 0.3s ease-out',
|
||||
zIndex: 1,
|
||||
'&:hover': {
|
||||
bgcolor: isSwipeRevealed
|
||||
? 'background.surface'
|
||||
: 'background.level1',
|
||||
boxShadow: isSwipeRevealed ? 'none' : 'sm',
|
||||
},
|
||||
}}
|
||||
onClick={() => {
|
||||
if (isSwipeRevealed) {
|
||||
resetSwipe()
|
||||
return
|
||||
}
|
||||
// Navigate to MyChores with filter applied via URL param
|
||||
navigate(`/chores?filterId=${encodeURIComponent(filter.id)}`)
|
||||
}}
|
||||
onTouchStart={handleTouchStart}
|
||||
onTouchMove={handleTouchMove}
|
||||
onTouchEnd={handleTouchEnd}
|
||||
onMouseDown={handleMouseDown}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseUp={handleMouseUp}
|
||||
>
|
||||
{/* Right drag area */}
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
right: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
width: '20px',
|
||||
cursor: 'grab',
|
||||
zIndex: 2,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
opacity: isSwipeRevealed ? 0 : 0.3,
|
||||
transition: 'opacity 0.2s ease',
|
||||
pointerEvents: isSwipeRevealed ? 'none' : 'auto',
|
||||
'&:hover': {
|
||||
opacity: isSwipeRevealed ? 0 : 0.7,
|
||||
},
|
||||
'&:active': {
|
||||
cursor: 'grabbing',
|
||||
},
|
||||
}}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
>
|
||||
{/* Drag indicator dots */}
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 0.25,
|
||||
}}
|
||||
>
|
||||
{[...Array(3)].map((_, i) => (
|
||||
<Box
|
||||
key={i}
|
||||
sx={{
|
||||
width: 3,
|
||||
height: 3,
|
||||
borderRadius: '50%',
|
||||
bgcolor: 'text.tertiary',
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Filter Icon */}
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
mr: 2,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<Avatar
|
||||
size='sm'
|
||||
sx={{
|
||||
width: 32,
|
||||
height: 32,
|
||||
bgcolor: filter.color || 'neutral.500',
|
||||
border: '2px solid',
|
||||
borderColor: filter.isPinned
|
||||
? 'warning.300'
|
||||
: 'background.surface',
|
||||
boxShadow: filter.isPinned
|
||||
? '0 0 0 1px var(--joy-palette-warning-300)'
|
||||
: 'sm',
|
||||
}}
|
||||
>
|
||||
<FilterAlt
|
||||
sx={{
|
||||
fontSize: 16,
|
||||
color: 'white',
|
||||
}}
|
||||
/>
|
||||
</Avatar>
|
||||
</Box>
|
||||
|
||||
{/* Content - Center */}
|
||||
<Box
|
||||
sx={{
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
}}
|
||||
>
|
||||
{/* Filter Name */}
|
||||
<Box
|
||||
sx={{ display: 'flex', alignItems: 'center', gap: 0.5, mb: 0.25 }}
|
||||
>
|
||||
<Typography
|
||||
level='title-sm'
|
||||
sx={{
|
||||
fontWeight: 600,
|
||||
fontSize: 14,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{filter.name}
|
||||
</Typography>
|
||||
{filter.isPinned && (
|
||||
<Star
|
||||
sx={{
|
||||
fontSize: 14,
|
||||
color: 'warning.500',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Filter Info */}
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 0.5,
|
||||
flexWrap: 'wrap',
|
||||
}}
|
||||
>
|
||||
{filter.description && (
|
||||
<Typography
|
||||
level='body-xs'
|
||||
sx={{
|
||||
color: 'text.tertiary',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
maxWidth: '150px',
|
||||
}}
|
||||
>
|
||||
{filter.description}
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
<Chip
|
||||
size='sm'
|
||||
variant='soft'
|
||||
startDecorator={<Task />}
|
||||
color={overdueCount > 0 ? 'danger' : 'primary'}
|
||||
sx={{
|
||||
fontSize: 10,
|
||||
height: 18,
|
||||
px: 0.75,
|
||||
bgcolor:
|
||||
overdueCount > 0 ? 'danger.softBg' : 'primary.softBg',
|
||||
color: overdueCount > 0 ? 'danger.500' : 'primary.500',
|
||||
}}
|
||||
>
|
||||
{taskCount} tasks
|
||||
</Chip>
|
||||
|
||||
{overdueCount > 0 && (
|
||||
<Chip
|
||||
size='sm'
|
||||
variant='solid'
|
||||
color='danger'
|
||||
sx={{
|
||||
fontSize: 10,
|
||||
height: 18,
|
||||
px: 0.75,
|
||||
}}
|
||||
>
|
||||
{overdueCount} overdue
|
||||
</Chip>
|
||||
)}
|
||||
|
||||
<Chip
|
||||
size='sm'
|
||||
variant='soft'
|
||||
startDecorator={<FilterAlt />}
|
||||
sx={{
|
||||
fontSize: 10,
|
||||
height: 18,
|
||||
px: 0.75,
|
||||
bgcolor: 'neutral.softBg',
|
||||
color: 'neutral.600',
|
||||
}}
|
||||
>
|
||||
{getConditionSummary()}
|
||||
</Chip>
|
||||
|
||||
{filter.usageCount > 0 && (
|
||||
<Chip
|
||||
size='sm'
|
||||
variant='soft'
|
||||
sx={{
|
||||
fontSize: 10,
|
||||
height: 18,
|
||||
px: 0.75,
|
||||
bgcolor: 'success.softBg',
|
||||
color: 'success.600',
|
||||
}}
|
||||
>
|
||||
Used {filter.usageCount}x
|
||||
</Chip>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
const FilterView = () => {
|
||||
const { data: userProfile } = useUserProfile()
|
||||
const { data: chores = { res: [] } } = useChores(false)
|
||||
const { data: labels = [] } = useLabels()
|
||||
const { data: projects = [] } = useProjects()
|
||||
const { data: membersData } = useCircleMembers()
|
||||
|
||||
const [savedFilters, setSavedFilters] = useState([])
|
||||
const [filterCounts, setFilterCounts] = useState({})
|
||||
const [showAdvancedFilterBuilder, setShowAdvancedFilterBuilder] =
|
||||
useState(false)
|
||||
const [editingFilter, setEditingFilter] = useState(null)
|
||||
const [confirmationModel, setConfirmationModel] = useState({})
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
|
||||
// Load filters
|
||||
const loadFilters = () => {
|
||||
try {
|
||||
const filters = getSavedFilters()
|
||||
// Sort: pinned first, then by usage count, then by last used
|
||||
const sortedFilters = filters.sort((a, b) => {
|
||||
if (a.isPinned !== b.isPinned) {
|
||||
return a.isPinned ? -1 : 1
|
||||
}
|
||||
if ((b.usageCount || 0) !== (a.usageCount || 0)) {
|
||||
return (b.usageCount || 0) - (a.usageCount || 0)
|
||||
}
|
||||
if (a.lastUsedAt && b.lastUsedAt) {
|
||||
return new Date(b.lastUsedAt) - new Date(a.lastUsedAt)
|
||||
}
|
||||
return new Date(b.createdAt) - new Date(a.createdAt)
|
||||
})
|
||||
setSavedFilters(sortedFilters)
|
||||
} catch (error) {
|
||||
console.error('Error loading filters:', error)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadFilters()
|
||||
}, [])
|
||||
|
||||
// Calculate task counts for each filter
|
||||
useEffect(() => {
|
||||
if (chores && chores.res && savedFilters.length > 0) {
|
||||
const choresList = chores.res
|
||||
const counts = {}
|
||||
|
||||
const context = {
|
||||
userId: userProfile?.id,
|
||||
members: membersData?.res || [],
|
||||
labels: labels || [],
|
||||
projects: projects || [],
|
||||
}
|
||||
|
||||
savedFilters.forEach(filter => {
|
||||
try {
|
||||
const count = getFilterCount(choresList, filter, context)
|
||||
const overdueCount = getFilterOverdueCount(
|
||||
choresList,
|
||||
filter,
|
||||
context,
|
||||
)
|
||||
counts[filter.id] = { count, overdueCount }
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`Error calculating count for filter ${filter.id}:`,
|
||||
error,
|
||||
)
|
||||
counts[filter.id] = { count: 0, overdueCount: 0 }
|
||||
}
|
||||
})
|
||||
|
||||
setFilterCounts(counts)
|
||||
}
|
||||
}, [
|
||||
chores,
|
||||
savedFilters,
|
||||
userProfile?.id,
|
||||
labels,
|
||||
projects,
|
||||
membersData?.res,
|
||||
])
|
||||
|
||||
const handleAddFilter = () => {
|
||||
setEditingFilter(null)
|
||||
setShowAdvancedFilterBuilder(true)
|
||||
}
|
||||
|
||||
const handleEditFilter = filter => {
|
||||
setEditingFilter(filter)
|
||||
setShowAdvancedFilterBuilder(true)
|
||||
}
|
||||
|
||||
const handleDeleteClicked = id => {
|
||||
const filter = savedFilters.find(f => f.id === id)
|
||||
setConfirmationModel({
|
||||
isOpen: true,
|
||||
title: 'Delete Filter',
|
||||
message: `Are you sure you want to delete "${filter?.name}"? This cannot be undone.`,
|
||||
confirmText: 'Delete',
|
||||
color: 'danger',
|
||||
cancelText: 'Cancel',
|
||||
onClose: confirmed => {
|
||||
if (confirmed === true) {
|
||||
handleDeleteFilter(id)
|
||||
}
|
||||
setConfirmationModel({})
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const handleDeleteFilter = id => {
|
||||
try {
|
||||
deleteFilter(id)
|
||||
// if it's the selected filter, we might want to clear it
|
||||
localStorage.getItem('selectedChoreFilter') === id &&
|
||||
localStorage.removeItem('selectedChoreFilter')
|
||||
|
||||
loadFilters()
|
||||
} catch (error) {
|
||||
console.error('Error deleting filter:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const handlePinFilter = id => {
|
||||
try {
|
||||
toggleFilterPin(id)
|
||||
loadFilters()
|
||||
} catch (error) {
|
||||
console.error('Error pinning filter:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSaveFilter = filterData => {
|
||||
try {
|
||||
if (editingFilter) {
|
||||
// Update existing filter
|
||||
updateFilter(editingFilter.id, filterData)
|
||||
} else {
|
||||
// Save new filter
|
||||
saveFilter(filterData)
|
||||
}
|
||||
setShowAdvancedFilterBuilder(false)
|
||||
setEditingFilter(null)
|
||||
loadFilters()
|
||||
} catch (error) {
|
||||
console.error('Error saving filter:', error)
|
||||
}
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Box
|
||||
display='flex'
|
||||
justifyContent='center'
|
||||
alignItems='center'
|
||||
height='100vh'
|
||||
>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
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' }}
|
||||
>
|
||||
Filters
|
||||
</Typography>
|
||||
<Typography level='body-sm' sx={{ color: 'text.secondary' }}>
|
||||
Save your favorite filter combinations for quick access. Create
|
||||
custom views to organize and find tasks faster.
|
||||
</Typography>
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{savedFilters.length === 0 ? (
|
||||
<Box
|
||||
sx={{
|
||||
p: 4,
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
<FilterAlt
|
||||
sx={{
|
||||
fontSize: 48,
|
||||
color: 'neutral.300',
|
||||
mb: 2,
|
||||
}}
|
||||
/>
|
||||
<Typography
|
||||
level='title-lg'
|
||||
sx={{ mb: 1, color: 'text.secondary' }}
|
||||
>
|
||||
No saved filters yet
|
||||
</Typography>
|
||||
<Typography level='body-sm' sx={{ color: 'text.tertiary', mb: 2 }}>
|
||||
Create custom filters to quickly access your most used chore
|
||||
</Typography>
|
||||
</Box>
|
||||
) : (
|
||||
savedFilters.map(filter => (
|
||||
<FilterCard
|
||||
key={filter.id}
|
||||
filter={filter}
|
||||
onEditClick={handleEditFilter}
|
||||
onDeleteClick={handleDeleteClicked}
|
||||
onPinClick={handlePinFilter}
|
||||
taskCount={filterCounts[filter.id]?.count || 0}
|
||||
overdueCount={filterCounts[filter.id]?.overdueCount || 0}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{showAdvancedFilterBuilder && (
|
||||
<AdvancedFilterBuilder
|
||||
isOpen={showAdvancedFilterBuilder}
|
||||
onClose={() => {
|
||||
setShowAdvancedFilterBuilder(false)
|
||||
setEditingFilter(null)
|
||||
}}
|
||||
onSave={handleSaveFilter}
|
||||
members={membersData?.res || []}
|
||||
labels={labels || []}
|
||||
projects={projects || []}
|
||||
allChores={chores?.res || []}
|
||||
userProfile={userProfile}
|
||||
editingFilter={editingFilter}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
...getSafeBottomStyles({ bottom: 0, padding: 16 }),
|
||||
left: 10,
|
||||
display: 'flex',
|
||||
justifyContent: 'flex-end',
|
||||
gap: 2,
|
||||
'z-index': 1000,
|
||||
}}
|
||||
>
|
||||
<IconButton
|
||||
color='primary'
|
||||
variant='solid'
|
||||
sx={{
|
||||
borderRadius: '50%',
|
||||
width: 50,
|
||||
height: 50,
|
||||
}}
|
||||
onClick={handleAddFilter}
|
||||
>
|
||||
<Add />
|
||||
</IconButton>
|
||||
</Box>
|
||||
|
||||
<ConfirmationModal config={confirmationModel} />
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
||||
export default FilterView
|
||||
@@ -9,10 +9,12 @@ import {
|
||||
ListItem,
|
||||
Option,
|
||||
Select,
|
||||
Textarea,
|
||||
Typography,
|
||||
} from '@mui/joy'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useResponsiveModal } from '../../../hooks/useResponsiveModal'
|
||||
import { FILTER_COLORS } from '../../../utils/Colors'
|
||||
import { filterNameExists } from '../../../utils/CustomFilterStorage'
|
||||
import { applyFilter } from '../../../utils/FilterEngine'
|
||||
import Priorities from '../../../utils/Priorities'
|
||||
@@ -30,19 +32,36 @@ const AdvancedFilterBuilder = ({
|
||||
}) => {
|
||||
const { ResponsiveModal } = useResponsiveModal()
|
||||
const [filterName, setFilterName] = useState('')
|
||||
const [filterDescription, setFilterDescription] = useState('')
|
||||
const [filterColor, setFilterColor] = useState(FILTER_COLORS[0].value)
|
||||
const [conditions, setConditions] = useState([
|
||||
{ type: 'assignee', operator: 'is', value: [] },
|
||||
])
|
||||
const [error, setError] = useState('')
|
||||
const [existedFilters] = useState(() => {
|
||||
const storedFilters = localStorage.getItem('customFilters')
|
||||
return storedFilters ? JSON.parse(storedFilters) : []
|
||||
})
|
||||
|
||||
// Initialize state when editing a filter
|
||||
useEffect(() => {
|
||||
if (editingFilter) {
|
||||
setFilterName(editingFilter.name)
|
||||
setFilterDescription(editingFilter.description || '')
|
||||
setFilterColor(editingFilter.color || FILTER_COLORS[0].value)
|
||||
setConditions(editingFilter.conditions || [])
|
||||
setError('')
|
||||
} else {
|
||||
setFilterName('')
|
||||
setFilterDescription('')
|
||||
// find color no filter has it :
|
||||
const potentialColor = FILTER_COLORS.find(
|
||||
color => !existedFilters.some(filter => filter.color === color.value),
|
||||
)
|
||||
|
||||
setFilterColor(
|
||||
potentialColor ? potentialColor.value : FILTER_COLORS[0].value,
|
||||
)
|
||||
setConditions([{ type: 'assignee', operator: 'is', value: [] }])
|
||||
setError('')
|
||||
}
|
||||
@@ -127,6 +146,8 @@ const AdvancedFilterBuilder = ({
|
||||
|
||||
const filterData = {
|
||||
name: filterName.trim(),
|
||||
description: filterDescription.trim(),
|
||||
color: filterColor,
|
||||
conditions: validConditions,
|
||||
operator: 'AND',
|
||||
}
|
||||
@@ -375,11 +396,27 @@ const AdvancedFilterBuilder = ({
|
||||
}
|
||||
|
||||
return (
|
||||
<ResponsiveModal open={isOpen} onClose={onClose} size='md'>
|
||||
<Typography level='h4' sx={{ mb: 2 }}>
|
||||
{editingFilter ? 'Edit Filter' : 'Create Advanced Filter'}
|
||||
</Typography>
|
||||
|
||||
<ResponsiveModal
|
||||
open={isOpen}
|
||||
onClose={onClose}
|
||||
size='lg'
|
||||
title={editingFilter ? 'Edit Filter' : 'Create Advanced Filter'}
|
||||
footer={
|
||||
<Box sx={{ display: 'flex', gap: 1, justifyContent: 'flex-end' }}>
|
||||
<Button variant='outlined' color='neutral' onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant='solid'
|
||||
color='primary'
|
||||
onClick={handleSave}
|
||||
startDecorator={<Save />}
|
||||
>
|
||||
{editingFilter ? 'Update Filter' : 'Save Filter'}
|
||||
</Button>
|
||||
</Box>
|
||||
}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
@@ -409,6 +446,61 @@ const AdvancedFilterBuilder = ({
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<Typography level='body-sm' sx={{ mb: 1 }}>
|
||||
Description (Optional)
|
||||
</Typography>
|
||||
<Textarea
|
||||
placeholder='Optional description for this filter...'
|
||||
value={filterDescription}
|
||||
onChange={e => setFilterDescription(e.target.value)}
|
||||
minRows={2}
|
||||
maxRows={3}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<Typography level='body-sm' sx={{ mb: 1 }}>
|
||||
Color
|
||||
</Typography>
|
||||
<Select
|
||||
value={filterColor}
|
||||
onChange={(_, value) => value && setFilterColor(value)}
|
||||
renderValue={selected => (
|
||||
<Typography
|
||||
startDecorator={
|
||||
<Box
|
||||
sx={{
|
||||
width: 16,
|
||||
height: 16,
|
||||
borderRadius: '50%',
|
||||
background: selected.value,
|
||||
}}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{selected.label}
|
||||
</Typography>
|
||||
)}
|
||||
>
|
||||
{FILTER_COLORS.map(color => (
|
||||
<Option key={color.value} value={color.value}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Box
|
||||
sx={{
|
||||
width: 20,
|
||||
height: 20,
|
||||
borderRadius: '50%',
|
||||
background: color.value,
|
||||
}}
|
||||
/>
|
||||
<Typography>{color.name}</Typography>
|
||||
</Box>
|
||||
</Option>
|
||||
))}
|
||||
</Select>
|
||||
</Box>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
flex: 1,
|
||||
@@ -572,20 +664,6 @@ const AdvancedFilterBuilder = ({
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: 'flex', gap: 1, justifyContent: 'flex-end' }}>
|
||||
<Button variant='outlined' color='neutral' onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant='solid'
|
||||
color='primary'
|
||||
onClick={handleSave}
|
||||
startDecorator={<Save />}
|
||||
>
|
||||
{editingFilter ? 'Update Filter' : 'Save Filter'}
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
</ResponsiveModal>
|
||||
)
|
||||
|
||||
@@ -1,263 +0,0 @@
|
||||
import { Save, Star, StarBorder } from '@mui/icons-material'
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Chip,
|
||||
Input,
|
||||
Modal,
|
||||
ModalClose,
|
||||
ModalDialog,
|
||||
Typography,
|
||||
} from '@mui/joy'
|
||||
import { useState } from 'react'
|
||||
import { filterNameExists } from '../../../utils/CustomFilterStorage'
|
||||
import CompactChoreCard from '../../Chores/CompactChoreCard'
|
||||
|
||||
const SaveFilterModal = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
onSave,
|
||||
filterData,
|
||||
previewChores = [],
|
||||
previewCount = 0,
|
||||
previewOverdueCount = 0,
|
||||
}) => {
|
||||
const [filterName, setFilterName] = useState('')
|
||||
const [isPinned, setIsPinned] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const handleSave = () => {
|
||||
if (!filterName.trim()) {
|
||||
setError('Please enter a filter name')
|
||||
return
|
||||
}
|
||||
|
||||
if (filterNameExists(filterName.trim())) {
|
||||
setError('A filter with this name already exists')
|
||||
return
|
||||
}
|
||||
|
||||
const newFilter = {
|
||||
...filterData,
|
||||
name: filterName.trim(),
|
||||
isPinned,
|
||||
}
|
||||
|
||||
onSave(newFilter)
|
||||
onClose()
|
||||
}
|
||||
|
||||
const getConditionLabel = condition => {
|
||||
switch (condition.type) {
|
||||
case 'assignee':
|
||||
if (condition.value === 'me') return 'Assigned to me'
|
||||
if (condition.value === 'others') return 'Assigned to others'
|
||||
return 'Specific assignee'
|
||||
|
||||
case 'createdBy':
|
||||
if (condition.value === 'me') return 'Created by me'
|
||||
return 'Created by specific user'
|
||||
|
||||
case 'priority':
|
||||
return `Priority ${condition.value}`
|
||||
|
||||
case 'status':
|
||||
return condition.value === 3 ? 'Pending approval' : `Status ${condition.value}`
|
||||
|
||||
case 'dueDate':
|
||||
if (condition.operator === 'isOverdue') return 'Overdue'
|
||||
if (condition.operator === 'isDueToday') return 'Due today'
|
||||
if (condition.operator === 'isDueThisWeek') return 'Due this week'
|
||||
if (condition.operator === 'hasNoDueDate') return 'No due date'
|
||||
return 'Due date condition'
|
||||
|
||||
case 'label':
|
||||
return 'Has label'
|
||||
|
||||
case 'project':
|
||||
if (condition.value === 'default') return 'Default project'
|
||||
return 'Specific project'
|
||||
|
||||
default:
|
||||
return condition.type
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal open={isOpen} onClose={onClose}>
|
||||
<ModalDialog
|
||||
sx={{
|
||||
maxWidth: 500,
|
||||
width: '90%',
|
||||
maxHeight: '90vh',
|
||||
overflow: 'auto',
|
||||
}}
|
||||
>
|
||||
<ModalClose />
|
||||
<Typography level='h4' sx={{ mb: 2 }}>
|
||||
Save Filter
|
||||
</Typography>
|
||||
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
<Box>
|
||||
<Typography level='body-sm' sx={{ mb: 1 }}>
|
||||
Filter Name
|
||||
</Typography>
|
||||
<Input
|
||||
placeholder='e.g., My High Priority Tasks'
|
||||
value={filterName}
|
||||
onChange={e => {
|
||||
setFilterName(e.target.value)
|
||||
setError('')
|
||||
}}
|
||||
error={!!error}
|
||||
autoFocus
|
||||
/>
|
||||
{error && (
|
||||
<Typography level='body-sm' color='danger' sx={{ mt: 0.5 }}>
|
||||
{error}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<Typography level='body-sm' sx={{ mb: 1 }}>
|
||||
Filter Conditions
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap' }}>
|
||||
{filterData.conditions.length === 0 ? (
|
||||
<Typography level='body-sm' color='neutral'>
|
||||
No filters applied
|
||||
</Typography>
|
||||
) : (
|
||||
filterData.conditions.map((condition, index) => (
|
||||
<Chip
|
||||
key={index}
|
||||
variant='soft'
|
||||
color='neutral'
|
||||
size='sm'
|
||||
>
|
||||
{getConditionLabel(condition)}
|
||||
</Chip>
|
||||
))
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
mb: 1,
|
||||
}}
|
||||
>
|
||||
<Typography level='body-sm'>Preview</Typography>
|
||||
<Box sx={{ display: 'flex', gap: 1 }}>
|
||||
<Chip size='sm' variant='soft' color='neutral'>
|
||||
{previewCount} tasks
|
||||
</Chip>
|
||||
{previewOverdueCount > 0 && (
|
||||
<Chip size='sm' variant='solid' color='danger'>
|
||||
{previewOverdueCount} overdue
|
||||
</Chip>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
maxHeight: 200,
|
||||
overflowY: 'auto',
|
||||
bgcolor: 'background.level1',
|
||||
p: 1,
|
||||
borderRadius: 'sm',
|
||||
}}
|
||||
>
|
||||
{previewCount === 0 ? (
|
||||
<Typography
|
||||
level='body-sm'
|
||||
color='neutral'
|
||||
sx={{ textAlign: 'center', py: 2 }}
|
||||
>
|
||||
No tasks match these filters
|
||||
</Typography>
|
||||
) : (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||
{previewChores.slice(0, 3).map(chore => (
|
||||
<Box
|
||||
key={chore.id}
|
||||
sx={{
|
||||
bgcolor: 'background.surface',
|
||||
p: 1,
|
||||
borderRadius: 'sm',
|
||||
}}
|
||||
>
|
||||
<Typography level='body-sm'>{chore.name}</Typography>
|
||||
</Box>
|
||||
))}
|
||||
{previewCount > 3 && (
|
||||
<Typography
|
||||
level='body-xs'
|
||||
color='neutral'
|
||||
sx={{ textAlign: 'center', mt: 0.5 }}
|
||||
>
|
||||
...and {previewCount - 3} more
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 1,
|
||||
cursor: 'pointer',
|
||||
p: 1,
|
||||
borderRadius: 'sm',
|
||||
'&:hover': {
|
||||
bgcolor: 'background.level1',
|
||||
},
|
||||
}}
|
||||
onClick={() => setIsPinned(!isPinned)}
|
||||
>
|
||||
{isPinned ? (
|
||||
<Star color='warning' />
|
||||
) : (
|
||||
<StarBorder color='neutral' />
|
||||
)}
|
||||
<Box>
|
||||
<Typography level='body-sm' fontWeight='md'>
|
||||
Pin this filter
|
||||
</Typography>
|
||||
<Typography level='body-xs' color='neutral'>
|
||||
Pinned filters appear first in the list
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: 'flex', gap: 1, justifyContent: 'flex-end' }}>
|
||||
<Button variant='outlined' color='neutral' onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant='solid'
|
||||
color='primary'
|
||||
onClick={handleSave}
|
||||
startDecorator={<Save />}
|
||||
disabled={!filterName.trim() || filterData.conditions.length === 0}
|
||||
>
|
||||
Save Filter
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
</ModalDialog>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
export default SaveFilterModal
|
||||
@@ -2,6 +2,7 @@ import { Capacitor } from '@capacitor/core'
|
||||
import {
|
||||
Archive,
|
||||
ArrowBack,
|
||||
FilterAlt,
|
||||
FolderOpen,
|
||||
History,
|
||||
Inbox,
|
||||
@@ -60,6 +61,11 @@ const links = [
|
||||
label: 'Projects',
|
||||
icon: <FolderOpen />,
|
||||
},
|
||||
{
|
||||
to: 'filters',
|
||||
label: 'Filters',
|
||||
icon: <FilterAlt />,
|
||||
},
|
||||
{
|
||||
to: 'activities',
|
||||
label: 'Activities',
|
||||
|
||||
Reference in New Issue
Block a user