implement unified ChoreToolbar and enhance ChoreHistory with filtering options

This commit is contained in:
Mo Tarbin
2026-06-14 15:54:40 -04:00
parent fe22bb1035
commit 23e777cd94
3 changed files with 915 additions and 164 deletions

View File

@@ -2,15 +2,10 @@ import {
Add,
Bolt,
CalendarMonth,
CheckBox,
CheckBoxOutlineBlank,
EditCalendar,
ExpandCircleDown,
PriorityHigh,
Sort,
Style,
ViewAgenda,
ViewModule,
} from '@mui/icons-material'
import {
Accordion,
@@ -37,7 +32,6 @@ import IconButtonWithMenu from './IconButtonWithMenu'
import { useMediaQuery } from '@mui/material'
import { useQueryClient } from '@tanstack/react-query'
import FilterBar from '../../components/common/FilterBar'
import KeyboardShortcutHint from '../../components/common/KeyboardShortcutHint'
import { useFilter } from '../../hooks/useFilter'
import { useImpersonateUser } from '../../contexts/ImpersonateUserContext.jsx'
@@ -52,15 +46,13 @@ import { getSafeBottom } from '../../utils/SafeAreaUtils.js'
import TaskInput from '../components/AddTaskModal'
import CalendarDual from '../components/CalendarDual'
import CalendarMonthly from '../components/CalendarMonthly.jsx'
import ProjectSelector from '../components/ProjectSelector'
import AdvancedFilterBuilder from '../Modals/Inputs/AdvancedFilterBuilder'
import { useProjects } from '../Projects/ProjectQueries.js'
import ChoreListView from './ChoreListView.jsx'
import ChoreToolbar from './components/ChoreToolbarPrototype'
import ChoreModals from './components/ChoreModals'
import FilterSection from './components/FilterSection'
import MultiSelectToolbar from './components/MultiSelectToolbar'
import MyChoreHeader from './components/MyChoreHeader'
import SearchBar from './components/SearchBar'
import { useChoreActions } from './hooks/useChoreActions'
import { useChoreFilters } from './hooks/useChoreFilters'
import { useChoreModals } from './hooks/useChoreModals'
@@ -75,7 +67,6 @@ import {
import NotificationAccessSnackbar from './NotificationAccessSnackbar'
import Sidepanel from './Sidepanel'
import { INSIGHT_FILTER_DEFS } from './SmartInsightsCard'
import SortAndGrouping from './SortAndGrouping'
const MyChores = () => {
const { data: userProfile, isLoading: isUserProfileLoading } =
@@ -790,14 +781,13 @@ const MyChores = () => {
localStorage.setItem('openChoreSections', JSON.stringify(value))
}
const toggleViewMode = () => {
const toggleViewMode = value => {
const newMode = value ?? (() => {
const modes = ['default', 'compact', 'calendar']
const currentIndex = modes.indexOf(viewMode)
const nextIndex = (currentIndex + 1) % modes.length
const newMode = modes[nextIndex]
return modes[(modes.indexOf(viewMode) + 1) % modes.length]
})()
setViewMode(newMode)
localStorage.setItem('choreCardViewMode', newMode)
if (newMode !== 'calendar') {
setSelectedCalendarDate(null)
}
@@ -957,126 +947,7 @@ const MyChores = () => {
tempFilter={tempFilter}
tempFilterMeta={tempFilterMeta}
/>
<Box
sx={{
display: 'flex',
justifyContent: 'space-between',
alignContent: 'center',
alignItems: 'center',
gap: 0.5,
}}
>
<SearchBar
value={searchTerm}
onChange={handleSearchChange}
onClose={handleSearchClose}
showKeyboardShortcuts={showKeyboardShortcuts}
inputRef={searchInputRef}
/>
<SortAndGrouping
title='Group by'
k={'icon-menu-group-by'}
icon={<Sort />}
selectedItem={selectedChoreSection}
selectedFilter={selectedChoreFilter}
setFilter={filter => {
setSelectedChoreFilterWithCache(filter)
// Clear active custom filter when quick filter is applied
if (activeFilterId) {
clearActiveFilter()
updateFilterUrl(null, null)
}
}}
onItemSelect={selected => {
setSelectedChoreSectionWithCache(selected.value)
setFilteredChores(chores)
clearQuickFilters()
}}
onCreateNewFilter={() => {
setShowAdvancedFilterBuilder(true)
setEditingFilter(null)
}}
mouseClickHandler={handleMenuOutsideClick}
/>
{/* Project Selector - Hidden when active filter has project conditions */}
{projectsWithDefault.length > 1 &&
!hasProjectConditions &&
!hasFilterApplied && (
<ProjectSelector
selectedProject={selectedProject?.name || 'Default Project'}
onProjectSelect={project => {
setSelectedProjectWithCache(project)
clearActiveFilter()
}}
showKeyboardShortcuts={showKeyboardShortcuts}
/>
)}
{/* View Mode Toggle Button */}
<IconButton
variant='outlined'
color='neutral'
size='sm'
sx={{
height: 32,
width: 32,
borderRadius: '50%',
}}
onClick={toggleViewMode}
title={
viewMode === 'default'
? 'Switch to Compact View'
: viewMode === 'compact'
? 'Switch to Calendar View'
: 'Switch to Card View'
}
>
{viewMode === 'default' ? (
<ViewAgenda />
) : viewMode === 'compact' ? (
<CalendarMonth />
) : (
<ViewModule />
)}
</IconButton>
{/* Multi-select Toggle Button */}
<Box sx={{ position: 'relative', display: 'inline-flex' }}>
<IconButton
variant={isMultiSelectMode ? 'solid' : 'outlined'}
color={isMultiSelectMode ? 'primary' : 'neutral'}
size='sm'
sx={{
height: 32,
width: 32,
borderRadius: '50%',
}}
onClick={toggleMultiSelectMode}
title={
isMultiSelectMode
? 'Exit Multi-select Mode (Ctrl+S)'
: 'Enable Multi-select Mode (Ctrl+S)'
}
>
{isMultiSelectMode ? <CheckBox /> : <CheckBoxOutlineBlank />}
</IconButton>
<KeyboardShortcutHint
shortcut='S'
show={showKeyboardShortcuts}
sx={{
position: 'absolute',
top: -8,
right: -8,
zIndex: 1000,
}}
/>
</Box>
</Box>
{/* Quick Filters */}
<FilterBar
<ChoreToolbar
filterDefs={quickFilterDefs}
activeFilters={quickFilters}
onSetFilter={(id, value) => {
@@ -1084,21 +955,40 @@ const MyChores = () => {
setQuickFilter(id, value)
setSelectedCalendarDate(null)
}}
onClearAll={() => {
onClearAllFilters={() => {
clearQuickFilters()
clearActiveFilter()
setSelectedChoreFilterWithCache('anyone')
setSelectedProjectWithCache(projectsWithDefault.find(p => p.id === 'default') || null)
updateFilterUrl(null, null)
}}
resultCount={hasQuickFilters ? getFilteredChores.length : undefined}
totalCount={hasQuickFilters ? projectFilteredChores.length : undefined}
/>
{/* Custom Filters Section */}
<FilterSection
resultCount={
hasQuickFilters || !!activeFilterId ? getFilteredChores.length : undefined
}
totalCount={
hasQuickFilters || !!activeFilterId ? projectFilteredChores.length : undefined
}
projects={
!hasProjectConditions && !hasFilterApplied
? projectsWithDefault
: []
}
selectedProject={selectedProject}
onProjectSelect={project => {
setSelectedProjectWithCache(project)
clearActiveFilter()
}}
selectedAssigneeFilter={selectedChoreFilter}
onAssigneeFilterChange={filter => {
setSelectedChoreFilterWithCache(filter)
if (activeFilterId) {
clearActiveFilter()
updateFilterUrl(null, null)
}
}}
savedFilters={savedFilters}
activeFilterId={activeFilterId}
activeFilter={activeFilter}
hasProjectConditions={hasProjectConditions}
onFilterClick={filterId => {
onSavedFilterClick={filterId => {
if (activeFilterId === filterId) {
clearActiveFilter()
updateFilterUrl(null, null)
@@ -1106,31 +996,42 @@ const MyChores = () => {
clearQuickFilters()
setSearchTerm('')
setFilteredChores([])
// Reset quick filter to 'anyone' when custom filter is applied
if (selectedChoreFilter !== 'anyone') {
setSelectedChoreFilterWithCache('anyone')
}
// Clear project selection if the filter has project conditions
const filter = savedFilters.find(f => f.id === filterId)
if (filter?.conditions?.some(c => c.type === 'project')) {
setSelectedProjectWithCache(null)
}
applyCustomFilter(filterId)
updateFilterUrl('filterId', filterId)
}
}}
onFilterDelete={deleteFilter}
onFilterPin={pinFilter}
onFilterEdit={filter => {
onSavedFilterEdit={filter => {
setEditingFilter(filter)
setShowAdvancedFilterBuilder(true)
}}
onClearActiveFilter={clearActiveFilter}
onCreateAdvancedFilter={() => setShowAdvancedFilterBuilder(true)}
updateFilterUrl={updateFilterUrl}
onSavedFilterDelete={deleteFilter}
onSavedFilterPin={pinFilter}
onCreateAdvancedFilter={() => {
setShowAdvancedFilterBuilder(true)
setEditingFilter(null)
}}
selectedGroupBy={selectedChoreSection}
onGroupBySelect={value => {
setSelectedChoreSectionWithCache(value)
setFilteredChores(chores)
clearQuickFilters()
}}
viewMode={viewMode}
onToggleViewMode={toggleViewMode}
isMultiSelectMode={isMultiSelectMode}
onToggleMultiSelect={toggleMultiSelectMode}
searchTerm={searchTerm}
onSearchChange={handleSearchChange}
onSearchClose={handleSearchClose}
searchInputRef={searchInputRef}
showKeyboardShortcuts={showKeyboardShortcuts}
/>
<MultiSelectToolbar

View File

@@ -0,0 +1,732 @@
/**
* PROTOTYPE Unified Chore Toolbar
*
* Proposed design to replace the current 3-surface layout:
* OLD: [Search] [Sort+Group+AssigneeFilter+CreateFilter] [ProjectSelector] [View] [Multiselect]
* + FilterBar (Due Date / Priority / Labels chips row)
* + FilterSection (saved/pinned filter chips row)
*
* NEW: [Search] [Filter(n)] [Group ▾] [View] [Multiselect]
* + active filter chips appear inline next to Filter button
* + Filter button opens ONE unified bottom sheet containing:
* Project · Assignee · Due Date · Priority · Labels · Saved Filters
*
* How to try it: in MyChores.jsx, replace the <Box sx={{display:'flex'...}}> toolbar block
* and the two rows below it (FilterBar + FilterSection) with:
* <ChoreToolbar ... />
*/
import {
Add,
CalendarMonth,
Check,
CheckBox,
CheckBoxOutlineBlank,
Close,
FilterList,
FolderOpen,
PriorityHigh,
Settings,
Sort,
Style,
Tune,
ViewAgenda,
ViewComfy,
ViewModule,
} from '@mui/icons-material'
import {
Badge,
Box,
Button,
Chip,
Divider,
IconButton,
Typography,
} from '@mui/joy'
import { useState } from 'react'
import BottomSheetModal from '../../../components/common/BottomSheetModal'
import KeyboardShortcutHint from '../../../components/common/KeyboardShortcutHint'
import SearchBar from './SearchBar'
// ─── helpers ─────────────────────────────────────────────────────────────────
const chipLabel = (def, value) => {
if (!value || (Array.isArray(value) && value.length === 0)) return null
if (def.type === 'single-select')
return def.options?.find(o => o.value === value)?.label ?? null
if (def.type === 'multi-select' && Array.isArray(value)) {
if (value.length === 1)
return def.options?.find(o => o.value === value[0])?.label ?? def.label
return `${def.label} (${value.length})`
}
return null
}
// ─── sub-components ──────────────────────────────────────────────────────────
const SectionHeader = ({ icon, label, badge }) => (
<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>
{badge != null && (
<Chip
size='sm'
variant='solid'
color='primary'
sx={{ ml: 'auto', fontSize: '0.7rem', height: 20 }}
>
{badge}
</Chip>
)}
</Box>
)
const OptionChips = ({ options, selected, multi, onToggle }) => (
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
{options.map(opt => {
const isSelected = multi
? (selected || []).includes(opt.value)
: selected === opt.value
return (
<Chip
key={opt.value}
variant={isSelected ? 'solid' : 'soft'}
color={isSelected ? opt.color ?? 'primary' : 'neutral'}
startDecorator={
opt.icon != null
? isSelected
? <Check sx={{ fontSize: 14 }} />
: opt.icon
: undefined
}
onClick={() => onToggle(opt.value)}
sx={{
py: 0.64,
cursor: 'pointer',
transition: 'all 0.15s ease',
userSelect: 'none',
'&:hover': { opacity: 0.85 },
}}
>
{opt.label}
</Chip>
)
})}
</Box>
)
// ─── main component ───────────────────────────────────────────────────────────
/**
* Props:
* filterDefs same shape as FilterBar: [{ id, label, type, icon, options, filterFn }]
* PLUS two new built-in sections handled here: 'project' and 'assignee'
* activeFilters { [id]: value }
* onSetFilter (id, value | null) => void
* onClearAllFilters () => void
* resultCount / totalCount
*
* projects [{ id, name }] — drives the Project section
* selectedProject current project object
* onProjectSelect (project) => void
*
* selectedAssigneeFilter 'anyone' | 'assigned_to_me' | 'available_for_me' | 'assigned_to_others'
* onAssigneeFilterChange (key) => void
*
* savedFilters [{ id, name, color, count, isPinned }]
* activeFilterId number | null
* onSavedFilterClick (id) => void
* onSavedFilterEdit (filter) => void
* onSavedFilterDelete (id) => void
* onSavedFilterPin (id) => void
* onCreateAdvancedFilter () => void
*
* selectedGroupBy 'default' | 'due_date' | 'priority' | 'labels'
* onGroupBySelect (value) => void
*
* viewMode 'default' | 'compact' | 'calendar'
* onToggleViewMode () => void
*
* isMultiSelectMode bool
* onToggleMultiSelect () => void
*
* searchTerm / onSearchChange / onSearchClose / searchInputRef
* showKeyboardShortcuts
*/
const ChoreToolbar = ({
// quick filters
filterDefs = [],
activeFilters = {},
onSetFilter,
onClearAllFilters,
resultCount,
totalCount,
// project
projects = [],
selectedProject,
onProjectSelect,
// assignee
selectedAssigneeFilter = 'anyone',
onAssigneeFilterChange,
// saved / custom
savedFilters = [],
activeFilterId,
onSavedFilterClick,
onSavedFilterEdit,
onSavedFilterDelete,
onSavedFilterPin,
onCreateAdvancedFilter,
// grouping
selectedGroupBy = 'default',
onGroupBySelect,
// view + multiselect
viewMode = 'default',
onToggleViewMode,
isMultiSelectMode,
onToggleMultiSelect,
// search
searchTerm,
onSearchChange,
onSearchClose,
searchInputRef,
showKeyboardShortcuts,
}) => {
const [filterSheetOpen, setFilterSheetOpen] = useState(false)
const [displaySheetOpen, setDisplaySheetOpen] = useState(false)
// ── count active filters for badge ──────────────────────────────────────────
const quickFilterCount = filterDefs.filter(def => {
const v = activeFilters[def.id]
return v != null && !(Array.isArray(v) && v.length === 0)
}).length
const projectActive =
selectedProject && selectedProject.id !== 'default' ? 1 : 0
const assigneeActive = selectedAssigneeFilter !== 'anyone' ? 1 : 0
const savedFilterActive = activeFilterId != null ? 1 : 0
const totalActiveCount = quickFilterCount + savedFilterActive
const hasAnyActive = totalActiveCount > 0
// ── inline chip strip (max 2 visible + overflow) ─────────────────────────────
const inlineChips = []
if (savedFilterActive) {
const sf = savedFilters.find(f => f.id === activeFilterId)
if (sf)
inlineChips.push({
key: '__saved',
label: sf.name,
onClear: () => onSavedFilterClick?.(activeFilterId),
})
}
filterDefs.forEach(def => {
const label = chipLabel(def, activeFilters[def.id])
if (label) inlineChips.push({ key: def.id, label, onClear: () => onSetFilter(def.id, null) })
})
const MAX_CHIPS = 2
const visibleChips = inlineChips.slice(0, MAX_CHIPS)
const overflow = inlineChips.length - MAX_CHIPS
// ── groupby options ──────────────────────────────────────────────────────────
const groupByOptions = [
{ value: 'default', label: 'Smart' },
{ value: 'due_date', label: 'Due Date' },
{ value: 'priority', label: 'Priority' },
{ value: 'labels', label: 'Labels' },
]
// ── assignee options ─────────────────────────────────────────────────────────
const assigneeOptions = [
{ value: 'anyone', label: 'Everyone' },
{ value: 'assigned_to_me', label: 'Mine' },
{ value: 'available_for_me', label: 'Available to me' },
{ value: 'assigned_to_others', label: 'Others' },
]
// ── project options (show only if more than just Default) ───────────────────
const showProjectSection = projects.filter(p => p.id !== 'default').length > 0
// ── pinned saved filters ─────────────────────────────────────────────────────
const pinnedFilters = savedFilters.filter(f => f.isPinned)
// ── display active state (highlight button when non-default) ─────────────────
const displayActive =
selectedGroupBy !== 'default' ||
viewMode !== 'default' ||
projectActive > 0 ||
assigneeActive > 0
const viewOptions = [
{ value: 'default', label: 'Cards', icon: <ViewAgenda sx={{ fontSize: 16 }} /> },
{ value: 'compact', label: 'Compact', icon: <ViewComfy sx={{ fontSize: 16 }} /> },
{ value: 'calendar', label: 'Calendar', icon: <CalendarMonth sx={{ fontSize: 16 }} /> },
]
return (
<>
{/* ── Row 1: main toolbar ─────────────────────────────────────────────── */}
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 0.5,
justifyContent: 'space-between',
}}
>
{/* Search takes available space */}
<SearchBar
value={searchTerm}
onChange={onSearchChange}
onClose={onSearchClose}
showKeyboardShortcuts={showKeyboardShortcuts}
inputRef={searchInputRef}
/>
{/* Filter button */}
<Badge
badgeContent={totalActiveCount || null}
color='primary'
size='sm'
anchorOrigin={{ vertical: 'top', horizontal: 'right' }}
>
<IconButton
variant={hasAnyActive ? 'solid' : 'outlined'}
color={hasAnyActive ? 'primary' : 'neutral'}
size='sm'
sx={{ height: 32, width: 32, borderRadius: '50%' }}
onClick={() => setFilterSheetOpen(true)}
title='Filters'
>
<FilterList />
</IconButton>
</Badge>
{/* Display button — View + Group combined */}
<IconButton
variant={displayActive ? 'solid' : 'outlined'}
color={displayActive ? 'primary' : 'neutral'}
size='sm'
sx={{ height: 32, width: 32, borderRadius: '50%' }}
onClick={() => setDisplaySheetOpen(true)}
title='View & Group'
>
{viewMode === 'calendar' ? (
<CalendarMonth />
) : viewMode === 'compact' ? (
<ViewModule />
) : (
<ViewAgenda />
)}
</IconButton>
{/* Multiselect */}
<Box sx={{ position: 'relative', display: 'inline-flex' }}>
<IconButton
variant={isMultiSelectMode ? 'solid' : 'outlined'}
color={isMultiSelectMode ? 'primary' : 'neutral'}
size='sm'
sx={{ height: 32, width: 32, borderRadius: '50%' }}
onClick={onToggleMultiSelect}
title={isMultiSelectMode ? 'Exit multi-select (Ctrl+S)' : 'Multi-select (Ctrl+S)'}
>
{isMultiSelectMode ? <CheckBox /> : <CheckBoxOutlineBlank />}
</IconButton>
<KeyboardShortcutHint
shortcut='S'
show={showKeyboardShortcuts}
sx={{ position: 'absolute', top: -8, right: -8, zIndex: 1000 }}
/>
</Box>
</Box>
{/* ── Row 2: active filter chips (only when something is active) ─────── */}
{hasAnyActive && (
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 1,
flexWrap: 'nowrap',
overflowX: 'auto',
py: 0.5,
'&::-webkit-scrollbar': { display: 'none' },
scrollbarWidth: 'none',
}}
>
{visibleChips.map(({ key, label, onClear }) => (
<Chip
key={key}
size='sm'
variant='soft'
color='primary'
endDecorator={
<Close
sx={{ fontSize: 12, cursor: 'pointer' }}
onClick={e => {
e.stopPropagation()
onClear()
}}
/>
}
onClick={() => setFilterSheetOpen(true)}
sx={{ cursor: 'pointer', flexShrink: 0 }}
>
{label}
</Chip>
))}
{overflow > 0 && (
<Chip
size='sm'
variant='soft'
color='neutral'
onClick={() => setFilterSheetOpen(true)}
sx={{ cursor: 'pointer', flexShrink: 0 }}
>
+{overflow} more
</Chip>
)}
{resultCount != null && totalCount != null && (
<Typography
level='body-xs'
sx={{ color: 'text.tertiary', ml: 'auto', flexShrink: 0 }}
>
{resultCount} / {totalCount}
</Typography>
)}
<Button
size='sm'
variant='plain'
color='neutral'
sx={{
px: 0.5,
fontSize: '0.72rem',
color: 'text.tertiary',
minHeight: 0,
flexShrink: 0,
}}
onClick={onClearAllFilters}
>
Clear all
</Button>
</Box>
)}
{/* ── Unified Filter bottom sheet ─────────────────────────────────────── */}
<BottomSheetModal
open={filterSheetOpen}
onClose={() => setFilterSheetOpen(false)}
title={
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Tune sx={{ fontSize: 20 }} />
Filters
{hasAnyActive && (
<Chip size='sm' variant='solid' color='primary' sx={{ ml: 0.5 }}>
{totalActiveCount}
</Chip>
)}
</Box>
}
footer={
<Box
sx={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
gap: 1,
}}
>
<Button
variant='plain'
color='danger'
size='sm'
disabled={!hasAnyActive}
onClick={onClearAllFilters}
>
Clear all
</Button>
<Button
onClick={() => setFilterSheetOpen(false)}
sx={{ minWidth: 140 }}
>
{resultCount != null
? `Show ${resultCount} result${resultCount !== 1 ? 's' : ''}`
: 'Done'}
</Button>
</Box>
}
>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0 }}>
{/* ── Quick filter sections (Due Date / Priority / Labels etc.) ────── */}
{filterDefs.map(def => (
<Box key={def.id}>
<Divider sx={{ my: 2.5 }} />
<SectionHeader
icon={def.icon}
label={def.label}
badge={chipLabel(def, activeFilters[def.id])}
/>
<OptionChips
options={def.options ?? []}
selected={activeFilters[def.id]}
multi={def.type === 'multi-select'}
onToggle={val => {
if (def.type === 'multi-select') {
const curr = activeFilters[def.id] || []
const next = curr.includes(val)
? curr.filter(v => v !== val)
: [...curr, val]
onSetFilter(def.id, next.length > 0 ? next : null)
} else {
onSetFilter(def.id, activeFilters[def.id] === val ? null : val)
}
}}
/>
</Box>
))}
{/* ── Saved / custom filters ──────────────────────────────────────── */}
{(pinnedFilters.length > 0 || savedFilters.length > 0) && (
<>
<Divider sx={{ my: 2.5 }} />
<Box
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
mb: 1.5,
}}
>
<Typography level='title-sm' fontWeight={600}>
Saved Filters
</Typography>
<IconButton
size='sm'
variant='plain'
color='neutral'
onClick={() => {
setFilterSheetOpen(false)
// navigate to /filters or open settings
}}
>
<Settings sx={{ fontSize: 16 }} />
</IconButton>
</Box>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
{savedFilters.map(filter => {
const isActive = activeFilterId === filter.id
return (
<Chip
key={filter.id}
variant={isActive ? 'solid' : 'soft'}
color='neutral'
startDecorator={
isActive ? (
<Check sx={{ fontSize: 14 }} />
) : (
<Chip size='sm' variant='plain' color='neutral'>
{filter.count ?? 0}
</Chip>
)
}
onClick={() => {
onSavedFilterClick?.(filter.id)
setFilterSheetOpen(false)
}}
sx={{
cursor: 'pointer',
transition: 'all 0.15s ease',
userSelect: 'none',
'&:hover': { opacity: 0.85 },
...(filter.color && !isActive
? { borderColor: filter.color }
: {}),
}}
>
{filter.name}
</Chip>
)
})}
</Box>
<Button
variant='plain'
color='primary'
size='sm'
startDecorator={<Add sx={{ fontSize: 16 }} />}
onClick={() => {
setFilterSheetOpen(false)
onCreateAdvancedFilter?.()
}}
sx={{ mt: 1.5, alignSelf: 'flex-start', px: 0 }}
>
Create advanced filter
</Button>
</>
)}
{/* Edge case: no saved filters yet → still show "Create" CTA */}
{savedFilters.length === 0 && (
<>
<Divider sx={{ my: 2.5 }} />
<Button
variant='plain'
color='primary'
size='sm'
startDecorator={<Add sx={{ fontSize: 16 }} />}
onClick={() => {
setFilterSheetOpen(false)
onCreateAdvancedFilter?.()
}}
sx={{ alignSelf: 'flex-start', px: 0 }}
>
Create advanced filter
</Button>
</>
)}
</Box>
</BottomSheetModal>
{/* ── Display bottom sheet (View + Group combined) ───────────────────── */}
<BottomSheetModal
open={displaySheetOpen}
onClose={() => setDisplaySheetOpen(false)}
title={
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<ViewAgenda sx={{ fontSize: 20 }} />
Display
</Box>
}
footer={
<Button onClick={() => setDisplaySheetOpen(false)} sx={{ minWidth: 140 }}>
Done
</Button>
}
>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0 }}>
{/* View section */}
<SectionHeader label='View' />
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
{viewOptions.map(opt => (
<Chip
key={opt.value}
variant={viewMode === opt.value ? 'solid' : 'soft'}
color={viewMode === opt.value ? 'primary' : 'neutral'}
startDecorator={viewMode === opt.value ? <Check sx={{ fontSize: 14 }} /> : opt.icon}
onClick={() => onToggleViewMode?.(opt.value)}
sx={{
py: 0.64,
cursor: 'pointer',
transition: 'all 0.15s ease',
userSelect: 'none',
'&:hover': { opacity: 0.85 },
}}
>
{opt.label}
</Chip>
))}
</Box>
<Divider sx={{ my: 2.5 }} />
{/* Group by section */}
<SectionHeader
icon={<Sort />}
label='Group by'
badge={
selectedGroupBy !== 'default'
? groupByOptions.find(o => o.value === selectedGroupBy)?.label
: null
}
/>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
{groupByOptions.map(opt => (
<Chip
key={opt.value}
variant={selectedGroupBy === opt.value ? 'solid' : 'soft'}
color={selectedGroupBy === opt.value ? 'primary' : 'neutral'}
onClick={() => onGroupBySelect?.(opt.value)}
sx={{
py: 0.64,
cursor: 'pointer',
transition: 'all 0.15s ease',
userSelect: 'none',
'&:hover': { opacity: 0.85 },
}}
>
{opt.label}
</Chip>
))}
</Box>
{/* Show tasks for section */}
<Divider sx={{ my: 2.5 }} />
<SectionHeader
icon={<FilterList />}
label='Show tasks for'
badge={
selectedAssigneeFilter !== 'anyone'
? assigneeOptions.find(o => o.value === selectedAssigneeFilter)?.label
: null
}
/>
<OptionChips
options={assigneeOptions}
selected={selectedAssigneeFilter}
multi={false}
onToggle={v => onAssigneeFilterChange?.(v)}
/>
{/* Project section */}
{showProjectSection && (
<>
<Divider sx={{ my: 2.5 }} />
<SectionHeader
icon={<FolderOpen />}
label='Project'
badge={projectActive ? selectedProject.name : null}
/>
<OptionChips
options={projects.map(p => ({ value: p.id, label: p.name }))}
selected={selectedProject?.id ?? 'default'}
multi={false}
onToggle={id => {
const p = projects.find(x => x.id === id)
if (p) onProjectSelect?.(p)
}}
/>
</>
)}
</Box>
</BottomSheetModal>
</>
)
}
export default ChoreToolbar

View File

@@ -8,11 +8,21 @@ import {
import '@meauxt/react-swipeable-list/dist/styles.css'
import {
Analytics,
CalendarMonth,
Check,
Checklist,
EventBusy,
EventNote,
FilterList,
Group,
History,
HourglassEmpty,
Person,
Redo,
RunningWithErrors,
Schedule,
Star,
ThumbDown,
Timelapse,
TrendingUp,
} from '@mui/icons-material'
@@ -20,10 +30,12 @@ import DeleteIcon from '@mui/icons-material/Delete'
import EditIcon from '@mui/icons-material/Edit'
import { Box, Button, Card, Container, Grid, Sheet, Typography } from '@mui/joy'
import moment from 'moment'
import { useEffect, useState } from 'react'
import { useEffect, useMemo, useState } from 'react'
import { Link, useParams } from 'react-router-dom'
import FilterBar from '../../components/common/FilterBar'
import { useLocalization } from '../../contexts/LocalizationContext'
import useConfirmationModal from '../../hooks/useConfirmationModal'
import { useFilter } from '../../hooks/useFilter'
import {
useChoreHistory,
useDeleteChoreHistory,
@@ -34,6 +46,7 @@ import { useNotification } from '../../service/NotificationProvider'
import { ChoreHistoryStatus } from '../../utils/Chores'
import LoadingComponent from '../components/Loading'
import EditHistoryModal from '../Modals/EditHistoryModal'
import HistoryDetailModal from '../Modals/HistoryDetailModal'
import ConfirmationModal from '../Modals/Inputs/ConfirmationModal'
import NoteViewerModal from '../Modals/Inputs/NoteViewerModal'
import HistoryCard from './HistoryCard'
@@ -48,6 +61,7 @@ const ChoreHistory = () => {
const { fmt } = useLocalization()
const [showMoreInfoId, setShowMoreInfoId] = useState(null)
const [noteViewerConfig, setNoteViewerConfig] = useState({ isOpen: false })
const [detailModalConfig, setDetailModalConfig] = useState({ isOpen: false })
const { showSuccess, showError } = useNotification()
// React Query hooks
const { data: choreHistoryData, isLoading } = useChoreHistory(choreId)
@@ -58,6 +72,61 @@ const ChoreHistory = () => {
const choreHistory = choreHistoryData?.res || []
const performers = circleMembersData?.res || []
const filterDefs = useMemo(
() => [
{
id: 'status',
label: 'Status',
type: 'multi-select',
icon: <FilterList />,
options: [
{ value: ChoreHistoryStatus.COMPLETED, label: 'Completed', color: 'success', icon: <Check sx={{ fontSize: 14 }} /> },
{ value: ChoreHistoryStatus.SKIPPED, label: 'Skipped', color: 'warning', icon: <Redo sx={{ fontSize: 14 }} /> },
{ value: ChoreHistoryStatus.PENDING_APPROVAL, label: 'Pending', color: 'neutral', icon: <HourglassEmpty sx={{ fontSize: 14 }} /> },
{ value: ChoreHistoryStatus.REJECTED, label: 'Rejected', color: 'danger', icon: <ThumbDown sx={{ fontSize: 14 }} /> },
{ value: 5, label: 'Missed', color: 'danger', icon: <RunningWithErrors sx={{ fontSize: 14 }} /> },
{ value: 6, label: 'Rescheduled', color: 'warning', icon: <Schedule sx={{ fontSize: 14 }} /> },
],
filterFn: (item, values) => values.includes(item.status),
},
{
id: 'hasNotes',
label: 'Has Notes',
type: 'boolean',
icon: <EventNote />,
filterFn: item => !!item.notes,
},
{
id: 'completedBy',
label: 'Completed By',
type: 'multi-select',
icon: <Person />,
options: performers.map(p => ({
value: p.userId,
label: p.displayName,
avatar: p.image,
})),
filterFn: (item, values) => values.includes(item.completedBy),
},
{
id: 'dateRange',
label: 'Completed At',
type: 'date-range',
icon: <CalendarMonth />,
filterFn: (item, value) => {
const performed = new Date(item.performedAt || item.updatedAt)
if (value.from && performed < new Date(value.from)) return false
if (value.to && performed > new Date(value.to)) return false
return true
},
},
],
[performers],
)
const { filteredData: filteredHistory, activeFilters, setFilter, clearAll, activeFilterCount } =
useFilter(choreHistory, filterDefs)
const handleDelete = historyEntry => {
showConfirmation(
`Are you sure you want to delete this history record?`,
@@ -205,9 +274,10 @@ const ChoreHistory = () => {
}
return (
<Container maxWidth='md'>
<Container maxWidth='md' sx={{ px: 0 }}>
{/* Enhanced Header Section */}
<Box sx={{ mb: 4 }}>
<Box sx={{ gap: 2, p: 2 }}>
{/* <Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 2, p: 2 }}> */}
{/* Statistics Cards Grid - Compact Design */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 3 }}>
<History sx={{ fontSize: '1.5rem' }} />
@@ -285,7 +355,9 @@ const ChoreHistory = () => {
</Box>
{/* History Section Header */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 3 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, p: 2 }}>
<Analytics sx={{ fontSize: '1.5rem' }} />
<Typography
level='title-md'
@@ -294,14 +366,50 @@ const ChoreHistory = () => {
Task Activity
</Typography>
</Box>
<Box sx={{ px: 2 }}>
<FilterBar
filterDefs={filterDefs}
activeFilters={activeFilters}
onSetFilter={setFilter}
onClearAll={clearAll}
resultCount={filteredHistory.length}
totalCount={choreHistory.length}
/>
</Box>
{filteredHistory.length === 0 && activeFilterCount > 0 && (
<Box
sx={{
textAlign: 'center',
py: 6,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: 1.5,
}}
>
<FilterList sx={{ fontSize: '3rem', color: 'text.tertiary' }} />
<Typography level='title-md' sx={{ color: 'text.secondary' }}>
No results match your filters
</Typography>
<Typography level='body-sm' sx={{ color: 'text.tertiary' }}>
Try adjusting or clearing the active filters.
</Typography>
<Button variant='soft' size='sm' onClick={clearAll} sx={{ mt: 0.5 }}>
Clear filters
</Button>
</Box>
)}
{filteredHistory.length > 0 && (
<Sheet
variant='plain'
sx={{ borderRadius: 'sm', boxShadow: 'md', overflow: 'hidden' }}
sx={{ borderRadius: 'sm', overflow: 'hidden' }}
>
{/* Chore History List (Updated Style) */}
<SwipeableList type={ListType.IOS} fullSwipe={false}>
{choreHistory.map((historyEntry, index) => (
{filteredHistory.map((historyEntry, index) => (
<SwipeableListItem
key={historyEntry.id || index}
swipeActionOpen={
@@ -366,6 +474,14 @@ const ChoreHistory = () => {
performers={performers}
allHistory={choreHistory}
index={index}
onViewDetails={() => {
setDetailModalConfig({
isOpen: true,
entry: historyEntry,
performers,
onClose: () => setDetailModalConfig({ isOpen: false }),
})
}}
onViewNote={notes => {
setNoteViewerConfig({
isOpen: true,
@@ -387,6 +503,7 @@ const ChoreHistory = () => {
))}
</SwipeableList>
</Sheet>
)}
<EditHistoryModal
config={{
isOpen: isEditModalOpen,
@@ -445,6 +562,7 @@ const ChoreHistory = () => {
/>
<ConfirmationModal config={confirmModalConfig} />
<NoteViewerModal config={noteViewerConfig} />
<HistoryDetailModal config={detailModalConfig} />
</Container>
)
}