Refactor MyChores component for improved maintainability
Reduced MyChores.jsx from 2,839 to 1,364 lines (52% reduction) by extracting reusable hooks and components. Created 6 custom hooks: - useMultiSelect: Multi-select mode and selection state management - useChoreModals: Centralized modal state management - useProjectFilter: Project filtering with localStorage sync - useChoreFilters: Multi-layer filtering (project, search, user filters) - useKeyboardShortcuts: All keyboard event handling - useChoreActions: Chore actions and bulk operations Created 3 components: - SearchBar: Clean search input with keyboard hints - MultiSelectToolbar: Full multi-select toolbar with action buttons - ChoreModals: Centralized modal rendering
This commit is contained in:
File diff suppressed because it is too large
Load Diff
88
src/views/Chores/components/ChoreModals.jsx
Normal file
88
src/views/Chores/components/ChoreModals.jsx
Normal file
@@ -0,0 +1,88 @@
|
||||
import DateModal from '../../Modals/Inputs/DateModal'
|
||||
import NudgeModal from '../../Modals/Inputs/NudgeModal'
|
||||
import SelectModal from '../../Modals/Inputs/SelectModal'
|
||||
import TextModal from '../../Modals/Inputs/TextModal'
|
||||
import WriteNFCModal from '../../Modals/Inputs/WriteNFCModal'
|
||||
|
||||
const ChoreModals = ({
|
||||
activeModal,
|
||||
modalChore,
|
||||
membersData,
|
||||
onChangeDueDate,
|
||||
onCompleteWithPastDate,
|
||||
onAssigneeChange,
|
||||
onCompleteWithNote,
|
||||
onNudge,
|
||||
onClose,
|
||||
}) => {
|
||||
return (
|
||||
<>
|
||||
{activeModal === 'changeDueDate' && modalChore && (
|
||||
<DateModal
|
||||
isOpen={true}
|
||||
key={'changeDueDate' + modalChore.id}
|
||||
current={modalChore.nextDueDate}
|
||||
title='Change due date'
|
||||
onClose={onClose}
|
||||
onSave={onChangeDueDate}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeModal === 'completeWithPastDate' && modalChore && (
|
||||
<DateModal
|
||||
isOpen={true}
|
||||
key={'completedInPast' + modalChore.id}
|
||||
current={modalChore.nextDueDate}
|
||||
title='Save Chore that you completed in the past'
|
||||
onClose={onClose}
|
||||
onSave={onCompleteWithPastDate}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeModal === 'changeAssignee' && modalChore && (
|
||||
<SelectModal
|
||||
isOpen={true}
|
||||
options={membersData?.res || []}
|
||||
displayKey='displayName'
|
||||
title='Delegate to someone else'
|
||||
placeholder='Select a performer'
|
||||
onClose={onClose}
|
||||
onSave={selected => onAssigneeChange(selected.id)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeModal === 'completeWithNote' && modalChore && (
|
||||
<TextModal
|
||||
isOpen={true}
|
||||
title='Add note to attach to this completion:'
|
||||
onClose={onClose}
|
||||
okText='Complete'
|
||||
onSave={onCompleteWithNote}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeModal === 'writeNFC' && modalChore && (
|
||||
<WriteNFCModal
|
||||
config={{
|
||||
isOpen: true,
|
||||
url: `${window.location.origin}/chores/${modalChore.id}`,
|
||||
onClose: onClose,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeModal === 'nudge' && modalChore && (
|
||||
<NudgeModal
|
||||
config={{
|
||||
isOpen: true,
|
||||
choreId: modalChore.id,
|
||||
onClose: onClose,
|
||||
onConfirm: onNudge,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default ChoreModals
|
||||
278
src/views/Chores/components/MultiSelectToolbar.jsx
Normal file
278
src/views/Chores/components/MultiSelectToolbar.jsx
Normal file
@@ -0,0 +1,278 @@
|
||||
import {
|
||||
Archive,
|
||||
CheckBox,
|
||||
CheckBoxOutlineBlank,
|
||||
Close,
|
||||
Delete,
|
||||
Done,
|
||||
SelectAll,
|
||||
SkipNext,
|
||||
} from '@mui/icons-material'
|
||||
import { Box, Button, Divider, Typography } from '@mui/joy'
|
||||
import KeyboardShortcutHint from '../../../components/common/KeyboardShortcutHint'
|
||||
|
||||
const MultiSelectToolbar = ({
|
||||
isVisible,
|
||||
selectedCount,
|
||||
onSelectAll,
|
||||
onClear,
|
||||
onComplete,
|
||||
onSkip,
|
||||
onArchive,
|
||||
onDelete,
|
||||
showKeyboardShortcuts,
|
||||
selectAllDisabled,
|
||||
}) => {
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
position: 'sticky',
|
||||
top: 0,
|
||||
zIndex: 1000,
|
||||
overflow: 'hidden',
|
||||
transition: 'all 0.3s ease-in-out',
|
||||
maxHeight: isVisible ? '200px' : '0',
|
||||
opacity: isVisible ? 1 : 0,
|
||||
transform: isVisible ? 'translateY(0)' : 'translateY(-20px)',
|
||||
marginBottom: isVisible ? 2 : 0,
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
backgroundColor: 'background.surface',
|
||||
backdropFilter: 'blur(8px)',
|
||||
borderRadius: 'lg',
|
||||
p: 2,
|
||||
border: '1px solid',
|
||||
borderColor: 'divider',
|
||||
boxShadow: 'm',
|
||||
gap: 2,
|
||||
display: 'flex',
|
||||
flexDirection: {
|
||||
sm: 'column',
|
||||
md: 'row',
|
||||
},
|
||||
alignItems: {
|
||||
xs: 'stretch',
|
||||
sm: 'center',
|
||||
},
|
||||
justifyContent: {
|
||||
xs: 'center',
|
||||
sm: 'space-between',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 2,
|
||||
flexWrap: {
|
||||
xs: 'wrap',
|
||||
sm: 'nowrap',
|
||||
},
|
||||
justifyContent: {
|
||||
xs: 'center',
|
||||
sm: 'flex-start',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<CheckBox sx={{ color: 'primary.500' }} />
|
||||
<Typography level='body-sm' fontWeight='md'>
|
||||
{selectedCount} task{selectedCount !== 1 ? 's' : ''} selected
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Divider
|
||||
orientation='vertical'
|
||||
sx={{
|
||||
display: { xs: 'none', sm: 'block' },
|
||||
}}
|
||||
/>
|
||||
|
||||
<Box sx={{ display: 'flex', gap: 1 }}>
|
||||
<Button
|
||||
size='sm'
|
||||
variant='outlined'
|
||||
onClick={onSelectAll}
|
||||
startDecorator={<SelectAll />}
|
||||
disabled={selectAllDisabled}
|
||||
sx={{
|
||||
minWidth: 'auto',
|
||||
'--Button-paddingInline': '0.75rem',
|
||||
position: 'relative',
|
||||
}}
|
||||
title='Select all visible tasks (Ctrl+A)'
|
||||
>
|
||||
All
|
||||
{showKeyboardShortcuts && (
|
||||
<KeyboardShortcutHint
|
||||
shortcut='A'
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: -8,
|
||||
right: -8,
|
||||
zIndex: 1000,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
size='sm'
|
||||
variant='outlined'
|
||||
onClick={onClear}
|
||||
startDecorator={
|
||||
selectedCount === 0 ? <Close /> : <CheckBoxOutlineBlank />
|
||||
}
|
||||
sx={{
|
||||
minWidth: 'auto',
|
||||
'--Button-paddingInline': '0.75rem',
|
||||
position: 'relative',
|
||||
}}
|
||||
title={`${selectedCount === 0 ? 'Close' : 'Clear'} multi-select (Esc)`}
|
||||
>
|
||||
{selectedCount === 0 ? 'Close' : 'Clear'}
|
||||
{showKeyboardShortcuts && (
|
||||
<KeyboardShortcutHint
|
||||
withCtrl={false}
|
||||
shortcut='Esc'
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: -8,
|
||||
right: -8,
|
||||
zIndex: 1000,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 1,
|
||||
flexWrap: {
|
||||
xs: 'wrap',
|
||||
sm: 'nowrap',
|
||||
},
|
||||
justifyContent: {
|
||||
xs: 'center',
|
||||
sm: 'flex-end',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
size='sm'
|
||||
variant='solid'
|
||||
color='success'
|
||||
onClick={onComplete}
|
||||
startDecorator={<Done />}
|
||||
disabled={selectedCount === 0}
|
||||
sx={{
|
||||
'--Button-paddingInline': { xs: '0.75rem', sm: '1rem' },
|
||||
position: 'relative',
|
||||
}}
|
||||
title='Complete selected tasks (Enter)'
|
||||
>
|
||||
Complete
|
||||
{showKeyboardShortcuts && selectedCount > 0 && (
|
||||
<KeyboardShortcutHint
|
||||
shortcut='Enter'
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: -8,
|
||||
right: -8,
|
||||
zIndex: 1000,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
size='sm'
|
||||
variant='soft'
|
||||
color='warning'
|
||||
onClick={onSkip}
|
||||
startDecorator={<SkipNext />}
|
||||
disabled={selectedCount === 0}
|
||||
sx={{
|
||||
'--Button-paddingInline': { xs: '0.75rem', sm: '1rem' },
|
||||
position: 'relative',
|
||||
}}
|
||||
title='Skip selected tasks (/)'
|
||||
>
|
||||
Skip
|
||||
{showKeyboardShortcuts && selectedCount > 0 && (
|
||||
<KeyboardShortcutHint
|
||||
shortcut='/'
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: -8,
|
||||
right: -8,
|
||||
zIndex: 1000,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
size='sm'
|
||||
variant='soft'
|
||||
color='danger'
|
||||
onClick={onArchive}
|
||||
startDecorator={<Archive />}
|
||||
disabled={selectedCount === 0}
|
||||
sx={{
|
||||
'--Button-paddingInline': { xs: '0.75rem', sm: '1rem' },
|
||||
position: 'relative',
|
||||
}}
|
||||
title='Archive selected tasks (X)'
|
||||
>
|
||||
Archive
|
||||
{showKeyboardShortcuts && selectedCount > 0 && (
|
||||
<KeyboardShortcutHint
|
||||
shortcut='X'
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: -8,
|
||||
right: -8,
|
||||
zIndex: 1000,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
size='sm'
|
||||
variant='soft'
|
||||
color='danger'
|
||||
onClick={onDelete}
|
||||
startDecorator={<Delete />}
|
||||
disabled={selectedCount === 0}
|
||||
sx={{
|
||||
'--Button-paddingInline': { xs: '0.75rem', sm: '1rem' },
|
||||
position: 'relative',
|
||||
}}
|
||||
title='Delete selected tasks (Shift+X)'
|
||||
>
|
||||
Delete
|
||||
{showKeyboardShortcuts && selectedCount > 0 && (
|
||||
<KeyboardShortcutHint
|
||||
shortcut='E'
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: -8,
|
||||
right: -8,
|
||||
zIndex: 1000,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
export default MultiSelectToolbar
|
||||
46
src/views/Chores/components/SearchBar.jsx
Normal file
46
src/views/Chores/components/SearchBar.jsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import { CancelRounded } from '@mui/icons-material'
|
||||
import { Box, Input } from '@mui/joy'
|
||||
import KeyboardShortcutHint from '../../../components/common/KeyboardShortcutHint'
|
||||
|
||||
const SearchBar = ({
|
||||
value,
|
||||
onChange,
|
||||
onClose,
|
||||
onFocus,
|
||||
showKeyboardShortcuts,
|
||||
inputRef,
|
||||
}) => {
|
||||
return (
|
||||
<Input
|
||||
slotProps={{ input: { ref: inputRef } }}
|
||||
placeholder='Search'
|
||||
value={value}
|
||||
onFocus={onFocus}
|
||||
fullWidth
|
||||
sx={{
|
||||
mt: 1,
|
||||
mb: 1,
|
||||
borderRadius: 24,
|
||||
height: 24,
|
||||
borderColor: 'text.disabled',
|
||||
padding: 1,
|
||||
}}
|
||||
onChange={onChange}
|
||||
startDecorator={
|
||||
<KeyboardShortcutHint shortcut='F' show={showKeyboardShortcuts} />
|
||||
}
|
||||
endDecorator={
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
||||
{value && (
|
||||
<>
|
||||
<KeyboardShortcutHint shortcut='X' show={showKeyboardShortcuts} />
|
||||
<CancelRounded onClick={onClose} />
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default SearchBar
|
||||
721
src/views/Chores/hooks/useChoreActions.js
Normal file
721
src/views/Chores/hooks/useChoreActions.js
Normal file
@@ -0,0 +1,721 @@
|
||||
import { useCallback } from 'react'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { useArchiveChore, useDeleteChores } from '../../../queries/ChoreQueries'
|
||||
import { usePauseChore, useStartChore } from '../../../queries/TimeQueries'
|
||||
import {
|
||||
ApproveChore,
|
||||
DeleteChore,
|
||||
MarkChoreComplete,
|
||||
NudgeChore,
|
||||
RejectChore,
|
||||
SkipChore,
|
||||
UndoChoreAction,
|
||||
UpdateChoreAssignee,
|
||||
UpdateDueDate,
|
||||
} from '../../../utils/Fetcher'
|
||||
|
||||
export const useChoreActions = ({
|
||||
chores,
|
||||
filteredChores,
|
||||
setChores,
|
||||
setFilteredChores,
|
||||
userProfile,
|
||||
impersonatedUser,
|
||||
showSuccess,
|
||||
showError,
|
||||
showWarning,
|
||||
showUndo,
|
||||
refetchChores,
|
||||
setConfirmModelConfig,
|
||||
openModal,
|
||||
closeModal,
|
||||
modalChore,
|
||||
getSelectedChoresData,
|
||||
clearSelection,
|
||||
}) => {
|
||||
const queryClient = useQueryClient()
|
||||
const archiveChore = useArchiveChore()
|
||||
const startChore = useStartChore()
|
||||
const pauseChore = usePauseChore()
|
||||
|
||||
const updateChoreInState = useCallback(
|
||||
(updatedChore, event) => {
|
||||
let newChores = chores.map(c =>
|
||||
c.id === updatedChore.id ? updatedChore : c,
|
||||
)
|
||||
let newFilteredChores = filteredChores.map(c =>
|
||||
c.id === updatedChore.id ? updatedChore : c,
|
||||
)
|
||||
|
||||
if (
|
||||
event === 'archive' ||
|
||||
(event === 'completed' && updatedChore.frequencyType === 'once') ||
|
||||
updatedChore.frequencyType === 'trigger'
|
||||
) {
|
||||
newChores = newChores.filter(c => c.id !== updatedChore.id)
|
||||
newFilteredChores = newFilteredChores.filter(
|
||||
c => c.id !== updatedChore.id,
|
||||
)
|
||||
}
|
||||
|
||||
setChores(newChores)
|
||||
setFilteredChores(newFilteredChores)
|
||||
|
||||
queryClient.invalidateQueries({ queryKey: ['chores'] })
|
||||
|
||||
const undoableActions = {
|
||||
completed: 'Task completed',
|
||||
approved: 'Task approved',
|
||||
rejected: 'Task rejected',
|
||||
skipped: 'Task skipped',
|
||||
}
|
||||
|
||||
if (undoableActions[event]) {
|
||||
showSuccess({
|
||||
message: undoableActions[event],
|
||||
undoAction: async () => {
|
||||
try {
|
||||
const undoResponse = await UndoChoreAction(updatedChore.id)
|
||||
if (undoResponse.ok) {
|
||||
refetchChores()
|
||||
const undoMessages = {
|
||||
completed: 'Task completion has been undone.',
|
||||
approved: 'Task approval has been undone.',
|
||||
rejected: 'Task rejection has been undone.',
|
||||
skipped: 'Task skip has been undone.',
|
||||
}
|
||||
showUndo({
|
||||
title: 'Undo Successful',
|
||||
message: undoMessages[event],
|
||||
})
|
||||
} else {
|
||||
throw new Error('Failed to undo')
|
||||
}
|
||||
} catch (error) {
|
||||
showError({
|
||||
title: 'Undo Failed',
|
||||
message: 'Unable to undo the action. Please try again.',
|
||||
})
|
||||
}
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const notifications = {
|
||||
rescheduled: {
|
||||
type: 'success',
|
||||
title: 'Task Rescheduled',
|
||||
message: 'The task due date has been updated successfully.',
|
||||
},
|
||||
'due-date-removed': {
|
||||
type: 'success',
|
||||
title: 'Task Unplanned',
|
||||
message: 'The task is now unplanned and has no due date.',
|
||||
},
|
||||
unarchive: {
|
||||
type: 'success',
|
||||
title: 'Task Restored',
|
||||
message: 'The task has been restored and is now active.',
|
||||
},
|
||||
archive: {
|
||||
type: 'success',
|
||||
title: 'Task Archived',
|
||||
message: 'The task has been archived and hidden from the active list.',
|
||||
},
|
||||
started: {
|
||||
type: 'success',
|
||||
title: 'Task Started',
|
||||
message: 'The task has been marked as started.',
|
||||
},
|
||||
paused: {
|
||||
type: 'warning',
|
||||
title: 'Task Paused',
|
||||
message: 'The task has been paused.',
|
||||
},
|
||||
deleted: {
|
||||
type: 'success',
|
||||
title: 'Task Deleted',
|
||||
message: 'The task has been deleted.',
|
||||
},
|
||||
}
|
||||
|
||||
const notification = notifications[event]
|
||||
if (notification) {
|
||||
const notifyFn =
|
||||
notification.type === 'warning' ? showWarning : showSuccess
|
||||
notifyFn({ title: notification.title, message: notification.message })
|
||||
}
|
||||
},
|
||||
[chores, filteredChores, setChores, setFilteredChores, queryClient, showSuccess, showError, showWarning, showUndo, refetchChores],
|
||||
)
|
||||
|
||||
const handleChoreAction = useCallback(
|
||||
async (action, chore, extraData = {}) => {
|
||||
switch (action) {
|
||||
case 'complete':
|
||||
try {
|
||||
const response = await MarkChoreComplete(
|
||||
chore.id,
|
||||
impersonatedUser ? { completedBy: impersonatedUser.userId } : null,
|
||||
null,
|
||||
null,
|
||||
)
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
updateChoreInState(data.res, 'completed')
|
||||
}
|
||||
} catch (error) {
|
||||
if (error?.queued) {
|
||||
showError({
|
||||
title: 'Update Failed',
|
||||
message: 'Request will be reattempt when you are online',
|
||||
})
|
||||
} else {
|
||||
showError({
|
||||
title: 'Failed to update',
|
||||
message: error,
|
||||
})
|
||||
}
|
||||
}
|
||||
break
|
||||
|
||||
case 'start':
|
||||
startChore.mutate(chore.id, {
|
||||
onSuccess: async res => {
|
||||
const data = await res.json()
|
||||
const newChore = { ...chore, status: data.res.status }
|
||||
updateChoreInState(newChore, 'started')
|
||||
},
|
||||
onError: error => {
|
||||
showError({
|
||||
title: 'Failed to start',
|
||||
message: error.message || 'Unable to start chore',
|
||||
})
|
||||
},
|
||||
})
|
||||
break
|
||||
|
||||
case 'pause':
|
||||
pauseChore.mutate(chore.id, {
|
||||
onSuccess: async res => {
|
||||
const data = await res.json()
|
||||
const newChore = { ...chore, status: data.res.status }
|
||||
updateChoreInState(newChore, 'paused')
|
||||
},
|
||||
onError: error => {
|
||||
showError({
|
||||
title: 'Failed to pause',
|
||||
message: error.message || 'Unable to pause chore',
|
||||
})
|
||||
},
|
||||
})
|
||||
break
|
||||
|
||||
case 'approve':
|
||||
try {
|
||||
const response = await ApproveChore(chore.id)
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
updateChoreInState(data.res, 'approved')
|
||||
}
|
||||
} catch (error) {
|
||||
showError({
|
||||
title: 'Failed to approve',
|
||||
message: error.message || 'Unable to approve chore',
|
||||
})
|
||||
}
|
||||
break
|
||||
|
||||
case 'reject':
|
||||
try {
|
||||
const response = await RejectChore(chore.id)
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
updateChoreInState(data.res, 'rejected')
|
||||
}
|
||||
} catch (error) {
|
||||
showError({
|
||||
title: 'Failed to reject',
|
||||
message: error.message || 'Unable to reject chore',
|
||||
})
|
||||
}
|
||||
break
|
||||
|
||||
case 'delete':
|
||||
setConfirmModelConfig({
|
||||
isOpen: true,
|
||||
title: 'Delete Chore',
|
||||
confirmText: 'Delete',
|
||||
cancelText: 'Cancel',
|
||||
message: 'Are you sure you want to delete this chore?',
|
||||
onClose: async isConfirmed => {
|
||||
if (isConfirmed === true) {
|
||||
try {
|
||||
const response = await DeleteChore(chore.id)
|
||||
if (response.ok) {
|
||||
const newChores = chores.filter(c => c.id !== chore.id)
|
||||
const newFilteredChores = filteredChores.filter(
|
||||
c => c.id !== chore.id,
|
||||
)
|
||||
setChores(newChores)
|
||||
setFilteredChores(newFilteredChores)
|
||||
showSuccess({
|
||||
title: 'Task Deleted',
|
||||
message: 'The task has been deleted successfully.',
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
showError({
|
||||
title: 'Failed to delete',
|
||||
message: error,
|
||||
})
|
||||
}
|
||||
}
|
||||
setConfirmModelConfig({})
|
||||
},
|
||||
})
|
||||
break
|
||||
|
||||
case 'archive':
|
||||
try {
|
||||
await new Promise((resolve, reject) => {
|
||||
archiveChore.mutate(chore.id, {
|
||||
onSuccess: data => {
|
||||
updateChoreInState(data, 'archive')
|
||||
resolve(data)
|
||||
},
|
||||
onError: error => {
|
||||
showError({
|
||||
title: 'Failed to archive',
|
||||
message: error.message || 'Unable to archive chore',
|
||||
})
|
||||
reject(error)
|
||||
},
|
||||
})
|
||||
})
|
||||
} catch (error) {
|
||||
}
|
||||
break
|
||||
|
||||
case 'skip':
|
||||
try {
|
||||
const response = await SkipChore(chore.id)
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
updateChoreInState(data.res, 'skipped')
|
||||
}
|
||||
} catch (error) {
|
||||
showError({
|
||||
title: 'Failed to skip',
|
||||
message: error,
|
||||
})
|
||||
}
|
||||
break
|
||||
|
||||
case 'changeDueDate':
|
||||
if (extraData && 'date' in extraData) {
|
||||
try {
|
||||
const response = await UpdateDueDate(chore.id, extraData.date)
|
||||
if (response.ok) {
|
||||
chore.nextDueDate = extraData.date
|
||||
const eventType =
|
||||
extraData.date === null ? 'due-date-removed' : 'rescheduled'
|
||||
updateChoreInState(chore, eventType)
|
||||
}
|
||||
} catch (error) {
|
||||
showError({
|
||||
title:
|
||||
extraData.date === null
|
||||
? 'Failed to remove due date'
|
||||
: 'Failed to reschedule',
|
||||
message: error.message || 'Unable to update due date',
|
||||
})
|
||||
}
|
||||
} else {
|
||||
openModal(action, chore, extraData)
|
||||
}
|
||||
break
|
||||
|
||||
case 'completeWithNote':
|
||||
case 'completeWithPastDate':
|
||||
case 'changeAssignee':
|
||||
case 'writeNFC':
|
||||
case 'nudge':
|
||||
openModal(action, chore, extraData)
|
||||
break
|
||||
|
||||
default:
|
||||
console.warn('Unknown action:', action)
|
||||
}
|
||||
},
|
||||
[
|
||||
impersonatedUser,
|
||||
chores,
|
||||
filteredChores,
|
||||
setChores,
|
||||
setFilteredChores,
|
||||
updateChoreInState,
|
||||
showError,
|
||||
showSuccess,
|
||||
setConfirmModelConfig,
|
||||
openModal,
|
||||
archiveChore,
|
||||
startChore,
|
||||
pauseChore,
|
||||
],
|
||||
)
|
||||
|
||||
const handleChangeDueDate = useCallback(
|
||||
newDate => {
|
||||
if (!modalChore) return
|
||||
UpdateDueDate(modalChore.id, newDate).then(response => {
|
||||
if (response.ok) {
|
||||
response.json().then(data => {
|
||||
const newChore = modalChore
|
||||
newChore.nextDueDate = newDate
|
||||
updateChoreInState(newChore, 'rescheduled')
|
||||
})
|
||||
}
|
||||
})
|
||||
closeModal()
|
||||
},
|
||||
[modalChore, updateChoreInState, closeModal],
|
||||
)
|
||||
|
||||
const handleCompleteWithPastDate = useCallback(
|
||||
newDate => {
|
||||
if (!modalChore) return
|
||||
MarkChoreComplete(
|
||||
modalChore.id,
|
||||
impersonatedUser ? { completedBy: impersonatedUser.userId } : null,
|
||||
new Date(newDate).toISOString(),
|
||||
null,
|
||||
).then(response => {
|
||||
if (response.ok) {
|
||||
response.json().then(data => {
|
||||
const newChore = data.res
|
||||
updateChoreInState(newChore, 'completed')
|
||||
})
|
||||
}
|
||||
})
|
||||
closeModal()
|
||||
},
|
||||
[modalChore, impersonatedUser, updateChoreInState, closeModal],
|
||||
)
|
||||
|
||||
const handleAssigneeChange = useCallback(
|
||||
assigneeId => {
|
||||
if (!modalChore) return
|
||||
UpdateChoreAssignee(modalChore.id, assigneeId).then(response => {
|
||||
if (response.ok) {
|
||||
response.json().then(data => {
|
||||
const newChore = data.res
|
||||
updateChoreInState(newChore, 'assigned')
|
||||
})
|
||||
}
|
||||
})
|
||||
closeModal()
|
||||
},
|
||||
[modalChore, updateChoreInState, closeModal],
|
||||
)
|
||||
|
||||
const handleCompleteWithNote = useCallback(
|
||||
note => {
|
||||
if (!modalChore) return
|
||||
MarkChoreComplete(
|
||||
modalChore.id,
|
||||
impersonatedUser
|
||||
? { note, completedBy: impersonatedUser.userId }
|
||||
: { note },
|
||||
null,
|
||||
null,
|
||||
).then(response => {
|
||||
if (response.ok) {
|
||||
response.json().then(data => {
|
||||
const newChore = data.res
|
||||
updateChoreInState(newChore, 'completed')
|
||||
})
|
||||
}
|
||||
})
|
||||
closeModal()
|
||||
},
|
||||
[modalChore, impersonatedUser, updateChoreInState, closeModal],
|
||||
)
|
||||
|
||||
const handleNudge = useCallback(
|
||||
async ({ choreId, message, notifyAllAssignees }) => {
|
||||
try {
|
||||
const response = await NudgeChore(choreId, {
|
||||
message,
|
||||
notifyAllAssignees,
|
||||
})
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
showSuccess({
|
||||
title: 'Nudge Sent!',
|
||||
message: data.message || 'Nudge sent successfully',
|
||||
})
|
||||
} else {
|
||||
throw new Error('Failed to send nudge')
|
||||
}
|
||||
} catch (error) {
|
||||
showError({
|
||||
title: 'Failed to Send Nudge',
|
||||
message: error.message || 'Unable to send nudge at this time',
|
||||
})
|
||||
} finally {
|
||||
closeModal()
|
||||
}
|
||||
},
|
||||
[showSuccess, showError, closeModal],
|
||||
)
|
||||
|
||||
const handleBulkComplete = useCallback(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({})
|
||||
},
|
||||
})
|
||||
}, [getSelectedChoresData, impersonatedUser, showSuccess, showError, refetchChores, clearSelection, setConfirmModelConfig])
|
||||
|
||||
const handleBulkArchive = useCallback(async () => {
|
||||
const selectedData = getSelectedChoresData()
|
||||
if (selectedData.length === 0) return
|
||||
|
||||
setConfirmModelConfig({
|
||||
isOpen: true,
|
||||
title: 'Archive Tasks',
|
||||
confirmText: 'Archive',
|
||||
cancelText: 'Cancel',
|
||||
message: `Archive ${selectedData.length} task${selectedData.length > 1 ? 's' : ''}?`,
|
||||
onClose: async isConfirmed => {
|
||||
if (isConfirmed === true) {
|
||||
try {
|
||||
const archivedTasks = []
|
||||
const failedTasks = []
|
||||
for (const chore of selectedData) {
|
||||
try {
|
||||
await new Promise((resolve, reject) => {
|
||||
archiveChore.mutate(chore.id, {
|
||||
onSuccess: data => {
|
||||
archivedTasks.push(data)
|
||||
setChores(prev => prev.filter(c => c.id !== chore.id))
|
||||
setFilteredChores(prev =>
|
||||
prev.filter(c => c.id !== chore.id),
|
||||
)
|
||||
resolve(data)
|
||||
},
|
||||
onError: error => {
|
||||
failedTasks.push(chore)
|
||||
reject(error)
|
||||
},
|
||||
})
|
||||
})
|
||||
} catch (error) {
|
||||
}
|
||||
}
|
||||
if (archivedTasks.length > 0) {
|
||||
showSuccess({
|
||||
title: '📦 Tasks Archived',
|
||||
message: `Successfully archived ${archivedTasks.length} task${archivedTasks.length > 1 ? 's' : ''}.`,
|
||||
})
|
||||
}
|
||||
if (failedTasks.length > 0) {
|
||||
showError({
|
||||
title: 'Some Tasks Failed',
|
||||
message: `${failedTasks.length} task${failedTasks.length > 1 ? 's' : ''} could not be archived.`,
|
||||
})
|
||||
}
|
||||
refetchChores()
|
||||
clearSelection()
|
||||
} catch (error) {
|
||||
showError({
|
||||
title: 'Bulk Archive Failed',
|
||||
message: 'An unexpected error occurred. Please try again.',
|
||||
})
|
||||
}
|
||||
}
|
||||
setConfirmModelConfig({})
|
||||
},
|
||||
})
|
||||
}, [getSelectedChoresData, archiveChore, setChores, setFilteredChores, showSuccess, showError, refetchChores, clearSelection, setConfirmModelConfig])
|
||||
|
||||
const handleBulkDelete = useCallback(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))
|
||||
const newChores = chores.filter(c => !deletedIds.has(c.id))
|
||||
const newFilteredChores = filteredChores.filter(
|
||||
c => !deletedIds.has(c.id),
|
||||
)
|
||||
setChores(newChores)
|
||||
setFilteredChores(newFilteredChores)
|
||||
}
|
||||
|
||||
if (failedTasks.length > 0) {
|
||||
showError({
|
||||
title: 'Some Tasks Failed',
|
||||
message: `${failedTasks.length} task${failedTasks.length > 1 ? 's' : ''} could not be deleted.`,
|
||||
})
|
||||
}
|
||||
refetchChores()
|
||||
clearSelection()
|
||||
} catch (error) {
|
||||
showError({
|
||||
title: 'Bulk Delete Failed',
|
||||
message: 'An unexpected error occurred. Please try again.',
|
||||
})
|
||||
}
|
||||
}
|
||||
setConfirmModelConfig({})
|
||||
},
|
||||
})
|
||||
}, [getSelectedChoresData, chores, filteredChores, setChores, setFilteredChores, showSuccess, showError, refetchChores, clearSelection, setConfirmModelConfig])
|
||||
|
||||
const handleBulkSkip = useCallback(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 > 1 ? 's' : ''} could not be skipped.`,
|
||||
})
|
||||
}
|
||||
|
||||
refetchChores()
|
||||
clearSelection()
|
||||
} catch (error) {
|
||||
showError({
|
||||
title: 'Bulk Skip Failed',
|
||||
message: 'An unexpected error occurred. Please try again.',
|
||||
})
|
||||
}
|
||||
}
|
||||
setConfirmModelConfig({})
|
||||
},
|
||||
})
|
||||
}, [getSelectedChoresData, showSuccess, showError, refetchChores, clearSelection, setConfirmModelConfig])
|
||||
|
||||
return {
|
||||
handleChoreAction,
|
||||
handleChangeDueDate,
|
||||
handleCompleteWithPastDate,
|
||||
handleAssigneeChange,
|
||||
handleCompleteWithNote,
|
||||
handleNudge,
|
||||
handleBulkComplete,
|
||||
handleBulkArchive,
|
||||
handleBulkDelete,
|
||||
handleBulkSkip,
|
||||
}
|
||||
}
|
||||
87
src/views/Chores/hooks/useChoreFilters.js
Normal file
87
src/views/Chores/hooks/useChoreFilters.js
Normal file
@@ -0,0 +1,87 @@
|
||||
import { useState, useMemo, useCallback } from 'react'
|
||||
import Fuse from 'fuse.js'
|
||||
import { filterByProject, ChoreFilters } from '../../../utils/Chores'
|
||||
|
||||
export const useChoreFilters = ({
|
||||
chores,
|
||||
selectedProject,
|
||||
impersonatedUser,
|
||||
userProfile,
|
||||
}) => {
|
||||
const [searchTerm, setSearchTerm] = useState('')
|
||||
const [searchFilter, setSearchFilter] = useState('All')
|
||||
const [selectedChoreFilter, setSelectedChoreFilter] = useState(
|
||||
localStorage.getItem('selectedChoreFilter') || 'anyone',
|
||||
)
|
||||
|
||||
const projectFilteredChores = useMemo(() => {
|
||||
if (!selectedProject) return chores
|
||||
|
||||
if (selectedProject.id === 'default') {
|
||||
return chores.filter(chore => !chore.projectId)
|
||||
}
|
||||
|
||||
return filterByProject(chores, selectedProject.id)
|
||||
}, [chores, selectedProject])
|
||||
|
||||
const searchFilteredChores = useMemo(() => {
|
||||
let baseChores = projectFilteredChores
|
||||
|
||||
if (searchTerm?.length > 0) {
|
||||
const searchableChores = baseChores.map(c => ({
|
||||
...c,
|
||||
raw_label: c.labelsV2?.map(l => l.name).join(' '),
|
||||
}))
|
||||
|
||||
const fuse = new Fuse(searchableChores, {
|
||||
keys: ['name', 'raw_label'],
|
||||
includeScore: true,
|
||||
isCaseSensitive: false,
|
||||
findAllMatches: true,
|
||||
})
|
||||
|
||||
return fuse.search(searchTerm.toLowerCase()).map(result => result.item)
|
||||
}
|
||||
|
||||
if (impersonatedUser) {
|
||||
baseChores = baseChores.filter(
|
||||
chore => chore.assignedTo === impersonatedUser.userId,
|
||||
)
|
||||
}
|
||||
|
||||
return baseChores.filter(
|
||||
ChoreFilters(impersonatedUser?.userId || userProfile?.id)[
|
||||
selectedChoreFilter
|
||||
],
|
||||
)
|
||||
}, [
|
||||
searchTerm,
|
||||
projectFilteredChores,
|
||||
impersonatedUser,
|
||||
userProfile?.id,
|
||||
selectedChoreFilter,
|
||||
])
|
||||
|
||||
const setSelectedChoreFilterWithCache = useCallback(value => {
|
||||
setSelectedChoreFilter(value)
|
||||
localStorage.setItem('selectedChoreFilter', value)
|
||||
}, [])
|
||||
|
||||
const clearFilters = useCallback(() => {
|
||||
setSearchFilter('All')
|
||||
setSearchTerm('')
|
||||
}, [])
|
||||
|
||||
return {
|
||||
searchTerm,
|
||||
searchFilter,
|
||||
selectedChoreFilter,
|
||||
projectFilteredChores,
|
||||
searchFilteredChores,
|
||||
setSearchTerm,
|
||||
setSearchFilter,
|
||||
setSelectedChoreFilter,
|
||||
setSelectedChoreFilterWithCache,
|
||||
clearFilters,
|
||||
}
|
||||
}
|
||||
27
src/views/Chores/hooks/useChoreModals.js
Normal file
27
src/views/Chores/hooks/useChoreModals.js
Normal file
@@ -0,0 +1,27 @@
|
||||
import { useState, useCallback } from 'react'
|
||||
|
||||
export const useChoreModals = () => {
|
||||
const [activeModal, setActiveModal] = useState(null)
|
||||
const [modalData, setModalData] = useState({})
|
||||
const [modalChore, setModalChore] = useState(null)
|
||||
|
||||
const openModal = useCallback((modal, chore, data = {}) => {
|
||||
setActiveModal(modal)
|
||||
setModalChore(chore)
|
||||
setModalData(data)
|
||||
}, [])
|
||||
|
||||
const closeModal = useCallback(() => {
|
||||
setActiveModal(null)
|
||||
setModalChore(null)
|
||||
setModalData({})
|
||||
}, [])
|
||||
|
||||
return {
|
||||
activeModal,
|
||||
modalChore,
|
||||
modalData,
|
||||
openModal,
|
||||
closeModal,
|
||||
}
|
||||
}
|
||||
195
src/views/Chores/hooks/useKeyboardShortcuts.js
Normal file
195
src/views/Chores/hooks/useKeyboardShortcuts.js
Normal file
@@ -0,0 +1,195 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
|
||||
export const useKeyboardShortcuts = ({
|
||||
isMultiSelectMode,
|
||||
selectedChores,
|
||||
addTaskModalOpen,
|
||||
searchTerm,
|
||||
searchFilter,
|
||||
filteredChores,
|
||||
choreSections,
|
||||
openChoreSections,
|
||||
handlers,
|
||||
}) => {
|
||||
const [showKeyboardShortcuts, setShowKeyboardShortcuts] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = event => {
|
||||
if (addTaskModalOpen) return
|
||||
|
||||
if (event.ctrlKey || event.metaKey) {
|
||||
setShowKeyboardShortcuts(true)
|
||||
}
|
||||
|
||||
const isHoldingCmdOrCtrl = event.ctrlKey || event.metaKey
|
||||
|
||||
if (isHoldingCmdOrCtrl && event.key === 'k') {
|
||||
event.preventDefault()
|
||||
handlers.onOpenTaskModal()
|
||||
return
|
||||
}
|
||||
|
||||
if (addTaskModalOpen) return
|
||||
|
||||
if (isHoldingCmdOrCtrl && event.key === 'j') {
|
||||
event.preventDefault()
|
||||
handlers.onNavigateToCreate()
|
||||
return
|
||||
} else if (isHoldingCmdOrCtrl && event.key === 'f') {
|
||||
event.preventDefault()
|
||||
handlers.onFocusSearch()
|
||||
return
|
||||
} else if (isHoldingCmdOrCtrl && event.key === 'x') {
|
||||
event.preventDefault()
|
||||
if (searchTerm?.length > 0) {
|
||||
handlers.onCloseSearch()
|
||||
}
|
||||
} else if (isHoldingCmdOrCtrl && event.key === 's') {
|
||||
event.preventDefault()
|
||||
handlers.onToggleMultiSelect()
|
||||
return
|
||||
} else if (
|
||||
isHoldingCmdOrCtrl &&
|
||||
!event.shiftKey &&
|
||||
event.key === 'a' &&
|
||||
!['INPUT', 'TEXTAREA'].includes(document.activeElement.tagName)
|
||||
) {
|
||||
event.preventDefault()
|
||||
if (!isMultiSelectMode) {
|
||||
handlers.onEnableMultiSelectAndSelectAll()
|
||||
} else {
|
||||
let visibleChores = []
|
||||
|
||||
if (searchTerm?.length > 0 || searchFilter !== 'All') {
|
||||
visibleChores = filteredChores
|
||||
const allVisibleSelected =
|
||||
visibleChores.length > 0 &&
|
||||
visibleChores.every(chore => selectedChores.has(chore.id))
|
||||
|
||||
if (allVisibleSelected) {
|
||||
handlers.onShowMessage({
|
||||
title: '✅ All Tasks Selected',
|
||||
message: `All ${visibleChores.length} filtered task${visibleChores.length !== 1 ? 's are' : ' is'} already selected.`,
|
||||
})
|
||||
} else {
|
||||
handlers.onSelectAll()
|
||||
handlers.onShowMessage({
|
||||
title: '🎯 Tasks Selected',
|
||||
message: `Selected ${visibleChores.length} filtered task${visibleChores.length !== 1 ? 's' : ''}.`,
|
||||
})
|
||||
}
|
||||
} else {
|
||||
const expandedChores = choreSections
|
||||
.filter((_section, index) => openChoreSections[index])
|
||||
.flatMap(section => section.content || [])
|
||||
|
||||
const allExpandedSelected =
|
||||
expandedChores.length > 0 &&
|
||||
expandedChores.every(chore => selectedChores.has(chore.id))
|
||||
|
||||
const allChores = choreSections.flatMap(
|
||||
section => section.content || [],
|
||||
)
|
||||
const allChoresSelected =
|
||||
allChores.length > 0 &&
|
||||
allChores.every(chore => selectedChores.has(chore.id))
|
||||
|
||||
if (allChoresSelected) {
|
||||
handlers.onShowMessage({
|
||||
title: '✅ All Tasks Selected',
|
||||
message: `All ${allChores.length} task${allChores.length !== 1 ? 's are' : ' is'} already selected (including collapsed sections).`,
|
||||
})
|
||||
} else if (allExpandedSelected) {
|
||||
handlers.onSelectAll()
|
||||
const collapsedCount = allChores.length - expandedChores.length
|
||||
handlers.onShowMessage({
|
||||
title: '🎯 All Tasks Selected',
|
||||
message: `Selected all ${allChores.length} tasks (including ${collapsedCount} from collapsed sections).`,
|
||||
})
|
||||
} else {
|
||||
handlers.onSelectAll()
|
||||
handlers.onShowMessage({
|
||||
title: '🎯 Tasks Selected',
|
||||
message: `Selected ${expandedChores.length} task${expandedChores.length !== 1 ? 's' : ''} from expanded sections.`,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (isMultiSelectMode) {
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault()
|
||||
handlers.onClearSelection()
|
||||
return
|
||||
}
|
||||
|
||||
if (
|
||||
isHoldingCmdOrCtrl &&
|
||||
event.key === 'Enter' &&
|
||||
selectedChores.size > 0
|
||||
) {
|
||||
event.preventDefault()
|
||||
handlers.onBulkComplete()
|
||||
return
|
||||
}
|
||||
|
||||
if (
|
||||
isHoldingCmdOrCtrl &&
|
||||
event.key === '/' &&
|
||||
selectedChores.size > 0
|
||||
) {
|
||||
event.preventDefault()
|
||||
handlers.onBulkSkip()
|
||||
return
|
||||
}
|
||||
|
||||
if (
|
||||
isHoldingCmdOrCtrl &&
|
||||
(event.key === 'x' || event.key === 'X') &&
|
||||
selectedChores.size > 0 &&
|
||||
!['INPUT', 'TEXTAREA'].includes(document.activeElement.tagName)
|
||||
) {
|
||||
event.preventDefault()
|
||||
handlers.onBulkArchive()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
isHoldingCmdOrCtrl &&
|
||||
(event.key === 'e' || event.key === 'E') &&
|
||||
selectedChores.size > 0 &&
|
||||
!['INPUT', 'TEXTAREA'].includes(document.activeElement.tagName)
|
||||
) {
|
||||
event.preventDefault()
|
||||
if (isMultiSelectMode && selectedChores.size > 0) {
|
||||
handlers.onBulkDelete()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (isHoldingCmdOrCtrl && event.key === 'o') {
|
||||
event.preventDefault()
|
||||
handlers.onNavigateToArchived()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const handleKeyUp = event => {
|
||||
if (!event.ctrlKey && !event.metaKey) {
|
||||
setShowKeyboardShortcuts(false)
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown)
|
||||
document.addEventListener('keyup', handleKeyUp)
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeyDown)
|
||||
document.removeEventListener('keyup', handleKeyUp)
|
||||
}
|
||||
}, [isMultiSelectMode, selectedChores.size, addTaskModalOpen])
|
||||
|
||||
return { showKeyboardShortcuts }
|
||||
}
|
||||
89
src/views/Chores/hooks/useMultiSelect.js
Normal file
89
src/views/Chores/hooks/useMultiSelect.js
Normal file
@@ -0,0 +1,89 @@
|
||||
import { useState, useCallback } from 'react'
|
||||
|
||||
export const useMultiSelect = () => {
|
||||
const [isMultiSelectMode, setIsMultiSelectMode] = useState(false)
|
||||
const [selectedChores, setSelectedChores] = useState(new Set())
|
||||
|
||||
const toggleMultiSelectMode = useCallback(() => {
|
||||
const newMode = !isMultiSelectMode
|
||||
setIsMultiSelectMode(newMode)
|
||||
|
||||
if (newMode) {
|
||||
setSelectedChores(new Set())
|
||||
}
|
||||
}, [isMultiSelectMode])
|
||||
|
||||
const toggleChoreSelection = useCallback(
|
||||
choreId => {
|
||||
const newSelection = new Set(selectedChores)
|
||||
if (newSelection.has(choreId)) {
|
||||
newSelection.delete(choreId)
|
||||
} else {
|
||||
newSelection.add(choreId)
|
||||
}
|
||||
setSelectedChores(newSelection)
|
||||
},
|
||||
[selectedChores],
|
||||
)
|
||||
|
||||
const selectAllVisibleChores = useCallback(
|
||||
(visibleChores, choreSections = [], openChoreSections = {}) => {
|
||||
let choresToSelect = []
|
||||
|
||||
if (visibleChores && visibleChores.length > 0) {
|
||||
choresToSelect = visibleChores
|
||||
} else {
|
||||
const expandedChores = choreSections
|
||||
.filter((_section, index) => openChoreSections[index])
|
||||
.flatMap(section => section.content || [])
|
||||
|
||||
const allExpandedSelected =
|
||||
expandedChores.length > 0 &&
|
||||
expandedChores.every(chore => selectedChores.has(chore.id))
|
||||
|
||||
if (allExpandedSelected) {
|
||||
choresToSelect = choreSections.flatMap(section => section.content || [])
|
||||
} else {
|
||||
choresToSelect = expandedChores
|
||||
}
|
||||
}
|
||||
|
||||
if (choresToSelect.length > 0) {
|
||||
const allIds = new Set(choresToSelect.map(chore => chore.id))
|
||||
setSelectedChores(allIds)
|
||||
}
|
||||
|
||||
return choresToSelect.length
|
||||
},
|
||||
[selectedChores],
|
||||
)
|
||||
|
||||
const clearSelection = useCallback(() => {
|
||||
if (selectedChores.size === 0) {
|
||||
setIsMultiSelectMode(false)
|
||||
return
|
||||
}
|
||||
setSelectedChores(new Set())
|
||||
}, [selectedChores.size])
|
||||
|
||||
const getSelectedChoresData = useCallback(
|
||||
allChores => {
|
||||
return Array.from(selectedChores)
|
||||
.map(id => allChores.find(chore => chore.id === id))
|
||||
.filter(Boolean)
|
||||
},
|
||||
[selectedChores],
|
||||
)
|
||||
|
||||
return {
|
||||
isMultiSelectMode,
|
||||
selectedChores,
|
||||
toggleMultiSelectMode,
|
||||
toggleChoreSelection,
|
||||
selectAllVisibleChores,
|
||||
clearSelection,
|
||||
getSelectedChoresData,
|
||||
setIsMultiSelectMode,
|
||||
setSelectedChores,
|
||||
}
|
||||
}
|
||||
45
src/views/Chores/hooks/useProjectFilter.js
Normal file
45
src/views/Chores/hooks/useProjectFilter.js
Normal file
@@ -0,0 +1,45 @@
|
||||
import { useState, useMemo, useCallback } from 'react'
|
||||
|
||||
export const useProjectFilter = projects => {
|
||||
const [selectedProject, setSelectedProject] = useState(() => {
|
||||
const saved = localStorage.getItem('selectedProject')
|
||||
return saved ? JSON.parse(saved) : null
|
||||
})
|
||||
|
||||
const projectsWithDefault = useMemo(() => {
|
||||
const defaultProject = {
|
||||
id: 'default',
|
||||
name: 'Default Project',
|
||||
description: 'Your default project workspace',
|
||||
color: '#1976d2',
|
||||
icon: 'FolderOpen',
|
||||
}
|
||||
|
||||
const hasDefault = projects.some(
|
||||
p => p.id === 'default' || p.name === 'Default Project',
|
||||
)
|
||||
|
||||
return hasDefault ? projects : [defaultProject, ...projects]
|
||||
}, [projects])
|
||||
|
||||
const setSelectedProjectWithCache = useCallback(project => {
|
||||
const finalProject = project || null
|
||||
|
||||
setSelectedProject(finalProject)
|
||||
localStorage.setItem('selectedProject', JSON.stringify(finalProject))
|
||||
|
||||
const newUrl = new URL(window.location)
|
||||
if (finalProject && finalProject.id !== 'default') {
|
||||
newUrl.searchParams.set('project', encodeURIComponent(finalProject.id))
|
||||
} else {
|
||||
newUrl.searchParams.delete('project')
|
||||
}
|
||||
window.history.replaceState({}, '', newUrl)
|
||||
}, [])
|
||||
|
||||
return {
|
||||
selectedProject,
|
||||
projectsWithDefault,
|
||||
setSelectedProjectWithCache,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user