remove the old filter logic and use advance filter in myChore
This commit is contained in:
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
477
src/views/Chores/components/FilterBuilderContent.jsx
Normal file
477
src/views/Chores/components/FilterBuilderContent.jsx
Normal 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
|
||||||
@@ -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>
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user