Refactor chore components and enhance scheduling features
- Removed redundant priority color function from CompactChoreCard and imported from Colors utility. - Replaced FadeModal with ResponsiveModal in MultiSelectHelp for improved modal handling. - Simplified MyChores component by removing archived chores state and related logic, and added keyboard shortcut for navigating to archived tasks. - Integrated ResponsiveModal in RedeemPointsModal for consistent modal usage. - Added quick scheduling options in ChoreActionMenu for better task management, including scheduling for today, tomorrow, weekend, and next week. - Enhanced CustomParsers to support nth occurrence of days in monthly scheduling. - Updated NavBar links and navigation logic for improved user experience.
This commit is contained in:
@@ -21,6 +21,7 @@ export const Z_INDEX = {
|
||||
// Modals and Overlays (2000-8999)
|
||||
MODAL_BACKDROP: 2000,
|
||||
MODAL_CONTENT: 2001,
|
||||
MODAL_CLOSE_BUTTON: 2002,
|
||||
TOAST: 3000,
|
||||
|
||||
// Critical System UI (9000-9999)
|
||||
|
||||
@@ -107,3 +107,18 @@ export const getTextColorFromBackgroundColor = bgColor => {
|
||||
const b = parseInt(hex.substring(4, 6), 16)
|
||||
return r * 0.299 + g * 0.587 + b * 0.114 > 186 ? '#000000' : '#ffffff'
|
||||
}
|
||||
|
||||
export const getPriorityColor = priority => {
|
||||
switch (priority) {
|
||||
case 1:
|
||||
return TASK_COLOR.PRIORITY_1
|
||||
case 2:
|
||||
return TASK_COLOR.PRIORITY_2
|
||||
case 3:
|
||||
return TASK_COLOR.PRIORITY_3
|
||||
case 4:
|
||||
return TASK_COLOR.PRIORITY_4
|
||||
default:
|
||||
return TASK_COLOR.NO_PRIORITY
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,8 @@ import {
|
||||
Typography,
|
||||
} from '@mui/joy'
|
||||
import { useState } from 'react'
|
||||
import FadeModal from '../../components/common/FadeModal'
|
||||
|
||||
import { useResponsiveModal } from '../../hooks/useResponsiveModal'
|
||||
import { VerifyMFA } from '../../utils/Fetcher'
|
||||
|
||||
const MFAVerificationModal = ({
|
||||
@@ -24,7 +25,7 @@ const MFAVerificationModal = ({
|
||||
const [isBackupCode, setIsBackupCode] = useState(false)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const { ResponsiveModal } = useResponsiveModal()
|
||||
const handleVerify = async () => {
|
||||
if (!verificationCode.trim()) {
|
||||
setError('Please enter a verification code')
|
||||
@@ -69,7 +70,7 @@ const MFAVerificationModal = ({
|
||||
}
|
||||
|
||||
return (
|
||||
<FadeModal open={open} onClose={handleClose} size='sm'>
|
||||
<ResponsiveModal open={open} onClose={handleClose} size='sm'>
|
||||
<ModalClose />
|
||||
|
||||
<Box className='mb-4 text-center'>
|
||||
@@ -150,7 +151,7 @@ const MFAVerificationModal = ({
|
||||
</Typography>
|
||||
</Alert>
|
||||
</Stack>
|
||||
</FadeModal>
|
||||
</ResponsiveModal>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,4 @@
|
||||
import {
|
||||
Add,
|
||||
ChevronRight,
|
||||
ExpandMore,
|
||||
HorizontalRule,
|
||||
} from '@mui/icons-material'
|
||||
import { Add, HorizontalRule } from '@mui/icons-material'
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
@@ -107,12 +102,7 @@ const ChoreEdit = () => {
|
||||
const [errors, setErrors] = useState({})
|
||||
const [attemptToSave, setAttemptToSave] = useState(false)
|
||||
const [addLabelModalOpen, setAddLabelModalOpen] = useState(false)
|
||||
const [expandedSections, setExpandedSections] = useState({
|
||||
basicInfo: true,
|
||||
assignment: true,
|
||||
schedule: true,
|
||||
taskSettings: true,
|
||||
})
|
||||
|
||||
const { data: userLabelsRaw, isLoading: isUserLabelsLoading } = useLabels()
|
||||
const updateChoreMutation = useUpdateChore()
|
||||
const createChoreMutation = useCreateChore()
|
||||
@@ -135,78 +125,6 @@ const ChoreEdit = () => {
|
||||
|
||||
const Navigate = useNavigate()
|
||||
|
||||
const toggleSection = sectionKey => {
|
||||
setExpandedSections(prev => ({
|
||||
...prev,
|
||||
[sectionKey]: !prev[sectionKey],
|
||||
}))
|
||||
}
|
||||
|
||||
const CollapsibleSection = ({ sectionKey, title, subtitle, children }) => {
|
||||
const isExpanded = expandedSections[sectionKey]
|
||||
|
||||
return (
|
||||
<Box mb={4}>
|
||||
<Box
|
||||
onClick={() => toggleSection(sectionKey)}
|
||||
sx={{
|
||||
mx: 0,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
cursor: 'pointer',
|
||||
width: '100%',
|
||||
py: 2,
|
||||
borderRadius: 'md',
|
||||
backgroundColor: 'background.level1',
|
||||
border: '1px solid',
|
||||
borderColor: 'divider',
|
||||
mb: isExpanded ? 2 : 0,
|
||||
transition: 'all 0.2s ease-in-out',
|
||||
'&:hover': {
|
||||
backgroundColor: 'background.level2',
|
||||
borderColor: 'primary.main',
|
||||
},
|
||||
}}
|
||||
>
|
||||
{isExpanded ? (
|
||||
<ExpandMore sx={{ mr: 1, color: 'primary.main' }} />
|
||||
) : (
|
||||
<ChevronRight sx={{ mr: 1, color: 'text.secondary' }} />
|
||||
)}
|
||||
<Box sx={{ flexGrow: 1 }}>
|
||||
<Typography
|
||||
level='h3'
|
||||
sx={{
|
||||
color: isExpanded ? 'primary.main' : 'text.primary',
|
||||
fontWeight: 'bold',
|
||||
mb: subtitle ? 0.5 : 0,
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</Typography>
|
||||
{subtitle && (
|
||||
<Typography level='body-sm' sx={{ color: 'text.secondary' }}>
|
||||
{subtitle}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{isExpanded && (
|
||||
<Box
|
||||
sx={{
|
||||
// pl: 2,
|
||||
// pr: 2,
|
||||
pb: 2,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
const HandleValidateChore = () => {
|
||||
const errors = {}
|
||||
|
||||
@@ -493,15 +411,21 @@ const ChoreEdit = () => {
|
||||
return (
|
||||
<Container maxWidth='md'>
|
||||
{/* Section 1: Basic Information */}
|
||||
<CollapsibleSection
|
||||
sectionKey='basicInfo'
|
||||
title='Basic Information'
|
||||
subtitle='Essential details about your task'
|
||||
<Box mb={4}>
|
||||
{/* <Typography
|
||||
level='h4'
|
||||
mb={2}
|
||||
sx={{ borderBottom: '2px solid', borderColor: 'primary.main', pb: 1 }}
|
||||
>
|
||||
Basic Information
|
||||
</Typography> */}
|
||||
|
||||
<Box mb={3}>
|
||||
<FormControl error={errors.name}>
|
||||
<Typography level='h4'>Name</Typography>
|
||||
<Typography level='h5'>What is the name of this chore?</Typography>
|
||||
<Typography level='body-md'>
|
||||
What is the name of this task?
|
||||
</Typography>
|
||||
<Input value={name} onChange={e => setName(e.target.value)} />
|
||||
<FormHelperText error>{errors.name}</FormHelperText>
|
||||
</FormControl>
|
||||
@@ -510,7 +434,7 @@ const ChoreEdit = () => {
|
||||
<Box mb={3}>
|
||||
<FormControl error={errors.description}>
|
||||
<Typography level='h4'>Description</Typography>
|
||||
<Typography level='h5'>What is this task about?</Typography>
|
||||
<Typography level='body-md'>What is this task about?</Typography>
|
||||
<RichTextEditor
|
||||
value={description}
|
||||
onChange={setDescription}
|
||||
@@ -523,7 +447,7 @@ const ChoreEdit = () => {
|
||||
|
||||
<Box mb={3}>
|
||||
<Typography level='h4'>Priority</Typography>
|
||||
<Typography level='h5'>How important is this task?</Typography>
|
||||
<Typography level='body-md'>How important is this task?</Typography>
|
||||
|
||||
{/* Priority Chip Selection */}
|
||||
<Box
|
||||
@@ -576,7 +500,7 @@ const ChoreEdit = () => {
|
||||
|
||||
<Box mb={3}>
|
||||
<Typography level='h4'>Labels</Typography>
|
||||
<Typography level='h5'>
|
||||
<Typography level='body-md'>
|
||||
Things to remember about this task or to tag it
|
||||
</Typography>
|
||||
<Select
|
||||
@@ -675,17 +599,13 @@ const ChoreEdit = () => {
|
||||
/>
|
||||
</Card>
|
||||
</Box>
|
||||
</CollapsibleSection>
|
||||
</Box>
|
||||
|
||||
{/* Section 2: Assignment & Responsibility */}
|
||||
<CollapsibleSection
|
||||
sectionKey='assignment'
|
||||
title='Assignment & Responsibility'
|
||||
subtitle='Who will be responsible for this task'
|
||||
>
|
||||
<Box mb={4}>
|
||||
<Box mb={3}>
|
||||
<Typography level='h4'>Assignees</Typography>
|
||||
<Typography level='h5'>Who can do this task?</Typography>
|
||||
<Typography level='body-md'>Who can do this task?</Typography>
|
||||
<Card>
|
||||
<List
|
||||
orientation='horizontal'
|
||||
@@ -729,7 +649,9 @@ const ChoreEdit = () => {
|
||||
<>
|
||||
<Box mb={3}>
|
||||
<Typography level='h4'>Currently Assigned To</Typography>
|
||||
<Typography level='h5'>Who is assigned the next due?</Typography>
|
||||
<Typography level='body-md'>
|
||||
Who is assigned the next due?
|
||||
</Typography>
|
||||
<Select
|
||||
placeholder={
|
||||
assignees.length === 0
|
||||
@@ -757,7 +679,7 @@ const ChoreEdit = () => {
|
||||
|
||||
<Box>
|
||||
<Typography level='h4'>Assignment Strategy</Typography>
|
||||
<Typography level='h5'>
|
||||
<Typography level='body-md'>
|
||||
How to pick the next assignee for the following task?
|
||||
</Typography>
|
||||
<Card>
|
||||
@@ -789,14 +711,10 @@ const ChoreEdit = () => {
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
</CollapsibleSection>
|
||||
</Box>
|
||||
|
||||
{/* Section 3: Schedule & Timing */}
|
||||
<CollapsibleSection
|
||||
sectionKey='schedule'
|
||||
title='Schedule & Timing'
|
||||
subtitle='When and how often this task should be done'
|
||||
>
|
||||
<Box mb={4}>
|
||||
<RepeatSection
|
||||
frequency={frequency}
|
||||
onFrequencyUpdate={setFrequency}
|
||||
@@ -931,7 +849,7 @@ const ChoreEdit = () => {
|
||||
{!['once', 'no_repeat'].includes(frequencyType) && (
|
||||
<Box>
|
||||
<Typography level='h4'>Scheduling Preferences</Typography>
|
||||
<Typography level='h5'>
|
||||
<Typography level='body-md'>
|
||||
How to reschedule the next due date?
|
||||
</Typography>
|
||||
<RadioGroup name='tiers' sx={{ gap: 1, '& > div': { p: 1 } }}>
|
||||
@@ -1084,14 +1002,21 @@ const ChoreEdit = () => {
|
||||
</Card>
|
||||
</Box>
|
||||
)}
|
||||
</CollapsibleSection>
|
||||
</Box>
|
||||
|
||||
{/* Section 4: Task Settings */}
|
||||
<CollapsibleSection
|
||||
sectionKey='taskSettings'
|
||||
title='Task Settings'
|
||||
subtitle='Additional options and configurations'
|
||||
<Box mb={4}>
|
||||
<Typography
|
||||
level='h3'
|
||||
mb={2}
|
||||
sx={{
|
||||
borderColor: 'primary.main',
|
||||
pb: 1,
|
||||
}}
|
||||
>
|
||||
Task Settings:
|
||||
</Typography>
|
||||
|
||||
<Box mb={3}>
|
||||
<Typography level='h4'>Points System</Typography>
|
||||
<FormControl sx={{ mt: 1 }}>
|
||||
@@ -1160,16 +1085,16 @@ const ChoreEdit = () => {
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<Typography level='h4'>Visibility</Typography>
|
||||
<Typography level='h5' sx={{ mb: 2 }}>
|
||||
Choose who can see this task
|
||||
</Typography>
|
||||
<Typography level='h4'>Privacy Settings</Typography>
|
||||
<Typography level='body-md'>Who can see this task?</Typography>
|
||||
<RadioGroup
|
||||
name='isPrivate'
|
||||
value={isPrivate}
|
||||
onChange={event => setIsPrivate(event.target.value)}
|
||||
onChange={event => {
|
||||
setIsPrivate(event.target.value === 'true' ? true : false)
|
||||
}}
|
||||
sx={{
|
||||
'& > div': { p: 1 },
|
||||
'& > div': { py: 1 },
|
||||
}}
|
||||
>
|
||||
<FormControl>
|
||||
@@ -1177,14 +1102,14 @@ const ChoreEdit = () => {
|
||||
<FormHelperText>Everyone in your circle</FormHelperText>
|
||||
</FormControl>
|
||||
<FormControl>
|
||||
<Radio overlay value={true} label='Private' />
|
||||
<Radio overlay value={true} label='Limited' />
|
||||
<FormHelperText>
|
||||
Only you and others that are assigned to the task
|
||||
You and others that are assigned to the task
|
||||
</FormHelperText>
|
||||
</FormControl>
|
||||
</RadioGroup>
|
||||
</Box>
|
||||
</CollapsibleSection>
|
||||
</Box>
|
||||
|
||||
{choreId > 0 && (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5, mt: 3 }}>
|
||||
|
||||
843
src/views/Chores/ArchivedTasks.jsx
Normal file
843
src/views/Chores/ArchivedTasks.jsx
Normal file
@@ -0,0 +1,843 @@
|
||||
import {
|
||||
Archive,
|
||||
CheckBox,
|
||||
CheckBoxOutlineBlank,
|
||||
Close,
|
||||
Delete,
|
||||
SelectAll,
|
||||
Unarchive,
|
||||
ViewAgenda,
|
||||
ViewModule,
|
||||
} from '@mui/icons-material'
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Container,
|
||||
Divider,
|
||||
IconButton,
|
||||
Input,
|
||||
List,
|
||||
Typography,
|
||||
} from '@mui/joy'
|
||||
import Fuse from 'fuse.js'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import KeyboardShortcutHint from '../../components/common/KeyboardShortcutHint'
|
||||
import { useImpersonateUser } from '../../contexts/ImpersonateUserContext.jsx'
|
||||
import { useCircleMembers, useUserProfile } from '../../queries/UserQueries'
|
||||
import { useNotification } from '../../service/NotificationProvider'
|
||||
import { ChoreSorter } from '../../utils/Chores'
|
||||
import {
|
||||
DeleteChore,
|
||||
GetArchivedChores,
|
||||
UnArchiveChore,
|
||||
} from '../../utils/Fetcher'
|
||||
import LoadingComponent from '../components/Loading'
|
||||
import ConfirmationModal from '../Modals/Inputs/ConfirmationModal'
|
||||
import ChoreCard from './ChoreCard'
|
||||
import CompactChoreCard from './CompactChoreCard'
|
||||
import MultiSelectHelp from './MultiSelectHelp'
|
||||
|
||||
const ArchivedTasks = () => {
|
||||
const { data: userProfile, isLoading: isUserProfileLoading } =
|
||||
useUserProfile()
|
||||
const { showSuccess, showError } = useNotification()
|
||||
const { impersonatedUser } = useImpersonateUser()
|
||||
const [archivedChores, setArchivedChores] = useState([])
|
||||
const [filteredChores, setFilteredChores] = useState([])
|
||||
const [searchTerm, setSearchTerm] = useState('')
|
||||
const [performers, setPerformers] = useState([])
|
||||
const navigate = useNavigate()
|
||||
const [viewMode, setViewMode] = useState(
|
||||
localStorage.getItem('archivedChoreCardViewMode') || 'default',
|
||||
)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [showKeyboardShortcuts, setShowKeyboardShortcuts] = useState(false)
|
||||
const searchInputRef = useRef(null)
|
||||
|
||||
// Multi-select state
|
||||
const [isMultiSelectMode, setIsMultiSelectMode] = useState(false)
|
||||
const [selectedChores, setSelectedChores] = useState(new Set())
|
||||
const [confirmModelConfig, setConfirmModelConfig] = useState({})
|
||||
|
||||
const { data: membersData, isLoading: membersLoading } = useCircleMembers()
|
||||
|
||||
useEffect(() => {
|
||||
const loadArchivedChores = async () => {
|
||||
if (!membersLoading && userProfile) {
|
||||
setPerformers(membersData.res)
|
||||
try {
|
||||
const response = await GetArchivedChores()
|
||||
const data = await response.json()
|
||||
const sortedChores = data.res.sort(ChoreSorter)
|
||||
setArchivedChores(sortedChores)
|
||||
setFilteredChores(sortedChores)
|
||||
} catch (error) {
|
||||
showError({
|
||||
title: 'Failed to load archived tasks',
|
||||
message: 'Please try again later.',
|
||||
})
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
loadArchivedChores()
|
||||
}, [membersLoading, userProfile, membersData])
|
||||
|
||||
// Keyboard shortcuts
|
||||
useEffect(() => {
|
||||
const handleKeyDown = event => {
|
||||
const isHoldingCmdOrCtrl = event.ctrlKey || event.metaKey
|
||||
|
||||
if (isHoldingCmdOrCtrl) {
|
||||
setShowKeyboardShortcuts(true)
|
||||
}
|
||||
|
||||
// Ctrl/Cmd + F to focus search input
|
||||
if (isHoldingCmdOrCtrl && event.key === 'f') {
|
||||
event.preventDefault()
|
||||
searchInputRef.current?.focus()
|
||||
return
|
||||
}
|
||||
|
||||
// Ctrl/Cmd + S Toggle Multi-select mode
|
||||
if (isHoldingCmdOrCtrl && event.key === 's') {
|
||||
event.preventDefault()
|
||||
toggleMultiSelectMode()
|
||||
return
|
||||
}
|
||||
|
||||
// Ctrl/Cmd + A to select all
|
||||
if (
|
||||
isHoldingCmdOrCtrl &&
|
||||
event.key === 'a' &&
|
||||
!['INPUT', 'TEXTAREA'].includes(document.activeElement.tagName)
|
||||
) {
|
||||
event.preventDefault()
|
||||
if (!isMultiSelectMode) {
|
||||
setIsMultiSelectMode(true)
|
||||
setTimeout(() => {
|
||||
selectAllVisibleChores()
|
||||
}, 0)
|
||||
} else {
|
||||
selectAllVisibleChores()
|
||||
}
|
||||
}
|
||||
|
||||
// Multi-select keyboard shortcuts
|
||||
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
|
||||
}
|
||||
|
||||
// "r" key for bulk restore (unarchive)
|
||||
if (
|
||||
isHoldingCmdOrCtrl &&
|
||||
event.key === 'r' &&
|
||||
selectedChores.size > 0
|
||||
) {
|
||||
event.preventDefault()
|
||||
handleBulkRestore()
|
||||
return
|
||||
}
|
||||
|
||||
// "e" key for bulk delete
|
||||
if (
|
||||
isHoldingCmdOrCtrl &&
|
||||
event.key === 'e' &&
|
||||
selectedChores.size > 0
|
||||
) {
|
||||
event.preventDefault()
|
||||
handleBulkDelete()
|
||||
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])
|
||||
|
||||
const toggleViewMode = () => {
|
||||
const modes = ['default', 'compact']
|
||||
const currentIndex = modes.indexOf(viewMode)
|
||||
const nextIndex = (currentIndex + 1) % modes.length
|
||||
const newMode = modes[nextIndex]
|
||||
setViewMode(newMode)
|
||||
localStorage.setItem('archivedChoreCardViewMode', newMode)
|
||||
}
|
||||
|
||||
const searchOptions = {
|
||||
keys: ['name', 'raw_label'],
|
||||
includeScore: true,
|
||||
isCaseSensitive: false,
|
||||
findAllMatches: true,
|
||||
}
|
||||
|
||||
const fuse = new Fuse(
|
||||
archivedChores.map(c => ({
|
||||
...c,
|
||||
raw_label: c.labelsV2?.map(c => c.name).join(' '),
|
||||
})),
|
||||
searchOptions,
|
||||
)
|
||||
|
||||
const handleSearchChange = e => {
|
||||
const search = e.target.value
|
||||
if (search === '') {
|
||||
setFilteredChores(archivedChores)
|
||||
setSearchTerm('')
|
||||
return
|
||||
}
|
||||
|
||||
const term = search.toLowerCase()
|
||||
setSearchTerm(term)
|
||||
setFilteredChores(fuse.search(term).map(result => result.item))
|
||||
}
|
||||
|
||||
const handleSearchClose = () => {
|
||||
setSearchTerm('')
|
||||
setFilteredChores(archivedChores)
|
||||
searchInputRef.current?.blur()
|
||||
}
|
||||
|
||||
const handleChoreUpdated = (updatedChore, event) => {
|
||||
if (event === 'unarchive') {
|
||||
// Remove from archived list when unarchived
|
||||
const newArchivedChores = archivedChores.filter(
|
||||
chore => chore.id !== updatedChore.id,
|
||||
)
|
||||
const newFilteredChores = filteredChores.filter(
|
||||
chore => chore.id !== updatedChore.id,
|
||||
)
|
||||
setArchivedChores(newArchivedChores)
|
||||
setFilteredChores(newFilteredChores)
|
||||
|
||||
showSuccess({
|
||||
title: 'Task Restored',
|
||||
message: 'The task has been restored and is now active.',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const handleChoreDeleted = deletedChore => {
|
||||
const newArchivedChores = archivedChores.filter(
|
||||
chore => chore.id !== deletedChore.id,
|
||||
)
|
||||
const newFilteredChores = filteredChores.filter(
|
||||
chore => chore.id !== deletedChore.id,
|
||||
)
|
||||
setArchivedChores(newArchivedChores)
|
||||
setFilteredChores(newFilteredChores)
|
||||
|
||||
showSuccess({
|
||||
title: 'Task Deleted',
|
||||
message: 'The archived task has been permanently deleted.',
|
||||
})
|
||||
}
|
||||
|
||||
// Multi-select helper functions
|
||||
const toggleMultiSelectMode = () => {
|
||||
const newMode = !isMultiSelectMode
|
||||
setIsMultiSelectMode(newMode)
|
||||
|
||||
if (!newMode) {
|
||||
setSelectedChores(new Set())
|
||||
}
|
||||
}
|
||||
|
||||
const toggleChoreSelection = choreId => {
|
||||
const newSelection = new Set(selectedChores)
|
||||
if (newSelection.has(choreId)) {
|
||||
newSelection.delete(choreId)
|
||||
} else {
|
||||
newSelection.add(choreId)
|
||||
}
|
||||
setSelectedChores(newSelection)
|
||||
}
|
||||
|
||||
const selectAllVisibleChores = () => {
|
||||
const visibleChores =
|
||||
searchTerm?.length > 0 ? filteredChores : archivedChores
|
||||
if (visibleChores.length > 0) {
|
||||
const allIds = new Set(visibleChores.map(chore => chore.id))
|
||||
setSelectedChores(allIds)
|
||||
}
|
||||
}
|
||||
|
||||
const clearSelection = () => {
|
||||
if (selectedChores.size === 0) {
|
||||
setIsMultiSelectMode(false)
|
||||
return
|
||||
}
|
||||
setSelectedChores(new Set())
|
||||
}
|
||||
|
||||
const getSelectedChoresData = () => {
|
||||
return Array.from(selectedChores)
|
||||
.map(id => archivedChores.find(chore => chore.id === id))
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
// Bulk operations
|
||||
const handleBulkRestore = async () => {
|
||||
const selectedData = getSelectedChoresData()
|
||||
if (selectedData.length === 0) return
|
||||
|
||||
setConfirmModelConfig({
|
||||
isOpen: true,
|
||||
title: 'Restore Tasks',
|
||||
confirmText: 'Restore',
|
||||
cancelText: 'Cancel',
|
||||
message: `Restore ${selectedData.length} task${selectedData.length > 1 ? 's' : ''} to active list?`,
|
||||
onClose: async isConfirmed => {
|
||||
if (isConfirmed === true) {
|
||||
try {
|
||||
const restoredTasks = []
|
||||
const failedTasks = []
|
||||
|
||||
for (const chore of selectedData) {
|
||||
try {
|
||||
await UnArchiveChore(chore.id)
|
||||
restoredTasks.push(chore)
|
||||
} catch (error) {
|
||||
failedTasks.push(chore)
|
||||
}
|
||||
}
|
||||
|
||||
if (restoredTasks.length > 0) {
|
||||
showSuccess({
|
||||
title: '📤 Tasks Restored',
|
||||
message: `Successfully restored ${restoredTasks.length} task${restoredTasks.length > 1 ? 's' : ''}.`,
|
||||
})
|
||||
|
||||
// Remove restored tasks from archived list
|
||||
const restoredIds = new Set(restoredTasks.map(c => c.id))
|
||||
const newArchivedChores = archivedChores.filter(
|
||||
c => !restoredIds.has(c.id),
|
||||
)
|
||||
const newFilteredChores = filteredChores.filter(
|
||||
c => !restoredIds.has(c.id),
|
||||
)
|
||||
setArchivedChores(newArchivedChores)
|
||||
setFilteredChores(newFilteredChores)
|
||||
}
|
||||
|
||||
if (failedTasks.length > 0) {
|
||||
showError({
|
||||
title: 'Some Tasks Failed',
|
||||
message: `${failedTasks.length} task${failedTasks.length > 1 ? 's' : ''} could not be restored.`,
|
||||
})
|
||||
}
|
||||
|
||||
clearSelection()
|
||||
} catch (error) {
|
||||
showError({
|
||||
title: 'Bulk Restore 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 Archived Tasks',
|
||||
confirmText: 'Delete',
|
||||
cancelText: 'Cancel',
|
||||
message: `Permanently delete ${selectedData.length} archived 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 newArchivedChores = archivedChores.filter(
|
||||
c => !deletedIds.has(c.id),
|
||||
)
|
||||
const newFilteredChores = filteredChores.filter(
|
||||
c => !deletedIds.has(c.id),
|
||||
)
|
||||
setArchivedChores(newArchivedChores)
|
||||
setFilteredChores(newFilteredChores)
|
||||
}
|
||||
|
||||
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({})
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Helper function to render the appropriate card component
|
||||
const renderChoreCard = (chore, key) => {
|
||||
const CardComponent = viewMode === 'compact' ? CompactChoreCard : ChoreCard
|
||||
return (
|
||||
<CardComponent
|
||||
key={key || chore.id}
|
||||
chore={chore}
|
||||
onChoreUpdate={handleChoreUpdated}
|
||||
onChoreRemove={handleChoreDeleted}
|
||||
performers={performers}
|
||||
viewOnly={false}
|
||||
// Multi-select props
|
||||
isMultiSelectMode={isMultiSelectMode}
|
||||
isSelected={selectedChores.has(chore.id)}
|
||||
onSelectionToggle={() => toggleChoreSelection(chore.id)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (isUserProfileLoading || performers.length === 0 || isLoading) {
|
||||
return <LoadingComponent />
|
||||
}
|
||||
|
||||
return (
|
||||
<Container maxWidth='md'>
|
||||
{/* Header */}
|
||||
{/* <Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
mb: 2,
|
||||
pt: 2,
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
level='h3'
|
||||
sx={{ display: 'flex', alignItems: 'center', gap: 1 }}
|
||||
>
|
||||
<Archive />
|
||||
Archived Tasks
|
||||
</Typography>
|
||||
<Button
|
||||
variant='outlined'
|
||||
color='neutral'
|
||||
startDecorator={<Close />}
|
||||
onClick={() => navigate('/chores')}
|
||||
sx={{ ml: 'auto' }}
|
||||
>
|
||||
Close
|
||||
</Button>
|
||||
</Box> */}
|
||||
|
||||
{/* Search and Controls */}
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignContent: 'center',
|
||||
alignItems: 'center',
|
||||
gap: 0.5,
|
||||
mb: 2,
|
||||
}}
|
||||
>
|
||||
<Input
|
||||
slotProps={{ input: { ref: searchInputRef } }}
|
||||
placeholder='Search archived tasks'
|
||||
value={searchTerm}
|
||||
fullWidth
|
||||
sx={{
|
||||
borderRadius: 24,
|
||||
height: 24,
|
||||
borderColor: 'text.disabled',
|
||||
padding: 1,
|
||||
}}
|
||||
onChange={handleSearchChange}
|
||||
startDecorator={
|
||||
<KeyboardShortcutHint shortcut='F' show={showKeyboardShortcuts} />
|
||||
}
|
||||
endDecorator={
|
||||
searchTerm && (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
||||
<KeyboardShortcutHint
|
||||
shortcut='X'
|
||||
show={showKeyboardShortcuts}
|
||||
/>
|
||||
<IconButton
|
||||
variant='plain'
|
||||
size='sm'
|
||||
onClick={handleSearchClose}
|
||||
sx={{ borderRadius: '50%' }}
|
||||
>
|
||||
<Close />
|
||||
</IconButton>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
{/* View Mode Toggle Button */}
|
||||
<IconButton
|
||||
variant='outlined'
|
||||
color='neutral'
|
||||
size='sm'
|
||||
sx={{
|
||||
height: 32,
|
||||
width: 32,
|
||||
borderRadius: '50%',
|
||||
}}
|
||||
onClick={toggleViewMode}
|
||||
title={
|
||||
viewMode === 'default'
|
||||
? 'Switch to Compact View'
|
||||
: 'Switch to Card View'
|
||||
}
|
||||
>
|
||||
{viewMode === 'default' ? <ViewAgenda /> : <ViewModule />}
|
||||
</IconButton>
|
||||
|
||||
{/* Multi-select Toggle Button */}
|
||||
<Box sx={{ position: 'relative', display: 'inline-flex' }}>
|
||||
<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 (Ctrl+S)'
|
||||
: 'Enable Multi-select Mode (Ctrl+S)'
|
||||
}
|
||||
>
|
||||
{isMultiSelectMode ? <CheckBox /> : <CheckBoxOutlineBlank />}
|
||||
</IconButton>
|
||||
<KeyboardShortcutHint
|
||||
shortcut='S'
|
||||
show={showKeyboardShortcuts}
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: -8,
|
||||
right: -8,
|
||||
zIndex: 1000,
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Multi-select Toolbar */}
|
||||
{isMultiSelectMode && (
|
||||
<Box
|
||||
sx={{
|
||||
position: 'sticky',
|
||||
top: 0,
|
||||
zIndex: 1000,
|
||||
mb: 2,
|
||||
}}
|
||||
>
|
||||
<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',
|
||||
},
|
||||
}}
|
||||
>
|
||||
{/* Selection Info and Controls */}
|
||||
<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'>
|
||||
{selectedChores.size} task
|
||||
{selectedChores.size !== 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={selectAllVisibleChores}
|
||||
startDecorator={<SelectAll />}
|
||||
disabled={selectedChores.size === filteredChores.length}
|
||||
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={clearSelection}
|
||||
startDecorator={
|
||||
selectedChores.size === 0 ? (
|
||||
<Close />
|
||||
) : (
|
||||
<CheckBoxOutlineBlank />
|
||||
)
|
||||
}
|
||||
sx={{
|
||||
minWidth: 'auto',
|
||||
'--Button-paddingInline': '0.75rem',
|
||||
position: 'relative',
|
||||
}}
|
||||
title={`${selectedChores.size === 0 ? 'Close' : 'Clear'} multi-select (Esc)`}
|
||||
>
|
||||
{selectedChores.size === 0 ? 'Close' : 'Clear'}
|
||||
{showKeyboardShortcuts && (
|
||||
<KeyboardShortcutHint
|
||||
withCtrl={false}
|
||||
shortcut='Esc'
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: -8,
|
||||
right: -8,
|
||||
zIndex: 1000,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<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={handleBulkRestore}
|
||||
startDecorator={<Unarchive />}
|
||||
disabled={selectedChores.size === 0}
|
||||
sx={{
|
||||
'--Button-paddingInline': { xs: '0.75rem', sm: '1rem' },
|
||||
position: 'relative',
|
||||
}}
|
||||
title='Restore selected tasks (R)'
|
||||
>
|
||||
Restore
|
||||
{showKeyboardShortcuts && selectedChores.size > 0 && (
|
||||
<KeyboardShortcutHint
|
||||
shortcut='R'
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: -8,
|
||||
right: -8,
|
||||
zIndex: 1000,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
size='sm'
|
||||
variant='soft'
|
||||
color='danger'
|
||||
onClick={handleBulkDelete}
|
||||
startDecorator={<Delete />}
|
||||
disabled={selectedChores.size === 0}
|
||||
sx={{
|
||||
'--Button-paddingInline': { xs: '0.75rem', sm: '1rem' },
|
||||
position: 'relative',
|
||||
}}
|
||||
title='Delete selected tasks (E)'
|
||||
>
|
||||
Delete
|
||||
{showKeyboardShortcuts && selectedChores.size > 0 && (
|
||||
<KeyboardShortcutHint
|
||||
shortcut='E'
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: -8,
|
||||
right: -8,
|
||||
zIndex: 1000,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Content */}
|
||||
{filteredChores.length === 0 ? (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
flexDirection: 'column',
|
||||
height: '50vh',
|
||||
}}
|
||||
>
|
||||
<Archive
|
||||
sx={{
|
||||
fontSize: '4rem',
|
||||
mb: 1,
|
||||
color: 'text.tertiary',
|
||||
}}
|
||||
/>
|
||||
<Typography level='title-md' gutterBottom>
|
||||
{searchTerm ? 'No archived tasks found' : 'No archived tasks'}
|
||||
</Typography>
|
||||
<Typography level='body-sm' color='text.secondary' sx={{ mb: 2 }}>
|
||||
{searchTerm
|
||||
? 'Try adjusting your search terms'
|
||||
: 'Archived tasks will appear here when you archive them from the main task list'}
|
||||
</Typography>
|
||||
{searchTerm && (
|
||||
<Button
|
||||
onClick={handleSearchClose}
|
||||
variant='outlined'
|
||||
color='neutral'
|
||||
>
|
||||
Clear search
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
) : (
|
||||
<Box>
|
||||
<Typography level='body-sm' color='text.secondary' sx={{ mb: 2 }}>
|
||||
{filteredChores.length} archived task
|
||||
{filteredChores.length !== 1 ? 's' : ''}
|
||||
{searchTerm && ` matching "${searchTerm}"`}
|
||||
</Typography>
|
||||
|
||||
<List sx={{ gap: viewMode === 'compact' ? 0 : 1 }}>
|
||||
{filteredChores.map(chore =>
|
||||
renderChoreCard(chore, `archived-${chore.id}`),
|
||||
)}
|
||||
</List>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Multi-select Help */}
|
||||
<MultiSelectHelp isVisible={isMultiSelectMode} />
|
||||
|
||||
{/* Confirmation Modal */}
|
||||
{confirmModelConfig?.isOpen && (
|
||||
<ConfirmationModal config={confirmModelConfig} />
|
||||
)}
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
||||
export default ArchivedTasks
|
||||
@@ -31,8 +31,8 @@ import { useCircleMembers, useUserProfile } from '../../queries/UserQueries.jsx'
|
||||
import { useNotification } from '../../service/NotificationProvider'
|
||||
import { notInCompletionWindow } from '../../utils/Chores.jsx'
|
||||
import {
|
||||
getPriorityColor,
|
||||
getTextColorFromBackgroundColor,
|
||||
TASK_COLOR,
|
||||
} from '../../utils/Colors.jsx'
|
||||
import {
|
||||
ApproveChore,
|
||||
@@ -614,20 +614,6 @@ const CompactChoreCard = ({
|
||||
return parts.join(' • ')
|
||||
}
|
||||
|
||||
const getPriorityColor = priority => {
|
||||
switch (priority) {
|
||||
case 1:
|
||||
return TASK_COLOR.PRIORITY_1
|
||||
case 2:
|
||||
return TASK_COLOR.PRIORITY_2
|
||||
case 3:
|
||||
return TASK_COLOR.PRIORITY_3
|
||||
case 4:
|
||||
return TASK_COLOR.PRIORITY_4
|
||||
default:
|
||||
return TASK_COLOR.NO_PRIORITY
|
||||
}
|
||||
}
|
||||
const handleChorePause = () => {
|
||||
PauseChore(chore.id).then(response => {
|
||||
if (response.ok) {
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { Close, HelpOutline, Keyboard } from '@mui/icons-material'
|
||||
import { Box, Button, Card, Divider, IconButton, Typography } from '@mui/joy'
|
||||
import { useState } from 'react'
|
||||
import FadeModal from '../../components/common/FadeModal'
|
||||
import { useResponsiveModal } from '../../hooks/useResponsiveModal'
|
||||
|
||||
const MultiSelectHelp = ({ isVisible = true }) => {
|
||||
const { ResponsiveModal } = useResponsiveModal()
|
||||
|
||||
const [isHelpOpen, setIsHelpOpen] = useState(false)
|
||||
|
||||
if (!isVisible) return null
|
||||
@@ -32,7 +34,7 @@ const MultiSelectHelp = ({ isVisible = true }) => {
|
||||
</IconButton>
|
||||
|
||||
{/* Help Modal */}
|
||||
<FadeModal open={isHelpOpen} onClose={() => setIsHelpOpen(false)}>
|
||||
<ResponsiveModal open={isHelpOpen} onClose={() => setIsHelpOpen(false)}>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
@@ -115,7 +117,7 @@ const MultiSelectHelp = ({ isVisible = true }) => {
|
||||
Got it!
|
||||
</Button>
|
||||
</Box>
|
||||
</FadeModal>
|
||||
</ResponsiveModal>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -17,7 +17,6 @@ import {
|
||||
SkipNext,
|
||||
Sort,
|
||||
Style,
|
||||
Unarchive,
|
||||
ViewAgenda,
|
||||
ViewModule,
|
||||
} from '@mui/icons-material'
|
||||
@@ -42,7 +41,7 @@ import { useEffect, useRef, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useChores } from '../../queries/ChoreQueries'
|
||||
import { useNotification } from '../../service/NotificationProvider'
|
||||
import { ArchiveChore, GetArchivedChores } from '../../utils/Fetcher'
|
||||
import { ArchiveChore } from '../../utils/Fetcher'
|
||||
import Priorities from '../../utils/Priorities'
|
||||
import LoadingComponent from '../components/Loading'
|
||||
import { useLabels } from '../Labels/LabelQueries'
|
||||
@@ -76,7 +75,6 @@ const MyChores = () => {
|
||||
const { showSuccess, showError, showWarning } = useNotification()
|
||||
const { impersonatedUser } = useImpersonateUser()
|
||||
const [chores, setChores] = useState([])
|
||||
const [archivedChores, setArchivedChores] = useState(null)
|
||||
const [filteredChores, setFilteredChores] = useState([])
|
||||
const [searchFilter, setSearchFilter] = useState('All')
|
||||
const [choreSections, setChoreSections] = useState([])
|
||||
@@ -366,6 +364,13 @@ const MyChores = () => {
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Ctrl/Cmd + O to navigate to archived tasks
|
||||
if (isHoldingCmdOrCtrl && event.key === 'o') {
|
||||
event.preventDefault()
|
||||
Navigate('/archived')
|
||||
return
|
||||
}
|
||||
}
|
||||
const handleKeyUp = event => {
|
||||
if (!event.ctrlKey && !event.metaKey) {
|
||||
@@ -524,16 +529,6 @@ const MyChores = () => {
|
||||
newFilteredChores = newFilteredChores.filter(
|
||||
chore => chore.id !== updatedChore.id,
|
||||
)
|
||||
if (archivedChores !== null) {
|
||||
setArchivedChores([...archivedChores, updatedChore])
|
||||
}
|
||||
}
|
||||
if (event === 'unarchive') {
|
||||
newChores.push(updatedChore)
|
||||
newFilteredChores.push(updatedChore)
|
||||
setArchivedChores(
|
||||
archivedChores.filter(chore => chore.id !== updatedChore.id),
|
||||
)
|
||||
}
|
||||
setChores(newChores)
|
||||
setFilteredChores(newFilteredChores)
|
||||
@@ -726,9 +721,8 @@ const MyChores = () => {
|
||||
}
|
||||
|
||||
const getSelectedChoresData = () => {
|
||||
const allChores = [...chores, ...(archivedChores || [])]
|
||||
return Array.from(selectedChores)
|
||||
.map(id => allChores.find(chore => chore.id === id))
|
||||
.map(id => chores.find(chore => chore.id === id))
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
@@ -822,14 +816,6 @@ const MyChores = () => {
|
||||
title: '📦 Tasks Archived',
|
||||
message: `Successfully archived ${archivedTasks.length} task${archivedTasks.length > 1 ? 's' : ''}.`,
|
||||
})
|
||||
// Update archived chores state
|
||||
setArchivedChores([
|
||||
...(archivedChores || []),
|
||||
...archivedTasks.map(c => ({
|
||||
...c,
|
||||
archived: true,
|
||||
})),
|
||||
])
|
||||
}
|
||||
if (failedTasks.length > 0) {
|
||||
showError({
|
||||
@@ -1595,7 +1581,7 @@ const MyChores = () => {
|
||||
Current Filter: {searchFilter}
|
||||
</Chip>
|
||||
)}
|
||||
{filteredChores.length === 0 && archivedChores == null && (
|
||||
{filteredChores.length === 0 && (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
@@ -1785,55 +1771,7 @@ const MyChores = () => {
|
||||
justifyContent: 'center',
|
||||
mt: 2,
|
||||
}}
|
||||
>
|
||||
{archivedChores === null && (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center' }}>
|
||||
<Button
|
||||
sx={{}}
|
||||
onClick={() => {
|
||||
GetArchivedChores()
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
setArchivedChores(data.res)
|
||||
})
|
||||
}}
|
||||
variant='outlined'
|
||||
color='neutral'
|
||||
startDecorator={<Unarchive />}
|
||||
endDecorator={
|
||||
<KeyboardShortcutHint
|
||||
shortcut='O'
|
||||
show={showKeyboardShortcuts}
|
||||
/>
|
||||
}
|
||||
>
|
||||
Show Archived
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
{archivedChores !== null && (
|
||||
<>
|
||||
<Divider orientation='horizontal'>
|
||||
<Chip
|
||||
variant='soft'
|
||||
color='danger'
|
||||
size='md'
|
||||
startDecorator={
|
||||
<>
|
||||
<Chip color='danger' size='sm' variant='plain'>
|
||||
{archivedChores?.length}
|
||||
</Chip>
|
||||
</>
|
||||
}
|
||||
>
|
||||
Archived
|
||||
</Chip>
|
||||
</Divider>
|
||||
|
||||
{archivedChores?.map(chore => renderChoreCard(chore))}
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
></Box>
|
||||
<Box
|
||||
// variant='outlined'
|
||||
sx={{
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
} from '@mui/joy'
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
import { useResponsiveModal } from '../../hooks/useResponsiveModal.js'
|
||||
import { resolvePhotoURL } from '../../utils/Helpers.jsx'
|
||||
|
||||
function RedeemPointsModal({ config }) {
|
||||
|
||||
@@ -1,20 +1,25 @@
|
||||
import {
|
||||
Archive,
|
||||
Cancel,
|
||||
CopyAll,
|
||||
Delete,
|
||||
Edit,
|
||||
ManageSearch,
|
||||
MoreTime,
|
||||
MoreVert,
|
||||
NextWeek,
|
||||
Nfc,
|
||||
NoteAdd,
|
||||
RecordVoiceOver,
|
||||
SwitchAccessShortcut,
|
||||
Today,
|
||||
Unarchive,
|
||||
Update,
|
||||
ViewCarousel,
|
||||
WbSunny,
|
||||
Weekend,
|
||||
} from '@mui/icons-material'
|
||||
import { Divider, IconButton, Menu, MenuItem } from '@mui/joy'
|
||||
import { Divider, IconButton, Menu, MenuItem, Tooltip } from '@mui/joy'
|
||||
import React, { useEffect } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useNotification } from '../../service/NotificationProvider'
|
||||
@@ -23,6 +28,7 @@ import {
|
||||
DeleteChore,
|
||||
SkipChore,
|
||||
UnArchiveChore,
|
||||
UpdateDueDate,
|
||||
} from '../../utils/Fetcher'
|
||||
|
||||
const ChoreActionMenu = ({
|
||||
@@ -158,6 +164,86 @@ const ChoreActionMenu = ({
|
||||
handleMenuClose()
|
||||
}
|
||||
|
||||
const getQuickScheduleDate = option => {
|
||||
const now = new Date()
|
||||
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate())
|
||||
|
||||
switch (option) {
|
||||
case 'today': {
|
||||
// Schedule for today at the next available slot: 9am, 12pm, 5pm, or now if after 5pm
|
||||
const nowHour = now.getHours()
|
||||
const scheduled = new Date(today)
|
||||
if (nowHour < 9) {
|
||||
scheduled.setHours(9, 0, 0, 0)
|
||||
} else if (nowHour < 12) {
|
||||
scheduled.setHours(12, 0, 0, 0)
|
||||
} else if (nowHour < 17) {
|
||||
scheduled.setHours(17, 0, 0, 0)
|
||||
} else {
|
||||
// After 5pm, use current time
|
||||
scheduled.setHours(
|
||||
now.getHours(),
|
||||
now.getMinutes(),
|
||||
now.getSeconds(),
|
||||
now.getMilliseconds(),
|
||||
)
|
||||
}
|
||||
return scheduled
|
||||
}
|
||||
case 'tomorrow-morning': {
|
||||
const tomorrowMorning = new Date(today)
|
||||
tomorrowMorning.setDate(today.getDate() + 1)
|
||||
tomorrowMorning.setHours(9, 0, 0, 0)
|
||||
return tomorrowMorning
|
||||
}
|
||||
case 'tomorrow': {
|
||||
const tomorrow = new Date(today)
|
||||
tomorrow.setDate(today.getDate() + 1)
|
||||
tomorrow.setHours(12, 0, 0, 0) // Set to noon
|
||||
return tomorrow
|
||||
}
|
||||
case 'tomorrow-afternoon': {
|
||||
const tomorrowAfternoon = new Date(today)
|
||||
tomorrowAfternoon.setDate(today.getDate() + 1)
|
||||
tomorrowAfternoon.setHours(14, 0, 0, 0)
|
||||
return tomorrowAfternoon
|
||||
}
|
||||
case 'weekend': {
|
||||
const weekend = new Date(today)
|
||||
const daysUntilSaturday = (6 - today.getDay() + 7) % 7 || 7
|
||||
weekend.setDate(today.getDate() + daysUntilSaturday)
|
||||
return weekend
|
||||
}
|
||||
case 'next-week': {
|
||||
const nextWeek = new Date(today)
|
||||
const daysUntilMonday = (1 - today.getDay() + 7) % 7 || 7
|
||||
nextWeek.setDate(today.getDate() + daysUntilMonday)
|
||||
return nextWeek
|
||||
}
|
||||
default:
|
||||
return today
|
||||
}
|
||||
}
|
||||
|
||||
const handleQuickSchedule = option => {
|
||||
const date = option === 'remove' ? null : getQuickScheduleDate(option)
|
||||
UpdateDueDate(chore.id, date).then(response => {
|
||||
if (response.ok) {
|
||||
response.json().then(data => {
|
||||
const newChore = {
|
||||
...chore,
|
||||
nextDueDate: date ? date.toISOString() : null,
|
||||
}
|
||||
onChoreUpdate(
|
||||
newChore,
|
||||
option === 'remove' ? 'due-date-removed' : 'rescheduled',
|
||||
)
|
||||
})
|
||||
}
|
||||
})
|
||||
handleMenuClose()
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<IconButton
|
||||
@@ -240,6 +326,88 @@ const ChoreActionMenu = ({
|
||||
History
|
||||
</MenuItem>
|
||||
<Divider />
|
||||
<MenuItem
|
||||
sx={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-around',
|
||||
alignItems: 'center',
|
||||
gap: 1,
|
||||
cursor: 'default',
|
||||
'&:hover': {
|
||||
backgroundColor: 'transparent',
|
||||
},
|
||||
}}
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
<Tooltip title='Today' placement='top'>
|
||||
<IconButton
|
||||
size='sm'
|
||||
onClick={e => {
|
||||
e.stopPropagation()
|
||||
handleQuickSchedule('today')
|
||||
}}
|
||||
>
|
||||
<Today />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title='Tomorrow' placement='top'>
|
||||
<IconButton
|
||||
size='sm'
|
||||
onClick={e => {
|
||||
e.stopPropagation()
|
||||
handleQuickSchedule('tomorrow')
|
||||
}}
|
||||
>
|
||||
<WbSunny />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
{/* <Tooltip title='Tomorrow afternoon' placement='top'>
|
||||
<IconButton
|
||||
size='sm'
|
||||
onClick={e => {
|
||||
e.stopPropagation()
|
||||
handleQuickSchedule('tomorrow-afternoon')
|
||||
}}
|
||||
>
|
||||
<WbTwilight />
|
||||
</IconButton>
|
||||
</Tooltip> */}
|
||||
<Tooltip title='Weekend' placement='top'>
|
||||
<IconButton
|
||||
size='sm'
|
||||
onClick={e => {
|
||||
e.stopPropagation()
|
||||
handleQuickSchedule('weekend')
|
||||
}}
|
||||
>
|
||||
<Weekend />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title='Next week' placement='top'>
|
||||
<IconButton
|
||||
size='sm'
|
||||
onClick={e => {
|
||||
e.stopPropagation()
|
||||
handleQuickSchedule('next-week')
|
||||
}}
|
||||
>
|
||||
<NextWeek />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title='Remove due date' placement='top'>
|
||||
<IconButton
|
||||
size='sm'
|
||||
color='neutral'
|
||||
onClick={e => {
|
||||
e.stopPropagation()
|
||||
handleQuickSchedule('remove')
|
||||
}}
|
||||
>
|
||||
<Cancel />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</MenuItem>
|
||||
<Divider />
|
||||
<MenuItem
|
||||
onClick={e => {
|
||||
e.stopPropagation()
|
||||
|
||||
@@ -135,6 +135,18 @@ export const parseRepeatV2 = inputSentence => {
|
||||
regex: /(\d+)(?:th|st|nd|rd)? of every month/i,
|
||||
name: 'Every {day} of every month',
|
||||
},
|
||||
{
|
||||
frequencyType: 'days_of_the_week:nth_occurrence',
|
||||
regex:
|
||||
/(first|second|third|fourth|last|\d+(?:st|nd|rd|th)?) (monday|tuesday|wednesday|thursday|friday|saturday|sunday) of (?:the )?month/i,
|
||||
name: '{occurrence} {day} of the month',
|
||||
},
|
||||
{
|
||||
frequencyType: 'days_of_the_week:nth_occurrence_multiple',
|
||||
regex:
|
||||
/((?:first|second|third|fourth|last|\d+(?:st|nd|rd|th)?)(?:,? (?:and |& )?))+\s+(monday|tuesday|wednesday|thursday|friday|saturday|sunday)s? of (?:the )?month/i,
|
||||
name: '{occurrences} {day} of the month',
|
||||
},
|
||||
{
|
||||
frequencyType: 'daily',
|
||||
regex: /(every day|daily|everyday)/i,
|
||||
@@ -361,6 +373,116 @@ export const parseRepeatV2 = inputSentence => {
|
||||
],
|
||||
cleanedSentence: inputSentence.replace(match[0], '').trim(),
|
||||
}
|
||||
|
||||
case 'days_of_the_week:nth_occurrence':
|
||||
const occurrenceText = match[1].toLowerCase()
|
||||
const dayName = match[2].toLowerCase()
|
||||
|
||||
// Map occurrence words to numbers
|
||||
const occurrenceMap = {
|
||||
first: 1,
|
||||
'1st': 1,
|
||||
second: 2,
|
||||
'2nd': 2,
|
||||
third: 3,
|
||||
'3rd': 3,
|
||||
fourth: 4,
|
||||
'4th': 4,
|
||||
last: -1,
|
||||
}
|
||||
|
||||
const occurrence =
|
||||
occurrenceMap[occurrenceText] ||
|
||||
parseInt(occurrenceText.replace(/\D/g, ''), 10)
|
||||
|
||||
if (!VALID_DAYS[dayName]) {
|
||||
return { result: null, name: null, cleanedSentence: inputSentence }
|
||||
}
|
||||
|
||||
result.frequencyType = 'days_of_the_week'
|
||||
result.frequencyMetadata.days = [VALID_DAYS[dayName].toLowerCase()]
|
||||
result.frequencyMetadata.weekPattern = 'nth_day_of_month'
|
||||
result.frequencyMetadata.occurrences = [occurrence]
|
||||
|
||||
const startIndex = inputSentence
|
||||
.toLowerCase()
|
||||
.indexOf(match[0].toLowerCase())
|
||||
return {
|
||||
result,
|
||||
name: pattern.name
|
||||
.replace('{occurrence}', match[1])
|
||||
.replace('{day}', VALID_DAYS[dayName]),
|
||||
highlight: [
|
||||
{
|
||||
text: inputSentence.substring(
|
||||
startIndex,
|
||||
startIndex + match[0].length,
|
||||
),
|
||||
start: startIndex,
|
||||
end: startIndex + match[0].length,
|
||||
},
|
||||
],
|
||||
cleanedSentence: inputSentence.replace(match[0], '').trim(),
|
||||
}
|
||||
|
||||
case 'days_of_the_week:nth_occurrence_multiple':
|
||||
const occurrencesText = match[1].toLowerCase()
|
||||
const dayName2 = match[2].toLowerCase()
|
||||
|
||||
if (!VALID_DAYS[dayName2]) {
|
||||
return { result: null, name: null, cleanedSentence: inputSentence }
|
||||
}
|
||||
|
||||
// Parse multiple occurrences like "first, second and third"
|
||||
const occurrences = occurrencesText
|
||||
.replace(/,?\s*(and|&)\s*/g, ' ')
|
||||
.split(/\s+/)
|
||||
.filter(word => word.trim())
|
||||
.map(word => {
|
||||
const cleanWord = word.replace(',', '').trim()
|
||||
const occurrenceMap = {
|
||||
first: 1,
|
||||
'1st': 1,
|
||||
second: 2,
|
||||
'2nd': 2,
|
||||
third: 3,
|
||||
'3rd': 3,
|
||||
fourth: 4,
|
||||
'4th': 4,
|
||||
last: -1,
|
||||
}
|
||||
return (
|
||||
occurrenceMap[cleanWord] ||
|
||||
parseInt(cleanWord.replace(/\D/g, ''), 10)
|
||||
)
|
||||
})
|
||||
.filter(num => !isNaN(num))
|
||||
|
||||
result.frequencyType = 'days_of_the_week'
|
||||
result.frequencyMetadata.days = [VALID_DAYS[dayName2].toLowerCase()]
|
||||
result.frequencyMetadata.weekPattern = 'nth_day_of_month'
|
||||
result.frequencyMetadata.occurrences = occurrences
|
||||
|
||||
const startIndex2 = inputSentence
|
||||
.toLowerCase()
|
||||
.indexOf(match[0].toLowerCase())
|
||||
return {
|
||||
result,
|
||||
name: pattern.name
|
||||
.replace('{occurrences}', match[1])
|
||||
.replace('{day}', VALID_DAYS[dayName2]),
|
||||
highlight: [
|
||||
{
|
||||
text: inputSentence.substring(
|
||||
startIndex2,
|
||||
startIndex2 + match[0].length,
|
||||
),
|
||||
start: startIndex2,
|
||||
end: startIndex2 + match[0].length,
|
||||
},
|
||||
],
|
||||
cleanedSentence: inputSentence.replace(match[0], '').trim(),
|
||||
}
|
||||
}
|
||||
}
|
||||
return {
|
||||
@@ -375,17 +497,19 @@ export const parseAssignees = (inputSentence, users) => {
|
||||
const sentence = inputSentence.toLowerCase()
|
||||
const result = []
|
||||
const highlight = []
|
||||
|
||||
for (const user of users) {
|
||||
if (sentence.includes(`@${user.username.toLowerCase()}`)) {
|
||||
// sort users by the longest so we remove first the full match:
|
||||
for (const user of users.sort(
|
||||
(a, b) => b.displayName.length - a.displayName.length,
|
||||
)) {
|
||||
if (sentence.includes(`@${user.displayName.toLowerCase()}`)) {
|
||||
result.push(user)
|
||||
const index = inputSentence
|
||||
.toLowerCase()
|
||||
.indexOf(`@${user.username.toLowerCase()}`)
|
||||
.indexOf(`@${user.displayName.toLowerCase()}`)
|
||||
highlight.push({
|
||||
text: `@${user.username}`,
|
||||
text: `@${user.displayName}`,
|
||||
start: index,
|
||||
end: index + user.username.length + 1,
|
||||
end: index + user.displayName.length + 1,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -395,7 +519,10 @@ export const parseAssignees = (inputSentence, users) => {
|
||||
result,
|
||||
highlight,
|
||||
cleanedSentence: sentence.replace(
|
||||
new RegExp(`@(${users.map(u => u.username).join('|')})`, 'g'),
|
||||
new RegExp(
|
||||
`@(${result.map(u => u.displayName.toLowerCase()).join('|')})`,
|
||||
'g',
|
||||
),
|
||||
'',
|
||||
),
|
||||
}
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
import Logo from '@/assets/logo.svg'
|
||||
import {
|
||||
AccountBox,
|
||||
Archive,
|
||||
ArrowBack,
|
||||
History,
|
||||
HomeOutlined,
|
||||
Inbox,
|
||||
ListAlt,
|
||||
Logout,
|
||||
MenuRounded,
|
||||
Message,
|
||||
SettingsOutlined,
|
||||
ShareOutlined,
|
||||
Toll,
|
||||
Widgets,
|
||||
} from '@mui/icons-material'
|
||||
@@ -30,9 +29,14 @@ import ThemeToggleButton from '../Settings/ThemeToggleButton'
|
||||
import NavBarLink from './NavBarLink'
|
||||
const links = [
|
||||
{
|
||||
to: '/my/chores',
|
||||
label: 'Home',
|
||||
icon: <HomeOutlined />,
|
||||
to: '/chores',
|
||||
label: 'All Tasks',
|
||||
icon: <Inbox />,
|
||||
},
|
||||
{
|
||||
to: '/archived',
|
||||
label: 'Archived',
|
||||
icon: <Archive />,
|
||||
},
|
||||
|
||||
// {
|
||||
@@ -60,21 +64,21 @@ const links = [
|
||||
label: 'Points',
|
||||
icon: <Toll />,
|
||||
},
|
||||
{
|
||||
to: '/settings#sharing',
|
||||
label: 'Sharing',
|
||||
icon: <ShareOutlined />,
|
||||
},
|
||||
{
|
||||
to: '/settings#notifications',
|
||||
label: 'Notifications',
|
||||
icon: <Message />,
|
||||
},
|
||||
{
|
||||
to: '/settings#account',
|
||||
label: 'Account',
|
||||
icon: <AccountBox />,
|
||||
},
|
||||
// {
|
||||
// to: '/settings#sharing',
|
||||
// label: 'Sharing',
|
||||
// icon: <ShareOutlined />,
|
||||
// },
|
||||
// {
|
||||
// to: '/settings#notifications',
|
||||
// label: 'Notifications',
|
||||
// icon: <Message />,
|
||||
// },
|
||||
// {
|
||||
// to: '/settings#account',
|
||||
// label: 'Account',
|
||||
// icon: <AccountBox />,
|
||||
// },
|
||||
{
|
||||
to: '/settings',
|
||||
label: 'Settings',
|
||||
@@ -119,16 +123,26 @@ const NavBar = () => {
|
||||
backgroundColor: 'var(--joy-palette-background-body)',
|
||||
}}
|
||||
>
|
||||
<IconButton size='md' variant='plain' onClick={() => setDrawerOpen(true)}>
|
||||
{['/chores', '/'].includes(location.pathname) ? (
|
||||
<IconButton
|
||||
size='md'
|
||||
variant='plain'
|
||||
onClick={() => setDrawerOpen(true)}
|
||||
>
|
||||
<MenuRounded />
|
||||
</IconButton>
|
||||
) : (
|
||||
<IconButton size='md' variant='plain' onClick={() => navigate(-1)}>
|
||||
<ArrowBack />
|
||||
</IconButton>
|
||||
)}
|
||||
<Box
|
||||
className='flex items-center gap-2'
|
||||
onClick={() => {
|
||||
navigate('/my/chores')
|
||||
navigate('/chores')
|
||||
}}
|
||||
>
|
||||
<img component='img' src={Logo} width='25' />
|
||||
<img src={Logo} width='25' alt='Logo' />
|
||||
<Typography
|
||||
level='title-lg'
|
||||
sx={{
|
||||
|
||||
Reference in New Issue
Block a user