feat: Implement advanced filtering in FilterBar and integrate ActiveFilterChips component
- Refactored FilterBar to utilize ActiveFilterChips for displaying active filters. - Added selectable chip styles and improved hover effects for better UX. - Enhanced filter handling with new props for managing active filters and results count. - Introduced new hooks and state management for custom filters in MyChores and UserActivities. - Updated ChoreToolbar to support new filter functionalities and improved UI components. - Added client-side filtering capabilities in UserActivities with dynamic filter definitions.
This commit is contained in:
@@ -1,4 +1,4 @@
|
|||||||
import { Check, Close, FilterList, Tune } from '@mui/icons-material'
|
import { Check, FilterList, Tune } from '@mui/icons-material'
|
||||||
import {
|
import {
|
||||||
Avatar,
|
Avatar,
|
||||||
Badge,
|
Badge,
|
||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
} from '@mui/joy'
|
} from '@mui/joy'
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import BottomSheetModal from './BottomSheetModal'
|
import BottomSheetModal from './BottomSheetModal'
|
||||||
|
import ActiveFilterChips from './filter/ActiveFilterChips'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reusable filter bar component.
|
* Reusable filter bar component.
|
||||||
@@ -134,6 +135,47 @@ const FilterBar = ({
|
|||||||
|
|
||||||
const hasActive = activeFilterCount > 0
|
const hasActive = activeFilterCount > 0
|
||||||
|
|
||||||
|
const selectableChipSx = {
|
||||||
|
cursor: 'pointer',
|
||||||
|
transition: 'all 0.15s ease',
|
||||||
|
userSelect: 'none',
|
||||||
|
alignItems: 'center',
|
||||||
|
'& .MuiChip-startDecorator': {
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
mr: 0.5,
|
||||||
|
},
|
||||||
|
'& .MuiChip-label': {
|
||||||
|
lineHeight: 1.2,
|
||||||
|
},
|
||||||
|
'&:hover': { opacity: 0.85 },
|
||||||
|
}
|
||||||
|
|
||||||
|
const sectionBadgeChipSx = {
|
||||||
|
ml: 'auto',
|
||||||
|
fontSize: '0.7rem',
|
||||||
|
minHeight: 22,
|
||||||
|
py: 0.25,
|
||||||
|
px: 0.75,
|
||||||
|
alignItems: 'center',
|
||||||
|
'& .MuiChip-label': {
|
||||||
|
lineHeight: 1.2,
|
||||||
|
px: 0,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const modalCountChipSx = {
|
||||||
|
ml: 0.5,
|
||||||
|
minHeight: 22,
|
||||||
|
py: 0.25,
|
||||||
|
px: 0.75,
|
||||||
|
alignItems: 'center',
|
||||||
|
'& .MuiChip-label': {
|
||||||
|
lineHeight: 1.2,
|
||||||
|
px: 0,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
// ── Chip labels for inline bar ─────────────────────────────────────────────
|
// ── Chip labels for inline bar ─────────────────────────────────────────────
|
||||||
|
|
||||||
const getActiveChipLabel = def => {
|
const getActiveChipLabel = def => {
|
||||||
@@ -223,85 +265,47 @@ const FilterBar = ({
|
|||||||
color='primary'
|
color='primary'
|
||||||
size='sm'
|
size='sm'
|
||||||
anchorOrigin={{ vertical: 'top', horizontal: 'right' }}
|
anchorOrigin={{ vertical: 'top', horizontal: 'right' }}
|
||||||
|
sx={{ display: 'flex', alignItems: 'center' }}
|
||||||
>
|
>
|
||||||
<Button
|
<Button
|
||||||
size='sm'
|
size='md'
|
||||||
variant={hasActive ? 'solid' : 'outlined'}
|
variant={hasActive ? 'solid' : 'outlined'}
|
||||||
color={hasActive ? 'primary' : 'neutral'}
|
color={hasActive ? 'primary' : 'neutral'}
|
||||||
startDecorator={<FilterList sx={{ fontSize: 16 }} />}
|
startDecorator={<FilterList sx={{ fontSize: 16 }} />}
|
||||||
onClick={() => setIsOpen(true)}
|
onClick={() => setIsOpen(true)}
|
||||||
sx={{ borderRadius: 'xl', gap: 0.5 }}
|
sx={{
|
||||||
|
borderRadius: 'xl',
|
||||||
|
py: 0.5,
|
||||||
|
px: 1,
|
||||||
|
gap: 0.5,
|
||||||
|
alignItems: 'center',
|
||||||
|
'& .MuiButton-startDecorator': {
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
mr: 0.5,
|
||||||
|
},
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
Filters
|
Filters
|
||||||
</Button>
|
</Button>
|
||||||
</Badge>
|
</Badge>
|
||||||
|
|
||||||
{(() => {
|
<ActiveFilterChips
|
||||||
const activeChips = filterDefs
|
chips={filterDefs
|
||||||
.map(def => ({ def, label: getActiveChipLabel(def) }))
|
.map(def => ({ def, label: getActiveChipLabel(def) }))
|
||||||
.filter(({ label }) => !!label)
|
.filter(({ label }) => !!label)
|
||||||
const MAX_VISIBLE = 2
|
.map(({ def, label }) => ({
|
||||||
const visible = activeChips.slice(0, MAX_VISIBLE)
|
key: def.id,
|
||||||
const overflow = activeChips.length - MAX_VISIBLE
|
label,
|
||||||
|
onClear: () => onSetFilter(def.id, null),
|
||||||
return (
|
}))}
|
||||||
<>
|
onOpen={() => setIsOpen(true)}
|
||||||
{visible.map(({ def, label }) => (
|
onClearAll={hasActive ? onClearAll : undefined}
|
||||||
<Chip
|
resultCount={hasActive ? resultCount : undefined}
|
||||||
key={def.id}
|
totalCount={hasActive ? totalCount : undefined}
|
||||||
size='md'
|
maxVisible={2}
|
||||||
variant='soft'
|
chipSize='md'
|
||||||
color='primary'
|
/>
|
||||||
endDecorator={
|
|
||||||
<Close
|
|
||||||
sx={{ cursor: 'pointer' }}
|
|
||||||
onClick={e => {
|
|
||||||
e.stopPropagation()
|
|
||||||
onSetFilter(def.id, null)
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
onClick={() => setIsOpen(true)}
|
|
||||||
sx={{
|
|
||||||
py: 0.64,
|
|
||||||
cursor: 'pointer', transition: 'all 0.15s ease', '&:hover': { opacity: 0.85 } }}
|
|
||||||
>
|
|
||||||
{label}
|
|
||||||
</Chip>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{overflow > 0 && (
|
|
||||||
<Chip
|
|
||||||
size='md'
|
|
||||||
variant='soft'
|
|
||||||
color='neutral'
|
|
||||||
onClick={() => setIsOpen(true)}
|
|
||||||
sx={{ py: 0.64, cursor: 'pointer', transition: 'all 0.15s ease', '&:hover': { opacity: 0.85 } }}
|
|
||||||
>
|
|
||||||
+{overflow} more
|
|
||||||
</Chip>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
})()}
|
|
||||||
|
|
||||||
{hasActive && (
|
|
||||||
<Button
|
|
||||||
size='sm'
|
|
||||||
variant='plain'
|
|
||||||
color='neutral'
|
|
||||||
sx={{ px: 0.5, fontSize: '0.75rem', color: 'text.secondary', minHeight: 0 }}
|
|
||||||
onClick={onClearAll}
|
|
||||||
>
|
|
||||||
Clear all
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{hasActive && resultCount !== undefined && totalCount !== undefined && (
|
|
||||||
<Typography level='body-xs' sx={{ color: 'text.tertiary', ml: 'auto', flexShrink: 0 }}>
|
|
||||||
{resultCount} / {totalCount}
|
|
||||||
</Typography>
|
|
||||||
)}
|
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* ── Bottom sheet ────────────────────────────────────── */}
|
{/* ── Bottom sheet ────────────────────────────────────── */}
|
||||||
@@ -313,7 +317,7 @@ const FilterBar = ({
|
|||||||
<Tune sx={{ fontSize: 20 }} />
|
<Tune sx={{ fontSize: 20 }} />
|
||||||
Filters
|
Filters
|
||||||
{hasActive && (
|
{hasActive && (
|
||||||
<Chip size='sm' variant='solid' color='primary' sx={{ ml: 0.5 }}>
|
<Chip size='sm' variant='solid' color='primary' sx={modalCountChipSx}>
|
||||||
{activeFilterCount}
|
{activeFilterCount}
|
||||||
</Chip>
|
</Chip>
|
||||||
)}
|
)}
|
||||||
@@ -356,20 +360,20 @@ const FilterBar = ({
|
|||||||
|
|
||||||
{/* active badge in header */}
|
{/* active badge in header */}
|
||||||
{def.type === 'multi-select' && (activeFilters[def.id]?.length ?? 0) > 0 && (
|
{def.type === 'multi-select' && (activeFilters[def.id]?.length ?? 0) > 0 && (
|
||||||
<Chip size='sm' variant='solid' color='primary' sx={{ ml: 'auto', fontSize: '0.7rem', height: 20 }}>
|
<Chip size='sm' variant='solid' color='primary' sx={sectionBadgeChipSx}>
|
||||||
{activeFilters[def.id].length} selected
|
{activeFilters[def.id].length} selected
|
||||||
</Chip>
|
</Chip>
|
||||||
)}
|
)}
|
||||||
{def.type === 'single-select' && activeFilters[def.id] != null && (() => {
|
{def.type === 'single-select' && activeFilters[def.id] != null && (() => {
|
||||||
const opt = def.options?.find(o => o.value === activeFilters[def.id])
|
const opt = def.options?.find(o => o.value === activeFilters[def.id])
|
||||||
return opt ? (
|
return opt ? (
|
||||||
<Chip size='sm' variant='solid' color='primary' sx={{ ml: 'auto', fontSize: '0.7rem', height: 20 }}>
|
<Chip size='sm' variant='solid' color='primary' sx={sectionBadgeChipSx}>
|
||||||
{opt.label}
|
{opt.label}
|
||||||
</Chip>
|
</Chip>
|
||||||
) : null
|
) : null
|
||||||
})()}
|
})()}
|
||||||
{def.type === 'date-range' && getActiveChipLabel(def) && (
|
{def.type === 'date-range' && getActiveChipLabel(def) && (
|
||||||
<Chip size='sm' variant='solid' color='primary' sx={{ ml: 'auto', fontSize: '0.7rem', height: 20 }}>
|
<Chip size='sm' variant='solid' color='primary' sx={sectionBadgeChipSx}>
|
||||||
{getActiveChipLabel(def)}
|
{getActiveChipLabel(def)}
|
||||||
</Chip>
|
</Chip>
|
||||||
)}
|
)}
|
||||||
@@ -393,7 +397,7 @@ const FilterBar = ({
|
|||||||
) : (opt.icon ?? null)
|
) : (opt.icon ?? null)
|
||||||
}
|
}
|
||||||
onClick={() => handleMultiToggle(def.id, opt.value)}
|
onClick={() => handleMultiToggle(def.id, opt.value)}
|
||||||
sx={{ cursor: 'pointer', transition: 'all 0.15s ease', userSelect: 'none', '&:hover': { opacity: 0.85 } }}
|
sx={selectableChipSx}
|
||||||
>
|
>
|
||||||
{opt.label}
|
{opt.label}
|
||||||
</Chip>
|
</Chip>
|
||||||
@@ -420,7 +424,7 @@ const FilterBar = ({
|
|||||||
) : (opt.icon ?? null)
|
) : (opt.icon ?? null)
|
||||||
}
|
}
|
||||||
onClick={() => handleSingleToggle(def.id, opt.value)}
|
onClick={() => handleSingleToggle(def.id, opt.value)}
|
||||||
sx={{ cursor: 'pointer', transition: 'all 0.15s ease', userSelect: 'none', '&:hover': { opacity: 0.85 } }}
|
sx={selectableChipSx}
|
||||||
>
|
>
|
||||||
{opt.label}
|
{opt.label}
|
||||||
</Chip>
|
</Chip>
|
||||||
@@ -436,7 +440,7 @@ const FilterBar = ({
|
|||||||
color={activeFilters[def.id] ? 'primary' : 'neutral'}
|
color={activeFilters[def.id] ? 'primary' : 'neutral'}
|
||||||
startDecorator={activeFilters[def.id] ? <Check sx={{ fontSize: 14 }} /> : null}
|
startDecorator={activeFilters[def.id] ? <Check sx={{ fontSize: 14 }} /> : null}
|
||||||
onClick={() => handleBoolToggle(def.id)}
|
onClick={() => handleBoolToggle(def.id)}
|
||||||
sx={{ cursor: 'pointer', transition: 'all 0.15s ease', userSelect: 'none', '&:hover': { opacity: 0.85 } }}
|
sx={selectableChipSx}
|
||||||
>
|
>
|
||||||
{def.label}
|
{def.label}
|
||||||
</Chip>
|
</Chip>
|
||||||
@@ -458,7 +462,7 @@ const FilterBar = ({
|
|||||||
color={isSelected ? 'primary' : 'neutral'}
|
color={isSelected ? 'primary' : 'neutral'}
|
||||||
startDecorator={isSelected ? <Check sx={{ fontSize: 14 }} /> : null}
|
startDecorator={isSelected ? <Check sx={{ fontSize: 14 }} /> : null}
|
||||||
onClick={() => handleDateRangePreset(def.id, preset.value)}
|
onClick={() => handleDateRangePreset(def.id, preset.value)}
|
||||||
sx={{ cursor: 'pointer', transition: 'all 0.15s ease', userSelect: 'none', '&:hover': { opacity: 0.85 } }}
|
sx={selectableChipSx}
|
||||||
>
|
>
|
||||||
{preset.label}
|
{preset.label}
|
||||||
</Chip>
|
</Chip>
|
||||||
|
|||||||
131
src/components/common/filter/ActiveFilterChips.jsx
Normal file
131
src/components/common/filter/ActiveFilterChips.jsx
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
import { Close } from '@mui/icons-material'
|
||||||
|
import { Box, Button, Chip, Typography } from '@mui/joy'
|
||||||
|
|
||||||
|
const ActiveFilterChips = ({
|
||||||
|
chips = [],
|
||||||
|
onOpen,
|
||||||
|
onClearAll,
|
||||||
|
resultCount,
|
||||||
|
totalCount,
|
||||||
|
maxVisible = 2,
|
||||||
|
chipSize = 'md',
|
||||||
|
clearButtonSize = 'sm',
|
||||||
|
clearButtonSx,
|
||||||
|
containerSx,
|
||||||
|
chipSx,
|
||||||
|
overflowChipSx,
|
||||||
|
resultSx,
|
||||||
|
}) => {
|
||||||
|
if (!chips.length) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const visible = chips.slice(0, maxVisible)
|
||||||
|
const overflow = chips.length - maxVisible
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 1,
|
||||||
|
flexWrap: 'nowrap',
|
||||||
|
overflowX: 'auto',
|
||||||
|
py: 0.5,
|
||||||
|
'&::-webkit-scrollbar': { display: 'none' },
|
||||||
|
scrollbarWidth: 'none',
|
||||||
|
...containerSx,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{visible.map(({ key, label, onClear, color = 'primary' }) => (
|
||||||
|
<Chip
|
||||||
|
key={key}
|
||||||
|
size={chipSize}
|
||||||
|
variant='soft'
|
||||||
|
color={color}
|
||||||
|
endDecorator={
|
||||||
|
<Close
|
||||||
|
sx={{ cursor: 'pointer', fontSize: chipSize === 'sm' ? 12 : 16 }}
|
||||||
|
onClick={e => {
|
||||||
|
e.stopPropagation()
|
||||||
|
onClear?.()
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
onClick={onOpen}
|
||||||
|
sx={{
|
||||||
|
cursor: 'pointer',
|
||||||
|
flexShrink: 0,
|
||||||
|
transition: 'all 0.15s ease',
|
||||||
|
alignItems: 'center',
|
||||||
|
'& .MuiChip-endDecorator': {
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
ml: 0.5,
|
||||||
|
},
|
||||||
|
'& .MuiChip-label': {
|
||||||
|
lineHeight: 1.2,
|
||||||
|
},
|
||||||
|
'&:hover': { opacity: 0.85 },
|
||||||
|
...chipSx,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</Chip>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{overflow > 0 && (
|
||||||
|
<Chip
|
||||||
|
size={chipSize}
|
||||||
|
variant='soft'
|
||||||
|
color='neutral'
|
||||||
|
onClick={onOpen}
|
||||||
|
sx={{
|
||||||
|
cursor: 'pointer',
|
||||||
|
flexShrink: 0,
|
||||||
|
transition: 'all 0.15s ease',
|
||||||
|
'&:hover': { opacity: 0.85 },
|
||||||
|
...overflowChipSx,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
+{overflow} more
|
||||||
|
</Chip>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{resultCount != null && totalCount != null && (
|
||||||
|
<Typography
|
||||||
|
level='body-xs'
|
||||||
|
sx={{
|
||||||
|
color: 'text.tertiary',
|
||||||
|
ml: 'auto',
|
||||||
|
flexShrink: 0,
|
||||||
|
...resultSx,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{resultCount} / {totalCount}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{onClearAll && (
|
||||||
|
<Button
|
||||||
|
size={clearButtonSize}
|
||||||
|
variant='plain'
|
||||||
|
color='neutral'
|
||||||
|
onClick={onClearAll}
|
||||||
|
sx={{
|
||||||
|
px: 0.5,
|
||||||
|
fontSize: chipSize === 'sm' ? '0.72rem' : '0.75rem',
|
||||||
|
color: 'text.secondary',
|
||||||
|
minHeight: 0,
|
||||||
|
flexShrink: 0,
|
||||||
|
...clearButtonSx,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Clear all
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ActiveFilterChips
|
||||||
@@ -951,9 +951,11 @@ const MyChores = () => {
|
|||||||
labels={userLabels || []}
|
labels={userLabels || []}
|
||||||
projects={projectsWithDefault}
|
projects={projectsWithDefault}
|
||||||
tempFilter={tempFilter}
|
tempFilter={tempFilter}
|
||||||
|
tempFilterMeta={tempFilterMeta}
|
||||||
applyTempFilter={applyTempFilter}
|
applyTempFilter={applyTempFilter}
|
||||||
clearTempFilter={clearTempFilter}
|
clearTempFilter={clearTempFilter}
|
||||||
saveFilter={saveFilter}
|
saveFilter={saveFilter}
|
||||||
|
updateFilter={updateFilter}
|
||||||
onFilterSaved={name =>
|
onFilterSaved={name =>
|
||||||
showSuccess({
|
showSuccess({
|
||||||
title: 'Filter Saved',
|
title: 'Filter Saved',
|
||||||
|
|||||||
@@ -23,9 +23,7 @@ import {
|
|||||||
Check,
|
Check,
|
||||||
CheckBox,
|
CheckBox,
|
||||||
CheckBoxOutlineBlank,
|
CheckBoxOutlineBlank,
|
||||||
Close,
|
|
||||||
FilterList,
|
FilterList,
|
||||||
FolderOpen,
|
|
||||||
Save,
|
Save,
|
||||||
Sort,
|
Sort,
|
||||||
Tune,
|
Tune,
|
||||||
@@ -46,17 +44,23 @@ import {
|
|||||||
MenuItem,
|
MenuItem,
|
||||||
Typography,
|
Typography,
|
||||||
} from '@mui/joy'
|
} from '@mui/joy'
|
||||||
import { useRef, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import BottomSheetModal from '../../../components/common/BottomSheetModal'
|
import BottomSheetModal from '../../../components/common/BottomSheetModal'
|
||||||
|
import ActiveFilterChips from '../../../components/common/filter/ActiveFilterChips'
|
||||||
import { Z_INDEX } from '../../../constants/zIndex'
|
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 { FILTER_COLORS } from '../../../utils/Colors'
|
||||||
|
import Priorities from '../../../utils/Priorities'
|
||||||
import FilterBuilderContent, {
|
import FilterBuilderContent, {
|
||||||
|
CHORE_STATUSES,
|
||||||
|
DUE_DATE_OPTIONS,
|
||||||
|
POINTS_OPERATORS,
|
||||||
conditionsToSelections,
|
conditionsToSelections,
|
||||||
defaultSelections,
|
defaultSelections,
|
||||||
selectionsToConditions,
|
selectionsToConditions,
|
||||||
} from './FilterBuilderContent'
|
} from './FilterBuilderContent'
|
||||||
import SearchBar from './SearchBar'
|
import SearchBar from './SearchBar'
|
||||||
|
import ProjectSelector from '../../components/ProjectSelector'
|
||||||
|
|
||||||
// ─── sub-components for the Display sheet ────────────────────────────────────
|
// ─── sub-components for the Display sheet ────────────────────────────────────
|
||||||
|
|
||||||
@@ -133,9 +137,11 @@ const OptionChips = ({ options, selected, multi, onToggle }) => (
|
|||||||
* labels – user labels for Labels section
|
* labels – user labels for Labels section
|
||||||
* projects – projects list (projectsWithDefault) for Projects section + Display sheet
|
* projects – projects list (projectsWithDefault) for Projects section + Display sheet
|
||||||
* tempFilter – current temp filter object { conditions, operator } or null
|
* tempFilter – current temp filter object { conditions, operator } or null
|
||||||
|
* tempFilterMeta – metadata for temp filter, including saved-filter edit source when applicable
|
||||||
* applyTempFilter – (filter) => void — called immediately as selections change
|
* applyTempFilter – (filter) => void — called immediately as selections change
|
||||||
* clearTempFilter – () => void
|
* clearTempFilter – () => void
|
||||||
* saveFilter – (filterData) => Promise — saves as a named filter
|
* saveFilter – (filterData) => Promise — saves as a named filter
|
||||||
|
* updateFilter – (filterId, filterData) => Promise — updates an existing saved filter
|
||||||
* onFilterSaved – (name) => void — called after successful save (for notifications)
|
* onFilterSaved – (name) => void — called after successful save (for notifications)
|
||||||
*
|
*
|
||||||
* -- Result counts --
|
* -- Result counts --
|
||||||
@@ -176,9 +182,11 @@ const ChoreToolbar = ({
|
|||||||
labels = [],
|
labels = [],
|
||||||
projects = [],
|
projects = [],
|
||||||
tempFilter,
|
tempFilter,
|
||||||
|
tempFilterMeta,
|
||||||
applyTempFilter,
|
applyTempFilter,
|
||||||
clearTempFilter,
|
clearTempFilter,
|
||||||
saveFilter,
|
saveFilter,
|
||||||
|
updateFilter,
|
||||||
onFilterSaved,
|
onFilterSaved,
|
||||||
// result counts
|
// result counts
|
||||||
resultCount,
|
resultCount,
|
||||||
@@ -219,7 +227,9 @@ const ChoreToolbar = ({
|
|||||||
const [savingFilter, setSavingFilter] = useState(false)
|
const [savingFilter, setSavingFilter] = useState(false)
|
||||||
const [saveFilterName, setSaveFilterName] = useState('')
|
const [saveFilterName, setSaveFilterName] = useState('')
|
||||||
const [saveMenuAnchorEl, setSaveMenuAnchorEl] = useState(null)
|
const [saveMenuAnchorEl, setSaveMenuAnchorEl] = useState(null)
|
||||||
|
const [editingSavedFilter, setEditingSavedFilter] = useState(null)
|
||||||
const saveMenuRef = useRef(null)
|
const saveMenuRef = useRef(null)
|
||||||
|
const activeConditions = selectionsToConditions(localSelections)
|
||||||
|
|
||||||
// ── badge counts ─────────────────────────────────────────────────────────────
|
// ── badge counts ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -232,46 +242,166 @@ const ChoreToolbar = ({
|
|||||||
|
|
||||||
const inlineChips = []
|
const inlineChips = []
|
||||||
|
|
||||||
if (savedFilterActive) {
|
const getConditionChipLabel = condition => {
|
||||||
const sf = savedFilters.find(f => f.id === activeFilterId)
|
if (!condition?.type) return 'Filter'
|
||||||
if (sf) {
|
|
||||||
inlineChips.push({
|
const typeLabels = {
|
||||||
key: '__saved',
|
assignee: 'Assignee',
|
||||||
label: sf.name,
|
createdBy: 'Created By',
|
||||||
onClear: () => onSavedFilterClick?.(activeFilterId),
|
status: 'Status',
|
||||||
})
|
priority: 'Priority',
|
||||||
|
label: 'Labels',
|
||||||
|
project: 'Project',
|
||||||
|
dueDate: 'Due Date',
|
||||||
|
points: 'Points',
|
||||||
}
|
}
|
||||||
} else if (tempConditionCount > 0) {
|
|
||||||
|
const typeLabel = typeLabels[condition.type] || 'Filter'
|
||||||
|
const prefix = condition.operator === 'isNot' ? 'Not ' : ''
|
||||||
|
|
||||||
|
if (condition.type === 'dueDate') {
|
||||||
|
const dueDateLabel =
|
||||||
|
DUE_DATE_OPTIONS.find(o => o.value === condition.operator)?.label ||
|
||||||
|
'Custom'
|
||||||
|
return `${typeLabel}: ${dueDateLabel}`
|
||||||
|
}
|
||||||
|
|
||||||
|
if (condition.type === 'points') {
|
||||||
|
const pointsOp =
|
||||||
|
POINTS_OPERATORS.find(o => o.value === condition.operator)?.label ||
|
||||||
|
condition.operator ||
|
||||||
|
''
|
||||||
|
return `${typeLabel} ${pointsOp} ${condition.value ?? 0}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawValues = Array.isArray(condition.value)
|
||||||
|
? condition.value
|
||||||
|
: condition.value != null
|
||||||
|
? [condition.value]
|
||||||
|
: []
|
||||||
|
|
||||||
|
const resolveLabel = value => {
|
||||||
|
if (condition.type === 'assignee' || condition.type === 'createdBy') {
|
||||||
|
const member = members.find(m => m.userId === value)
|
||||||
|
return member?.displayName || member?.username || String(value)
|
||||||
|
}
|
||||||
|
if (condition.type === 'status') {
|
||||||
|
return CHORE_STATUSES.find(s => s.value === value)?.label || String(value)
|
||||||
|
}
|
||||||
|
if (condition.type === 'priority') {
|
||||||
|
return Priorities.find(p => p.value === value)?.name || String(value)
|
||||||
|
}
|
||||||
|
if (condition.type === 'label') {
|
||||||
|
return labels.find(l => l.id === value)?.name || String(value)
|
||||||
|
}
|
||||||
|
if (condition.type === 'project') {
|
||||||
|
if (value === 'default') return 'Default Project'
|
||||||
|
return projects.find(p => p.id === value)?.name || String(value)
|
||||||
|
}
|
||||||
|
return String(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rawValues.length === 0) {
|
||||||
|
return `${prefix}${typeLabel}`
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rawValues.length === 1) {
|
||||||
|
return `${prefix}${typeLabel}: ${resolveLabel(rawValues[0])}`
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${prefix}${typeLabel} (${rawValues.length})`
|
||||||
|
}
|
||||||
|
|
||||||
|
const clearConditionAtIndex = index => {
|
||||||
|
const nextConditions = (tempFilter?.conditions || []).filter(
|
||||||
|
(_condition, conditionIndex) => conditionIndex !== index,
|
||||||
|
)
|
||||||
|
|
||||||
|
if (nextConditions.length === 0) {
|
||||||
|
setLocalSelections(defaultSelections())
|
||||||
|
clearTempFilter?.()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextFilter = {
|
||||||
|
...tempFilter,
|
||||||
|
operator: tempFilter?.operator || 'AND',
|
||||||
|
conditions: nextConditions,
|
||||||
|
}
|
||||||
|
|
||||||
|
setLocalSelections(conditionsToSelections(nextConditions))
|
||||||
|
applyTempFilter?.(nextFilter)
|
||||||
|
}
|
||||||
|
|
||||||
|
const activeSavedFilter = savedFilterActive
|
||||||
|
? savedFilters.find(f => f.id === activeFilterId)
|
||||||
|
: null
|
||||||
|
|
||||||
|
const activeChipConditions = savedFilterActive
|
||||||
|
? activeSavedFilter?.conditions || []
|
||||||
|
: tempFilter?.conditions || []
|
||||||
|
|
||||||
|
activeChipConditions.forEach((condition, index) => {
|
||||||
inlineChips.push({
|
inlineChips.push({
|
||||||
key: '__temp',
|
key: `${savedFilterActive ? '__saved' : '__temp'}_${index}`,
|
||||||
label: `${tempConditionCount} condition${tempConditionCount !== 1 ? 's' : ''}`,
|
label: getConditionChipLabel(condition),
|
||||||
onClear: () => {
|
onClear: () => {
|
||||||
setLocalSelections(defaultSelections())
|
if (savedFilterActive) {
|
||||||
clearTempFilter?.()
|
onSavedFilterClick?.(activeFilterId)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
clearConditionAtIndex(index)
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
})
|
||||||
|
|
||||||
// ── open filter sheet ────────────────────────────────────────────────────────
|
// ── open filter sheet ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const openFilterSheet = () => {
|
const openFilterSheet = () => {
|
||||||
if (tempFilter?.conditions?.length > 0) {
|
if (tempFilter?.conditions?.length > 0) {
|
||||||
setLocalSelections(conditionsToSelections(tempFilter.conditions))
|
setLocalSelections(conditionsToSelections(tempFilter.conditions))
|
||||||
|
if (tempFilterMeta?.sourceFilterId) {
|
||||||
|
const sourceFilter =
|
||||||
|
savedFilters.find(f => f.id === tempFilterMeta.sourceFilterId) ||
|
||||||
|
null
|
||||||
|
setEditingSavedFilter(
|
||||||
|
sourceFilter ||
|
||||||
|
(tempFilterMeta.sourceFilterId
|
||||||
|
? {
|
||||||
|
id: tempFilterMeta.sourceFilterId,
|
||||||
|
name: tempFilterMeta.sourceFilterName,
|
||||||
|
description: tempFilterMeta.sourceFilterDescription,
|
||||||
|
color: tempFilterMeta.sourceFilterColor,
|
||||||
|
}
|
||||||
|
: null),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
setEditingSavedFilter(null)
|
||||||
|
}
|
||||||
} else if (activeFilterId) {
|
} else if (activeFilterId) {
|
||||||
const sf = savedFilters.find(f => f.id === activeFilterId)
|
const sf = savedFilters.find(f => f.id === activeFilterId)
|
||||||
|
setEditingSavedFilter(sf || null)
|
||||||
setLocalSelections(
|
setLocalSelections(
|
||||||
sf?.conditions
|
sf?.conditions
|
||||||
? conditionsToSelections(sf.conditions)
|
? conditionsToSelections(sf.conditions)
|
||||||
: defaultSelections(),
|
: defaultSelections(),
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
|
setEditingSavedFilter(null)
|
||||||
setLocalSelections(defaultSelections())
|
setLocalSelections(defaultSelections())
|
||||||
}
|
}
|
||||||
setSavingFilter(false)
|
setSavingFilter(false)
|
||||||
setSaveFilterName('')
|
setSaveFilterName('')
|
||||||
|
setSaveMenuAnchorEl(null)
|
||||||
setFilterSheetOpen(true)
|
setFilterSheetOpen(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!filterSheetOpen || savingFilter || activeConditions.length === 0) {
|
||||||
|
setSaveMenuAnchorEl(null)
|
||||||
|
}
|
||||||
|
}, [filterSheetOpen, savingFilter, activeConditions.length])
|
||||||
|
|
||||||
// ── selection changes → apply temp filter immediately ────────────────────────
|
// ── selection changes → apply temp filter immediately ────────────────────────
|
||||||
|
|
||||||
const handleSelectionsChange = updater => {
|
const handleSelectionsChange = updater => {
|
||||||
@@ -279,7 +409,20 @@ const ChoreToolbar = ({
|
|||||||
const next = typeof updater === 'function' ? updater(prev) : updater
|
const next = typeof updater === 'function' ? updater(prev) : updater
|
||||||
const conditions = selectionsToConditions(next)
|
const conditions = selectionsToConditions(next)
|
||||||
if (conditions.length > 0) {
|
if (conditions.length > 0) {
|
||||||
applyTempFilter?.({ conditions, operator: 'AND' })
|
applyTempFilter?.(
|
||||||
|
{ conditions, operator: 'AND' },
|
||||||
|
editingSavedFilter
|
||||||
|
? {
|
||||||
|
name: editingSavedFilter.name,
|
||||||
|
description: editingSavedFilter.description,
|
||||||
|
sourceFilterId: editingSavedFilter.id,
|
||||||
|
sourceFilterName: editingSavedFilter.name,
|
||||||
|
sourceFilterDescription: editingSavedFilter.description,
|
||||||
|
sourceFilterColor: editingSavedFilter.color,
|
||||||
|
isEditingSavedFilter: true,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
clearTempFilter?.()
|
clearTempFilter?.()
|
||||||
}
|
}
|
||||||
@@ -310,6 +453,31 @@ const ChoreToolbar = ({
|
|||||||
setFilterSheetOpen(false)
|
setFilterSheetOpen(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleUpdateFilter = () => {
|
||||||
|
if (!editingSavedFilter?.id || !updateFilter) return
|
||||||
|
|
||||||
|
const conditions = selectionsToConditions(localSelections)
|
||||||
|
if (conditions.length === 0) return
|
||||||
|
|
||||||
|
updateFilter(
|
||||||
|
editingSavedFilter.id,
|
||||||
|
{
|
||||||
|
name: editingSavedFilter.name,
|
||||||
|
description: editingSavedFilter.description || '',
|
||||||
|
color: editingSavedFilter.color,
|
||||||
|
conditions,
|
||||||
|
operator: 'AND',
|
||||||
|
},
|
||||||
|
)?.then?.(() => {
|
||||||
|
clearTempFilter?.()
|
||||||
|
onSavedFilterClick?.(editingSavedFilter.id)
|
||||||
|
onFilterSaved?.(editingSavedFilter.name)
|
||||||
|
})
|
||||||
|
|
||||||
|
setSaveMenuAnchorEl(null)
|
||||||
|
setFilterSheetOpen(false)
|
||||||
|
}
|
||||||
|
|
||||||
// ── display sheet helpers ────────────────────────────────────────────────────
|
// ── display sheet helpers ────────────────────────────────────────────────────
|
||||||
|
|
||||||
const filterActive = activeFilterId != null || tempConditionCount > 0
|
const filterActive = activeFilterId != null || tempConditionCount > 0
|
||||||
@@ -341,13 +509,6 @@ const ChoreToolbar = ({
|
|||||||
{ 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 ─────────────────────────────────────────────── */}
|
||||||
@@ -387,6 +548,15 @@ const ChoreToolbar = ({
|
|||||||
</IconButton>
|
</IconButton>
|
||||||
</Badge>
|
</Badge>
|
||||||
|
|
||||||
|
{/* Project selector */}
|
||||||
|
{!filterActive && projects.filter(p => p.id !== 'default').length > 0 && (
|
||||||
|
<ProjectSelector
|
||||||
|
selectedProject={selectedProject?.name || 'Default Project'}
|
||||||
|
onProjectSelect={onProjectSelect}
|
||||||
|
showKeyboardShortcuts={showKeyboardShortcuts}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Display button — View + Group combined */}
|
{/* Display button — View + Group combined */}
|
||||||
<IconButton
|
<IconButton
|
||||||
variant={displayActive ? 'solid' : 'outlined'}
|
variant={displayActive ? 'solid' : 'outlined'}
|
||||||
@@ -431,74 +601,29 @@ const ChoreToolbar = ({
|
|||||||
|
|
||||||
{/* ── Row 2: active filter chips ──────────────────────────────────────── */}
|
{/* ── Row 2: active filter chips ──────────────────────────────────────── */}
|
||||||
{hasAnyActive && (
|
{hasAnyActive && (
|
||||||
<Box
|
<ActiveFilterChips
|
||||||
sx={{
|
chips={inlineChips}
|
||||||
display: 'flex',
|
onOpen={openFilterSheet}
|
||||||
alignItems: 'center',
|
onClearAll={() => {
|
||||||
gap: 1,
|
setLocalSelections(defaultSelections())
|
||||||
flexWrap: 'nowrap',
|
onClearAllFilters?.()
|
||||||
overflowX: 'auto',
|
|
||||||
py: 0.5,
|
|
||||||
'&::-webkit-scrollbar': { display: 'none' },
|
|
||||||
scrollbarWidth: 'none',
|
|
||||||
}}
|
}}
|
||||||
>
|
resultCount={resultCount}
|
||||||
{inlineChips.map(({ key, label, onClear }) => (
|
totalCount={totalCount}
|
||||||
<Chip
|
maxVisible={2}
|
||||||
key={key}
|
chipSize='md'
|
||||||
size='sm'
|
clearButtonSize='sm'
|
||||||
variant='soft'
|
clearButtonSx={{ color: 'text.secondary' }}
|
||||||
color='primary'
|
/>
|
||||||
endDecorator={
|
|
||||||
<Close
|
|
||||||
sx={{ fontSize: 12, cursor: 'pointer' }}
|
|
||||||
onClick={e => {
|
|
||||||
e.stopPropagation()
|
|
||||||
onClear()
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
onClick={openFilterSheet}
|
|
||||||
sx={{ cursor: 'pointer', flexShrink: 0 }}
|
|
||||||
>
|
|
||||||
{label}
|
|
||||||
</Chip>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{resultCount != null && totalCount != null && (
|
|
||||||
<Typography
|
|
||||||
level='body-xs'
|
|
||||||
sx={{ color: 'text.tertiary', ml: 'auto', flexShrink: 0 }}
|
|
||||||
>
|
|
||||||
{resultCount} / {totalCount}
|
|
||||||
</Typography>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Button
|
|
||||||
size='sm'
|
|
||||||
variant='plain'
|
|
||||||
color='neutral'
|
|
||||||
sx={{
|
|
||||||
px: 0.5,
|
|
||||||
fontSize: '0.72rem',
|
|
||||||
color: 'text.tertiary',
|
|
||||||
minHeight: 0,
|
|
||||||
flexShrink: 0,
|
|
||||||
}}
|
|
||||||
onClick={() => {
|
|
||||||
setLocalSelections(defaultSelections())
|
|
||||||
onClearAllFilters?.()
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Clear all
|
|
||||||
</Button>
|
|
||||||
</Box>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ── Unified Filter bottom sheet ─────────────────────────────────────── */}
|
{/* ── Unified Filter bottom sheet ─────────────────────────────────────── */}
|
||||||
<BottomSheetModal
|
<BottomSheetModal
|
||||||
open={filterSheetOpen}
|
open={filterSheetOpen}
|
||||||
onClose={() => setFilterSheetOpen(false)}
|
onClose={() => {
|
||||||
|
setSaveMenuAnchorEl(null)
|
||||||
|
setFilterSheetOpen(false)
|
||||||
|
}}
|
||||||
maxHeight='92vh'
|
maxHeight='92vh'
|
||||||
title={
|
title={
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||||
@@ -557,6 +682,7 @@ const ChoreToolbar = ({
|
|||||||
disabled={!hasAnyActive && activeConditions.length === 0}
|
disabled={!hasAnyActive && activeConditions.length === 0}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setLocalSelections(defaultSelections())
|
setLocalSelections(defaultSelections())
|
||||||
|
setSaveMenuAnchorEl(null)
|
||||||
onClearAllFilters?.()
|
onClearAllFilters?.()
|
||||||
setFilterSheetOpen(false)
|
setFilterSheetOpen(false)
|
||||||
}}
|
}}
|
||||||
@@ -568,7 +694,10 @@ const ChoreToolbar = ({
|
|||||||
<>
|
<>
|
||||||
<ButtonGroup variant='solid' color='primary'>
|
<ButtonGroup variant='solid' color='primary'>
|
||||||
<Button
|
<Button
|
||||||
onClick={() => setFilterSheetOpen(false)}
|
onClick={() => {
|
||||||
|
setSaveMenuAnchorEl(null)
|
||||||
|
setFilterSheetOpen(false)
|
||||||
|
}}
|
||||||
sx={{ minWidth: 140 }}
|
sx={{ minWidth: 140 }}
|
||||||
>
|
>
|
||||||
{resultCount != null
|
{resultCount != null
|
||||||
@@ -590,14 +719,26 @@ const ChoreToolbar = ({
|
|||||||
placement='top-end'
|
placement='top-end'
|
||||||
sx={{ zIndex: Z_INDEX.MODAL_CONTENT + 10 }}
|
sx={{ zIndex: Z_INDEX.MODAL_CONTENT + 10 }}
|
||||||
>
|
>
|
||||||
|
<MenuItem
|
||||||
|
onClick={handleUpdateFilter}
|
||||||
|
disabled={!editingSavedFilter}
|
||||||
|
>
|
||||||
|
<Save sx={{ fontSize: 16, mr: 1 }} />
|
||||||
|
Save Filter
|
||||||
|
</MenuItem>
|
||||||
<MenuItem
|
<MenuItem
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setSaveMenuAnchorEl(null)
|
setSaveMenuAnchorEl(null)
|
||||||
|
setSaveFilterName(
|
||||||
|
editingSavedFilter
|
||||||
|
? `${editingSavedFilter.name} Copy`
|
||||||
|
: '',
|
||||||
|
)
|
||||||
setSavingFilter(true)
|
setSavingFilter(true)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Save sx={{ fontSize: 16, mr: 1 }} />
|
<Save sx={{ fontSize: 16, mr: 1 }} />
|
||||||
Save as Filter
|
Save as New Filter
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
</Menu>
|
</Menu>
|
||||||
</>
|
</>
|
||||||
@@ -605,7 +746,10 @@ const ChoreToolbar = ({
|
|||||||
<Button
|
<Button
|
||||||
variant='solid'
|
variant='solid'
|
||||||
color='primary'
|
color='primary'
|
||||||
onClick={() => setFilterSheetOpen(false)}
|
onClick={() => {
|
||||||
|
setSaveMenuAnchorEl(null)
|
||||||
|
setFilterSheetOpen(false)
|
||||||
|
}}
|
||||||
sx={{ minWidth: 140 }}
|
sx={{ minWidth: 140 }}
|
||||||
>
|
>
|
||||||
Done
|
Done
|
||||||
@@ -767,26 +911,6 @@ const ChoreToolbar = ({
|
|||||||
onToggle={v => onAssigneeFilterChange?.(v)}
|
onToggle={v => onAssigneeFilterChange?.(v)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Project section — only when no advanced filter is active */}
|
|
||||||
{showProjectInDisplay && (
|
|
||||||
<>
|
|
||||||
<Divider sx={{ my: 2.5 }} />
|
|
||||||
<SectionHeader
|
|
||||||
icon={<FolderOpen />}
|
|
||||||
label='Project'
|
|
||||||
badge={projectActive ? selectedProject.name : null}
|
|
||||||
/>
|
|
||||||
<OptionChips
|
|
||||||
options={projects.map(p => ({ value: p.id, label: p.name }))}
|
|
||||||
selected={selectedProject?.id ?? 'default'}
|
|
||||||
multi={false}
|
|
||||||
onToggle={id => {
|
|
||||||
const p = projects.find(x => x.id === id)
|
|
||||||
if (p) onProjectSelect?.(p)
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Box>
|
</Box>
|
||||||
</BottomSheetModal>
|
</BottomSheetModal>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -90,6 +90,8 @@ export const useCustomFilters = (chores, membersData, labels, projects) => {
|
|||||||
}, [chores, activeFilter, tempFilter, context])
|
}, [chores, activeFilter, tempFilter, context])
|
||||||
|
|
||||||
const applyCustomFilter = useCallback(filterId => {
|
const applyCustomFilter = useCallback(filterId => {
|
||||||
|
setTempFilter(null)
|
||||||
|
setTempFilterMeta(null)
|
||||||
setActiveFilterId(filterId)
|
setActiveFilterId(filterId)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
|||||||
@@ -2,14 +2,18 @@ import { Cell, Pie, PieChart, Tooltip } from 'recharts'
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
AccessTime,
|
AccessTime,
|
||||||
|
CalendarMonth,
|
||||||
Check,
|
Check,
|
||||||
|
Checklist,
|
||||||
EventBusy,
|
EventBusy,
|
||||||
EventNote,
|
EventNote,
|
||||||
Group,
|
Group,
|
||||||
HourglassEmpty,
|
HourglassEmpty,
|
||||||
|
Person,
|
||||||
Redo,
|
Redo,
|
||||||
RunningWithErrors,
|
RunningWithErrors,
|
||||||
Schedule,
|
Schedule,
|
||||||
|
Style,
|
||||||
ThumbDown,
|
ThumbDown,
|
||||||
Timeline,
|
Timeline,
|
||||||
Toll,
|
Toll,
|
||||||
@@ -24,23 +28,20 @@ import {
|
|||||||
Divider,
|
Divider,
|
||||||
Grid,
|
Grid,
|
||||||
Link,
|
Link,
|
||||||
Option,
|
|
||||||
Select,
|
|
||||||
Stack,
|
Stack,
|
||||||
Tab,
|
|
||||||
TabList,
|
|
||||||
Tabs,
|
|
||||||
Typography,
|
Typography,
|
||||||
} from '@mui/joy'
|
} from '@mui/joy'
|
||||||
import React, { useEffect, useState } from 'react'
|
import React, { useEffect, useMemo, useState } from 'react'
|
||||||
|
import FilterBar from '../../components/common/FilterBar'
|
||||||
|
import { useFilter } from '../../hooks/useFilter'
|
||||||
|
|
||||||
import { useLocalization } from '../../contexts/LocalizationContext'
|
import { useLocalization } from '../../contexts/LocalizationContext'
|
||||||
import { useChores, useChoresHistory } from '../../queries/ChoreQueries'
|
import { useChores, useChoresHistory } from '../../queries/ChoreQueries'
|
||||||
import NoteViewerModal from '../Modals/Inputs/NoteViewerModal'
|
import NoteViewerModal from '../Modals/Inputs/NoteViewerModal'
|
||||||
import { useCircleMembers, useUserProfile } from '../../queries/UserQueries.jsx'
|
import { useCircleMembers, useUserProfile } from '../../queries/UserQueries.jsx'
|
||||||
|
import { useLabels } from '../Labels/LabelQueries'
|
||||||
import { ChoresGrouper } from '../../utils/Chores'
|
import { ChoresGrouper } from '../../utils/Chores'
|
||||||
import { COLORS, TASK_COLOR } from '../../utils/Colors.jsx'
|
import { COLORS, TASK_COLOR } from '../../utils/Colors.jsx'
|
||||||
import { resolvePhotoURL } from '../../utils/Helpers.jsx'
|
|
||||||
import LoadingComponent from '../components/Loading'
|
import LoadingComponent from '../components/Loading'
|
||||||
|
|
||||||
const groupByDate = history => {
|
const groupByDate = history => {
|
||||||
@@ -416,6 +417,7 @@ const UserActivites = () => {
|
|||||||
choresAssigneeBreakdownChartData,
|
choresAssigneeBreakdownChartData,
|
||||||
setChoresAssigneeBreakdownChartData,
|
setChoresAssigneeBreakdownChartData,
|
||||||
] = React.useState([])
|
] = React.useState([])
|
||||||
|
const { data: userLabels } = useLabels()
|
||||||
const { data: choresData, isLoading: isChoresLoading } = useChores(true)
|
const { data: choresData, isLoading: isChoresLoading } = useChores(true)
|
||||||
const {
|
const {
|
||||||
data: choresHistory,
|
data: choresHistory,
|
||||||
@@ -432,6 +434,142 @@ const UserActivites = () => {
|
|||||||
}
|
}
|
||||||
}, [circleMembersData])
|
}, [circleMembersData])
|
||||||
|
|
||||||
|
// Client-side filters applied on top of the user+time-window slice
|
||||||
|
const clientFilterDefs = useMemo(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
id: 'status',
|
||||||
|
label: 'Status',
|
||||||
|
type: 'multi-select',
|
||||||
|
icon: <Checklist />,
|
||||||
|
options: [
|
||||||
|
{ value: 1, label: 'Completed', color: 'success', icon: <Check sx={{ fontSize: 14 }} /> },
|
||||||
|
{ value: 2, label: 'Skipped', color: 'warning', icon: <Redo sx={{ fontSize: 14 }} /> },
|
||||||
|
{ value: 3, label: 'Pending', color: 'neutral', icon: <HourglassEmpty sx={{ fontSize: 14 }} /> },
|
||||||
|
{ value: 4, label: 'Rejected', color: 'danger', icon: <ThumbDown sx={{ fontSize: 14 }} /> },
|
||||||
|
{ value: 5, label: 'Missed', color: 'danger', icon: <RunningWithErrors sx={{ fontSize: 14 }} /> },
|
||||||
|
{ value: 6, label: 'Rescheduled', color: 'warning', icon: <Schedule sx={{ fontSize: 14 }} /> },
|
||||||
|
],
|
||||||
|
filterFn: (item, values) => values.includes(item.status),
|
||||||
|
},
|
||||||
|
...(userLabels?.length > 0
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
id: 'label',
|
||||||
|
label: 'Labels',
|
||||||
|
type: 'multi-select',
|
||||||
|
icon: <Style />,
|
||||||
|
options: userLabels.map(l => ({
|
||||||
|
value: l.id,
|
||||||
|
label: l.name,
|
||||||
|
icon: (
|
||||||
|
<Box
|
||||||
|
component='span'
|
||||||
|
sx={{
|
||||||
|
display: 'inline-block',
|
||||||
|
width: 10,
|
||||||
|
height: 10,
|
||||||
|
borderRadius: '50%',
|
||||||
|
bgcolor: l.color || '#90a4ae',
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
})),
|
||||||
|
filterFn: (item, values) =>
|
||||||
|
item.labelsV2?.some(l => values.includes(l.id)) ?? false,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
{
|
||||||
|
id: 'hasNotes',
|
||||||
|
label: 'Has Notes',
|
||||||
|
type: 'boolean',
|
||||||
|
icon: <EventNote />,
|
||||||
|
filterFn: item => !!item.notes,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'hasPoints',
|
||||||
|
label: 'Has Points',
|
||||||
|
type: 'boolean',
|
||||||
|
icon: <Toll />,
|
||||||
|
filterFn: item => (item.points ?? 0) > 0,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[userLabels],
|
||||||
|
)
|
||||||
|
|
||||||
|
const {
|
||||||
|
filteredData: filteredTimeline,
|
||||||
|
activeFilters: clientActiveFilters,
|
||||||
|
setFilter: setClientFilter,
|
||||||
|
clearAll: clearClientFilters,
|
||||||
|
} = useFilter(selectedHistory, clientFilterDefs)
|
||||||
|
|
||||||
|
// All filter defs merged for FilterBar display
|
||||||
|
const filterDefs = useMemo(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
id: 'timePeriod',
|
||||||
|
label: 'Time Period',
|
||||||
|
type: 'single-select',
|
||||||
|
icon: <CalendarMonth />,
|
||||||
|
defaultValue: 7,
|
||||||
|
options: [
|
||||||
|
{ value: 7, label: '7 Days' },
|
||||||
|
{ value: 30, label: '30 Days' },
|
||||||
|
{ value: 90, label: '90 Days' },
|
||||||
|
{ value: 365, label: 'All Time' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'completedBy',
|
||||||
|
label: 'User',
|
||||||
|
type: 'single-select',
|
||||||
|
icon: <Person />,
|
||||||
|
options: circleUsers.map(u => ({
|
||||||
|
value: u.userId,
|
||||||
|
label: u.displayName,
|
||||||
|
avatar: u.image,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
...clientFilterDefs,
|
||||||
|
],
|
||||||
|
[circleUsers, clientFilterDefs],
|
||||||
|
)
|
||||||
|
|
||||||
|
// Merge server-driven and client-driven active filter states for the bar
|
||||||
|
const activeFilters = useMemo(
|
||||||
|
() => ({
|
||||||
|
timePeriod: tabValue,
|
||||||
|
...(selectedUser !== 'all' ? { completedBy: selectedUser } : {}),
|
||||||
|
...clientActiveFilters,
|
||||||
|
}),
|
||||||
|
[tabValue, selectedUser, clientActiveFilters],
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleSetFilter = (id, value) => {
|
||||||
|
if (id === 'completedBy') {
|
||||||
|
const userId = value ?? 'all'
|
||||||
|
setSelectedUser(userId)
|
||||||
|
setSelectedHistory(enrichedHistory.filter(h => USER_FILTER(h, userId)))
|
||||||
|
} else if (id === 'timePeriod') {
|
||||||
|
const days = value ?? 7
|
||||||
|
setTabValue(days)
|
||||||
|
refetchHistory(days)
|
||||||
|
} else {
|
||||||
|
setClientFilter(id, value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleClearAll = () => {
|
||||||
|
setSelectedUser('all')
|
||||||
|
setSelectedHistory(enrichedHistory)
|
||||||
|
setTabValue(7)
|
||||||
|
refetchHistory(7)
|
||||||
|
clearClientFilters()
|
||||||
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (
|
if (
|
||||||
!isChoresHistoryLoading &&
|
!isChoresHistoryLoading &&
|
||||||
@@ -444,6 +582,7 @@ const UserActivites = () => {
|
|||||||
return {
|
return {
|
||||||
...item,
|
...item,
|
||||||
choreName: chore?.name,
|
choreName: chore?.name,
|
||||||
|
labelsV2: chore?.labelsV2,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
setEnrichedHistory(enrichedHistory)
|
setEnrichedHistory(enrichedHistory)
|
||||||
@@ -837,207 +976,14 @@ const UserActivites = () => {
|
|||||||
</Stack>
|
</Stack>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* Filter Controls - Always visible */}
|
<FilterBar
|
||||||
<Card
|
filterDefs={filterDefs}
|
||||||
variant='outlined'
|
activeFilters={activeFilters}
|
||||||
sx={{
|
onSetFilter={handleSetFilter}
|
||||||
width: '100%',
|
onClearAll={handleClearAll}
|
||||||
p: 2,
|
resultCount={filteredTimeline.length}
|
||||||
mb: 3,
|
totalCount={selectedHistory.length}
|
||||||
borderRadius: 12,
|
/>
|
||||||
background:
|
|
||||||
'linear-gradient(135deg, rgba(255,255,255,0.1) 0%, rgba(255,255,255,0.05) 100%)',
|
|
||||||
backdropFilter: 'blur(10px)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Stack spacing={2}>
|
|
||||||
<Typography level='title-sm' sx={{ color: 'text.secondary' }}>
|
|
||||||
Filter Activities
|
|
||||||
</Typography>
|
|
||||||
|
|
||||||
<Stack
|
|
||||||
direction={{ xs: 'column', sm: 'row' }}
|
|
||||||
spacing={2}
|
|
||||||
alignItems={{ xs: 'stretch', sm: 'center' }}
|
|
||||||
>
|
|
||||||
{/* User Filter */}
|
|
||||||
<Box sx={{ flex: 1, minWidth: 200 }}>
|
|
||||||
<Typography level='body-sm' sx={{ mb: 1, fontWeight: 500 }}>
|
|
||||||
Show activities for:
|
|
||||||
</Typography>
|
|
||||||
<Select
|
|
||||||
sx={{
|
|
||||||
width: '100%',
|
|
||||||
}}
|
|
||||||
variant='outlined'
|
|
||||||
value={selectedUser}
|
|
||||||
onChange={(e, selected) => {
|
|
||||||
setSelectedUser(selected)
|
|
||||||
setSelectedHistory(
|
|
||||||
enrichedHistory.filter(h => USER_FILTER(h, selected)),
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
renderValue={() => {
|
|
||||||
if (selectedUser === undefined || selectedUser === 'all') {
|
|
||||||
return (
|
|
||||||
<Typography
|
|
||||||
startDecorator={
|
|
||||||
<Avatar color='primary' size='sm'>
|
|
||||||
<Group />
|
|
||||||
</Avatar>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
All Users
|
|
||||||
</Typography>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<Typography
|
|
||||||
startDecorator={
|
|
||||||
<Avatar
|
|
||||||
color='primary'
|
|
||||||
size='sm'
|
|
||||||
src={resolvePhotoURL(
|
|
||||||
circleUsers.find(
|
|
||||||
user => user.userId === selectedUser,
|
|
||||||
)?.image,
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{circleUsers
|
|
||||||
.find(user => user.userId === selectedUser)
|
|
||||||
?.displayName?.charAt(0)}
|
|
||||||
</Avatar>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{
|
|
||||||
circleUsers.find(user => user.userId === selectedUser)
|
|
||||||
?.displayName
|
|
||||||
}
|
|
||||||
</Typography>
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Option value='all'>
|
|
||||||
<Typography
|
|
||||||
startDecorator={
|
|
||||||
<Avatar color='primary' size='sm'>
|
|
||||||
<Group />
|
|
||||||
</Avatar>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
All Users
|
|
||||||
</Typography>
|
|
||||||
</Option>
|
|
||||||
{circleUsers.map(user => (
|
|
||||||
<Option key={user.userId} value={user.userId}>
|
|
||||||
<Avatar
|
|
||||||
color='primary'
|
|
||||||
size='sm'
|
|
||||||
src={resolvePhotoURL(user.image)}
|
|
||||||
>
|
|
||||||
{user.displayName?.charAt(0)}
|
|
||||||
</Avatar>
|
|
||||||
<Typography>{user.displayName}</Typography>
|
|
||||||
<Chip
|
|
||||||
color='success'
|
|
||||||
size='sm'
|
|
||||||
variant='soft'
|
|
||||||
startDecorator={<Toll />}
|
|
||||||
>
|
|
||||||
{user.points - user.pointsRedeemed}
|
|
||||||
</Chip>
|
|
||||||
</Option>
|
|
||||||
))}
|
|
||||||
</Select>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* Time Period Filter */}
|
|
||||||
<Box sx={{ flex: 1, minWidth: 200 }}>
|
|
||||||
<Typography level='body-sm' sx={{ mb: 1, fontWeight: 500 }}>
|
|
||||||
Time period:
|
|
||||||
</Typography>
|
|
||||||
<Tabs
|
|
||||||
onChange={(e, tabValue) => {
|
|
||||||
setTabValue(tabValue)
|
|
||||||
refetchHistory(tabValue)
|
|
||||||
}}
|
|
||||||
value={tabValue}
|
|
||||||
sx={{
|
|
||||||
borderRadius: 8,
|
|
||||||
backgroundColor: 'background.surface',
|
|
||||||
border: '1px solid',
|
|
||||||
borderColor: 'divider',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<TabList
|
|
||||||
disableUnderline
|
|
||||||
sx={{
|
|
||||||
borderRadius: 8,
|
|
||||||
backgroundColor: 'transparent',
|
|
||||||
p: 0.5,
|
|
||||||
gap: 0.5,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{[
|
|
||||||
{ label: '7 Days', value: 7 },
|
|
||||||
{ label: '30 Days', value: 30 },
|
|
||||||
{ label: '90 Days', value: 90 },
|
|
||||||
{ label: 'All Time', value: 365 },
|
|
||||||
].map((tab, index) => (
|
|
||||||
<Tab
|
|
||||||
key={index}
|
|
||||||
sx={{
|
|
||||||
borderRadius: 6,
|
|
||||||
minWidth: 'auto',
|
|
||||||
px: 2,
|
|
||||||
py: 1,
|
|
||||||
fontSize: 'sm',
|
|
||||||
fontWeight: 500,
|
|
||||||
color: 'text.secondary',
|
|
||||||
'&.Mui-selected': {
|
|
||||||
color: 'primary.plainColor',
|
|
||||||
backgroundColor: 'primary.softBg',
|
|
||||||
fontWeight: 600,
|
|
||||||
},
|
|
||||||
'&:hover': {
|
|
||||||
backgroundColor: 'neutral.softHoverBg',
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
disableIndicator
|
|
||||||
value={tab.value}
|
|
||||||
>
|
|
||||||
{tab.label}
|
|
||||||
</Tab>
|
|
||||||
))}
|
|
||||||
</TabList>
|
|
||||||
</Tabs>
|
|
||||||
</Box>
|
|
||||||
</Stack>
|
|
||||||
</Stack>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Current Filter Summary */}
|
|
||||||
<Box sx={{ mb: 3, textAlign: 'center' }}>
|
|
||||||
<Typography level='body-sm' sx={{ color: 'text.secondary' }}>
|
|
||||||
Showing activities for{' '}
|
|
||||||
<Typography
|
|
||||||
component='span'
|
|
||||||
sx={{ fontWeight: 600, color: 'primary.500' }}
|
|
||||||
>
|
|
||||||
{selectedUser === undefined || selectedUser === 'all'
|
|
||||||
? 'All Users'
|
|
||||||
: circleUsers.find(user => user.userId === selectedUser)
|
|
||||||
?.displayName || 'Unknown User'}
|
|
||||||
</Typography>{' '}
|
|
||||||
over the{' '}
|
|
||||||
<Typography
|
|
||||||
component='span'
|
|
||||||
sx={{ fontWeight: 600, color: 'primary.500' }}
|
|
||||||
>
|
|
||||||
{tabValue === 365 ? 'All Time' : `Last ${tabValue} Days`}
|
|
||||||
</Typography>
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* Conditional Content Based on Data Availability */}
|
{/* Conditional Content Based on Data Availability */}
|
||||||
{!choresData.res?.length > 0 || !choresHistory?.length > 0 ? (
|
{!choresData.res?.length > 0 || !choresHistory?.length > 0 ? (
|
||||||
@@ -1103,7 +1049,7 @@ const UserActivites = () => {
|
|||||||
{/* Left Side - Timeline (Mobile: Full width, Desktop: Flexible) */}
|
{/* Left Side - Timeline (Mobile: Full width, Desktop: Flexible) */}
|
||||||
<Box sx={{ flex: 1, minWidth: 0, width: '100%' }}>
|
<Box sx={{ flex: 1, minWidth: 0, width: '100%' }}>
|
||||||
<ChoreHistoryTimeline
|
<ChoreHistoryTimeline
|
||||||
history={selectedHistory}
|
history={filteredTimeline}
|
||||||
onViewNote={notes => {
|
onViewNote={notes => {
|
||||||
setNoteViewerConfig({
|
setNoteViewerConfig({
|
||||||
isOpen: true,
|
isOpen: true,
|
||||||
|
|||||||
Reference in New Issue
Block a user