feat: enhance chore management with new modals and filter options
This commit is contained in:
@@ -63,6 +63,8 @@
|
||||
"skip": "Skip",
|
||||
"cancel": "Cancel",
|
||||
"noPriority": "No Priority",
|
||||
"more": "More",
|
||||
"changeDueDate": "Change due date",
|
||||
"subtasks": "Subtasks",
|
||||
"noDescription": "No description available",
|
||||
"timer": {
|
||||
|
||||
@@ -147,8 +147,19 @@ const FilterBar = ({
|
||||
onClearAll,
|
||||
resultCount,
|
||||
totalCount,
|
||||
// When the host renders its own trigger (e.g. an icon button in a toolbar
|
||||
// row), it drives the sheet through `open`/`onOpenChange` and hides ours.
|
||||
open,
|
||||
onOpenChange,
|
||||
showTrigger = true,
|
||||
}) => {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const [internalOpen, setInternalOpen] = useState(false)
|
||||
const isControlled = open !== undefined
|
||||
const isOpen = isControlled ? open : internalOpen
|
||||
const setIsOpen = next => {
|
||||
if (!isControlled) setInternalOpen(next)
|
||||
onOpenChange?.(next)
|
||||
}
|
||||
|
||||
// ── Active count ───────────────────────────────────────────────────────────
|
||||
|
||||
@@ -293,65 +304,75 @@ const FilterBar = ({
|
||||
|
||||
// ── Render ─────────────────────────────────────────────────────────────────
|
||||
|
||||
const activeChips = filterDefs
|
||||
.map(def => ({ def, label: getActiveChipLabel(def) }))
|
||||
.filter(({ label }) => !!label)
|
||||
.map(({ def, label }) => ({
|
||||
key: def.id,
|
||||
label,
|
||||
onClear: () => onSetFilter(def.id, null),
|
||||
}))
|
||||
|
||||
// With the trigger hoisted into a toolbar, the inline row has nothing to show
|
||||
// until a filter is on — rendering it anyway would leave a phantom gap.
|
||||
const showInlineBar = showTrigger || activeChips.length > 0
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* ── Inline bar ─────────────────────────────────────── */}
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 1,
|
||||
flexWrap: 'wrap',
|
||||
mb: 2,
|
||||
}}
|
||||
>
|
||||
<Badge
|
||||
badgeContent={activeFilterCount || null}
|
||||
color='primary'
|
||||
size='sm'
|
||||
anchorOrigin={{ vertical: 'top', horizontal: 'right' }}
|
||||
sx={{ display: 'flex', alignItems: 'center' }}
|
||||
{showInlineBar && (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 1,
|
||||
flexWrap: 'wrap',
|
||||
mb: 2,
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
size='md'
|
||||
variant={hasActive ? 'solid' : 'outlined'}
|
||||
color={hasActive ? 'primary' : 'neutral'}
|
||||
startDecorator={<FilterList sx={{ fontSize: 16 }} />}
|
||||
onClick={() => setIsOpen(true)}
|
||||
sx={{
|
||||
borderRadius: 'xl',
|
||||
py: 0.5,
|
||||
px: 1,
|
||||
gap: 0.5,
|
||||
alignItems: 'center',
|
||||
'& .MuiButton-startDecorator': {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
mr: 0.5,
|
||||
},
|
||||
}}
|
||||
>
|
||||
Filters
|
||||
</Button>
|
||||
</Badge>
|
||||
{showTrigger && (
|
||||
<Badge
|
||||
badgeContent={activeFilterCount || null}
|
||||
color='primary'
|
||||
size='sm'
|
||||
anchorOrigin={{ vertical: 'top', horizontal: 'right' }}
|
||||
sx={{ display: 'flex', alignItems: 'center' }}
|
||||
>
|
||||
<Button
|
||||
size='md'
|
||||
variant={hasActive ? 'solid' : 'outlined'}
|
||||
color={hasActive ? 'primary' : 'neutral'}
|
||||
startDecorator={<FilterList sx={{ fontSize: 16 }} />}
|
||||
onClick={() => setIsOpen(true)}
|
||||
sx={{
|
||||
borderRadius: 'xl',
|
||||
py: 0.5,
|
||||
px: 1,
|
||||
gap: 0.5,
|
||||
alignItems: 'center',
|
||||
'& .MuiButton-startDecorator': {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
mr: 0.5,
|
||||
},
|
||||
}}
|
||||
>
|
||||
Filters
|
||||
</Button>
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
<ActiveFilterChips
|
||||
chips={filterDefs
|
||||
.map(def => ({ def, label: getActiveChipLabel(def) }))
|
||||
.filter(({ label }) => !!label)
|
||||
.map(({ def, label }) => ({
|
||||
key: def.id,
|
||||
label,
|
||||
onClear: () => onSetFilter(def.id, null),
|
||||
}))}
|
||||
onOpen={() => setIsOpen(true)}
|
||||
onClearAll={hasActive ? onClearAll : undefined}
|
||||
resultCount={hasActive ? resultCount : undefined}
|
||||
totalCount={hasActive ? totalCount : undefined}
|
||||
maxVisible={2}
|
||||
chipSize='md'
|
||||
/>
|
||||
</Box>
|
||||
<ActiveFilterChips
|
||||
chips={activeChips}
|
||||
onOpen={() => setIsOpen(true)}
|
||||
onClearAll={hasActive ? onClearAll : undefined}
|
||||
resultCount={hasActive ? resultCount : undefined}
|
||||
totalCount={hasActive ? totalCount : undefined}
|
||||
maxVisible={2}
|
||||
chipSize='md'
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* ── Bottom sheet ────────────────────────────────────── */}
|
||||
<AppModal
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Capacitor } from '@capacitor/core'
|
||||
import {
|
||||
Archive,
|
||||
AttachFile,
|
||||
@@ -7,7 +8,7 @@ import {
|
||||
Edit,
|
||||
History,
|
||||
HourglassEmpty,
|
||||
LowPriority,
|
||||
MoreVert,
|
||||
OpenInFull,
|
||||
PeopleAlt,
|
||||
Person,
|
||||
@@ -25,18 +26,13 @@ import {
|
||||
Checkbox,
|
||||
Chip,
|
||||
Container,
|
||||
Dropdown,
|
||||
FormControl,
|
||||
Grid,
|
||||
IconButton,
|
||||
Input,
|
||||
Menu,
|
||||
MenuButton,
|
||||
MenuItem,
|
||||
Sheet,
|
||||
Typography,
|
||||
} from '@mui/joy'
|
||||
import { Divider } from '@mui/material'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import moment from 'moment'
|
||||
import { useEffect, useState } from 'react'
|
||||
@@ -68,20 +64,33 @@ import { getTextColorFromBackgroundColor } from '../../utils/Colors.jsx'
|
||||
import { commandQueue, CommandType } from '../../utils/CommandQueue'
|
||||
import {
|
||||
ApproveChore,
|
||||
ArchiveChore,
|
||||
DeleteChore,
|
||||
GetChoreDetailById,
|
||||
MarkChoreComplete,
|
||||
NudgeChore,
|
||||
RejectChore,
|
||||
SaveChore,
|
||||
SkipChore,
|
||||
UnArchiveChore,
|
||||
UndoChoreAction,
|
||||
UpdateChoreAssignee,
|
||||
UpdateChorePriority,
|
||||
UpdateDueDate,
|
||||
} from '../../utils/Fetcher'
|
||||
import { offlineDB } from '../../utils/OfflineDB'
|
||||
import Priorities from '../../utils/Priorities'
|
||||
import { getSafeBottomPadding } from '../../utils/SafeAreaUtils.js'
|
||||
import AttachmentBrowserModal from '../Modals/Inputs/AttachmentBrowserModal'
|
||||
import ConfirmationModal from '../Modals/Inputs/ConfirmationModal'
|
||||
import NoteViewerModal from '../Modals/Inputs/NoteViewerModal'
|
||||
import NudgeModal from '../Modals/Inputs/NudgeModal'
|
||||
import SelectModal from '../Modals/Inputs/SelectModal'
|
||||
import WriteNFCModal from '../Modals/Inputs/WriteNFCModal'
|
||||
import ChoreActionMenu from '../components/ChoreActionMenu'
|
||||
import DueDatePickerModal, {
|
||||
combineDueDate,
|
||||
splitDueDate,
|
||||
} from '../components/DueDatePickerModal'
|
||||
import LoadingComponent from '../components/Loading.jsx'
|
||||
import PendingBadge from '../components/PendingBadge'
|
||||
import RichTextEditor from '../components/RichTextEditor.jsx'
|
||||
@@ -106,6 +115,11 @@ const decodeHtmlEntities = value => {
|
||||
|
||||
const hasHtmlTags = value => /<\/?[a-z][\s\S]*>/i.test(value)
|
||||
|
||||
const getNFCUrl = choreId =>
|
||||
Capacitor.getPlatform() === 'android' || Capacitor.getPlatform() === 'ios'
|
||||
? `donetick://chores/${choreId}`
|
||||
: `${window.location.origin}/chores/${choreId}`
|
||||
|
||||
const ChoreView = () => {
|
||||
const { t } = useTranslation('chores')
|
||||
const { fmt } = useLocalization()
|
||||
@@ -124,7 +138,7 @@ const ChoreView = () => {
|
||||
const [confirmModelConfig, setConfirmModelConfig] = useState({
|
||||
isOpen: false,
|
||||
})
|
||||
const [chorePriority, setChorePriority] = useState(null)
|
||||
const [activeModal, setActiveModal] = useState(null)
|
||||
const [noteViewerConfig, setNoteViewerConfig] = useState({ isOpen: false })
|
||||
const [timerActionConfig, setTimerActionConfig] = useState({ isOpen: false })
|
||||
const [attachmentBrowserOpen, setAttachmentBrowserOpen] = useState(false)
|
||||
@@ -165,7 +179,6 @@ const ChoreView = () => {
|
||||
return
|
||||
}
|
||||
setChore(choreData.res)
|
||||
setChorePriority(Priorities.find(p => p.value === choreData.res.priority))
|
||||
document.title = 'Donetick: ' + choreData.res.name
|
||||
|
||||
setPerformers(circleMembersData.res)
|
||||
@@ -236,7 +249,7 @@ const ChoreView = () => {
|
||||
UpdateChorePriority(choreId, priority.value).then(response => {
|
||||
if (response.ok) {
|
||||
response.json().then(() => {
|
||||
setChorePriority(priority)
|
||||
setChore(prev => ({ ...prev, priority: priority.value }))
|
||||
queryClient.invalidateQueries(['chores'])
|
||||
})
|
||||
}
|
||||
@@ -583,6 +596,168 @@ const ChoreView = () => {
|
||||
}
|
||||
}
|
||||
|
||||
const confirmSkipTask = () => {
|
||||
setConfirmModelConfig({
|
||||
isOpen: true,
|
||||
title: t('choreView.skipTask'),
|
||||
message: t('choreView.skipTaskConfirmation'),
|
||||
confirmText: t('choreView.skip'),
|
||||
cancelText: t('choreView.cancel'),
|
||||
onClose: confirmed => {
|
||||
if (confirmed) {
|
||||
handleSkippingTask()
|
||||
}
|
||||
setConfirmModelConfig({})
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const handleArchiveChore = async () => {
|
||||
try {
|
||||
const response = await ArchiveChore(choreId)
|
||||
if (response.ok) {
|
||||
await offlineDB.saveChores([{ ...chore, isActive: false }])
|
||||
setChore({ ...chore, isActive: false })
|
||||
queryClient.invalidateQueries(['chores'])
|
||||
}
|
||||
} catch (error) {
|
||||
showError({
|
||||
title: 'Failed to archive',
|
||||
message: error?.message || 'Unable to archive task',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const confirmDeleteChore = () => {
|
||||
setConfirmModelConfig({
|
||||
isOpen: true,
|
||||
title: 'Delete task',
|
||||
message: 'Are you sure you want to delete this task?',
|
||||
confirmText: 'Delete',
|
||||
cancelText: t('choreView.cancel'),
|
||||
onClose: async confirmed => {
|
||||
setConfirmModelConfig({})
|
||||
if (!confirmed) return
|
||||
try {
|
||||
const response = await DeleteChore(choreId)
|
||||
if (response.ok) {
|
||||
queryClient.invalidateQueries(['chores'])
|
||||
showSuccess({
|
||||
title: 'Task Deleted',
|
||||
message: 'The task has been deleted successfully.',
|
||||
})
|
||||
navigate('/chores')
|
||||
}
|
||||
} catch (error) {
|
||||
showError({
|
||||
title: 'Failed to delete',
|
||||
message: error?.message || 'Unable to delete task',
|
||||
})
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const handleDueDateChange = async newDate => {
|
||||
try {
|
||||
const response = await UpdateDueDate(choreId, newDate)
|
||||
if (response.ok) {
|
||||
setChore(prev => ({ ...prev, nextDueDate: newDate }))
|
||||
queryClient.invalidateQueries(['chores'])
|
||||
}
|
||||
} catch (error) {
|
||||
showError({
|
||||
title: 'Failed to reschedule',
|
||||
message: error?.message || 'Unable to change the due date',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const handleMoveToProject = async project => {
|
||||
const projectId = project?.id ?? null
|
||||
try {
|
||||
const response = await SaveChore({ ...chore, projectId })
|
||||
if (response.ok) {
|
||||
setChore(prev => ({ ...prev, projectId }))
|
||||
queryClient.invalidateQueries(['chores'])
|
||||
showSuccess({
|
||||
title: 'Task Moved',
|
||||
message: `Task moved to ${project?.name || 'Default Project'}.`,
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
showError({
|
||||
title: 'Failed to move task',
|
||||
message: error?.message || 'Unable to move task to project',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const handleNudge = async ({ message, notifyAllAssignees }) => {
|
||||
try {
|
||||
const response = await NudgeChore(choreId, {
|
||||
message,
|
||||
notifyAllAssignees,
|
||||
})
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to send nudge')
|
||||
}
|
||||
const data = await response.json()
|
||||
showSuccess({
|
||||
title: 'Nudge Sent!',
|
||||
message: data.message || 'Nudge sent successfully',
|
||||
})
|
||||
} catch (error) {
|
||||
showError({
|
||||
title: 'Failed to Send Nudge',
|
||||
message: error?.message || 'Unable to send nudge at this time',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const handleAssigneeChange = async assigneeId => {
|
||||
try {
|
||||
const response = await UpdateChoreAssignee(choreId, assigneeId)
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
setChore(data.res)
|
||||
queryClient.invalidateQueries(['chores'])
|
||||
}
|
||||
} catch (error) {
|
||||
showError({
|
||||
title: 'Failed to delegate',
|
||||
message: error?.message || 'Unable to change the assignee',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Actions the menu raises that ChoreView owns; the rest of its items either
|
||||
// navigate on their own or come in through the dedicated callbacks.
|
||||
const handleMenuAction = (type, _chore, extraData) => {
|
||||
switch (type) {
|
||||
case 'skip':
|
||||
confirmSkipTask()
|
||||
break
|
||||
case 'archive':
|
||||
handleArchiveChore()
|
||||
break
|
||||
case 'unarchive':
|
||||
handleUnarchiveChore()
|
||||
break
|
||||
case 'delete':
|
||||
confirmDeleteChore()
|
||||
break
|
||||
case 'changeDueDate':
|
||||
handleDueDateChange(extraData?.date?.toISOString() ?? null)
|
||||
break
|
||||
case 'moveToProject':
|
||||
handleMoveToProject(extraData?.project)
|
||||
break
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Check if the current user can approve/reject (admin, manager, or task owner)
|
||||
const canApproveReject = () => {
|
||||
if (!circleMembersData?.res || !chore) return false
|
||||
@@ -794,64 +969,6 @@ const ChoreView = () => {
|
||||
mb: 1,
|
||||
}}
|
||||
>
|
||||
<Dropdown>
|
||||
<MenuButton
|
||||
disabled={chore.isActive === false}
|
||||
color={
|
||||
chorePriority?.name === 'P1'
|
||||
? 'danger'
|
||||
: chorePriority?.name === 'P2'
|
||||
? 'warning'
|
||||
: 'neutral'
|
||||
}
|
||||
sx={{
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
p: 1,
|
||||
width: '100%',
|
||||
}}
|
||||
variant='plain'
|
||||
>
|
||||
{chorePriority ? chorePriority.icon : <LowPriority />}
|
||||
{chorePriority ? chorePriority.name : t('choreView.noPriority')}
|
||||
</MenuButton>
|
||||
<Menu>
|
||||
{Priorities.map((priority, index) => (
|
||||
<MenuItem
|
||||
sx={{
|
||||
pr: 1,
|
||||
py: 1,
|
||||
}}
|
||||
key={index}
|
||||
onClick={() => {
|
||||
handleUpdatePriority(priority)
|
||||
}}
|
||||
color={priority.color}
|
||||
>
|
||||
{priority.icon}
|
||||
{priority.name}
|
||||
</MenuItem>
|
||||
))}
|
||||
<Divider />
|
||||
<MenuItem
|
||||
sx={{
|
||||
pr: 1,
|
||||
py: 1,
|
||||
}}
|
||||
onClick={() => {
|
||||
handleUpdatePriority({
|
||||
name: t('choreView.noPriority'),
|
||||
value: 0,
|
||||
})
|
||||
setChorePriority(null)
|
||||
}}
|
||||
>
|
||||
{t('choreView.noPriority')}
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
</Dropdown>
|
||||
|
||||
<Button
|
||||
size='sm'
|
||||
color='neutral'
|
||||
@@ -889,6 +1006,38 @@ const ChoreView = () => {
|
||||
<Edit />
|
||||
Edit
|
||||
</Button>
|
||||
<ChoreActionMenu
|
||||
chore={chore}
|
||||
hiddenActions={['view']}
|
||||
onAction={handleMenuAction}
|
||||
onNudge={() => setActiveModal('nudge')}
|
||||
onWriteNFC={() => setActiveModal('writeNFC')}
|
||||
onCompleteWithNote={() => setNote('')}
|
||||
onCompleteWithPastDate={() =>
|
||||
setCompletedDate(moment(new Date()).format('YYYY-MM-DDTHH:00:00'))
|
||||
}
|
||||
onChangeAssignee={() => setActiveModal('changeAssignee')}
|
||||
onChangeDueDate={() => setActiveModal('changeDueDate')}
|
||||
onChangePriority={handleUpdatePriority}
|
||||
onDelete={confirmDeleteChore}
|
||||
trigger={
|
||||
<Button
|
||||
size='sm'
|
||||
color='neutral'
|
||||
variant='plain'
|
||||
fullWidth
|
||||
sx={{
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
p: 1,
|
||||
}}
|
||||
>
|
||||
<MoreVert />
|
||||
{t('choreView.more', 'More')}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{chore.description && (
|
||||
@@ -1261,21 +1410,7 @@ const ChoreView = () => {
|
||||
<Button
|
||||
fullWidth
|
||||
size='lg'
|
||||
onClick={() => {
|
||||
setConfirmModelConfig({
|
||||
isOpen: true,
|
||||
title: t('choreView.skipTask'),
|
||||
message: t('choreView.skipTaskConfirmation'),
|
||||
confirmText: t('choreView.skip'),
|
||||
cancelText: t('choreView.cancel'),
|
||||
onClose: confirmed => {
|
||||
if (confirmed) {
|
||||
handleSkippingTask()
|
||||
}
|
||||
setConfirmModelConfig({})
|
||||
},
|
||||
})
|
||||
}}
|
||||
onClick={confirmSkipTask}
|
||||
disabled={
|
||||
notInCompletionWindow(chore) || chore.isActive === false
|
||||
}
|
||||
@@ -1348,6 +1483,52 @@ const ChoreView = () => {
|
||||
<ConfirmationModal config={confirmModelConfig} />
|
||||
<ConfirmationModal config={timerActionConfig} />
|
||||
<NoteViewerModal config={noteViewerConfig} />
|
||||
{activeModal === 'changeDueDate' && (
|
||||
<DueDatePickerModal
|
||||
open={true}
|
||||
title={t('choreView.changeDueDate', 'Change due date')}
|
||||
{...splitDueDate(chore.nextDueDate)}
|
||||
onClose={() => setActiveModal(null)}
|
||||
onApply={parts => {
|
||||
handleDueDateChange(combineDueDate(parts)?.toISOString() ?? null)
|
||||
setActiveModal(null)
|
||||
}}
|
||||
onRemove={() => {
|
||||
handleDueDateChange(null)
|
||||
setActiveModal(null)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{activeModal === 'changeAssignee' && (
|
||||
<SelectModal
|
||||
isOpen={true}
|
||||
options={performers}
|
||||
displayKey='displayName'
|
||||
title='Delegate to someone else'
|
||||
placeholder='Select a performer'
|
||||
onClose={() => setActiveModal(null)}
|
||||
onSave={selected => handleAssigneeChange(selected.id)}
|
||||
/>
|
||||
)}
|
||||
{activeModal === 'nudge' && (
|
||||
<NudgeModal
|
||||
config={{
|
||||
isOpen: true,
|
||||
choreId: chore.id,
|
||||
onClose: () => setActiveModal(null),
|
||||
onConfirm: handleNudge,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{activeModal === 'writeNFC' && (
|
||||
<WriteNFCModal
|
||||
config={{
|
||||
isOpen: true,
|
||||
url: getNFCUrl(choreId),
|
||||
onClose: () => setActiveModal(null),
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<AttachmentBrowserModal
|
||||
choreId={choreId}
|
||||
isOpen={attachmentBrowserOpen}
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
CheckBoxOutlineBlank,
|
||||
Close,
|
||||
Delete,
|
||||
FilterList,
|
||||
Label,
|
||||
Person,
|
||||
PriorityHigh,
|
||||
@@ -14,6 +15,7 @@ import {
|
||||
ViewModule,
|
||||
} from '@mui/icons-material'
|
||||
import {
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Container,
|
||||
@@ -202,6 +204,7 @@ const ArchivedTasks = () => {
|
||||
)
|
||||
|
||||
const {
|
||||
activeFilterCount,
|
||||
activeFilters,
|
||||
clearAll,
|
||||
filteredData: filteredByBar,
|
||||
@@ -209,6 +212,8 @@ const ArchivedTasks = () => {
|
||||
setFilter,
|
||||
} = useFilter(filteredChores, filterDefs)
|
||||
|
||||
const [isFilterSheetOpen, setIsFilterSheetOpen] = useState(false)
|
||||
|
||||
const [sortBy, setSortBy] = useState(
|
||||
() => localStorage.getItem('archivedChoresSortBy') || 'archivedAt',
|
||||
)
|
||||
@@ -770,6 +775,30 @@ const ArchivedTasks = () => {
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Filter Sheet Trigger */}
|
||||
<Badge
|
||||
badgeContent={activeFilterCount || null}
|
||||
color='primary'
|
||||
size='sm'
|
||||
anchorOrigin={{ vertical: 'top', horizontal: 'right' }}
|
||||
sx={{ display: 'flex', alignItems: 'center' }}
|
||||
>
|
||||
<IconButton
|
||||
variant={hasActiveFilters ? 'solid' : 'outlined'}
|
||||
color={hasActiveFilters ? 'primary' : 'neutral'}
|
||||
size='sm'
|
||||
sx={{
|
||||
height: 32,
|
||||
width: 32,
|
||||
borderRadius: '50%',
|
||||
}}
|
||||
onClick={() => setIsFilterSheetOpen(true)}
|
||||
title='Filters'
|
||||
>
|
||||
<FilterList />
|
||||
</IconButton>
|
||||
</Badge>
|
||||
|
||||
{/* Sort Menu */}
|
||||
<SortAndFilterMenu
|
||||
sortOptions={[
|
||||
@@ -845,6 +874,9 @@ const ArchivedTasks = () => {
|
||||
onClearAll={clearAll}
|
||||
resultCount={finalChores.length}
|
||||
totalCount={filteredChores.length}
|
||||
showTrigger={false}
|
||||
open={isFilterSheetOpen}
|
||||
onOpenChange={setIsFilterSheetOpen}
|
||||
/>
|
||||
|
||||
{/* Multi-select Toolbar */}
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
Delete,
|
||||
DriveFileMove,
|
||||
Edit,
|
||||
Flag,
|
||||
ManageSearch,
|
||||
MoreTime,
|
||||
MoreVert,
|
||||
@@ -25,6 +26,7 @@ import {
|
||||
import {
|
||||
Avatar,
|
||||
Button,
|
||||
Chip,
|
||||
Divider,
|
||||
IconButton,
|
||||
List,
|
||||
@@ -45,9 +47,21 @@ import LABEL_COLORS, {
|
||||
getTextColorFromBackgroundColor,
|
||||
} from '../../utils/Colors'
|
||||
import { isOfficialDonetickInstanceSync } from '../../utils/FeatureToggle'
|
||||
import Priorities from '../../utils/Priorities'
|
||||
import { getIconComponent } from '../../utils/ProjectIcons'
|
||||
import { useProjects } from '../Projects/ProjectQueries'
|
||||
|
||||
const NO_PRIORITY = { name: 'No priority', value: 0, color: 'neutral' }
|
||||
|
||||
// After hiding actions the caller does not support, the dividers around them
|
||||
// would otherwise stack up or dangle at the edges of the list.
|
||||
const collapseDividers = items =>
|
||||
items.filter((item, index) => {
|
||||
if (item.type !== 'divider') return true
|
||||
if (index === 0 || index === items.length - 1) return false
|
||||
return items[index - 1].type !== 'divider'
|
||||
})
|
||||
|
||||
const ChoreActionMenu = ({
|
||||
chore,
|
||||
onAction,
|
||||
@@ -55,18 +69,22 @@ const ChoreActionMenu = ({
|
||||
onCompleteWithPastDate,
|
||||
onChangeAssignee,
|
||||
onChangeDueDate,
|
||||
onChangePriority,
|
||||
onWriteNFC,
|
||||
onNudge,
|
||||
onDelete,
|
||||
onOpen,
|
||||
onMouseEnter,
|
||||
onMouseLeave,
|
||||
hiddenActions = [],
|
||||
trigger,
|
||||
sx = {},
|
||||
variant = 'soft',
|
||||
}) => {
|
||||
const [anchorEl, setAnchorEl] = React.useState(null)
|
||||
const [isOfficialInstance, setIsOfficialInstance] = useState(false)
|
||||
const [showProjectPicker, setShowProjectPicker] = useState(false)
|
||||
const [showPriorityPicker, setShowPriorityPicker] = useState(false)
|
||||
const menuRef = React.useRef(null)
|
||||
const navigate = useNavigate()
|
||||
const { data: projects = [] } = useProjects()
|
||||
@@ -118,6 +136,12 @@ const ChoreActionMenu = ({
|
||||
const handleMenuClose = () => {
|
||||
setAnchorEl(null)
|
||||
setShowProjectPicker(false)
|
||||
setShowPriorityPicker(false)
|
||||
}
|
||||
|
||||
const handleChangePriority = priority => {
|
||||
onChangePriority?.(priority)
|
||||
handleMenuClose()
|
||||
}
|
||||
|
||||
const handleMoveToProject = project => {
|
||||
@@ -245,6 +269,9 @@ const ChoreActionMenu = ({
|
||||
)
|
||||
}
|
||||
|
||||
const currentPriority =
|
||||
Priorities.find(p => p.value === chore?.priority) || null
|
||||
|
||||
// Shared action list, rendered as MenuItems on large screens and as a
|
||||
// ListItemButton list inside an AppModal sheet on small screens.
|
||||
const actionItems = [
|
||||
@@ -309,6 +336,21 @@ const ChoreActionMenu = ({
|
||||
handleMenuClose()
|
||||
},
|
||||
},
|
||||
onChangePriority && {
|
||||
key: 'priority',
|
||||
icon: <Flag />,
|
||||
label: 'Priority',
|
||||
onClick: () => setShowPriorityPicker(true),
|
||||
endDecorator: (
|
||||
<Chip
|
||||
size='sm'
|
||||
variant='soft'
|
||||
color={currentPriority?.color || 'neutral'}
|
||||
>
|
||||
{currentPriority?.name.trim() || 'None'}
|
||||
</Chip>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'writeNfc',
|
||||
icon: <Nfc />,
|
||||
@@ -342,7 +384,11 @@ const ChoreActionMenu = ({
|
||||
onClick: handleDelete,
|
||||
color: 'danger',
|
||||
},
|
||||
].filter(Boolean)
|
||||
]
|
||||
.filter(Boolean)
|
||||
.filter(item => !hiddenActions.includes(item.key))
|
||||
|
||||
const visibleActionItems = collapseDividers(actionItems)
|
||||
|
||||
const quickScheduleButtons = (
|
||||
<>
|
||||
@@ -415,7 +461,7 @@ const ChoreActionMenu = ({
|
||||
}
|
||||
|
||||
const renderMenuActionItems = () =>
|
||||
actionItems.map(item => {
|
||||
visibleActionItems.map(item => {
|
||||
if (item.type === 'divider') return <Divider key={item.key} />
|
||||
if (item.type === 'quickSchedule') {
|
||||
return (
|
||||
@@ -443,12 +489,17 @@ const ChoreActionMenu = ({
|
||||
>
|
||||
{item.icon}
|
||||
{item.label}
|
||||
{item.endDecorator && (
|
||||
<ListItemDecorator sx={{ ml: 'auto', minInlineSize: 0 }}>
|
||||
{item.endDecorator}
|
||||
</ListItemDecorator>
|
||||
)}
|
||||
</MenuItem>
|
||||
)
|
||||
})
|
||||
|
||||
const renderModalActionItems = () =>
|
||||
actionItems.map(item => {
|
||||
visibleActionItems.map(item => {
|
||||
if (item.type === 'divider') return <Divider key={item.key} />
|
||||
if (item.type === 'quickSchedule') {
|
||||
return (
|
||||
@@ -462,11 +513,60 @@ const ChoreActionMenu = ({
|
||||
<ListItemButton color={item.color} onClick={() => item.onClick()}>
|
||||
<ListItemDecorator>{item.icon}</ListItemDecorator>
|
||||
<ListItemContent>{item.label}</ListItemContent>
|
||||
{item.endDecorator}
|
||||
</ListItemButton>
|
||||
</ListItem>
|
||||
)
|
||||
})
|
||||
|
||||
const priorityOptions = [...Priorities, NO_PRIORITY]
|
||||
|
||||
const renderModalPriorityPicker = () =>
|
||||
priorityOptions.map(priority => (
|
||||
<ListItem key={priority.value}>
|
||||
<ListItemButton
|
||||
selected={(currentPriority?.value || 0) === priority.value}
|
||||
color={priority.color || 'neutral'}
|
||||
onClick={() => handleChangePriority(priority)}
|
||||
>
|
||||
<ListItemDecorator>{priority.icon || <Flag />}</ListItemDecorator>
|
||||
<ListItemContent>{priority.name.trim()}</ListItemContent>
|
||||
</ListItemButton>
|
||||
</ListItem>
|
||||
))
|
||||
|
||||
const renderMenuPriorityPicker = () => (
|
||||
<>
|
||||
<MenuItem
|
||||
onClick={e => {
|
||||
e.stopPropagation()
|
||||
setShowPriorityPicker(false)
|
||||
}}
|
||||
sx={{ gap: 1 }}
|
||||
>
|
||||
<ArrowBack fontSize='small' />
|
||||
<Typography level='body-sm' fontWeight={600}>
|
||||
Priority
|
||||
</Typography>
|
||||
</MenuItem>
|
||||
<Divider />
|
||||
{priorityOptions.map(priority => (
|
||||
<MenuItem
|
||||
key={priority.value}
|
||||
selected={(currentPriority?.value || 0) === priority.value}
|
||||
color={priority.color || 'neutral'}
|
||||
onClick={e => {
|
||||
e.stopPropagation()
|
||||
handleChangePriority(priority)
|
||||
}}
|
||||
>
|
||||
{priority.icon || <Flag />}
|
||||
{priority.name.trim()}
|
||||
</MenuItem>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
|
||||
const renderModalProjectPicker = () => (
|
||||
<>
|
||||
<ListItem>
|
||||
@@ -496,40 +596,57 @@ const ChoreActionMenu = ({
|
||||
|
||||
return (
|
||||
<>
|
||||
<IconButton
|
||||
variant={variant}
|
||||
color='success'
|
||||
onClick={handleMenuOpen}
|
||||
onMouseEnter={onMouseEnter}
|
||||
onMouseLeave={onMouseLeave}
|
||||
sx={{
|
||||
borderRadius: '50%',
|
||||
width: 25,
|
||||
height: 25,
|
||||
position: 'relative',
|
||||
left: -10,
|
||||
...sx,
|
||||
}}
|
||||
>
|
||||
<MoreVert />
|
||||
</IconButton>
|
||||
{trigger ? (
|
||||
React.cloneElement(trigger, {
|
||||
onClick: handleMenuOpen,
|
||||
onMouseEnter,
|
||||
onMouseLeave,
|
||||
})
|
||||
) : (
|
||||
<IconButton
|
||||
variant={variant}
|
||||
color='success'
|
||||
onClick={handleMenuOpen}
|
||||
onMouseEnter={onMouseEnter}
|
||||
onMouseLeave={onMouseLeave}
|
||||
sx={{
|
||||
borderRadius: '50%',
|
||||
width: 25,
|
||||
height: 25,
|
||||
position: 'relative',
|
||||
left: -10,
|
||||
...sx,
|
||||
}}
|
||||
>
|
||||
<MoreVert />
|
||||
</IconButton>
|
||||
)}
|
||||
|
||||
{isSmallScreen ? (
|
||||
<AppModal
|
||||
open={Boolean(anchorEl)}
|
||||
onClose={handleMenuClose}
|
||||
title={showProjectPicker ? 'Move to project' : chore?.name}
|
||||
title={
|
||||
showProjectPicker
|
||||
? 'Move to project'
|
||||
: showPriorityPicker
|
||||
? 'Priority'
|
||||
: chore?.name
|
||||
}
|
||||
mobilePresentation='sheet'
|
||||
showHandle
|
||||
contentSx={{ px: 0, pb: 1 }}
|
||||
>
|
||||
{showProjectPicker && (
|
||||
{(showProjectPicker || showPriorityPicker) && (
|
||||
<Button
|
||||
variant='plain'
|
||||
color='neutral'
|
||||
size='sm'
|
||||
startDecorator={<ArrowBack fontSize='small' />}
|
||||
onClick={() => setShowProjectPicker(false)}
|
||||
onClick={() => {
|
||||
setShowProjectPicker(false)
|
||||
setShowPriorityPicker(false)
|
||||
}}
|
||||
sx={{ mx: 2, mb: 1 }}
|
||||
>
|
||||
<Typography level='body-sm' fontWeight={600}>
|
||||
@@ -540,7 +657,9 @@ const ChoreActionMenu = ({
|
||||
<List sx={{ '--ListItem-radius': '8px', px: 1 }}>
|
||||
{showProjectPicker
|
||||
? renderModalProjectPicker()
|
||||
: renderModalActionItems()}
|
||||
: showPriorityPicker
|
||||
? renderModalPriorityPicker()
|
||||
: renderModalActionItems()}
|
||||
</List>
|
||||
</AppModal>
|
||||
) : (
|
||||
@@ -556,7 +675,9 @@ const ChoreActionMenu = ({
|
||||
left: '50%',
|
||||
}}
|
||||
>
|
||||
{showProjectPicker ? (
|
||||
{showPriorityPicker ? (
|
||||
renderMenuPriorityPicker()
|
||||
) : showProjectPicker ? (
|
||||
<>
|
||||
<MenuItem
|
||||
onClick={e => {
|
||||
|
||||
Reference in New Issue
Block a user