feat: enhance chore actions with bulk operations and new label detail view
- Refactored bulk operations in useChoreActions to streamline completion, archiving, deletion, and other actions. - Introduced new hooks for managing local chore state during bulk operations. - Added handleBulkDueDate, handleBulkAssignee, handleBulkPriority, and handleBulkLabels functions for better task management. - Implemented a new LabelDetailView component to display and manage tasks associated with a specific label. - Updated LabelView to navigate to LabelDetailView on label click. - Improved multi-select functionality to support range selection and summary of selected chores. - Minor UI adjustments and text updates in AdvancedOptionsSection for clarity.
This commit is contained in:
@@ -11,5 +11,27 @@
|
||||
"noResultsDescription": "No label matches \"{{searchTerm}}\".",
|
||||
"clear": "Clear search"
|
||||
},
|
||||
"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."
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ import JoinCircleView from '../views/Circles/JoinCircle'
|
||||
import NotFound from '../views/components/NotFound'
|
||||
import FilterView from '../views/Filters/FilterView'
|
||||
import ChoreHistory from '../views/History/ChoreHistory'
|
||||
import LabelDetailView from '../views/Labels/LabelDetailView'
|
||||
import LabelView from '../views/Labels/LabelView'
|
||||
import Landing from '../views/Landing/Landing'
|
||||
import CircleSetupView from '../views/Onboarding/CircleSetupView'
|
||||
@@ -262,6 +263,10 @@ const Router = createBrowserRouter([
|
||||
path: 'labels/',
|
||||
element: <LabelView />,
|
||||
},
|
||||
{
|
||||
path: 'labels/:labelId',
|
||||
element: <LabelDetailView />,
|
||||
},
|
||||
{
|
||||
path: 'projects/',
|
||||
element: <ProjectView />,
|
||||
|
||||
@@ -125,7 +125,7 @@ registerSearchProvider({
|
||||
title: label.name || 'Untitled label',
|
||||
subtitle: 'Label',
|
||||
keywords: 'tag label',
|
||||
route: '/labels',
|
||||
route: `/labels/${label.id}`,
|
||||
color: label.color,
|
||||
}),
|
||||
),
|
||||
|
||||
@@ -149,6 +149,7 @@ const MyChores = () => {
|
||||
clearSelection,
|
||||
enterMultiSelectWithChore,
|
||||
getSelectedChoresData,
|
||||
getSelectionSummary,
|
||||
isMultiSelectMode,
|
||||
selectAllVisibleChores,
|
||||
selectedChores,
|
||||
@@ -575,9 +576,13 @@ const MyChores = () => {
|
||||
const {
|
||||
handleAssigneeChange,
|
||||
handleBulkArchive,
|
||||
handleBulkAssignee,
|
||||
handleBulkComplete,
|
||||
handleBulkDelete,
|
||||
handleBulkDueDate,
|
||||
handleBulkLabels,
|
||||
handleBulkMoveToProject,
|
||||
handleBulkPriority,
|
||||
handleBulkSkip,
|
||||
handleChangeDueDate,
|
||||
handleChoreAction,
|
||||
@@ -629,6 +634,13 @@ const MyChores = () => {
|
||||
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({
|
||||
isMultiSelectMode,
|
||||
selectedChores,
|
||||
@@ -1075,6 +1087,13 @@ const MyChores = () => {
|
||||
onArchive={handleBulkArchive}
|
||||
onDelete={handleBulkDelete}
|
||||
onMoveToProject={handleBulkMoveToProject}
|
||||
onSetDueDate={handleBulkDueDate}
|
||||
onSetAssignee={handleBulkAssignee}
|
||||
onSetPriority={handleBulkPriority}
|
||||
onToggleLabel={handleBulkLabels}
|
||||
selectionSummary={selectionSummary}
|
||||
members={membersData?.res || []}
|
||||
labels={userLabels || []}
|
||||
projects={projects}
|
||||
showKeyboardShortcuts={showKeyboardShortcuts}
|
||||
selectAllDisabled={
|
||||
|
||||
@@ -1,11 +1,19 @@
|
||||
import {
|
||||
Archive,
|
||||
CalendarMonth,
|
||||
Check,
|
||||
CheckBox,
|
||||
CheckBoxOutlineBlank,
|
||||
Close,
|
||||
Delete,
|
||||
Done,
|
||||
DriveFileMove,
|
||||
EditCalendar,
|
||||
Flag,
|
||||
Label as LabelIcon,
|
||||
MoreHoriz,
|
||||
Person,
|
||||
Remove,
|
||||
SelectAll,
|
||||
SkipNext,
|
||||
} from '@mui/icons-material'
|
||||
@@ -13,6 +21,7 @@ import {
|
||||
Avatar,
|
||||
Box,
|
||||
Button,
|
||||
Chip,
|
||||
Divider,
|
||||
ListItemContent,
|
||||
ListItemDecorator,
|
||||
@@ -20,12 +29,18 @@ import {
|
||||
MenuItem,
|
||||
Typography,
|
||||
} from '@mui/joy'
|
||||
import moment from 'moment'
|
||||
import { useRef, useState } from 'react'
|
||||
import AppModal from '../../../components/common/AppModal'
|
||||
import KeyboardShortcutHint from '../../../components/common/KeyboardShortcutHint'
|
||||
import LABEL_COLORS, {
|
||||
getTextColorFromBackgroundColor,
|
||||
} from '../../../utils/Colors'
|
||||
import Priorities from '../../../utils/Priorities'
|
||||
import { getIconComponent } from '../../../utils/ProjectIcons'
|
||||
import DueDatePickerModal, {
|
||||
splitDueDate,
|
||||
} from '../../components/DueDatePickerModal'
|
||||
|
||||
const renderProjectAvatar = (color, icon) => {
|
||||
const bg = color || LABEL_COLORS[0].value
|
||||
@@ -39,6 +54,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 = ({
|
||||
isVisible,
|
||||
selectedCount,
|
||||
@@ -49,19 +141,65 @@ const MultiSelectToolbar = ({
|
||||
onArchive,
|
||||
onDelete,
|
||||
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 = [],
|
||||
showKeyboardShortcuts,
|
||||
selectAllDisabled,
|
||||
}) => {
|
||||
const [moreOpen, setMoreOpen] = useState(false)
|
||||
const [dueDatePickerOpen, setDueDatePickerOpen] = useState(false)
|
||||
const [dueMenuAnchor, setDueMenuAnchor] = useState(null)
|
||||
const [projectMenuAnchor, setProjectMenuAnchor] = useState(null)
|
||||
const dueMenuRef = useRef(null)
|
||||
const projectMenuRef = useRef(null)
|
||||
|
||||
const closeDueMenu = () => setDueMenuAnchor(null)
|
||||
const closeProjectMenu = () => setProjectMenuAnchor(null)
|
||||
|
||||
const handleMoveToProject = project => {
|
||||
closeProjectMenu()
|
||||
onMoveToProject?.(project)
|
||||
}
|
||||
const summary = selectionSummary || {}
|
||||
const labelState = summary.labels || { common: [], partial: [] }
|
||||
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 (
|
||||
<Box
|
||||
@@ -71,7 +209,10 @@ const MultiSelectToolbar = ({
|
||||
zIndex: 1000,
|
||||
overflow: 'hidden',
|
||||
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,
|
||||
transform: isVisible ? 'translateY(0)' : 'translateY(-20px)',
|
||||
marginBottom: isVisible ? 2 : 0,
|
||||
@@ -86,20 +227,14 @@ const MultiSelectToolbar = ({
|
||||
border: '1px solid',
|
||||
borderColor: 'divider',
|
||||
boxShadow: 'm',
|
||||
gap: 2,
|
||||
gap: 1.5,
|
||||
display: 'flex',
|
||||
flexDirection: {
|
||||
sm: 'column',
|
||||
md: 'row',
|
||||
},
|
||||
alignItems: {
|
||||
xs: 'stretch',
|
||||
sm: 'center',
|
||||
},
|
||||
justifyContent: {
|
||||
xs: 'center',
|
||||
sm: 'space-between',
|
||||
},
|
||||
// Narrow screens stack: the selection status gets its own row, then
|
||||
// the actions get the full width to lay out in. Only above md is
|
||||
// there room to put both on one line.
|
||||
flexDirection: { xs: 'column', md: 'row' },
|
||||
alignItems: { xs: 'stretch', md: 'center' },
|
||||
justifyContent: 'space-between',
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
@@ -107,14 +242,10 @@ const MultiSelectToolbar = ({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 2,
|
||||
flexWrap: {
|
||||
xs: 'wrap',
|
||||
sm: 'nowrap',
|
||||
},
|
||||
justifyContent: {
|
||||
xs: 'center',
|
||||
sm: 'flex-start',
|
||||
},
|
||||
flexWrap: 'nowrap',
|
||||
// Stacked, the count sits left and All/Close anchor right, so the
|
||||
// status row reads edge to edge instead of floating in the middle.
|
||||
justifyContent: { xs: 'space-between', md: 'flex-start' },
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
@@ -127,7 +258,7 @@ const MultiSelectToolbar = ({
|
||||
<Divider
|
||||
orientation='vertical'
|
||||
sx={{
|
||||
display: { xs: 'none', sm: 'block' },
|
||||
display: { xs: 'none', md: 'block' },
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -189,19 +320,22 @@ const MultiSelectToolbar = ({
|
||||
</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
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 1,
|
||||
flexWrap: {
|
||||
xs: 'wrap',
|
||||
sm: 'nowrap',
|
||||
},
|
||||
justifyContent: {
|
||||
xs: 'center',
|
||||
sm: 'flex-end',
|
||||
},
|
||||
// Stacked, the actions become an auto-fitting grid: every button
|
||||
// stretches to fill its cell, so the rows come out flush instead
|
||||
// of trailing ragged whitespace. Above md they go back to a
|
||||
// right-aligned row.
|
||||
display: { xs: 'grid', md: 'flex' },
|
||||
gridTemplateColumns: 'repeat(auto-fit, minmax(112px, 1fr))',
|
||||
alignItems: 'center',
|
||||
flexWrap: 'wrap',
|
||||
justifyContent: 'flex-end',
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
@@ -230,6 +364,91 @@ const MultiSelectToolbar = ({
|
||||
/>
|
||||
)}
|
||||
</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
|
||||
size='sm'
|
||||
variant='soft'
|
||||
@@ -256,6 +475,7 @@ const MultiSelectToolbar = ({
|
||||
/>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{onMoveToProject && (
|
||||
<>
|
||||
<Button
|
||||
@@ -285,9 +505,10 @@ const MultiSelectToolbar = ({
|
||||
placement='bottom-end'
|
||||
>
|
||||
<MenuItem
|
||||
onClick={() =>
|
||||
handleMoveToProject({ id: null, name: 'Default Project' })
|
||||
}
|
||||
onClick={() => {
|
||||
closeProjectMenu()
|
||||
onMoveToProject({ id: null, name: 'Default Project' })
|
||||
}}
|
||||
>
|
||||
<ListItemDecorator>
|
||||
{renderProjectAvatar(LABEL_COLORS[0].value, 'FolderOpen')}
|
||||
@@ -299,7 +520,10 @@ const MultiSelectToolbar = ({
|
||||
{projects.map(project => (
|
||||
<MenuItem
|
||||
key={project.id}
|
||||
onClick={() => handleMoveToProject(project)}
|
||||
onClick={() => {
|
||||
closeProjectMenu()
|
||||
onMoveToProject(project)
|
||||
}}
|
||||
>
|
||||
<ListItemDecorator>
|
||||
{renderProjectAvatar(project.color, project.icon)}
|
||||
@@ -366,8 +590,210 @@ const MultiSelectToolbar = ({
|
||||
/>
|
||||
)}
|
||||
</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>
|
||||
|
||||
{/* ── 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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import moment from 'moment'
|
||||
import { useCallback } from 'react'
|
||||
import {
|
||||
useArchiveChore,
|
||||
@@ -16,6 +17,7 @@ import {
|
||||
SkipChore,
|
||||
UndoChoreAction,
|
||||
UpdateChoreAssignee,
|
||||
UpdateChorePriority,
|
||||
UpdateDueDate,
|
||||
} from '../../../utils/Fetcher'
|
||||
import { offlineDB } from '../../../utils/OfflineDB'
|
||||
@@ -28,6 +30,28 @@ const isNetworkError = err =>
|
||||
err instanceof TypeError &&
|
||||
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 = ({
|
||||
chores,
|
||||
filteredChores,
|
||||
@@ -867,346 +891,386 @@ export const useChoreActions = ({
|
||||
[showSuccess, showError, closeModal],
|
||||
)
|
||||
|
||||
const handleBulkComplete = useCallback(async () => {
|
||||
const selectedData = getSelectedChoresData(chores)
|
||||
if (selectedData.length === 0) return
|
||||
// ── bulk operations ────────────────────────────────────────────────────────
|
||||
//
|
||||
// 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({
|
||||
isOpen: true,
|
||||
title: 'Complete Tasks',
|
||||
confirmText: 'Complete',
|
||||
cancelText: 'Cancel',
|
||||
message: `Mark ${selectedData.length} task${selectedData.length > 1 ? 's' : ''} as completed?`,
|
||||
onClose: async isConfirmed => {
|
||||
if (isConfirmed === true) {
|
||||
try {
|
||||
const completedTasks = []
|
||||
const failedTasks = []
|
||||
|
||||
for (const chore of selectedData) {
|
||||
try {
|
||||
await MarkChoreComplete(
|
||||
chore.id,
|
||||
impersonatedUser
|
||||
? { completedBy: impersonatedUser.userId }
|
||||
: null,
|
||||
null,
|
||||
null,
|
||||
)
|
||||
completedTasks.push(chore)
|
||||
} catch (error) {
|
||||
failedTasks.push(chore)
|
||||
const patchLocalChores = useCallback(
|
||||
(ids, patch) => {
|
||||
const idSet = new Set(ids)
|
||||
const apply = list =>
|
||||
list.map(chore =>
|
||||
idSet.has(chore.id)
|
||||
? {
|
||||
...chore,
|
||||
...(typeof patch === 'function' ? patch(chore) : patch),
|
||||
}
|
||||
}
|
||||
: chore,
|
||||
)
|
||||
setChores(apply)
|
||||
setFilteredChores(apply)
|
||||
},
|
||||
[setChores, setFilteredChores],
|
||||
)
|
||||
|
||||
if (completedTasks.length > 0) {
|
||||
showSuccess({
|
||||
title: '✅ Tasks Completed',
|
||||
message: `Successfully completed ${completedTasks.length} task${completedTasks.length > 1 ? 's' : ''}.`,
|
||||
})
|
||||
}
|
||||
const removeLocalChores = useCallback(
|
||||
ids => {
|
||||
const idSet = new Set(ids)
|
||||
const drop = list => list.filter(chore => !idSet.has(chore.id))
|
||||
setChores(drop)
|
||||
setFilteredChores(drop)
|
||||
},
|
||||
[setChores, setFilteredChores],
|
||||
)
|
||||
|
||||
if (failedTasks.length > 0) {
|
||||
showError({
|
||||
title: 'Some Tasks Failed',
|
||||
message: `${failedTasks.length} task${failedTasks.length > 1 ? 's' : ''} could not be completed.`,
|
||||
})
|
||||
}
|
||||
const runBulk = useCallback(
|
||||
async ({
|
||||
buildUndo,
|
||||
// { 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}.`
|
||||
failureVerb,
|
||||
onSucceeded,
|
||||
perChore,
|
||||
successTitle,
|
||||
// "Completed", "Rescheduled", … — reads as `${verb} 3 tasks.`
|
||||
successVerb,
|
||||
targets,
|
||||
}) => {
|
||||
if (!targets || targets.length === 0) return
|
||||
|
||||
refetchChores()
|
||||
clearSelection()
|
||||
} catch (error) {
|
||||
showError({
|
||||
title: 'Bulk Complete Failed',
|
||||
message: 'An unexpected error occurred. Please try again.',
|
||||
})
|
||||
}
|
||||
}
|
||||
setConfirmModelConfig({})
|
||||
},
|
||||
})
|
||||
}, [
|
||||
getSelectedChoresData,
|
||||
impersonatedUser,
|
||||
showSuccess,
|
||||
showError,
|
||||
refetchChores,
|
||||
clearSelection,
|
||||
setConfirmModelConfig,
|
||||
])
|
||||
const execute = async () => {
|
||||
const succeeded = []
|
||||
const failed = []
|
||||
|
||||
const handleBulkArchive = useCallback(async () => {
|
||||
const selectedData = getSelectedChoresData(chores)
|
||||
if (selectedData.length === 0) return
|
||||
|
||||
setConfirmModelConfig({
|
||||
isOpen: true,
|
||||
title: 'Archive Tasks',
|
||||
confirmText: 'Archive',
|
||||
cancelText: 'Cancel',
|
||||
message: `Archive ${selectedData.length} task${selectedData.length > 1 ? 's' : ''}?`,
|
||||
onClose: async isConfirmed => {
|
||||
if (isConfirmed === true) {
|
||||
for (const chore of targets) {
|
||||
try {
|
||||
const archivedTasks = []
|
||||
const failedTasks = []
|
||||
for (const chore of selectedData) {
|
||||
try {
|
||||
await new Promise((resolve, reject) => {
|
||||
archiveChore.mutate(chore.id, {
|
||||
onSuccess: data => {
|
||||
archivedTasks.push(data)
|
||||
setChores(prev => prev.filter(c => c.id !== chore.id))
|
||||
setFilteredChores(prev =>
|
||||
prev.filter(c => c.id !== chore.id),
|
||||
)
|
||||
resolve(data)
|
||||
},
|
||||
onError: error => {
|
||||
failedTasks.push(chore)
|
||||
reject(error)
|
||||
},
|
||||
})
|
||||
})
|
||||
} catch (error) {}
|
||||
}
|
||||
if (archivedTasks.length > 0) {
|
||||
showSuccess({
|
||||
title: '📦 Tasks Archived',
|
||||
message: `Successfully archived ${archivedTasks.length} task${archivedTasks.length > 1 ? 's' : ''}.`,
|
||||
})
|
||||
}
|
||||
if (failedTasks.length > 0) {
|
||||
showError({
|
||||
title: 'Some Tasks Failed',
|
||||
message: `${failedTasks.length} task${failedTasks.length > 1 ? 's' : ''} could not be archived.`,
|
||||
})
|
||||
}
|
||||
refetchChores()
|
||||
clearSelection()
|
||||
await perChore(chore)
|
||||
succeeded.push(chore)
|
||||
} catch (error) {
|
||||
showError({
|
||||
title: 'Bulk Archive Failed',
|
||||
message: 'An unexpected error occurred. Please try again.',
|
||||
})
|
||||
failed.push(chore)
|
||||
}
|
||||
}
|
||||
setConfirmModelConfig({})
|
||||
},
|
||||
})
|
||||
}, [
|
||||
getSelectedChoresData,
|
||||
archiveChore,
|
||||
setChores,
|
||||
setFilteredChores,
|
||||
showSuccess,
|
||||
showError,
|
||||
refetchChores,
|
||||
clearSelection,
|
||||
setConfirmModelConfig,
|
||||
])
|
||||
|
||||
const handleBulkDelete = useCallback(async () => {
|
||||
const selectedData = getSelectedChoresData(chores)
|
||||
if (selectedData.length === 0) return
|
||||
|
||||
setConfirmModelConfig({
|
||||
isOpen: true,
|
||||
title: 'Delete Tasks',
|
||||
confirmText: 'Delete',
|
||||
cancelText: 'Cancel',
|
||||
message: `Delete ${selectedData.length} task${selectedData.length > 1 ? 's' : ''}?\n\nThis action cannot be undone.`,
|
||||
onClose: async isConfirmed => {
|
||||
if (isConfirmed === true) {
|
||||
try {
|
||||
const deletedTasks = []
|
||||
const failedTasks = []
|
||||
|
||||
for (const chore of selectedData) {
|
||||
try {
|
||||
await DeleteChore(chore.id)
|
||||
deletedTasks.push(chore)
|
||||
} catch (error) {
|
||||
failedTasks.push(chore)
|
||||
}
|
||||
}
|
||||
|
||||
if (deletedTasks.length > 0) {
|
||||
showSuccess({
|
||||
title: '🗑️ Tasks Deleted',
|
||||
message: `Successfully deleted ${deletedTasks.length} task${deletedTasks.length > 1 ? 's' : ''}.`,
|
||||
})
|
||||
|
||||
const deletedIds = new Set(deletedTasks.map(c => c.id))
|
||||
const newChores = chores.filter(c => !deletedIds.has(c.id))
|
||||
const newFilteredChores = filteredChores.filter(
|
||||
c => !deletedIds.has(c.id),
|
||||
)
|
||||
setChores(newChores)
|
||||
setFilteredChores(newFilteredChores)
|
||||
}
|
||||
|
||||
if (failedTasks.length > 0) {
|
||||
showError({
|
||||
title: 'Some Tasks Failed',
|
||||
message: `${failedTasks.length} task${failedTasks.length > 1 ? 's' : ''} could not be deleted.`,
|
||||
})
|
||||
}
|
||||
refetchChores()
|
||||
clearSelection()
|
||||
} catch (error) {
|
||||
showError({
|
||||
title: 'Bulk Delete Failed',
|
||||
message: 'An unexpected error occurred. Please try again.',
|
||||
})
|
||||
}
|
||||
if (succeeded.length > 0) {
|
||||
onSucceeded?.(succeeded)
|
||||
showSuccess({
|
||||
title: successTitle,
|
||||
message: `${successVerb} ${taskCount(succeeded.length)}.`,
|
||||
...(buildUndo ? { undoAction: buildUndo(succeeded) } : {}),
|
||||
})
|
||||
}
|
||||
setConfirmModelConfig({})
|
||||
},
|
||||
})
|
||||
}, [
|
||||
getSelectedChoresData,
|
||||
chores,
|
||||
filteredChores,
|
||||
setChores,
|
||||
setFilteredChores,
|
||||
showSuccess,
|
||||
showError,
|
||||
refetchChores,
|
||||
clearSelection,
|
||||
setConfirmModelConfig,
|
||||
])
|
||||
|
||||
const handleBulkSkip = useCallback(async () => {
|
||||
const selectedData = getSelectedChoresData(chores)
|
||||
if (selectedData.length === 0) return
|
||||
|
||||
setConfirmModelConfig({
|
||||
isOpen: true,
|
||||
title: 'Skip Tasks',
|
||||
confirmText: 'Skip',
|
||||
cancelText: 'Cancel',
|
||||
message: `Skip ${selectedData.length} task${selectedData.length > 1 ? 's' : ''} to next due date?`,
|
||||
onClose: async isConfirmed => {
|
||||
if (isConfirmed === true) {
|
||||
try {
|
||||
const skippedTasks = []
|
||||
const failedTasks = []
|
||||
|
||||
for (const chore of selectedData) {
|
||||
try {
|
||||
await SkipChore(chore.id)
|
||||
skippedTasks.push(chore)
|
||||
} catch (error) {
|
||||
failedTasks.push(chore)
|
||||
}
|
||||
}
|
||||
|
||||
if (skippedTasks.length > 0) {
|
||||
showSuccess({
|
||||
title: '⏭️ Tasks Skipped',
|
||||
message: `Successfully skipped ${skippedTasks.length} task${skippedTasks.length > 1 ? 's' : ''}.`,
|
||||
undoAction: async () => {
|
||||
try {
|
||||
for (const chore of skippedTasks) {
|
||||
await UndoChoreAction(chore.id)
|
||||
}
|
||||
queryClient.invalidateQueries(['chores'])
|
||||
showUndo({
|
||||
title: 'Undo Successful',
|
||||
message: `Undo skip for ${skippedTasks.length} task${skippedTasks.length > 1 ? 's' : ''}.`,
|
||||
})
|
||||
} catch (error) {
|
||||
showError({
|
||||
title: 'Undo Failed',
|
||||
message: 'Unable to undo the action. Please try again.',
|
||||
})
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
if (failedTasks.length > 0) {
|
||||
showError({
|
||||
title: 'Some Tasks Failed',
|
||||
message: `${failedTasks.length > 1 ? 's' : ''} could not be skipped.`,
|
||||
})
|
||||
}
|
||||
|
||||
refetchChores()
|
||||
clearSelection()
|
||||
} catch (error) {
|
||||
showError({
|
||||
title: 'Bulk Skip Failed',
|
||||
message: 'An unexpected error occurred. Please try again.',
|
||||
})
|
||||
}
|
||||
if (failed.length > 0) {
|
||||
showError({
|
||||
title: 'Some Tasks Failed',
|
||||
message: `${taskCount(failed.length)} could not be ${failureVerb}.`,
|
||||
})
|
||||
}
|
||||
setConfirmModelConfig({})
|
||||
},
|
||||
})
|
||||
}, [
|
||||
getSelectedChoresData,
|
||||
showSuccess,
|
||||
showError,
|
||||
showUndo,
|
||||
refetchChores,
|
||||
clearSelection,
|
||||
setConfirmModelConfig,
|
||||
])
|
||||
|
||||
const handleBulkMoveToProject = useCallback(
|
||||
async project => {
|
||||
const selectedData = getSelectedChoresData(chores)
|
||||
if (selectedData.length === 0) return
|
||||
refetchChores()
|
||||
clearSelection()
|
||||
}
|
||||
|
||||
const projectId = project?.id ?? null
|
||||
const movedTasks = []
|
||||
const failedTasks = []
|
||||
|
||||
for (const chore of selectedData) {
|
||||
if (!confirm) {
|
||||
try {
|
||||
const response = await SaveChore({ ...chore, projectId })
|
||||
if (response.ok) {
|
||||
movedTasks.push(chore)
|
||||
} else {
|
||||
failedTasks.push(chore)
|
||||
}
|
||||
await execute()
|
||||
} catch (error) {
|
||||
failedTasks.push(chore)
|
||||
showError({
|
||||
title: `Bulk ${failureVerb} failed`,
|
||||
message: 'An unexpected error occurred. Please try again.',
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (movedTasks.length > 0) {
|
||||
const movedIds = new Set(movedTasks.map(c => c.id))
|
||||
const applyMove = list =>
|
||||
list.map(c => (movedIds.has(c.id) ? { ...c, projectId } : c))
|
||||
setChores(applyMove)
|
||||
setFilteredChores(applyMove)
|
||||
showSuccess({
|
||||
title: 'Tasks Moved',
|
||||
message: `Moved ${movedTasks.length} task${movedTasks.length > 1 ? 's' : ''} to ${project?.name || 'Default Project'}.`,
|
||||
})
|
||||
}
|
||||
if (failedTasks.length > 0) {
|
||||
showError({
|
||||
title: 'Some Tasks Failed',
|
||||
message: `${failedTasks.length} task${failedTasks.length > 1 ? 's' : ''} could not be moved.`,
|
||||
})
|
||||
}
|
||||
|
||||
refetchChores()
|
||||
clearSelection()
|
||||
setConfirmModelConfig({
|
||||
isOpen: true,
|
||||
cancelText: 'Cancel',
|
||||
...confirm,
|
||||
onClose: async isConfirmed => {
|
||||
setConfirmModelConfig({})
|
||||
if (isConfirmed !== true) return
|
||||
try {
|
||||
await execute()
|
||||
} catch (error) {
|
||||
showError({
|
||||
title: `Bulk ${failureVerb} failed`,
|
||||
message: 'An unexpected error occurred. Please try again.',
|
||||
})
|
||||
}
|
||||
},
|
||||
})
|
||||
},
|
||||
[
|
||||
chores,
|
||||
getSelectedChoresData,
|
||||
setChores,
|
||||
setFilteredChores,
|
||||
showSuccess,
|
||||
showError,
|
||||
refetchChores,
|
||||
clearSelection,
|
||||
setConfirmModelConfig,
|
||||
],
|
||||
)
|
||||
|
||||
const handleBulkComplete = useCallback(async () => {
|
||||
const targets = getSelectedChoresData(chores)
|
||||
runBulk({
|
||||
targets,
|
||||
confirm: {
|
||||
title: 'Complete Tasks',
|
||||
confirmText: 'Complete',
|
||||
message: `Mark ${taskCount(targets.length)} as completed?`,
|
||||
},
|
||||
perChore: chore =>
|
||||
expectOk(
|
||||
MarkChoreComplete(
|
||||
chore.id,
|
||||
impersonatedUser ? { completedBy: impersonatedUser.userId } : null,
|
||||
null,
|
||||
null,
|
||||
),
|
||||
),
|
||||
successTitle: '✅ Tasks Completed',
|
||||
successVerb: 'Completed',
|
||||
failureVerb: 'completed',
|
||||
})
|
||||
}, [getSelectedChoresData, chores, impersonatedUser, runBulk])
|
||||
|
||||
const handleBulkSkip = useCallback(async () => {
|
||||
const targets = getSelectedChoresData(chores)
|
||||
runBulk({
|
||||
targets,
|
||||
confirm: {
|
||||
title: 'Skip Tasks',
|
||||
confirmText: 'Skip',
|
||||
message: `Skip ${taskCount(targets.length)} to next due date?`,
|
||||
},
|
||||
perChore: chore => expectOk(SkipChore(chore.id)),
|
||||
successTitle: '⏭️ Tasks Skipped',
|
||||
successVerb: 'Skipped',
|
||||
failureVerb: 'skipped',
|
||||
buildUndo: succeeded => async () => {
|
||||
try {
|
||||
for (const chore of succeeded) {
|
||||
await UndoChoreAction(chore.id)
|
||||
}
|
||||
queryClient.invalidateQueries(['chores'])
|
||||
showUndo({
|
||||
title: 'Undo Successful',
|
||||
message: `Undo skip for ${taskCount(succeeded.length)}.`,
|
||||
})
|
||||
} catch (error) {
|
||||
showError({
|
||||
title: 'Undo Failed',
|
||||
message: 'Unable to undo the action. Please try again.',
|
||||
})
|
||||
}
|
||||
},
|
||||
})
|
||||
}, [getSelectedChoresData, chores, runBulk, queryClient, showUndo, showError])
|
||||
|
||||
const handleBulkArchive = useCallback(async () => {
|
||||
const targets = getSelectedChoresData(chores)
|
||||
runBulk({
|
||||
targets,
|
||||
confirm: {
|
||||
title: 'Archive Tasks',
|
||||
confirmText: 'Archive',
|
||||
message: `Archive ${taskCount(targets.length)}?`,
|
||||
},
|
||||
perChore: chore =>
|
||||
new Promise((resolve, reject) => {
|
||||
archiveChore.mutate(chore.id, {
|
||||
onSuccess: resolve,
|
||||
onError: reject,
|
||||
})
|
||||
}),
|
||||
successTitle: '📦 Tasks Archived',
|
||||
successVerb: 'Archived',
|
||||
failureVerb: 'archived',
|
||||
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: 'Delete Tasks',
|
||||
confirmText: 'Delete',
|
||||
message: `Delete ${taskCount(targets.length)}?\n\nThis action cannot be undone.`,
|
||||
},
|
||||
perChore: chore => expectOk(DeleteChore(chore.id)),
|
||||
successTitle: '🗑️ Tasks Deleted',
|
||||
successVerb: 'Deleted',
|
||||
failureVerb: 'deleted',
|
||||
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,
|
||||
],
|
||||
)
|
||||
|
||||
@@ -1222,5 +1286,9 @@ export const useChoreActions = ({
|
||||
handleBulkDelete,
|
||||
handleBulkSkip,
|
||||
handleBulkMoveToProject,
|
||||
handleBulkDueDate,
|
||||
handleBulkAssignee,
|
||||
handleBulkPriority,
|
||||
handleBulkLabels,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 = () => {
|
||||
const [isMultiSelectMode, setIsMultiSelectMode] = useState(false)
|
||||
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 newMode = !isMultiSelectMode
|
||||
setIsMultiSelectMode(newMode)
|
||||
setIsMultiSelectMode(prev => {
|
||||
if (!prev) setSelectedChores(new Set())
|
||||
return !prev
|
||||
})
|
||||
lastSelectedId.current = null
|
||||
}, [])
|
||||
|
||||
if (newMode) {
|
||||
setSelectedChores(new Set())
|
||||
}
|
||||
}, [isMultiSelectMode])
|
||||
|
||||
const toggleChoreSelection = useCallback(
|
||||
choreId => {
|
||||
const newSelection = new Set(selectedChores)
|
||||
if (newSelection.has(choreId)) {
|
||||
newSelection.delete(choreId)
|
||||
const toggleChoreSelection = useCallback(choreId => {
|
||||
setSelectedChores(prev => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(choreId)) {
|
||||
next.delete(choreId)
|
||||
} else {
|
||||
newSelection.add(choreId)
|
||||
next.add(choreId)
|
||||
}
|
||||
setSelectedChores(newSelection)
|
||||
},
|
||||
[selectedChores],
|
||||
)
|
||||
return next
|
||||
})
|
||||
lastSelectedId.current = choreId
|
||||
}, [])
|
||||
|
||||
// Entry point for press-and-hold on a task card: turn multi-select on (if it
|
||||
// isn't already) with that task selected.
|
||||
@@ -33,6 +47,7 @@ export const useMultiSelect = () => {
|
||||
if (!isMultiSelectMode) {
|
||||
setIsMultiSelectMode(true)
|
||||
setSelectedChores(new Set([choreId]))
|
||||
lastSelectedId.current = choreId
|
||||
return
|
||||
}
|
||||
toggleChoreSelection(choreId)
|
||||
@@ -40,6 +55,39 @@ export const useMultiSelect = () => {
|
||||
[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(
|
||||
(visibleChores, choreSections = [], openChoreSections = {}) => {
|
||||
let choresToSelect = []
|
||||
@@ -65,8 +113,7 @@ export const useMultiSelect = () => {
|
||||
}
|
||||
|
||||
if (choresToSelect.length > 0) {
|
||||
const allIds = new Set(choresToSelect.map(chore => chore.id))
|
||||
setSelectedChores(allIds)
|
||||
setSelectedChores(new Set(choresToSelect.map(chore => chore.id)))
|
||||
}
|
||||
|
||||
return choresToSelect.length
|
||||
@@ -75,6 +122,7 @@ export const useMultiSelect = () => {
|
||||
)
|
||||
|
||||
const clearSelection = useCallback(() => {
|
||||
lastSelectedId.current = null
|
||||
if (selectedChores.size === 0) {
|
||||
setIsMultiSelectMode(false)
|
||||
return
|
||||
@@ -84,22 +132,79 @@ export const useMultiSelect = () => {
|
||||
|
||||
const getSelectedChoresData = useCallback(
|
||||
allChores => {
|
||||
if (selectedChores.size === 0) return []
|
||||
const byId = new Map(allChores.map(chore => [chore.id, chore]))
|
||||
return Array.from(selectedChores)
|
||||
.map(id => allChores.find(chore => chore.id === id))
|
||||
.map(id => byId.get(id))
|
||||
.filter(Boolean)
|
||||
},
|
||||
[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 {
|
||||
isMultiSelectMode,
|
||||
selectedChores,
|
||||
toggleMultiSelectMode,
|
||||
toggleChoreSelection,
|
||||
selectChoreRange,
|
||||
enterMultiSelectWithChore,
|
||||
selectAllVisibleChores,
|
||||
clearSelection,
|
||||
getSelectedChoresData,
|
||||
getSelectionSummary,
|
||||
setIsMultiSelectMode,
|
||||
setSelectedChores,
|
||||
}
|
||||
|
||||
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
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
import Fuse from 'fuse.js'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import LabelModal from '../Modals/Inputs/LabelModal'
|
||||
|
||||
import {
|
||||
@@ -163,6 +164,7 @@ const LabelView = () => {
|
||||
const { t } = useTranslation('labels')
|
||||
const { data: labels, isLabelsLoading, isError } = useLabels()
|
||||
const { data: userProfile } = useUserProfile()
|
||||
const navigate = useNavigate()
|
||||
|
||||
const [userLabels, setUserLabels] = useState([])
|
||||
const [modalOpen, setModalOpen] = useState(false)
|
||||
@@ -357,6 +359,7 @@ const LabelView = () => {
|
||||
{filteredLabels.map(label => (
|
||||
<SwipeableListItem
|
||||
key={label.id}
|
||||
onClick={() => navigate(`/labels/${label.id}`)}
|
||||
swipeActionOpen={showMoreInfoId === label.id ? 'trailing' : null}
|
||||
trailingActions={
|
||||
<TrailingActions>
|
||||
|
||||
@@ -319,7 +319,7 @@ const AdvancedOptionsSection = ({
|
||||
textColor='text.tertiary'
|
||||
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>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
Reference in New Issue
Block a user