remove the old filter logic and use advance filter in myChore

This commit is contained in:
Mo Tarbin
2026-06-14 15:55:19 -04:00
parent 23e777cd94
commit 8e5c31f7f3
4 changed files with 823 additions and 639 deletions

View File

@@ -268,7 +268,6 @@ const MyChores = () => {
const { const {
filteredData: quickFilteredChores, filteredData: quickFilteredChores,
activeFilters: quickFilters,
setFilter: setQuickFilter, setFilter: setQuickFilter,
clearAll: clearQuickFilters, clearAll: clearQuickFilters,
hasActiveFilters: hasQuickFilters, hasActiveFilters: hasQuickFilters,
@@ -948,30 +947,37 @@ const MyChores = () => {
tempFilterMeta={tempFilterMeta} tempFilterMeta={tempFilterMeta}
/> />
<ChoreToolbar <ChoreToolbar
filterDefs={quickFilterDefs} members={membersData?.res || []}
activeFilters={quickFilters} labels={userLabels || []}
onSetFilter={(id, value) => { projects={projectsWithDefault}
clearActiveFilter() tempFilter={tempFilter}
setQuickFilter(id, value) applyTempFilter={applyTempFilter}
setSelectedCalendarDate(null) clearTempFilter={clearTempFilter}
}} saveFilter={saveFilter}
onFilterSaved={name =>
showSuccess({
title: 'Filter Saved',
message: `"${name}" has been saved`,
})
}
onClearAllFilters={() => { onClearAllFilters={() => {
clearQuickFilters() clearQuickFilters()
clearActiveFilter() clearActiveFilter()
setSelectedChoreFilterWithCache('anyone') setSelectedChoreFilterWithCache('anyone')
setSelectedProjectWithCache(projectsWithDefault.find(p => p.id === 'default') || null) setSelectedProjectWithCache(
projectsWithDefault.find(p => p.id === 'default') || null,
)
updateFilterUrl(null, null) updateFilterUrl(null, null)
}} }}
resultCount={ resultCount={
hasQuickFilters || !!activeFilterId ? getFilteredChores.length : undefined hasQuickFilters || hasFilterApplied
? getFilteredChores.length
: undefined
} }
totalCount={ totalCount={
hasQuickFilters || !!activeFilterId ? projectFilteredChores.length : undefined hasQuickFilters || hasFilterApplied
} ? projectFilteredChores.length
projects={ : undefined
!hasProjectConditions && !hasFilterApplied
? projectsWithDefault
: []
} }
selectedProject={selectedProject} selectedProject={selectedProject}
onProjectSelect={project => { onProjectSelect={project => {
@@ -1013,10 +1019,6 @@ const MyChores = () => {
}} }}
onSavedFilterDelete={deleteFilter} onSavedFilterDelete={deleteFilter}
onSavedFilterPin={pinFilter} onSavedFilterPin={pinFilter}
onCreateAdvancedFilter={() => {
setShowAdvancedFilterBuilder(true)
setEditingFilter(null)
}}
selectedGroupBy={selectedChoreSection} selectedGroupBy={selectedChoreSection}
onGroupBySelect={value => { onGroupBySelect={value => {
setSelectedChoreSectionWithCache(value) setSelectedChoreSectionWithCache(value)

View File

@@ -9,7 +9,8 @@
* NEW: [Search] [Filter(n)] [Group ▾] [View] [Multiselect] * NEW: [Search] [Filter(n)] [Group ▾] [View] [Multiselect]
* + active filter chips appear inline next to Filter button * + active filter chips appear inline next to Filter button
* + Filter button opens ONE unified bottom sheet containing: * + Filter button opens ONE unified bottom sheet containing:
* Project · Assignee · Due Date · Priority · Labels · Saved Filters * Assignee · Created By · Status · Priority · Due Date · Labels · Projects · Points
* + Saved Filters section
* *
* How to try it: in MyChores.jsx, replace the <Box sx={{display:'flex'...}}> toolbar block * How to try it: in MyChores.jsx, replace the <Box sx={{display:'flex'...}}> toolbar block
* and the two rows below it (FilterBar + FilterSection) with: * and the two rows below it (FilterBar + FilterSection) with:
@@ -17,7 +18,7 @@
*/ */
import { import {
Add, ArrowDropDown,
CalendarMonth, CalendarMonth,
Check, Check,
CheckBox, CheckBox,
@@ -25,10 +26,8 @@ import {
Close, Close,
FilterList, FilterList,
FolderOpen, FolderOpen,
PriorityHigh, Save,
Settings,
Sort, Sort,
Style,
Tune, Tune,
ViewAgenda, ViewAgenda,
ViewComfy, ViewComfy,
@@ -38,31 +37,28 @@ import {
Badge, Badge,
Box, Box,
Button, Button,
ButtonGroup,
Chip, Chip,
Divider, Divider,
IconButton, IconButton,
Input,
Menu,
MenuItem,
Typography, Typography,
} from '@mui/joy' } from '@mui/joy'
import { useState } from 'react' import { useRef, useState } from 'react'
import BottomSheetModal from '../../../components/common/BottomSheetModal' import BottomSheetModal from '../../../components/common/BottomSheetModal'
import { Z_INDEX } from '../../../constants/zIndex'
import KeyboardShortcutHint from '../../../components/common/KeyboardShortcutHint' import KeyboardShortcutHint from '../../../components/common/KeyboardShortcutHint'
import { FILTER_COLORS } from '../../../utils/Colors'
import FilterBuilderContent, {
conditionsToSelections,
defaultSelections,
selectionsToConditions,
} from './FilterBuilderContent'
import SearchBar from './SearchBar' import SearchBar from './SearchBar'
// ─── helpers ───────────────────────────────────────────────────────────────── // ─── sub-components for the Display sheet ────────────────────────────────────
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 }) => ( const SectionHeader = ({ icon, label, badge }) => (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}> <Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}>
@@ -132,53 +128,67 @@ const OptionChips = ({ options, selected, multi, onToggle }) => (
/** /**
* Props: * Props:
* filterDefs same shape as FilterBar: [{ id, label, type, icon, options, filterFn }] * -- Advanced filter (filter sheet) --
* PLUS two new built-in sections handled here: 'project' and 'assignee' * members circle members for Assignee / Created By sections
* activeFilters { [id]: value } * labels user labels for Labels section
* onSetFilter (id, value | null) => void * projects projects list (projectsWithDefault) for Projects section + Display sheet
* onClearAllFilters () => void * tempFilter current temp filter object { conditions, operator } or null
* applyTempFilter (filter) => void — called immediately as selections change
* clearTempFilter () => void
* saveFilter (filterData) => Promise — saves as a named filter
* onFilterSaved (name) => void — called after successful save (for notifications)
*
* -- Result counts --
* resultCount / totalCount * resultCount / totalCount
* *
* projects [{ id, name }] — drives the Project section * -- Clear all --
* selectedProject current project object * onClearAllFilters () => void
* onProjectSelect (project) => void
*
* selectedAssigneeFilter 'anyone' | 'assigned_to_me' | 'available_for_me' | 'assigned_to_others'
* onAssigneeFilterChange (key) => void
* *
* -- Saved filters --
* savedFilters [{ id, name, color, count, isPinned }] * savedFilters [{ id, name, color, count, isPinned }]
* activeFilterId number | null * activeFilterId number | null
* onSavedFilterClick (id) => void * onSavedFilterClick (id) => void
* onSavedFilterEdit (filter) => void * onSavedFilterEdit (filter) => void
* onSavedFilterDelete (id) => void * onSavedFilterDelete (id) => void
* onSavedFilterPin (id) => void * onSavedFilterPin (id) => void
* onCreateAdvancedFilter () => void
* *
* -- Display sheet --
* selectedProject current project object (for Display sheet section)
* onProjectSelect (project) => void
* selectedAssigneeFilter 'anyone' | 'assigned_to_me' | 'available_for_me' | 'assigned_to_others'
* onAssigneeFilterChange (key) => void
* selectedGroupBy 'default' | 'due_date' | 'priority' | 'labels' * selectedGroupBy 'default' | 'due_date' | 'priority' | 'labels'
* onGroupBySelect (value) => void * onGroupBySelect (value) => void
*
* viewMode 'default' | 'compact' | 'calendar' * viewMode 'default' | 'compact' | 'calendar'
* onToggleViewMode () => void * onToggleViewMode (value?) => void
* *
* -- Multi-select --
* isMultiSelectMode bool * isMultiSelectMode bool
* onToggleMultiSelect () => void * onToggleMultiSelect () => void
* *
* -- Search --
* searchTerm / onSearchChange / onSearchClose / searchInputRef * searchTerm / onSearchChange / onSearchClose / searchInputRef
* showKeyboardShortcuts * showKeyboardShortcuts
*/ */
const ChoreToolbar = ({ const ChoreToolbar = ({
// quick filters // advanced filter
filterDefs = [], members = [],
activeFilters = {}, labels = [],
onSetFilter, projects = [],
onClearAllFilters, tempFilter,
applyTempFilter,
clearTempFilter,
saveFilter,
onFilterSaved,
// result counts
resultCount, resultCount,
totalCount, totalCount,
// project // clear all
projects = [], onClearAllFilters,
// project (for Display sheet)
selectedProject, selectedProject,
onProjectSelect, onProjectSelect,
// assignee // assignee (for Display sheet)
selectedAssigneeFilter = 'anyone', selectedAssigneeFilter = 'anyone',
onAssigneeFilterChange, onAssigneeFilterChange,
// saved / custom // saved / custom
@@ -188,7 +198,6 @@ const ChoreToolbar = ({
onSavedFilterEdit, onSavedFilterEdit,
onSavedFilterDelete, onSavedFilterDelete,
onSavedFilterPin, onSavedFilterPin,
onCreateAdvancedFilter,
// grouping // grouping
selectedGroupBy = 'default', selectedGroupBy = 'default',
onGroupBySelect, onGroupBySelect,
@@ -206,46 +215,111 @@ const ChoreToolbar = ({
}) => { }) => {
const [filterSheetOpen, setFilterSheetOpen] = useState(false) const [filterSheetOpen, setFilterSheetOpen] = useState(false)
const [displaySheetOpen, setDisplaySheetOpen] = useState(false) const [displaySheetOpen, setDisplaySheetOpen] = useState(false)
const [localSelections, setLocalSelections] = useState(defaultSelections())
const [savingFilter, setSavingFilter] = useState(false)
const [saveFilterName, setSaveFilterName] = useState('')
const [saveMenuAnchorEl, setSaveMenuAnchorEl] = useState(null)
const saveMenuRef = useRef(null)
// ── count active filters for badge ────────────────────────────────────────── // ── badge counts ─────────────────────────────────────────────────────────────
const quickFilterCount = filterDefs.filter(def => { const tempConditionCount = tempFilter?.conditions?.length || 0
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 savedFilterActive = activeFilterId != null ? 1 : 0
const totalActiveCount = tempConditionCount + savedFilterActive
const totalActiveCount = quickFilterCount + savedFilterActive
const hasAnyActive = totalActiveCount > 0 const hasAnyActive = totalActiveCount > 0
// ── inline chip strip (max 2 visible + overflow) ───────────────────────────── // ── inline chip strip ────────────────────────────────────────────────────────
const inlineChips = [] const inlineChips = []
if (savedFilterActive) { if (savedFilterActive) {
const sf = savedFilters.find(f => f.id === activeFilterId) const sf = savedFilters.find(f => f.id === activeFilterId)
if (sf) if (sf) {
inlineChips.push({ inlineChips.push({
key: '__saved', key: '__saved',
label: sf.name, label: sf.name,
onClear: () => onSavedFilterClick?.(activeFilterId), onClear: () => onSavedFilterClick?.(activeFilterId),
}) })
} }
} else if (tempConditionCount > 0) {
inlineChips.push({
key: '__temp',
label: `${tempConditionCount} condition${tempConditionCount !== 1 ? 's' : ''}`,
onClear: () => {
setLocalSelections(defaultSelections())
clearTempFilter?.()
},
})
}
filterDefs.forEach(def => { // ── open filter sheet ────────────────────────────────────────────────────────
const label = chipLabel(def, activeFilters[def.id])
if (label) inlineChips.push({ key: def.id, label, onClear: () => onSetFilter(def.id, null) }) const openFilterSheet = () => {
if (tempFilter?.conditions?.length > 0) {
setLocalSelections(conditionsToSelections(tempFilter.conditions))
} else if (activeFilterId) {
const sf = savedFilters.find(f => f.id === activeFilterId)
setLocalSelections(
sf?.conditions
? conditionsToSelections(sf.conditions)
: defaultSelections(),
)
} else {
setLocalSelections(defaultSelections())
}
setSavingFilter(false)
setSaveFilterName('')
setFilterSheetOpen(true)
}
// ── selection changes → apply temp filter immediately ────────────────────────
const handleSelectionsChange = updater => {
setLocalSelections(prev => {
const next = typeof updater === 'function' ? updater(prev) : updater
const conditions = selectionsToConditions(next)
if (conditions.length > 0) {
applyTempFilter?.({ conditions, operator: 'AND' })
} else {
clearTempFilter?.()
}
return next
})
}
// ── save filter ───────────────────────────────────────────────────────────────
const handleSaveFilter = () => {
const name = saveFilterName.trim()
if (!name) return
const conditions = selectionsToConditions(localSelections)
if (conditions.length === 0) return
const usedColors = savedFilters.map(f => f.color)
const color =
FILTER_COLORS.find(c => !usedColors.includes(c.value))?.value ??
FILTER_COLORS[0].value
saveFilter?.({ name, description: '', color, conditions, operator: 'AND' })?.then?.(() => {
applyTempFilter?.({ conditions, operator: 'AND' }, { name })
onFilterSaved?.(name)
}) })
const MAX_CHIPS = 2 setSavingFilter(false)
const visibleChips = inlineChips.slice(0, MAX_CHIPS) setSaveFilterName('')
const overflow = inlineChips.length - MAX_CHIPS setFilterSheetOpen(false)
}
// ── groupby options ────────────────────────────────────────────────────────── // ── display sheet helpers ────────────────────────────────────────────────────
const filterActive = activeFilterId != null || tempConditionCount > 0
const projectActive = selectedProject && selectedProject.id !== 'default' ? 1 : 0
const assigneeActive = selectedAssigneeFilter !== 'anyone' ? 1 : 0
const displayActive =
selectedGroupBy !== 'default' ||
viewMode !== 'default' ||
projectActive > 0 ||
assigneeActive > 0
const groupByOptions = [ const groupByOptions = [
{ value: 'default', label: 'Smart' }, { value: 'default', label: 'Smart' },
@@ -254,8 +328,6 @@ const ChoreToolbar = ({
{ value: 'labels', label: 'Labels' }, { value: 'labels', label: 'Labels' },
] ]
// ── assignee options ─────────────────────────────────────────────────────────
const assigneeOptions = [ const assigneeOptions = [
{ value: 'anyone', label: 'Everyone' }, { value: 'anyone', label: 'Everyone' },
{ value: 'assigned_to_me', label: 'Mine' }, { value: 'assigned_to_me', label: 'Mine' },
@@ -263,28 +335,19 @@ const ChoreToolbar = ({
{ value: 'assigned_to_others', label: 'Others' }, { 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 = [ const viewOptions = [
{ value: 'default', label: 'Cards', icon: <ViewAgenda sx={{ fontSize: 16 }} /> }, { value: 'default', label: 'Cards', icon: <ViewAgenda sx={{ fontSize: 16 }} /> },
{ value: 'compact', label: 'Compact', icon: <ViewComfy sx={{ fontSize: 16 }} /> }, { value: 'compact', label: 'Compact', icon: <ViewComfy sx={{ fontSize: 16 }} /> },
{ value: 'calendar', label: 'Calendar', icon: <CalendarMonth sx={{ fontSize: 16 }} /> }, { value: 'calendar', label: 'Calendar', icon: <CalendarMonth sx={{ fontSize: 16 }} /> },
] ]
// Only show project in Display sheet when no advanced filter is active
const showProjectInDisplay =
!filterActive &&
projects.filter(p => p.id !== 'default').length > 0
const activeConditions = selectionsToConditions(localSelections)
return ( return (
<> <>
{/* ── Row 1: main toolbar ─────────────────────────────────────────────── */} {/* ── Row 1: main toolbar ─────────────────────────────────────────────── */}
@@ -317,7 +380,7 @@ const ChoreToolbar = ({
color={hasAnyActive ? 'primary' : 'neutral'} color={hasAnyActive ? 'primary' : 'neutral'}
size='sm' size='sm'
sx={{ height: 32, width: 32, borderRadius: '50%' }} sx={{ height: 32, width: 32, borderRadius: '50%' }}
onClick={() => setFilterSheetOpen(true)} onClick={openFilterSheet}
title='Filters' title='Filters'
> >
<FilterList /> <FilterList />
@@ -350,7 +413,11 @@ const ChoreToolbar = ({
size='sm' size='sm'
sx={{ height: 32, width: 32, borderRadius: '50%' }} sx={{ height: 32, width: 32, borderRadius: '50%' }}
onClick={onToggleMultiSelect} onClick={onToggleMultiSelect}
title={isMultiSelectMode ? 'Exit multi-select (Ctrl+S)' : 'Multi-select (Ctrl+S)'} title={
isMultiSelectMode
? 'Exit multi-select (Ctrl+S)'
: 'Multi-select (Ctrl+S)'
}
> >
{isMultiSelectMode ? <CheckBox /> : <CheckBoxOutlineBlank />} {isMultiSelectMode ? <CheckBox /> : <CheckBoxOutlineBlank />}
</IconButton> </IconButton>
@@ -362,7 +429,7 @@ const ChoreToolbar = ({
</Box> </Box>
</Box> </Box>
{/* ── Row 2: active filter chips (only when something is active) ─────── */} {/* ── Row 2: active filter chips ──────────────────────────────────────── */}
{hasAnyActive && ( {hasAnyActive && (
<Box <Box
sx={{ sx={{
@@ -376,7 +443,7 @@ const ChoreToolbar = ({
scrollbarWidth: 'none', scrollbarWidth: 'none',
}} }}
> >
{visibleChips.map(({ key, label, onClear }) => ( {inlineChips.map(({ key, label, onClear }) => (
<Chip <Chip
key={key} key={key}
size='sm' size='sm'
@@ -391,25 +458,13 @@ const ChoreToolbar = ({
}} }}
/> />
} }
onClick={() => setFilterSheetOpen(true)} onClick={openFilterSheet}
sx={{ cursor: 'pointer', flexShrink: 0 }} sx={{ cursor: 'pointer', flexShrink: 0 }}
> >
{label} {label}
</Chip> </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 && ( {resultCount != null && totalCount != null && (
<Typography <Typography
level='body-xs' level='body-xs'
@@ -430,7 +485,10 @@ const ChoreToolbar = ({
minHeight: 0, minHeight: 0,
flexShrink: 0, flexShrink: 0,
}} }}
onClick={onClearAllFilters} onClick={() => {
setLocalSelections(defaultSelections())
onClearAllFilters?.()
}}
> >
Clear all Clear all
</Button> </Button>
@@ -441,6 +499,7 @@ const ChoreToolbar = ({
<BottomSheetModal <BottomSheetModal
open={filterSheetOpen} open={filterSheetOpen}
onClose={() => setFilterSheetOpen(false)} onClose={() => setFilterSheetOpen(false)}
maxHeight='92vh'
title={ title={
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}> <Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Tune sx={{ fontSize: 20 }} /> <Tune sx={{ fontSize: 20 }} />
@@ -453,6 +512,36 @@ const ChoreToolbar = ({
</Box> </Box>
} }
footer={ footer={
savingFilter ? (
<Box
sx={{ display: 'flex', gap: 1, width: '100%', alignItems: 'center' }}
>
<Input
size='sm'
placeholder='Filter name…'
value={saveFilterName}
onChange={e => setSaveFilterName(e.target.value)}
onKeyDown={e => e.key === 'Enter' && handleSaveFilter()}
autoFocus
sx={{ flex: 1 }}
/>
<Button
size='sm'
onClick={handleSaveFilter}
disabled={!saveFilterName.trim()}
>
Save
</Button>
<Button
size='sm'
variant='plain'
color='neutral'
onClick={() => setSavingFilter(false)}
>
Cancel
</Button>
</Box>
) : (
<Box <Box
sx={{ sx={{
display: 'flex', display: 'flex',
@@ -465,79 +554,84 @@ const ChoreToolbar = ({
variant='plain' variant='plain'
color='danger' color='danger'
size='sm' size='sm'
disabled={!hasAnyActive} disabled={!hasAnyActive && activeConditions.length === 0}
onClick={onClearAllFilters} onClick={() => {
setLocalSelections(defaultSelections())
onClearAllFilters?.()
setFilterSheetOpen(false)
}}
> >
Clear all Clear all
</Button> </Button>
{activeConditions.length > 0 ? (
<>
<ButtonGroup variant='solid' color='primary'>
<Button <Button
onClick={() => setFilterSheetOpen(false)} onClick={() => setFilterSheetOpen(false)}
sx={{ minWidth: 140 }} sx={{ minWidth: 140 }}
> >
{resultCount != null {resultCount != null
? `Show ${resultCount} result${resultCount !== 1 ? 's' : ''}` ? `Show ${resultCount}`
: 'Done'} : 'Done'}
</Button> </Button>
<IconButton
ref={saveMenuRef}
onClick={e => setSaveMenuAnchorEl(e.currentTarget)}
>
<ArrowDropDown />
</IconButton>
</ButtonGroup>
<Menu
anchorEl={saveMenuAnchorEl}
open={Boolean(saveMenuAnchorEl)}
onClose={() => setSaveMenuAnchorEl(null)}
placement='top-end'
sx={{ zIndex: Z_INDEX.MODAL_CONTENT + 10 }}
>
<MenuItem
onClick={() => {
setSaveMenuAnchorEl(null)
setSavingFilter(true)
}}
>
<Save sx={{ fontSize: 16, mr: 1 }} />
Save as Filter
</MenuItem>
</Menu>
</>
) : (
<Button
variant='solid'
color='primary'
onClick={() => setFilterSheetOpen(false)}
sx={{ minWidth: 140 }}
>
Done
</Button>
)}
</Box> </Box>
)
} }
> >
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0 }}> <Box sx={{ display: 'flex', flexDirection: 'column', gap: 0 }}>
{/* Full advanced filter content */}
{/* ── Quick filter sections (Due Date / Priority / Labels etc.) ────── */} <FilterBuilderContent
{filterDefs.map(def => ( selections={localSelections}
<Box key={def.id}> onSelectionsChange={handleSelectionsChange}
<Divider sx={{ my: 2.5 }} /> members={members}
<SectionHeader labels={labels}
icon={def.icon} projects={projects}
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 ──────────────────────────────────────── */} {/* Saved filters section */}
{(pinnedFilters.length > 0 || savedFilters.length > 0) && ( {savedFilters.length > 0 && (
<> <>
<Divider sx={{ my: 2.5 }} /> <Divider sx={{ my: 2.5 }} />
<Box <Typography level='title-sm' fontWeight={600} sx={{ mb: 1.5 }}>
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
mb: 1.5,
}}
>
<Typography level='title-sm' fontWeight={600}>
Saved Filters Saved Filters
</Typography> </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 }}> <Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
{savedFilters.map(filter => { {savedFilters.map(filter => {
const isActive = activeFilterId === filter.id const isActive = activeFilterId === filter.id
@@ -557,7 +651,7 @@ const ChoreToolbar = ({
} }
onClick={() => { onClick={() => {
onSavedFilterClick?.(filter.id) onSavedFilterClick?.(filter.id)
setFilterSheetOpen(false) if (!isActive) setFilterSheetOpen(false)
}} }}
sx={{ sx={{
cursor: 'pointer', cursor: 'pointer',
@@ -574,46 +668,12 @@ const ChoreToolbar = ({
) )
})} })}
</Box> </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> </Box>
</BottomSheetModal> </BottomSheetModal>
{/* ── Display bottom sheet (View + Group combined) ───────────────────── */} {/* ── Display bottom sheet (View + Group + Assignee + Project) ──────────── */}
<BottomSheetModal <BottomSheetModal
open={displaySheetOpen} open={displaySheetOpen}
onClose={() => setDisplaySheetOpen(false)} onClose={() => setDisplaySheetOpen(false)}
@@ -638,7 +698,11 @@ const ChoreToolbar = ({
key={opt.value} key={opt.value}
variant={viewMode === opt.value ? 'solid' : 'soft'} variant={viewMode === opt.value ? 'solid' : 'soft'}
color={viewMode === opt.value ? 'primary' : 'neutral'} color={viewMode === opt.value ? 'primary' : 'neutral'}
startDecorator={viewMode === opt.value ? <Check sx={{ fontSize: 14 }} /> : opt.icon} startDecorator={
viewMode === opt.value
? <Check sx={{ fontSize: 14 }} />
: opt.icon
}
onClick={() => onToggleViewMode?.(opt.value)} onClick={() => onToggleViewMode?.(opt.value)}
sx={{ sx={{
py: 0.64, py: 0.64,
@@ -703,8 +767,8 @@ const ChoreToolbar = ({
onToggle={v => onAssigneeFilterChange?.(v)} onToggle={v => onAssigneeFilterChange?.(v)}
/> />
{/* Project section */} {/* Project section — only when no advanced filter is active */}
{showProjectSection && ( {showProjectInDisplay && (
<> <>
<Divider sx={{ my: 2.5 }} /> <Divider sx={{ my: 2.5 }} />
<SectionHeader <SectionHeader

View File

@@ -0,0 +1,477 @@
import {
CalendarMonth,
Check,
FolderOpen,
Label,
Person,
PriorityHigh,
Stars,
TaskAlt,
} from '@mui/icons-material'
import { Avatar, Box, Chip, Divider, Input, Typography } from '@mui/joy'
import Priorities from '../../../utils/Priorities'
export const DUE_DATE_OPTIONS = [
{ value: 'isOverdue', label: 'Overdue', color: 'danger' },
{ value: 'isDueToday', label: 'Today', color: 'warning' },
{ value: 'isDueTomorrow', label: 'Tomorrow', color: 'primary' },
{ value: 'isDueThisWeek', label: 'This Week', color: 'primary' },
{ value: 'isDueThisMonth', label: 'This Month', color: 'neutral' },
{ value: 'hasNoDueDate', label: 'No Due Date', color: 'neutral' },
{ value: 'hasDueDate', label: 'Has Due Date', color: 'neutral' },
]
export const POINTS_OPERATORS = [
{ value: 'greaterThan', label: '>' },
{ value: 'greaterThanOrEqual', label: '>=' },
{ value: 'equals', label: '=' },
{ value: 'lessThanOrEqual', label: '<=' },
{ value: 'lessThan', label: '<' },
]
export const CHORE_STATUSES = [
{ value: 0, label: 'Active' },
{ value: 1, label: 'Started' },
{ value: 2, label: 'In Progress' },
{ value: 3, label: 'Pending Approval' },
]
export const defaultSelections = () => ({
assignee: { operator: 'is', values: [] },
createdBy: { operator: 'is', values: [] },
status: { operator: 'is', values: [] },
priority: { operator: 'is', values: [] },
label: { operator: 'is', values: [] },
project: { operator: 'is', values: [] },
dueDate: { operator: null },
points: { operator: 'greaterThan', value: 0, active: false },
})
export const conditionsToSelections = conditions => {
const sel = defaultSelections()
if (!conditions) return sel
conditions.forEach(c => {
if (c.type === 'dueDate') {
sel.dueDate = { operator: c.operator }
} else if (c.type === 'points') {
sel.points = { operator: c.operator, value: c.value ?? 0, active: true }
} else if (c.type in sel) {
sel[c.type] = {
operator: c.operator ?? 'is',
values: Array.isArray(c.value)
? c.value
: c.value != null
? [c.value]
: [],
}
}
})
return sel
}
export const selectionsToConditions = selections => {
const conditions = []
;['assignee', 'createdBy', 'status', 'priority', 'label', 'project'].forEach(
type => {
if (selections[type].values?.length > 0) {
conditions.push({
type,
operator: selections[type].operator,
value: selections[type].values,
})
}
},
)
if (selections.dueDate.operator) {
conditions.push({
type: 'dueDate',
operator: selections.dueDate.operator,
value: null,
})
}
if (selections.points.active) {
conditions.push({
type: 'points',
operator: selections.points.operator,
value: selections.points.value,
})
}
return conditions
}
const SectionHeader = ({ icon, label, children }) => (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}>
<Box
sx={{
color: 'text.secondary',
display: 'flex',
alignItems: 'center',
'& svg': { fontSize: 18 },
}}
>
{icon}
</Box>
<Typography level='title-sm' sx={{ fontWeight: 600 }}>
{label}
</Typography>
{children}
</Box>
)
const IncludeExcludeToggle = ({
value,
onChange,
labels = ['Include', 'Exclude'],
}) => (
<Box sx={{ display: 'flex', gap: 0.5, ml: 'auto' }}>
{[
{ op: 'is', label: labels[0] },
{ op: 'isNot', label: labels[1] },
].map(o => (
<Chip
key={o.op}
size='sm'
variant={value === o.op ? 'solid' : 'soft'}
color={
value === o.op ? (o.op === 'isNot' ? 'danger' : 'primary') : 'neutral'
}
onClick={() => onChange(o.op)}
sx={{ cursor: 'pointer', userSelect: 'none', transition: 'all 0.15s ease' }}
>
{o.label}
</Chip>
))}
</Box>
)
/**
* Reusable filter conditions UI used by both the filter sheet in ChoreToolbar
* and the AdvancedFilterBuilder save modal.
*
* `onSelectionsChange` must accept either a new selections object or a
* functional updater `prev => next` (same contract as React's setState setter).
*/
const FilterBuilderContent = ({
selections,
onSelectionsChange,
members = [],
labels = [],
projects = [],
}) => {
const toggleValue = (type, value) =>
onSelectionsChange(prev => {
const cur = prev[type].values || []
const next = cur.includes(value)
? cur.filter(v => v !== value)
: [...cur, value]
return { ...prev, [type]: { ...prev[type], values: next } }
})
const setOperator = (type, op) =>
onSelectionsChange(prev => ({
...prev,
[type]: { ...prev[type], operator: op },
}))
const toggleDueDate = op =>
onSelectionsChange(prev => ({
...prev,
dueDate: { operator: prev.dueDate.operator === op ? null : op },
}))
const setPointsOperator = op =>
onSelectionsChange(prev => ({
...prev,
points: { ...prev.points, operator: op, active: true },
}))
const setPointsValue = val =>
onSelectionsChange(prev => ({
...prev,
points: { ...prev.points, value: val, active: val > 0 },
}))
const chipRow = (type, options, getChipProps) => {
const selected = selections[type].values || []
return (
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
{options.map(opt => {
const isSelected = selected.includes(opt.value)
const extra = getChipProps ? getChipProps(opt, isSelected) : {}
return (
<Chip
key={opt.value}
variant={isSelected ? 'solid' : 'soft'}
color={isSelected ? (extra.color ?? 'primary') : 'neutral'}
startDecorator={
isSelected
? <Check sx={{ fontSize: 14 }} />
: (extra.startDecorator ?? null)
}
onClick={() => toggleValue(type, opt.value)}
sx={{
cursor: 'pointer',
userSelect: 'none',
transition: 'all 0.15s ease',
}}
>
{opt.label}
</Chip>
)
})}
</Box>
)
}
const personChipRow = type => {
const selected = selections[type].values || []
return (
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
{members.map(m => {
const isSelected = selected.includes(m.userId)
return (
<Chip
key={m.userId}
variant={isSelected ? 'solid' : 'soft'}
color={isSelected ? 'primary' : 'neutral'}
startDecorator={
isSelected ? (
<Check sx={{ fontSize: 14 }} />
) : (
<Avatar
src={m.image}
alt={m.displayName}
sx={{ '--Avatar-size': '20px' }}
/>
)
}
onClick={() => toggleValue(type, m.userId)}
sx={{
cursor: 'pointer',
userSelect: 'none',
transition: 'all 0.15s ease',
}}
>
{m.displayName || m.username}
</Chip>
)
})}
</Box>
)
}
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0 }}>
{/* Assignee */}
{members.length > 0 && (
<>
<SectionHeader icon={<Person />} label='Assignee'>
<IncludeExcludeToggle
value={selections.assignee.operator}
onChange={op => setOperator('assignee', op)}
/>
</SectionHeader>
{personChipRow('assignee')}
<Divider sx={{ my: 2.5 }} />
</>
)}
{/* Created By */}
{members.length > 0 && (
<>
<SectionHeader icon={<Person />} label='Created By'>
<IncludeExcludeToggle
value={selections.createdBy.operator}
onChange={op => setOperator('createdBy', op)}
/>
</SectionHeader>
{personChipRow('createdBy')}
<Divider sx={{ my: 2.5 }} />
</>
)}
{/* Status */}
<SectionHeader icon={<TaskAlt />} label='Status'>
<IncludeExcludeToggle
value={selections.status.operator}
onChange={op => setOperator('status', op)}
/>
</SectionHeader>
{chipRow('status', CHORE_STATUSES)}
<Divider sx={{ my: 2.5 }} />
{/* Priority */}
<SectionHeader icon={<PriorityHigh />} label='Priority'>
<IncludeExcludeToggle
value={selections.priority.operator}
onChange={op => setOperator('priority', op)}
/>
</SectionHeader>
{chipRow(
'priority',
Priorities.map(p => ({ value: p.value, label: p.name })),
(opt, isSelected) => ({
color: isSelected
? (Priorities.find(p => p.value === opt.value)?.color || 'primary')
: 'neutral',
startDecorator: !isSelected
? Priorities.find(p => p.value === opt.value)?.icon
: null,
}),
)}
<Divider sx={{ my: 2.5 }} />
{/* Due Date */}
<SectionHeader icon={<CalendarMonth />} label='Due Date' />
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
{DUE_DATE_OPTIONS.map(opt => {
const isSelected = selections.dueDate.operator === opt.value
return (
<Chip
key={opt.value}
variant={isSelected ? 'solid' : 'soft'}
color={isSelected ? (opt.color ?? 'primary') : 'neutral'}
startDecorator={isSelected ? <Check sx={{ fontSize: 14 }} /> : null}
onClick={() => toggleDueDate(opt.value)}
sx={{
cursor: 'pointer',
userSelect: 'none',
transition: 'all 0.15s ease',
}}
>
{opt.label}
</Chip>
)
})}
</Box>
<Divider sx={{ my: 2.5 }} />
{/* Labels */}
{labels.length > 0 && (
<>
<SectionHeader icon={<Label />} label='Labels'>
<IncludeExcludeToggle
value={selections.label.operator}
onChange={op => setOperator('label', op)}
labels={['Has', "Doesn't Have"]}
/>
</SectionHeader>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
{labels.map(lbl => {
const isSelected = selections.label.values.includes(lbl.id)
return (
<Chip
key={lbl.id}
variant={isSelected ? 'solid' : 'soft'}
color='neutral'
startDecorator={
<Box
sx={{
width: 10,
height: 10,
borderRadius: '50%',
bgcolor: lbl.color || '#90a4ae',
flexShrink: 0,
}}
/>
}
endDecorator={isSelected ? <Check sx={{ fontSize: 12 }} /> : null}
onClick={() => toggleValue('label', lbl.id)}
sx={{
cursor: 'pointer',
userSelect: 'none',
transition: 'all 0.15s ease',
...(isSelected && {
outline: '2px solid',
outlineColor: 'primary.400',
}),
}}
>
{lbl.name}
</Chip>
)
})}
</Box>
<Divider sx={{ my: 2.5 }} />
</>
)}
{/* Projects */}
{projects.length > 0 && (
<>
<SectionHeader icon={<FolderOpen />} label='Projects'>
<IncludeExcludeToggle
value={selections.project.operator}
onChange={op => setOperator('project', op)}
/>
</SectionHeader>
{chipRow('project', [
{ value: 'default', label: 'Default Project' },
...projects
.filter(p => p.id !== 'default')
.map(p => ({ value: p.id, label: p.name })),
])}
<Divider sx={{ my: 2.5 }} />
</>
)}
{/* Points */}
<SectionHeader icon={<Stars />} label='Points' />
<Box
sx={{ display: 'flex', gap: 1, alignItems: 'center', flexWrap: 'wrap' }}
>
{POINTS_OPERATORS.map(op => (
<Chip
key={op.value}
size='sm'
variant={
selections.points.operator === op.value && selections.points.active
? 'solid'
: 'soft'
}
color={
selections.points.operator === op.value && selections.points.active
? 'primary'
: 'neutral'
}
onClick={() => setPointsOperator(op.value)}
sx={{
cursor: 'pointer',
userSelect: 'none',
fontFamily: 'monospace',
fontWeight: 600,
}}
>
{op.label}
</Chip>
))}
<Input
type='number'
size='sm'
value={selections.points.value}
onChange={e => setPointsValue(parseInt(e.target.value) || 0)}
sx={{ width: 80 }}
slotProps={{ input: { min: 0 } }}
/>
{selections.points.active && (
<Chip
size='sm'
variant='soft'
color='danger'
onClick={() =>
onSelectionsChange(prev => ({
...prev,
points: { ...prev.points, active: false, value: 0 },
}))
}
sx={{ cursor: 'pointer' }}
>
Clear
</Chip>
)}
</Box>
</Box>
)
}
export default FilterBuilderContent

View File

@@ -1,16 +1,5 @@
import { Save } from '@mui/icons-material'
import { import {
CalendarMonth,
Check,
FolderOpen,
Label,
Person,
PriorityHigh,
Save,
Stars,
TaskAlt,
} from '@mui/icons-material'
import {
Avatar,
Box, Box,
Button, Button,
Chip, Chip,
@@ -20,125 +9,15 @@ import {
} from '@mui/joy' } from '@mui/joy'
import { useEffect, useMemo, useState } from 'react' import { useEffect, useMemo, useState } from 'react'
import BottomSheetModal from '../../../components/common/BottomSheetModal' import BottomSheetModal from '../../../components/common/BottomSheetModal'
import FilterBuilderContent, {
conditionsToSelections,
defaultSelections,
selectionsToConditions,
} from '../../Chores/components/FilterBuilderContent'
import { FILTER_COLORS } from '../../../utils/Colors' import { FILTER_COLORS } from '../../../utils/Colors'
import { applyFilter } from '../../../utils/FilterEngine' import { applyFilter } from '../../../utils/FilterEngine'
import Priorities from '../../../utils/Priorities'
import { useFilters } from '../../Filters/FilterQueries' import { useFilters } from '../../Filters/FilterQueries'
const DUE_DATE_OPTIONS = [
{ value: 'isOverdue', label: 'Overdue', color: 'danger' },
{ value: 'isDueToday', label: 'Today', color: 'warning' },
{ value: 'isDueTomorrow', label: 'Tomorrow', color: 'primary' },
{ value: 'isDueThisWeek', label: 'This Week', color: 'primary' },
{ value: 'isDueThisMonth', label: 'This Month', color: 'neutral' },
{ value: 'hasNoDueDate', label: 'No Due Date', color: 'neutral' },
{ value: 'hasDueDate', label: 'Has Due Date', color: 'neutral' },
]
const POINTS_OPERATORS = [
{ value: 'greaterThan', label: '>' },
{ value: 'greaterThanOrEqual', label: '>=' },
{ value: 'equals', label: '=' },
{ value: 'lessThanOrEqual', label: '<=' },
{ value: 'lessThan', label: '<' },
]
const CHORE_STATUSES = [
{ value: 0, label: 'Active' },
{ value: 1, label: 'Started' },
{ value: 2, label: 'In Progress' },
{ value: 3, label: 'Pending Approval' },
]
const defaultSelections = () => ({
assignee: { operator: 'is', values: [] },
createdBy: { operator: 'is', values: [] },
status: { operator: 'is', values: [] },
priority: { operator: 'is', values: [] },
label: { operator: 'is', values: [] },
project: { operator: 'is', values: [] },
dueDate: { operator: null },
points: { operator: 'greaterThan', value: 0, active: false },
})
const conditionsToSelections = conditions => {
const sel = defaultSelections()
if (!conditions) return sel
conditions.forEach(c => {
if (c.type === 'dueDate') {
sel.dueDate = { operator: c.operator }
} else if (c.type === 'points') {
sel.points = { operator: c.operator, value: c.value ?? 0, active: true }
} else if (c.type in sel) {
sel[c.type] = {
operator: c.operator ?? 'is',
values: Array.isArray(c.value) ? c.value : c.value != null ? [c.value] : [],
}
}
})
return sel
}
const selectionsToConditions = selections => {
const conditions = []
;['assignee', 'createdBy', 'status', 'priority', 'label', 'project'].forEach(type => {
if (selections[type].values?.length > 0) {
conditions.push({
type,
operator: selections[type].operator,
value: selections[type].values,
})
}
})
if (selections.dueDate.operator) {
conditions.push({ type: 'dueDate', operator: selections.dueDate.operator, value: null })
}
if (selections.points.active) {
conditions.push({
type: 'points',
operator: selections.points.operator,
value: selections.points.value,
})
}
return conditions
}
const hasAnySelection = selections => selectionsToConditions(selections).length > 0
// ── Sub-components ───────────────────────────────────────────────────────────
const SectionHeader = ({ icon, label, children }) => (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}>
<Box sx={{ color: 'text.secondary', display: 'flex', alignItems: 'center', '& svg': { fontSize: 18 } }}>
{icon}
</Box>
<Typography level='title-sm' sx={{ fontWeight: 600 }}>{label}</Typography>
{children}
</Box>
)
const IncludeExcludeToggle = ({ value, onChange, labels = ['Include', 'Exclude'] }) => (
<Box sx={{ display: 'flex', gap: 0.5, ml: 'auto' }}>
{[
{ op: 'is', label: labels[0] },
{ op: 'isNot', label: labels[1] },
].map(o => (
<Chip
key={o.op}
size='sm'
variant={value === o.op ? 'solid' : 'soft'}
color={value === o.op ? (o.op === 'isNot' ? 'danger' : 'primary') : 'neutral'}
onClick={() => onChange(o.op)}
sx={{ cursor: 'pointer', userSelect: 'none', transition: 'all 0.15s ease' }}
>
{o.label}
</Chip>
))}
</Box>
)
// ── Main Component ───────────────────────────────────────────────────────────
const AdvancedFilterBuilder = ({ const AdvancedFilterBuilder = ({
isOpen, isOpen,
onClose, onClose,
@@ -194,32 +73,7 @@ const AdvancedFilterBuilder = ({
c => c.nextDueDate && new Date(c.nextDueDate) < new Date(), c => c.nextDueDate && new Date(c.nextDueDate) < new Date(),
).length ).length
// ── Mutators ─────────────────────────────────────────────────────────────── const activeConditionCount = conditions.length
const toggleValue = (type, value) => {
setSelections(prev => {
const cur = prev[type].values || []
const next = cur.includes(value) ? cur.filter(v => v !== value) : [...cur, value]
return { ...prev, [type]: { ...prev[type], values: next } }
})
}
const setOperator = (type, op) =>
setSelections(prev => ({ ...prev, [type]: { ...prev[type], operator: op } }))
const toggleDueDate = op =>
setSelections(prev => ({
...prev,
dueDate: { operator: prev.dueDate.operator === op ? null : op },
}))
const setPointsOperator = op =>
setSelections(prev => ({ ...prev, points: { ...prev.points, operator: op, active: true } }))
const setPointsValue = val =>
setSelections(prev => ({ ...prev, points: { ...prev.points, value: val, active: val > 0 } }))
// ── Save ───────────────────────────────────────────────────────────────────
const handleSave = () => { const handleSave = () => {
if (!filterName.trim()) { if (!filterName.trim()) {
@@ -245,65 +99,6 @@ const AdvancedFilterBuilder = ({
onClose() onClose()
} }
// ── Render helpers ─────────────────────────────────────────────────────────
const chipRow = (type, options, getChipProps) => {
const selected = selections[type].values || []
return (
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
{options.map(opt => {
const isSelected = selected.includes(opt.value)
const extra = getChipProps ? getChipProps(opt, isSelected) : {}
return (
<Chip
key={opt.value}
variant={isSelected ? 'solid' : 'soft'}
color={isSelected ? (extra.color ?? 'primary') : 'neutral'}
startDecorator={
isSelected ? <Check sx={{ fontSize: 14 }} /> : (extra.startDecorator ?? null)
}
onClick={() => toggleValue(type, opt.value)}
sx={{ cursor: 'pointer', userSelect: 'none', transition: 'all 0.15s ease' }}
>
{opt.label}
</Chip>
)
})}
</Box>
)
}
const personChipRow = type => {
const selected = selections[type].values || []
return (
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
{members.map(m => {
const isSelected = selected.includes(m.userId)
return (
<Chip
key={m.userId}
variant={isSelected ? 'solid' : 'soft'}
color={isSelected ? 'primary' : 'neutral'}
startDecorator={
isSelected ? (
<Check sx={{ fontSize: 14 }} />
) : (
<Avatar src={m.image} alt={m.displayName} sx={{ '--Avatar-size': '20px' }} />
)
}
onClick={() => toggleValue(type, m.userId)}
sx={{ cursor: 'pointer', userSelect: 'none', transition: 'all 0.15s ease' }}
>
{m.displayName || m.username}
</Chip>
)
})}
</Box>
)
}
const activeConditionCount = conditions.length
return ( return (
<BottomSheetModal <BottomSheetModal
open={isOpen} open={isOpen}
@@ -320,7 +115,14 @@ const AdvancedFilterBuilder = ({
</Box> </Box>
} }
footer={ footer={
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 1 }}> <Box
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 1,
}}
>
{/* Preview */} {/* Preview */}
<Box sx={{ display: 'flex', gap: 1, flexShrink: 0 }}> <Box sx={{ display: 'flex', gap: 1, flexShrink: 0 }}>
{conditions.length > 0 ? ( {conditions.length > 0 ? (
@@ -360,27 +162,37 @@ const AdvancedFilterBuilder = ({
} }
> >
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0 }}> <Box sx={{ display: 'flex', flexDirection: 'column', gap: 0 }}>
{/* Name */}
{/* ── Name ─────────────────────────────────────────────────────────── */}
<Box sx={{ mb: 2 }}> <Box sx={{ mb: 2 }}>
<Typography level='body-xs' sx={{ mb: 0.75, color: 'text.secondary', fontWeight: 600 }}> <Typography
level='body-xs'
sx={{ mb: 0.75, color: 'text.secondary', fontWeight: 600 }}
>
Filter Name Filter Name
</Typography> </Typography>
<Input <Input
placeholder='e.g. Overdue tasks for Alice' placeholder='e.g. Overdue tasks for Alice'
value={filterName} value={filterName}
onChange={e => { setFilterName(e.target.value); setError('') }} onChange={e => {
setFilterName(e.target.value)
setError('')
}}
error={!!error} error={!!error}
autoFocus autoFocus
/> />
{error && ( {error && (
<Typography level='body-xs' color='danger' sx={{ mt: 0.5 }}>{error}</Typography> <Typography level='body-xs' color='danger' sx={{ mt: 0.5 }}>
{error}
</Typography>
)} )}
</Box> </Box>
{/* ── Color ────────────────────────────────────────────────────────── */} {/* Color */}
<Box sx={{ mb: 2 }}> <Box sx={{ mb: 2 }}>
<Typography level='body-xs' sx={{ mb: 0.75, color: 'text.secondary', fontWeight: 600 }}> <Typography
level='body-xs'
sx={{ mb: 0.75, color: 'text.secondary', fontWeight: 600 }}
>
Color Color
</Typography> </Typography>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}> <Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
@@ -395,7 +207,8 @@ const AdvancedFilterBuilder = ({
borderRadius: '50%', borderRadius: '50%',
background: c.value, background: c.value,
cursor: 'pointer', cursor: 'pointer',
outline: filterColor === c.value outline:
filterColor === c.value
? '3px solid var(--joy-palette-primary-500)' ? '3px solid var(--joy-palette-primary-500)'
: '2px solid transparent', : '2px solid transparent',
outlineOffset: '2px', outlineOffset: '2px',
@@ -410,185 +223,13 @@ const AdvancedFilterBuilder = ({
<Divider sx={{ mb: 2.5 }} /> <Divider sx={{ mb: 2.5 }} />
{/* ── Assignee ─────────────────────────────────────────────────────── */} <FilterBuilderContent
{members.length > 0 && ( selections={selections}
<> onSelectionsChange={setSelections}
<SectionHeader icon={<Person />} label='Assignee'> members={members}
<IncludeExcludeToggle labels={labels}
value={selections.assignee.operator} projects={projects}
onChange={op => setOperator('assignee', op)}
/> />
</SectionHeader>
{personChipRow('assignee')}
<Divider sx={{ my: 2.5 }} />
</>
)}
{/* ── Created By ───────────────────────────────────────────────────── */}
{members.length > 0 && (
<>
<SectionHeader icon={<Person />} label='Created By'>
<IncludeExcludeToggle
value={selections.createdBy.operator}
onChange={op => setOperator('createdBy', op)}
/>
</SectionHeader>
{personChipRow('createdBy')}
<Divider sx={{ my: 2.5 }} />
</>
)}
{/* ── Status ───────────────────────────────────────────────────────── */}
<SectionHeader icon={<TaskAlt />} label='Status'>
<IncludeExcludeToggle
value={selections.status.operator}
onChange={op => setOperator('status', op)}
/>
</SectionHeader>
{chipRow('status', CHORE_STATUSES)}
<Divider sx={{ my: 2.5 }} />
{/* ── Priority ─────────────────────────────────────────────────────── */}
<SectionHeader icon={<PriorityHigh />} label='Priority'>
<IncludeExcludeToggle
value={selections.priority.operator}
onChange={op => setOperator('priority', op)}
/>
</SectionHeader>
{chipRow('priority', Priorities.map(p => ({ value: p.value, label: p.name })), (opt, isSelected) => ({
color: isSelected
? (Priorities.find(p => p.value === opt.value)?.color || 'primary')
: 'neutral',
startDecorator: !isSelected
? Priorities.find(p => p.value === opt.value)?.icon
: null,
}))}
<Divider sx={{ my: 2.5 }} />
{/* ── Due Date ─────────────────────────────────────────────────────── */}
<SectionHeader icon={<CalendarMonth />} label='Due Date' />
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
{DUE_DATE_OPTIONS.map(opt => {
const isSelected = selections.dueDate.operator === opt.value
return (
<Chip
key={opt.value}
variant={isSelected ? 'solid' : 'soft'}
color={isSelected ? (opt.color ?? 'primary') : 'neutral'}
startDecorator={isSelected ? <Check sx={{ fontSize: 14 }} /> : null}
onClick={() => toggleDueDate(opt.value)}
sx={{ cursor: 'pointer', userSelect: 'none', transition: 'all 0.15s ease' }}
>
{opt.label}
</Chip>
)
})}
</Box>
<Divider sx={{ my: 2.5 }} />
{/* ── Labels ───────────────────────────────────────────────────────── */}
{labels.length > 0 && (
<>
<SectionHeader icon={<Label />} label='Labels'>
<IncludeExcludeToggle
value={selections.label.operator}
onChange={op => setOperator('label', op)}
labels={['Has', "Doesn't Have"]}
/>
</SectionHeader>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
{labels.map(lbl => {
const isSelected = selections.label.values.includes(lbl.id)
return (
<Chip
key={lbl.id}
variant={isSelected ? 'solid' : 'soft'}
color='neutral'
startDecorator={
<Box
sx={{
width: 10,
height: 10,
borderRadius: '50%',
bgcolor: lbl.color || '#90a4ae',
flexShrink: 0,
}}
/>
}
endDecorator={isSelected ? <Check sx={{ fontSize: 12 }} /> : null}
onClick={() => toggleValue('label', lbl.id)}
sx={{
cursor: 'pointer',
userSelect: 'none',
transition: 'all 0.15s ease',
...(isSelected && { outline: '2px solid', outlineColor: 'primary.400' }),
}}
>
{lbl.name}
</Chip>
)
})}
</Box>
<Divider sx={{ my: 2.5 }} />
</>
)}
{/* ── Projects ─────────────────────────────────────────────────────── */}
{projects.length > 0 && (
<>
<SectionHeader icon={<FolderOpen />} label='Projects'>
<IncludeExcludeToggle
value={selections.project.operator}
onChange={op => setOperator('project', op)}
/>
</SectionHeader>
{chipRow(
'project',
[
{ value: 'default', label: 'Default Project' },
...projects.filter(p => p.id !== 'default').map(p => ({ value: p.id, label: p.name })),
],
)}
<Divider sx={{ my: 2.5 }} />
</>
)}
{/* ── Points ───────────────────────────────────────────────────────── */}
<SectionHeader icon={<Stars />} label='Points' />
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center', flexWrap: 'wrap' }}>
{POINTS_OPERATORS.map(op => (
<Chip
key={op.value}
size='sm'
variant={selections.points.operator === op.value && selections.points.active ? 'solid' : 'soft'}
color={selections.points.operator === op.value && selections.points.active ? 'primary' : 'neutral'}
onClick={() => setPointsOperator(op.value)}
sx={{ cursor: 'pointer', userSelect: 'none', fontFamily: 'monospace', fontWeight: 600 }}
>
{op.label}
</Chip>
))}
<Input
type='number'
size='sm'
value={selections.points.value}
onChange={e => setPointsValue(parseInt(e.target.value) || 0)}
sx={{ width: 80 }}
slotProps={{ input: { min: 0 } }}
/>
{selections.points.active && (
<Chip
size='sm'
variant='soft'
color='danger'
onClick={() => setSelections(prev => ({ ...prev, points: { ...prev.points, active: false, value: 0 } }))}
sx={{ cursor: 'pointer' }}
>
Clear
</Chip>
)}
</Box>
</Box> </Box>
</BottomSheetModal> </BottomSheetModal>
) )