feat: Implement multi-select functionality with keyboard shortcuts and bulk actions
This commit is contained in:
@@ -11,6 +11,7 @@ import {
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
Checkbox,
|
||||
Chip,
|
||||
CircularProgress,
|
||||
Grid,
|
||||
@@ -47,6 +48,10 @@ const ChoreCard = ({
|
||||
sx,
|
||||
viewOnly,
|
||||
onChipClick,
|
||||
// Multi-select props
|
||||
isMultiSelectMode = false,
|
||||
isSelected = false,
|
||||
onSelectionToggle,
|
||||
}) => {
|
||||
const [isChangeDueDateModalOpen, setIsChangeDueDateModalOpen] =
|
||||
React.useState(false)
|
||||
@@ -392,14 +397,54 @@ const ChoreCard = ({
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'space-between',
|
||||
p: 2,
|
||||
// backgroundColor: 'white',
|
||||
boxShadow: 'sm',
|
||||
borderRadius: 20,
|
||||
key: `${chore.id}-card`,
|
||||
|
||||
// mb: 2,
|
||||
position: 'relative',
|
||||
backgroundColor: 'background.surface',
|
||||
border: '1px solid',
|
||||
borderColor: 'divider',
|
||||
transition: 'all 0.2s ease-in-out',
|
||||
'&:hover': {
|
||||
boxShadow: 'md',
|
||||
borderColor: 'primary.300',
|
||||
},
|
||||
// Add padding when in multi-select mode to account for checkbox
|
||||
pl: isMultiSelectMode ? 6 : 2,
|
||||
}}
|
||||
>
|
||||
{/* Multi-select checkbox */}
|
||||
{isMultiSelectMode && (
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
onChange={onSelectionToggle}
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: 12,
|
||||
left: 12,
|
||||
zIndex: 2,
|
||||
bgcolor: 'background.surface',
|
||||
borderRadius: 'md',
|
||||
boxShadow: 'sm',
|
||||
border: '2px solid',
|
||||
borderColor: 'divider',
|
||||
'&:hover': {
|
||||
bgcolor: 'background.level1',
|
||||
borderColor: 'primary.300',
|
||||
},
|
||||
'&.Mui-checked': {
|
||||
bgcolor: 'primary.500',
|
||||
borderColor: 'primary.500',
|
||||
color: 'primary.solidColor',
|
||||
'&:hover': {
|
||||
bgcolor: 'primary.600',
|
||||
borderColor: 'primary.600',
|
||||
},
|
||||
},
|
||||
}}
|
||||
onClick={e => e.stopPropagation()}
|
||||
/>
|
||||
)}
|
||||
<Grid container>
|
||||
<Grid
|
||||
xs={9}
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Checkbox,
|
||||
Chip,
|
||||
CircularProgress,
|
||||
IconButton,
|
||||
@@ -46,6 +47,10 @@ const CompactChoreCard = ({
|
||||
sx,
|
||||
viewOnly,
|
||||
onChipClick,
|
||||
// Multi-select props
|
||||
isMultiSelectMode = false,
|
||||
isSelected = false,
|
||||
onSelectionToggle,
|
||||
}) => {
|
||||
const [isChangeDueDateModalOpen, setIsChangeDueDateModalOpen] =
|
||||
React.useState(false)
|
||||
@@ -389,16 +394,17 @@ const CompactChoreCard = ({
|
||||
...sx,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
// px: 1,
|
||||
// py: 0.75,
|
||||
minHeight: 56, // More compact height
|
||||
cursor: 'pointer',
|
||||
borderBottom: '1px solid',
|
||||
borderColor: 'divider',
|
||||
position: 'relative',
|
||||
pl: '16px', // Add left padding for the priority bar
|
||||
pl: isMultiSelectMode ? '48px' : '16px', // Add space for checkbox when in multi-select mode
|
||||
backgroundColor: 'background.surface',
|
||||
transition: 'all 0.2s ease-in-out',
|
||||
'&:hover': {
|
||||
bgcolor: 'background.level1',
|
||||
boxShadow: 'sm',
|
||||
},
|
||||
'&:last-child': {
|
||||
borderBottom: 'none',
|
||||
@@ -416,6 +422,37 @@ const CompactChoreCard = ({
|
||||
}}
|
||||
onClick={() => navigate(`/chores/${chore.id}`)}
|
||||
>
|
||||
{/* Multi-select checkbox */}
|
||||
{isMultiSelectMode && (
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
onChange={onSelectionToggle}
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
left: 16,
|
||||
zIndex: 2,
|
||||
bgcolor: 'background.surface',
|
||||
borderRadius: 'md',
|
||||
boxShadow: 'sm',
|
||||
border: '2px solid',
|
||||
borderColor: 'divider',
|
||||
'&:hover': {
|
||||
bgcolor: 'background.level1',
|
||||
borderColor: 'primary.300',
|
||||
},
|
||||
'&.Mui-checked': {
|
||||
bgcolor: 'primary.500',
|
||||
borderColor: 'primary.500',
|
||||
color: 'primary.solidColor',
|
||||
'&:hover': {
|
||||
bgcolor: 'primary.600',
|
||||
borderColor: 'primary.600',
|
||||
},
|
||||
},
|
||||
}}
|
||||
onClick={e => e.stopPropagation()}
|
||||
/>
|
||||
)}
|
||||
{/* Priority bar clickable area */}
|
||||
{chore.priority > 0 && (
|
||||
<Box
|
||||
|
||||
198
src/views/Chores/MultiSelectHelp.jsx
Normal file
198
src/views/Chores/MultiSelectHelp.jsx
Normal file
@@ -0,0 +1,198 @@
|
||||
import { Close, HelpOutline, Keyboard } from '@mui/icons-material'
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
Divider,
|
||||
IconButton,
|
||||
Modal,
|
||||
ModalDialog,
|
||||
Typography,
|
||||
} from '@mui/joy'
|
||||
import { useState } from 'react'
|
||||
|
||||
const MultiSelectHelp = ({ isVisible = true }) => {
|
||||
const [isHelpOpen, setIsHelpOpen] = useState(false)
|
||||
|
||||
if (!isVisible) return null
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Help Button */}
|
||||
<IconButton
|
||||
size='sm'
|
||||
variant='soft'
|
||||
color='neutral'
|
||||
onClick={() => setIsHelpOpen(true)}
|
||||
sx={{
|
||||
position: 'fixed',
|
||||
bottom: 24,
|
||||
right: 24,
|
||||
zIndex: 1000,
|
||||
width: 48,
|
||||
height: 48,
|
||||
borderRadius: '50%',
|
||||
boxShadow: 'lg',
|
||||
}}
|
||||
title='Show keyboard shortcuts'
|
||||
>
|
||||
<HelpOutline />
|
||||
</IconButton>
|
||||
|
||||
{/* Help Modal */}
|
||||
<Modal open={isHelpOpen} onClose={() => setIsHelpOpen(false)}>
|
||||
<ModalDialog
|
||||
variant='outlined'
|
||||
size='md'
|
||||
sx={{
|
||||
maxWidth: 500,
|
||||
p: 3,
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
mb: 2,
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Keyboard color='primary' />
|
||||
<Typography level='title-lg'>Multi-select Mode</Typography>
|
||||
</Box>
|
||||
<IconButton
|
||||
variant='plain'
|
||||
size='sm'
|
||||
onClick={() => setIsHelpOpen(false)}
|
||||
>
|
||||
<Close />
|
||||
</IconButton>
|
||||
</Box>
|
||||
|
||||
<Typography level='body-md' sx={{ mb: 3, color: 'text.secondary' }}>
|
||||
Use these keyboard shortcuts to work more efficiently with multiple
|
||||
tasks:
|
||||
</Typography>
|
||||
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
{/* Selection shortcuts */}
|
||||
<Card variant='soft' sx={{ p: 2 }}>
|
||||
<Typography
|
||||
level='title-sm'
|
||||
sx={{ mb: 1.5, color: 'primary.600' }}
|
||||
>
|
||||
Selection
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||
<ShortcutItem
|
||||
keys={['Ctrl', 'A']}
|
||||
description='Select all visible tasks'
|
||||
/>
|
||||
<ShortcutItem
|
||||
keys={['Esc']}
|
||||
description='Clear selection or exit multi-select mode'
|
||||
/>
|
||||
</Box>
|
||||
</Card>
|
||||
|
||||
{/* Action shortcuts */}
|
||||
<Card variant='soft' sx={{ p: 2 }}>
|
||||
<Typography
|
||||
level='title-sm'
|
||||
sx={{ mb: 1.5, color: 'success.600' }}
|
||||
>
|
||||
Actions
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||
<ShortcutItem
|
||||
keys={['Enter']}
|
||||
description='Mark selected tasks as completed'
|
||||
/>
|
||||
<ShortcutItem
|
||||
keys={['Del']}
|
||||
description='Delete selected tasks'
|
||||
/>
|
||||
</Box>
|
||||
</Card>
|
||||
|
||||
{/* Interface shortcuts */}
|
||||
<Card variant='soft' sx={{ p: 2 }}>
|
||||
<Typography
|
||||
level='title-sm'
|
||||
sx={{ mb: 1.5, color: 'warning.600' }}
|
||||
>
|
||||
Interface
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||
<ShortcutItem
|
||||
keys={['Ctrl', 'K']}
|
||||
description='Quick add new task'
|
||||
/>
|
||||
</Box>
|
||||
</Card>
|
||||
</Box>
|
||||
|
||||
<Divider sx={{ my: 3 }} />
|
||||
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center' }}>
|
||||
<Button
|
||||
variant='soft'
|
||||
onClick={() => setIsHelpOpen(false)}
|
||||
sx={{ minWidth: 120 }}
|
||||
>
|
||||
Got it!
|
||||
</Button>
|
||||
</Box>
|
||||
</ModalDialog>
|
||||
</Modal>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const ShortcutItem = ({ keys, description }) => (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: 2,
|
||||
}}
|
||||
>
|
||||
<Typography level='body-sm' sx={{ flex: 1 }}>
|
||||
{description}
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', gap: 0.5 }}>
|
||||
{keys.map((key, index) => (
|
||||
<Box
|
||||
key={index}
|
||||
sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}
|
||||
>
|
||||
{index > 0 && (
|
||||
<Typography level='body-xs' color='text.secondary'>
|
||||
+
|
||||
</Typography>
|
||||
)}
|
||||
<Box
|
||||
sx={{
|
||||
px: 1,
|
||||
py: 0.25,
|
||||
bgcolor: 'background.level2',
|
||||
borderRadius: 'sm',
|
||||
border: '1px solid',
|
||||
borderColor: 'divider',
|
||||
minWidth: 32,
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
<Typography level='body-xs' fontWeight='bold'>
|
||||
{key}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
|
||||
export default MultiSelectHelp
|
||||
@@ -2,10 +2,17 @@ import {
|
||||
Add,
|
||||
Bolt,
|
||||
CancelRounded,
|
||||
CheckBox,
|
||||
CheckBoxOutlineBlank,
|
||||
Close,
|
||||
Delete,
|
||||
Done,
|
||||
EditCalendar,
|
||||
ExpandCircleDown,
|
||||
Grain,
|
||||
PriorityHigh,
|
||||
SelectAll,
|
||||
SkipNext,
|
||||
Sort,
|
||||
Style,
|
||||
Unarchive,
|
||||
@@ -37,12 +44,16 @@ import { GetArchivedChores } from '../../utils/Fetcher'
|
||||
import Priorities from '../../utils/Priorities'
|
||||
import LoadingComponent from '../components/Loading'
|
||||
import { useLabels } from '../Labels/LabelQueries'
|
||||
import ConfirmationModal from '../Modals/Inputs/ConfirmationModal'
|
||||
import ChoreCard from './ChoreCard'
|
||||
import CompactChoreCard from './CompactChoreCard'
|
||||
import IconButtonWithMenu from './IconButtonWithMenu'
|
||||
import MultiSelectHelp from './MultiSelectHelp'
|
||||
|
||||
import { useImpersonateUser } from '../../contexts/ImpersonateUserContext.jsx'
|
||||
import { useCircleMembers, useUserProfile } from '../../queries/UserQueries'
|
||||
import { ChoreFilters, ChoresGrouper, ChoreSorter } from '../../utils/Chores'
|
||||
import { DeleteChore, MarkChoreComplete, SkipChore } from '../../utils/Fetcher'
|
||||
import TaskInput from '../components/AddTaskModal'
|
||||
import {
|
||||
canScheduleNotification,
|
||||
@@ -55,7 +66,8 @@ import SortAndGrouping from './SortAndGrouping'
|
||||
const MyChores = () => {
|
||||
const { data: userProfile, isLoading: isUserProfileLoading } =
|
||||
useUserProfile()
|
||||
const { showSuccess } = useNotification()
|
||||
const { showSuccess, showError } = useNotification()
|
||||
const { impersonatedUser } = useImpersonateUser()
|
||||
const [chores, setChores] = useState([])
|
||||
const [archivedChores, setArchivedChores] = useState(null)
|
||||
const [filteredChores, setFilteredChores] = useState([])
|
||||
@@ -92,6 +104,11 @@ const MyChores = () => {
|
||||
} = useChores()
|
||||
const { data: membersData, isLoading: membersLoading } = useCircleMembers()
|
||||
|
||||
// Multi-select state
|
||||
const [isMultiSelectMode, setIsMultiSelectMode] = useState(false)
|
||||
const [selectedChores, setSelectedChores] = useState(new Set())
|
||||
const [confirmModelConfig, setConfirmModelConfig] = useState({})
|
||||
|
||||
useEffect(() => {
|
||||
if (!choresLoading && !membersLoading && userProfile) {
|
||||
setPerformers(membersData.res)
|
||||
@@ -143,20 +160,133 @@ const MyChores = () => {
|
||||
}
|
||||
}, [searchInputFocus])
|
||||
|
||||
// add listern to Control/Command + K to focus on search input
|
||||
// Keyboard shortcuts for multi-select and other actions
|
||||
useEffect(() => {
|
||||
const handleKeyDown = event => {
|
||||
// Ctrl/Cmd + K to open task modal
|
||||
if ((event.ctrlKey || event.metaKey) && event.key === 'k') {
|
||||
event.preventDefault()
|
||||
setAddTaskModalOpen(true)
|
||||
return
|
||||
}
|
||||
|
||||
// Ctrl/Cmd + A to select all - works both in and out of multi-select mode
|
||||
if ((event.ctrlKey || event.metaKey) && event.key === 'a') {
|
||||
event.preventDefault()
|
||||
if (!isMultiSelectMode) {
|
||||
// Enable multi-select mode and select all visible tasks
|
||||
setIsMultiSelectMode(true)
|
||||
setTimeout(() => {
|
||||
selectAllVisibleChores()
|
||||
}, 0)
|
||||
// showSuccess({
|
||||
// title: '🎯 Multi-select Mode Active',
|
||||
// message: 'Selected all visible tasks. Press Esc to exit.',
|
||||
// })
|
||||
} else {
|
||||
// Already in multi-select mode, check if all visible tasks are already selected
|
||||
let visibleChores = []
|
||||
|
||||
if (searchTerm?.length > 0 || searchFilter !== 'All') {
|
||||
visibleChores = filteredChores
|
||||
const allVisibleSelected =
|
||||
visibleChores.length > 0 &&
|
||||
visibleChores.every(chore => selectedChores.has(chore.id))
|
||||
|
||||
if (allVisibleSelected) {
|
||||
showSuccess({
|
||||
title: '✅ All Tasks Selected',
|
||||
message: `All ${visibleChores.length} filtered task${visibleChores.length !== 1 ? 's are' : ' is'} already selected.`,
|
||||
})
|
||||
} else {
|
||||
selectAllVisibleChores()
|
||||
showSuccess({
|
||||
title: '🎯 Tasks Selected',
|
||||
message: `Selected ${visibleChores.length} filtered task${visibleChores.length !== 1 ? 's' : ''}.`,
|
||||
})
|
||||
}
|
||||
} else {
|
||||
// Check expanded sections first
|
||||
const expandedChores = choreSections
|
||||
.filter((section, index) => openChoreSections[index])
|
||||
.flatMap(section => section.content || [])
|
||||
|
||||
const allExpandedSelected =
|
||||
expandedChores.length > 0 &&
|
||||
expandedChores.every(chore => selectedChores.has(chore.id))
|
||||
|
||||
// Get all chores (including collapsed sections)
|
||||
const allChores = choreSections.flatMap(
|
||||
section => section.content || [],
|
||||
)
|
||||
const allChoresSelected =
|
||||
allChores.length > 0 &&
|
||||
allChores.every(chore => selectedChores.has(chore.id))
|
||||
|
||||
if (allChoresSelected) {
|
||||
// All chores (including collapsed) are already selected
|
||||
showSuccess({
|
||||
title: '✅ All Tasks Selected',
|
||||
message: `All ${allChores.length} task${allChores.length !== 1 ? 's are' : ' is'} already selected (including collapsed sections).`,
|
||||
})
|
||||
} else if (allExpandedSelected) {
|
||||
// All expanded are selected, now select ALL (including collapsed)
|
||||
selectAllVisibleChores() // This will now select all chores
|
||||
const collapsedCount = allChores.length - expandedChores.length
|
||||
showSuccess({
|
||||
title: '🎯 All Tasks Selected',
|
||||
message: `Selected all ${allChores.length} tasks (including ${collapsedCount} from collapsed sections).`,
|
||||
})
|
||||
} else {
|
||||
// Not all expanded are selected, select expanded only
|
||||
selectAllVisibleChores() // This will select expanded only
|
||||
showSuccess({
|
||||
title: '🎯 Tasks Selected',
|
||||
message: `Selected ${expandedChores.length} task${expandedChores.length !== 1 ? 's' : ''} from expanded sections.`,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Multi-select keyboard shortcuts (only when in multi-select mode)
|
||||
if (isMultiSelectMode) {
|
||||
// Escape to clear selection or exit multi-select mode
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault()
|
||||
if (selectedChores.size > 0) {
|
||||
clearSelection()
|
||||
} else {
|
||||
setIsMultiSelectMode(false)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Delete/Backspace key for bulk delete (with confirmation)
|
||||
if (
|
||||
(event.key === 'Delete' || event.key === 'Backspace') &&
|
||||
selectedChores.size > 0
|
||||
) {
|
||||
event.preventDefault()
|
||||
handleBulkDelete()
|
||||
return
|
||||
}
|
||||
|
||||
// Enter key for bulk complete
|
||||
if (event.key === 'Enter' && selectedChores.size > 0) {
|
||||
event.preventDefault()
|
||||
handleBulkComplete()
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
document.addEventListener('keydown', handleKeyDown)
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown)
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeyDown)
|
||||
}
|
||||
}, [])
|
||||
}, [isMultiSelectMode, selectedChores.size])
|
||||
const setSelectedChoreSectionWithCache = value => {
|
||||
setSelectedChoreSection(value)
|
||||
localStorage.setItem('selectedChoreSection', value)
|
||||
@@ -188,6 +318,10 @@ const MyChores = () => {
|
||||
performers={performers}
|
||||
userLabels={userLabels}
|
||||
onChipClick={handleLabelFiltering}
|
||||
// Multi-select props
|
||||
isMultiSelectMode={isMultiSelectMode}
|
||||
isSelected={selectedChores.has(chore.id)}
|
||||
onSelectionToggle={() => toggleChoreSelection(chore.id)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -375,6 +509,246 @@ const MyChores = () => {
|
||||
setFilteredChores(fuse.search(term).map(result => result.item))
|
||||
}
|
||||
|
||||
// Multi-select helper functions
|
||||
const toggleMultiSelectMode = () => {
|
||||
const newMode = !isMultiSelectMode
|
||||
setIsMultiSelectMode(newMode)
|
||||
|
||||
if (newMode) {
|
||||
setSelectedChores(new Set()) // Clear selection when exiting multi-select
|
||||
}
|
||||
}
|
||||
|
||||
const toggleChoreSelection = choreId => {
|
||||
const newSelection = new Set(selectedChores)
|
||||
if (newSelection.has(choreId)) {
|
||||
newSelection.delete(choreId)
|
||||
} else {
|
||||
newSelection.add(choreId)
|
||||
}
|
||||
setSelectedChores(newSelection)
|
||||
}
|
||||
|
||||
const selectAllVisibleChores = () => {
|
||||
let visibleChores = []
|
||||
|
||||
if (searchTerm?.length > 0 || searchFilter !== 'All') {
|
||||
// If there's a search term or filter, all filtered chores are visible
|
||||
visibleChores = filteredChores
|
||||
} else {
|
||||
// First, get chores from expanded sections only
|
||||
const expandedChores = choreSections
|
||||
.filter((section, index) => openChoreSections[index]) // Only expanded sections
|
||||
.flatMap(section => section.content || []) // Get all chores from expanded sections
|
||||
|
||||
// Check if all expanded chores are already selected
|
||||
const allExpandedSelected =
|
||||
expandedChores.length > 0 &&
|
||||
expandedChores.every(chore => selectedChores.has(chore.id))
|
||||
|
||||
if (allExpandedSelected) {
|
||||
// If all expanded chores are already selected, select ALL chores (including collapsed sections)
|
||||
visibleChores = choreSections.flatMap(section => section.content || [])
|
||||
} else {
|
||||
// Otherwise, just select expanded chores
|
||||
visibleChores = expandedChores
|
||||
}
|
||||
}
|
||||
|
||||
if (visibleChores.length > 0) {
|
||||
const allIds = new Set(visibleChores.map(chore => chore.id))
|
||||
setSelectedChores(allIds)
|
||||
}
|
||||
}
|
||||
|
||||
const clearSelection = () => {
|
||||
// if already empty, just exit multi-select mode:
|
||||
if (selectedChores.size === 0) {
|
||||
setIsMultiSelectMode(false)
|
||||
return
|
||||
}
|
||||
setSelectedChores(new Set())
|
||||
}
|
||||
|
||||
const getSelectedChoresData = () => {
|
||||
const allChores = [...chores, ...(archivedChores || [])]
|
||||
return Array.from(selectedChores)
|
||||
.map(id => allChores.find(chore => chore.id === id))
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
// Bulk operations with improved UX and confirmation modal
|
||||
const handleBulkComplete = async () => {
|
||||
const selectedData = getSelectedChoresData()
|
||||
if (selectedData.length === 0) return
|
||||
|
||||
setConfirmModelConfig({
|
||||
isOpen: true,
|
||||
title: 'Complete Tasks',
|
||||
confirmText: 'Complete',
|
||||
cancelText: 'Cancel',
|
||||
message: `Mark ${selectedData.length} task${selectedData.length > 1 ? 's' : ''} as completed?`,
|
||||
onClose: async isConfirmed => {
|
||||
if (isConfirmed === true) {
|
||||
try {
|
||||
const completedTasks = []
|
||||
const failedTasks = []
|
||||
|
||||
for (const chore of selectedData) {
|
||||
try {
|
||||
await MarkChoreComplete(
|
||||
chore.id,
|
||||
impersonatedUser
|
||||
? { completedBy: impersonatedUser.userId }
|
||||
: null,
|
||||
null,
|
||||
null,
|
||||
)
|
||||
completedTasks.push(chore)
|
||||
} catch (error) {
|
||||
failedTasks.push(chore)
|
||||
}
|
||||
}
|
||||
|
||||
if (completedTasks.length > 0) {
|
||||
showSuccess({
|
||||
title: '✅ Tasks Completed',
|
||||
message: `Successfully completed ${completedTasks.length} task${completedTasks.length > 1 ? 's' : ''}.`,
|
||||
})
|
||||
}
|
||||
|
||||
if (failedTasks.length > 0) {
|
||||
showError({
|
||||
title: 'Some Tasks Failed',
|
||||
message: `${failedTasks.length} task${failedTasks.length > 1 ? 's' : ''} could not be completed.`,
|
||||
})
|
||||
}
|
||||
|
||||
refetchChores()
|
||||
clearSelection()
|
||||
} catch (error) {
|
||||
showError({
|
||||
title: 'Bulk Complete Failed',
|
||||
message: 'An unexpected error occurred. Please try again.',
|
||||
})
|
||||
}
|
||||
}
|
||||
setConfirmModelConfig({})
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const handleBulkDelete = async () => {
|
||||
const selectedData = getSelectedChoresData()
|
||||
if (selectedData.length === 0) return
|
||||
|
||||
setConfirmModelConfig({
|
||||
isOpen: true,
|
||||
title: 'Delete Tasks',
|
||||
confirmText: 'Delete',
|
||||
cancelText: 'Cancel',
|
||||
message: `Delete ${selectedData.length} task${selectedData.length > 1 ? 's' : ''}?\n\nThis action cannot be undone.`,
|
||||
onClose: async isConfirmed => {
|
||||
if (isConfirmed === true) {
|
||||
try {
|
||||
const deletedTasks = []
|
||||
const failedTasks = []
|
||||
|
||||
for (const chore of selectedData) {
|
||||
try {
|
||||
await DeleteChore(chore.id)
|
||||
deletedTasks.push(chore)
|
||||
} catch (error) {
|
||||
failedTasks.push(chore)
|
||||
}
|
||||
}
|
||||
|
||||
if (deletedTasks.length > 0) {
|
||||
showSuccess({
|
||||
title: '🗑️ Tasks Deleted',
|
||||
message: `Successfully deleted ${deletedTasks.length} task${deletedTasks.length > 1 ? 's' : ''}.`,
|
||||
})
|
||||
|
||||
const deletedIds = new Set(deletedTasks.map(c => c.id))
|
||||
setChores(chores.filter(c => !deletedIds.has(c.id)))
|
||||
setFilteredChores(
|
||||
filteredChores.filter(c => !deletedIds.has(c.id)),
|
||||
)
|
||||
}
|
||||
|
||||
if (failedTasks.length > 0) {
|
||||
showError({
|
||||
title: 'Some Tasks Failed',
|
||||
message: `${failedTasks.length} task${failedTasks.length > 1 ? 's' : ''} could not be deleted.`,
|
||||
})
|
||||
}
|
||||
|
||||
clearSelection()
|
||||
} catch (error) {
|
||||
showError({
|
||||
title: 'Bulk Delete Failed',
|
||||
message: 'An unexpected error occurred. Please try again.',
|
||||
})
|
||||
}
|
||||
}
|
||||
setConfirmModelConfig({})
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const handleBulkSkip = async () => {
|
||||
const selectedData = getSelectedChoresData()
|
||||
if (selectedData.length === 0) return
|
||||
|
||||
setConfirmModelConfig({
|
||||
isOpen: true,
|
||||
title: 'Skip Tasks',
|
||||
confirmText: 'Skip',
|
||||
cancelText: 'Cancel',
|
||||
message: `Skip ${selectedData.length} task${selectedData.length > 1 ? 's' : ''} to next due date?`,
|
||||
onClose: async isConfirmed => {
|
||||
if (isConfirmed === true) {
|
||||
try {
|
||||
const skippedTasks = []
|
||||
const failedTasks = []
|
||||
|
||||
for (const chore of selectedData) {
|
||||
try {
|
||||
await SkipChore(chore.id)
|
||||
skippedTasks.push(chore)
|
||||
} catch (error) {
|
||||
failedTasks.push(chore)
|
||||
}
|
||||
}
|
||||
|
||||
if (skippedTasks.length > 0) {
|
||||
showSuccess({
|
||||
title: '⏭️ Tasks Skipped',
|
||||
message: `Successfully skipped ${skippedTasks.length} task${skippedTasks.length > 1 ? 's' : ''}.`,
|
||||
})
|
||||
}
|
||||
|
||||
if (failedTasks.length > 0) {
|
||||
showError({
|
||||
title: 'Some Tasks Failed',
|
||||
message: `${failedTasks.length} task${failedTasks.length > 1 ? 's' : ''} could not be skipped.`,
|
||||
})
|
||||
}
|
||||
|
||||
refetchChores()
|
||||
clearSelection()
|
||||
} catch (error) {
|
||||
showError({
|
||||
title: 'Bulk Skip Failed',
|
||||
message: 'An unexpected error occurred. Please try again.',
|
||||
})
|
||||
}
|
||||
}
|
||||
setConfirmModelConfig({})
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
if (
|
||||
isUserProfileLoading ||
|
||||
userLabelsLoading ||
|
||||
@@ -536,6 +910,26 @@ const MyChores = () => {
|
||||
>
|
||||
{isCompactView ? <ViewModule /> : <ViewAgenda />}
|
||||
</IconButton>
|
||||
|
||||
{/* Multi-select Toggle Button */}
|
||||
<IconButton
|
||||
variant={isMultiSelectMode ? 'solid' : 'outlined'}
|
||||
color={isMultiSelectMode ? 'primary' : 'neutral'}
|
||||
size='sm'
|
||||
sx={{
|
||||
height: 32,
|
||||
width: 32,
|
||||
borderRadius: '50%',
|
||||
}}
|
||||
onClick={toggleMultiSelectMode}
|
||||
title={
|
||||
isMultiSelectMode
|
||||
? 'Exit Multi-select Mode'
|
||||
: 'Enable Multi-select Mode'
|
||||
}
|
||||
>
|
||||
{isMultiSelectMode ? <CheckBox /> : <CheckBoxOutlineBlank />}
|
||||
</IconButton>
|
||||
</Box>
|
||||
{showSearchFilter && (
|
||||
<div className='flex gap-4'>
|
||||
@@ -657,6 +1051,186 @@ const MyChores = () => {
|
||||
</IconButton>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Multi-select Toolbar */}
|
||||
{isMultiSelectMode && (
|
||||
<Box
|
||||
sx={{
|
||||
borderRadius: 'lg',
|
||||
p: 2,
|
||||
mb: 2,
|
||||
border: '1px solid',
|
||||
borderColor: 'divider',
|
||||
// boxShadow: 'sm',
|
||||
gap: 2,
|
||||
display: 'flex',
|
||||
flexDirection: {
|
||||
xs: 'column', // Stack vertically on mobile
|
||||
sm: 'row', // Horizontal on tablet and larger
|
||||
},
|
||||
alignItems: {
|
||||
xs: 'stretch', // Full width on mobile
|
||||
sm: 'center', // Center aligned on larger screens
|
||||
},
|
||||
justifyContent: {
|
||||
xs: 'center',
|
||||
sm: 'space-between',
|
||||
},
|
||||
}}
|
||||
>
|
||||
{/* Selection Info and Controls */}
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 2,
|
||||
flexWrap: {
|
||||
xs: 'wrap', // Allow wrapping on mobile if needed
|
||||
sm: 'nowrap',
|
||||
},
|
||||
justifyContent: {
|
||||
xs: 'center', // Center on mobile
|
||||
sm: 'flex-start',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<CheckBox sx={{ color: 'primary.500' }} />
|
||||
<Typography level='body-sm' fontWeight='md'>
|
||||
{selectedChores.size} task
|
||||
{selectedChores.size !== 1 ? 's' : ''} selected
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Divider
|
||||
orientation='vertical'
|
||||
sx={{
|
||||
display: { xs: 'none', sm: 'block' }, // Hide vertical divider on mobile
|
||||
}}
|
||||
/>
|
||||
|
||||
<Box sx={{ display: 'flex', gap: 1 }}>
|
||||
<Button
|
||||
size='sm'
|
||||
variant='outlined'
|
||||
onClick={selectAllVisibleChores}
|
||||
startDecorator={<SelectAll />}
|
||||
disabled={
|
||||
searchTerm?.length > 0 || searchFilter !== 'All'
|
||||
? selectedChores.size === filteredChores.length
|
||||
: selectedChores.size ===
|
||||
choreSections.flatMap(s => s.content || []).length
|
||||
}
|
||||
sx={{
|
||||
minWidth: 'auto',
|
||||
'--Button-paddingInline': '0.75rem',
|
||||
}}
|
||||
>
|
||||
All
|
||||
</Button>
|
||||
<Button
|
||||
size='sm'
|
||||
variant='outlined'
|
||||
onClick={clearSelection}
|
||||
startDecorator={
|
||||
selectedChores.size === 0 ? (
|
||||
<Close />
|
||||
) : (
|
||||
<CheckBoxOutlineBlank />
|
||||
)
|
||||
}
|
||||
sx={{
|
||||
minWidth: 'auto',
|
||||
'--Button-paddingInline': '0.75rem',
|
||||
}}
|
||||
>
|
||||
{selectedChores.size === 0 ? 'Close' : 'Clear'}
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 1,
|
||||
flexWrap: {
|
||||
xs: 'wrap', // Allow wrapping on mobile
|
||||
sm: 'nowrap',
|
||||
},
|
||||
justifyContent: {
|
||||
xs: 'center', // Center on mobile
|
||||
sm: 'flex-end',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
size='sm'
|
||||
variant='solid'
|
||||
color='success'
|
||||
onClick={handleBulkComplete}
|
||||
startDecorator={<Done />}
|
||||
disabled={selectedChores.size === 0}
|
||||
sx={{
|
||||
'--Button-paddingInline': { xs: '0.75rem', sm: '1rem' },
|
||||
}}
|
||||
>
|
||||
Complete
|
||||
</Button>
|
||||
<Button
|
||||
size='sm'
|
||||
variant='soft'
|
||||
color='warning'
|
||||
onClick={handleBulkSkip}
|
||||
startDecorator={<SkipNext />}
|
||||
disabled={selectedChores.size === 0}
|
||||
sx={{
|
||||
'--Button-paddingInline': { xs: '0.75rem', sm: '1rem' },
|
||||
}}
|
||||
>
|
||||
Skip
|
||||
</Button>
|
||||
<Button
|
||||
size='sm'
|
||||
variant='soft'
|
||||
color='danger'
|
||||
onClick={handleBulkDelete}
|
||||
startDecorator={<Delete />}
|
||||
disabled={selectedChores.size === 0}
|
||||
sx={{
|
||||
'--Button-paddingInline': { xs: '0.75rem', sm: '1rem' },
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
{/*
|
||||
<Divider
|
||||
orientation='vertical'
|
||||
sx={{
|
||||
display: { xs: 'none', sm: 'block' }, // Hide vertical divider on mobile
|
||||
}}
|
||||
/>
|
||||
|
||||
<IconButton
|
||||
size='sm'
|
||||
variant='plain'
|
||||
onClick={toggleMultiSelectMode}
|
||||
color='neutral'
|
||||
title='Exit multi-select mode (Esc)'
|
||||
sx={{
|
||||
'&:hover': {
|
||||
bgcolor: 'danger.softBg',
|
||||
color: 'danger.softColor',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<CancelRounded />
|
||||
</IconButton> */}
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{searchFilter !== 'All' && (
|
||||
<Chip
|
||||
level='title-md'
|
||||
@@ -902,6 +1476,14 @@ const MyChores = () => {
|
||||
</Container>
|
||||
|
||||
<Sidepanel chores={chores} performers={performers} />
|
||||
|
||||
{/* Multi-select Help - only show when in multi-select mode */}
|
||||
<MultiSelectHelp isVisible={isMultiSelectMode} />
|
||||
|
||||
{/* Confirmation Modal for bulk operations */}
|
||||
{confirmModelConfig?.isOpen && (
|
||||
<ConfirmationModal config={confirmModelConfig} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -949,7 +1531,7 @@ const FILTERS = {
|
||||
return chore.assignedTo === userID
|
||||
})
|
||||
},
|
||||
'No Due Date': function (chores, userID) {
|
||||
'No Due Date': function (chores) {
|
||||
return chores.filter(chore => {
|
||||
return chore.nextDueDate === null
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user