remove the old filter logic and use advance filter in myChore
This commit is contained in:
@@ -268,7 +268,6 @@ const MyChores = () => {
|
||||
|
||||
const {
|
||||
filteredData: quickFilteredChores,
|
||||
activeFilters: quickFilters,
|
||||
setFilter: setQuickFilter,
|
||||
clearAll: clearQuickFilters,
|
||||
hasActiveFilters: hasQuickFilters,
|
||||
@@ -948,30 +947,37 @@ const MyChores = () => {
|
||||
tempFilterMeta={tempFilterMeta}
|
||||
/>
|
||||
<ChoreToolbar
|
||||
filterDefs={quickFilterDefs}
|
||||
activeFilters={quickFilters}
|
||||
onSetFilter={(id, value) => {
|
||||
clearActiveFilter()
|
||||
setQuickFilter(id, value)
|
||||
setSelectedCalendarDate(null)
|
||||
}}
|
||||
members={membersData?.res || []}
|
||||
labels={userLabels || []}
|
||||
projects={projectsWithDefault}
|
||||
tempFilter={tempFilter}
|
||||
applyTempFilter={applyTempFilter}
|
||||
clearTempFilter={clearTempFilter}
|
||||
saveFilter={saveFilter}
|
||||
onFilterSaved={name =>
|
||||
showSuccess({
|
||||
title: 'Filter Saved',
|
||||
message: `"${name}" has been saved`,
|
||||
})
|
||||
}
|
||||
onClearAllFilters={() => {
|
||||
clearQuickFilters()
|
||||
clearActiveFilter()
|
||||
setSelectedChoreFilterWithCache('anyone')
|
||||
setSelectedProjectWithCache(projectsWithDefault.find(p => p.id === 'default') || null)
|
||||
setSelectedProjectWithCache(
|
||||
projectsWithDefault.find(p => p.id === 'default') || null,
|
||||
)
|
||||
updateFilterUrl(null, null)
|
||||
}}
|
||||
resultCount={
|
||||
hasQuickFilters || !!activeFilterId ? getFilteredChores.length : undefined
|
||||
hasQuickFilters || hasFilterApplied
|
||||
? getFilteredChores.length
|
||||
: undefined
|
||||
}
|
||||
totalCount={
|
||||
hasQuickFilters || !!activeFilterId ? projectFilteredChores.length : undefined
|
||||
}
|
||||
projects={
|
||||
!hasProjectConditions && !hasFilterApplied
|
||||
? projectsWithDefault
|
||||
: []
|
||||
hasQuickFilters || hasFilterApplied
|
||||
? projectFilteredChores.length
|
||||
: undefined
|
||||
}
|
||||
selectedProject={selectedProject}
|
||||
onProjectSelect={project => {
|
||||
@@ -1013,10 +1019,6 @@ const MyChores = () => {
|
||||
}}
|
||||
onSavedFilterDelete={deleteFilter}
|
||||
onSavedFilterPin={pinFilter}
|
||||
onCreateAdvancedFilter={() => {
|
||||
setShowAdvancedFilterBuilder(true)
|
||||
setEditingFilter(null)
|
||||
}}
|
||||
selectedGroupBy={selectedChoreSection}
|
||||
onGroupBySelect={value => {
|
||||
setSelectedChoreSectionWithCache(value)
|
||||
|
||||
@@ -9,7 +9,8 @@
|
||||
* NEW: [Search] [Filter(n)] [Group ▾] [View] [Multiselect]
|
||||
* + active filter chips appear inline next to Filter button
|
||||
* + Filter button opens ONE unified bottom sheet containing:
|
||||
* Project · Assignee · Due Date · Priority · Labels · Saved Filters
|
||||
* 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
|
||||
* and the two rows below it (FilterBar + FilterSection) with:
|
||||
@@ -17,7 +18,7 @@
|
||||
*/
|
||||
|
||||
import {
|
||||
Add,
|
||||
ArrowDropDown,
|
||||
CalendarMonth,
|
||||
Check,
|
||||
CheckBox,
|
||||
@@ -25,10 +26,8 @@ import {
|
||||
Close,
|
||||
FilterList,
|
||||
FolderOpen,
|
||||
PriorityHigh,
|
||||
Settings,
|
||||
Save,
|
||||
Sort,
|
||||
Style,
|
||||
Tune,
|
||||
ViewAgenda,
|
||||
ViewComfy,
|
||||
@@ -38,31 +37,28 @@ import {
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
ButtonGroup,
|
||||
Chip,
|
||||
Divider,
|
||||
IconButton,
|
||||
Input,
|
||||
Menu,
|
||||
MenuItem,
|
||||
Typography,
|
||||
} from '@mui/joy'
|
||||
import { useState } from 'react'
|
||||
import { useRef, useState } from 'react'
|
||||
import BottomSheetModal from '../../../components/common/BottomSheetModal'
|
||||
import { Z_INDEX } from '../../../constants/zIndex'
|
||||
import KeyboardShortcutHint from '../../../components/common/KeyboardShortcutHint'
|
||||
import { FILTER_COLORS } from '../../../utils/Colors'
|
||||
import FilterBuilderContent, {
|
||||
conditionsToSelections,
|
||||
defaultSelections,
|
||||
selectionsToConditions,
|
||||
} from './FilterBuilderContent'
|
||||
import SearchBar from './SearchBar'
|
||||
|
||||
// ─── helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
const chipLabel = (def, value) => {
|
||||
if (!value || (Array.isArray(value) && value.length === 0)) return null
|
||||
if (def.type === 'single-select')
|
||||
return def.options?.find(o => o.value === value)?.label ?? null
|
||||
if (def.type === 'multi-select' && Array.isArray(value)) {
|
||||
if (value.length === 1)
|
||||
return def.options?.find(o => o.value === value[0])?.label ?? def.label
|
||||
return `${def.label} (${value.length})`
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// ─── sub-components ──────────────────────────────────────────────────────────
|
||||
// ─── sub-components for the Display sheet ────────────────────────────────────
|
||||
|
||||
const SectionHeader = ({ icon, label, badge }) => (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}>
|
||||
@@ -132,53 +128,67 @@ const OptionChips = ({ options, selected, multi, onToggle }) => (
|
||||
|
||||
/**
|
||||
* Props:
|
||||
* filterDefs – same shape as FilterBar: [{ id, label, type, icon, options, filterFn }]
|
||||
* PLUS two new built-in sections handled here: 'project' and 'assignee'
|
||||
* activeFilters – { [id]: value }
|
||||
* onSetFilter – (id, value | null) => void
|
||||
* onClearAllFilters – () => void
|
||||
* -- Advanced filter (filter sheet) --
|
||||
* members – circle members for Assignee / Created By sections
|
||||
* labels – user labels for Labels section
|
||||
* projects – projects list (projectsWithDefault) for Projects section + Display sheet
|
||||
* 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
|
||||
*
|
||||
* projects – [{ id, name }] — drives the Project section
|
||||
* selectedProject – current project object
|
||||
* onProjectSelect – (project) => void
|
||||
*
|
||||
* selectedAssigneeFilter – 'anyone' | 'assigned_to_me' | 'available_for_me' | 'assigned_to_others'
|
||||
* onAssigneeFilterChange – (key) => void
|
||||
* -- Clear all --
|
||||
* onClearAllFilters – () => void
|
||||
*
|
||||
* -- Saved filters --
|
||||
* savedFilters – [{ id, name, color, count, isPinned }]
|
||||
* activeFilterId – number | null
|
||||
* onSavedFilterClick – (id) => void
|
||||
* onSavedFilterEdit – (filter) => void
|
||||
* onSavedFilterDelete– (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'
|
||||
* onGroupBySelect – (value) => void
|
||||
*
|
||||
* viewMode – 'default' | 'compact' | 'calendar'
|
||||
* onToggleViewMode – () => void
|
||||
* onToggleViewMode – (value?) => void
|
||||
*
|
||||
* -- Multi-select --
|
||||
* isMultiSelectMode – bool
|
||||
* onToggleMultiSelect – () => void
|
||||
*
|
||||
* -- Search --
|
||||
* searchTerm / onSearchChange / onSearchClose / searchInputRef
|
||||
* showKeyboardShortcuts
|
||||
*/
|
||||
const ChoreToolbar = ({
|
||||
// quick filters
|
||||
filterDefs = [],
|
||||
activeFilters = {},
|
||||
onSetFilter,
|
||||
onClearAllFilters,
|
||||
// advanced filter
|
||||
members = [],
|
||||
labels = [],
|
||||
projects = [],
|
||||
tempFilter,
|
||||
applyTempFilter,
|
||||
clearTempFilter,
|
||||
saveFilter,
|
||||
onFilterSaved,
|
||||
// result counts
|
||||
resultCount,
|
||||
totalCount,
|
||||
// project
|
||||
projects = [],
|
||||
// clear all
|
||||
onClearAllFilters,
|
||||
// project (for Display sheet)
|
||||
selectedProject,
|
||||
onProjectSelect,
|
||||
// assignee
|
||||
// assignee (for Display sheet)
|
||||
selectedAssigneeFilter = 'anyone',
|
||||
onAssigneeFilterChange,
|
||||
// saved / custom
|
||||
@@ -188,7 +198,6 @@ const ChoreToolbar = ({
|
||||
onSavedFilterEdit,
|
||||
onSavedFilterDelete,
|
||||
onSavedFilterPin,
|
||||
onCreateAdvancedFilter,
|
||||
// grouping
|
||||
selectedGroupBy = 'default',
|
||||
onGroupBySelect,
|
||||
@@ -206,46 +215,111 @@ const ChoreToolbar = ({
|
||||
}) => {
|
||||
const [filterSheetOpen, setFilterSheetOpen] = 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 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 tempConditionCount = tempFilter?.conditions?.length || 0
|
||||
const savedFilterActive = activeFilterId != null ? 1 : 0
|
||||
|
||||
const totalActiveCount = quickFilterCount + savedFilterActive
|
||||
const totalActiveCount = tempConditionCount + savedFilterActive
|
||||
const hasAnyActive = totalActiveCount > 0
|
||||
|
||||
// ── inline chip strip (max 2 visible + overflow) ─────────────────────────────
|
||||
// ── inline chip strip ────────────────────────────────────────────────────────
|
||||
|
||||
const inlineChips = []
|
||||
|
||||
if (savedFilterActive) {
|
||||
const sf = savedFilters.find(f => f.id === activeFilterId)
|
||||
if (sf)
|
||||
if (sf) {
|
||||
inlineChips.push({
|
||||
key: '__saved',
|
||||
label: sf.name,
|
||||
onClear: () => onSavedFilterClick?.(activeFilterId),
|
||||
})
|
||||
}
|
||||
} else if (tempConditionCount > 0) {
|
||||
inlineChips.push({
|
||||
key: '__temp',
|
||||
label: `${tempConditionCount} condition${tempConditionCount !== 1 ? 's' : ''}`,
|
||||
onClear: () => {
|
||||
setLocalSelections(defaultSelections())
|
||||
clearTempFilter?.()
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
filterDefs.forEach(def => {
|
||||
const label = chipLabel(def, activeFilters[def.id])
|
||||
if (label) inlineChips.push({ key: def.id, label, onClear: () => onSetFilter(def.id, null) })
|
||||
})
|
||||
// ── open filter sheet ────────────────────────────────────────────────────────
|
||||
|
||||
const MAX_CHIPS = 2
|
||||
const visibleChips = inlineChips.slice(0, MAX_CHIPS)
|
||||
const overflow = inlineChips.length - MAX_CHIPS
|
||||
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)
|
||||
}
|
||||
|
||||
// ── groupby options ──────────────────────────────────────────────────────────
|
||||
// ── 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)
|
||||
})
|
||||
|
||||
setSavingFilter(false)
|
||||
setSaveFilterName('')
|
||||
setFilterSheetOpen(false)
|
||||
}
|
||||
|
||||
// ── 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 = [
|
||||
{ value: 'default', label: 'Smart' },
|
||||
@@ -254,8 +328,6 @@ const ChoreToolbar = ({
|
||||
{ value: 'labels', label: 'Labels' },
|
||||
]
|
||||
|
||||
// ── assignee options ─────────────────────────────────────────────────────────
|
||||
|
||||
const assigneeOptions = [
|
||||
{ value: 'anyone', label: 'Everyone' },
|
||||
{ value: 'assigned_to_me', label: 'Mine' },
|
||||
@@ -263,28 +335,19 @@ const ChoreToolbar = ({
|
||||
{ value: 'assigned_to_others', label: 'Others' },
|
||||
]
|
||||
|
||||
// ── project options (show only if more than just Default) ───────────────────
|
||||
|
||||
const showProjectSection = projects.filter(p => p.id !== 'default').length > 0
|
||||
|
||||
// ── pinned saved filters ─────────────────────────────────────────────────────
|
||||
|
||||
const pinnedFilters = savedFilters.filter(f => f.isPinned)
|
||||
|
||||
// ── display active state (highlight button when non-default) ─────────────────
|
||||
|
||||
const displayActive =
|
||||
selectedGroupBy !== 'default' ||
|
||||
viewMode !== 'default' ||
|
||||
projectActive > 0 ||
|
||||
assigneeActive > 0
|
||||
|
||||
const viewOptions = [
|
||||
{ value: 'default', label: 'Cards', icon: <ViewAgenda sx={{ fontSize: 16 }} /> },
|
||||
{ value: 'compact', label: 'Compact', icon: <ViewComfy sx={{ fontSize: 16 }} /> },
|
||||
{ value: 'calendar', label: 'Calendar', icon: <CalendarMonth sx={{ fontSize: 16 }} /> },
|
||||
]
|
||||
|
||||
// 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 (
|
||||
<>
|
||||
{/* ── Row 1: main toolbar ─────────────────────────────────────────────── */}
|
||||
@@ -317,7 +380,7 @@ const ChoreToolbar = ({
|
||||
color={hasAnyActive ? 'primary' : 'neutral'}
|
||||
size='sm'
|
||||
sx={{ height: 32, width: 32, borderRadius: '50%' }}
|
||||
onClick={() => setFilterSheetOpen(true)}
|
||||
onClick={openFilterSheet}
|
||||
title='Filters'
|
||||
>
|
||||
<FilterList />
|
||||
@@ -350,7 +413,11 @@ const ChoreToolbar = ({
|
||||
size='sm'
|
||||
sx={{ height: 32, width: 32, borderRadius: '50%' }}
|
||||
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 />}
|
||||
</IconButton>
|
||||
@@ -362,7 +429,7 @@ const ChoreToolbar = ({
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* ── Row 2: active filter chips (only when something is active) ─────── */}
|
||||
{/* ── Row 2: active filter chips ──────────────────────────────────────── */}
|
||||
{hasAnyActive && (
|
||||
<Box
|
||||
sx={{
|
||||
@@ -376,7 +443,7 @@ const ChoreToolbar = ({
|
||||
scrollbarWidth: 'none',
|
||||
}}
|
||||
>
|
||||
{visibleChips.map(({ key, label, onClear }) => (
|
||||
{inlineChips.map(({ key, label, onClear }) => (
|
||||
<Chip
|
||||
key={key}
|
||||
size='sm'
|
||||
@@ -391,25 +458,13 @@ const ChoreToolbar = ({
|
||||
}}
|
||||
/>
|
||||
}
|
||||
onClick={() => setFilterSheetOpen(true)}
|
||||
onClick={openFilterSheet}
|
||||
sx={{ cursor: 'pointer', flexShrink: 0 }}
|
||||
>
|
||||
{label}
|
||||
</Chip>
|
||||
))}
|
||||
|
||||
{overflow > 0 && (
|
||||
<Chip
|
||||
size='sm'
|
||||
variant='soft'
|
||||
color='neutral'
|
||||
onClick={() => setFilterSheetOpen(true)}
|
||||
sx={{ cursor: 'pointer', flexShrink: 0 }}
|
||||
>
|
||||
+{overflow} more
|
||||
</Chip>
|
||||
)}
|
||||
|
||||
{resultCount != null && totalCount != null && (
|
||||
<Typography
|
||||
level='body-xs'
|
||||
@@ -430,7 +485,10 @@ const ChoreToolbar = ({
|
||||
minHeight: 0,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
onClick={onClearAllFilters}
|
||||
onClick={() => {
|
||||
setLocalSelections(defaultSelections())
|
||||
onClearAllFilters?.()
|
||||
}}
|
||||
>
|
||||
Clear all
|
||||
</Button>
|
||||
@@ -441,6 +499,7 @@ const ChoreToolbar = ({
|
||||
<BottomSheetModal
|
||||
open={filterSheetOpen}
|
||||
onClose={() => setFilterSheetOpen(false)}
|
||||
maxHeight='92vh'
|
||||
title={
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Tune sx={{ fontSize: 20 }} />
|
||||
@@ -453,91 +512,126 @@ const ChoreToolbar = ({
|
||||
</Box>
|
||||
}
|
||||
footer={
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
gap: 1,
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant='plain'
|
||||
color='danger'
|
||||
size='sm'
|
||||
disabled={!hasAnyActive}
|
||||
onClick={onClearAllFilters}
|
||||
savingFilter ? (
|
||||
<Box
|
||||
sx={{ display: 'flex', gap: 1, width: '100%', alignItems: 'center' }}
|
||||
>
|
||||
Clear all
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => setFilterSheetOpen(false)}
|
||||
sx={{ minWidth: 140 }}
|
||||
<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
|
||||
sx={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
gap: 1,
|
||||
}}
|
||||
>
|
||||
{resultCount != null
|
||||
? `Show ${resultCount} result${resultCount !== 1 ? 's' : ''}`
|
||||
: 'Done'}
|
||||
</Button>
|
||||
</Box>
|
||||
<Button
|
||||
variant='plain'
|
||||
color='danger'
|
||||
size='sm'
|
||||
disabled={!hasAnyActive && activeConditions.length === 0}
|
||||
onClick={() => {
|
||||
setLocalSelections(defaultSelections())
|
||||
onClearAllFilters?.()
|
||||
setFilterSheetOpen(false)
|
||||
}}
|
||||
>
|
||||
Clear all
|
||||
</Button>
|
||||
|
||||
{activeConditions.length > 0 ? (
|
||||
<>
|
||||
<ButtonGroup variant='solid' color='primary'>
|
||||
<Button
|
||||
onClick={() => setFilterSheetOpen(false)}
|
||||
sx={{ minWidth: 140 }}
|
||||
>
|
||||
{resultCount != null
|
||||
? `Show ${resultCount}`
|
||||
: 'Done'}
|
||||
</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 sx={{ display: 'flex', flexDirection: 'column', gap: 0 }}>
|
||||
{/* Full advanced filter content */}
|
||||
<FilterBuilderContent
|
||||
selections={localSelections}
|
||||
onSelectionsChange={handleSelectionsChange}
|
||||
members={members}
|
||||
labels={labels}
|
||||
projects={projects}
|
||||
/>
|
||||
|
||||
{/* ── Quick filter sections (Due Date / Priority / Labels etc.) ────── */}
|
||||
{filterDefs.map(def => (
|
||||
<Box key={def.id}>
|
||||
<Divider sx={{ my: 2.5 }} />
|
||||
<SectionHeader
|
||||
icon={def.icon}
|
||||
label={def.label}
|
||||
badge={chipLabel(def, activeFilters[def.id])}
|
||||
/>
|
||||
<OptionChips
|
||||
options={def.options ?? []}
|
||||
selected={activeFilters[def.id]}
|
||||
multi={def.type === 'multi-select'}
|
||||
onToggle={val => {
|
||||
if (def.type === 'multi-select') {
|
||||
const curr = activeFilters[def.id] || []
|
||||
const next = curr.includes(val)
|
||||
? curr.filter(v => v !== val)
|
||||
: [...curr, val]
|
||||
onSetFilter(def.id, next.length > 0 ? next : null)
|
||||
} else {
|
||||
onSetFilter(def.id, activeFilters[def.id] === val ? null : val)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
))}
|
||||
|
||||
{/* ── Saved / custom filters ──────────────────────────────────────── */}
|
||||
{(pinnedFilters.length > 0 || savedFilters.length > 0) && (
|
||||
{/* Saved filters section */}
|
||||
{savedFilters.length > 0 && (
|
||||
<>
|
||||
<Divider sx={{ my: 2.5 }} />
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
mb: 1.5,
|
||||
}}
|
||||
>
|
||||
<Typography level='title-sm' fontWeight={600}>
|
||||
Saved Filters
|
||||
</Typography>
|
||||
<IconButton
|
||||
size='sm'
|
||||
variant='plain'
|
||||
color='neutral'
|
||||
onClick={() => {
|
||||
setFilterSheetOpen(false)
|
||||
// navigate to /filters or open settings
|
||||
}}
|
||||
>
|
||||
<Settings sx={{ fontSize: 16 }} />
|
||||
</IconButton>
|
||||
</Box>
|
||||
<Typography level='title-sm' fontWeight={600} sx={{ mb: 1.5 }}>
|
||||
Saved Filters
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
|
||||
{savedFilters.map(filter => {
|
||||
const isActive = activeFilterId === filter.id
|
||||
@@ -557,7 +651,7 @@ const ChoreToolbar = ({
|
||||
}
|
||||
onClick={() => {
|
||||
onSavedFilterClick?.(filter.id)
|
||||
setFilterSheetOpen(false)
|
||||
if (!isActive) setFilterSheetOpen(false)
|
||||
}}
|
||||
sx={{
|
||||
cursor: 'pointer',
|
||||
@@ -574,46 +668,12 @@ const ChoreToolbar = ({
|
||||
)
|
||||
})}
|
||||
</Box>
|
||||
|
||||
<Button
|
||||
variant='plain'
|
||||
color='primary'
|
||||
size='sm'
|
||||
startDecorator={<Add sx={{ fontSize: 16 }} />}
|
||||
onClick={() => {
|
||||
setFilterSheetOpen(false)
|
||||
onCreateAdvancedFilter?.()
|
||||
}}
|
||||
sx={{ mt: 1.5, alignSelf: 'flex-start', px: 0 }}
|
||||
>
|
||||
Create advanced filter
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Edge case: no saved filters yet → still show "Create" CTA */}
|
||||
{savedFilters.length === 0 && (
|
||||
<>
|
||||
<Divider sx={{ my: 2.5 }} />
|
||||
<Button
|
||||
variant='plain'
|
||||
color='primary'
|
||||
size='sm'
|
||||
startDecorator={<Add sx={{ fontSize: 16 }} />}
|
||||
onClick={() => {
|
||||
setFilterSheetOpen(false)
|
||||
onCreateAdvancedFilter?.()
|
||||
}}
|
||||
sx={{ alignSelf: 'flex-start', px: 0 }}
|
||||
>
|
||||
Create advanced filter
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
</BottomSheetModal>
|
||||
|
||||
{/* ── Display bottom sheet (View + Group combined) ───────────────────── */}
|
||||
{/* ── Display bottom sheet (View + Group + Assignee + Project) ──────────── */}
|
||||
<BottomSheetModal
|
||||
open={displaySheetOpen}
|
||||
onClose={() => setDisplaySheetOpen(false)}
|
||||
@@ -638,7 +698,11 @@ const ChoreToolbar = ({
|
||||
key={opt.value}
|
||||
variant={viewMode === opt.value ? 'solid' : 'soft'}
|
||||
color={viewMode === opt.value ? 'primary' : 'neutral'}
|
||||
startDecorator={viewMode === opt.value ? <Check sx={{ fontSize: 14 }} /> : opt.icon}
|
||||
startDecorator={
|
||||
viewMode === opt.value
|
||||
? <Check sx={{ fontSize: 14 }} />
|
||||
: opt.icon
|
||||
}
|
||||
onClick={() => onToggleViewMode?.(opt.value)}
|
||||
sx={{
|
||||
py: 0.64,
|
||||
@@ -703,8 +767,8 @@ const ChoreToolbar = ({
|
||||
onToggle={v => onAssigneeFilterChange?.(v)}
|
||||
/>
|
||||
|
||||
{/* Project section */}
|
||||
{showProjectSection && (
|
||||
{/* Project section — only when no advanced filter is active */}
|
||||
{showProjectInDisplay && (
|
||||
<>
|
||||
<Divider sx={{ my: 2.5 }} />
|
||||
<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 {
|
||||
CalendarMonth,
|
||||
Check,
|
||||
FolderOpen,
|
||||
Label,
|
||||
Person,
|
||||
PriorityHigh,
|
||||
Save,
|
||||
Stars,
|
||||
TaskAlt,
|
||||
} from '@mui/icons-material'
|
||||
import {
|
||||
Avatar,
|
||||
Box,
|
||||
Button,
|
||||
Chip,
|
||||
@@ -20,125 +9,15 @@ import {
|
||||
} from '@mui/joy'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import BottomSheetModal from '../../../components/common/BottomSheetModal'
|
||||
import FilterBuilderContent, {
|
||||
conditionsToSelections,
|
||||
defaultSelections,
|
||||
selectionsToConditions,
|
||||
} from '../../Chores/components/FilterBuilderContent'
|
||||
import { FILTER_COLORS } from '../../../utils/Colors'
|
||||
import { applyFilter } from '../../../utils/FilterEngine'
|
||||
import Priorities from '../../../utils/Priorities'
|
||||
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 = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
@@ -194,32 +73,7 @@ const AdvancedFilterBuilder = ({
|
||||
c => c.nextDueDate && new Date(c.nextDueDate) < new Date(),
|
||||
).length
|
||||
|
||||
// ── Mutators ───────────────────────────────────────────────────────────────
|
||||
|
||||
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 activeConditionCount = conditions.length
|
||||
|
||||
const handleSave = () => {
|
||||
if (!filterName.trim()) {
|
||||
@@ -245,65 +99,6 @@ const AdvancedFilterBuilder = ({
|
||||
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 (
|
||||
<BottomSheetModal
|
||||
open={isOpen}
|
||||
@@ -320,7 +115,14 @@ const AdvancedFilterBuilder = ({
|
||||
</Box>
|
||||
}
|
||||
footer={
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 1 }}>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: 1,
|
||||
}}
|
||||
>
|
||||
{/* Preview */}
|
||||
<Box sx={{ display: 'flex', gap: 1, flexShrink: 0 }}>
|
||||
{conditions.length > 0 ? (
|
||||
@@ -360,27 +162,37 @@ const AdvancedFilterBuilder = ({
|
||||
}
|
||||
>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0 }}>
|
||||
|
||||
{/* ── Name ─────────────────────────────────────────────────────────── */}
|
||||
{/* Name */}
|
||||
<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
|
||||
</Typography>
|
||||
<Input
|
||||
placeholder='e.g. Overdue tasks for Alice'
|
||||
value={filterName}
|
||||
onChange={e => { setFilterName(e.target.value); setError('') }}
|
||||
onChange={e => {
|
||||
setFilterName(e.target.value)
|
||||
setError('')
|
||||
}}
|
||||
error={!!error}
|
||||
autoFocus
|
||||
/>
|
||||
{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>
|
||||
|
||||
{/* ── Color ────────────────────────────────────────────────────────── */}
|
||||
{/* Color */}
|
||||
<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
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
|
||||
@@ -395,9 +207,10 @@ const AdvancedFilterBuilder = ({
|
||||
borderRadius: '50%',
|
||||
background: c.value,
|
||||
cursor: 'pointer',
|
||||
outline: filterColor === c.value
|
||||
? '3px solid var(--joy-palette-primary-500)'
|
||||
: '2px solid transparent',
|
||||
outline:
|
||||
filterColor === c.value
|
||||
? '3px solid var(--joy-palette-primary-500)'
|
||||
: '2px solid transparent',
|
||||
outlineOffset: '2px',
|
||||
transition: 'all 0.15s ease',
|
||||
flexShrink: 0,
|
||||
@@ -410,185 +223,13 @@ const AdvancedFilterBuilder = ({
|
||||
|
||||
<Divider sx={{ mb: 2.5 }} />
|
||||
|
||||
{/* ── 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={() => setSelections(prev => ({ ...prev, points: { ...prev.points, active: false, value: 0 } }))}
|
||||
sx={{ cursor: 'pointer' }}
|
||||
>
|
||||
Clear
|
||||
</Chip>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<FilterBuilderContent
|
||||
selections={selections}
|
||||
onSelectionsChange={setSelections}
|
||||
members={members}
|
||||
labels={labels}
|
||||
projects={projects}
|
||||
/>
|
||||
</Box>
|
||||
</BottomSheetModal>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user