feat: enhance AdvancedFilterBuilder with new filtering options and UI improvements
- Introduced new filter options for due dates, points, and chore statuses. - Refactored condition management to use selections for better state handling. - Improved UI components for filter conditions, including chips for selection. - Added a bottom sheet modal for filter creation and editing. - Enhanced user experience with clear actions and previews for selected filters.
This commit is contained in:
502
src/components/common/FilterBar.jsx
Normal file
502
src/components/common/FilterBar.jsx
Normal file
@@ -0,0 +1,502 @@
|
||||
import { Check, Close, FilterList, Tune } from '@mui/icons-material'
|
||||
import {
|
||||
Avatar,
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Chip,
|
||||
Divider,
|
||||
Input,
|
||||
Typography,
|
||||
} from '@mui/joy'
|
||||
import { useState } from 'react'
|
||||
import BottomSheetModal from './BottomSheetModal'
|
||||
|
||||
/**
|
||||
* Reusable filter bar component.
|
||||
*
|
||||
* Props:
|
||||
* filterDefs - array of filter definitions:
|
||||
* { id, label, type ('multi-select'|'single-select'|'boolean'|'date-range'),
|
||||
* icon, options?, defaultValue?, filterFn }
|
||||
* options item: { value, label, color?, icon?, avatar? }
|
||||
* defaultValue: if the active value equals this, no chip is shown
|
||||
* date-range value shape: { preset?, from?: ISO string, to?: ISO string }
|
||||
* activeFilters - current filter state object { [id]: value }
|
||||
* onSetFilter - (filterId, value | null) => void
|
||||
* onClearAll - () => void
|
||||
* resultCount - optional number shown in "Show N results" button
|
||||
* totalCount - optional total for "N of M" label
|
||||
*/
|
||||
|
||||
// ── Date range presets (no moment dependency — pure Date) ────────────────────
|
||||
|
||||
const d = (date, h = 0, m = 0, s = 0, ms = 0) =>
|
||||
new Date(date.getFullYear(), date.getMonth(), date.getDate(), h, m, s, ms)
|
||||
|
||||
const DATE_RANGE_PRESETS = [
|
||||
{
|
||||
value: 'today',
|
||||
label: 'Today',
|
||||
getRange: () => {
|
||||
const t = d(new Date())
|
||||
return { from: t.toISOString(), to: d(new Date(), 23, 59, 59, 999).toISOString() }
|
||||
},
|
||||
},
|
||||
{
|
||||
value: 'yesterday',
|
||||
label: 'Yesterday',
|
||||
getRange: () => {
|
||||
const t = d(new Date())
|
||||
const y = new Date(t); y.setDate(t.getDate() - 1)
|
||||
return { from: d(y).toISOString(), to: d(y, 23, 59, 59, 999).toISOString() }
|
||||
},
|
||||
},
|
||||
{
|
||||
value: 'this-week',
|
||||
label: 'This Week',
|
||||
getRange: () => {
|
||||
const t = d(new Date())
|
||||
const start = new Date(t); start.setDate(t.getDate() - t.getDay())
|
||||
const end = new Date(start); end.setDate(start.getDate() + 6)
|
||||
return { from: d(start).toISOString(), to: d(end, 23, 59, 59, 999).toISOString() }
|
||||
},
|
||||
},
|
||||
{
|
||||
value: 'last-7-days',
|
||||
label: 'Last 7 Days',
|
||||
getRange: () => {
|
||||
const t = d(new Date())
|
||||
const start = new Date(t); start.setDate(t.getDate() - 6)
|
||||
return { from: d(start).toISOString(), to: d(new Date(), 23, 59, 59, 999).toISOString() }
|
||||
},
|
||||
},
|
||||
{
|
||||
value: 'this-month',
|
||||
label: 'This Month',
|
||||
getRange: () => {
|
||||
const n = new Date()
|
||||
const start = new Date(n.getFullYear(), n.getMonth(), 1)
|
||||
const end = new Date(n.getFullYear(), n.getMonth() + 1, 0)
|
||||
return { from: start.toISOString(), to: d(end, 23, 59, 59, 999).toISOString() }
|
||||
},
|
||||
},
|
||||
{
|
||||
value: 'last-30-days',
|
||||
label: 'Last 30 Days',
|
||||
getRange: () => {
|
||||
const t = d(new Date())
|
||||
const start = new Date(t); start.setDate(t.getDate() - 29)
|
||||
return { from: d(start).toISOString(), to: d(new Date(), 23, 59, 59, 999).toISOString() }
|
||||
},
|
||||
},
|
||||
{
|
||||
value: 'last-3-months',
|
||||
label: 'Last 3 Months',
|
||||
getRange: () => {
|
||||
const t = d(new Date())
|
||||
const start = new Date(t); start.setMonth(t.getMonth() - 3)
|
||||
return { from: d(start).toISOString(), to: d(new Date(), 23, 59, 59, 999).toISOString() }
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
const toInputDate = iso => (iso ? iso.split('T')[0] : '')
|
||||
|
||||
const fmtDisplayDate = iso => {
|
||||
if (!iso) return null
|
||||
const dt = new Date(iso)
|
||||
return dt.toLocaleDateString(undefined, { month: 'short', day: 'numeric' })
|
||||
}
|
||||
|
||||
// ── Component ────────────────────────────────────────────────────────────────
|
||||
|
||||
const FilterBar = ({
|
||||
filterDefs,
|
||||
activeFilters,
|
||||
onSetFilter,
|
||||
onClearAll,
|
||||
resultCount,
|
||||
totalCount,
|
||||
}) => {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
|
||||
// ── Active count ───────────────────────────────────────────────────────────
|
||||
|
||||
const activeFilterCount = filterDefs.filter(def => {
|
||||
const value = activeFilters[def.id]
|
||||
if (value === undefined || value === null) return false
|
||||
if (def.defaultValue !== undefined && value === def.defaultValue) return false
|
||||
if (Array.isArray(value) && value.length === 0) return false
|
||||
if (def.type === 'date-range') return !!(value?.from || value?.to)
|
||||
return true
|
||||
}).length
|
||||
|
||||
const hasActive = activeFilterCount > 0
|
||||
|
||||
// ── Chip labels for inline bar ─────────────────────────────────────────────
|
||||
|
||||
const getActiveChipLabel = def => {
|
||||
const value = activeFilters[def.id]
|
||||
if (value === undefined || value === null) return null
|
||||
|
||||
if (def.type === 'single-select') {
|
||||
if (def.defaultValue !== undefined && value === def.defaultValue) return null
|
||||
return def.options?.find(o => o.value === value)?.label ?? def.label
|
||||
}
|
||||
|
||||
if (def.type === 'boolean') return def.label
|
||||
|
||||
if (def.type === 'multi-select' && Array.isArray(value) && value.length > 0) {
|
||||
if (value.length === 1) {
|
||||
return def.options?.find(o => o.value === value[0])?.label ?? def.label
|
||||
}
|
||||
return `${def.label} (${value.length})`
|
||||
}
|
||||
|
||||
if (def.type === 'date-range') {
|
||||
if (!value?.from && !value?.to) return null
|
||||
if (value.preset) {
|
||||
return DATE_RANGE_PRESETS.find(p => p.value === value.preset)?.label ?? 'Date Range'
|
||||
}
|
||||
const from = fmtDisplayDate(value.from)
|
||||
const to = fmtDisplayDate(value.to)
|
||||
if (from && to) return `${from} – ${to}`
|
||||
if (from) return `From ${from}`
|
||||
if (to) return `Until ${to}`
|
||||
return null
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
// ── Handlers ───────────────────────────────────────────────────────────────
|
||||
|
||||
const handleMultiToggle = (defId, optValue) => {
|
||||
const current = activeFilters[defId] || []
|
||||
const next = current.includes(optValue)
|
||||
? current.filter(v => v !== optValue)
|
||||
: [...current, optValue]
|
||||
onSetFilter(defId, next.length > 0 ? next : null)
|
||||
}
|
||||
|
||||
const handleSingleToggle = (defId, optValue) => {
|
||||
onSetFilter(defId, activeFilters[defId] === optValue ? null : optValue)
|
||||
}
|
||||
|
||||
const handleBoolToggle = defId => {
|
||||
onSetFilter(defId, activeFilters[defId] ? null : true)
|
||||
}
|
||||
|
||||
const handleDateRangePreset = (defId, presetValue) => {
|
||||
const current = activeFilters[defId] || {}
|
||||
if (current.preset === presetValue) {
|
||||
onSetFilter(defId, null)
|
||||
return
|
||||
}
|
||||
const preset = DATE_RANGE_PRESETS.find(p => p.value === presetValue)
|
||||
onSetFilter(defId, { preset: presetValue, ...preset.getRange() })
|
||||
}
|
||||
|
||||
const handleDateRangeInput = (defId, field, dateStr) => {
|
||||
const current = activeFilters[defId] || {}
|
||||
if (!dateStr) {
|
||||
const next = { ...current, preset: null, [field]: null }
|
||||
onSetFilter(defId, next.from || next.to ? next : null)
|
||||
} else {
|
||||
const iso =
|
||||
field === 'to'
|
||||
? new Date(dateStr + 'T23:59:59').toISOString()
|
||||
: new Date(dateStr + 'T00:00:00').toISOString()
|
||||
onSetFilter(defId, { ...current, preset: null, [field]: iso })
|
||||
}
|
||||
}
|
||||
|
||||
// ── Render ─────────────────────────────────────────────────────────────────
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* ── Inline bar ─────────────────────────────────────── */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flexWrap: 'wrap', mb: 2 }}>
|
||||
<Badge
|
||||
badgeContent={activeFilterCount || null}
|
||||
color='primary'
|
||||
size='sm'
|
||||
anchorOrigin={{ vertical: 'top', horizontal: 'right' }}
|
||||
>
|
||||
<Button
|
||||
size='sm'
|
||||
variant={hasActive ? 'solid' : 'outlined'}
|
||||
color={hasActive ? 'primary' : 'neutral'}
|
||||
startDecorator={<FilterList sx={{ fontSize: 16 }} />}
|
||||
onClick={() => setIsOpen(true)}
|
||||
sx={{ borderRadius: 'xl', gap: 0.5 }}
|
||||
>
|
||||
Filters
|
||||
</Button>
|
||||
</Badge>
|
||||
|
||||
{(() => {
|
||||
const activeChips = filterDefs
|
||||
.map(def => ({ def, label: getActiveChipLabel(def) }))
|
||||
.filter(({ label }) => !!label)
|
||||
const MAX_VISIBLE = 2
|
||||
const visible = activeChips.slice(0, MAX_VISIBLE)
|
||||
const overflow = activeChips.length - MAX_VISIBLE
|
||||
|
||||
return (
|
||||
<>
|
||||
{visible.map(({ def, label }) => (
|
||||
<Chip
|
||||
key={def.id}
|
||||
size='md'
|
||||
variant='soft'
|
||||
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>
|
||||
|
||||
{/* ── Bottom sheet ────────────────────────────────────── */}
|
||||
<BottomSheetModal
|
||||
open={isOpen}
|
||||
onClose={() => setIsOpen(false)}
|
||||
title={
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Tune sx={{ fontSize: 20 }} />
|
||||
Filters
|
||||
{hasActive && (
|
||||
<Chip size='sm' variant='solid' color='primary' sx={{ ml: 0.5 }}>
|
||||
{activeFilterCount}
|
||||
</Chip>
|
||||
)}
|
||||
</Box>
|
||||
}
|
||||
footer={
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 1 }}>
|
||||
<Button
|
||||
variant='plain'
|
||||
color='danger'
|
||||
size='sm'
|
||||
disabled={!hasActive}
|
||||
onClick={onClearAll}
|
||||
>
|
||||
Clear all
|
||||
</Button>
|
||||
<Button onClick={() => setIsOpen(false)} sx={{ minWidth: 140 }}>
|
||||
{resultCount !== undefined
|
||||
? `Show ${resultCount} result${resultCount !== 1 ? 's' : ''}`
|
||||
: 'Done'}
|
||||
</Button>
|
||||
</Box>
|
||||
}
|
||||
>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0 }}>
|
||||
{filterDefs.map((def, idx) => (
|
||||
<Box key={def.id}>
|
||||
{idx > 0 && <Divider sx={{ my: 2.5 }} />}
|
||||
|
||||
{/* Section header */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}>
|
||||
{def.icon && (
|
||||
<Box sx={{ color: 'text.secondary', display: 'flex', alignItems: 'center', '& svg': { fontSize: 18 } }}>
|
||||
{def.icon}
|
||||
</Box>
|
||||
)}
|
||||
<Typography level='title-sm' sx={{ fontWeight: 600 }}>
|
||||
{def.label}
|
||||
</Typography>
|
||||
|
||||
{/* active badge in header */}
|
||||
{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 }}>
|
||||
{activeFilters[def.id].length} selected
|
||||
</Chip>
|
||||
)}
|
||||
{def.type === 'single-select' && activeFilters[def.id] != null && (() => {
|
||||
const opt = def.options?.find(o => o.value === activeFilters[def.id])
|
||||
return opt ? (
|
||||
<Chip size='sm' variant='solid' color='primary' sx={{ ml: 'auto', fontSize: '0.7rem', height: 20 }}>
|
||||
{opt.label}
|
||||
</Chip>
|
||||
) : null
|
||||
})()}
|
||||
{def.type === 'date-range' && getActiveChipLabel(def) && (
|
||||
<Chip size='sm' variant='solid' color='primary' sx={{ ml: 'auto', fontSize: '0.7rem', height: 20 }}>
|
||||
{getActiveChipLabel(def)}
|
||||
</Chip>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* multi-select */}
|
||||
{def.type === 'multi-select' && (
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
|
||||
{def.options?.map(opt => {
|
||||
const isSelected = (activeFilters[def.id] || []).includes(opt.value)
|
||||
return (
|
||||
<Chip
|
||||
key={opt.value}
|
||||
variant={isSelected ? 'solid' : 'soft'}
|
||||
color={isSelected ? (opt.color ?? 'primary') : 'neutral'}
|
||||
startDecorator={
|
||||
opt.avatar ? (
|
||||
<Avatar src={opt.avatar} alt={opt.label} sx={{ '--Avatar-size': '20px' }} />
|
||||
) : isSelected ? (
|
||||
<Check sx={{ fontSize: 14 }} />
|
||||
) : (opt.icon ?? null)
|
||||
}
|
||||
onClick={() => handleMultiToggle(def.id, opt.value)}
|
||||
sx={{ cursor: 'pointer', transition: 'all 0.15s ease', userSelect: 'none', '&:hover': { opacity: 0.85 } }}
|
||||
>
|
||||
{opt.label}
|
||||
</Chip>
|
||||
)
|
||||
})}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* single-select */}
|
||||
{def.type === 'single-select' && (
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
|
||||
{def.options?.map(opt => {
|
||||
const isSelected = activeFilters[def.id] === opt.value
|
||||
return (
|
||||
<Chip
|
||||
key={opt.value}
|
||||
variant={isSelected ? 'solid' : 'soft'}
|
||||
color={isSelected ? (opt.color ?? 'primary') : 'neutral'}
|
||||
startDecorator={
|
||||
opt.avatar ? (
|
||||
<Avatar src={opt.avatar} alt={opt.label} sx={{ '--Avatar-size': '20px' }} />
|
||||
) : isSelected ? (
|
||||
<Check sx={{ fontSize: 14 }} />
|
||||
) : (opt.icon ?? null)
|
||||
}
|
||||
onClick={() => handleSingleToggle(def.id, opt.value)}
|
||||
sx={{ cursor: 'pointer', transition: 'all 0.15s ease', userSelect: 'none', '&:hover': { opacity: 0.85 } }}
|
||||
>
|
||||
{opt.label}
|
||||
</Chip>
|
||||
)
|
||||
})}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* boolean */}
|
||||
{def.type === 'boolean' && (
|
||||
<Chip
|
||||
variant={activeFilters[def.id] ? 'solid' : 'soft'}
|
||||
color={activeFilters[def.id] ? 'primary' : 'neutral'}
|
||||
startDecorator={activeFilters[def.id] ? <Check sx={{ fontSize: 14 }} /> : null}
|
||||
onClick={() => handleBoolToggle(def.id)}
|
||||
sx={{ cursor: 'pointer', transition: 'all 0.15s ease', userSelect: 'none', '&:hover': { opacity: 0.85 } }}
|
||||
>
|
||||
{def.label}
|
||||
</Chip>
|
||||
)}
|
||||
|
||||
{/* date-range */}
|
||||
{def.type === 'date-range' && (() => {
|
||||
const val = activeFilters[def.id] || {}
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
|
||||
{/* Preset chips */}
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
|
||||
{DATE_RANGE_PRESETS.map(preset => {
|
||||
const isSelected = val.preset === preset.value
|
||||
return (
|
||||
<Chip
|
||||
key={preset.value}
|
||||
variant={isSelected ? 'solid' : 'soft'}
|
||||
color={isSelected ? 'primary' : 'neutral'}
|
||||
startDecorator={isSelected ? <Check sx={{ fontSize: 14 }} /> : null}
|
||||
onClick={() => handleDateRangePreset(def.id, preset.value)}
|
||||
sx={{ cursor: 'pointer', transition: 'all 0.15s ease', userSelect: 'none', '&:hover': { opacity: 0.85 } }}
|
||||
>
|
||||
{preset.label}
|
||||
</Chip>
|
||||
)
|
||||
})}
|
||||
</Box>
|
||||
|
||||
{/* Custom date inputs */}
|
||||
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center' }}>
|
||||
<Input
|
||||
type='date'
|
||||
size='sm'
|
||||
value={toInputDate(val.from)}
|
||||
onChange={e => handleDateRangeInput(def.id, 'from', e.target.value)}
|
||||
slotProps={{ input: { max: toInputDate(val.to) || undefined } }}
|
||||
sx={{ flex: 1, fontSize: '0.8rem' }}
|
||||
/>
|
||||
<Typography level='body-xs' sx={{ color: 'text.tertiary', flexShrink: 0 }}>
|
||||
–
|
||||
</Typography>
|
||||
<Input
|
||||
type='date'
|
||||
size='sm'
|
||||
value={toInputDate(val.to)}
|
||||
onChange={e => handleDateRangeInput(def.id, 'to', e.target.value)}
|
||||
slotProps={{ input: { min: toInputDate(val.from) || undefined } }}
|
||||
sx={{ flex: 1, fontSize: '0.8rem' }}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
})()}
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
</BottomSheetModal>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default FilterBar
|
||||
59
src/hooks/useFilter.js
Normal file
59
src/hooks/useFilter.js
Normal file
@@ -0,0 +1,59 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
|
||||
/**
|
||||
* Generic client-side filter hook.
|
||||
*
|
||||
* @param {Array} data - the full list to filter
|
||||
* @param {Array} filterDefs - array of filter definitions (see FilterBar)
|
||||
* @returns {{ filteredData, activeFilters, setFilter, clearAll, activeFilterCount, hasActiveFilters }}
|
||||
*
|
||||
* Each filterDef must include:
|
||||
* id - unique string key
|
||||
* type - 'multi-select' | 'boolean'
|
||||
* filterFn - (item, filterValue) => boolean
|
||||
*/
|
||||
export const useFilter = (data, filterDefs) => {
|
||||
const [activeFilters, setActiveFilters] = useState({})
|
||||
|
||||
const setFilter = (filterId, value) => {
|
||||
setActiveFilters(prev => {
|
||||
const isEmpty =
|
||||
value === null ||
|
||||
value === undefined ||
|
||||
(Array.isArray(value) && value.length === 0)
|
||||
|
||||
if (isEmpty) {
|
||||
const { [filterId]: _removed, ...rest } = prev
|
||||
return rest
|
||||
}
|
||||
return { ...prev, [filterId]: value }
|
||||
})
|
||||
}
|
||||
|
||||
const clearAll = () => setActiveFilters({})
|
||||
|
||||
const filteredData = useMemo(() => {
|
||||
if (!data) return []
|
||||
if (!Object.keys(activeFilters).length) return data
|
||||
|
||||
return data.filter(item =>
|
||||
filterDefs.every(def => {
|
||||
const value = activeFilters[def.id]
|
||||
if (value === undefined || value === null) return true
|
||||
if (Array.isArray(value) && value.length === 0) return true
|
||||
return def.filterFn(item, value)
|
||||
}),
|
||||
)
|
||||
}, [data, activeFilters, filterDefs])
|
||||
|
||||
const activeFilterCount = Object.keys(activeFilters).length
|
||||
|
||||
return {
|
||||
filteredData,
|
||||
activeFilters,
|
||||
setFilter,
|
||||
clearAll,
|
||||
activeFilterCount,
|
||||
hasActiveFilters: activeFilterCount > 0,
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,9 @@ import {
|
||||
CheckBoxOutlineBlank,
|
||||
Close,
|
||||
Delete,
|
||||
Label,
|
||||
Person,
|
||||
PriorityHigh,
|
||||
SelectAll,
|
||||
Unarchive,
|
||||
ViewAgenda,
|
||||
@@ -21,14 +24,17 @@ import {
|
||||
Typography,
|
||||
} from '@mui/joy'
|
||||
import Fuse from 'fuse.js'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import FilterBar from '../../components/common/FilterBar'
|
||||
import KeyboardShortcutHint from '../../components/common/KeyboardShortcutHint'
|
||||
import { useImpersonateUser } from '../../contexts/ImpersonateUserContext.jsx'
|
||||
import { useFilter } from '../../hooks/useFilter'
|
||||
import { useUnArchiveChore } from '../../queries/ChoreQueries'
|
||||
import { useCircleMembers, useUserProfile } from '../../queries/UserQueries'
|
||||
import { useNotification } from '../../service/NotificationProvider'
|
||||
import { DeleteChore, GetArchivedChores } from '../../utils/Fetcher'
|
||||
import Priorities from '../../utils/Priorities'
|
||||
import LoadingComponent from '../components/Loading'
|
||||
import ConfirmationModal from '../Modals/Inputs/ConfirmationModal'
|
||||
import ChoreCard from './ChoreCard'
|
||||
@@ -61,6 +67,95 @@ const ArchivedTasks = () => {
|
||||
|
||||
const { data: membersData, isLoading: membersLoading } = useCircleMembers()
|
||||
|
||||
// Unique labels present across all archived chores
|
||||
const availableLabels = useMemo(() => {
|
||||
const seen = {}
|
||||
archivedChores.forEach(c => {
|
||||
c.labelsV2?.forEach(l => { seen[l.id] = l })
|
||||
})
|
||||
return Object.values(seen)
|
||||
}, [archivedChores])
|
||||
|
||||
const filterDefs = useMemo(
|
||||
() => [
|
||||
{
|
||||
id: 'assignee',
|
||||
label: 'Assignee',
|
||||
type: 'multi-select',
|
||||
icon: <Person />,
|
||||
options: performers.map(p => ({
|
||||
value: p.userId,
|
||||
label: p.displayName,
|
||||
avatar: p.image,
|
||||
})),
|
||||
filterFn: (item, values) => values.includes(item.assignedTo),
|
||||
},
|
||||
{
|
||||
id: 'priority',
|
||||
label: 'Priority',
|
||||
type: 'multi-select',
|
||||
icon: <PriorityHigh />,
|
||||
options: Priorities.map(p => ({
|
||||
value: p.value,
|
||||
label: p.name,
|
||||
color: p.color || 'neutral',
|
||||
icon: p.icon,
|
||||
})),
|
||||
filterFn: (item, values) => values.includes(item.priority ?? 0),
|
||||
},
|
||||
...(availableLabels.length > 0
|
||||
? [
|
||||
{
|
||||
id: 'label',
|
||||
label: 'Labels',
|
||||
type: 'multi-select',
|
||||
icon: <Label />,
|
||||
options: availableLabels.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: 'archivedAt',
|
||||
label: 'Archived Date',
|
||||
type: 'date-range',
|
||||
icon: <Archive />,
|
||||
filterFn: (item, value) => {
|
||||
const date = new Date(item.updatedAt)
|
||||
if (value.from && date < new Date(value.from)) return false
|
||||
if (value.to && date > new Date(value.to)) return false
|
||||
return true
|
||||
},
|
||||
},
|
||||
],
|
||||
[performers, availableLabels],
|
||||
)
|
||||
|
||||
const {
|
||||
filteredData: finalChores,
|
||||
activeFilters,
|
||||
setFilter,
|
||||
clearAll,
|
||||
hasActiveFilters,
|
||||
} = useFilter(filteredChores, filterDefs)
|
||||
|
||||
useEffect(() => {
|
||||
const loadArchivedChores = async () => {
|
||||
if (!membersLoading && userProfile) {
|
||||
@@ -280,11 +375,8 @@ const ArchivedTasks = () => {
|
||||
}
|
||||
|
||||
const selectAllVisibleChores = () => {
|
||||
const visibleChores =
|
||||
searchTerm?.length > 0 ? filteredChores : archivedChores
|
||||
if (visibleChores.length > 0) {
|
||||
const allIds = new Set(visibleChores.map(chore => chore.id))
|
||||
setSelectedChores(allIds)
|
||||
if (finalChores.length > 0) {
|
||||
setSelectedChores(new Set(finalChores.map(c => c.id)))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -605,6 +697,15 @@ const ArchivedTasks = () => {
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<FilterBar
|
||||
filterDefs={filterDefs}
|
||||
activeFilters={activeFilters}
|
||||
onSetFilter={setFilter}
|
||||
onClearAll={clearAll}
|
||||
resultCount={finalChores.length}
|
||||
totalCount={filteredChores.length}
|
||||
/>
|
||||
|
||||
{/* Multi-select Toolbar */}
|
||||
{isMultiSelectMode && (
|
||||
<Box
|
||||
@@ -677,7 +778,7 @@ const ArchivedTasks = () => {
|
||||
variant='outlined'
|
||||
onClick={selectAllVisibleChores}
|
||||
startDecorator={<SelectAll />}
|
||||
disabled={selectedChores.size === filteredChores.length}
|
||||
disabled={selectedChores.size === finalChores.length}
|
||||
sx={{
|
||||
minWidth: 'auto',
|
||||
'--Button-paddingInline': '0.75rem',
|
||||
@@ -808,7 +909,7 @@ const ArchivedTasks = () => {
|
||||
)}
|
||||
|
||||
{/* Content */}
|
||||
{filteredChores.length === 0 ? (
|
||||
{finalChores.length === 0 ? (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
@@ -818,42 +919,43 @@ const ArchivedTasks = () => {
|
||||
height: '50vh',
|
||||
}}
|
||||
>
|
||||
<Archive
|
||||
sx={{
|
||||
fontSize: '4rem',
|
||||
mb: 1,
|
||||
color: 'text.tertiary',
|
||||
}}
|
||||
/>
|
||||
<Archive sx={{ fontSize: '4rem', mb: 1, color: 'text.tertiary' }} />
|
||||
<Typography level='title-md' gutterBottom>
|
||||
{searchTerm ? 'No archived tasks found' : 'No archived tasks'}
|
||||
{searchTerm || hasActiveFilters
|
||||
? 'No archived tasks found'
|
||||
: 'No archived tasks'}
|
||||
</Typography>
|
||||
<Typography level='body-sm' color='text.secondary' sx={{ mb: 2 }}>
|
||||
{searchTerm
|
||||
? 'Try adjusting your search terms'
|
||||
{searchTerm || hasActiveFilters
|
||||
? 'Try adjusting your search or filters'
|
||||
: 'Archived tasks will appear here when you archive them from the main task list'}
|
||||
</Typography>
|
||||
{searchTerm && (
|
||||
<Button
|
||||
onClick={handleSearchClose}
|
||||
variant='outlined'
|
||||
color='neutral'
|
||||
>
|
||||
Clear search
|
||||
</Button>
|
||||
{(searchTerm || hasActiveFilters) && (
|
||||
<Box sx={{ display: 'flex', gap: 1 }}>
|
||||
{searchTerm && (
|
||||
<Button onClick={handleSearchClose} variant='outlined' color='neutral'>
|
||||
Clear search
|
||||
</Button>
|
||||
)}
|
||||
{hasActiveFilters && (
|
||||
<Button onClick={clearAll} variant='outlined' color='neutral'>
|
||||
Clear filters
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
) : (
|
||||
<Box>
|
||||
<Typography level='body-sm' color='text.secondary' sx={{ mb: 2 }}>
|
||||
{filteredChores.length} archived task
|
||||
{filteredChores.length !== 1 ? 's' : ''}
|
||||
{finalChores.length} archived task
|
||||
{finalChores.length !== 1 ? 's' : ''}
|
||||
{searchTerm && ` matching "${searchTerm}"`}
|
||||
</Typography>
|
||||
|
||||
<List sx={{ gap: viewMode === 'compact' ? 0 : 1 }}>
|
||||
<ChoreListView
|
||||
chores={filteredChores}
|
||||
chores={finalChores}
|
||||
// viewOnly={true}
|
||||
showActions={false}
|
||||
viewMode={viewMode}
|
||||
|
||||
@@ -2,12 +2,10 @@ import {
|
||||
Add,
|
||||
Bolt,
|
||||
CalendarMonth,
|
||||
CancelRounded,
|
||||
CheckBox,
|
||||
CheckBoxOutlineBlank,
|
||||
EditCalendar,
|
||||
ExpandCircleDown,
|
||||
Grain,
|
||||
PriorityHigh,
|
||||
Sort,
|
||||
Style,
|
||||
@@ -24,9 +22,6 @@ import {
|
||||
Container,
|
||||
Divider,
|
||||
IconButton,
|
||||
List,
|
||||
Menu,
|
||||
MenuItem,
|
||||
Typography,
|
||||
} from '@mui/joy'
|
||||
import Fuse from 'fuse.js'
|
||||
@@ -42,7 +37,9 @@ import IconButtonWithMenu from './IconButtonWithMenu'
|
||||
|
||||
import { useMediaQuery } from '@mui/material'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import FilterBar from '../../components/common/FilterBar'
|
||||
import KeyboardShortcutHint from '../../components/common/KeyboardShortcutHint'
|
||||
import { useFilter } from '../../hooks/useFilter'
|
||||
import { useImpersonateUser } from '../../contexts/ImpersonateUserContext.jsx'
|
||||
import { useCircleMembers, useUserProfile } from '../../queries/UserQueries'
|
||||
import {
|
||||
@@ -107,7 +104,6 @@ const MyChores = () => {
|
||||
const [chores, setChores] = useState([])
|
||||
const [filteredChores, setFilteredChores] = useState([])
|
||||
const [choreSections, setChoreSections] = useState([])
|
||||
const [showSearchFilter, setShowSearchFilter] = useState(false)
|
||||
const [addTaskModalOpen, setAddTaskModalOpen] = useState(false)
|
||||
const [taskInputFocus, setTaskInputFocus] = useState(0)
|
||||
const searchInputRef = useRef(null)
|
||||
@@ -135,15 +131,12 @@ const MyChores = () => {
|
||||
|
||||
const {
|
||||
searchTerm,
|
||||
searchFilter,
|
||||
selectedChoreFilter,
|
||||
projectFilteredChores,
|
||||
searchFilteredChores,
|
||||
nonProjectFilteredChores,
|
||||
setSearchTerm,
|
||||
setSearchFilter,
|
||||
setSelectedChoreFilterWithCache,
|
||||
clearFilters,
|
||||
} = useChoreFilters({
|
||||
chores,
|
||||
selectedProject,
|
||||
@@ -193,6 +186,103 @@ const MyChores = () => {
|
||||
useState(false)
|
||||
const [editingFilter, setEditingFilter] = useState(null)
|
||||
|
||||
const quickFilterDefs = useMemo(
|
||||
() => [
|
||||
{
|
||||
id: 'status',
|
||||
label: 'Due Date',
|
||||
type: 'single-select',
|
||||
icon: <CalendarMonth />,
|
||||
options: [
|
||||
{ value: 'Overdue', label: 'Overdue', color: 'danger' },
|
||||
{ value: 'Due today', label: 'Due Today', color: 'warning' },
|
||||
{ value: 'Due in week', label: 'Due This Week' },
|
||||
{ value: 'Due Later', label: 'Due Later' },
|
||||
{ value: 'No Due Date', label: 'No Due Date' },
|
||||
{ value: 'Pending Approval', label: 'Pending Approval' },
|
||||
],
|
||||
filterFn: (item, value) => {
|
||||
const now = new Date()
|
||||
const d = item.nextDueDate ? new Date(item.nextDueDate) : null
|
||||
switch (value) {
|
||||
case 'Overdue':
|
||||
return d !== null && d < now
|
||||
case 'Due today':
|
||||
return d !== null && d.toDateString() === now.toDateString()
|
||||
case 'Due in week':
|
||||
return (
|
||||
d !== null &&
|
||||
d < new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000) &&
|
||||
d > now
|
||||
)
|
||||
case 'Due Later':
|
||||
return (
|
||||
d !== null &&
|
||||
d > new Date(now.getTime() + 24 * 60 * 60 * 1000)
|
||||
)
|
||||
case 'No Due Date':
|
||||
return item.nextDueDate === null
|
||||
case 'Pending Approval':
|
||||
return item.status === 3
|
||||
default:
|
||||
return true
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'priority',
|
||||
label: 'Priority',
|
||||
type: 'multi-select',
|
||||
icon: <PriorityHigh />,
|
||||
options: Priorities.map(p => ({
|
||||
value: p.value,
|
||||
label: p.name,
|
||||
color: p.color || 'neutral',
|
||||
icon: p.icon,
|
||||
})),
|
||||
filterFn: (item, values) => values.includes(item.priority ?? 0),
|
||||
},
|
||||
...(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,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
],
|
||||
[userLabels],
|
||||
)
|
||||
|
||||
const {
|
||||
filteredData: quickFilteredChores,
|
||||
activeFilters: quickFilters,
|
||||
setFilter: setQuickFilter,
|
||||
clearAll: clearQuickFilters,
|
||||
hasActiveFilters: hasQuickFilters,
|
||||
} = useFilter(projectFilteredChores, quickFilterDefs)
|
||||
|
||||
const processedChores = useMemo(() => {
|
||||
if (!choresData?.res) {
|
||||
return []
|
||||
@@ -222,9 +312,9 @@ const MyChores = () => {
|
||||
if (tempFilter || activeFilterId) {
|
||||
// Advanced/custom filter active
|
||||
choresToGroup = customFilteredChores
|
||||
} else if (searchFilter !== 'All') {
|
||||
// Quick filter active (Overdue, Due today, Label, Priority, etc.)
|
||||
choresToGroup = filteredChores
|
||||
} else if (hasQuickFilters) {
|
||||
// Quick filter active (Due date, Priority, Labels)
|
||||
choresToGroup = quickFilteredChores
|
||||
} else if (!selectedProject || selectedProject.id === 'default') {
|
||||
// No project selected or default project: only show tasks without a projectId
|
||||
choresToGroup = chores.filter(chore => !chore.projectId)
|
||||
@@ -244,11 +334,11 @@ const MyChores = () => {
|
||||
return sections
|
||||
}, [
|
||||
chores,
|
||||
filteredChores,
|
||||
quickFilteredChores,
|
||||
customFilteredChores,
|
||||
tempFilter,
|
||||
activeFilterId,
|
||||
searchFilter,
|
||||
hasQuickFilters,
|
||||
selectedChoreSection,
|
||||
selectedChoreFilter,
|
||||
selectedProject,
|
||||
@@ -412,7 +502,7 @@ const MyChores = () => {
|
||||
}
|
||||
|
||||
// Handle legacy filter parameter (e.g., filter=unplanned)
|
||||
if (oldFilter && searchFilter === 'All' && !activeFilterId) {
|
||||
if (oldFilter && !hasQuickFilters && !activeFilterId) {
|
||||
const filterMap = {
|
||||
unplanned: 'No Due Date',
|
||||
overdue: 'Overdue',
|
||||
@@ -423,12 +513,8 @@ const MyChores = () => {
|
||||
}
|
||||
|
||||
const filterName = filterMap[oldFilter.toLowerCase()]
|
||||
if (filterName && FILTERS[filterName]) {
|
||||
const filtered = FILTERS[filterName](
|
||||
selectedProject ? projectFilteredChores : chores,
|
||||
)
|
||||
setFilteredChores(filtered)
|
||||
setSearchFilter(filterName)
|
||||
if (filterName) {
|
||||
setQuickFilter('status', filterName)
|
||||
setViewMode('default')
|
||||
setSelectedCalendarDate(null)
|
||||
}
|
||||
@@ -436,7 +522,7 @@ const MyChores = () => {
|
||||
}, [
|
||||
searchParams,
|
||||
chores,
|
||||
searchFilter,
|
||||
hasQuickFilters,
|
||||
activeFilterId,
|
||||
savedFilters,
|
||||
applyCustomFilter,
|
||||
@@ -444,8 +530,7 @@ const MyChores = () => {
|
||||
clearActiveFilter,
|
||||
selectedProject,
|
||||
projectFilteredChores,
|
||||
setSearchFilter,
|
||||
setFilteredChores,
|
||||
setQuickFilter,
|
||||
setViewMode,
|
||||
setSelectedCalendarDate,
|
||||
])
|
||||
@@ -510,13 +595,47 @@ const MyChores = () => {
|
||||
clearSelection,
|
||||
})
|
||||
|
||||
const getFilteredChores = useMemo(() => {
|
||||
if (activeFilterId || tempFilter) {
|
||||
return customFilteredChores
|
||||
}
|
||||
|
||||
const baseChores = hasQuickFilters
|
||||
? quickFilteredChores
|
||||
: projectFilteredChores
|
||||
|
||||
if (searchTerm?.length > 0) {
|
||||
const searchableChores = baseChores.map(c => ({
|
||||
...c,
|
||||
raw_label: c.labelsV2?.map(l => l.name).join(' '),
|
||||
}))
|
||||
const fuse = new Fuse(searchableChores, {
|
||||
keys: ['name', 'raw_label'],
|
||||
includeScore: true,
|
||||
isCaseSensitive: false,
|
||||
findAllMatches: true,
|
||||
})
|
||||
return fuse.search(searchTerm).map(result => result.item)
|
||||
}
|
||||
|
||||
return baseChores
|
||||
}, [
|
||||
activeFilterId,
|
||||
tempFilter,
|
||||
customFilteredChores,
|
||||
hasQuickFilters,
|
||||
quickFilteredChores,
|
||||
projectFilteredChores,
|
||||
searchTerm,
|
||||
])
|
||||
|
||||
const { showKeyboardShortcuts } = useKeyboardShortcuts({
|
||||
isMultiSelectMode,
|
||||
selectedChores,
|
||||
addTaskModalOpen,
|
||||
searchTerm,
|
||||
searchFilter,
|
||||
filteredChores,
|
||||
searchFilter: hasQuickFilters || searchTerm?.length > 0 ? 'filtered' : 'All',
|
||||
filteredChores: getFilteredChores,
|
||||
choreSections,
|
||||
openChoreSections,
|
||||
handlers: {
|
||||
@@ -567,25 +686,10 @@ const MyChores = () => {
|
||||
|
||||
const handleLabelFiltering = chipClicked => {
|
||||
clearActiveFilter()
|
||||
|
||||
const baseChores = selectedProject ? projectFilteredChores : chores
|
||||
|
||||
if (chipClicked.label) {
|
||||
const label = chipClicked.label
|
||||
const labelFiltered = baseChores.filter(chore =>
|
||||
chore.labelsV2.some(
|
||||
l => l.id === label.id && l.created_by === label.created_by,
|
||||
),
|
||||
)
|
||||
setFilteredChores(labelFiltered)
|
||||
setSearchFilter('Label: ' + label.name)
|
||||
setQuickFilter('label', [chipClicked.label.id])
|
||||
} else if (chipClicked.priority) {
|
||||
const priority = chipClicked.priority
|
||||
const priorityFiltered = baseChores.filter(
|
||||
chore => chore.priority === priority,
|
||||
)
|
||||
setFilteredChores(priorityFiltered)
|
||||
setSearchFilter('Priority: ' + priority)
|
||||
setQuickFilter('priority', [chipClicked.priority])
|
||||
}
|
||||
setSelectedCalendarDate(null)
|
||||
}
|
||||
@@ -637,8 +741,8 @@ const MyChores = () => {
|
||||
|
||||
const handleSearchChange = e => {
|
||||
clearActiveFilter()
|
||||
if (searchFilter !== 'All') {
|
||||
setSearchFilter('All')
|
||||
if (hasQuickFilters) {
|
||||
clearQuickFilters()
|
||||
}
|
||||
const search = e.target.value
|
||||
if (search === '') {
|
||||
@@ -754,42 +858,6 @@ const MyChores = () => {
|
||||
// )
|
||||
// }
|
||||
|
||||
const getFilteredChores = useMemo(() => {
|
||||
if (activeFilterId || tempFilter) {
|
||||
return customFilteredChores
|
||||
}
|
||||
|
||||
let baseChores = projectFilteredChores
|
||||
|
||||
if (searchTerm?.length > 0 || searchFilter !== 'All') {
|
||||
if (searchTerm?.length > 0) {
|
||||
const projectFilteredForSearch = baseChores.map(c => ({
|
||||
...c,
|
||||
raw_label: c.labelsV2?.map(l => l.name).join(' '),
|
||||
}))
|
||||
const fuse = new Fuse(projectFilteredForSearch, {
|
||||
keys: ['name', 'raw_label'],
|
||||
includeScore: true,
|
||||
isCaseSensitive: false,
|
||||
findAllMatches: true,
|
||||
})
|
||||
return fuse.search(searchTerm).map(result => result.item)
|
||||
} else if (searchFilter !== 'All') {
|
||||
return filteredChores
|
||||
}
|
||||
}
|
||||
|
||||
return baseChores
|
||||
}, [
|
||||
activeFilterId,
|
||||
tempFilter,
|
||||
customFilteredChores,
|
||||
projectFilteredChores,
|
||||
searchTerm,
|
||||
searchFilter,
|
||||
filteredChores,
|
||||
])
|
||||
|
||||
const getChoresForDate = useCallback(
|
||||
date => {
|
||||
const filteredChoresData = getFilteredChores
|
||||
@@ -814,7 +882,7 @@ const MyChores = () => {
|
||||
|
||||
setChores(newChores)
|
||||
setFilteredChores(newChores)
|
||||
setSearchFilter('All')
|
||||
clearQuickFilters()
|
||||
}
|
||||
|
||||
// Show error state when API is unreachable
|
||||
@@ -902,7 +970,6 @@ const MyChores = () => {
|
||||
value={searchTerm}
|
||||
onChange={handleSearchChange}
|
||||
onClose={handleSearchClose}
|
||||
onFocus={() => setShowSearchFilter(true)}
|
||||
showKeyboardShortcuts={showKeyboardShortcuts}
|
||||
inputRef={searchInputRef}
|
||||
/>
|
||||
@@ -924,7 +991,7 @@ const MyChores = () => {
|
||||
onItemSelect={selected => {
|
||||
setSelectedChoreSectionWithCache(selected.value)
|
||||
setFilteredChores(chores)
|
||||
setSearchFilter('All')
|
||||
clearQuickFilters()
|
||||
}}
|
||||
onCreateNewFilter={() => {
|
||||
setShowAdvancedFilterBuilder(true)
|
||||
@@ -1008,163 +1075,22 @@ const MyChores = () => {
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Search Filter with animation */}
|
||||
<Box
|
||||
sx={{
|
||||
overflow: 'hidden',
|
||||
transition: 'all 0.3s ease-in-out',
|
||||
maxHeight: showSearchFilter ? '150px' : '0',
|
||||
opacity: showSearchFilter ? 1 : 0,
|
||||
transform: showSearchFilter ? 'translateY(0)' : 'translateY(-10px)',
|
||||
marginBottom: showSearchFilter ? 1 : 0,
|
||||
{/* Quick Filters */}
|
||||
<FilterBar
|
||||
filterDefs={quickFilterDefs}
|
||||
activeFilters={quickFilters}
|
||||
onSetFilter={(id, value) => {
|
||||
clearActiveFilter()
|
||||
setQuickFilter(id, value)
|
||||
setSelectedCalendarDate(null)
|
||||
}}
|
||||
>
|
||||
<div className='flex gap-4'>
|
||||
<div className='grid flex-1 grid-cols-3 gap-4'>
|
||||
<IconButtonWithMenu
|
||||
label={' Priority'}
|
||||
k={'icon-menu-priority-filter'}
|
||||
icon={<PriorityHigh />}
|
||||
options={Priorities}
|
||||
selectedItem={searchFilter}
|
||||
onItemSelect={selected => {
|
||||
handleLabelFiltering({ priority: selected.value })
|
||||
}}
|
||||
mouseClickHandler={handleMenuOutsideClick}
|
||||
isActive={searchFilter.startsWith('Priority: ')}
|
||||
/>
|
||||
|
||||
<IconButtonWithMenu
|
||||
k={'icon-menu-labels-filter'}
|
||||
label={' Labels'}
|
||||
icon={<Style />}
|
||||
options={userLabels}
|
||||
selectedItem={searchFilter}
|
||||
onItemSelect={selected => {
|
||||
handleLabelFiltering({ label: selected })
|
||||
}}
|
||||
isActive={searchFilter.startsWith('Label: ')}
|
||||
mouseClickHandler={handleMenuOutsideClick}
|
||||
useChips
|
||||
/>
|
||||
|
||||
<Button
|
||||
onClick={handleFilterMenuOpen}
|
||||
variant='outlined'
|
||||
startDecorator={<Grain />}
|
||||
color={
|
||||
searchFilter && FILTERS[searchFilter] && searchFilter != 'All'
|
||||
? 'primary'
|
||||
: 'neutral'
|
||||
}
|
||||
size='sm'
|
||||
sx={{
|
||||
height: 24,
|
||||
borderRadius: 24,
|
||||
}}
|
||||
>
|
||||
{' Other'}
|
||||
</Button>
|
||||
|
||||
<List
|
||||
orientation='horizontal'
|
||||
wrap
|
||||
sx={{
|
||||
mt: 0.2,
|
||||
}}
|
||||
>
|
||||
<Menu
|
||||
ref={menuRef}
|
||||
anchorEl={anchorEl}
|
||||
open={Boolean(anchorEl)}
|
||||
onClose={handleFilterMenuClose}
|
||||
>
|
||||
{Object.keys(FILTERS).map((filter, index) => (
|
||||
<MenuItem
|
||||
key={`filter-list-${filter}-${index}`}
|
||||
onClick={() => {
|
||||
const filterFunction = FILTERS[filter]
|
||||
const baseChores = selectedProject
|
||||
? projectFilteredChores
|
||||
: chores
|
||||
const filteredChores =
|
||||
filterFunction.length === 2
|
||||
? filterFunction(baseChores, userProfile?.id)
|
||||
: filterFunction(baseChores)
|
||||
setFilteredChores(filteredChores)
|
||||
setSearchFilter(filter)
|
||||
handleFilterMenuClose()
|
||||
|
||||
// Update URL with legacy filter parameter
|
||||
const filterMap = {
|
||||
'No Due Date': 'unplanned',
|
||||
Overdue: 'overdue',
|
||||
'Due today': 'today',
|
||||
'Due in week': 'week',
|
||||
'Due Later': 'later',
|
||||
'Pending Approval': 'pending',
|
||||
}
|
||||
const urlFilter = filterMap[filter]
|
||||
if (urlFilter) {
|
||||
updateFilterUrl('filter', urlFilter)
|
||||
}
|
||||
}}
|
||||
>
|
||||
{filter}
|
||||
<Chip
|
||||
color={searchFilter === filter ? 'primary' : 'neutral'}
|
||||
>
|
||||
{(() => {
|
||||
const baseChores = selectedProject
|
||||
? projectFilteredChores
|
||||
: chores
|
||||
return FILTERS[filter].length === 2
|
||||
? FILTERS[filter](baseChores, userProfile?.id)
|
||||
.length
|
||||
: FILTERS[filter](baseChores).length
|
||||
})()}
|
||||
</Chip>
|
||||
</MenuItem>
|
||||
))}
|
||||
|
||||
{searchFilter.startsWith('Label: ') ||
|
||||
(searchFilter.startsWith('Priority: ') && (
|
||||
<MenuItem
|
||||
key={`filter-list-cancel-all-filters`}
|
||||
onClick={() => {
|
||||
setFilteredChores(
|
||||
selectedProject ? projectFilteredChores : chores,
|
||||
)
|
||||
setSearchFilter('All')
|
||||
updateFilterUrl(null, null)
|
||||
}}
|
||||
>
|
||||
Cancel All Filters
|
||||
</MenuItem>
|
||||
))}
|
||||
</Menu>
|
||||
</List>
|
||||
</div>
|
||||
<IconButton
|
||||
variant='outlined'
|
||||
color='neutral'
|
||||
size='sm'
|
||||
sx={{
|
||||
height: 24,
|
||||
borderRadius: 24,
|
||||
}}
|
||||
onClick={() => {
|
||||
setShowSearchFilter(false)
|
||||
setSearchTerm('')
|
||||
setFilteredChores(chores)
|
||||
setSearchFilter('All')
|
||||
updateFilterUrl(null, null)
|
||||
}}
|
||||
>
|
||||
<CancelRounded />
|
||||
</IconButton>
|
||||
</div>
|
||||
</Box>
|
||||
onClearAll={() => {
|
||||
clearQuickFilters()
|
||||
updateFilterUrl(null, null)
|
||||
}}
|
||||
resultCount={hasQuickFilters ? getFilteredChores.length : undefined}
|
||||
totalCount={hasQuickFilters ? projectFilteredChores.length : undefined}
|
||||
/>
|
||||
|
||||
{/* Custom Filters Section */}
|
||||
<FilterSection
|
||||
@@ -1177,7 +1103,7 @@ const MyChores = () => {
|
||||
clearActiveFilter()
|
||||
updateFilterUrl(null, null)
|
||||
} else {
|
||||
setSearchFilter('All')
|
||||
clearQuickFilters()
|
||||
setSearchTerm('')
|
||||
setFilteredChores([])
|
||||
|
||||
@@ -1218,41 +1144,15 @@ const MyChores = () => {
|
||||
onDelete={handleBulkDelete}
|
||||
showKeyboardShortcuts={showKeyboardShortcuts}
|
||||
selectAllDisabled={
|
||||
searchTerm?.length > 0 || searchFilter !== 'All'
|
||||
? selectedChores.size === filteredChores.length
|
||||
searchTerm?.length > 0 || hasQuickFilters
|
||||
? selectedChores.size === getFilteredChores.length
|
||||
: selectedChores.size ===
|
||||
choreSections.flatMap(s => s.content || []).length
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Additional Filters Display */}
|
||||
{searchFilter !== 'All' && (
|
||||
<Chip
|
||||
level='title-md'
|
||||
gutterBottom
|
||||
color='warning'
|
||||
label={searchFilter}
|
||||
onDelete={() => {
|
||||
setFilteredChores(
|
||||
selectedProject ? projectFilteredChores : chores,
|
||||
)
|
||||
setSearchFilter('All')
|
||||
updateFilterUrl(null, null)
|
||||
}}
|
||||
endDecorator={<CancelRounded />}
|
||||
onClick={() => {
|
||||
setFilteredChores(
|
||||
selectedProject ? projectFilteredChores : chores,
|
||||
)
|
||||
setSearchFilter('All')
|
||||
updateFilterUrl(null, null)
|
||||
}}
|
||||
>
|
||||
Additional Filter: {searchFilter}
|
||||
</Chip>
|
||||
)}
|
||||
{/* Show "Nothing scheduled" when appropriate based on current view mode */}
|
||||
{(searchTerm?.length > 0 || searchFilter !== 'All' || activeFilterId
|
||||
{(searchTerm?.length > 0 || hasQuickFilters || activeFilterId
|
||||
? getFilteredChores.length === 0
|
||||
: projectFilteredChores.length === 0) &&
|
||||
// only if not in calendar view:
|
||||
@@ -1280,10 +1180,9 @@ const MyChores = () => {
|
||||
<>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setSearchFilter('All')
|
||||
clearQuickFilters()
|
||||
setSearchTerm('')
|
||||
clearActiveFilter()
|
||||
// reset project and filters :
|
||||
setSelectedProjectWithCache(null)
|
||||
updateFilterUrl(null, null)
|
||||
}}
|
||||
@@ -1735,59 +1634,4 @@ const MyChores = () => {
|
||||
)
|
||||
}
|
||||
|
||||
const FILTERS = {
|
||||
All: function (chores) {
|
||||
return chores
|
||||
},
|
||||
Overdue: function (chores) {
|
||||
return chores.filter(chore => {
|
||||
if (chore.nextDueDate === null) return false
|
||||
return new Date(chore.nextDueDate) < new Date()
|
||||
})
|
||||
},
|
||||
'Due today': function (chores) {
|
||||
return chores.filter(chore => {
|
||||
return (
|
||||
new Date(chore.nextDueDate).toDateString() === new Date().toDateString()
|
||||
)
|
||||
})
|
||||
},
|
||||
'Due in week': function (chores) {
|
||||
return chores.filter(chore => {
|
||||
return (
|
||||
new Date(chore.nextDueDate) <
|
||||
new Date(Date.now() + 7 * 24 * 60 * 60 * 1000) &&
|
||||
new Date(chore.nextDueDate) > new Date()
|
||||
)
|
||||
})
|
||||
},
|
||||
'Due Later': function (chores) {
|
||||
return chores.filter(chore => {
|
||||
return (
|
||||
new Date(chore.nextDueDate) > new Date(Date.now() + 24 * 60 * 60 * 1000)
|
||||
)
|
||||
})
|
||||
},
|
||||
'Created By Me': function (chores, userID) {
|
||||
return chores.filter(chore => {
|
||||
return chore.createdBy === userID
|
||||
})
|
||||
},
|
||||
'Assigned To Me': function (chores, userID) {
|
||||
return chores.filter(chore => {
|
||||
return chore.assignedTo === userID
|
||||
})
|
||||
},
|
||||
'No Due Date': function (chores) {
|
||||
return chores.filter(chore => {
|
||||
return chore.nextDueDate === null
|
||||
})
|
||||
},
|
||||
'Pending Approval': function (chores) {
|
||||
return chores.filter(chore => {
|
||||
return chore.status === 3
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
export default MyChores
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user