@@ -63,6 +63,8 @@
|
|||||||
"skip": "Skip",
|
"skip": "Skip",
|
||||||
"cancel": "Cancel",
|
"cancel": "Cancel",
|
||||||
"noPriority": "No Priority",
|
"noPriority": "No Priority",
|
||||||
|
"more": "More",
|
||||||
|
"changeDueDate": "Change due date",
|
||||||
"subtasks": "Subtasks",
|
"subtasks": "Subtasks",
|
||||||
"noDescription": "No description available",
|
"noDescription": "No description available",
|
||||||
"timer": {
|
"timer": {
|
||||||
@@ -103,7 +105,9 @@
|
|||||||
"showTasksFor": "Show tasks for",
|
"showTasksFor": "Show tasks for",
|
||||||
"type": {
|
"type": {
|
||||||
"assignee": "Assignee"
|
"assignee": "Assignee"
|
||||||
}
|
},
|
||||||
|
"display": "Display",
|
||||||
|
"displayOptions": "Display options"
|
||||||
},
|
},
|
||||||
"sort": {
|
"sort": {
|
||||||
"assignedToMe": "Assigned to me",
|
"assignedToMe": "Assigned to me",
|
||||||
|
|||||||
@@ -56,8 +56,17 @@
|
|||||||
"quickAction": "Quick action",
|
"quickAction": "Quick action",
|
||||||
"navigation": "Navigation",
|
"navigation": "Navigation",
|
||||||
"createTask": "Create a task",
|
"createTask": "Create a task",
|
||||||
|
"createLabel": "Create a label",
|
||||||
|
"createProject": "Create a project",
|
||||||
|
"createFilter": "Create a filter",
|
||||||
"viewAllTasks": "View all tasks",
|
"viewAllTasks": "View all tasks",
|
||||||
"viewArchivedTasks": "View archived tasks",
|
"viewArchivedTasks": "View archived tasks",
|
||||||
|
"viewThings": "View things",
|
||||||
|
"viewLabels": "View labels",
|
||||||
|
"viewProjects": "View projects",
|
||||||
|
"viewFilters": "View filters",
|
||||||
|
"viewActivities": "View activities",
|
||||||
|
"viewPoints": "View points",
|
||||||
"openSettings": "Open settings",
|
"openSettings": "Open settings",
|
||||||
"filterTasks": "Show tasks matching “{{query}}”",
|
"filterTasks": "Show tasks matching “{{query}}”",
|
||||||
"filterTasksSubtitle": "Filter the task list"
|
"filterTasksSubtitle": "Filter the task list"
|
||||||
|
|||||||
@@ -5,6 +5,36 @@
|
|||||||
"message": "Are you sure you want to delete this label? This will remove the label from all tasks."
|
"message": "Are you sure you want to delete this label? This will remove the label from all tasks."
|
||||||
},
|
},
|
||||||
"loadError": "Failed to load labels. Please try again.",
|
"loadError": "Failed to load labels. Please try again.",
|
||||||
|
"search": {
|
||||||
|
"placeholder": "Search labels",
|
||||||
|
"noResultsTitle": "No labels match",
|
||||||
|
"noResultsDescription": "No label matches \"{{searchTerm}}\".",
|
||||||
|
"noFilterResultsDescription": "No label matches the current filter.",
|
||||||
|
"clear": "Clear search",
|
||||||
|
"showAll": "Show all labels"
|
||||||
|
},
|
||||||
|
"detail": {
|
||||||
|
"taskCount_one": "{{count}} task",
|
||||||
|
"taskCount_other": "{{count}} tasks",
|
||||||
|
"labelActions": "Label actions",
|
||||||
|
"filters": {
|
||||||
|
"all": "All",
|
||||||
|
"overdue": "Overdue",
|
||||||
|
"today": "Today",
|
||||||
|
"undated": "No date"
|
||||||
|
},
|
||||||
|
"noMatchingStatus": "No task in this label is in that state right now.",
|
||||||
|
"clearFilters": "Show all tasks",
|
||||||
|
"searchPlaceholder": "Search tasks in this label",
|
||||||
|
"emptyTitle": "No tasks with this label",
|
||||||
|
"emptyDescription": "Nothing is tagged \"{{label}}\" yet. Add the label to a task and it will show up here.",
|
||||||
|
"browseTasks": "Browse tasks",
|
||||||
|
"noResultsTitle": "No tasks match",
|
||||||
|
"noResultsDescription": "No task in this label matches \"{{searchTerm}}\".",
|
||||||
|
"notFoundTitle": "Label not found",
|
||||||
|
"notFoundDescription": "This label may have been deleted or is no longer shared with you.",
|
||||||
|
"backToLabels": "Back to labels"
|
||||||
|
},
|
||||||
"blurb": "Manage your labels and organize your tasks effectively. Labels will be automatically shared with your circle if they are used on a shared task.",
|
"blurb": "Manage your labels and organize your tasks effectively. Labels will be automatically shared with your circle if they are used on a shared task.",
|
||||||
"modal": {
|
"modal": {
|
||||||
"errorEmptyName": "Name cannot be empty",
|
"errorEmptyName": "Name cannot be empty",
|
||||||
|
|||||||
@@ -12,6 +12,14 @@
|
|||||||
"message": "Are you sure you want to delete \"{{name}}\"? This will remove the project but keep all tasks (they'll move to the Default Project)."
|
"message": "Are you sure you want to delete \"{{name}}\"? This will remove the project but keep all tasks (they'll move to the Default Project)."
|
||||||
},
|
},
|
||||||
"loadError": "Failed to load projects. Please try again.",
|
"loadError": "Failed to load projects. Please try again.",
|
||||||
|
"search": {
|
||||||
|
"placeholder": "Search projects",
|
||||||
|
"noResultsTitle": "No projects match",
|
||||||
|
"noResultsDescription": "No project matches \"{{searchTerm}}\".",
|
||||||
|
"noFilterResultsDescription": "No project matches the current filter.",
|
||||||
|
"clear": "Clear search",
|
||||||
|
"showAll": "Show all projects"
|
||||||
|
},
|
||||||
"blurb": "Organize your tasks into projects. Create custom workspaces to keep your tasks organized and easily accessible.",
|
"blurb": "Organize your tasks into projects. Create custom workspaces to keep your tasks organized and easily accessible.",
|
||||||
"defaultDescription": "All tasks without a specific project",
|
"defaultDescription": "All tasks without a specific project",
|
||||||
"selector": {
|
"selector": {
|
||||||
|
|||||||
@@ -183,6 +183,7 @@ const AppModal = forwardRef(
|
|||||||
onClick={handleClose}
|
onClick={handleClose}
|
||||||
sx={{
|
sx={{
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
|
zIndex: 1,
|
||||||
top: isSheet && showHandle ? 6 : 12,
|
top: isSheet && showHandle ? 6 : 12,
|
||||||
right: { xs: 10, sm: 16 },
|
right: { xs: 10, sm: 16 },
|
||||||
borderRadius: '50%',
|
borderRadius: '50%',
|
||||||
|
|||||||
@@ -147,8 +147,19 @@ const FilterBar = ({
|
|||||||
onClearAll,
|
onClearAll,
|
||||||
resultCount,
|
resultCount,
|
||||||
totalCount,
|
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 ───────────────────────────────────────────────────────────
|
// ── Active count ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -293,65 +304,75 @@ const FilterBar = ({
|
|||||||
|
|
||||||
// ── Render ─────────────────────────────────────────────────────────────────
|
// ── 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 (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* ── Inline bar ─────────────────────────────────────── */}
|
{/* ── Inline bar ─────────────────────────────────────── */}
|
||||||
<Box
|
{showInlineBar && (
|
||||||
sx={{
|
<Box
|
||||||
display: 'flex',
|
sx={{
|
||||||
alignItems: 'center',
|
display: 'flex',
|
||||||
gap: 1,
|
alignItems: 'center',
|
||||||
flexWrap: 'wrap',
|
gap: 1,
|
||||||
mb: 2,
|
flexWrap: 'wrap',
|
||||||
}}
|
mb: 2,
|
||||||
>
|
}}
|
||||||
<Badge
|
|
||||||
badgeContent={activeFilterCount || null}
|
|
||||||
color='primary'
|
|
||||||
size='sm'
|
|
||||||
anchorOrigin={{ vertical: 'top', horizontal: 'right' }}
|
|
||||||
sx={{ display: 'flex', alignItems: 'center' }}
|
|
||||||
>
|
>
|
||||||
<Button
|
{showTrigger && (
|
||||||
size='md'
|
<Badge
|
||||||
variant={hasActive ? 'solid' : 'outlined'}
|
badgeContent={activeFilterCount || null}
|
||||||
color={hasActive ? 'primary' : 'neutral'}
|
color='primary'
|
||||||
startDecorator={<FilterList sx={{ fontSize: 16 }} />}
|
size='sm'
|
||||||
onClick={() => setIsOpen(true)}
|
anchorOrigin={{ vertical: 'top', horizontal: 'right' }}
|
||||||
sx={{
|
sx={{ display: 'flex', alignItems: 'center' }}
|
||||||
borderRadius: 'xl',
|
>
|
||||||
py: 0.5,
|
<Button
|
||||||
px: 1,
|
size='md'
|
||||||
gap: 0.5,
|
variant={hasActive ? 'solid' : 'outlined'}
|
||||||
alignItems: 'center',
|
color={hasActive ? 'primary' : 'neutral'}
|
||||||
'& .MuiButton-startDecorator': {
|
startDecorator={<FilterList sx={{ fontSize: 16 }} />}
|
||||||
display: 'flex',
|
onClick={() => setIsOpen(true)}
|
||||||
alignItems: 'center',
|
sx={{
|
||||||
mr: 0.5,
|
borderRadius: 'xl',
|
||||||
},
|
py: 0.5,
|
||||||
}}
|
px: 1,
|
||||||
>
|
gap: 0.5,
|
||||||
Filters
|
alignItems: 'center',
|
||||||
</Button>
|
'& .MuiButton-startDecorator': {
|
||||||
</Badge>
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
mr: 0.5,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Filters
|
||||||
|
</Button>
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
|
||||||
<ActiveFilterChips
|
<ActiveFilterChips
|
||||||
chips={filterDefs
|
chips={activeChips}
|
||||||
.map(def => ({ def, label: getActiveChipLabel(def) }))
|
onOpen={() => setIsOpen(true)}
|
||||||
.filter(({ label }) => !!label)
|
onClearAll={hasActive ? onClearAll : undefined}
|
||||||
.map(({ def, label }) => ({
|
resultCount={hasActive ? resultCount : undefined}
|
||||||
key: def.id,
|
totalCount={hasActive ? totalCount : undefined}
|
||||||
label,
|
maxVisible={2}
|
||||||
onClear: () => onSetFilter(def.id, null),
|
chipSize='md'
|
||||||
}))}
|
/>
|
||||||
onOpen={() => setIsOpen(true)}
|
</Box>
|
||||||
onClearAll={hasActive ? onClearAll : undefined}
|
)}
|
||||||
resultCount={hasActive ? resultCount : undefined}
|
|
||||||
totalCount={hasActive ? totalCount : undefined}
|
|
||||||
maxVisible={2}
|
|
||||||
chipSize='md'
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* ── Bottom sheet ────────────────────────────────────── */}
|
{/* ── Bottom sheet ────────────────────────────────────── */}
|
||||||
<AppModal
|
<AppModal
|
||||||
|
|||||||
256
src/components/common/SortAndFilterMenu.jsx
Normal file
256
src/components/common/SortAndFilterMenu.jsx
Normal file
@@ -0,0 +1,256 @@
|
|||||||
|
import { ArrowDownward, ArrowUpward, Check, Sort } from '@mui/icons-material'
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Divider,
|
||||||
|
IconButton,
|
||||||
|
ListItemContent,
|
||||||
|
ListItemDecorator,
|
||||||
|
Menu,
|
||||||
|
MenuItem,
|
||||||
|
Radio,
|
||||||
|
Typography,
|
||||||
|
} from '@mui/joy'
|
||||||
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compact sort + filter menu, meant to sit next to a search input.
|
||||||
|
*
|
||||||
|
* Props:
|
||||||
|
* sortOptions - [{ name, value }] shown under the sort header
|
||||||
|
* selectedSort - currently selected sort value
|
||||||
|
* onSortChange - (value) => void
|
||||||
|
* sortDirection - 'asc' | 'desc'
|
||||||
|
* onSortDirectionChange - (direction) => void
|
||||||
|
* filterTitle - optional header for the filter section
|
||||||
|
* filterOptions - optional [{ name, value }] rendered as radios
|
||||||
|
* selectedFilter - currently selected filter value
|
||||||
|
* onFilterChange - (value) => void
|
||||||
|
* isActive - highlights the trigger button when a non-default choice is on
|
||||||
|
*/
|
||||||
|
const SortAndFilterMenu = ({
|
||||||
|
filterOptions,
|
||||||
|
filterTitle,
|
||||||
|
icon = <Sort />,
|
||||||
|
isActive,
|
||||||
|
onFilterChange,
|
||||||
|
onSortChange,
|
||||||
|
onSortDirectionChange,
|
||||||
|
selectedFilter,
|
||||||
|
selectedSort,
|
||||||
|
sortDirection = 'asc',
|
||||||
|
sortOptions = [],
|
||||||
|
title = 'Sort by',
|
||||||
|
}) => {
|
||||||
|
const [anchorEl, setAnchorEl] = useState(null)
|
||||||
|
const menuRef = useRef(null)
|
||||||
|
const buttonRef = useRef(null)
|
||||||
|
|
||||||
|
const handleMenuClose = () => setAnchorEl(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleMenuOutsideClick = event => {
|
||||||
|
if (
|
||||||
|
menuRef.current &&
|
||||||
|
!menuRef.current.contains(event.target) &&
|
||||||
|
!buttonRef.current?.contains(event.target)
|
||||||
|
) {
|
||||||
|
handleMenuClose()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('mousedown', handleMenuOutsideClick)
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('mousedown', handleMenuOutsideClick)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const SectionHeader = ({ children }) => (
|
||||||
|
<MenuItem
|
||||||
|
disabled
|
||||||
|
sx={{
|
||||||
|
borderRadius: 'var(--joy-radius-sm)',
|
||||||
|
cursor: 'default',
|
||||||
|
opacity: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ListItemContent>
|
||||||
|
<Typography level='title-sm' sx={{ fontWeight: 600 }}>
|
||||||
|
{children}
|
||||||
|
</Typography>
|
||||||
|
</ListItemContent>
|
||||||
|
</MenuItem>
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<IconButton
|
||||||
|
ref={buttonRef}
|
||||||
|
onClick={event => setAnchorEl(anchorEl ? null : event.currentTarget)}
|
||||||
|
variant='outlined'
|
||||||
|
color={isActive ? 'primary' : 'neutral'}
|
||||||
|
size='sm'
|
||||||
|
sx={{ height: 32, width: 32, borderRadius: '50%', flexShrink: 0 }}
|
||||||
|
aria-label='Sort and filter options'
|
||||||
|
title='Sort & Filter'
|
||||||
|
>
|
||||||
|
{icon}
|
||||||
|
</IconButton>
|
||||||
|
|
||||||
|
<Menu
|
||||||
|
ref={menuRef}
|
||||||
|
anchorEl={anchorEl}
|
||||||
|
open={Boolean(anchorEl)}
|
||||||
|
onClose={handleMenuClose}
|
||||||
|
placement='bottom-end'
|
||||||
|
sx={{
|
||||||
|
minWidth: 240,
|
||||||
|
p: 1,
|
||||||
|
'--List-gap': '4px',
|
||||||
|
boxShadow: 'var(--joy-shadow-lg)',
|
||||||
|
border: '1px solid var(--joy-palette-divider)',
|
||||||
|
borderRadius: 'var(--joy-radius-md)',
|
||||||
|
zIndex: 1300,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SectionHeader>{title}</SectionHeader>
|
||||||
|
<Divider sx={{ my: 1 }} />
|
||||||
|
|
||||||
|
{sortOptions.map(option => (
|
||||||
|
<MenuItem
|
||||||
|
key={option.value}
|
||||||
|
onClick={() => {
|
||||||
|
onSortChange(option.value)
|
||||||
|
handleMenuClose()
|
||||||
|
}}
|
||||||
|
sx={{
|
||||||
|
borderRadius: 'var(--joy-radius-sm)',
|
||||||
|
backgroundColor:
|
||||||
|
selectedSort === option.value
|
||||||
|
? 'var(--joy-palette-primary-softBg)'
|
||||||
|
: 'transparent',
|
||||||
|
'&:hover': {
|
||||||
|
backgroundColor:
|
||||||
|
selectedSort === option.value
|
||||||
|
? 'var(--joy-palette-primary-softBg)'
|
||||||
|
: 'var(--joy-palette-neutral-softHoverBg)',
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ListItemContent>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography
|
||||||
|
level='body-sm'
|
||||||
|
sx={{
|
||||||
|
fontWeight: selectedSort === option.value ? 600 : 400,
|
||||||
|
color:
|
||||||
|
selectedSort === option.value
|
||||||
|
? 'var(--joy-palette-primary-600)'
|
||||||
|
: 'var(--joy-palette-text-primary)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{option.name}
|
||||||
|
</Typography>
|
||||||
|
{selectedSort === option.value && (
|
||||||
|
<Check
|
||||||
|
sx={{
|
||||||
|
fontSize: '16px',
|
||||||
|
color: 'var(--joy-palette-primary-500)',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</ListItemContent>
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{onSortDirectionChange && (
|
||||||
|
<>
|
||||||
|
<Divider sx={{ my: 1 }} />
|
||||||
|
<MenuItem
|
||||||
|
onClick={() =>
|
||||||
|
onSortDirectionChange(sortDirection === 'asc' ? 'desc' : 'asc')
|
||||||
|
}
|
||||||
|
sx={{
|
||||||
|
borderRadius: 'var(--joy-radius-sm)',
|
||||||
|
'&:hover': {
|
||||||
|
backgroundColor: 'var(--joy-palette-neutral-softHoverBg)',
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ListItemDecorator>
|
||||||
|
{sortDirection === 'asc' ? (
|
||||||
|
<ArrowUpward sx={{ fontSize: '18px' }} />
|
||||||
|
) : (
|
||||||
|
<ArrowDownward sx={{ fontSize: '18px' }} />
|
||||||
|
)}
|
||||||
|
</ListItemDecorator>
|
||||||
|
<ListItemContent>
|
||||||
|
<Typography level='body-sm'>
|
||||||
|
{sortDirection === 'asc' ? 'Ascending' : 'Descending'}
|
||||||
|
</Typography>
|
||||||
|
</ListItemContent>
|
||||||
|
</MenuItem>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{filterOptions?.length > 0 && (
|
||||||
|
<>
|
||||||
|
<Divider sx={{ my: 1 }} />
|
||||||
|
<SectionHeader>{filterTitle || 'Filter'}</SectionHeader>
|
||||||
|
{filterOptions.map(option => (
|
||||||
|
<MenuItem
|
||||||
|
key={option.value}
|
||||||
|
onClick={() => {
|
||||||
|
onFilterChange(option.value)
|
||||||
|
handleMenuClose()
|
||||||
|
}}
|
||||||
|
sx={{
|
||||||
|
borderRadius: 'var(--joy-radius-sm)',
|
||||||
|
backgroundColor:
|
||||||
|
selectedFilter === option.value
|
||||||
|
? 'var(--joy-palette-primary-softBg)'
|
||||||
|
: 'transparent',
|
||||||
|
'&:hover': {
|
||||||
|
backgroundColor:
|
||||||
|
selectedFilter === option.value
|
||||||
|
? 'var(--joy-palette-primary-softBg)'
|
||||||
|
: 'var(--joy-palette-neutral-softHoverBg)',
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ListItemDecorator>
|
||||||
|
<Radio
|
||||||
|
checked={selectedFilter === option.value}
|
||||||
|
variant='outlined'
|
||||||
|
/>
|
||||||
|
</ListItemDecorator>
|
||||||
|
<ListItemContent>
|
||||||
|
<Typography
|
||||||
|
level='body-sm'
|
||||||
|
sx={{
|
||||||
|
fontWeight: selectedFilter === option.value ? 600 : 400,
|
||||||
|
color:
|
||||||
|
selectedFilter === option.value
|
||||||
|
? 'var(--joy-palette-primary-600)'
|
||||||
|
: 'var(--joy-palette-text-primary)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{option.name}
|
||||||
|
</Typography>
|
||||||
|
</ListItemContent>
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Menu>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SortAndFilterMenu
|
||||||
@@ -27,6 +27,7 @@ import JoinCircleView from '../views/Circles/JoinCircle'
|
|||||||
import NotFound from '../views/components/NotFound'
|
import NotFound from '../views/components/NotFound'
|
||||||
import FilterView from '../views/Filters/FilterView'
|
import FilterView from '../views/Filters/FilterView'
|
||||||
import ChoreHistory from '../views/History/ChoreHistory'
|
import ChoreHistory from '../views/History/ChoreHistory'
|
||||||
|
import LabelDetailView from '../views/Labels/LabelDetailView'
|
||||||
import LabelView from '../views/Labels/LabelView'
|
import LabelView from '../views/Labels/LabelView'
|
||||||
import Landing from '../views/Landing/Landing'
|
import Landing from '../views/Landing/Landing'
|
||||||
import CircleSetupView from '../views/Onboarding/CircleSetupView'
|
import CircleSetupView from '../views/Onboarding/CircleSetupView'
|
||||||
@@ -262,6 +263,10 @@ const Router = createBrowserRouter([
|
|||||||
path: 'labels/',
|
path: 'labels/',
|
||||||
element: <LabelView />,
|
element: <LabelView />,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'labels/:labelId',
|
||||||
|
element: <LabelDetailView />,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'projects/',
|
path: 'projects/',
|
||||||
element: <ProjectView />,
|
element: <ProjectView />,
|
||||||
|
|||||||
@@ -152,7 +152,7 @@ export const GlobalSearchProvider = ({ children }) => {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onKeyDown = event => {
|
const onKeyDown = event => {
|
||||||
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'f') {
|
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'k') {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
isOpen ? closeSearch() : openSearch()
|
isOpen ? closeSearch() : openSearch()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import {
|
import {
|
||||||
AddRounded,
|
AddRounded,
|
||||||
|
ArchiveOutlined,
|
||||||
CheckCircleOutline,
|
CheckCircleOutline,
|
||||||
|
FilterAltOutlined,
|
||||||
FolderOutlined,
|
FolderOutlined,
|
||||||
HistoryRounded,
|
HistoryRounded,
|
||||||
InboxOutlined,
|
InboxOutlined,
|
||||||
@@ -8,6 +10,8 @@ import {
|
|||||||
PersonOutline,
|
PersonOutline,
|
||||||
SearchRounded,
|
SearchRounded,
|
||||||
SettingsOutlined,
|
SettingsOutlined,
|
||||||
|
TollOutlined,
|
||||||
|
WidgetsOutlined,
|
||||||
} from '@mui/icons-material'
|
} from '@mui/icons-material'
|
||||||
import {
|
import {
|
||||||
Box,
|
Box,
|
||||||
@@ -56,29 +60,106 @@ const buildQuickActions = t => [
|
|||||||
provider: 'actions',
|
provider: 'actions',
|
||||||
title: t('search.actions.createTask'),
|
title: t('search.actions.createTask'),
|
||||||
subtitle: t('search.actions.quickAction'),
|
subtitle: t('search.actions.quickAction'),
|
||||||
route: '/chores/create',
|
keywords: 'new task chore add create',
|
||||||
|
// Reuses the widget deep-link param so this lands on the task list with the
|
||||||
|
// quick-add modal open, instead of the full create page.
|
||||||
|
route: '/chores?add_task=1',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'action:tasks',
|
id: 'action:create-label',
|
||||||
provider: 'actions',
|
provider: 'actions',
|
||||||
title: t('search.actions.viewAllTasks'),
|
title: t('search.actions.createLabel'),
|
||||||
subtitle: t('search.actions.navigation'),
|
subtitle: t('search.actions.quickAction'),
|
||||||
route: '/chores',
|
keywords: 'new label tag add create',
|
||||||
|
route: '/labels?create=1',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'action:archived',
|
id: 'action:create-project',
|
||||||
provider: 'actions',
|
provider: 'actions',
|
||||||
title: t('search.actions.viewArchivedTasks'),
|
title: t('search.actions.createProject'),
|
||||||
subtitle: t('search.actions.navigation'),
|
subtitle: t('search.actions.quickAction'),
|
||||||
route: '/archived',
|
keywords: 'new project folder add create',
|
||||||
|
route: '/projects?create=1',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'action:settings',
|
id: 'action:create-filter',
|
||||||
provider: 'actions',
|
provider: 'actions',
|
||||||
title: t('search.actions.openSettings'),
|
title: t('search.actions.createFilter'),
|
||||||
subtitle: t('search.actions.navigation'),
|
subtitle: t('search.actions.quickAction'),
|
||||||
route: '/settings',
|
keywords: 'new filter view saved search add create',
|
||||||
|
route: '/filters?create=1',
|
||||||
},
|
},
|
||||||
|
// Every destination in the nav drawer is reachable from here, so the palette
|
||||||
|
// is a complete way to move around the app without opening the drawer.
|
||||||
|
...[
|
||||||
|
{
|
||||||
|
id: 'action:tasks',
|
||||||
|
title: t('search.actions.viewAllTasks'),
|
||||||
|
keywords: 'tasks chores list all open',
|
||||||
|
route: '/chores',
|
||||||
|
icon: <InboxOutlined />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'action:archived',
|
||||||
|
title: t('search.actions.viewArchivedTasks'),
|
||||||
|
keywords: 'archive archived tasks completed open',
|
||||||
|
route: '/archived',
|
||||||
|
icon: <ArchiveOutlined />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'action:things',
|
||||||
|
title: t('search.actions.viewThings'),
|
||||||
|
keywords: 'things devices sensors trackers state open',
|
||||||
|
route: '/things',
|
||||||
|
icon: <WidgetsOutlined />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'action:labels',
|
||||||
|
title: t('search.actions.viewLabels'),
|
||||||
|
keywords: 'labels tags open',
|
||||||
|
route: '/labels',
|
||||||
|
icon: <LabelOutlined />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'action:projects',
|
||||||
|
title: t('search.actions.viewProjects'),
|
||||||
|
keywords: 'projects folders groups open',
|
||||||
|
route: '/projects',
|
||||||
|
icon: <FolderOutlined />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'action:filters',
|
||||||
|
title: t('search.actions.viewFilters'),
|
||||||
|
keywords: 'filters saved views open',
|
||||||
|
route: '/filters',
|
||||||
|
icon: <FilterAltOutlined />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'action:activities',
|
||||||
|
title: t('search.actions.viewActivities'),
|
||||||
|
keywords: 'activities history timeline log open',
|
||||||
|
route: '/activities',
|
||||||
|
icon: <HistoryRounded />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'action:points',
|
||||||
|
title: t('search.actions.viewPoints'),
|
||||||
|
keywords: 'points rewards score leaderboard open',
|
||||||
|
route: '/points',
|
||||||
|
icon: <TollOutlined />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'action:settings',
|
||||||
|
title: t('search.actions.openSettings'),
|
||||||
|
keywords: 'settings preferences configuration open',
|
||||||
|
route: '/settings',
|
||||||
|
icon: <SettingsOutlined />,
|
||||||
|
},
|
||||||
|
].map(action => ({
|
||||||
|
...action,
|
||||||
|
provider: 'actions',
|
||||||
|
subtitle: t('search.actions.navigation'),
|
||||||
|
})),
|
||||||
]
|
]
|
||||||
|
|
||||||
const readRecents = () => {
|
const readRecents = () => {
|
||||||
@@ -192,6 +273,22 @@ const GlobalSearchPalette = ({
|
|||||||
const [recents] = useState(readRecents)
|
const [recents] = useState(readRecents)
|
||||||
const selectedResultRef = useRef(null)
|
const selectedResultRef = useRef(null)
|
||||||
|
|
||||||
|
const quickActions = useMemo(() => buildQuickActions(t), [t])
|
||||||
|
const quickActionIndex = useMemo(
|
||||||
|
() =>
|
||||||
|
new Fuse(quickActions, {
|
||||||
|
threshold: 0.38,
|
||||||
|
distance: 120,
|
||||||
|
ignoreLocation: true,
|
||||||
|
includeScore: true,
|
||||||
|
keys: [
|
||||||
|
{ name: 'title', weight: 0.7 },
|
||||||
|
{ name: 'keywords', weight: 0.3 },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
[quickActions],
|
||||||
|
)
|
||||||
|
|
||||||
const searchIndexes = useMemo(
|
const searchIndexes = useMemo(
|
||||||
() =>
|
() =>
|
||||||
new Map(
|
new Map(
|
||||||
@@ -227,7 +324,7 @@ const GlobalSearchPalette = ({
|
|||||||
const recentResults = recents
|
const recentResults = recents
|
||||||
.map(item => currentById.get(item.id) || item)
|
.map(item => currentById.get(item.id) || item)
|
||||||
.filter(item => item.provider !== 'history' || currentById.has(item.id))
|
.filter(item => item.provider !== 'history' || currentById.has(item.id))
|
||||||
return [...recentResults, ...buildQuickActions(t)]
|
return [...recentResults, ...quickActions]
|
||||||
}
|
}
|
||||||
|
|
||||||
const grouped = GROUPS.filter(group => group !== 'actions').flatMap(group =>
|
const grouped = GROUPS.filter(group => group !== 'actions').flatMap(group =>
|
||||||
@@ -250,15 +347,40 @@ const GlobalSearchPalette = ({
|
|||||||
})
|
})
|
||||||
.sort((a, b) => a.score - b.score),
|
.sort((a, b) => a.score - b.score),
|
||||||
)
|
)
|
||||||
grouped.push({
|
const actionMatches = (
|
||||||
id: 'action:filter-tasks',
|
quickActionIndex.search(normalized, { limit: 4 }) || []
|
||||||
provider: 'actions',
|
)
|
||||||
title: t('search.actions.filterTasks', { query: query.trim() }),
|
.map(match => ({ ...match.item, score: match.score ?? 1 }))
|
||||||
subtitle: t('search.actions.filterTasksSubtitle'),
|
.sort((a, b) => a.score - b.score)
|
||||||
route: `/chores?search=${encodeURIComponent(query.trim())}`,
|
|
||||||
})
|
// An action whose title the query starts spelling out ("create la…") is
|
||||||
return grouped
|
// what the person is after, so it leads. Anything matched only through its
|
||||||
}, [documents, query, recents, searchIndexes, t])
|
// keywords stays below the real content it shares words with.
|
||||||
|
const leadingActions = actionMatches.filter(action =>
|
||||||
|
action.title.toLocaleLowerCase().startsWith(normalized),
|
||||||
|
)
|
||||||
|
const trailingActions = actionMatches.filter(
|
||||||
|
action => !leadingActions.includes(action),
|
||||||
|
)
|
||||||
|
|
||||||
|
return [
|
||||||
|
...leadingActions,
|
||||||
|
...grouped,
|
||||||
|
...trailingActions,
|
||||||
|
{
|
||||||
|
id: 'action:filter-tasks',
|
||||||
|
provider: 'actions',
|
||||||
|
title: t('search.actions.filterTasks', { query: query.trim() }),
|
||||||
|
subtitle: t('search.actions.filterTasksSubtitle'),
|
||||||
|
route: `/chores?search=${encodeURIComponent(query.trim())}`,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}, [documents, query, quickActionIndex, recents, searchIndexes, t])
|
||||||
|
|
||||||
|
// Everything except the always-present "filter the task list" fallback.
|
||||||
|
const matchCount = results.filter(
|
||||||
|
result => result.id !== 'action:filter-tasks',
|
||||||
|
).length
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
selectedResultRef.current?.scrollIntoView({
|
selectedResultRef.current?.scrollIntoView({
|
||||||
@@ -339,7 +461,7 @@ const GlobalSearchPalette = ({
|
|||||||
pb: 'var(--safe-area-inset-bottom, 0px)',
|
pb: 'var(--safe-area-inset-bottom, 0px)',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{!isLoading && query.trim() && results.length === 1 && (
|
{!isLoading && query.trim() && matchCount === 0 && (
|
||||||
<Box sx={{ px: 3, py: 6, textAlign: 'center' }}>
|
<Box sx={{ px: 3, py: 6, textAlign: 'center' }}>
|
||||||
<InboxOutlined
|
<InboxOutlined
|
||||||
sx={{ fontSize: 36, color: 'text.tertiary', mb: 1 }}
|
sx={{ fontSize: 36, color: 'text.tertiary', mb: 1 }}
|
||||||
@@ -393,7 +515,7 @@ const GlobalSearchPalette = ({
|
|||||||
<ListItemDecorator
|
<ListItemDecorator
|
||||||
sx={{ mt: 0.25, color: result.color || 'text.secondary' }}
|
sx={{ mt: 0.25, color: result.color || 'text.secondary' }}
|
||||||
>
|
>
|
||||||
{ICONS[result.provider]}
|
{result.icon || ICONS[result.provider]}
|
||||||
</ListItemDecorator>
|
</ListItemDecorator>
|
||||||
<ListItemContent>
|
<ListItemContent>
|
||||||
<Typography
|
<Typography
|
||||||
@@ -434,9 +556,7 @@ const GlobalSearchPalette = ({
|
|||||||
<Typography level='body-xs'>↵ {t('search.footer.open')}</Typography>
|
<Typography level='body-xs'>↵ {t('search.footer.open')}</Typography>
|
||||||
<Typography level='body-xs' sx={{ ml: 'auto' }}>
|
<Typography level='body-xs' sx={{ ml: 'auto' }}>
|
||||||
{query.trim()
|
{query.trim()
|
||||||
? t('search.footer.results', {
|
? t('search.footer.results', { count: matchCount })
|
||||||
count: Math.max(0, results.length - 1),
|
|
||||||
})
|
|
||||||
: t('search.footer.typeToSearch')}
|
: t('search.footer.typeToSearch')}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -1,13 +1,5 @@
|
|||||||
import { SETTINGS_SECTIONS } from '../constants/settingsSections'
|
import { SETTINGS_SECTIONS } from '../constants/settingsSections'
|
||||||
|
import { stripHtml } from '../utils/Helpers'
|
||||||
const stripHtml = value => {
|
|
||||||
if (!value) return ''
|
|
||||||
if (typeof globalThis.document === 'undefined')
|
|
||||||
return String(value).replace(/<[^>]*>/g, ' ')
|
|
||||||
const element = globalThis.document.createElement('div')
|
|
||||||
element.innerHTML = String(value)
|
|
||||||
return element.textContent || element.innerText || ''
|
|
||||||
}
|
|
||||||
|
|
||||||
const HISTORY_STATUS = {
|
const HISTORY_STATUS = {
|
||||||
0: 'in progress',
|
0: 'in progress',
|
||||||
@@ -125,7 +117,7 @@ registerSearchProvider({
|
|||||||
title: label.name || 'Untitled label',
|
title: label.name || 'Untitled label',
|
||||||
subtitle: 'Label',
|
subtitle: 'Label',
|
||||||
keywords: 'tag label',
|
keywords: 'tag label',
|
||||||
route: '/labels',
|
route: `/labels/${label.id}`,
|
||||||
color: label.color,
|
color: label.color,
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -249,13 +249,28 @@ export const submitErrorReport = async ({
|
|||||||
description,
|
description,
|
||||||
report,
|
report,
|
||||||
}) => {
|
}) => {
|
||||||
|
// The relay rejects reports without an error. Manual bug reports have no
|
||||||
|
// thrown Error, so mark only the submitted copy while preserving the local
|
||||||
|
// diagnostics as a manual report.
|
||||||
|
const submittedReport =
|
||||||
|
report.kind === 'bug'
|
||||||
|
? {
|
||||||
|
...report,
|
||||||
|
error: {
|
||||||
|
...report.error,
|
||||||
|
name: 'ManualBugReport',
|
||||||
|
message: 'Submitted manually from the app',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: report
|
||||||
|
|
||||||
const payload = {
|
const payload = {
|
||||||
source: 'donetick-app',
|
source: 'donetick-app',
|
||||||
kind: report.kind === 'bug' ? 'bug-report' : 'error-report',
|
kind: 'error-report',
|
||||||
reportId: report.reportId,
|
reportId: report.reportId,
|
||||||
description: description?.trim() || null,
|
description: description?.trim() || null,
|
||||||
contactEmail: contactEmail?.trim() || null,
|
contactEmail: contactEmail?.trim() || null,
|
||||||
report,
|
report: submittedReport,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Enforced here, not only in the UI, so no future caller can relay a
|
// Enforced here, not only in the UI, so no future caller can relay a
|
||||||
|
|||||||
@@ -1,10 +1,22 @@
|
|||||||
import moment from 'moment'
|
import moment from 'moment'
|
||||||
|
|
||||||
import { apiClient } from './ApiClient'
|
import { apiClient } from './ApiClient'
|
||||||
|
|
||||||
const isPlusAccount = userProfile => {
|
const isPlusAccount = userProfile => {
|
||||||
return userProfile?.expiration && moment(userProfile?.expiration).isAfter()
|
return userProfile?.expiration && moment(userProfile?.expiration).isAfter()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Turns rich-text/HTML content (task descriptions, notes) into plain text so it
|
||||||
|
// can be indexed or matched by search.
|
||||||
|
const stripHtml = value => {
|
||||||
|
if (!value) return ''
|
||||||
|
if (typeof globalThis.document === 'undefined')
|
||||||
|
return String(value).replace(/<[^>]*>/g, ' ')
|
||||||
|
const element = globalThis.document.createElement('div')
|
||||||
|
element.innerHTML = String(value)
|
||||||
|
return element.textContent || element.innerText || ''
|
||||||
|
}
|
||||||
|
|
||||||
const resolvePhotoURL = url => {
|
const resolvePhotoURL = url => {
|
||||||
if (!url) return ''
|
if (!url) return ''
|
||||||
if (url.startsWith('http') || url.startsWith('https')) {
|
if (url.startsWith('http') || url.startsWith('https')) {
|
||||||
@@ -83,4 +95,5 @@ export {
|
|||||||
isPlusAccount,
|
isPlusAccount,
|
||||||
isSignedUrlExpired,
|
isSignedUrlExpired,
|
||||||
resolvePhotoURL,
|
resolvePhotoURL,
|
||||||
|
stripHtml,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { Capacitor } from '@capacitor/core'
|
||||||
import {
|
import {
|
||||||
Archive,
|
Archive,
|
||||||
AttachFile,
|
AttachFile,
|
||||||
@@ -7,7 +8,7 @@ import {
|
|||||||
Edit,
|
Edit,
|
||||||
History,
|
History,
|
||||||
HourglassEmpty,
|
HourglassEmpty,
|
||||||
LowPriority,
|
MoreVert,
|
||||||
OpenInFull,
|
OpenInFull,
|
||||||
PeopleAlt,
|
PeopleAlt,
|
||||||
Person,
|
Person,
|
||||||
@@ -25,18 +26,13 @@ import {
|
|||||||
Checkbox,
|
Checkbox,
|
||||||
Chip,
|
Chip,
|
||||||
Container,
|
Container,
|
||||||
Dropdown,
|
|
||||||
FormControl,
|
FormControl,
|
||||||
Grid,
|
Grid,
|
||||||
IconButton,
|
IconButton,
|
||||||
Input,
|
Input,
|
||||||
Menu,
|
|
||||||
MenuButton,
|
|
||||||
MenuItem,
|
|
||||||
Sheet,
|
Sheet,
|
||||||
Typography,
|
Typography,
|
||||||
} from '@mui/joy'
|
} from '@mui/joy'
|
||||||
import { Divider } from '@mui/material'
|
|
||||||
import { useQueryClient } from '@tanstack/react-query'
|
import { useQueryClient } from '@tanstack/react-query'
|
||||||
import moment from 'moment'
|
import moment from 'moment'
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
@@ -68,20 +64,33 @@ import { getTextColorFromBackgroundColor } from '../../utils/Colors.jsx'
|
|||||||
import { commandQueue, CommandType } from '../../utils/CommandQueue'
|
import { commandQueue, CommandType } from '../../utils/CommandQueue'
|
||||||
import {
|
import {
|
||||||
ApproveChore,
|
ApproveChore,
|
||||||
|
ArchiveChore,
|
||||||
|
DeleteChore,
|
||||||
GetChoreDetailById,
|
GetChoreDetailById,
|
||||||
MarkChoreComplete,
|
MarkChoreComplete,
|
||||||
|
NudgeChore,
|
||||||
RejectChore,
|
RejectChore,
|
||||||
|
SaveChore,
|
||||||
SkipChore,
|
SkipChore,
|
||||||
UnArchiveChore,
|
UnArchiveChore,
|
||||||
UndoChoreAction,
|
UndoChoreAction,
|
||||||
|
UpdateChoreAssignee,
|
||||||
UpdateChorePriority,
|
UpdateChorePriority,
|
||||||
|
UpdateDueDate,
|
||||||
} from '../../utils/Fetcher'
|
} from '../../utils/Fetcher'
|
||||||
import { offlineDB } from '../../utils/OfflineDB'
|
import { offlineDB } from '../../utils/OfflineDB'
|
||||||
import Priorities from '../../utils/Priorities'
|
|
||||||
import { getSafeBottomPadding } from '../../utils/SafeAreaUtils.js'
|
import { getSafeBottomPadding } from '../../utils/SafeAreaUtils.js'
|
||||||
import AttachmentBrowserModal from '../Modals/Inputs/AttachmentBrowserModal'
|
import AttachmentBrowserModal from '../Modals/Inputs/AttachmentBrowserModal'
|
||||||
import ConfirmationModal from '../Modals/Inputs/ConfirmationModal'
|
import ConfirmationModal from '../Modals/Inputs/ConfirmationModal'
|
||||||
import NoteViewerModal from '../Modals/Inputs/NoteViewerModal'
|
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 LoadingComponent from '../components/Loading.jsx'
|
||||||
import PendingBadge from '../components/PendingBadge'
|
import PendingBadge from '../components/PendingBadge'
|
||||||
import RichTextEditor from '../components/RichTextEditor.jsx'
|
import RichTextEditor from '../components/RichTextEditor.jsx'
|
||||||
@@ -106,6 +115,11 @@ const decodeHtmlEntities = value => {
|
|||||||
|
|
||||||
const hasHtmlTags = value => /<\/?[a-z][\s\S]*>/i.test(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 ChoreView = () => {
|
||||||
const { t } = useTranslation('chores')
|
const { t } = useTranslation('chores')
|
||||||
const { fmt } = useLocalization()
|
const { fmt } = useLocalization()
|
||||||
@@ -124,7 +138,7 @@ const ChoreView = () => {
|
|||||||
const [confirmModelConfig, setConfirmModelConfig] = useState({
|
const [confirmModelConfig, setConfirmModelConfig] = useState({
|
||||||
isOpen: false,
|
isOpen: false,
|
||||||
})
|
})
|
||||||
const [chorePriority, setChorePriority] = useState(null)
|
const [activeModal, setActiveModal] = useState(null)
|
||||||
const [noteViewerConfig, setNoteViewerConfig] = useState({ isOpen: false })
|
const [noteViewerConfig, setNoteViewerConfig] = useState({ isOpen: false })
|
||||||
const [timerActionConfig, setTimerActionConfig] = useState({ isOpen: false })
|
const [timerActionConfig, setTimerActionConfig] = useState({ isOpen: false })
|
||||||
const [attachmentBrowserOpen, setAttachmentBrowserOpen] = useState(false)
|
const [attachmentBrowserOpen, setAttachmentBrowserOpen] = useState(false)
|
||||||
@@ -165,7 +179,6 @@ const ChoreView = () => {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
setChore(choreData.res)
|
setChore(choreData.res)
|
||||||
setChorePriority(Priorities.find(p => p.value === choreData.res.priority))
|
|
||||||
document.title = 'Donetick: ' + choreData.res.name
|
document.title = 'Donetick: ' + choreData.res.name
|
||||||
|
|
||||||
setPerformers(circleMembersData.res)
|
setPerformers(circleMembersData.res)
|
||||||
@@ -236,7 +249,7 @@ const ChoreView = () => {
|
|||||||
UpdateChorePriority(choreId, priority.value).then(response => {
|
UpdateChorePriority(choreId, priority.value).then(response => {
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
response.json().then(() => {
|
response.json().then(() => {
|
||||||
setChorePriority(priority)
|
setChore(prev => ({ ...prev, priority: priority.value }))
|
||||||
queryClient.invalidateQueries(['chores'])
|
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)
|
// Check if the current user can approve/reject (admin, manager, or task owner)
|
||||||
const canApproveReject = () => {
|
const canApproveReject = () => {
|
||||||
if (!circleMembersData?.res || !chore) return false
|
if (!circleMembersData?.res || !chore) return false
|
||||||
@@ -794,64 +969,6 @@ const ChoreView = () => {
|
|||||||
mb: 1,
|
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
|
<Button
|
||||||
size='sm'
|
size='sm'
|
||||||
color='neutral'
|
color='neutral'
|
||||||
@@ -889,6 +1006,38 @@ const ChoreView = () => {
|
|||||||
<Edit />
|
<Edit />
|
||||||
Edit
|
Edit
|
||||||
</Button>
|
</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>
|
</Box>
|
||||||
|
|
||||||
{chore.description && (
|
{chore.description && (
|
||||||
@@ -1261,21 +1410,7 @@ const ChoreView = () => {
|
|||||||
<Button
|
<Button
|
||||||
fullWidth
|
fullWidth
|
||||||
size='lg'
|
size='lg'
|
||||||
onClick={() => {
|
onClick={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({})
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}}
|
|
||||||
disabled={
|
disabled={
|
||||||
notInCompletionWindow(chore) || chore.isActive === false
|
notInCompletionWindow(chore) || chore.isActive === false
|
||||||
}
|
}
|
||||||
@@ -1348,6 +1483,52 @@ const ChoreView = () => {
|
|||||||
<ConfirmationModal config={confirmModelConfig} />
|
<ConfirmationModal config={confirmModelConfig} />
|
||||||
<ConfirmationModal config={timerActionConfig} />
|
<ConfirmationModal config={timerActionConfig} />
|
||||||
<NoteViewerModal config={noteViewerConfig} />
|
<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
|
<AttachmentBrowserModal
|
||||||
choreId={choreId}
|
choreId={choreId}
|
||||||
isOpen={attachmentBrowserOpen}
|
isOpen={attachmentBrowserOpen}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
CheckBoxOutlineBlank,
|
CheckBoxOutlineBlank,
|
||||||
Close,
|
Close,
|
||||||
Delete,
|
Delete,
|
||||||
|
FilterList,
|
||||||
Label,
|
Label,
|
||||||
Person,
|
Person,
|
||||||
PriorityHigh,
|
PriorityHigh,
|
||||||
@@ -14,6 +15,7 @@ import {
|
|||||||
ViewModule,
|
ViewModule,
|
||||||
} from '@mui/icons-material'
|
} from '@mui/icons-material'
|
||||||
import {
|
import {
|
||||||
|
Badge,
|
||||||
Box,
|
Box,
|
||||||
Button,
|
Button,
|
||||||
Container,
|
Container,
|
||||||
@@ -33,6 +35,7 @@ import { useNavigate } from 'react-router-dom'
|
|||||||
import EmptyState from '../../components/common/EmptyState'
|
import EmptyState from '../../components/common/EmptyState'
|
||||||
import FilterBar from '../../components/common/FilterBar'
|
import FilterBar from '../../components/common/FilterBar'
|
||||||
import KeyboardShortcutHint from '../../components/common/KeyboardShortcutHint'
|
import KeyboardShortcutHint from '../../components/common/KeyboardShortcutHint'
|
||||||
|
import SortAndFilterMenu from '../../components/common/SortAndFilterMenu'
|
||||||
import { useImpersonateUser } from '../../contexts/ImpersonateUserContext.jsx'
|
import { useImpersonateUser } from '../../contexts/ImpersonateUserContext.jsx'
|
||||||
import { useFilter } from '../../hooks/useFilter'
|
import { useFilter } from '../../hooks/useFilter'
|
||||||
import { useUnArchiveChore } from '../../queries/ChoreQueries'
|
import { useUnArchiveChore } from '../../queries/ChoreQueries'
|
||||||
@@ -203,13 +206,51 @@ const ArchivedTasks = () => {
|
|||||||
)
|
)
|
||||||
|
|
||||||
const {
|
const {
|
||||||
|
activeFilterCount,
|
||||||
activeFilters,
|
activeFilters,
|
||||||
clearAll,
|
clearAll,
|
||||||
filteredData: finalChores,
|
filteredData: filteredByBar,
|
||||||
hasActiveFilters,
|
hasActiveFilters,
|
||||||
setFilter,
|
setFilter,
|
||||||
} = useFilter(filteredChores, filterDefs)
|
} = useFilter(filteredChores, filterDefs)
|
||||||
|
|
||||||
|
const [isFilterSheetOpen, setIsFilterSheetOpen] = useState(false)
|
||||||
|
|
||||||
|
const [sortBy, setSortBy] = useState(
|
||||||
|
() => localStorage.getItem('archivedChoresSortBy') || 'archivedAt',
|
||||||
|
)
|
||||||
|
const [sortDirection, setSortDirection] = useState(
|
||||||
|
() => localStorage.getItem('archivedChoresSortDirection') || 'desc',
|
||||||
|
)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
localStorage.setItem('archivedChoresSortBy', sortBy)
|
||||||
|
localStorage.setItem('archivedChoresSortDirection', sortDirection)
|
||||||
|
}, [sortBy, sortDirection])
|
||||||
|
|
||||||
|
const finalChores = useMemo(() => {
|
||||||
|
const direction = sortDirection === 'desc' ? -1 : 1
|
||||||
|
return [...filteredByBar].sort((a, b) => {
|
||||||
|
switch (sortBy) {
|
||||||
|
case 'name':
|
||||||
|
return direction * (a.name || '').localeCompare(b.name || '')
|
||||||
|
case 'priority':
|
||||||
|
return direction * ((a.priority ?? 0) - (b.priority ?? 0))
|
||||||
|
case 'dueDate': {
|
||||||
|
const aDue = new Date(a.nextDueDate || 0).getTime()
|
||||||
|
const bDue = new Date(b.nextDueDate || 0).getTime()
|
||||||
|
return direction * (aDue - bDue)
|
||||||
|
}
|
||||||
|
case 'archivedAt':
|
||||||
|
default: {
|
||||||
|
const aDate = new Date(a.updatedAt || 0).getTime()
|
||||||
|
const bDate = new Date(b.updatedAt || 0).getTime()
|
||||||
|
return direction * (aDate - bDate)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}, [filteredByBar, sortBy, sortDirection])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadArchivedChores = async () => {
|
const loadArchivedChores = async () => {
|
||||||
if (!membersLoading && userProfile) {
|
if (!membersLoading && userProfile) {
|
||||||
@@ -722,9 +763,6 @@ const ArchivedTasks = () => {
|
|||||||
padding: 1,
|
padding: 1,
|
||||||
}}
|
}}
|
||||||
onChange={handleSearchChange}
|
onChange={handleSearchChange}
|
||||||
startDecorator={
|
|
||||||
showKeyboardShortcuts ? <KeyboardShortcutHint shortcut='F' /> : null
|
|
||||||
}
|
|
||||||
endDecorator={
|
endDecorator={
|
||||||
searchTerm && (
|
searchTerm && (
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
||||||
@@ -745,6 +783,45 @@ 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={[
|
||||||
|
{ name: 'Archived date', value: 'archivedAt' },
|
||||||
|
{ name: 'Name', value: 'name' },
|
||||||
|
{ name: 'Priority', value: 'priority' },
|
||||||
|
{ name: 'Due date', value: 'dueDate' },
|
||||||
|
]}
|
||||||
|
selectedSort={sortBy}
|
||||||
|
onSortChange={setSortBy}
|
||||||
|
sortDirection={sortDirection}
|
||||||
|
onSortDirectionChange={setSortDirection}
|
||||||
|
isActive={sortBy !== 'archivedAt' || sortDirection !== 'desc'}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* View Mode Toggle Button */}
|
{/* View Mode Toggle Button */}
|
||||||
<IconButton
|
<IconButton
|
||||||
variant='outlined'
|
variant='outlined'
|
||||||
@@ -805,6 +882,9 @@ const ArchivedTasks = () => {
|
|||||||
onClearAll={clearAll}
|
onClearAll={clearAll}
|
||||||
resultCount={finalChores.length}
|
resultCount={finalChores.length}
|
||||||
totalCount={filteredChores.length}
|
totalCount={filteredChores.length}
|
||||||
|
showTrigger={false}
|
||||||
|
open={isFilterSheetOpen}
|
||||||
|
onOpenChange={setIsFilterSheetOpen}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Multi-select Toolbar */}
|
{/* Multi-select Toolbar */}
|
||||||
|
|||||||
@@ -78,11 +78,16 @@ const scheduleNotificationFromTemplate = (
|
|||||||
const now = new Date()
|
const now = new Date()
|
||||||
const time = getTimeFromTemplate(template, dueDate)
|
const time = getTimeFromTemplate(template, dueDate)
|
||||||
const notificationId = getIdFromTemplate(chore.id, template)
|
const notificationId = getIdFromTemplate(chore.id, template)
|
||||||
const { title, body } = getNotificationText(chore.name, template)
|
const { title, body } = getNotificationText(
|
||||||
|
chore.name,
|
||||||
|
template,
|
||||||
|
dueDate,
|
||||||
|
time,
|
||||||
|
)
|
||||||
if (time > now) {
|
if (time > now) {
|
||||||
notifications.push({
|
notifications.push({
|
||||||
title,
|
title,
|
||||||
body: `${body} at ${time.toLocaleTimeString()}`,
|
body,
|
||||||
id: notificationId,
|
id: notificationId,
|
||||||
allowWhileIdle: true,
|
allowWhileIdle: true,
|
||||||
schedule: {
|
schedule: {
|
||||||
@@ -96,91 +101,50 @@ const scheduleNotificationFromTemplate = (
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const getNotificationText = (choreName, template = {}) => {
|
const getNotificationText = (
|
||||||
// Determine notification type based on template value
|
choreName,
|
||||||
const getNotificationType = () => {
|
template = {},
|
||||||
if (!template || template.value === undefined) {
|
dueDate,
|
||||||
return 'due'
|
notificationTime,
|
||||||
}
|
) => {
|
||||||
|
const startOfDay = date =>
|
||||||
|
new Date(date.getFullYear(), date.getMonth(), date.getDate())
|
||||||
|
const dayDifference = Math.round(
|
||||||
|
(startOfDay(dueDate) - startOfDay(notificationTime)) /
|
||||||
|
(24 * 60 * 60 * 1000),
|
||||||
|
)
|
||||||
|
const time = dueDate.toLocaleTimeString([], {
|
||||||
|
hour: 'numeric',
|
||||||
|
minute: '2-digit',
|
||||||
|
})
|
||||||
|
|
||||||
if (template.value < 0) {
|
let dueTime
|
||||||
return 'reminder'
|
if (dayDifference === 0) {
|
||||||
} else if (template.value === 0) {
|
dueTime = `today at ${time}`
|
||||||
return 'due'
|
} else if (dayDifference === 1) {
|
||||||
} else {
|
dueTime = `tomorrow at ${time}`
|
||||||
return 'overdue'
|
} else if (dayDifference === -1) {
|
||||||
}
|
dueTime = `yesterday at ${time}`
|
||||||
|
} else {
|
||||||
|
const date = dueDate.toLocaleDateString([], {
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
})
|
||||||
|
dueTime = `${date} at ${time}`
|
||||||
}
|
}
|
||||||
|
|
||||||
const notificationType = getNotificationType()
|
let body
|
||||||
|
if (template.value < 0) {
|
||||||
// Truncate chore name if too long for better readability
|
body = `Due ${dueTime}`
|
||||||
const maxChoreNameLength = 25
|
} else if (template.value > 0) {
|
||||||
const truncatedName =
|
body = `Overdue · Was due ${dueTime}`
|
||||||
choreName.length > maxChoreNameLength
|
} else {
|
||||||
? `${choreName.substring(0, maxChoreNameLength)}...`
|
body = 'Due now'
|
||||||
: choreName
|
|
||||||
|
|
||||||
// Generate time-based descriptive text
|
|
||||||
const getTimeDescription = () => {
|
|
||||||
if (!template || !template.value || !template.unit) {
|
|
||||||
return 'soon'
|
|
||||||
}
|
|
||||||
|
|
||||||
const { value, unit } = template
|
|
||||||
const absValue = Math.abs(value)
|
|
||||||
|
|
||||||
switch (unit) {
|
|
||||||
case 'm':
|
|
||||||
if (absValue === 1) return value < 0 ? 'in 1 minute' : '1 minute ago'
|
|
||||||
if (absValue < 60)
|
|
||||||
return value < 0
|
|
||||||
? `in ${absValue} minutes`
|
|
||||||
: `${absValue} minutes ago`
|
|
||||||
break
|
|
||||||
case 'h':
|
|
||||||
if (absValue === 1) return value < 0 ? 'in 1 hour' : '1 hour ago'
|
|
||||||
if (absValue < 24)
|
|
||||||
return value < 0 ? `in ${absValue} hours` : `${absValue} hours ago`
|
|
||||||
break
|
|
||||||
case 'd':
|
|
||||||
if (absValue === 1) return value < 0 ? 'tomorrow' : 'yesterday'
|
|
||||||
if (absValue === 7) return value < 0 ? 'next week' : 'last week'
|
|
||||||
if (absValue < 7)
|
|
||||||
return value < 0 ? `in ${absValue} days` : `${absValue} days ago`
|
|
||||||
if (absValue < 30) {
|
|
||||||
const weeks = Math.round(absValue / 7)
|
|
||||||
return value < 0 ? `in ${weeks} weeks` : `${weeks} weeks ago`
|
|
||||||
}
|
|
||||||
break
|
|
||||||
default:
|
|
||||||
return value < 0 ? `in ${absValue} ${unit}` : `${absValue} ${unit} ago`
|
|
||||||
}
|
|
||||||
|
|
||||||
return value < 0 ? `in ${absValue} ${unit}` : `${absValue} ${unit} ago`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const messages = {
|
|
||||||
reminder: {
|
|
||||||
title: `📋 ${truncatedName}`,
|
|
||||||
body: `Reminder: Due ${getTimeDescription()}`,
|
|
||||||
},
|
|
||||||
due: {
|
|
||||||
title: `🔔 ${truncatedName}`,
|
|
||||||
body: 'Due now - Time to get started!',
|
|
||||||
},
|
|
||||||
overdue: {
|
|
||||||
title: `❗ ${truncatedName}`,
|
|
||||||
body: `Overdue ${getTimeDescription()} - Complete when you can`,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback to due if type not found
|
|
||||||
const messageTemplate = messages[notificationType] || messages.due
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
title: messageTemplate.title,
|
title: choreName,
|
||||||
body: messageTemplate.body,
|
body,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const cancelPendingNotifications = async () => {
|
const cancelPendingNotifications = async () => {
|
||||||
|
|||||||
@@ -75,6 +75,16 @@ import Sidepanel from './Sidepanel'
|
|||||||
import { INSIGHT_FILTER_DEFS } from './SmartInsightsCard'
|
import { INSIGHT_FILTER_DEFS } from './SmartInsightsCard'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
|
// Mirrors the assignee options in the toolbar, phrased to drop into a
|
||||||
|
// sentence ("none of them are assigned to you").
|
||||||
|
const ASSIGNEE_FILTER_LABELS = {
|
||||||
|
assigned_to_me: 'assigned to you',
|
||||||
|
available_for_me: 'available for you to pick up',
|
||||||
|
assigned_to_others: 'assigned to someone else',
|
||||||
|
assigned_to_me_tasks: 'assigned to you',
|
||||||
|
created_by_me: 'created by you',
|
||||||
|
}
|
||||||
|
|
||||||
const MyChores = () => {
|
const MyChores = () => {
|
||||||
const { data: userProfile, isLoading: isUserProfileLoading } =
|
const { data: userProfile, isLoading: isUserProfileLoading } =
|
||||||
useUserProfile()
|
useUserProfile()
|
||||||
@@ -151,6 +161,7 @@ const MyChores = () => {
|
|||||||
clearSelection,
|
clearSelection,
|
||||||
enterMultiSelectWithChore,
|
enterMultiSelectWithChore,
|
||||||
getSelectedChoresData,
|
getSelectedChoresData,
|
||||||
|
getSelectionSummary,
|
||||||
isMultiSelectMode,
|
isMultiSelectMode,
|
||||||
selectAllVisibleChores,
|
selectAllVisibleChores,
|
||||||
selectedChores,
|
selectedChores,
|
||||||
@@ -577,9 +588,13 @@ const MyChores = () => {
|
|||||||
const {
|
const {
|
||||||
handleAssigneeChange,
|
handleAssigneeChange,
|
||||||
handleBulkArchive,
|
handleBulkArchive,
|
||||||
|
handleBulkAssignee,
|
||||||
handleBulkComplete,
|
handleBulkComplete,
|
||||||
handleBulkDelete,
|
handleBulkDelete,
|
||||||
|
handleBulkDueDate,
|
||||||
|
handleBulkLabels,
|
||||||
handleBulkMoveToProject,
|
handleBulkMoveToProject,
|
||||||
|
handleBulkPriority,
|
||||||
handleBulkSkip,
|
handleBulkSkip,
|
||||||
handleChangeDueDate,
|
handleChangeDueDate,
|
||||||
handleChoreAction,
|
handleChoreAction,
|
||||||
@@ -631,6 +646,13 @@ const MyChores = () => {
|
|||||||
searchTerm,
|
searchTerm,
|
||||||
])
|
])
|
||||||
|
|
||||||
|
// Drives the bulk-edit sheet's controls (current value per field, which
|
||||||
|
// labels are on all vs some). Only worth computing while selecting.
|
||||||
|
const selectionSummary = useMemo(
|
||||||
|
() => (isMultiSelectMode ? getSelectionSummary(chores) : null),
|
||||||
|
[isMultiSelectMode, getSelectionSummary, chores],
|
||||||
|
)
|
||||||
|
|
||||||
const { showKeyboardShortcuts } = useKeyboardShortcuts({
|
const { showKeyboardShortcuts } = useKeyboardShortcuts({
|
||||||
isMultiSelectMode,
|
isMultiSelectMode,
|
||||||
selectedChores,
|
selectedChores,
|
||||||
@@ -874,20 +896,51 @@ const MyChores = () => {
|
|||||||
})
|
})
|
||||||
}, [getFilteredChores, selectedCalendarDate])
|
}, [getFilteredChores, selectedCalendarDate])
|
||||||
|
|
||||||
|
// The assignee filter ("Mine", "Available to me", ...) is applied inside
|
||||||
|
// ChoresGrouper, not in projectFilteredChores, so it can hide every task
|
||||||
|
// while the unfiltered list still looks full. It narrows like any other.
|
||||||
|
const assigneeFilterLabel = ASSIGNEE_FILTER_LABELS[selectedChoreFilter]
|
||||||
|
const hasAssigneeFilter = Boolean(
|
||||||
|
selectedChoreFilter && selectedChoreFilter !== 'anyone',
|
||||||
|
)
|
||||||
|
|
||||||
// "Narrowed" means the user actively cut the list down (search, quick
|
// "Narrowed" means the user actively cut the list down (search, quick
|
||||||
// filters, a saved filter). Picking a project is not narrowing: an empty
|
// filters, a saved filter, the assignee filter). Picking a project is not
|
||||||
// project is an empty place, not a filtered-away result.
|
// narrowing: an empty project is an empty place, not a filtered-away result.
|
||||||
const isNarrowed = Boolean(
|
const isNarrowed = Boolean(
|
||||||
searchTerm?.length > 0 || hasQuickFilters || activeFilterId,
|
searchTerm?.length > 0 ||
|
||||||
|
hasQuickFilters ||
|
||||||
|
activeFilterId ||
|
||||||
|
hasAssigneeFilter,
|
||||||
)
|
)
|
||||||
const isCustomProjectSelected = Boolean(
|
const isCustomProjectSelected = Boolean(
|
||||||
selectedProject && selectedProject.id !== 'default',
|
selectedProject && selectedProject.id !== 'default',
|
||||||
)
|
)
|
||||||
|
// Worth its own wording: the assignee filter is the one narrowing that is
|
||||||
|
// easy to forget you left on, so name it rather than saying "filters".
|
||||||
|
const isAssigneeOnlyNarrowing = Boolean(
|
||||||
|
assigneeFilterLabel &&
|
||||||
|
!searchTerm?.length &&
|
||||||
|
!hasQuickFilters &&
|
||||||
|
!activeFilterId,
|
||||||
|
)
|
||||||
|
|
||||||
|
// What the list actually renders. Sections are the source of truth outside
|
||||||
|
// of search, since they are the only place the assignee filter is applied.
|
||||||
|
const visibleChoreCount = useMemo(
|
||||||
|
() =>
|
||||||
|
choreSections.reduce(
|
||||||
|
(total, section) => total + (section.content?.length || 0),
|
||||||
|
0,
|
||||||
|
),
|
||||||
|
[choreSections],
|
||||||
|
)
|
||||||
|
|
||||||
const clearNarrowing = () => {
|
const clearNarrowing = () => {
|
||||||
clearQuickFilters()
|
clearQuickFilters()
|
||||||
setSearchTerm('')
|
setSearchTerm('')
|
||||||
clearActiveFilter()
|
clearActiveFilter()
|
||||||
|
setSelectedChoreFilterWithCache('anyone')
|
||||||
updateFilterUrl(null, null)
|
updateFilterUrl(null, null)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1077,6 +1130,13 @@ const MyChores = () => {
|
|||||||
onArchive={handleBulkArchive}
|
onArchive={handleBulkArchive}
|
||||||
onDelete={handleBulkDelete}
|
onDelete={handleBulkDelete}
|
||||||
onMoveToProject={handleBulkMoveToProject}
|
onMoveToProject={handleBulkMoveToProject}
|
||||||
|
onSetDueDate={handleBulkDueDate}
|
||||||
|
onSetAssignee={handleBulkAssignee}
|
||||||
|
onSetPriority={handleBulkPriority}
|
||||||
|
onToggleLabel={handleBulkLabels}
|
||||||
|
selectionSummary={selectionSummary}
|
||||||
|
members={membersData?.res || []}
|
||||||
|
labels={userLabels || []}
|
||||||
projects={projects}
|
projects={projects}
|
||||||
showKeyboardShortcuts={showKeyboardShortcuts}
|
showKeyboardShortcuts={showKeyboardShortcuts}
|
||||||
selectAllDisabled={
|
selectAllDisabled={
|
||||||
@@ -1089,10 +1149,13 @@ const MyChores = () => {
|
|||||||
|
|
||||||
{/* Empty state. Three different situations, three different messages:
|
{/* Empty state. Three different situations, three different messages:
|
||||||
nothing created yet, nothing left after narrowing, or an empty
|
nothing created yet, nothing left after narrowing, or an empty
|
||||||
project. Only the middle one is about filters. */}
|
project. Only the middle one is about filters.
|
||||||
{(isNarrowed
|
The trigger is what the list actually renders, not the pre-filter
|
||||||
|
count, so a view emptied purely by the assignee filter still
|
||||||
|
explains itself instead of showing a blank page. */}
|
||||||
|
{(searchTerm?.length > 0
|
||||||
? getFilteredChores.length === 0
|
? getFilteredChores.length === 0
|
||||||
: projectFilteredChores.length === 0) &&
|
: visibleChoreCount === 0) &&
|
||||||
// only if not in calendar view:
|
// only if not in calendar view:
|
||||||
viewMode !== 'calendar' &&
|
viewMode !== 'calendar' &&
|
||||||
(chores.length === 0 ? (
|
(chores.length === 0 ? (
|
||||||
@@ -1112,7 +1175,10 @@ const MyChores = () => {
|
|||||||
onClick: () => Navigate('/chores/create'),
|
onClick: () => Navigate('/chores/create'),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
) : isNarrowed ? (
|
) : isNarrowed &&
|
||||||
|
(searchTerm?.length > 0 ||
|
||||||
|
activeFilterId ||
|
||||||
|
projectFilteredChores.length > 0) ? (
|
||||||
<EmptyState
|
<EmptyState
|
||||||
variant='no-results'
|
variant='no-results'
|
||||||
fullHeight
|
fullHeight
|
||||||
@@ -1121,11 +1187,17 @@ const MyChores = () => {
|
|||||||
description={
|
description={
|
||||||
searchTerm?.length > 0
|
searchTerm?.length > 0
|
||||||
? `Nothing matches "${searchTerm}". Try a different search, or clear what is narrowing the list.`
|
? `Nothing matches "${searchTerm}". Try a different search, or clear what is narrowing the list.`
|
||||||
: 'You have tasks, but none of them fit the filters that are currently on.'
|
: isAssigneeOnlyNarrowing
|
||||||
|
? `There are tasks here, but none of them are ${assigneeFilterLabel}. Switch back to everyone to see the rest.`
|
||||||
|
: 'You have tasks, but none of them fit the filters that are currently on.'
|
||||||
}
|
}
|
||||||
primaryAction={{
|
primaryAction={{
|
||||||
label:
|
label:
|
||||||
searchTerm?.length > 0 ? 'Clear search' : 'Clear filters',
|
searchTerm?.length > 0
|
||||||
|
? 'Clear search'
|
||||||
|
: isAssigneeOnlyNarrowing
|
||||||
|
? "Show everyone's tasks"
|
||||||
|
: 'Clear filters',
|
||||||
onClick: clearNarrowing,
|
onClick: clearNarrowing,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -1479,12 +1551,16 @@ const MyChores = () => {
|
|||||||
<KeyboardShortcutHint
|
<KeyboardShortcutHint
|
||||||
sx={{
|
sx={{
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
top: -8,
|
top: -12,
|
||||||
right: -8,
|
// Anchored left so the wider "⌘ + Shift + J" label grows to the
|
||||||
|
// right instead of off the left edge of the viewport.
|
||||||
|
left: 2,
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
zIndex: 1000,
|
zIndex: 1000,
|
||||||
}}
|
}}
|
||||||
show={showKeyboardShortcuts}
|
show={showKeyboardShortcuts}
|
||||||
shortcut='J'
|
shortcut='J'
|
||||||
|
withShift
|
||||||
/>
|
/>
|
||||||
</IconButton>
|
</IconButton>
|
||||||
<IconButton
|
<IconButton
|
||||||
@@ -1513,7 +1589,7 @@ const MyChores = () => {
|
|||||||
<KeyboardShortcutHint
|
<KeyboardShortcutHint
|
||||||
sx={{ position: 'relative', left: -40, top: 30 }}
|
sx={{ position: 'relative', left: -40, top: 30 }}
|
||||||
show={showKeyboardShortcuts}
|
show={showKeyboardShortcuts}
|
||||||
shortcut='K'
|
shortcut='J'
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
<NotificationAccessSnackbar />
|
<NotificationAccessSnackbar />
|
||||||
|
|||||||
@@ -23,13 +23,13 @@ import {
|
|||||||
Check,
|
Check,
|
||||||
CheckBox,
|
CheckBox,
|
||||||
CheckBoxOutlineBlank,
|
CheckBoxOutlineBlank,
|
||||||
|
DisplaySettings,
|
||||||
FilterList,
|
FilterList,
|
||||||
Save,
|
Save,
|
||||||
Sort,
|
Sort,
|
||||||
Tune,
|
Tune,
|
||||||
ViewAgenda,
|
ViewAgenda,
|
||||||
ViewComfy,
|
ViewComfy,
|
||||||
ViewModule,
|
|
||||||
} from '@mui/icons-material'
|
} from '@mui/icons-material'
|
||||||
import {
|
import {
|
||||||
Badge,
|
Badge,
|
||||||
@@ -596,23 +596,19 @@ const ChoreToolbar = ({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Display button — View + Group combined */}
|
{/* Display button — View + Group combined.
|
||||||
|
Icon stays fixed: mirroring viewMode made this read as a toggle
|
||||||
|
showing the current view rather than a button that opens a sheet. */}
|
||||||
<IconButton
|
<IconButton
|
||||||
variant='outlined'
|
variant='outlined'
|
||||||
color='neutral'
|
color='neutral'
|
||||||
size='sm'
|
size='sm'
|
||||||
sx={{ height: 32, width: 32, borderRadius: '50%' }}
|
sx={{ height: 32, width: 32, borderRadius: '50%' }}
|
||||||
onClick={() => setDisplaySheetOpen(true)}
|
onClick={() => setDisplaySheetOpen(true)}
|
||||||
aria-label='View and group options'
|
aria-label={t('toolbar.displayOptions')}
|
||||||
title={t('toolbar.viewGroup')}
|
title={t('toolbar.display')}
|
||||||
>
|
>
|
||||||
{viewMode === 'calendar' ? (
|
<DisplaySettings />
|
||||||
<CalendarMonth />
|
|
||||||
) : viewMode === 'compact' ? (
|
|
||||||
<ViewModule />
|
|
||||||
) : (
|
|
||||||
<ViewAgenda />
|
|
||||||
)}
|
|
||||||
</IconButton>
|
</IconButton>
|
||||||
|
|
||||||
{/* Multiselect */}
|
{/* Multiselect */}
|
||||||
@@ -886,7 +882,7 @@ const ChoreToolbar = ({
|
|||||||
onClose={() => setDisplaySheetOpen(false)}
|
onClose={() => setDisplaySheetOpen(false)}
|
||||||
title={
|
title={
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||||
<ViewAgenda sx={{ fontSize: 20 }} />
|
<DisplaySettings sx={{ fontSize: 20 }} />
|
||||||
Display
|
Display
|
||||||
</Box>
|
</Box>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,19 @@
|
|||||||
import {
|
import {
|
||||||
Archive,
|
Archive,
|
||||||
|
CalendarMonth,
|
||||||
|
Check,
|
||||||
CheckBox,
|
CheckBox,
|
||||||
CheckBoxOutlineBlank,
|
CheckBoxOutlineBlank,
|
||||||
Close,
|
Close,
|
||||||
Delete,
|
Delete,
|
||||||
Done,
|
Done,
|
||||||
DriveFileMove,
|
DriveFileMove,
|
||||||
|
EditCalendar,
|
||||||
|
Flag,
|
||||||
|
Label as LabelIcon,
|
||||||
|
MoreHoriz,
|
||||||
|
Person,
|
||||||
|
Remove,
|
||||||
SelectAll,
|
SelectAll,
|
||||||
SkipNext,
|
SkipNext,
|
||||||
} from '@mui/icons-material'
|
} from '@mui/icons-material'
|
||||||
@@ -13,6 +21,7 @@ import {
|
|||||||
Avatar,
|
Avatar,
|
||||||
Box,
|
Box,
|
||||||
Button,
|
Button,
|
||||||
|
Chip,
|
||||||
Divider,
|
Divider,
|
||||||
ListItemContent,
|
ListItemContent,
|
||||||
ListItemDecorator,
|
ListItemDecorator,
|
||||||
@@ -20,13 +29,19 @@ import {
|
|||||||
MenuItem,
|
MenuItem,
|
||||||
Typography,
|
Typography,
|
||||||
} from '@mui/joy'
|
} from '@mui/joy'
|
||||||
|
import moment from 'moment'
|
||||||
import { useRef, useState } from 'react'
|
import { useRef, useState } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import AppModal from '../../../components/common/AppModal'
|
||||||
import KeyboardShortcutHint from '../../../components/common/KeyboardShortcutHint'
|
import KeyboardShortcutHint from '../../../components/common/KeyboardShortcutHint'
|
||||||
import LABEL_COLORS, {
|
import LABEL_COLORS, {
|
||||||
getTextColorFromBackgroundColor,
|
getTextColorFromBackgroundColor,
|
||||||
} from '../../../utils/Colors'
|
} from '../../../utils/Colors'
|
||||||
|
import Priorities from '../../../utils/Priorities'
|
||||||
import { getIconComponent } from '../../../utils/ProjectIcons'
|
import { getIconComponent } from '../../../utils/ProjectIcons'
|
||||||
|
import DueDatePickerModal, {
|
||||||
|
splitDueDate,
|
||||||
|
} from '../../components/DueDatePickerModal'
|
||||||
|
|
||||||
const renderProjectAvatar = (color, icon) => {
|
const renderProjectAvatar = (color, icon) => {
|
||||||
const bg = color || LABEL_COLORS[0].value
|
const bg = color || LABEL_COLORS[0].value
|
||||||
@@ -40,6 +55,83 @@ const renderProjectAvatar = (color, icon) => {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// `onSetDueDate` takes the same { dueDateOnly, dueTime, useCustomTime } shape
|
||||||
|
// the picker emits, or null to unplan. The quick options move the date only and
|
||||||
|
// leave the time unset, so each task keeps whatever hour it was already due at
|
||||||
|
// (and stays "anytime" if it had none).
|
||||||
|
// 23:59 is the app's "no specific time" stamp, so the picker should open on
|
||||||
|
// Anytime for it rather than showing it as a time the user chose.
|
||||||
|
const prefillDueDate = value => {
|
||||||
|
const parts = splitDueDate(value)
|
||||||
|
return parts.dueTime === '23:59'
|
||||||
|
? { dueDateOnly: parts.dueDateOnly, dueTime: null, useCustomTime: false }
|
||||||
|
: parts
|
||||||
|
}
|
||||||
|
|
||||||
|
const dateOnly = date => ({
|
||||||
|
dueDateOnly: date.format('YYYY-MM-DD'),
|
||||||
|
dueTime: null,
|
||||||
|
useCustomTime: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
const DUE_DATE_PRESETS = [
|
||||||
|
{
|
||||||
|
key: 'today',
|
||||||
|
label: 'Today',
|
||||||
|
resolve: () => dateOnly(moment()),
|
||||||
|
hint: () => moment().format('ddd, MMM D'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'tomorrow',
|
||||||
|
label: 'Tomorrow',
|
||||||
|
resolve: () => dateOnly(moment().add(1, 'day')),
|
||||||
|
hint: () => moment().add(1, 'day').format('ddd, MMM D'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'next-week',
|
||||||
|
label: 'Next week',
|
||||||
|
resolve: () => dateOnly(moment().add(1, 'week').startOf('isoWeek')),
|
||||||
|
hint: () => moment().add(1, 'week').startOf('isoWeek').format('ddd, MMM D'),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const SectionHeader = ({ icon, label, value }) => (
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}>
|
||||||
|
{icon && (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
color: 'text.secondary',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
'& svg': { fontSize: 18 },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{icon}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
<Typography level='title-sm' fontWeight={600}>
|
||||||
|
{label}
|
||||||
|
</Typography>
|
||||||
|
{value && (
|
||||||
|
<Typography level='body-xs' sx={{ ml: 'auto', color: 'text.tertiary' }}>
|
||||||
|
{value}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
|
||||||
|
const ChipRow = ({ children }) => (
|
||||||
|
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>{children}</Box>
|
||||||
|
)
|
||||||
|
|
||||||
|
const selectableChipSx = {
|
||||||
|
py: 0.64,
|
||||||
|
cursor: 'pointer',
|
||||||
|
transition: 'all 0.15s ease',
|
||||||
|
userSelect: 'none',
|
||||||
|
'&:hover': { opacity: 0.85 },
|
||||||
|
}
|
||||||
|
|
||||||
const MultiSelectToolbar = ({
|
const MultiSelectToolbar = ({
|
||||||
isVisible,
|
isVisible,
|
||||||
selectedCount,
|
selectedCount,
|
||||||
@@ -50,20 +142,66 @@ const MultiSelectToolbar = ({
|
|||||||
onArchive,
|
onArchive,
|
||||||
onDelete,
|
onDelete,
|
||||||
onMoveToProject,
|
onMoveToProject,
|
||||||
|
onSetDueDate,
|
||||||
|
onSetAssignee,
|
||||||
|
onSetPriority,
|
||||||
|
onToggleLabel,
|
||||||
|
// Shape produced by useMultiSelect.getSelectionSummary — drives which value
|
||||||
|
// each control shows as current, and which labels can be added vs removed.
|
||||||
|
selectionSummary,
|
||||||
|
members = [],
|
||||||
|
labels = [],
|
||||||
projects = [],
|
projects = [],
|
||||||
showKeyboardShortcuts,
|
showKeyboardShortcuts,
|
||||||
selectAllDisabled,
|
selectAllDisabled,
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation('chores')
|
const { t } = useTranslation('chores')
|
||||||
|
const [moreOpen, setMoreOpen] = useState(false)
|
||||||
|
const [dueDatePickerOpen, setDueDatePickerOpen] = useState(false)
|
||||||
|
const [dueMenuAnchor, setDueMenuAnchor] = useState(null)
|
||||||
const [projectMenuAnchor, setProjectMenuAnchor] = useState(null)
|
const [projectMenuAnchor, setProjectMenuAnchor] = useState(null)
|
||||||
|
const dueMenuRef = useRef(null)
|
||||||
const projectMenuRef = useRef(null)
|
const projectMenuRef = useRef(null)
|
||||||
|
|
||||||
|
const closeDueMenu = () => setDueMenuAnchor(null)
|
||||||
const closeProjectMenu = () => setProjectMenuAnchor(null)
|
const closeProjectMenu = () => setProjectMenuAnchor(null)
|
||||||
|
|
||||||
const handleMoveToProject = project => {
|
const summary = selectionSummary || {}
|
||||||
closeProjectMenu()
|
const labelState = summary.labels || { common: [], partial: [] }
|
||||||
onMoveToProject?.(project)
|
const commonLabelIds = new Set(labelState.common || [])
|
||||||
}
|
const partialLabelIds = new Set(labelState.partial || [])
|
||||||
|
|
||||||
|
// null from the summary means "no restriction" — every circle member is a
|
||||||
|
// valid assignee for the whole selection.
|
||||||
|
const assignableMembers =
|
||||||
|
summary.assignableUserIds == null
|
||||||
|
? members
|
||||||
|
: members.filter(m => summary.assignableUserIds.includes(m.userId))
|
||||||
|
|
||||||
|
// Every bulk edit clears the selection, so the sheet has nothing left to act
|
||||||
|
// on afterwards.
|
||||||
|
const runAndClose =
|
||||||
|
action =>
|
||||||
|
(...args) => {
|
||||||
|
setMoreOpen(false)
|
||||||
|
action?.(...args)
|
||||||
|
}
|
||||||
|
|
||||||
|
const dueDateValue = summary.dueDate?.isMixed
|
||||||
|
? 'Mixed'
|
||||||
|
: summary.dueDate?.value
|
||||||
|
? moment(summary.dueDate.value).format('MMM D')
|
||||||
|
: null
|
||||||
|
|
||||||
|
const priorityValue = summary.priority?.isMixed
|
||||||
|
? 'Mixed'
|
||||||
|
: Priorities.find(p => p.value === summary.priority?.value)?.name.trim() ||
|
||||||
|
null
|
||||||
|
|
||||||
|
const assigneeValue = summary.assignee?.isMixed
|
||||||
|
? 'Mixed'
|
||||||
|
: members.find(m => m.userId === summary.assignee?.value)?.displayName ||
|
||||||
|
null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
@@ -73,7 +211,10 @@ const MultiSelectToolbar = ({
|
|||||||
zIndex: 1000,
|
zIndex: 1000,
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
transition: 'all 0.3s ease-in-out',
|
transition: 'all 0.3s ease-in-out',
|
||||||
maxHeight: isVisible ? '200px' : '0',
|
// Generous enough that the action buttons can wrap to two or three
|
||||||
|
// rows on a narrow screen without being clipped, while still giving
|
||||||
|
// the collapse something finite to animate to.
|
||||||
|
maxHeight: isVisible ? '400px' : '0',
|
||||||
opacity: isVisible ? 1 : 0,
|
opacity: isVisible ? 1 : 0,
|
||||||
transform: isVisible ? 'translateY(0)' : 'translateY(-20px)',
|
transform: isVisible ? 'translateY(0)' : 'translateY(-20px)',
|
||||||
marginBottom: isVisible ? 2 : 0,
|
marginBottom: isVisible ? 2 : 0,
|
||||||
@@ -88,20 +229,14 @@ const MultiSelectToolbar = ({
|
|||||||
border: '1px solid',
|
border: '1px solid',
|
||||||
borderColor: 'divider',
|
borderColor: 'divider',
|
||||||
boxShadow: 'm',
|
boxShadow: 'm',
|
||||||
gap: 2,
|
gap: 1.5,
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: {
|
// Narrow screens stack: the selection status gets its own row, then
|
||||||
sm: 'column',
|
// the actions get the full width to lay out in. Only above md is
|
||||||
md: 'row',
|
// there room to put both on one line.
|
||||||
},
|
flexDirection: { xs: 'column', md: 'row' },
|
||||||
alignItems: {
|
alignItems: { xs: 'stretch', md: 'center' },
|
||||||
xs: 'stretch',
|
justifyContent: 'space-between',
|
||||||
sm: 'center',
|
|
||||||
},
|
|
||||||
justifyContent: {
|
|
||||||
xs: 'center',
|
|
||||||
sm: 'space-between',
|
|
||||||
},
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Box
|
<Box
|
||||||
@@ -109,14 +244,10 @@ const MultiSelectToolbar = ({
|
|||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
gap: 2,
|
gap: 2,
|
||||||
flexWrap: {
|
flexWrap: 'nowrap',
|
||||||
xs: 'wrap',
|
// Stacked, the count sits left and All/Close anchor right, so the
|
||||||
sm: 'nowrap',
|
// status row reads edge to edge instead of floating in the middle.
|
||||||
},
|
justifyContent: { xs: 'space-between', md: 'flex-start' },
|
||||||
justifyContent: {
|
|
||||||
xs: 'center',
|
|
||||||
sm: 'flex-start',
|
|
||||||
},
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||||
@@ -129,7 +260,7 @@ const MultiSelectToolbar = ({
|
|||||||
<Divider
|
<Divider
|
||||||
orientation='vertical'
|
orientation='vertical'
|
||||||
sx={{
|
sx={{
|
||||||
display: { xs: 'none', sm: 'block' },
|
display: { xs: 'none', md: 'block' },
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -195,19 +326,22 @@ const MultiSelectToolbar = ({
|
|||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
|
{/* The verbs a task can be done to — complete, reschedule, move,
|
||||||
|
archive, delete — all keep permanent buttons and wrap to a second
|
||||||
|
row when they need to. The sheet holds only the field editors
|
||||||
|
(priority, assignee, labels), so nothing appears in both places. */}
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: 1,
|
gap: 1,
|
||||||
flexWrap: {
|
// Stacked, the actions become an auto-fitting grid: every button
|
||||||
xs: 'wrap',
|
// stretches to fill its cell, so the rows come out flush instead
|
||||||
sm: 'nowrap',
|
// of trailing ragged whitespace. Above md they go back to a
|
||||||
},
|
// right-aligned row.
|
||||||
justifyContent: {
|
display: { xs: 'grid', md: 'flex' },
|
||||||
xs: 'center',
|
gridTemplateColumns: 'repeat(auto-fit, minmax(112px, 1fr))',
|
||||||
sm: 'flex-end',
|
alignItems: 'center',
|
||||||
},
|
flexWrap: 'wrap',
|
||||||
|
justifyContent: 'flex-end',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Button
|
<Button
|
||||||
@@ -236,6 +370,91 @@ const MultiSelectToolbar = ({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
|
{onSetDueDate && (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
size='sm'
|
||||||
|
variant='soft'
|
||||||
|
color='primary'
|
||||||
|
ref={dueMenuRef}
|
||||||
|
onClick={() =>
|
||||||
|
setDueMenuAnchor(prev => (prev ? null : dueMenuRef.current))
|
||||||
|
}
|
||||||
|
startDecorator={<CalendarMonth />}
|
||||||
|
disabled={selectedCount === 0}
|
||||||
|
sx={{
|
||||||
|
'--Button-paddingInline': { xs: '0.75rem', sm: '1rem' },
|
||||||
|
}}
|
||||||
|
title={
|
||||||
|
dueDateValue
|
||||||
|
? `Due date on selected tasks: ${dueDateValue}`
|
||||||
|
: 'Set due date on selected tasks'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Due
|
||||||
|
</Button>
|
||||||
|
<Menu
|
||||||
|
size='md'
|
||||||
|
anchorEl={dueMenuAnchor}
|
||||||
|
open={Boolean(dueMenuAnchor)}
|
||||||
|
onClose={closeDueMenu}
|
||||||
|
placement='bottom-end'
|
||||||
|
>
|
||||||
|
{DUE_DATE_PRESETS.map(preset => (
|
||||||
|
<MenuItem
|
||||||
|
key={preset.key}
|
||||||
|
onClick={() => {
|
||||||
|
closeDueMenu()
|
||||||
|
onSetDueDate(preset.resolve())
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ListItemDecorator>
|
||||||
|
<CalendarMonth sx={{ fontSize: 18 }} />
|
||||||
|
</ListItemDecorator>
|
||||||
|
<ListItemContent>
|
||||||
|
<Typography level='body-sm'>{preset.label}</Typography>
|
||||||
|
<Typography
|
||||||
|
level='body-xs'
|
||||||
|
sx={{ color: 'text.tertiary' }}
|
||||||
|
>
|
||||||
|
{preset.hint()}
|
||||||
|
</Typography>
|
||||||
|
</ListItemContent>
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
<Divider />
|
||||||
|
<MenuItem
|
||||||
|
onClick={() => {
|
||||||
|
closeDueMenu()
|
||||||
|
setDueDatePickerOpen(true)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ListItemDecorator>
|
||||||
|
<EditCalendar sx={{ fontSize: 18 }} />
|
||||||
|
</ListItemDecorator>
|
||||||
|
<ListItemContent>
|
||||||
|
<Typography level='body-sm'>Pick date…</Typography>
|
||||||
|
</ListItemContent>
|
||||||
|
</MenuItem>
|
||||||
|
<MenuItem
|
||||||
|
color='danger'
|
||||||
|
onClick={() => {
|
||||||
|
closeDueMenu()
|
||||||
|
onSetDueDate(null)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ListItemDecorator>
|
||||||
|
<Remove sx={{ fontSize: 18 }} />
|
||||||
|
</ListItemDecorator>
|
||||||
|
<ListItemContent>
|
||||||
|
<Typography level='body-sm'>No due date</Typography>
|
||||||
|
</ListItemContent>
|
||||||
|
</MenuItem>
|
||||||
|
</Menu>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
size='sm'
|
size='sm'
|
||||||
variant='soft'
|
variant='soft'
|
||||||
@@ -262,6 +481,7 @@ const MultiSelectToolbar = ({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{onMoveToProject && (
|
{onMoveToProject && (
|
||||||
<>
|
<>
|
||||||
<Button
|
<Button
|
||||||
@@ -291,9 +511,10 @@ const MultiSelectToolbar = ({
|
|||||||
placement='bottom-end'
|
placement='bottom-end'
|
||||||
>
|
>
|
||||||
<MenuItem
|
<MenuItem
|
||||||
onClick={() =>
|
onClick={() => {
|
||||||
handleMoveToProject({ id: null, name: 'Default Project' })
|
closeProjectMenu()
|
||||||
}
|
onMoveToProject({ id: null, name: 'Default Project' })
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<ListItemDecorator>
|
<ListItemDecorator>
|
||||||
{renderProjectAvatar(LABEL_COLORS[0].value, 'FolderOpen')}
|
{renderProjectAvatar(LABEL_COLORS[0].value, 'FolderOpen')}
|
||||||
@@ -305,7 +526,10 @@ const MultiSelectToolbar = ({
|
|||||||
{projects.map(project => (
|
{projects.map(project => (
|
||||||
<MenuItem
|
<MenuItem
|
||||||
key={project.id}
|
key={project.id}
|
||||||
onClick={() => handleMoveToProject(project)}
|
onClick={() => {
|
||||||
|
closeProjectMenu()
|
||||||
|
onMoveToProject(project)
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<ListItemDecorator>
|
<ListItemDecorator>
|
||||||
{renderProjectAvatar(project.color, project.icon)}
|
{renderProjectAvatar(project.color, project.icon)}
|
||||||
@@ -372,8 +596,210 @@ const MultiSelectToolbar = ({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
size='sm'
|
||||||
|
variant='outlined'
|
||||||
|
color='neutral'
|
||||||
|
onClick={() => setMoreOpen(true)}
|
||||||
|
startDecorator={<MoreHoriz />}
|
||||||
|
disabled={selectedCount === 0}
|
||||||
|
sx={{
|
||||||
|
'--Button-paddingInline': { xs: '0.75rem', sm: '1rem' },
|
||||||
|
}}
|
||||||
|
title='Set priority, assignee, or labels'
|
||||||
|
>
|
||||||
|
More
|
||||||
|
</Button>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
|
{/* ── More sheet: the field editors that have no button in the bar ────── */}
|
||||||
|
<AppModal
|
||||||
|
open={moreOpen}
|
||||||
|
isMobile
|
||||||
|
onClose={() => setMoreOpen(false)}
|
||||||
|
maxHeight='92vh'
|
||||||
|
title={
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||||
|
<CheckBox sx={{ fontSize: 20 }} />
|
||||||
|
{selectedCount} task{selectedCount !== 1 ? 's' : ''} selected
|
||||||
|
</Box>
|
||||||
|
}
|
||||||
|
footer={
|
||||||
|
<Button
|
||||||
|
variant='plain'
|
||||||
|
color='neutral'
|
||||||
|
onClick={() => setMoreOpen(false)}
|
||||||
|
sx={{ minWidth: 140 }}
|
||||||
|
>
|
||||||
|
Done
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0 }}>
|
||||||
|
{/* Due date, project and the destructive actions are deliberately
|
||||||
|
absent — each has its own button in the bar, and two entry points
|
||||||
|
would just make them ambiguous. */}
|
||||||
|
{onSetPriority && (
|
||||||
|
<>
|
||||||
|
<SectionHeader
|
||||||
|
icon={<Flag />}
|
||||||
|
label='Priority'
|
||||||
|
value={priorityValue}
|
||||||
|
/>
|
||||||
|
<ChipRow>
|
||||||
|
{Priorities.map(priority => {
|
||||||
|
const isCurrent =
|
||||||
|
!summary.priority?.isMixed &&
|
||||||
|
summary.priority?.value === priority.value
|
||||||
|
return (
|
||||||
|
<Chip
|
||||||
|
key={priority.value}
|
||||||
|
variant={isCurrent ? 'solid' : 'soft'}
|
||||||
|
color={
|
||||||
|
isCurrent ? priority.color || 'primary' : 'neutral'
|
||||||
|
}
|
||||||
|
startDecorator={
|
||||||
|
isCurrent ? <Check sx={{ fontSize: 14 }} /> : undefined
|
||||||
|
}
|
||||||
|
onClick={runAndClose(() => onSetPriority(priority.value))}
|
||||||
|
sx={selectableChipSx}
|
||||||
|
>
|
||||||
|
{priority.name.trim()}
|
||||||
|
</Chip>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
<Chip
|
||||||
|
variant='soft'
|
||||||
|
color='neutral'
|
||||||
|
onClick={runAndClose(() => onSetPriority(0))}
|
||||||
|
sx={selectableChipSx}
|
||||||
|
>
|
||||||
|
None
|
||||||
|
</Chip>
|
||||||
|
</ChipRow>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{onSetAssignee && assignableMembers.length > 0 && (
|
||||||
|
<>
|
||||||
|
<Divider sx={{ my: 2.5 }} />
|
||||||
|
<SectionHeader
|
||||||
|
icon={<Person />}
|
||||||
|
label='Assignee'
|
||||||
|
value={assigneeValue}
|
||||||
|
/>
|
||||||
|
<ChipRow>
|
||||||
|
{assignableMembers.map(member => {
|
||||||
|
const isCurrent =
|
||||||
|
!summary.assignee?.isMixed &&
|
||||||
|
summary.assignee?.value === member.userId
|
||||||
|
return (
|
||||||
|
<Chip
|
||||||
|
key={member.userId}
|
||||||
|
variant={isCurrent ? 'solid' : 'soft'}
|
||||||
|
color={isCurrent ? 'primary' : 'neutral'}
|
||||||
|
startDecorator={
|
||||||
|
isCurrent ? (
|
||||||
|
<Check sx={{ fontSize: 14 }} />
|
||||||
|
) : (
|
||||||
|
<Avatar size='sm' sx={{ width: 20, height: 20 }}>
|
||||||
|
{(member.displayName || '?')
|
||||||
|
.charAt(0)
|
||||||
|
.toUpperCase()}
|
||||||
|
</Avatar>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
onClick={runAndClose(() => onSetAssignee(member.userId))}
|
||||||
|
sx={selectableChipSx}
|
||||||
|
>
|
||||||
|
{member.displayName || member.username}
|
||||||
|
</Chip>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</ChipRow>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{onToggleLabel && labels.length > 0 && (
|
||||||
|
<>
|
||||||
|
<Divider sx={{ my: 2.5 }} />
|
||||||
|
{/* Tapping adds the label to every task; tapping one that is
|
||||||
|
already on all of them removes it. A half-filled chip means
|
||||||
|
only some of the selection has it — tapping completes the set. */}
|
||||||
|
<SectionHeader
|
||||||
|
icon={<LabelIcon />}
|
||||||
|
label='Labels'
|
||||||
|
value='Tap to add · tap again to remove'
|
||||||
|
/>
|
||||||
|
<ChipRow>
|
||||||
|
{labels.map(label => {
|
||||||
|
const onAll = commonLabelIds.has(label.id)
|
||||||
|
const onSome = partialLabelIds.has(label.id)
|
||||||
|
return (
|
||||||
|
<Chip
|
||||||
|
key={label.id}
|
||||||
|
variant={onAll ? 'solid' : onSome ? 'outlined' : 'soft'}
|
||||||
|
color='neutral'
|
||||||
|
startDecorator={
|
||||||
|
onAll ? (
|
||||||
|
<Check sx={{ fontSize: 14 }} />
|
||||||
|
) : onSome ? (
|
||||||
|
<Remove sx={{ fontSize: 14 }} />
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
|
onClick={runAndClose(() =>
|
||||||
|
onToggleLabel(label, onAll ? 'remove' : 'add'),
|
||||||
|
)}
|
||||||
|
sx={{
|
||||||
|
...selectableChipSx,
|
||||||
|
...(label.color && !onAll
|
||||||
|
? { borderColor: label.color }
|
||||||
|
: {}),
|
||||||
|
...(label.color && onAll
|
||||||
|
? {
|
||||||
|
backgroundColor: label.color,
|
||||||
|
color: getTextColorFromBackgroundColor(
|
||||||
|
label.color,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{label.name}
|
||||||
|
</Chip>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</ChipRow>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</AppModal>
|
||||||
|
|
||||||
|
{dueDatePickerOpen && (
|
||||||
|
<DueDatePickerModal
|
||||||
|
open
|
||||||
|
title={`Due date for ${selectedCount} task${selectedCount !== 1 ? 's' : ''}`}
|
||||||
|
{...prefillDueDate(
|
||||||
|
summary.dueDate?.isMixed ? null : summary.dueDate?.value,
|
||||||
|
)}
|
||||||
|
onClose={() => setDueDatePickerOpen(false)}
|
||||||
|
onApply={parts => {
|
||||||
|
setDueDatePickerOpen(false)
|
||||||
|
setMoreOpen(false)
|
||||||
|
// Passed through as parts, not a timestamp: leaving the time on
|
||||||
|
// "Anytime" means "keep each task's own hour", which only the
|
||||||
|
// per-chore handler can resolve.
|
||||||
|
onSetDueDate?.(parts.dueDateOnly ? parts : null)
|
||||||
|
}}
|
||||||
|
onRemove={() => {
|
||||||
|
setDueDatePickerOpen(false)
|
||||||
|
setMoreOpen(false)
|
||||||
|
onSetDueDate?.(null)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ const SearchBar = ({
|
|||||||
startDecorator={
|
startDecorator={
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75 }}>
|
||||||
<SearchRounded sx={{ fontSize: 18, color: 'text.secondary' }} />
|
<SearchRounded sx={{ fontSize: 18, color: 'text.secondary' }} />
|
||||||
<KeyboardShortcutHint shortcut='F' show={showKeyboardShortcuts} />
|
<KeyboardShortcutHint shortcut='K' show={showKeyboardShortcuts} />
|
||||||
</Box>
|
</Box>
|
||||||
}
|
}
|
||||||
endDecorator={
|
endDecorator={
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useQueryClient } from '@tanstack/react-query'
|
import { useQueryClient } from '@tanstack/react-query'
|
||||||
|
import moment from 'moment'
|
||||||
import { useCallback } from 'react'
|
import { useCallback } from 'react'
|
||||||
import {
|
import {
|
||||||
useArchiveChore,
|
useArchiveChore,
|
||||||
@@ -16,6 +17,7 @@ import {
|
|||||||
SkipChore,
|
SkipChore,
|
||||||
UndoChoreAction,
|
UndoChoreAction,
|
||||||
UpdateChoreAssignee,
|
UpdateChoreAssignee,
|
||||||
|
UpdateChorePriority,
|
||||||
UpdateDueDate,
|
UpdateDueDate,
|
||||||
} from '../../../utils/Fetcher'
|
} from '../../../utils/Fetcher'
|
||||||
import { offlineDB } from '../../../utils/OfflineDB'
|
import { offlineDB } from '../../../utils/OfflineDB'
|
||||||
@@ -29,6 +31,28 @@ const isNetworkError = err =>
|
|||||||
err instanceof TypeError &&
|
err instanceof TypeError &&
|
||||||
err.message === 'Failed to fetch'
|
err.message === 'Failed to fetch'
|
||||||
|
|
||||||
|
const plural = count => (count === 1 ? '' : 's')
|
||||||
|
const taskCount = count => `${count} task${plural(count)}`
|
||||||
|
|
||||||
|
// "No specific time" is stored as end of day, and it has to be exactly
|
||||||
|
// 23:59:59 — that stamp is what ChoreEdit writes and what the task card checks
|
||||||
|
// to render "Today" rather than "Today 11:59 PM". Rebuilding from HH:mm alone
|
||||||
|
// would land on :00 seconds and lose that meaning.
|
||||||
|
const END_OF_DAY = '23:59'
|
||||||
|
const atTimeOfDay = (date, time) =>
|
||||||
|
(!time || time === END_OF_DAY
|
||||||
|
? moment(date, 'YYYY-MM-DD').endOf('day')
|
||||||
|
: moment(`${date} ${time}`, 'YYYY-MM-DD HH:mm')
|
||||||
|
).toISOString()
|
||||||
|
|
||||||
|
// Fetcher calls resolve with a Response even on 4xx/5xx, so a bulk run has to
|
||||||
|
// check explicitly or it will report failures as successes.
|
||||||
|
const expectOk = async request => {
|
||||||
|
const response = await request
|
||||||
|
if (!response?.ok) throw new Error('Request failed')
|
||||||
|
return response
|
||||||
|
}
|
||||||
|
|
||||||
export const useChoreActions = ({
|
export const useChoreActions = ({
|
||||||
chores,
|
chores,
|
||||||
filteredChores,
|
filteredChores,
|
||||||
@@ -869,346 +893,392 @@ export const useChoreActions = ({
|
|||||||
[showSuccess, showError, closeModal],
|
[showSuccess, showError, closeModal],
|
||||||
)
|
)
|
||||||
|
|
||||||
const handleBulkComplete = useCallback(async () => {
|
// ── bulk operations ────────────────────────────────────────────────────────
|
||||||
const selectedData = getSelectedChoresData(chores)
|
//
|
||||||
if (selectedData.length === 0) return
|
// Every bulk action is the same shape: optionally confirm, apply per chore,
|
||||||
|
// tally what worked, tell the user, refetch, drop the selection. `runBulk`
|
||||||
|
// owns that shape so each action only describes what it does to one chore.
|
||||||
|
//
|
||||||
|
// Failures are best-effort and partial: a chore that fails leaves the others
|
||||||
|
// applied, and the toast says how many of each.
|
||||||
|
|
||||||
setConfirmModelConfig({
|
const patchLocalChores = useCallback(
|
||||||
isOpen: true,
|
(ids, patch) => {
|
||||||
title: t('actions.bulk.completeTitle'),
|
const idSet = new Set(ids)
|
||||||
confirmText: t('list.complete'),
|
const apply = list =>
|
||||||
cancelText: t('choreView.cancel'),
|
list.map(chore =>
|
||||||
message: `Mark ${selectedData.length} task${selectedData.length > 1 ? 's' : ''} as completed?`,
|
idSet.has(chore.id)
|
||||||
onClose: async isConfirmed => {
|
? {
|
||||||
if (isConfirmed === true) {
|
...chore,
|
||||||
try {
|
...(typeof patch === 'function' ? patch(chore) : patch),
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
}
|
: chore,
|
||||||
|
)
|
||||||
|
setChores(apply)
|
||||||
|
setFilteredChores(apply)
|
||||||
|
},
|
||||||
|
[setChores, setFilteredChores],
|
||||||
|
)
|
||||||
|
|
||||||
if (completedTasks.length > 0) {
|
const removeLocalChores = useCallback(
|
||||||
showSuccess({
|
ids => {
|
||||||
title: t('actions.bulk.completedTitle'),
|
const idSet = new Set(ids)
|
||||||
message: `Successfully completed ${completedTasks.length} task${completedTasks.length > 1 ? 's' : ''}.`,
|
const drop = list => list.filter(chore => !idSet.has(chore.id))
|
||||||
})
|
setChores(drop)
|
||||||
}
|
setFilteredChores(drop)
|
||||||
|
},
|
||||||
|
[setChores, setFilteredChores],
|
||||||
|
)
|
||||||
|
|
||||||
if (failedTasks.length > 0) {
|
const runBulk = useCallback(
|
||||||
showError({
|
async ({
|
||||||
title: t('archived.someFailedTitle'),
|
buildUndo,
|
||||||
message: `${failedTasks.length} task${failedTasks.length > 1 ? 's' : ''} could not be completed.`,
|
// { title, confirmText, message } — omitted when the picker the user
|
||||||
})
|
// just used is itself the confirmation.
|
||||||
}
|
confirm,
|
||||||
|
// "completed", "rescheduled", … — reads as `2 tasks could not be ${verb}.`
|
||||||
|
// t() key output for the "the whole batch blew up" toast title.
|
||||||
|
failedTitle,
|
||||||
|
failureVerb,
|
||||||
|
onSucceeded,
|
||||||
|
perChore,
|
||||||
|
successTitle,
|
||||||
|
// "Completed", "Rescheduled", … — reads as `${verb} 3 tasks.`
|
||||||
|
successVerb,
|
||||||
|
targets,
|
||||||
|
}) => {
|
||||||
|
if (!targets || targets.length === 0) return
|
||||||
|
|
||||||
refetchChores()
|
const execute = async () => {
|
||||||
clearSelection()
|
const succeeded = []
|
||||||
} catch (error) {
|
const failed = []
|
||||||
showError({
|
|
||||||
title: t('actions.bulk.completeFailedTitle'),
|
|
||||||
message: t('archived.unexpectedError'),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
setConfirmModelConfig({})
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}, [
|
|
||||||
getSelectedChoresData,
|
|
||||||
impersonatedUser,
|
|
||||||
showSuccess,
|
|
||||||
showError,
|
|
||||||
refetchChores,
|
|
||||||
clearSelection,
|
|
||||||
setConfirmModelConfig,
|
|
||||||
])
|
|
||||||
|
|
||||||
const handleBulkArchive = useCallback(async () => {
|
for (const chore of targets) {
|
||||||
const selectedData = getSelectedChoresData(chores)
|
|
||||||
if (selectedData.length === 0) return
|
|
||||||
|
|
||||||
setConfirmModelConfig({
|
|
||||||
isOpen: true,
|
|
||||||
title: t('actions.bulk.archiveTitle'),
|
|
||||||
confirmText: t('actionMenu.archive'),
|
|
||||||
cancelText: t('choreView.cancel'),
|
|
||||||
message: `Archive ${selectedData.length} task${selectedData.length > 1 ? 's' : ''}?`,
|
|
||||||
onClose: async isConfirmed => {
|
|
||||||
if (isConfirmed === true) {
|
|
||||||
try {
|
try {
|
||||||
const archivedTasks = []
|
await perChore(chore)
|
||||||
const failedTasks = []
|
succeeded.push(chore)
|
||||||
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: t('actions.bulk.archivedTitle'),
|
|
||||||
message: `Successfully archived ${archivedTasks.length} task${archivedTasks.length > 1 ? 's' : ''}.`,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
if (failedTasks.length > 0) {
|
|
||||||
showError({
|
|
||||||
title: t('archived.someFailedTitle'),
|
|
||||||
message: `${failedTasks.length} task${failedTasks.length > 1 ? 's' : ''} could not be archived.`,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
refetchChores()
|
|
||||||
clearSelection()
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showError({
|
failed.push(chore)
|
||||||
title: t('actions.bulk.archiveFailedTitle'),
|
|
||||||
message: t('archived.unexpectedError'),
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
setConfirmModelConfig({})
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}, [
|
|
||||||
getSelectedChoresData,
|
|
||||||
archiveChore,
|
|
||||||
setChores,
|
|
||||||
setFilteredChores,
|
|
||||||
showSuccess,
|
|
||||||
showError,
|
|
||||||
refetchChores,
|
|
||||||
clearSelection,
|
|
||||||
setConfirmModelConfig,
|
|
||||||
])
|
|
||||||
|
|
||||||
const handleBulkDelete = useCallback(async () => {
|
if (succeeded.length > 0) {
|
||||||
const selectedData = getSelectedChoresData(chores)
|
onSucceeded?.(succeeded)
|
||||||
if (selectedData.length === 0) return
|
showSuccess({
|
||||||
|
title: successTitle,
|
||||||
setConfirmModelConfig({
|
message: `${successVerb} ${taskCount(succeeded.length)}.`,
|
||||||
isOpen: true,
|
...(buildUndo ? { undoAction: buildUndo(succeeded) } : {}),
|
||||||
title: t('actions.bulk.deleteTitle'),
|
})
|
||||||
confirmText: t('archived.delete'),
|
|
||||||
cancelText: t('choreView.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: t('archived.deletedBulkTitle'),
|
|
||||||
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: t('archived.someFailedTitle'),
|
|
||||||
message: `${failedTasks.length} task${failedTasks.length > 1 ? 's' : ''} could not be deleted.`,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
refetchChores()
|
|
||||||
clearSelection()
|
|
||||||
} catch (error) {
|
|
||||||
showError({
|
|
||||||
title: t('archived.bulkDeleteFailTitle'),
|
|
||||||
message: t('archived.unexpectedError'),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
setConfirmModelConfig({})
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}, [
|
|
||||||
getSelectedChoresData,
|
|
||||||
chores,
|
|
||||||
filteredChores,
|
|
||||||
setChores,
|
|
||||||
setFilteredChores,
|
|
||||||
showSuccess,
|
|
||||||
showError,
|
|
||||||
refetchChores,
|
|
||||||
clearSelection,
|
|
||||||
setConfirmModelConfig,
|
|
||||||
])
|
|
||||||
|
|
||||||
const handleBulkSkip = useCallback(async () => {
|
if (failed.length > 0) {
|
||||||
const selectedData = getSelectedChoresData(chores)
|
showError({
|
||||||
if (selectedData.length === 0) return
|
title: t('archived.someFailedTitle'),
|
||||||
|
message: `${taskCount(failed.length)} could not be ${failureVerb}.`,
|
||||||
setConfirmModelConfig({
|
})
|
||||||
isOpen: true,
|
|
||||||
title: t('actions.bulk.skipTitle'),
|
|
||||||
confirmText: t('multiToolbar.skip'),
|
|
||||||
cancelText: t('choreView.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: t('actions.bulk.skippedTitle'),
|
|
||||||
message: `Successfully skipped ${skippedTasks.length} task${skippedTasks.length > 1 ? 's' : ''}.`,
|
|
||||||
undoAction: async () => {
|
|
||||||
try {
|
|
||||||
for (const chore of skippedTasks) {
|
|
||||||
await UndoChoreAction(chore.id)
|
|
||||||
}
|
|
||||||
queryClient.invalidateQueries(['chores'])
|
|
||||||
showUndo({
|
|
||||||
title: t('choreView.undoSuccessful'),
|
|
||||||
message: `Undo skip for ${skippedTasks.length} task${skippedTasks.length > 1 ? 's' : ''}.`,
|
|
||||||
})
|
|
||||||
} catch (error) {
|
|
||||||
showError({
|
|
||||||
title: t('choreView.undoFailed'),
|
|
||||||
message: t('choreView.undoFailedMessage'),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
if (failedTasks.length > 0) {
|
|
||||||
showError({
|
|
||||||
title: t('archived.someFailedTitle'),
|
|
||||||
message: `${failedTasks.length > 1 ? 's' : ''} could not be skipped.`,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
refetchChores()
|
|
||||||
clearSelection()
|
|
||||||
} catch (error) {
|
|
||||||
showError({
|
|
||||||
title: t('actions.bulk.skipFailedTitle'),
|
|
||||||
message: t('archived.unexpectedError'),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
setConfirmModelConfig({})
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}, [
|
|
||||||
getSelectedChoresData,
|
|
||||||
showSuccess,
|
|
||||||
showError,
|
|
||||||
showUndo,
|
|
||||||
refetchChores,
|
|
||||||
clearSelection,
|
|
||||||
setConfirmModelConfig,
|
|
||||||
])
|
|
||||||
|
|
||||||
const handleBulkMoveToProject = useCallback(
|
refetchChores()
|
||||||
async project => {
|
clearSelection()
|
||||||
const selectedData = getSelectedChoresData(chores)
|
}
|
||||||
if (selectedData.length === 0) return
|
|
||||||
|
|
||||||
const projectId = project?.id ?? null
|
if (!confirm) {
|
||||||
const movedTasks = []
|
|
||||||
const failedTasks = []
|
|
||||||
|
|
||||||
for (const chore of selectedData) {
|
|
||||||
try {
|
try {
|
||||||
const response = await SaveChore({ ...chore, projectId })
|
await execute()
|
||||||
if (response.ok) {
|
|
||||||
movedTasks.push(chore)
|
|
||||||
} else {
|
|
||||||
failedTasks.push(chore)
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
failedTasks.push(chore)
|
showError({
|
||||||
|
title: failedTitle || `Bulk ${failureVerb} failed`,
|
||||||
|
message: t('archived.unexpectedError'),
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (movedTasks.length > 0) {
|
setConfirmModelConfig({
|
||||||
const movedIds = new Set(movedTasks.map(c => c.id))
|
isOpen: true,
|
||||||
const applyMove = list =>
|
cancelText: t('choreView.cancel'),
|
||||||
list.map(c => (movedIds.has(c.id) ? { ...c, projectId } : c))
|
...confirm,
|
||||||
setChores(applyMove)
|
onClose: async isConfirmed => {
|
||||||
setFilteredChores(applyMove)
|
setConfirmModelConfig({})
|
||||||
showSuccess({
|
if (isConfirmed !== true) return
|
||||||
title: 'Tasks Moved',
|
try {
|
||||||
message: `Moved ${movedTasks.length} task${movedTasks.length > 1 ? 's' : ''} to ${project?.name || 'Default Project'}.`,
|
await execute()
|
||||||
})
|
} catch (error) {
|
||||||
}
|
showError({
|
||||||
if (failedTasks.length > 0) {
|
title: failedTitle || `Bulk ${failureVerb} failed`,
|
||||||
showError({
|
message: t('archived.unexpectedError'),
|
||||||
title: t('archived.someFailedTitle'),
|
})
|
||||||
message: `${failedTasks.length} task${failedTasks.length > 1 ? 's' : ''} could not be moved.`,
|
}
|
||||||
})
|
},
|
||||||
}
|
})
|
||||||
|
|
||||||
refetchChores()
|
|
||||||
clearSelection()
|
|
||||||
},
|
},
|
||||||
[
|
[
|
||||||
chores,
|
|
||||||
getSelectedChoresData,
|
|
||||||
setChores,
|
|
||||||
setFilteredChores,
|
|
||||||
showSuccess,
|
showSuccess,
|
||||||
showError,
|
showError,
|
||||||
refetchChores,
|
refetchChores,
|
||||||
clearSelection,
|
clearSelection,
|
||||||
|
setConfirmModelConfig,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleBulkComplete = useCallback(async () => {
|
||||||
|
const targets = getSelectedChoresData(chores)
|
||||||
|
runBulk({
|
||||||
|
targets,
|
||||||
|
confirm: {
|
||||||
|
title: t('actions.bulk.completeTitle'),
|
||||||
|
confirmText: t('list.complete'),
|
||||||
|
message: `Mark ${taskCount(targets.length)} as completed?`,
|
||||||
|
},
|
||||||
|
perChore: chore =>
|
||||||
|
expectOk(
|
||||||
|
MarkChoreComplete(
|
||||||
|
chore.id,
|
||||||
|
impersonatedUser ? { completedBy: impersonatedUser.userId } : null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
successTitle: t('actions.bulk.completedTitle'),
|
||||||
|
successVerb: 'Completed',
|
||||||
|
failureVerb: 'completed',
|
||||||
|
failedTitle: t('actions.bulk.completeFailedTitle'),
|
||||||
|
})
|
||||||
|
}, [getSelectedChoresData, chores, impersonatedUser, runBulk])
|
||||||
|
|
||||||
|
const handleBulkSkip = useCallback(async () => {
|
||||||
|
const targets = getSelectedChoresData(chores)
|
||||||
|
runBulk({
|
||||||
|
targets,
|
||||||
|
confirm: {
|
||||||
|
title: t('actions.bulk.skipTitle'),
|
||||||
|
confirmText: t('multiToolbar.skip'),
|
||||||
|
message: `Skip ${taskCount(targets.length)} to next due date?`,
|
||||||
|
},
|
||||||
|
perChore: chore => expectOk(SkipChore(chore.id)),
|
||||||
|
successTitle: t('actions.bulk.skippedTitle'),
|
||||||
|
successVerb: 'Skipped',
|
||||||
|
failureVerb: 'skipped',
|
||||||
|
failedTitle: t('actions.bulk.skipFailedTitle'),
|
||||||
|
buildUndo: succeeded => async () => {
|
||||||
|
try {
|
||||||
|
for (const chore of succeeded) {
|
||||||
|
await UndoChoreAction(chore.id)
|
||||||
|
}
|
||||||
|
queryClient.invalidateQueries(['chores'])
|
||||||
|
showUndo({
|
||||||
|
title: t('choreView.undoSuccessful'),
|
||||||
|
message: `Undo skip for ${taskCount(succeeded.length)}.`,
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
showError({
|
||||||
|
title: t('choreView.undoFailed'),
|
||||||
|
message: t('choreView.undoFailedMessage'),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}, [getSelectedChoresData, chores, runBulk, queryClient, showUndo, showError])
|
||||||
|
|
||||||
|
const handleBulkArchive = useCallback(async () => {
|
||||||
|
const targets = getSelectedChoresData(chores)
|
||||||
|
runBulk({
|
||||||
|
targets,
|
||||||
|
confirm: {
|
||||||
|
title: t('actions.bulk.archiveTitle'),
|
||||||
|
confirmText: t('actionMenu.archive'),
|
||||||
|
message: `Archive ${taskCount(targets.length)}?`,
|
||||||
|
},
|
||||||
|
perChore: chore =>
|
||||||
|
new Promise((resolve, reject) => {
|
||||||
|
archiveChore.mutate(chore.id, {
|
||||||
|
onSuccess: resolve,
|
||||||
|
onError: reject,
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
successTitle: t('actions.bulk.archivedTitle'),
|
||||||
|
successVerb: 'Archived',
|
||||||
|
failureVerb: 'archived',
|
||||||
|
failedTitle: t('actions.bulk.archiveFailedTitle'),
|
||||||
|
onSucceeded: succeeded => removeLocalChores(succeeded.map(c => c.id)),
|
||||||
|
})
|
||||||
|
}, [getSelectedChoresData, chores, runBulk, archiveChore, removeLocalChores])
|
||||||
|
|
||||||
|
const handleBulkDelete = useCallback(async () => {
|
||||||
|
const targets = getSelectedChoresData(chores)
|
||||||
|
runBulk({
|
||||||
|
targets,
|
||||||
|
confirm: {
|
||||||
|
title: t('actions.bulk.deleteTitle'),
|
||||||
|
confirmText: t('archived.delete'),
|
||||||
|
message: `Delete ${taskCount(targets.length)}?\n\nThis action cannot be undone.`,
|
||||||
|
},
|
||||||
|
perChore: chore => expectOk(DeleteChore(chore.id)),
|
||||||
|
successTitle: t('archived.deletedBulkTitle'),
|
||||||
|
successVerb: 'Deleted',
|
||||||
|
failureVerb: 'deleted',
|
||||||
|
failedTitle: t('archived.bulkDeleteFailTitle'),
|
||||||
|
onSucceeded: succeeded => removeLocalChores(succeeded.map(c => c.id)),
|
||||||
|
})
|
||||||
|
}, [getSelectedChoresData, chores, runBulk, removeLocalChores])
|
||||||
|
|
||||||
|
const handleBulkMoveToProject = useCallback(
|
||||||
|
async project => {
|
||||||
|
const projectId = project?.id ?? null
|
||||||
|
runBulk({
|
||||||
|
targets: getSelectedChoresData(chores),
|
||||||
|
perChore: chore => expectOk(SaveChore({ ...chore, projectId })),
|
||||||
|
successTitle: 'Tasks Moved',
|
||||||
|
successVerb: `Moved to ${project?.name || 'Default Project'} —`,
|
||||||
|
failureVerb: 'moved',
|
||||||
|
onSucceeded: succeeded =>
|
||||||
|
patchLocalChores(
|
||||||
|
succeeded.map(c => c.id),
|
||||||
|
{ projectId },
|
||||||
|
),
|
||||||
|
})
|
||||||
|
},
|
||||||
|
[getSelectedChoresData, chores, runBulk, patchLocalChores],
|
||||||
|
)
|
||||||
|
|
||||||
|
// Takes the picker's { dueDateOnly, dueTime, useCustomTime } parts, or null to
|
||||||
|
// unplan. Moving a batch is a date operation: each task keeps the time of day
|
||||||
|
// it was already due at, so "next week 5am" moved to tomorrow becomes
|
||||||
|
// "tomorrow 5am", and a task with no specific time stays at anytime. Only a
|
||||||
|
// time the user explicitly picked overrides that, for the whole selection.
|
||||||
|
const handleBulkDueDate = useCallback(
|
||||||
|
async parts => {
|
||||||
|
const clearing = !parts?.dueDateOnly
|
||||||
|
|
||||||
|
const dueDateFor = chore => {
|
||||||
|
if (clearing) return null
|
||||||
|
if (parts.useCustomTime && parts.dueTime) {
|
||||||
|
return atTimeOfDay(parts.dueDateOnly, parts.dueTime)
|
||||||
|
}
|
||||||
|
// A task with no due date yet has no hour to carry over, so it lands on
|
||||||
|
// end of day like anything else without a specific time.
|
||||||
|
const current = moment(chore.nextDueDate)
|
||||||
|
return atTimeOfDay(
|
||||||
|
parts.dueDateOnly,
|
||||||
|
chore.nextDueDate && current.isValid()
|
||||||
|
? current.format('HH:mm')
|
||||||
|
: null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
runBulk({
|
||||||
|
targets: getSelectedChoresData(chores),
|
||||||
|
perChore: chore => expectOk(UpdateDueDate(chore.id, dueDateFor(chore))),
|
||||||
|
successTitle: clearing ? 'Due Date Removed' : 'Tasks Scheduled',
|
||||||
|
successVerb: clearing ? 'Unplanned' : 'Rescheduled',
|
||||||
|
failureVerb: clearing ? 'unplanned' : 'rescheduled',
|
||||||
|
onSucceeded: succeeded =>
|
||||||
|
patchLocalChores(
|
||||||
|
succeeded.map(c => c.id),
|
||||||
|
chore => ({
|
||||||
|
nextDueDate: dueDateFor(chore),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
})
|
||||||
|
},
|
||||||
|
[getSelectedChoresData, chores, runBulk, patchLocalChores],
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleBulkAssignee = useCallback(
|
||||||
|
async assigneeId => {
|
||||||
|
runBulk({
|
||||||
|
targets: getSelectedChoresData(chores),
|
||||||
|
perChore: chore => expectOk(UpdateChoreAssignee(chore.id, assigneeId)),
|
||||||
|
successTitle: 'Tasks Reassigned',
|
||||||
|
successVerb: 'Reassigned',
|
||||||
|
failureVerb: 'reassigned',
|
||||||
|
onSucceeded: succeeded =>
|
||||||
|
patchLocalChores(
|
||||||
|
succeeded.map(c => c.id),
|
||||||
|
{ assignedTo: assigneeId },
|
||||||
|
),
|
||||||
|
})
|
||||||
|
},
|
||||||
|
[getSelectedChoresData, chores, runBulk, patchLocalChores],
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleBulkPriority = useCallback(
|
||||||
|
async priority => {
|
||||||
|
runBulk({
|
||||||
|
targets: getSelectedChoresData(chores),
|
||||||
|
perChore: chore => expectOk(UpdateChorePriority(chore.id, priority)),
|
||||||
|
successTitle: 'Priority Updated',
|
||||||
|
successVerb: 'Updated priority on',
|
||||||
|
failureVerb: 'updated',
|
||||||
|
onSucceeded: succeeded =>
|
||||||
|
patchLocalChores(
|
||||||
|
succeeded.map(c => c.id),
|
||||||
|
{ priority },
|
||||||
|
),
|
||||||
|
})
|
||||||
|
},
|
||||||
|
[getSelectedChoresData, chores, runBulk, patchLocalChores],
|
||||||
|
)
|
||||||
|
|
||||||
|
// Add/remove rather than replace: a mixed selection has no single "current"
|
||||||
|
// label set, and replacing would silently drop labels the user never saw.
|
||||||
|
// There is no per-label endpoint, so this goes through a full chore save.
|
||||||
|
const handleBulkLabels = useCallback(
|
||||||
|
async (label, mode) => {
|
||||||
|
if (!label) return
|
||||||
|
const selected = getSelectedChoresData(chores)
|
||||||
|
const nextLabelsFor = chore => {
|
||||||
|
const current = chore.labelsV2 || []
|
||||||
|
return mode === 'add'
|
||||||
|
? [...current, label]
|
||||||
|
: current.filter(l => l.id !== label.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Chores already in the desired state aren't worth a round trip, and
|
||||||
|
// counting them would inflate the toast.
|
||||||
|
const targets = selected.filter(chore => {
|
||||||
|
const hasLabel = (chore.labelsV2 || []).some(l => l.id === label.id)
|
||||||
|
return mode === 'add' ? !hasLabel : hasLabel
|
||||||
|
})
|
||||||
|
|
||||||
|
if (targets.length === 0) {
|
||||||
|
showSuccess({
|
||||||
|
title: 'No Changes',
|
||||||
|
message:
|
||||||
|
mode === 'add'
|
||||||
|
? `Every selected task already has "${label.name}".`
|
||||||
|
: `No selected task has "${label.name}".`,
|
||||||
|
})
|
||||||
|
clearSelection()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
runBulk({
|
||||||
|
targets,
|
||||||
|
perChore: chore =>
|
||||||
|
expectOk(SaveChore({ ...chore, labelsV2: nextLabelsFor(chore) })),
|
||||||
|
successTitle: mode === 'add' ? 'Label Added' : 'Label Removed',
|
||||||
|
successVerb:
|
||||||
|
mode === 'add'
|
||||||
|
? `Added "${label.name}" to`
|
||||||
|
: `Removed "${label.name}" from`,
|
||||||
|
failureVerb: 'updated',
|
||||||
|
onSucceeded: succeeded =>
|
||||||
|
patchLocalChores(
|
||||||
|
succeeded.map(c => c.id),
|
||||||
|
chore => ({
|
||||||
|
labelsV2: nextLabelsFor(chore),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
})
|
||||||
|
},
|
||||||
|
[
|
||||||
|
getSelectedChoresData,
|
||||||
|
chores,
|
||||||
|
runBulk,
|
||||||
|
patchLocalChores,
|
||||||
|
showSuccess,
|
||||||
|
clearSelection,
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1224,5 +1294,9 @@ export const useChoreActions = ({
|
|||||||
handleBulkDelete,
|
handleBulkDelete,
|
||||||
handleBulkSkip,
|
handleBulkSkip,
|
||||||
handleBulkMoveToProject,
|
handleBulkMoveToProject,
|
||||||
|
handleBulkDueDate,
|
||||||
|
handleBulkAssignee,
|
||||||
|
handleBulkPriority,
|
||||||
|
handleBulkLabels,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
import Fuse from 'fuse.js'
|
import Fuse from 'fuse.js'
|
||||||
import { useCallback, useMemo, useState } from 'react'
|
import { useCallback, useMemo, useState } from 'react'
|
||||||
|
|
||||||
import { ChoreFilters, filterByProject } from '../../../utils/Chores'
|
import { ChoreFilters, filterByProject } from '../../../utils/Chores'
|
||||||
|
import { stripHtml } from '../../../utils/Helpers'
|
||||||
|
|
||||||
export const useChoreFilters = ({
|
export const useChoreFilters = ({
|
||||||
chores,
|
chores,
|
||||||
selectedProject,
|
|
||||||
impersonatedUser,
|
impersonatedUser,
|
||||||
|
selectedProject,
|
||||||
userProfile,
|
userProfile,
|
||||||
}) => {
|
}) => {
|
||||||
const [searchTerm, setSearchTerm] = useState('')
|
const [searchTerm, setSearchTerm] = useState('')
|
||||||
@@ -30,9 +32,14 @@ export const useChoreFilters = ({
|
|||||||
const searchableChores = chores.map(chore => ({
|
const searchableChores = chores.map(chore => ({
|
||||||
...chore,
|
...chore,
|
||||||
raw_label: chore.labelsV2?.map(label => label.name).join(' '),
|
raw_label: chore.labelsV2?.map(label => label.name).join(' '),
|
||||||
|
raw_description: stripHtml(chore.description),
|
||||||
}))
|
}))
|
||||||
return new Fuse(searchableChores, {
|
return new Fuse(searchableChores, {
|
||||||
keys: ['name', 'raw_label'],
|
keys: [
|
||||||
|
{ name: 'name', weight: 0.6 },
|
||||||
|
{ name: 'raw_label', weight: 0.25 },
|
||||||
|
{ name: 'raw_description', weight: 0.15 },
|
||||||
|
],
|
||||||
includeScore: true,
|
includeScore: true,
|
||||||
isCaseSensitive: false,
|
isCaseSensitive: false,
|
||||||
findAllMatches: true,
|
findAllMatches: true,
|
||||||
|
|||||||
@@ -25,17 +25,16 @@ export const useKeyboardShortcuts = ({
|
|||||||
|
|
||||||
const isHoldingCmdOrCtrl = event.ctrlKey || event.metaKey
|
const isHoldingCmdOrCtrl = event.ctrlKey || event.metaKey
|
||||||
|
|
||||||
if (isHoldingCmdOrCtrl && event.key === 'k') {
|
// Cmd/Ctrl + J opens the quick-add modal, + Shift opens the full create
|
||||||
|
// page. Cmd/Ctrl + K is reserved for global search and handled by
|
||||||
|
// GlobalSearchContext.
|
||||||
|
if (isHoldingCmdOrCtrl && event.key.toLowerCase() === 'j') {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
handlers.onOpenTaskModal()
|
if (event.shiftKey) {
|
||||||
return
|
handlers.onNavigateToCreate()
|
||||||
}
|
} else {
|
||||||
|
handlers.onOpenTaskModal()
|
||||||
if (addTaskModalOpen) return
|
}
|
||||||
|
|
||||||
if (isHoldingCmdOrCtrl && event.key === 'j') {
|
|
||||||
event.preventDefault()
|
|
||||||
handlers.onNavigateToCreate()
|
|
||||||
return
|
return
|
||||||
} else if (isHoldingCmdOrCtrl && event.key === 'x') {
|
} else if (isHoldingCmdOrCtrl && event.key === 'x') {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
|
|||||||
@@ -1,30 +1,44 @@
|
|||||||
import { useState, useCallback } from 'react'
|
import { useCallback, useRef, useState } from 'react'
|
||||||
|
|
||||||
|
// A field is "shared" only when every selected chore agrees on it. Anything
|
||||||
|
// else is mixed, which the bulk editor shows as an unset control rather than
|
||||||
|
// pretending one of the values is the current one.
|
||||||
|
const sharedValue = (items, pick) => {
|
||||||
|
if (items.length === 0) return { value: null, isMixed: false }
|
||||||
|
const first = pick(items[0])
|
||||||
|
const isMixed = items.some(item => pick(item) !== first)
|
||||||
|
return { value: isMixed ? null : first, isMixed }
|
||||||
|
}
|
||||||
|
|
||||||
|
const labelIdsOf = chore => (chore.labelsV2 || []).map(label => label.id)
|
||||||
|
|
||||||
export const useMultiSelect = () => {
|
export const useMultiSelect = () => {
|
||||||
const [isMultiSelectMode, setIsMultiSelectMode] = useState(false)
|
const [isMultiSelectMode, setIsMultiSelectMode] = useState(false)
|
||||||
const [selectedChores, setSelectedChores] = useState(new Set())
|
const [selectedChores, setSelectedChores] = useState(new Set())
|
||||||
|
// Anchor for shift-click range selection. A ref because it only matters at
|
||||||
|
// the moment of the next click and should never trigger a render.
|
||||||
|
const lastSelectedId = useRef(null)
|
||||||
|
|
||||||
const toggleMultiSelectMode = useCallback(() => {
|
const toggleMultiSelectMode = useCallback(() => {
|
||||||
const newMode = !isMultiSelectMode
|
setIsMultiSelectMode(prev => {
|
||||||
setIsMultiSelectMode(newMode)
|
if (!prev) setSelectedChores(new Set())
|
||||||
|
return !prev
|
||||||
|
})
|
||||||
|
lastSelectedId.current = null
|
||||||
|
}, [])
|
||||||
|
|
||||||
if (newMode) {
|
const toggleChoreSelection = useCallback(choreId => {
|
||||||
setSelectedChores(new Set())
|
setSelectedChores(prev => {
|
||||||
}
|
const next = new Set(prev)
|
||||||
}, [isMultiSelectMode])
|
if (next.has(choreId)) {
|
||||||
|
next.delete(choreId)
|
||||||
const toggleChoreSelection = useCallback(
|
|
||||||
choreId => {
|
|
||||||
const newSelection = new Set(selectedChores)
|
|
||||||
if (newSelection.has(choreId)) {
|
|
||||||
newSelection.delete(choreId)
|
|
||||||
} else {
|
} else {
|
||||||
newSelection.add(choreId)
|
next.add(choreId)
|
||||||
}
|
}
|
||||||
setSelectedChores(newSelection)
|
return next
|
||||||
},
|
})
|
||||||
[selectedChores],
|
lastSelectedId.current = choreId
|
||||||
)
|
}, [])
|
||||||
|
|
||||||
// Entry point for press-and-hold on a task card: turn multi-select on (if it
|
// Entry point for press-and-hold on a task card: turn multi-select on (if it
|
||||||
// isn't already) with that task selected.
|
// isn't already) with that task selected.
|
||||||
@@ -33,6 +47,7 @@ export const useMultiSelect = () => {
|
|||||||
if (!isMultiSelectMode) {
|
if (!isMultiSelectMode) {
|
||||||
setIsMultiSelectMode(true)
|
setIsMultiSelectMode(true)
|
||||||
setSelectedChores(new Set([choreId]))
|
setSelectedChores(new Set([choreId]))
|
||||||
|
lastSelectedId.current = choreId
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
toggleChoreSelection(choreId)
|
toggleChoreSelection(choreId)
|
||||||
@@ -40,6 +55,39 @@ export const useMultiSelect = () => {
|
|||||||
[isMultiSelectMode, toggleChoreSelection],
|
[isMultiSelectMode, toggleChoreSelection],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Shift-click: add everything between the previous click and this one, in the
|
||||||
|
// order the user actually sees them. Falls back to a plain toggle when there
|
||||||
|
// is no anchor yet or the anchor has scrolled out of the current list.
|
||||||
|
const selectChoreRange = useCallback(
|
||||||
|
(choreId, orderedChores = []) => {
|
||||||
|
const anchorId = lastSelectedId.current
|
||||||
|
if (anchorId === null || anchorId === choreId) {
|
||||||
|
toggleChoreSelection(choreId)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const anchorIndex = orderedChores.findIndex(c => c.id === anchorId)
|
||||||
|
const targetIndex = orderedChores.findIndex(c => c.id === choreId)
|
||||||
|
if (anchorIndex === -1 || targetIndex === -1) {
|
||||||
|
toggleChoreSelection(choreId)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const [from, to] =
|
||||||
|
anchorIndex < targetIndex
|
||||||
|
? [anchorIndex, targetIndex]
|
||||||
|
: [targetIndex, anchorIndex]
|
||||||
|
|
||||||
|
setSelectedChores(prev => {
|
||||||
|
const next = new Set(prev)
|
||||||
|
for (let i = from; i <= to; i++) next.add(orderedChores[i].id)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
lastSelectedId.current = choreId
|
||||||
|
},
|
||||||
|
[toggleChoreSelection],
|
||||||
|
)
|
||||||
|
|
||||||
const selectAllVisibleChores = useCallback(
|
const selectAllVisibleChores = useCallback(
|
||||||
(visibleChores, choreSections = [], openChoreSections = {}) => {
|
(visibleChores, choreSections = [], openChoreSections = {}) => {
|
||||||
let choresToSelect = []
|
let choresToSelect = []
|
||||||
@@ -65,8 +113,7 @@ export const useMultiSelect = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (choresToSelect.length > 0) {
|
if (choresToSelect.length > 0) {
|
||||||
const allIds = new Set(choresToSelect.map(chore => chore.id))
|
setSelectedChores(new Set(choresToSelect.map(chore => chore.id)))
|
||||||
setSelectedChores(allIds)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return choresToSelect.length
|
return choresToSelect.length
|
||||||
@@ -75,6 +122,7 @@ export const useMultiSelect = () => {
|
|||||||
)
|
)
|
||||||
|
|
||||||
const clearSelection = useCallback(() => {
|
const clearSelection = useCallback(() => {
|
||||||
|
lastSelectedId.current = null
|
||||||
if (selectedChores.size === 0) {
|
if (selectedChores.size === 0) {
|
||||||
setIsMultiSelectMode(false)
|
setIsMultiSelectMode(false)
|
||||||
return
|
return
|
||||||
@@ -84,22 +132,79 @@ export const useMultiSelect = () => {
|
|||||||
|
|
||||||
const getSelectedChoresData = useCallback(
|
const getSelectedChoresData = useCallback(
|
||||||
allChores => {
|
allChores => {
|
||||||
|
if (selectedChores.size === 0) return []
|
||||||
|
const byId = new Map(allChores.map(chore => [chore.id, chore]))
|
||||||
return Array.from(selectedChores)
|
return Array.from(selectedChores)
|
||||||
.map(id => allChores.find(chore => chore.id === id))
|
.map(id => byId.get(id))
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
},
|
},
|
||||||
[selectedChores],
|
[selectedChores],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// What the bulk editor needs to render its controls: the current value where
|
||||||
|
// the selection agrees, and which labels are on all / only some of them so
|
||||||
|
// "add" and "remove" can be offered accurately.
|
||||||
|
const getSelectionSummary = useCallback(
|
||||||
|
allChores => {
|
||||||
|
const selected = getSelectedChoresData(allChores)
|
||||||
|
|
||||||
|
const labelCounts = new Map()
|
||||||
|
const labelsById = new Map()
|
||||||
|
selected.forEach(chore => {
|
||||||
|
;(chore.labelsV2 || []).forEach(label => {
|
||||||
|
labelsById.set(label.id, label)
|
||||||
|
labelCounts.set(label.id, (labelCounts.get(label.id) || 0) + 1)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const commonLabelIds = []
|
||||||
|
const partialLabelIds = []
|
||||||
|
labelCounts.forEach((count, id) => {
|
||||||
|
if (count === selected.length) commonLabelIds.push(id)
|
||||||
|
else partialLabelIds.push(id)
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
count: selected.length,
|
||||||
|
assignee: sharedValue(selected, c => c.assignedTo),
|
||||||
|
priority: sharedValue(selected, c => c.priority),
|
||||||
|
dueDate: sharedValue(selected, c => c.nextDueDate),
|
||||||
|
project: sharedValue(selected, c => c.projectId ?? null),
|
||||||
|
labels: {
|
||||||
|
byId: labelsById,
|
||||||
|
common: commonLabelIds,
|
||||||
|
partial: partialLabelIds,
|
||||||
|
// Anything present on at least one chore can be removed.
|
||||||
|
removable: [...commonLabelIds, ...partialLabelIds],
|
||||||
|
},
|
||||||
|
// Candidate assignees common to the whole selection. An empty (or
|
||||||
|
// absent) `assignees` list on a chore means "anyone", so those chores
|
||||||
|
// place no restriction. null here means no restriction at all, and the
|
||||||
|
// caller should offer every circle member.
|
||||||
|
assignableUserIds: selected.reduce((acc, chore) => {
|
||||||
|
const ids = (chore.assignees || []).map(a => a.userId)
|
||||||
|
if (ids.length === 0) return acc
|
||||||
|
if (acc === null) return ids
|
||||||
|
return acc.filter(id => ids.includes(id))
|
||||||
|
}, null),
|
||||||
|
hasArchived: selected.some(chore => chore.isActive === false),
|
||||||
|
labelIdsOf,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[getSelectedChoresData],
|
||||||
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
isMultiSelectMode,
|
isMultiSelectMode,
|
||||||
selectedChores,
|
selectedChores,
|
||||||
toggleMultiSelectMode,
|
toggleMultiSelectMode,
|
||||||
toggleChoreSelection,
|
toggleChoreSelection,
|
||||||
|
selectChoreRange,
|
||||||
enterMultiSelectWithChore,
|
enterMultiSelectWithChore,
|
||||||
selectAllVisibleChores,
|
selectAllVisibleChores,
|
||||||
clearSelection,
|
clearSelection,
|
||||||
getSelectedChoresData,
|
getSelectedChoresData,
|
||||||
|
getSelectionSummary,
|
||||||
setIsMultiSelectMode,
|
setIsMultiSelectMode,
|
||||||
setSelectedChores,
|
setSelectedChores,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,23 @@
|
|||||||
|
import '@meauxt/react-swipeable-list/dist/styles.css'
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Type as ListType,
|
|
||||||
SwipeableList,
|
SwipeableList,
|
||||||
SwipeableListItem,
|
SwipeableListItem,
|
||||||
SwipeAction,
|
SwipeAction,
|
||||||
TrailingActions,
|
TrailingActions,
|
||||||
|
Type as ListType,
|
||||||
} from '@meauxt/react-swipeable-list'
|
} from '@meauxt/react-swipeable-list'
|
||||||
import '@meauxt/react-swipeable-list/dist/styles.css'
|
import {
|
||||||
|
Add,
|
||||||
|
Close,
|
||||||
|
FilterAlt,
|
||||||
|
MoreVert,
|
||||||
|
Search,
|
||||||
|
SearchOff,
|
||||||
|
Star,
|
||||||
|
StarBorder,
|
||||||
|
Task,
|
||||||
|
} from '@mui/icons-material'
|
||||||
import DeleteIcon from '@mui/icons-material/Delete'
|
import DeleteIcon from '@mui/icons-material/Delete'
|
||||||
import EditIcon from '@mui/icons-material/Edit'
|
import EditIcon from '@mui/icons-material/Edit'
|
||||||
import {
|
import {
|
||||||
@@ -15,26 +27,20 @@ import {
|
|||||||
CircularProgress,
|
CircularProgress,
|
||||||
Container,
|
Container,
|
||||||
IconButton,
|
IconButton,
|
||||||
|
Input,
|
||||||
Stack,
|
Stack,
|
||||||
Typography,
|
Typography,
|
||||||
} from '@mui/joy'
|
} from '@mui/joy'
|
||||||
import { useEffect, useMemo, useState } from 'react'
|
import Fuse from 'fuse.js'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||||
|
import { useNavigate, useSearchParams } from 'react-router-dom'
|
||||||
|
|
||||||
import {
|
|
||||||
Add,
|
|
||||||
FilterAlt,
|
|
||||||
MoreVert,
|
|
||||||
Star,
|
|
||||||
StarBorder,
|
|
||||||
Task,
|
|
||||||
} from '@mui/icons-material'
|
|
||||||
import EmptyState from '../../components/common/EmptyState'
|
import EmptyState from '../../components/common/EmptyState'
|
||||||
|
import SortAndFilterMenu from '../../components/common/SortAndFilterMenu'
|
||||||
import { useChores } from '../../queries/ChoreQueries'
|
import { useChores } from '../../queries/ChoreQueries'
|
||||||
import { useCircleMembers, useUserProfile } from '../../queries/UserQueries'
|
import { useCircleMembers, useUserProfile } from '../../queries/UserQueries'
|
||||||
import { getFilterCount, getFilterOverdueCount } from '../../utils/FilterEngine'
|
import { getFilterCount, getFilterOverdueCount } from '../../utils/FilterEngine'
|
||||||
import { getSafeBottomStyles } from '../../utils/SafeAreaUtils'
|
import { getSafeBottomStyles } from '../../utils/SafeAreaUtils'
|
||||||
|
|
||||||
import { useLabels } from '../Labels/LabelQueries'
|
import { useLabels } from '../Labels/LabelQueries'
|
||||||
import AdvancedFilterBuilder from '../Modals/Inputs/AdvancedFilterBuilder'
|
import AdvancedFilterBuilder from '../Modals/Inputs/AdvancedFilterBuilder'
|
||||||
import ConfirmationModal from '../Modals/Inputs/ConfirmationModal'
|
import ConfirmationModal from '../Modals/Inputs/ConfirmationModal'
|
||||||
@@ -50,9 +56,9 @@ import { useTranslation } from 'react-i18next'
|
|||||||
|
|
||||||
const FilterCardContent = ({
|
const FilterCardContent = ({
|
||||||
filter,
|
filter,
|
||||||
taskCount = 0,
|
|
||||||
overdueCount = 0,
|
|
||||||
onToggleActions,
|
onToggleActions,
|
||||||
|
overdueCount = 0,
|
||||||
|
taskCount = 0,
|
||||||
}) => {
|
}) => {
|
||||||
// Get condition labels for display
|
// Get condition labels for display
|
||||||
const getConditionSummary = () => {
|
const getConditionSummary = () => {
|
||||||
@@ -248,6 +254,7 @@ const FilterCardContent = ({
|
|||||||
const FilterView = () => {
|
const FilterView = () => {
|
||||||
const { t } = useTranslation('filters')
|
const { t } = useTranslation('filters')
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams()
|
||||||
const { data: userProfile } = useUserProfile()
|
const { data: userProfile } = useUserProfile()
|
||||||
const { data: chores = { res: [] } } = useChores(false)
|
const { data: chores = { res: [] } } = useChores(false)
|
||||||
const { data: labels = [] } = useLabels()
|
const { data: labels = [] } = useLabels()
|
||||||
@@ -267,6 +274,21 @@ const FilterView = () => {
|
|||||||
const [editingFilter, setEditingFilter] = useState(null)
|
const [editingFilter, setEditingFilter] = useState(null)
|
||||||
const [confirmationModel, setConfirmationModel] = useState({})
|
const [confirmationModel, setConfirmationModel] = useState({})
|
||||||
const [showMoreInfoId, setShowMoreInfoId] = useState(null)
|
const [showMoreInfoId, setShowMoreInfoId] = useState(null)
|
||||||
|
const [searchTerm, setSearchTerm] = useState('')
|
||||||
|
const [sortBy, setSortBy] = useState(
|
||||||
|
() => localStorage.getItem('filtersSortBy') || 'smart',
|
||||||
|
)
|
||||||
|
const [sortDirection, setSortDirection] = useState(
|
||||||
|
() => localStorage.getItem('filtersSortDirection') || 'asc',
|
||||||
|
)
|
||||||
|
const [pinnedFilter, setPinnedFilter] = useState('all')
|
||||||
|
const searchInputRef = useRef(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
localStorage.setItem('filtersSortBy', sortBy)
|
||||||
|
localStorage.setItem('filtersSortDirection', sortDirection)
|
||||||
|
}, [sortBy, sortDirection])
|
||||||
|
|
||||||
// Sort filters: pinned first, then by usage count, then by last used
|
// Sort filters: pinned first, then by usage count, then by last used
|
||||||
const savedFilters = useMemo(() => {
|
const savedFilters = useMemo(() => {
|
||||||
return [...filtersData].sort((a, b) => {
|
return [...filtersData].sort((a, b) => {
|
||||||
@@ -283,6 +305,71 @@ const FilterView = () => {
|
|||||||
})
|
})
|
||||||
}, [filtersData])
|
}, [filtersData])
|
||||||
|
|
||||||
|
const visibleFilters = useMemo(() => {
|
||||||
|
if (pinnedFilter === 'pinned') {
|
||||||
|
return savedFilters.filter(filter => filter.isPinned)
|
||||||
|
}
|
||||||
|
if (pinnedFilter === 'unpinned') {
|
||||||
|
return savedFilters.filter(filter => !filter.isPinned)
|
||||||
|
}
|
||||||
|
return savedFilters
|
||||||
|
}, [pinnedFilter, savedFilters])
|
||||||
|
|
||||||
|
const fuse = useMemo(
|
||||||
|
() =>
|
||||||
|
new Fuse(visibleFilters, {
|
||||||
|
keys: ['name', 'description'],
|
||||||
|
includeScore: true,
|
||||||
|
isCaseSensitive: false,
|
||||||
|
findAllMatches: true,
|
||||||
|
}),
|
||||||
|
[visibleFilters],
|
||||||
|
)
|
||||||
|
|
||||||
|
const filteredFilters = useMemo(() => {
|
||||||
|
const matched = searchTerm
|
||||||
|
? fuse.search(searchTerm).map(result => result.item)
|
||||||
|
: visibleFilters
|
||||||
|
|
||||||
|
const direction = sortDirection === 'desc' ? -1 : 1
|
||||||
|
|
||||||
|
// "Smart" keeps the pinned-then-usage order the list already arrives in.
|
||||||
|
if (sortBy === 'smart') {
|
||||||
|
return direction === -1 ? [...matched].reverse() : matched
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...matched].sort((a, b) => {
|
||||||
|
switch (sortBy) {
|
||||||
|
case 'name':
|
||||||
|
return direction * (a.name || '').localeCompare(b.name || '')
|
||||||
|
case 'usage':
|
||||||
|
return direction * ((a.usageCount || 0) - (b.usageCount || 0))
|
||||||
|
case 'lastUsed': {
|
||||||
|
const aUsed = new Date(a.lastUsedAt || 0).getTime()
|
||||||
|
const bUsed = new Date(b.lastUsedAt || 0).getTime()
|
||||||
|
return direction * (aUsed - bUsed)
|
||||||
|
}
|
||||||
|
case 'created': {
|
||||||
|
const aCreated = new Date(a.createdAt || 0).getTime()
|
||||||
|
const bCreated = new Date(b.createdAt || 0).getTime()
|
||||||
|
return direction * (aCreated - bCreated)
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}, [fuse, searchTerm, visibleFilters, sortBy, sortDirection])
|
||||||
|
|
||||||
|
const handleSearchChange = e => {
|
||||||
|
setSearchTerm(e.target.value)
|
||||||
|
setShowMoreInfoId(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSearchClose = () => {
|
||||||
|
setSearchTerm('')
|
||||||
|
searchInputRef.current?.blur()
|
||||||
|
}
|
||||||
|
|
||||||
// Calculate task counts for each filter
|
// Calculate task counts for each filter
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (chores && chores.res && savedFilters.length > 0) {
|
if (chores && chores.res && savedFilters.length > 0) {
|
||||||
@@ -323,6 +410,21 @@ const FilterView = () => {
|
|||||||
setShowAdvancedFilterBuilder(true)
|
setShowAdvancedFilterBuilder(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ?create=1 lets other surfaces (global search quick actions) land here with
|
||||||
|
// the filter builder already open.
|
||||||
|
useEffect(() => {
|
||||||
|
if (searchParams.get('create') !== '1') return
|
||||||
|
setEditingFilter(null)
|
||||||
|
setShowAdvancedFilterBuilder(true)
|
||||||
|
setSearchParams(
|
||||||
|
params => {
|
||||||
|
params.delete('create')
|
||||||
|
return params
|
||||||
|
},
|
||||||
|
{ replace: true },
|
||||||
|
)
|
||||||
|
}, [searchParams, setSearchParams])
|
||||||
|
|
||||||
const handleEditFilter = filter => {
|
const handleEditFilter = filter => {
|
||||||
setEditingFilter(filter)
|
setEditingFilter(filter)
|
||||||
setShowAdvancedFilterBuilder(true)
|
setShowAdvancedFilterBuilder(true)
|
||||||
@@ -414,6 +516,68 @@ const FilterView = () => {
|
|||||||
</Stack>
|
</Stack>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
|
{savedFilters.length > 0 && (
|
||||||
|
<Box
|
||||||
|
sx={{ px: 2, mb: 2, display: 'flex', alignItems: 'center', gap: 1 }}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
slotProps={{ input: { ref: searchInputRef } }}
|
||||||
|
placeholder='Search filters'
|
||||||
|
value={searchTerm}
|
||||||
|
fullWidth
|
||||||
|
sx={{
|
||||||
|
borderRadius: 24,
|
||||||
|
height: 24,
|
||||||
|
borderColor: 'text.disabled',
|
||||||
|
padding: 1,
|
||||||
|
}}
|
||||||
|
onChange={handleSearchChange}
|
||||||
|
startDecorator={<Search />}
|
||||||
|
endDecorator={
|
||||||
|
searchTerm && (
|
||||||
|
<IconButton
|
||||||
|
variant='plain'
|
||||||
|
size='sm'
|
||||||
|
onClick={handleSearchClose}
|
||||||
|
sx={{ borderRadius: '50%' }}
|
||||||
|
>
|
||||||
|
<Close />
|
||||||
|
</IconButton>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<SortAndFilterMenu
|
||||||
|
sortOptions={[
|
||||||
|
{ name: 'Smart', value: 'smart' },
|
||||||
|
{ name: 'Name', value: 'name' },
|
||||||
|
{ name: 'Usage count', value: 'usage' },
|
||||||
|
{ name: 'Last used', value: 'lastUsed' },
|
||||||
|
{ name: 'Created date', value: 'created' },
|
||||||
|
]}
|
||||||
|
selectedSort={sortBy}
|
||||||
|
onSortChange={setSortBy}
|
||||||
|
sortDirection={sortDirection}
|
||||||
|
onSortDirectionChange={setSortDirection}
|
||||||
|
filterTitle='Show'
|
||||||
|
filterOptions={[
|
||||||
|
{ name: 'All filters', value: 'all' },
|
||||||
|
{ name: 'Pinned', value: 'pinned' },
|
||||||
|
{ name: 'Not pinned', value: 'unpinned' },
|
||||||
|
]}
|
||||||
|
selectedFilter={pinnedFilter}
|
||||||
|
onFilterChange={value => {
|
||||||
|
setPinnedFilter(value)
|
||||||
|
setShowMoreInfoId(null)
|
||||||
|
}}
|
||||||
|
isActive={
|
||||||
|
pinnedFilter !== 'all' ||
|
||||||
|
sortBy !== 'smart' ||
|
||||||
|
sortDirection !== 'asc'
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
@@ -431,9 +595,28 @@ const FilterView = () => {
|
|||||||
onClick: handleAddFilter,
|
onClick: handleAddFilter,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
) : filteredFilters.length === 0 ? (
|
||||||
|
<EmptyState
|
||||||
|
variant='no-results'
|
||||||
|
fullHeight
|
||||||
|
icon={<SearchOff />}
|
||||||
|
title='No filters match'
|
||||||
|
description={
|
||||||
|
searchTerm
|
||||||
|
? `No saved filter matches "${searchTerm}".`
|
||||||
|
: 'No saved filter matches the current filter.'
|
||||||
|
}
|
||||||
|
primaryAction={{
|
||||||
|
label: searchTerm ? 'Clear search' : 'Show all filters',
|
||||||
|
onClick: () => {
|
||||||
|
handleSearchClose()
|
||||||
|
setPinnedFilter('all')
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
<SwipeableList type={ListType.IOS} fullSwipe={false}>
|
<SwipeableList type={ListType.IOS} fullSwipe={false}>
|
||||||
{savedFilters.map(filter => {
|
{filteredFilters.map(filter => {
|
||||||
return (
|
return (
|
||||||
<SwipeableListItem
|
<SwipeableListItem
|
||||||
swipeActionOpen={
|
swipeActionOpen={
|
||||||
|
|||||||
405
src/views/Labels/LabelDetailView.jsx
Normal file
405
src/views/Labels/LabelDetailView.jsx
Normal file
@@ -0,0 +1,405 @@
|
|||||||
|
import {
|
||||||
|
Close,
|
||||||
|
Delete as DeleteIcon,
|
||||||
|
Edit as EditIcon,
|
||||||
|
MoreVert,
|
||||||
|
Search,
|
||||||
|
SearchOff,
|
||||||
|
Style,
|
||||||
|
ViewAgenda,
|
||||||
|
ViewModule,
|
||||||
|
} from '@mui/icons-material'
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Chip,
|
||||||
|
Container,
|
||||||
|
Divider,
|
||||||
|
Dropdown,
|
||||||
|
IconButton,
|
||||||
|
Input,
|
||||||
|
List,
|
||||||
|
Menu,
|
||||||
|
MenuButton,
|
||||||
|
MenuItem,
|
||||||
|
Stack,
|
||||||
|
Typography,
|
||||||
|
} from '@mui/joy'
|
||||||
|
import { useQueryClient } from '@tanstack/react-query'
|
||||||
|
import Fuse from 'fuse.js'
|
||||||
|
import moment from 'moment'
|
||||||
|
import { useMemo, useRef, useState } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { useNavigate, useParams } from 'react-router-dom'
|
||||||
|
|
||||||
|
import EmptyState from '../../components/common/EmptyState'
|
||||||
|
import { useChores } from '../../queries/ChoreQueries'
|
||||||
|
import { useCircleMembers, useUserProfile } from '../../queries/UserQueries'
|
||||||
|
import { DeleteLabel } from '../../utils/Fetcher'
|
||||||
|
import ChoreListView from '../Chores/ChoreListView'
|
||||||
|
import LoadingComponent from '../components/Loading'
|
||||||
|
import ConfirmationModal from '../Modals/Inputs/ConfirmationModal'
|
||||||
|
import LabelModal from '../Modals/Inputs/LabelModal'
|
||||||
|
import { useLabels } from './LabelQueries'
|
||||||
|
|
||||||
|
const EMPTY_SELECTION = new Set()
|
||||||
|
|
||||||
|
const LabelDetailView = () => {
|
||||||
|
const { t } = useTranslation('labels')
|
||||||
|
const { labelId } = useParams()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
|
const { data: labels, isLoading: isLabelsLoading } = useLabels()
|
||||||
|
const { data: choresData, isLoading: isChoresLoading } = useChores(false)
|
||||||
|
const { data: membersData, isLoading: isMembersLoading } = useCircleMembers()
|
||||||
|
const { data: userProfile } = useUserProfile()
|
||||||
|
|
||||||
|
const [searchTerm, setSearchTerm] = useState('')
|
||||||
|
const [statusFilter, setStatusFilter] = useState('all')
|
||||||
|
const [modalOpen, setModalOpen] = useState(false)
|
||||||
|
const [confirmationModel, setConfirmationModel] = useState({})
|
||||||
|
const [viewMode, setViewMode] = useState(
|
||||||
|
localStorage.getItem('labelDetailViewMode') || 'default',
|
||||||
|
)
|
||||||
|
const searchInputRef = useRef(null)
|
||||||
|
|
||||||
|
const label = useMemo(
|
||||||
|
() => (labels || []).find(item => String(item.id) === String(labelId)),
|
||||||
|
[labels, labelId],
|
||||||
|
)
|
||||||
|
|
||||||
|
// Tasks carrying this label, soonest due first — undated tasks sink to the
|
||||||
|
// bottom rather than sorting as epoch 0.
|
||||||
|
const labelChores = useMemo(() => {
|
||||||
|
const chores = choresData?.res || []
|
||||||
|
return chores
|
||||||
|
.filter(chore =>
|
||||||
|
chore.labelsV2?.some(item => String(item.id) === String(labelId)),
|
||||||
|
)
|
||||||
|
.sort((a, b) => {
|
||||||
|
if (!a.nextDueDate) return 1
|
||||||
|
if (!b.nextDueDate) return -1
|
||||||
|
return new Date(a.nextDueDate) - new Date(b.nextDueDate)
|
||||||
|
})
|
||||||
|
}, [choresData, labelId])
|
||||||
|
|
||||||
|
// Buckets are exclusive: a task due at 9am today is overdue by 3pm, and
|
||||||
|
// counting it under both "overdue" and "today" would make the chips add up
|
||||||
|
// to more than the task count.
|
||||||
|
const bucketOf = chore => {
|
||||||
|
if (!chore.nextDueDate) return 'undated'
|
||||||
|
if (moment(chore.nextDueDate).isBefore()) return 'overdue'
|
||||||
|
if (moment(chore.nextDueDate).isSame(moment(), 'day')) return 'today'
|
||||||
|
return 'upcoming'
|
||||||
|
}
|
||||||
|
|
||||||
|
const counts = useMemo(() => {
|
||||||
|
const tally = { overdue: 0, today: 0, undated: 0 }
|
||||||
|
labelChores.forEach(chore => {
|
||||||
|
const bucket = bucketOf(chore)
|
||||||
|
if (bucket in tally) tally[bucket] += 1
|
||||||
|
})
|
||||||
|
return { ...tally, all: labelChores.length }
|
||||||
|
}, [labelChores])
|
||||||
|
|
||||||
|
const fuse = useMemo(
|
||||||
|
() =>
|
||||||
|
new Fuse(labelChores, {
|
||||||
|
keys: ['name', 'description'],
|
||||||
|
includeScore: true,
|
||||||
|
isCaseSensitive: false,
|
||||||
|
findAllMatches: true,
|
||||||
|
}),
|
||||||
|
[labelChores],
|
||||||
|
)
|
||||||
|
|
||||||
|
const visibleChores = useMemo(() => {
|
||||||
|
const searched = searchTerm
|
||||||
|
? fuse.search(searchTerm).map(result => result.item)
|
||||||
|
: labelChores
|
||||||
|
if (statusFilter === 'all') return searched
|
||||||
|
return searched.filter(chore => bucketOf(chore) === statusFilter)
|
||||||
|
}, [fuse, searchTerm, labelChores, statusFilter])
|
||||||
|
|
||||||
|
const handleSearchClose = () => {
|
||||||
|
setSearchTerm('')
|
||||||
|
searchInputRef.current?.blur()
|
||||||
|
}
|
||||||
|
|
||||||
|
const resetFilters = () => {
|
||||||
|
setSearchTerm('')
|
||||||
|
setStatusFilter('all')
|
||||||
|
searchInputRef.current?.blur()
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleViewMode = () => {
|
||||||
|
const newMode = viewMode === 'default' ? 'compact' : 'default'
|
||||||
|
setViewMode(newMode)
|
||||||
|
localStorage.setItem('labelDetailViewMode', newMode)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSaveLabel = () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['labels'] })
|
||||||
|
setModalOpen(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDeleteClicked = () => {
|
||||||
|
setConfirmationModel({
|
||||||
|
isOpen: true,
|
||||||
|
title: t('delete.title'),
|
||||||
|
message: t('delete.message'),
|
||||||
|
confirmText: t('common:delete'),
|
||||||
|
color: 'danger',
|
||||||
|
cancelText: t('common:cancel'),
|
||||||
|
onClose: confirmed => {
|
||||||
|
if (confirmed === true) {
|
||||||
|
DeleteLabel(label.id).then(() => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['labels'] })
|
||||||
|
navigate('/labels')
|
||||||
|
})
|
||||||
|
}
|
||||||
|
setConfirmationModel({})
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLabelsLoading || isChoresLoading || isMembersLoading) {
|
||||||
|
return <LoadingComponent />
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!label) {
|
||||||
|
return (
|
||||||
|
<Container maxWidth='md'>
|
||||||
|
<EmptyState
|
||||||
|
variant='error'
|
||||||
|
fullHeight
|
||||||
|
icon={<Style />}
|
||||||
|
title={t('detail.notFoundTitle')}
|
||||||
|
description={t('detail.notFoundDescription')}
|
||||||
|
primaryAction={{ label: t('detail.backToLabels'), to: '/labels' }}
|
||||||
|
/>
|
||||||
|
</Container>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const isOwnedByCurrentUser = label.created_by === userProfile?.id
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Container maxWidth='md'>
|
||||||
|
{/* Identity: the label's own color is what names this page, so it leads
|
||||||
|
the title rather than sitting in a decorative avatar. */}
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 1.5, mb: 2 }}>
|
||||||
|
<Stack sx={{ flex: 1, minWidth: 0, gap: 0.5 }}>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
width: 12,
|
||||||
|
height: 12,
|
||||||
|
borderRadius: '50%',
|
||||||
|
bgcolor: label.color,
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Typography
|
||||||
|
level='h3'
|
||||||
|
sx={{
|
||||||
|
fontWeight: 'lg',
|
||||||
|
color: 'text.primary',
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{label.name}
|
||||||
|
</Typography>
|
||||||
|
{!isOwnedByCurrentUser && (
|
||||||
|
<Chip size='sm' variant='soft' color='warning'>
|
||||||
|
{t('shared')}
|
||||||
|
</Chip>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
<Typography level='body-sm' sx={{ color: 'text.secondary' }}>
|
||||||
|
{t('detail.taskCount', { count: counts.all })}
|
||||||
|
</Typography>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
{/* One trailing target. Delete is destructive, so it lives behind the
|
||||||
|
overflow instead of being a naked icon next to the title. */}
|
||||||
|
<Dropdown>
|
||||||
|
<MenuButton
|
||||||
|
slots={{ root: IconButton }}
|
||||||
|
slotProps={{ root: { variant: 'plain', color: 'neutral' } }}
|
||||||
|
aria-label={t('detail.labelActions')}
|
||||||
|
>
|
||||||
|
<MoreVert />
|
||||||
|
</MenuButton>
|
||||||
|
<Menu placement='bottom-end'>
|
||||||
|
<MenuItem onClick={() => setModalOpen(true)}>
|
||||||
|
<EditIcon fontSize='small' />
|
||||||
|
{t('common:edit')}
|
||||||
|
</MenuItem>
|
||||||
|
<Divider />
|
||||||
|
<MenuItem color='danger' onClick={handleDeleteClicked}>
|
||||||
|
<DeleteIcon fontSize='small' />
|
||||||
|
{t('common:delete')}
|
||||||
|
</MenuItem>
|
||||||
|
</Menu>
|
||||||
|
</Dropdown>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Status chips double as the filter control: the counts users want to
|
||||||
|
read are the cuts they want to make. */}
|
||||||
|
{labelChores.length > 0 && (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
gap: 1,
|
||||||
|
mb: 2,
|
||||||
|
overflowX: 'auto',
|
||||||
|
pb: 0.5,
|
||||||
|
scrollbarWidth: 'none',
|
||||||
|
'&::-webkit-scrollbar': { display: 'none' },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{[
|
||||||
|
{ id: 'all', label: t('detail.filters.all'), color: 'neutral' },
|
||||||
|
{
|
||||||
|
id: 'overdue',
|
||||||
|
label: t('detail.filters.overdue'),
|
||||||
|
color: 'danger',
|
||||||
|
},
|
||||||
|
{ id: 'today', label: t('detail.filters.today'), color: 'primary' },
|
||||||
|
{
|
||||||
|
id: 'undated',
|
||||||
|
label: t('detail.filters.undated'),
|
||||||
|
color: 'neutral',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
.filter(chip => chip.id === 'all' || counts[chip.id] > 0)
|
||||||
|
.map(chip => {
|
||||||
|
const isSelected = statusFilter === chip.id
|
||||||
|
return (
|
||||||
|
<Chip
|
||||||
|
key={chip.id}
|
||||||
|
variant={isSelected ? 'solid' : 'soft'}
|
||||||
|
color={chip.color}
|
||||||
|
onClick={() => setStatusFilter(chip.id)}
|
||||||
|
aria-pressed={isSelected}
|
||||||
|
sx={{ flexShrink: 0 }}
|
||||||
|
endDecorator={
|
||||||
|
<Typography
|
||||||
|
level='body-xs'
|
||||||
|
sx={{ color: 'inherit', fontWeight: 'lg' }}
|
||||||
|
>
|
||||||
|
{counts[chip.id]}
|
||||||
|
</Typography>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{chip.label}
|
||||||
|
</Chip>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Search + view mode */}
|
||||||
|
{labelChores.length > 0 && (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 0.5,
|
||||||
|
mb: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
slotProps={{ input: { ref: searchInputRef } }}
|
||||||
|
placeholder={t('detail.searchPlaceholder')}
|
||||||
|
value={searchTerm}
|
||||||
|
fullWidth
|
||||||
|
sx={{
|
||||||
|
borderRadius: 24,
|
||||||
|
height: 24,
|
||||||
|
borderColor: 'text.disabled',
|
||||||
|
padding: 1,
|
||||||
|
}}
|
||||||
|
onChange={e => setSearchTerm(e.target.value.toLowerCase())}
|
||||||
|
startDecorator={<Search />}
|
||||||
|
endDecorator={
|
||||||
|
searchTerm && (
|
||||||
|
<IconButton
|
||||||
|
variant='plain'
|
||||||
|
size='sm'
|
||||||
|
onClick={handleSearchClose}
|
||||||
|
sx={{ borderRadius: '50%' }}
|
||||||
|
>
|
||||||
|
<Close />
|
||||||
|
</IconButton>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<IconButton
|
||||||
|
variant='outlined'
|
||||||
|
color='neutral'
|
||||||
|
size='sm'
|
||||||
|
sx={{ height: 32, width: 32, borderRadius: '50%' }}
|
||||||
|
onClick={toggleViewMode}
|
||||||
|
>
|
||||||
|
{viewMode === 'default' ? <ViewAgenda /> : <ViewModule />}
|
||||||
|
</IconButton>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Tasks */}
|
||||||
|
{labelChores.length === 0 ? (
|
||||||
|
<EmptyState
|
||||||
|
fullHeight
|
||||||
|
icon={<Style />}
|
||||||
|
title={t('detail.emptyTitle')}
|
||||||
|
description={t('detail.emptyDescription', { label: label.name })}
|
||||||
|
primaryAction={{ label: t('detail.browseTasks'), to: '/chores' }}
|
||||||
|
/>
|
||||||
|
) : visibleChores.length === 0 ? (
|
||||||
|
<EmptyState
|
||||||
|
variant='no-results'
|
||||||
|
fullHeight
|
||||||
|
icon={<SearchOff />}
|
||||||
|
title={t('detail.noResultsTitle')}
|
||||||
|
description={
|
||||||
|
searchTerm
|
||||||
|
? t('detail.noResultsDescription', { searchTerm })
|
||||||
|
: t('detail.noMatchingStatus')
|
||||||
|
}
|
||||||
|
primaryAction={{
|
||||||
|
label: t('detail.clearFilters'),
|
||||||
|
onClick: resetFilters,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<List sx={{ gap: viewMode === 'compact' ? 0 : 1 }}>
|
||||||
|
<ChoreListView
|
||||||
|
chores={visibleChores}
|
||||||
|
viewMode={viewMode}
|
||||||
|
membersData={membersData}
|
||||||
|
userLabels={labels}
|
||||||
|
userProfile={userProfile}
|
||||||
|
showActions={false}
|
||||||
|
selectedChores={EMPTY_SELECTION}
|
||||||
|
/>
|
||||||
|
</List>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{modalOpen && (
|
||||||
|
<LabelModal
|
||||||
|
isOpen={modalOpen}
|
||||||
|
onClose={() => setModalOpen(false)}
|
||||||
|
onSave={handleSaveLabel}
|
||||||
|
label={label}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<ConfirmationModal config={confirmationModel} />
|
||||||
|
</Container>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default LabelDetailView
|
||||||
@@ -1,3 +1,20 @@
|
|||||||
|
import '@meauxt/react-swipeable-list/dist/styles.css'
|
||||||
|
|
||||||
|
import {
|
||||||
|
SwipeableList,
|
||||||
|
SwipeableListItem,
|
||||||
|
SwipeAction,
|
||||||
|
TrailingActions,
|
||||||
|
Type as ListType,
|
||||||
|
} from '@meauxt/react-swipeable-list'
|
||||||
|
import {
|
||||||
|
Add,
|
||||||
|
Close,
|
||||||
|
MoreVert,
|
||||||
|
Search,
|
||||||
|
SearchOff,
|
||||||
|
Style,
|
||||||
|
} from '@mui/icons-material'
|
||||||
import DeleteIcon from '@mui/icons-material/Delete'
|
import DeleteIcon from '@mui/icons-material/Delete'
|
||||||
import EditIcon from '@mui/icons-material/Edit'
|
import EditIcon from '@mui/icons-material/Edit'
|
||||||
import {
|
import {
|
||||||
@@ -7,32 +24,27 @@ import {
|
|||||||
CircularProgress,
|
CircularProgress,
|
||||||
Container,
|
Container,
|
||||||
IconButton,
|
IconButton,
|
||||||
|
Input,
|
||||||
Stack,
|
Stack,
|
||||||
Typography,
|
Typography,
|
||||||
} from '@mui/joy'
|
} from '@mui/joy'
|
||||||
import { useEffect, useState } from 'react'
|
|
||||||
import { useTranslation } from 'react-i18next'
|
|
||||||
import LabelModal from '../Modals/Inputs/LabelModal'
|
|
||||||
|
|
||||||
import {
|
|
||||||
Type as ListType,
|
|
||||||
SwipeableList,
|
|
||||||
SwipeableListItem,
|
|
||||||
SwipeAction,
|
|
||||||
TrailingActions,
|
|
||||||
} from '@meauxt/react-swipeable-list'
|
|
||||||
import '@meauxt/react-swipeable-list/dist/styles.css'
|
|
||||||
import { Add, MoreVert, Style } from '@mui/icons-material'
|
|
||||||
import EmptyState from '../../components/common/EmptyState'
|
|
||||||
import { useQueryClient } from '@tanstack/react-query'
|
import { useQueryClient } from '@tanstack/react-query'
|
||||||
|
import Fuse from 'fuse.js'
|
||||||
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { useNavigate, useSearchParams } from 'react-router-dom'
|
||||||
|
|
||||||
|
import EmptyState from '../../components/common/EmptyState'
|
||||||
|
import SortAndFilterMenu from '../../components/common/SortAndFilterMenu'
|
||||||
import { useUserProfile } from '../../queries/UserQueries'
|
import { useUserProfile } from '../../queries/UserQueries'
|
||||||
import { getTextColorFromBackgroundColor } from '../../utils/Colors'
|
import { getTextColorFromBackgroundColor } from '../../utils/Colors'
|
||||||
import { DeleteLabel } from '../../utils/Fetcher'
|
import { DeleteLabel } from '../../utils/Fetcher'
|
||||||
import { getSafeBottomStyles } from '../../utils/SafeAreaUtils'
|
import { getSafeBottomStyles } from '../../utils/SafeAreaUtils'
|
||||||
import ConfirmationModal from '../Modals/Inputs/ConfirmationModal'
|
import ConfirmationModal from '../Modals/Inputs/ConfirmationModal'
|
||||||
|
import LabelModal from '../Modals/Inputs/LabelModal'
|
||||||
import { useLabels } from './LabelQueries'
|
import { useLabels } from './LabelQueries'
|
||||||
|
|
||||||
const LabelCardContent = ({ label, currentUserId, onToggleActions }) => {
|
const LabelCardContent = ({ currentUserId, label, onToggleActions }) => {
|
||||||
const { t } = useTranslation('labels')
|
const { t } = useTranslation('labels')
|
||||||
// Check if current user owns this label
|
// Check if current user owns this label
|
||||||
const isOwnedByCurrentUser = label.created_by === currentUserId
|
const isOwnedByCurrentUser = label.created_by === currentUserId
|
||||||
@@ -152,8 +164,10 @@ const LabelCardContent = ({ label, currentUserId, onToggleActions }) => {
|
|||||||
|
|
||||||
const LabelView = () => {
|
const LabelView = () => {
|
||||||
const { t } = useTranslation('labels')
|
const { t } = useTranslation('labels')
|
||||||
const { data: labels, isLabelsLoading, isError } = useLabels()
|
const { data: labels, isError, isLabelsLoading } = useLabels()
|
||||||
const { data: userProfile } = useUserProfile()
|
const { data: userProfile } = useUserProfile()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams()
|
||||||
|
|
||||||
const [userLabels, setUserLabels] = useState([])
|
const [userLabels, setUserLabels] = useState([])
|
||||||
const [modalOpen, setModalOpen] = useState(false)
|
const [modalOpen, setModalOpen] = useState(false)
|
||||||
@@ -162,6 +176,70 @@ const LabelView = () => {
|
|||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
const [confirmationModel, setConfirmationModel] = useState({})
|
const [confirmationModel, setConfirmationModel] = useState({})
|
||||||
const [showMoreInfoId, setShowMoreInfoId] = useState(null)
|
const [showMoreInfoId, setShowMoreInfoId] = useState(null)
|
||||||
|
const [searchTerm, setSearchTerm] = useState('')
|
||||||
|
const [sortBy, setSortBy] = useState(
|
||||||
|
() => localStorage.getItem('labelsSortBy') || 'name',
|
||||||
|
)
|
||||||
|
const [sortDirection, setSortDirection] = useState(
|
||||||
|
() => localStorage.getItem('labelsSortDirection') || 'asc',
|
||||||
|
)
|
||||||
|
const [ownershipFilter, setOwnershipFilter] = useState('all')
|
||||||
|
const searchInputRef = useRef(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
localStorage.setItem('labelsSortBy', sortBy)
|
||||||
|
localStorage.setItem('labelsSortDirection', sortDirection)
|
||||||
|
}, [sortBy, sortDirection])
|
||||||
|
|
||||||
|
const visibleLabels = useMemo(() => {
|
||||||
|
if (ownershipFilter === 'mine') {
|
||||||
|
return userLabels.filter(label => label.created_by === userProfile?.id)
|
||||||
|
}
|
||||||
|
if (ownershipFilter === 'shared') {
|
||||||
|
return userLabels.filter(label => label.created_by !== userProfile?.id)
|
||||||
|
}
|
||||||
|
return userLabels
|
||||||
|
}, [ownershipFilter, userLabels, userProfile?.id])
|
||||||
|
|
||||||
|
const fuse = useMemo(
|
||||||
|
() =>
|
||||||
|
new Fuse(visibleLabels, {
|
||||||
|
keys: ['name'],
|
||||||
|
includeScore: true,
|
||||||
|
isCaseSensitive: false,
|
||||||
|
findAllMatches: true,
|
||||||
|
}),
|
||||||
|
[visibleLabels],
|
||||||
|
)
|
||||||
|
|
||||||
|
const filteredLabels = useMemo(() => {
|
||||||
|
const matched = searchTerm
|
||||||
|
? fuse.search(searchTerm).map(result => result.item)
|
||||||
|
: visibleLabels
|
||||||
|
|
||||||
|
const direction = sortDirection === 'desc' ? -1 : 1
|
||||||
|
return [...matched].sort((a, b) => {
|
||||||
|
switch (sortBy) {
|
||||||
|
case 'color':
|
||||||
|
return direction * (a.color || '').localeCompare(b.color || '')
|
||||||
|
case 'created':
|
||||||
|
return direction * ((a.id || 0) - (b.id || 0))
|
||||||
|
case 'name':
|
||||||
|
default:
|
||||||
|
return direction * (a.name || '').localeCompare(b.name || '')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}, [fuse, searchTerm, visibleLabels, sortBy, sortDirection])
|
||||||
|
|
||||||
|
const handleSearchChange = e => {
|
||||||
|
setSearchTerm(e.target.value.toLowerCase())
|
||||||
|
setShowMoreInfoId(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSearchClose = () => {
|
||||||
|
setSearchTerm('')
|
||||||
|
searchInputRef.current?.blur()
|
||||||
|
}
|
||||||
|
|
||||||
const handleAddLabel = () => {
|
const handleAddLabel = () => {
|
||||||
setCurrentLabel(null)
|
setCurrentLabel(null)
|
||||||
@@ -214,6 +292,21 @@ const LabelView = () => {
|
|||||||
}
|
}
|
||||||
}, [labels])
|
}, [labels])
|
||||||
|
|
||||||
|
// ?create=1 lets other surfaces (global search quick actions) land here with
|
||||||
|
// the create modal already open.
|
||||||
|
useEffect(() => {
|
||||||
|
if (searchParams.get('create') !== '1') return
|
||||||
|
setCurrentLabel(null)
|
||||||
|
setModalOpen(true)
|
||||||
|
setSearchParams(
|
||||||
|
params => {
|
||||||
|
params.delete('create')
|
||||||
|
return params
|
||||||
|
},
|
||||||
|
{ replace: true },
|
||||||
|
)
|
||||||
|
}, [searchParams, setSearchParams])
|
||||||
|
|
||||||
if (isLabelsLoading) {
|
if (isLabelsLoading) {
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
@@ -251,6 +344,65 @@ const LabelView = () => {
|
|||||||
</Typography>
|
</Typography>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Box>
|
</Box>
|
||||||
|
{userLabels.length > 0 && (
|
||||||
|
<Box
|
||||||
|
sx={{ px: 2, mb: 2, display: 'flex', alignItems: 'center', gap: 1 }}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
slotProps={{ input: { ref: searchInputRef } }}
|
||||||
|
placeholder={t('search.placeholder')}
|
||||||
|
value={searchTerm}
|
||||||
|
fullWidth
|
||||||
|
sx={{
|
||||||
|
borderRadius: 24,
|
||||||
|
height: 24,
|
||||||
|
borderColor: 'text.disabled',
|
||||||
|
padding: 1,
|
||||||
|
}}
|
||||||
|
onChange={handleSearchChange}
|
||||||
|
startDecorator={<Search />}
|
||||||
|
endDecorator={
|
||||||
|
searchTerm && (
|
||||||
|
<IconButton
|
||||||
|
variant='plain'
|
||||||
|
size='sm'
|
||||||
|
onClick={handleSearchClose}
|
||||||
|
sx={{ borderRadius: '50%' }}
|
||||||
|
>
|
||||||
|
<Close />
|
||||||
|
</IconButton>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<SortAndFilterMenu
|
||||||
|
sortOptions={[
|
||||||
|
{ name: 'Name', value: 'name' },
|
||||||
|
{ name: 'Color', value: 'color' },
|
||||||
|
{ name: 'Recently created', value: 'created' },
|
||||||
|
]}
|
||||||
|
selectedSort={sortBy}
|
||||||
|
onSortChange={setSortBy}
|
||||||
|
sortDirection={sortDirection}
|
||||||
|
onSortDirectionChange={setSortDirection}
|
||||||
|
filterTitle='Show'
|
||||||
|
filterOptions={[
|
||||||
|
{ name: 'All labels', value: 'all' },
|
||||||
|
{ name: 'Created by me', value: 'mine' },
|
||||||
|
{ name: 'Shared with me', value: 'shared' },
|
||||||
|
]}
|
||||||
|
selectedFilter={ownershipFilter}
|
||||||
|
onFilterChange={value => {
|
||||||
|
setOwnershipFilter(value)
|
||||||
|
setShowMoreInfoId(null)
|
||||||
|
}}
|
||||||
|
isActive={
|
||||||
|
ownershipFilter !== 'all' ||
|
||||||
|
sortBy !== 'name' ||
|
||||||
|
sortDirection !== 'asc'
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
@@ -269,10 +421,31 @@ const LabelView = () => {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{userLabels.length > 0 && filteredLabels.length === 0 && (
|
||||||
|
<EmptyState
|
||||||
|
variant='no-results'
|
||||||
|
fullHeight
|
||||||
|
icon={<SearchOff />}
|
||||||
|
title={t('search.noResultsTitle')}
|
||||||
|
description={
|
||||||
|
searchTerm
|
||||||
|
? t('search.noResultsDescription', { searchTerm })
|
||||||
|
: t('search.noFilterResultsDescription')
|
||||||
|
}
|
||||||
|
primaryAction={{
|
||||||
|
label: searchTerm ? t('search.clear') : t('search.showAll'),
|
||||||
|
onClick: () => {
|
||||||
|
handleSearchClose()
|
||||||
|
setOwnershipFilter('all')
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<SwipeableList type={ListType.IOS} fullSwipe={false}>
|
<SwipeableList type={ListType.IOS} fullSwipe={false}>
|
||||||
{userLabels.map(label => (
|
{filteredLabels.map(label => (
|
||||||
<SwipeableListItem
|
<SwipeableListItem
|
||||||
key={label.id}
|
key={label.id}
|
||||||
|
onClick={() => navigate(`/labels/${label.id}`)}
|
||||||
swipeActionOpen={showMoreInfoId === label.id ? 'trailing' : null}
|
swipeActionOpen={showMoreInfoId === label.id ? 'trailing' : null}
|
||||||
trailingActions={
|
trailingActions={
|
||||||
<TrailingActions>
|
<TrailingActions>
|
||||||
|
|||||||
@@ -329,7 +329,7 @@ const ErrorReportModal = ({ error, errorInfo, onClose, open }) => {
|
|||||||
level='body-sm'
|
level='body-sm'
|
||||||
sx={{ color: 'text.secondary', mt: 0.5 }}
|
sx={{ color: 'text.secondary', mt: 0.5 }}
|
||||||
>
|
>
|
||||||
Thanks — this goes straight to the people who can fix it.
|
Thanks! this goes straight to the people who can fix it.
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,23 @@
|
|||||||
|
import { Browser } from '@capacitor/browser'
|
||||||
|
import { Capacitor } from '@capacitor/core'
|
||||||
import { ChevronRight, Gavel, PrivacyTip } from '@mui/icons-material'
|
import { ChevronRight, Gavel, PrivacyTip } from '@mui/icons-material'
|
||||||
import { Button, Stack } from '@mui/joy'
|
import { Button, Stack } from '@mui/joy'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { useNavigate } from 'react-router-dom'
|
|
||||||
|
|
||||||
import ModalActions from '../../components/common/ModalActions.jsx'
|
import ModalActions from '../../components/common/ModalActions.jsx'
|
||||||
import { useResponsiveModal } from '../../hooks/useResponsiveModal.js'
|
import { useResponsiveModal } from '../../hooks/useResponsiveModal.js'
|
||||||
|
|
||||||
|
const POLICY_BASE_URL = 'https://app.donetick.com'
|
||||||
|
|
||||||
|
// Native webviews swallow target="_blank"; route through the system browser.
|
||||||
|
const openUrl = async url => {
|
||||||
|
if (Capacitor.isNativePlatform()) {
|
||||||
|
await Browser.open({ url })
|
||||||
|
} else {
|
||||||
|
window.open(url, '_blank', 'noopener,noreferrer')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One-time notice that the Privacy Policy and Terms changed. The frame is
|
* One-time notice that the Privacy Policy and Terms changed. The frame is
|
||||||
* generic and always points at the documents, so a future revision only needs
|
* generic and always points at the documents, so a future revision only needs
|
||||||
@@ -14,16 +26,18 @@ import { useResponsiveModal } from '../../hooks/useResponsiveModal.js'
|
|||||||
const PolicyUpdateModal = ({ onAcknowledge, onClose, open }) => {
|
const PolicyUpdateModal = ({ onAcknowledge, onClose, open }) => {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const { ResponsiveModal } = useResponsiveModal()
|
const { ResponsiveModal } = useResponsiveModal()
|
||||||
const navigate = useNavigate()
|
|
||||||
|
|
||||||
const handleClose = () => {
|
// Acknowledgement is the only way out: the backdrop, escape key, and close
|
||||||
|
// button are all disabled so the user must press "Got it".
|
||||||
|
const handleAcknowledge = () => {
|
||||||
onAcknowledge?.()
|
onAcknowledge?.()
|
||||||
onClose()
|
onClose()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Reading a document must not dismiss the notice; the modal is still waiting
|
||||||
|
// on an acknowledgement when the user returns from the browser.
|
||||||
const openDocument = path => {
|
const openDocument = path => {
|
||||||
handleClose()
|
openUrl(`${POLICY_BASE_URL}${path}`)
|
||||||
navigate(path)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const documentButtonSx = {
|
const documentButtonSx = {
|
||||||
@@ -36,7 +50,10 @@ const PolicyUpdateModal = ({ onAcknowledge, onClose, open }) => {
|
|||||||
return (
|
return (
|
||||||
<ResponsiveModal
|
<ResponsiveModal
|
||||||
open={open}
|
open={open}
|
||||||
onClose={handleClose}
|
onClose={handleAcknowledge}
|
||||||
|
closeOnBackdrop={false}
|
||||||
|
closeOnEscape={false}
|
||||||
|
showCloseButton={false}
|
||||||
size='sm'
|
size='sm'
|
||||||
title={t('policyUpdate.title')}
|
title={t('policyUpdate.title')}
|
||||||
description={t('policyUpdate.subtitle')}
|
description={t('policyUpdate.subtitle')}
|
||||||
@@ -44,7 +61,7 @@ const PolicyUpdateModal = ({ onAcknowledge, onClose, open }) => {
|
|||||||
<ModalActions
|
<ModalActions
|
||||||
primary={{
|
primary={{
|
||||||
label: t('policyUpdate.acknowledge'),
|
label: t('policyUpdate.acknowledge'),
|
||||||
onClick: handleClose,
|
onClick: handleAcknowledge,
|
||||||
sx: { width: { xs: '100%', sm: 'auto' } },
|
sx: { width: { xs: '100%', sm: 'auto' } },
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,3 +1,20 @@
|
|||||||
|
import '@meauxt/react-swipeable-list/dist/styles.css'
|
||||||
|
|
||||||
|
import {
|
||||||
|
SwipeableList,
|
||||||
|
SwipeableListItem,
|
||||||
|
SwipeAction,
|
||||||
|
TrailingActions,
|
||||||
|
Type as ListType,
|
||||||
|
} from '@meauxt/react-swipeable-list'
|
||||||
|
import {
|
||||||
|
Add,
|
||||||
|
Close,
|
||||||
|
MoreVert,
|
||||||
|
Search,
|
||||||
|
SearchOff,
|
||||||
|
Task,
|
||||||
|
} from '@mui/icons-material'
|
||||||
import DeleteIcon from '@mui/icons-material/Delete'
|
import DeleteIcon from '@mui/icons-material/Delete'
|
||||||
import EditIcon from '@mui/icons-material/Edit'
|
import EditIcon from '@mui/icons-material/Edit'
|
||||||
import {
|
import {
|
||||||
@@ -7,24 +24,18 @@ import {
|
|||||||
CircularProgress,
|
CircularProgress,
|
||||||
Container,
|
Container,
|
||||||
IconButton,
|
IconButton,
|
||||||
|
Input,
|
||||||
Stack,
|
Stack,
|
||||||
Typography,
|
Typography,
|
||||||
} from '@mui/joy'
|
} from '@mui/joy'
|
||||||
import { useEffect, useState } from 'react'
|
|
||||||
import { useTranslation } from 'react-i18next'
|
|
||||||
import { useNavigate } from 'react-router-dom'
|
|
||||||
import ProjectModal from '../Modals/Inputs/ProjectModal'
|
|
||||||
|
|
||||||
import {
|
|
||||||
Type as ListType,
|
|
||||||
SwipeableList,
|
|
||||||
SwipeableListItem,
|
|
||||||
SwipeAction,
|
|
||||||
TrailingActions,
|
|
||||||
} from '@meauxt/react-swipeable-list'
|
|
||||||
import '@meauxt/react-swipeable-list/dist/styles.css'
|
|
||||||
import { Add, MoreVert, Task } from '@mui/icons-material'
|
|
||||||
import { useQueryClient } from '@tanstack/react-query'
|
import { useQueryClient } from '@tanstack/react-query'
|
||||||
|
import Fuse from 'fuse.js'
|
||||||
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { useNavigate, useSearchParams } from 'react-router-dom'
|
||||||
|
|
||||||
|
import EmptyState from '../../components/common/EmptyState'
|
||||||
|
import SortAndFilterMenu from '../../components/common/SortAndFilterMenu'
|
||||||
import { useChores } from '../../queries/ChoreQueries'
|
import { useChores } from '../../queries/ChoreQueries'
|
||||||
import { useUserProfile } from '../../queries/UserQueries'
|
import { useUserProfile } from '../../queries/UserQueries'
|
||||||
import { getTextColorFromBackgroundColor } from '../../utils/Colors'
|
import { getTextColorFromBackgroundColor } from '../../utils/Colors'
|
||||||
@@ -33,13 +44,14 @@ import { getIconComponent } from '../../utils/ProjectIcons'
|
|||||||
import { getSafeBottomStyles } from '../../utils/SafeAreaUtils'
|
import { getSafeBottomStyles } from '../../utils/SafeAreaUtils'
|
||||||
import { useProjectFilter } from '../Chores/hooks/useProjectFilter'
|
import { useProjectFilter } from '../Chores/hooks/useProjectFilter'
|
||||||
import ConfirmationModal from '../Modals/Inputs/ConfirmationModal'
|
import ConfirmationModal from '../Modals/Inputs/ConfirmationModal'
|
||||||
|
import ProjectModal from '../Modals/Inputs/ProjectModal'
|
||||||
import { useProjects } from './ProjectQueries'
|
import { useProjects } from './ProjectQueries'
|
||||||
const ProjectCardContent = ({
|
const ProjectCardContent = ({
|
||||||
project,
|
|
||||||
currentUserId,
|
currentUserId,
|
||||||
taskCounts = {},
|
|
||||||
onCardClick,
|
onCardClick,
|
||||||
onToggleActions,
|
onToggleActions,
|
||||||
|
project,
|
||||||
|
taskCounts = {},
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation('projects')
|
const { t } = useTranslation('projects')
|
||||||
// Check if current user owns this project
|
// Check if current user owns this project
|
||||||
@@ -221,7 +233,7 @@ const ProjectCardContent = ({
|
|||||||
|
|
||||||
const ProjectView = () => {
|
const ProjectView = () => {
|
||||||
const { t } = useTranslation('projects')
|
const { t } = useTranslation('projects')
|
||||||
const { data: projects, isProjectsLoading, isError } = useProjects()
|
const { data: projects, isError, isProjectsLoading } = useProjects()
|
||||||
const { data: userProfile } = useUserProfile()
|
const { data: userProfile } = useUserProfile()
|
||||||
const { data: chores = { res: [] } } = useChores(false) // false to exclude archived
|
const { data: chores = { res: [] } } = useChores(false) // false to exclude archived
|
||||||
const { data: projectsData = [], isLoading: projectsLoading } = useProjects()
|
const { data: projectsData = [], isLoading: projectsLoading } = useProjects()
|
||||||
@@ -230,6 +242,7 @@ const ProjectView = () => {
|
|||||||
!projectsLoading,
|
!projectsLoading,
|
||||||
)
|
)
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams()
|
||||||
|
|
||||||
const [userProjects, setUserProjects] = useState([])
|
const [userProjects, setUserProjects] = useState([])
|
||||||
const [modalOpen, setModalOpen] = useState(false)
|
const [modalOpen, setModalOpen] = useState(false)
|
||||||
@@ -238,6 +251,83 @@ const ProjectView = () => {
|
|||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
const [confirmationModel, setConfirmationModel] = useState({})
|
const [confirmationModel, setConfirmationModel] = useState({})
|
||||||
const [showMoreInfoId, setShowMoreInfoId] = useState(null)
|
const [showMoreInfoId, setShowMoreInfoId] = useState(null)
|
||||||
|
const [searchTerm, setSearchTerm] = useState('')
|
||||||
|
const [sortBy, setSortBy] = useState(
|
||||||
|
() => localStorage.getItem('projectsSortBy') || 'name',
|
||||||
|
)
|
||||||
|
const [sortDirection, setSortDirection] = useState(
|
||||||
|
() => localStorage.getItem('projectsSortDirection') || 'asc',
|
||||||
|
)
|
||||||
|
const [ownershipFilter, setOwnershipFilter] = useState('all')
|
||||||
|
const searchInputRef = useRef(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
localStorage.setItem('projectsSortBy', sortBy)
|
||||||
|
localStorage.setItem('projectsSortDirection', sortDirection)
|
||||||
|
}, [sortBy, sortDirection])
|
||||||
|
|
||||||
|
const visibleProjects = useMemo(() => {
|
||||||
|
if (ownershipFilter === 'mine') {
|
||||||
|
return userProjects.filter(
|
||||||
|
project => project.created_by === userProfile?.id,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (ownershipFilter === 'shared') {
|
||||||
|
return userProjects.filter(
|
||||||
|
project => project.created_by !== userProfile?.id,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return userProjects
|
||||||
|
}, [ownershipFilter, userProjects, userProfile?.id])
|
||||||
|
|
||||||
|
const fuse = useMemo(
|
||||||
|
() =>
|
||||||
|
new Fuse(visibleProjects, {
|
||||||
|
keys: ['name', 'description'],
|
||||||
|
includeScore: true,
|
||||||
|
isCaseSensitive: false,
|
||||||
|
findAllMatches: true,
|
||||||
|
}),
|
||||||
|
[visibleProjects],
|
||||||
|
)
|
||||||
|
|
||||||
|
const filteredProjects = useMemo(() => {
|
||||||
|
const matched = searchTerm
|
||||||
|
? fuse.search(searchTerm).map(result => result.item)
|
||||||
|
: visibleProjects
|
||||||
|
|
||||||
|
const direction = sortDirection === 'desc' ? -1 : 1
|
||||||
|
return [...matched].sort((a, b) => {
|
||||||
|
switch (sortBy) {
|
||||||
|
case 'tasks':
|
||||||
|
return direction * ((taskCounts[a.id] || 0) - (taskCounts[b.id] || 0))
|
||||||
|
case 'created':
|
||||||
|
return direction * ((a.id || 0) - (b.id || 0))
|
||||||
|
case 'name':
|
||||||
|
default:
|
||||||
|
return direction * (a.name || '').localeCompare(b.name || '')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}, [fuse, searchTerm, visibleProjects, sortBy, sortDirection, taskCounts])
|
||||||
|
|
||||||
|
// The default project is pinned above the list, so it is matched separately.
|
||||||
|
const showDefaultProject = useMemo(() => {
|
||||||
|
if (ownershipFilter === 'shared') return false
|
||||||
|
if (!searchTerm) return true
|
||||||
|
return t('chores:toolbar.defaultProject')
|
||||||
|
.toLowerCase()
|
||||||
|
.includes(searchTerm.toLowerCase())
|
||||||
|
}, [ownershipFilter, searchTerm, t])
|
||||||
|
|
||||||
|
const handleSearchChange = e => {
|
||||||
|
setSearchTerm(e.target.value)
|
||||||
|
setShowMoreInfoId(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSearchClose = () => {
|
||||||
|
setSearchTerm('')
|
||||||
|
searchInputRef.current?.blur()
|
||||||
|
}
|
||||||
|
|
||||||
const handleAddProject = () => {
|
const handleAddProject = () => {
|
||||||
setCurrentProject(null)
|
setCurrentProject(null)
|
||||||
@@ -302,6 +392,21 @@ const ProjectView = () => {
|
|||||||
}
|
}
|
||||||
}, [projects])
|
}, [projects])
|
||||||
|
|
||||||
|
// ?create=1 lets other surfaces (global search quick actions) land here with
|
||||||
|
// the create modal already open.
|
||||||
|
useEffect(() => {
|
||||||
|
if (searchParams.get('create') !== '1') return
|
||||||
|
setCurrentProject(null)
|
||||||
|
setModalOpen(true)
|
||||||
|
setSearchParams(
|
||||||
|
params => {
|
||||||
|
params.delete('create')
|
||||||
|
return params
|
||||||
|
},
|
||||||
|
{ replace: true },
|
||||||
|
)
|
||||||
|
}, [searchParams, setSearchParams])
|
||||||
|
|
||||||
// Calculate real task counts from chores data
|
// Calculate real task counts from chores data
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (chores && chores.res) {
|
if (chores && chores.res) {
|
||||||
@@ -371,36 +476,114 @@ const ProjectView = () => {
|
|||||||
</Stack>
|
</Stack>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
|
<Box sx={{ px: 2, mb: 2, display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||||
|
<Input
|
||||||
|
slotProps={{ input: { ref: searchInputRef } }}
|
||||||
|
placeholder={t('search.placeholder')}
|
||||||
|
value={searchTerm}
|
||||||
|
fullWidth
|
||||||
|
sx={{
|
||||||
|
borderRadius: 24,
|
||||||
|
height: 24,
|
||||||
|
borderColor: 'text.disabled',
|
||||||
|
padding: 1,
|
||||||
|
}}
|
||||||
|
onChange={handleSearchChange}
|
||||||
|
startDecorator={<Search />}
|
||||||
|
endDecorator={
|
||||||
|
searchTerm && (
|
||||||
|
<IconButton
|
||||||
|
variant='plain'
|
||||||
|
size='sm'
|
||||||
|
onClick={handleSearchClose}
|
||||||
|
sx={{ borderRadius: '50%' }}
|
||||||
|
>
|
||||||
|
<Close />
|
||||||
|
</IconButton>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<SortAndFilterMenu
|
||||||
|
sortOptions={[
|
||||||
|
{ name: 'Name', value: 'name' },
|
||||||
|
{ name: 'Task count', value: 'tasks' },
|
||||||
|
{ name: 'Recently created', value: 'created' },
|
||||||
|
]}
|
||||||
|
selectedSort={sortBy}
|
||||||
|
onSortChange={setSortBy}
|
||||||
|
sortDirection={sortDirection}
|
||||||
|
onSortDirectionChange={setSortDirection}
|
||||||
|
filterTitle='Show'
|
||||||
|
filterOptions={[
|
||||||
|
{ name: 'All projects', value: 'all' },
|
||||||
|
{ name: 'Created by me', value: 'mine' },
|
||||||
|
{ name: 'Shared with me', value: 'shared' },
|
||||||
|
]}
|
||||||
|
selectedFilter={ownershipFilter}
|
||||||
|
onFilterChange={value => {
|
||||||
|
setOwnershipFilter(value)
|
||||||
|
setShowMoreInfoId(null)
|
||||||
|
}}
|
||||||
|
isActive={
|
||||||
|
ownershipFilter !== 'all' ||
|
||||||
|
sortBy !== 'name' ||
|
||||||
|
sortDirection !== 'asc'
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
{!showDefaultProject && filteredProjects.length === 0 && (
|
||||||
|
<EmptyState
|
||||||
|
variant='no-results'
|
||||||
|
fullHeight
|
||||||
|
icon={<SearchOff />}
|
||||||
|
title={t('search.noResultsTitle')}
|
||||||
|
description={
|
||||||
|
searchTerm
|
||||||
|
? t('search.noResultsDescription', { searchTerm })
|
||||||
|
: t('search.noFilterResultsDescription')
|
||||||
|
}
|
||||||
|
primaryAction={{
|
||||||
|
label: searchTerm ? t('search.clear') : t('search.showAll'),
|
||||||
|
onClick: () => {
|
||||||
|
handleSearchClose()
|
||||||
|
setOwnershipFilter('all')
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{/* Default project - not swipeable */}
|
{/* Default project - not swipeable */}
|
||||||
<ProjectCardContent
|
{showDefaultProject && (
|
||||||
project={{
|
<ProjectCardContent
|
||||||
id: 'default',
|
project={{
|
||||||
name: t('chores:toolbar.defaultProject'),
|
|
||||||
description: t('defaultDescription'),
|
|
||||||
icon: 'FolderOpen',
|
|
||||||
color: '#1976d2',
|
|
||||||
created_by: userProfile?.id,
|
|
||||||
}}
|
|
||||||
currentUserId={userProfile?.id}
|
|
||||||
taskCounts={{ default: taskCounts.default || 0 }}
|
|
||||||
onCardClick={() =>
|
|
||||||
handleCardClick({
|
|
||||||
id: 'default',
|
id: 'default',
|
||||||
name: t('chores:toolbar.defaultProject'),
|
name: t('chores:toolbar.defaultProject'),
|
||||||
|
description: t('defaultDescription'),
|
||||||
icon: 'FolderOpen',
|
icon: 'FolderOpen',
|
||||||
color: '#1976d2',
|
color: '#1976d2',
|
||||||
})
|
created_by: userProfile?.id,
|
||||||
}
|
}}
|
||||||
/>
|
currentUserId={userProfile?.id}
|
||||||
|
taskCounts={{ default: taskCounts.default || 0 }}
|
||||||
|
onCardClick={() =>
|
||||||
|
handleCardClick({
|
||||||
|
id: 'default',
|
||||||
|
name: t('chores:toolbar.defaultProject'),
|
||||||
|
icon: 'FolderOpen',
|
||||||
|
color: '#1976d2',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* User projects - swipeable */}
|
{/* User projects - swipeable */}
|
||||||
<SwipeableList type={ListType.IOS} fullSwipe={false}>
|
<SwipeableList type={ListType.IOS} fullSwipe={false}>
|
||||||
{userProjects.map(project => (
|
{filteredProjects.map(project => (
|
||||||
<SwipeableListItem
|
<SwipeableListItem
|
||||||
onClick={() => handleCardClick(project)}
|
onClick={() => handleCardClick(project)}
|
||||||
key={project.id}
|
key={project.id}
|
||||||
|
|||||||
@@ -9,11 +9,14 @@ import {
|
|||||||
} from '@meauxt/react-swipeable-list'
|
} from '@meauxt/react-swipeable-list'
|
||||||
import {
|
import {
|
||||||
Add,
|
Add,
|
||||||
|
Close,
|
||||||
Delete,
|
Delete,
|
||||||
Edit,
|
Edit,
|
||||||
Flip,
|
Flip,
|
||||||
MoreVert,
|
MoreVert,
|
||||||
PlusOne,
|
PlusOne,
|
||||||
|
Search,
|
||||||
|
SearchOff,
|
||||||
ToggleOff,
|
ToggleOff,
|
||||||
ToggleOn,
|
ToggleOn,
|
||||||
Widgets,
|
Widgets,
|
||||||
@@ -24,14 +27,17 @@ import {
|
|||||||
Chip,
|
Chip,
|
||||||
Container,
|
Container,
|
||||||
IconButton,
|
IconButton,
|
||||||
|
Input,
|
||||||
Stack,
|
Stack,
|
||||||
Typography,
|
Typography,
|
||||||
} from '@mui/joy'
|
} from '@mui/joy'
|
||||||
import { useEffect, useState } from 'react'
|
import Fuse from 'fuse.js'
|
||||||
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
|
||||||
import { track } from '../../analytics'
|
import { track } from '../../analytics'
|
||||||
import EmptyState from '../../components/common/EmptyState'
|
import EmptyState from '../../components/common/EmptyState'
|
||||||
|
import SortAndFilterMenu from '../../components/common/SortAndFilterMenu'
|
||||||
import { useNotification } from '../../service/NotificationProvider'
|
import { useNotification } from '../../service/NotificationProvider'
|
||||||
import {
|
import {
|
||||||
CreateThing,
|
CreateThing,
|
||||||
@@ -215,8 +221,78 @@ const ThingsView = () => {
|
|||||||
const [createModalThing, setCreateModalThing] = useState(null)
|
const [createModalThing, setCreateModalThing] = useState(null)
|
||||||
const [confirmModelConfig, setConfirmModelConfig] = useState({})
|
const [confirmModelConfig, setConfirmModelConfig] = useState({})
|
||||||
const [showMoreInfoId, setShowMoreInfoId] = useState(null)
|
const [showMoreInfoId, setShowMoreInfoId] = useState(null)
|
||||||
|
const [searchTerm, setSearchTerm] = useState('')
|
||||||
|
const [sortBy, setSortBy] = useState(
|
||||||
|
() => localStorage.getItem('thingsSortBy') || 'name',
|
||||||
|
)
|
||||||
|
const [sortDirection, setSortDirection] = useState(
|
||||||
|
() => localStorage.getItem('thingsSortDirection') || 'asc',
|
||||||
|
)
|
||||||
|
const [typeFilter, setTypeFilter] = useState('all')
|
||||||
|
const searchInputRef = useRef(null)
|
||||||
const { showError, showNotification } = useNotification()
|
const { showError, showNotification } = useNotification()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
localStorage.setItem('thingsSortBy', sortBy)
|
||||||
|
localStorage.setItem('thingsSortDirection', sortDirection)
|
||||||
|
}, [sortBy, sortDirection])
|
||||||
|
|
||||||
|
const visibleThings = useMemo(
|
||||||
|
() =>
|
||||||
|
typeFilter === 'all'
|
||||||
|
? things
|
||||||
|
: things.filter(thing => thing?.type === typeFilter),
|
||||||
|
[things, typeFilter],
|
||||||
|
)
|
||||||
|
|
||||||
|
const fuse = useMemo(
|
||||||
|
() =>
|
||||||
|
new Fuse(visibleThings, {
|
||||||
|
keys: ['name', 'state'],
|
||||||
|
includeScore: true,
|
||||||
|
isCaseSensitive: false,
|
||||||
|
findAllMatches: true,
|
||||||
|
}),
|
||||||
|
[visibleThings],
|
||||||
|
)
|
||||||
|
|
||||||
|
const filteredThings = useMemo(() => {
|
||||||
|
const matched = searchTerm
|
||||||
|
? fuse.search(searchTerm).map(result => result.item)
|
||||||
|
: visibleThings
|
||||||
|
|
||||||
|
const direction = sortDirection === 'desc' ? -1 : 1
|
||||||
|
return [...matched].sort((a, b) => {
|
||||||
|
switch (sortBy) {
|
||||||
|
case 'type':
|
||||||
|
return direction * (a.type || '').localeCompare(b.type || '')
|
||||||
|
case 'state':
|
||||||
|
return (
|
||||||
|
direction *
|
||||||
|
String(a.state ?? '').localeCompare(String(b.state ?? ''))
|
||||||
|
)
|
||||||
|
case 'updated': {
|
||||||
|
const aDate = new Date(a.updatedAt || a.updated_at || 0).getTime()
|
||||||
|
const bDate = new Date(b.updatedAt || b.updated_at || 0).getTime()
|
||||||
|
return direction * (aDate - bDate)
|
||||||
|
}
|
||||||
|
case 'name':
|
||||||
|
default:
|
||||||
|
return direction * (a.name || '').localeCompare(b.name || '')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}, [fuse, searchTerm, visibleThings, sortBy, sortDirection])
|
||||||
|
|
||||||
|
const handleSearchChange = e => {
|
||||||
|
setSearchTerm(e.target.value)
|
||||||
|
setShowMoreInfoId(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSearchClose = () => {
|
||||||
|
setSearchTerm('')
|
||||||
|
searchInputRef.current?.blur()
|
||||||
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// fetch things
|
// fetch things
|
||||||
GetThings().then(result => {
|
GetThings().then(result => {
|
||||||
@@ -404,6 +480,67 @@ const ThingsView = () => {
|
|||||||
</Typography>
|
</Typography>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Box>
|
</Box>
|
||||||
|
{things.length > 0 && (
|
||||||
|
<Box
|
||||||
|
sx={{ px: 2, mb: 2, display: 'flex', alignItems: 'center', gap: 1 }}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
slotProps={{ input: { ref: searchInputRef } }}
|
||||||
|
placeholder='Search things'
|
||||||
|
value={searchTerm}
|
||||||
|
fullWidth
|
||||||
|
sx={{
|
||||||
|
borderRadius: 24,
|
||||||
|
height: 24,
|
||||||
|
borderColor: 'text.disabled',
|
||||||
|
padding: 1,
|
||||||
|
}}
|
||||||
|
onChange={handleSearchChange}
|
||||||
|
startDecorator={<Search />}
|
||||||
|
endDecorator={
|
||||||
|
searchTerm && (
|
||||||
|
<IconButton
|
||||||
|
variant='plain'
|
||||||
|
size='sm'
|
||||||
|
onClick={handleSearchClose}
|
||||||
|
sx={{ borderRadius: '50%' }}
|
||||||
|
>
|
||||||
|
<Close />
|
||||||
|
</IconButton>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<SortAndFilterMenu
|
||||||
|
sortOptions={[
|
||||||
|
{ name: 'Name', value: 'name' },
|
||||||
|
{ name: 'Type', value: 'type' },
|
||||||
|
{ name: 'State', value: 'state' },
|
||||||
|
{ name: 'Last updated', value: 'updated' },
|
||||||
|
]}
|
||||||
|
selectedSort={sortBy}
|
||||||
|
onSortChange={setSortBy}
|
||||||
|
sortDirection={sortDirection}
|
||||||
|
onSortDirectionChange={setSortDirection}
|
||||||
|
filterTitle='Type'
|
||||||
|
filterOptions={[
|
||||||
|
{ name: 'All types', value: 'all' },
|
||||||
|
{ name: 'Text', value: 'text' },
|
||||||
|
{ name: 'Number', value: 'number' },
|
||||||
|
{ name: 'Boolean', value: 'boolean' },
|
||||||
|
]}
|
||||||
|
selectedFilter={typeFilter}
|
||||||
|
onFilterChange={value => {
|
||||||
|
setTypeFilter(value)
|
||||||
|
setShowMoreInfoId(null)
|
||||||
|
}}
|
||||||
|
isActive={
|
||||||
|
typeFilter !== 'all' ||
|
||||||
|
sortBy !== 'name' ||
|
||||||
|
sortDirection !== 'asc'
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
@@ -425,8 +562,28 @@ const ThingsView = () => {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{things.length > 0 && filteredThings.length === 0 && (
|
||||||
|
<EmptyState
|
||||||
|
variant='no-results'
|
||||||
|
fullHeight
|
||||||
|
icon={<SearchOff />}
|
||||||
|
title='No things match'
|
||||||
|
description={
|
||||||
|
searchTerm
|
||||||
|
? `No thing matches "${searchTerm}".`
|
||||||
|
: 'No thing matches the current filter.'
|
||||||
|
}
|
||||||
|
primaryAction={{
|
||||||
|
label: searchTerm ? 'Clear search' : 'Show all things',
|
||||||
|
onClick: () => {
|
||||||
|
handleSearchClose()
|
||||||
|
setTypeFilter('all')
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<SwipeableList type={ListType.IOS} fullSwipe={false}>
|
<SwipeableList type={ListType.IOS} fullSwipe={false}>
|
||||||
{things.map(thing => (
|
{filteredThings.map(thing => (
|
||||||
<SwipeableListItem
|
<SwipeableListItem
|
||||||
onClick={() => navigate(`/things/${thing?.id}`)}
|
onClick={() => navigate(`/things/${thing?.id}`)}
|
||||||
key={thing.id}
|
key={thing.id}
|
||||||
|
|||||||
@@ -319,7 +319,7 @@ const AdvancedOptionsSection = ({
|
|||||||
textColor='text.tertiary'
|
textColor='text.tertiary'
|
||||||
sx={{ my: 0.5, fontStyle: 'italic' }}
|
sx={{ my: 0.5, fontStyle: 'italic' }}
|
||||||
>
|
>
|
||||||
Set a due date to configure completion window and deadline.
|
Set a due date to configure completion window
|
||||||
</Typography>
|
</Typography>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
Delete,
|
Delete,
|
||||||
DriveFileMove,
|
DriveFileMove,
|
||||||
Edit,
|
Edit,
|
||||||
|
Flag,
|
||||||
ManageSearch,
|
ManageSearch,
|
||||||
MoreTime,
|
MoreTime,
|
||||||
MoreVert,
|
MoreVert,
|
||||||
@@ -24,6 +25,8 @@ import {
|
|||||||
} from '@mui/icons-material'
|
} from '@mui/icons-material'
|
||||||
import {
|
import {
|
||||||
Avatar,
|
Avatar,
|
||||||
|
Button,
|
||||||
|
Chip,
|
||||||
Divider,
|
Divider,
|
||||||
IconButton,
|
IconButton,
|
||||||
List,
|
List,
|
||||||
@@ -45,9 +48,21 @@ import LABEL_COLORS, {
|
|||||||
getTextColorFromBackgroundColor,
|
getTextColorFromBackgroundColor,
|
||||||
} from '../../utils/Colors'
|
} from '../../utils/Colors'
|
||||||
import { isOfficialDonetickInstanceSync } from '../../utils/FeatureToggle'
|
import { isOfficialDonetickInstanceSync } from '../../utils/FeatureToggle'
|
||||||
|
import Priorities from '../../utils/Priorities'
|
||||||
import { getIconComponent } from '../../utils/ProjectIcons'
|
import { getIconComponent } from '../../utils/ProjectIcons'
|
||||||
import { useProjects } from '../Projects/ProjectQueries'
|
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 = ({
|
const ChoreActionMenu = ({
|
||||||
chore,
|
chore,
|
||||||
onAction,
|
onAction,
|
||||||
@@ -55,12 +70,15 @@ const ChoreActionMenu = ({
|
|||||||
onCompleteWithPastDate,
|
onCompleteWithPastDate,
|
||||||
onChangeAssignee,
|
onChangeAssignee,
|
||||||
onChangeDueDate,
|
onChangeDueDate,
|
||||||
|
onChangePriority,
|
||||||
onWriteNFC,
|
onWriteNFC,
|
||||||
onNudge,
|
onNudge,
|
||||||
onDelete,
|
onDelete,
|
||||||
onOpen,
|
onOpen,
|
||||||
onMouseEnter,
|
onMouseEnter,
|
||||||
onMouseLeave,
|
onMouseLeave,
|
||||||
|
hiddenActions = [],
|
||||||
|
trigger,
|
||||||
sx = {},
|
sx = {},
|
||||||
variant = 'soft',
|
variant = 'soft',
|
||||||
}) => {
|
}) => {
|
||||||
@@ -68,6 +86,7 @@ const ChoreActionMenu = ({
|
|||||||
const [anchorEl, setAnchorEl] = React.useState(null)
|
const [anchorEl, setAnchorEl] = React.useState(null)
|
||||||
const [isOfficialInstance, setIsOfficialInstance] = useState(false)
|
const [isOfficialInstance, setIsOfficialInstance] = useState(false)
|
||||||
const [showProjectPicker, setShowProjectPicker] = useState(false)
|
const [showProjectPicker, setShowProjectPicker] = useState(false)
|
||||||
|
const [showPriorityPicker, setShowPriorityPicker] = useState(false)
|
||||||
const menuRef = React.useRef(null)
|
const menuRef = React.useRef(null)
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const { data: projects = [] } = useProjects()
|
const { data: projects = [] } = useProjects()
|
||||||
@@ -119,6 +138,12 @@ const ChoreActionMenu = ({
|
|||||||
const handleMenuClose = () => {
|
const handleMenuClose = () => {
|
||||||
setAnchorEl(null)
|
setAnchorEl(null)
|
||||||
setShowProjectPicker(false)
|
setShowProjectPicker(false)
|
||||||
|
setShowPriorityPicker(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleChangePriority = priority => {
|
||||||
|
onChangePriority?.(priority)
|
||||||
|
handleMenuClose()
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleMoveToProject = project => {
|
const handleMoveToProject = project => {
|
||||||
@@ -246,6 +271,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
|
// Shared action list, rendered as MenuItems on large screens and as a
|
||||||
// ListItemButton list inside an AppModal sheet on small screens.
|
// ListItemButton list inside an AppModal sheet on small screens.
|
||||||
const actionItems = [
|
const actionItems = [
|
||||||
@@ -310,6 +338,21 @@ const ChoreActionMenu = ({
|
|||||||
handleMenuClose()
|
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',
|
key: 'writeNfc',
|
||||||
icon: <Nfc />,
|
icon: <Nfc />,
|
||||||
@@ -343,7 +386,11 @@ const ChoreActionMenu = ({
|
|||||||
onClick: handleDelete,
|
onClick: handleDelete,
|
||||||
color: 'danger',
|
color: 'danger',
|
||||||
},
|
},
|
||||||
].filter(Boolean)
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.filter(item => !hiddenActions.includes(item.key))
|
||||||
|
|
||||||
|
const visibleActionItems = collapseDividers(actionItems)
|
||||||
|
|
||||||
const quickScheduleButtons = (
|
const quickScheduleButtons = (
|
||||||
<>
|
<>
|
||||||
@@ -416,7 +463,7 @@ const ChoreActionMenu = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const renderMenuActionItems = () =>
|
const renderMenuActionItems = () =>
|
||||||
actionItems.map(item => {
|
visibleActionItems.map(item => {
|
||||||
if (item.type === 'divider') return <Divider key={item.key} />
|
if (item.type === 'divider') return <Divider key={item.key} />
|
||||||
if (item.type === 'quickSchedule') {
|
if (item.type === 'quickSchedule') {
|
||||||
return (
|
return (
|
||||||
@@ -444,12 +491,17 @@ const ChoreActionMenu = ({
|
|||||||
>
|
>
|
||||||
{item.icon}
|
{item.icon}
|
||||||
{item.label}
|
{item.label}
|
||||||
|
{item.endDecorator && (
|
||||||
|
<ListItemDecorator sx={{ ml: 'auto', minInlineSize: 0 }}>
|
||||||
|
{item.endDecorator}
|
||||||
|
</ListItemDecorator>
|
||||||
|
)}
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
const renderModalActionItems = () =>
|
const renderModalActionItems = () =>
|
||||||
actionItems.map(item => {
|
visibleActionItems.map(item => {
|
||||||
if (item.type === 'divider') return <Divider key={item.key} />
|
if (item.type === 'divider') return <Divider key={item.key} />
|
||||||
if (item.type === 'quickSchedule') {
|
if (item.type === 'quickSchedule') {
|
||||||
return (
|
return (
|
||||||
@@ -463,13 +515,62 @@ const ChoreActionMenu = ({
|
|||||||
<ListItemButton color={item.color} onClick={() => item.onClick()}>
|
<ListItemButton color={item.color} onClick={() => item.onClick()}>
|
||||||
<ListItemDecorator>{item.icon}</ListItemDecorator>
|
<ListItemDecorator>{item.icon}</ListItemDecorator>
|
||||||
<ListItemContent>{item.label}</ListItemContent>
|
<ListItemContent>{item.label}</ListItemContent>
|
||||||
|
{item.endDecorator}
|
||||||
</ListItemButton>
|
</ListItemButton>
|
||||||
</ListItem>
|
</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 = () => (
|
const renderModalProjectPicker = () => (
|
||||||
<List>
|
<>
|
||||||
<ListItem>
|
<ListItem>
|
||||||
<ListItemButton
|
<ListItemButton
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
@@ -492,53 +593,75 @@ const ChoreActionMenu = ({
|
|||||||
</ListItemButton>
|
</ListItemButton>
|
||||||
</ListItem>
|
</ListItem>
|
||||||
))}
|
))}
|
||||||
</List>
|
</>
|
||||||
)
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<IconButton
|
{trigger ? (
|
||||||
variant={variant}
|
React.cloneElement(trigger, {
|
||||||
color='success'
|
onClick: handleMenuOpen,
|
||||||
onClick={handleMenuOpen}
|
onMouseEnter,
|
||||||
onMouseEnter={onMouseEnter}
|
onMouseLeave,
|
||||||
onMouseLeave={onMouseLeave}
|
})
|
||||||
sx={{
|
) : (
|
||||||
borderRadius: '50%',
|
<IconButton
|
||||||
width: 25,
|
variant={variant}
|
||||||
height: 25,
|
color='success'
|
||||||
position: 'relative',
|
onClick={handleMenuOpen}
|
||||||
left: -10,
|
onMouseEnter={onMouseEnter}
|
||||||
...sx,
|
onMouseLeave={onMouseLeave}
|
||||||
}}
|
sx={{
|
||||||
>
|
borderRadius: '50%',
|
||||||
<MoreVert />
|
width: 25,
|
||||||
</IconButton>
|
height: 25,
|
||||||
|
position: 'relative',
|
||||||
|
left: -10,
|
||||||
|
...sx,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<MoreVert />
|
||||||
|
</IconButton>
|
||||||
|
)}
|
||||||
|
|
||||||
{isSmallScreen ? (
|
{isSmallScreen ? (
|
||||||
<AppModal
|
<AppModal
|
||||||
open={Boolean(anchorEl)}
|
open={Boolean(anchorEl)}
|
||||||
onClose={handleMenuClose}
|
onClose={handleMenuClose}
|
||||||
title={showProjectPicker ? 'Move to project' : chore?.name}
|
title={
|
||||||
|
showProjectPicker
|
||||||
|
? 'Move to project'
|
||||||
|
: showPriorityPicker
|
||||||
|
? 'Priority'
|
||||||
|
: chore?.name
|
||||||
|
}
|
||||||
mobilePresentation='sheet'
|
mobilePresentation='sheet'
|
||||||
showHandle
|
showHandle
|
||||||
contentSx={{ px: 0, pb: 1 }}
|
contentSx={{ px: 0, pb: 1 }}
|
||||||
>
|
>
|
||||||
{showProjectPicker && (
|
{(showProjectPicker || showPriorityPicker) && (
|
||||||
<MenuItem
|
<Button
|
||||||
onClick={() => setShowProjectPicker(false)}
|
variant='plain'
|
||||||
sx={{ gap: 1, mx: 2, mb: 1 }}
|
color='neutral'
|
||||||
|
size='sm'
|
||||||
|
startDecorator={<ArrowBack fontSize='small' />}
|
||||||
|
onClick={() => {
|
||||||
|
setShowProjectPicker(false)
|
||||||
|
setShowPriorityPicker(false)
|
||||||
|
}}
|
||||||
|
sx={{ mx: 2, mb: 1 }}
|
||||||
>
|
>
|
||||||
<ArrowBack fontSize='small' />
|
|
||||||
<Typography level='body-sm' fontWeight={600}>
|
<Typography level='body-sm' fontWeight={600}>
|
||||||
{t('common:back')}
|
{t('common:back')}
|
||||||
</Typography>
|
</Typography>
|
||||||
</MenuItem>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<List sx={{ '--ListItem-radius': '8px', px: 1 }}>
|
<List sx={{ '--ListItem-radius': '8px', px: 1 }}>
|
||||||
{showProjectPicker
|
{showProjectPicker
|
||||||
? renderModalProjectPicker()
|
? renderModalProjectPicker()
|
||||||
: renderModalActionItems()}
|
: showPriorityPicker
|
||||||
|
? renderModalPriorityPicker()
|
||||||
|
: renderModalActionItems()}
|
||||||
</List>
|
</List>
|
||||||
</AppModal>
|
</AppModal>
|
||||||
) : (
|
) : (
|
||||||
@@ -554,7 +677,9 @@ const ChoreActionMenu = ({
|
|||||||
left: '50%',
|
left: '50%',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{showProjectPicker ? (
|
{showPriorityPicker ? (
|
||||||
|
renderMenuPriorityPicker()
|
||||||
|
) : showProjectPicker ? (
|
||||||
<>
|
<>
|
||||||
<MenuItem
|
<MenuItem
|
||||||
onClick={e => {
|
onClick={e => {
|
||||||
|
|||||||
Reference in New Issue
Block a user