Merge pull request #126 from donetick/advance-filtering
Advance filtering
This commit is contained in:
506
src/components/common/FilterBar.jsx
Normal file
506
src/components/common/FilterBar.jsx
Normal file
@@ -0,0 +1,506 @@
|
||||
import { Check, 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'
|
||||
import ActiveFilterChips from './filter/ActiveFilterChips'
|
||||
|
||||
/**
|
||||
* 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
|
||||
|
||||
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 ─────────────────────────────────────────────
|
||||
|
||||
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' }}
|
||||
sx={{ display: 'flex', alignItems: 'center' }}
|
||||
>
|
||||
<Button
|
||||
size='md'
|
||||
variant={hasActive ? 'solid' : 'outlined'}
|
||||
color={hasActive ? 'primary' : 'neutral'}
|
||||
startDecorator={<FilterList sx={{ fontSize: 16 }} />}
|
||||
onClick={() => setIsOpen(true)}
|
||||
sx={{
|
||||
borderRadius: 'xl',
|
||||
py: 0.5,
|
||||
px: 1,
|
||||
gap: 0.5,
|
||||
alignItems: 'center',
|
||||
'& .MuiButton-startDecorator': {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
mr: 0.5,
|
||||
},
|
||||
}}
|
||||
>
|
||||
Filters
|
||||
</Button>
|
||||
</Badge>
|
||||
|
||||
<ActiveFilterChips
|
||||
chips={filterDefs
|
||||
.map(def => ({ def, label: getActiveChipLabel(def) }))
|
||||
.filter(({ label }) => !!label)
|
||||
.map(({ def, label }) => ({
|
||||
key: def.id,
|
||||
label,
|
||||
onClear: () => onSetFilter(def.id, null),
|
||||
}))}
|
||||
onOpen={() => setIsOpen(true)}
|
||||
onClearAll={hasActive ? onClearAll : undefined}
|
||||
resultCount={hasActive ? resultCount : undefined}
|
||||
totalCount={hasActive ? totalCount : undefined}
|
||||
maxVisible={2}
|
||||
chipSize='md'
|
||||
/>
|
||||
</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={modalCountChipSx}>
|
||||
{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={sectionBadgeChipSx}>
|
||||
{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={sectionBadgeChipSx}>
|
||||
{opt.label}
|
||||
</Chip>
|
||||
) : null
|
||||
})()}
|
||||
{def.type === 'date-range' && getActiveChipLabel(def) && (
|
||||
<Chip size='sm' variant='solid' color='primary' sx={sectionBadgeChipSx}>
|
||||
{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={selectableChipSx}
|
||||
>
|
||||
{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={selectableChipSx}
|
||||
>
|
||||
{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={selectableChipSx}
|
||||
>
|
||||
{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={selectableChipSx}
|
||||
>
|
||||
{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
|
||||
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
|
||||
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,
|
||||
@@ -22,15 +25,18 @@ import {
|
||||
} from '@mui/joy'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
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 { commandQueue, CommandType } from '../../utils/CommandQueue'
|
||||
import { DeleteChore, GetArchivedChores } from '../../utils/Fetcher'
|
||||
import Priorities from '../../utils/Priorities'
|
||||
import { offlineDB } from '../../utils/OfflineDB'
|
||||
import LoadingComponent from '../components/Loading'
|
||||
import ConfirmationModal from '../Modals/Inputs/ConfirmationModal'
|
||||
@@ -107,6 +113,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) {
|
||||
@@ -335,11 +430,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)))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -673,6 +765,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
|
||||
@@ -745,7 +846,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',
|
||||
@@ -876,7 +977,7 @@ const ArchivedTasks = () => {
|
||||
)}
|
||||
|
||||
{/* Content */}
|
||||
{filteredChores.length === 0 ? (
|
||||
{finalChores.length === 0 ? (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
@@ -886,42 +987,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 || hasActiveFilters) && (
|
||||
<Box sx={{ display: 'flex', gap: 1 }}>
|
||||
{searchTerm && (
|
||||
<Button
|
||||
onClick={handleSearchClose}
|
||||
variant='outlined'
|
||||
color='neutral'
|
||||
>
|
||||
<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,17 +2,10 @@ import {
|
||||
Add,
|
||||
Bolt,
|
||||
CalendarMonth,
|
||||
CancelRounded,
|
||||
CheckBox,
|
||||
CheckBoxOutlineBlank,
|
||||
EditCalendar,
|
||||
ExpandCircleDown,
|
||||
Grain,
|
||||
PriorityHigh,
|
||||
Sort,
|
||||
Style,
|
||||
ViewAgenda,
|
||||
ViewModule,
|
||||
} from '@mui/icons-material'
|
||||
import Logo from '../../Logo'
|
||||
import {
|
||||
@@ -25,9 +18,6 @@ import {
|
||||
Container,
|
||||
Divider,
|
||||
IconButton,
|
||||
List,
|
||||
Menu,
|
||||
MenuItem,
|
||||
Typography,
|
||||
} from '@mui/joy'
|
||||
import Fuse from 'fuse.js'
|
||||
@@ -44,6 +34,7 @@ import IconButtonWithMenu from './IconButtonWithMenu'
|
||||
import { useMediaQuery } from '@mui/material'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import KeyboardShortcutHint from '../../components/common/KeyboardShortcutHint'
|
||||
import { useFilter } from '../../hooks/useFilter'
|
||||
import { useImpersonateUser } from '../../contexts/ImpersonateUserContext.jsx'
|
||||
import { useCircleMembers, useUserProfile } from '../../queries/UserQueries'
|
||||
import {
|
||||
@@ -56,15 +47,13 @@ import { getSafeBottom } from '../../utils/SafeAreaUtils.js'
|
||||
import TaskInput from '../components/AddTaskModal'
|
||||
import CalendarDual from '../components/CalendarDual'
|
||||
import CalendarMonthly from '../components/CalendarMonthly.jsx'
|
||||
import ProjectSelector from '../components/ProjectSelector'
|
||||
import AdvancedFilterBuilder from '../Modals/Inputs/AdvancedFilterBuilder'
|
||||
import { useProjects } from '../Projects/ProjectQueries.js'
|
||||
import ChoreListView from './ChoreListView.jsx'
|
||||
import ChoreToolbar from './components/ChoreToolbarPrototype'
|
||||
import ChoreModals from './components/ChoreModals'
|
||||
import FilterSection from './components/FilterSection'
|
||||
import MultiSelectToolbar from './components/MultiSelectToolbar'
|
||||
import MyChoreHeader from './components/MyChoreHeader'
|
||||
import SearchBar from './components/SearchBar'
|
||||
import { useChoreActions } from './hooks/useChoreActions'
|
||||
import { useChoreFilters } from './hooks/useChoreFilters'
|
||||
import { useChoreModals } from './hooks/useChoreModals'
|
||||
@@ -79,7 +68,6 @@ import {
|
||||
import NotificationAccessSnackbar from './NotificationAccessSnackbar'
|
||||
import Sidepanel from './Sidepanel'
|
||||
import { INSIGHT_FILTER_DEFS } from './SmartInsightsCard'
|
||||
import SortAndGrouping from './SortAndGrouping'
|
||||
|
||||
const MyChores = () => {
|
||||
const { data: userProfile, isLoading: isUserProfileLoading } =
|
||||
@@ -108,7 +96,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)
|
||||
@@ -136,15 +123,12 @@ const MyChores = () => {
|
||||
|
||||
const {
|
||||
searchTerm,
|
||||
searchFilter,
|
||||
selectedChoreFilter,
|
||||
projectFilteredChores,
|
||||
searchFilteredChores,
|
||||
nonProjectFilteredChores,
|
||||
setSearchTerm,
|
||||
setSearchFilter,
|
||||
setSelectedChoreFilterWithCache,
|
||||
clearFilters,
|
||||
} = useChoreFilters({
|
||||
chores,
|
||||
selectedProject,
|
||||
@@ -194,6 +178,102 @@ 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,
|
||||
setFilter: setQuickFilter,
|
||||
clearAll: clearQuickFilters,
|
||||
hasActiveFilters: hasQuickFilters,
|
||||
} = useFilter(projectFilteredChores, quickFilterDefs)
|
||||
|
||||
const processedChores = useMemo(() => {
|
||||
if (!choresData?.res) {
|
||||
return []
|
||||
@@ -223,9 +303,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)
|
||||
@@ -245,11 +325,11 @@ const MyChores = () => {
|
||||
return sections
|
||||
}, [
|
||||
chores,
|
||||
filteredChores,
|
||||
quickFilteredChores,
|
||||
customFilteredChores,
|
||||
tempFilter,
|
||||
activeFilterId,
|
||||
searchFilter,
|
||||
hasQuickFilters,
|
||||
selectedChoreSection,
|
||||
selectedChoreFilter,
|
||||
selectedProject,
|
||||
@@ -399,7 +479,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',
|
||||
@@ -410,12 +490,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)
|
||||
}
|
||||
@@ -423,7 +499,7 @@ const MyChores = () => {
|
||||
}, [
|
||||
searchParams,
|
||||
chores,
|
||||
searchFilter,
|
||||
hasQuickFilters,
|
||||
activeFilterId,
|
||||
savedFilters,
|
||||
applyCustomFilter,
|
||||
@@ -431,8 +507,7 @@ const MyChores = () => {
|
||||
clearActiveFilter,
|
||||
selectedProject,
|
||||
projectFilteredChores,
|
||||
setSearchFilter,
|
||||
setFilteredChores,
|
||||
setQuickFilter,
|
||||
setViewMode,
|
||||
setSelectedCalendarDate,
|
||||
])
|
||||
@@ -497,13 +572,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: {
|
||||
@@ -554,25 +663,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)
|
||||
}
|
||||
@@ -624,8 +718,8 @@ const MyChores = () => {
|
||||
|
||||
const handleSearchChange = e => {
|
||||
clearActiveFilter()
|
||||
if (searchFilter !== 'All') {
|
||||
setSearchFilter('All')
|
||||
if (hasQuickFilters) {
|
||||
clearQuickFilters()
|
||||
}
|
||||
const search = e.target.value
|
||||
if (search === '') {
|
||||
@@ -673,14 +767,13 @@ const MyChores = () => {
|
||||
localStorage.setItem('openChoreSections', JSON.stringify(value))
|
||||
}
|
||||
|
||||
const toggleViewMode = () => {
|
||||
const toggleViewMode = value => {
|
||||
const newMode = value ?? (() => {
|
||||
const modes = ['default', 'compact', 'calendar']
|
||||
const currentIndex = modes.indexOf(viewMode)
|
||||
const nextIndex = (currentIndex + 1) % modes.length
|
||||
const newMode = modes[nextIndex]
|
||||
return modes[(modes.indexOf(viewMode) + 1) % modes.length]
|
||||
})()
|
||||
setViewMode(newMode)
|
||||
localStorage.setItem('choreCardViewMode', newMode)
|
||||
|
||||
if (newMode !== 'calendar') {
|
||||
setSelectedCalendarDate(null)
|
||||
}
|
||||
@@ -741,42 +834,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
|
||||
@@ -801,7 +858,7 @@ const MyChores = () => {
|
||||
|
||||
setChores(newChores)
|
||||
setFilteredChores(newChores)
|
||||
setSearchFilter('All')
|
||||
clearQuickFilters()
|
||||
}
|
||||
|
||||
// Show error state when API is unreachable
|
||||
@@ -876,322 +933,96 @@ const MyChores = () => {
|
||||
tempFilter={tempFilter}
|
||||
tempFilterMeta={tempFilterMeta}
|
||||
/>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignContent: 'center',
|
||||
alignItems: 'center',
|
||||
gap: 0.5,
|
||||
<ChoreToolbar
|
||||
members={membersData?.res || []}
|
||||
labels={userLabels || []}
|
||||
projects={projectsWithDefault}
|
||||
tempFilter={tempFilter}
|
||||
tempFilterMeta={tempFilterMeta}
|
||||
applyTempFilter={applyTempFilter}
|
||||
clearTempFilter={clearTempFilter}
|
||||
saveFilter={saveFilter}
|
||||
updateFilter={updateFilter}
|
||||
onFilterSaved={name =>
|
||||
showSuccess({
|
||||
title: 'Filter Saved',
|
||||
message: `"${name}" has been saved`,
|
||||
})
|
||||
}
|
||||
onClearAllFilters={() => {
|
||||
clearQuickFilters()
|
||||
clearActiveFilter()
|
||||
setSelectedChoreFilterWithCache('anyone')
|
||||
setSelectedProjectWithCache(
|
||||
projectsWithDefault.find(p => p.id === 'default') || null,
|
||||
)
|
||||
updateFilterUrl(null, null)
|
||||
}}
|
||||
>
|
||||
<SearchBar
|
||||
value={searchTerm}
|
||||
onChange={handleSearchChange}
|
||||
onClose={handleSearchClose}
|
||||
onFocus={() => setShowSearchFilter(true)}
|
||||
showKeyboardShortcuts={showKeyboardShortcuts}
|
||||
inputRef={searchInputRef}
|
||||
/>
|
||||
|
||||
<SortAndGrouping
|
||||
title='Group by'
|
||||
k={'icon-menu-group-by'}
|
||||
icon={<Sort />}
|
||||
selectedItem={selectedChoreSection}
|
||||
selectedFilter={selectedChoreFilter}
|
||||
setFilter={filter => {
|
||||
resultCount={
|
||||
hasQuickFilters || hasFilterApplied
|
||||
? getFilteredChores.length
|
||||
: undefined
|
||||
}
|
||||
totalCount={
|
||||
hasQuickFilters || hasFilterApplied
|
||||
? projectFilteredChores.length
|
||||
: undefined
|
||||
}
|
||||
selectedProject={selectedProject}
|
||||
onProjectSelect={project => {
|
||||
setSelectedProjectWithCache(project)
|
||||
clearActiveFilter()
|
||||
}}
|
||||
selectedAssigneeFilter={selectedChoreFilter}
|
||||
onAssigneeFilterChange={filter => {
|
||||
setSelectedChoreFilterWithCache(filter)
|
||||
// Clear active custom filter when quick filter is applied
|
||||
if (activeFilterId) {
|
||||
clearActiveFilter()
|
||||
updateFilterUrl(null, null)
|
||||
}
|
||||
}}
|
||||
onItemSelect={selected => {
|
||||
setSelectedChoreSectionWithCache(selected.value)
|
||||
setFilteredChores(chores)
|
||||
setSearchFilter('All')
|
||||
}}
|
||||
onCreateNewFilter={() => {
|
||||
setShowAdvancedFilterBuilder(true)
|
||||
setEditingFilter(null)
|
||||
}}
|
||||
mouseClickHandler={handleMenuOutsideClick}
|
||||
/>
|
||||
|
||||
{/* Project Selector - Hidden when active filter has project conditions */}
|
||||
{projectsWithDefault.length > 1 &&
|
||||
!hasProjectConditions &&
|
||||
!hasFilterApplied && (
|
||||
<ProjectSelector
|
||||
selectedProject={selectedProject?.name || 'Default Project'}
|
||||
onProjectSelect={project => {
|
||||
setSelectedProjectWithCache(project)
|
||||
clearActiveFilter()
|
||||
}}
|
||||
showKeyboardShortcuts={showKeyboardShortcuts}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* View Mode Toggle Button */}
|
||||
<IconButton
|
||||
variant='outlined'
|
||||
color='neutral'
|
||||
size='sm'
|
||||
sx={{
|
||||
height: 32,
|
||||
width: 32,
|
||||
borderRadius: '50%',
|
||||
}}
|
||||
onClick={toggleViewMode}
|
||||
title={
|
||||
viewMode === 'default'
|
||||
? 'Switch to Compact View'
|
||||
: viewMode === 'compact'
|
||||
? 'Switch to Calendar View'
|
||||
: 'Switch to Card View'
|
||||
}
|
||||
>
|
||||
{viewMode === 'default' ? (
|
||||
<ViewAgenda />
|
||||
) : viewMode === 'compact' ? (
|
||||
<CalendarMonth />
|
||||
) : (
|
||||
<ViewModule />
|
||||
)}
|
||||
</IconButton>
|
||||
|
||||
{/* Multi-select Toggle Button */}
|
||||
<Box sx={{ position: 'relative', display: 'inline-flex' }}>
|
||||
<IconButton
|
||||
variant={isMultiSelectMode ? 'solid' : 'outlined'}
|
||||
color={isMultiSelectMode ? 'primary' : 'neutral'}
|
||||
size='sm'
|
||||
sx={{
|
||||
height: 32,
|
||||
width: 32,
|
||||
borderRadius: '50%',
|
||||
}}
|
||||
onClick={toggleMultiSelectMode}
|
||||
title={
|
||||
isMultiSelectMode
|
||||
? 'Exit Multi-select Mode (Ctrl+S)'
|
||||
: 'Enable Multi-select Mode (Ctrl+S)'
|
||||
}
|
||||
>
|
||||
{isMultiSelectMode ? <CheckBox /> : <CheckBoxOutlineBlank />}
|
||||
</IconButton>
|
||||
<KeyboardShortcutHint
|
||||
shortcut='S'
|
||||
show={showKeyboardShortcuts}
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: -8,
|
||||
right: -8,
|
||||
zIndex: 1000,
|
||||
}}
|
||||
/>
|
||||
</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,
|
||||
}}
|
||||
>
|
||||
<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>
|
||||
|
||||
{/* Custom Filters Section */}
|
||||
<FilterSection
|
||||
savedFilters={savedFilters}
|
||||
activeFilterId={activeFilterId}
|
||||
activeFilter={activeFilter}
|
||||
hasProjectConditions={hasProjectConditions}
|
||||
onFilterClick={filterId => {
|
||||
onSavedFilterClick={filterId => {
|
||||
if (activeFilterId === filterId) {
|
||||
clearActiveFilter()
|
||||
updateFilterUrl(null, null)
|
||||
} else {
|
||||
setSearchFilter('All')
|
||||
clearQuickFilters()
|
||||
setSearchTerm('')
|
||||
setFilteredChores([])
|
||||
|
||||
// Reset quick filter to 'anyone' when custom filter is applied
|
||||
if (selectedChoreFilter !== 'anyone') {
|
||||
setSelectedChoreFilterWithCache('anyone')
|
||||
}
|
||||
|
||||
// Clear project selection if the filter has project conditions
|
||||
const filter = savedFilters.find(f => f.id === filterId)
|
||||
if (filter?.conditions?.some(c => c.type === 'project')) {
|
||||
setSelectedProjectWithCache(null)
|
||||
}
|
||||
|
||||
applyCustomFilter(filterId)
|
||||
updateFilterUrl('filterId', filterId)
|
||||
}
|
||||
}}
|
||||
onFilterDelete={deleteFilter}
|
||||
onFilterPin={pinFilter}
|
||||
onFilterEdit={filter => {
|
||||
onSavedFilterEdit={filter => {
|
||||
setEditingFilter(filter)
|
||||
setShowAdvancedFilterBuilder(true)
|
||||
}}
|
||||
onClearActiveFilter={clearActiveFilter}
|
||||
onCreateAdvancedFilter={() => setShowAdvancedFilterBuilder(true)}
|
||||
updateFilterUrl={updateFilterUrl}
|
||||
onSavedFilterDelete={deleteFilter}
|
||||
onSavedFilterPin={pinFilter}
|
||||
selectedGroupBy={selectedChoreSection}
|
||||
onGroupBySelect={value => {
|
||||
setSelectedChoreSectionWithCache(value)
|
||||
setFilteredChores(chores)
|
||||
clearQuickFilters()
|
||||
}}
|
||||
viewMode={viewMode}
|
||||
onToggleViewMode={toggleViewMode}
|
||||
isMultiSelectMode={isMultiSelectMode}
|
||||
onToggleMultiSelect={toggleMultiSelectMode}
|
||||
searchTerm={searchTerm}
|
||||
onSearchChange={handleSearchChange}
|
||||
onSearchClose={handleSearchClose}
|
||||
searchInputRef={searchInputRef}
|
||||
showKeyboardShortcuts={showKeyboardShortcuts}
|
||||
/>
|
||||
|
||||
<MultiSelectToolbar
|
||||
@@ -1205,41 +1036,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:
|
||||
@@ -1267,10 +1072,9 @@ const MyChores = () => {
|
||||
<>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setSearchFilter('All')
|
||||
clearQuickFilters()
|
||||
setSearchTerm('')
|
||||
clearActiveFilter()
|
||||
// reset project and filters :
|
||||
setSelectedProjectWithCache(null)
|
||||
updateFilterUrl(null, null)
|
||||
}}
|
||||
@@ -1721,59 +1525,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
|
||||
|
||||
920
src/views/Chores/components/ChoreToolbarPrototype.jsx
Normal file
920
src/views/Chores/components/ChoreToolbarPrototype.jsx
Normal file
@@ -0,0 +1,920 @@
|
||||
/**
|
||||
* PROTOTYPE – Unified Chore Toolbar
|
||||
*
|
||||
* Proposed design to replace the current 3-surface layout:
|
||||
* OLD: [Search] [Sort+Group+AssigneeFilter+CreateFilter] [ProjectSelector] [View] [Multiselect]
|
||||
* + FilterBar (Due Date / Priority / Labels chips row)
|
||||
* + FilterSection (saved/pinned filter chips row)
|
||||
*
|
||||
* NEW: [Search] [Filter(n)] [Group ▾] [View] [Multiselect]
|
||||
* + active filter chips appear inline next to Filter button
|
||||
* + Filter button opens ONE unified bottom sheet containing:
|
||||
* Assignee · Created By · Status · Priority · Due Date · Labels · Projects · Points
|
||||
* + Saved Filters section
|
||||
*
|
||||
* How to try it: in MyChores.jsx, replace the <Box sx={{display:'flex'...}}> toolbar block
|
||||
* and the two rows below it (FilterBar + FilterSection) with:
|
||||
* <ChoreToolbar ... />
|
||||
*/
|
||||
|
||||
import {
|
||||
ArrowDropDown,
|
||||
CalendarMonth,
|
||||
Check,
|
||||
CheckBox,
|
||||
CheckBoxOutlineBlank,
|
||||
FilterList,
|
||||
Save,
|
||||
Sort,
|
||||
Tune,
|
||||
ViewAgenda,
|
||||
ViewComfy,
|
||||
ViewModule,
|
||||
} from '@mui/icons-material'
|
||||
import {
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
ButtonGroup,
|
||||
Chip,
|
||||
Divider,
|
||||
IconButton,
|
||||
Input,
|
||||
Menu,
|
||||
MenuItem,
|
||||
Typography,
|
||||
} from '@mui/joy'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import BottomSheetModal from '../../../components/common/BottomSheetModal'
|
||||
import ActiveFilterChips from '../../../components/common/filter/ActiveFilterChips'
|
||||
import { Z_INDEX } from '../../../constants/zIndex'
|
||||
import KeyboardShortcutHint from '../../../components/common/KeyboardShortcutHint'
|
||||
import { FILTER_COLORS } from '../../../utils/Colors'
|
||||
import Priorities from '../../../utils/Priorities'
|
||||
import FilterBuilderContent, {
|
||||
CHORE_STATUSES,
|
||||
DUE_DATE_OPTIONS,
|
||||
POINTS_OPERATORS,
|
||||
conditionsToSelections,
|
||||
defaultSelections,
|
||||
selectionsToConditions,
|
||||
} from './FilterBuilderContent'
|
||||
import SearchBar from './SearchBar'
|
||||
import ProjectSelector from '../../components/ProjectSelector'
|
||||
|
||||
// ─── sub-components for the Display sheet ────────────────────────────────────
|
||||
|
||||
const SectionHeader = ({ icon, label, badge }) => (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}>
|
||||
{icon && (
|
||||
<Box
|
||||
sx={{
|
||||
color: 'text.secondary',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
'& svg': { fontSize: 18 },
|
||||
}}
|
||||
>
|
||||
{icon}
|
||||
</Box>
|
||||
)}
|
||||
<Typography level='title-sm' fontWeight={600}>
|
||||
{label}
|
||||
</Typography>
|
||||
{badge != null && (
|
||||
<Chip
|
||||
size='sm'
|
||||
variant='solid'
|
||||
color='primary'
|
||||
sx={{ ml: 'auto', fontSize: '0.7rem', height: 20 }}
|
||||
>
|
||||
{badge}
|
||||
</Chip>
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
|
||||
const OptionChips = ({ options, selected, multi, onToggle }) => (
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
|
||||
{options.map(opt => {
|
||||
const isSelected = multi
|
||||
? (selected || []).includes(opt.value)
|
||||
: selected === opt.value
|
||||
return (
|
||||
<Chip
|
||||
key={opt.value}
|
||||
variant={isSelected ? 'solid' : 'soft'}
|
||||
color={isSelected ? opt.color ?? 'primary' : 'neutral'}
|
||||
startDecorator={
|
||||
opt.icon != null
|
||||
? isSelected
|
||||
? <Check sx={{ fontSize: 14 }} />
|
||||
: opt.icon
|
||||
: undefined
|
||||
}
|
||||
onClick={() => onToggle(opt.value)}
|
||||
sx={{
|
||||
py: 0.64,
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.15s ease',
|
||||
userSelect: 'none',
|
||||
'&:hover': { opacity: 0.85 },
|
||||
}}
|
||||
>
|
||||
{opt.label}
|
||||
</Chip>
|
||||
)
|
||||
})}
|
||||
</Box>
|
||||
)
|
||||
|
||||
// ─── main component ───────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Props:
|
||||
* -- Advanced filter (filter sheet) --
|
||||
* members – circle members for Assignee / Created By sections
|
||||
* labels – user labels for Labels section
|
||||
* projects – projects list (projectsWithDefault) for Projects section + Display sheet
|
||||
* tempFilter – current temp filter object { conditions, operator } or null
|
||||
* tempFilterMeta – metadata for temp filter, including saved-filter edit source when applicable
|
||||
* applyTempFilter – (filter) => void — called immediately as selections change
|
||||
* clearTempFilter – () => void
|
||||
* 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)
|
||||
*
|
||||
* -- Result counts --
|
||||
* resultCount / totalCount
|
||||
*
|
||||
* -- Clear all --
|
||||
* onClearAllFilters – () => void
|
||||
*
|
||||
* -- Saved filters --
|
||||
* savedFilters – [{ id, name, color, count, isPinned }]
|
||||
* activeFilterId – number | null
|
||||
* onSavedFilterClick – (id) => void
|
||||
* onSavedFilterEdit – (filter) => void
|
||||
* onSavedFilterDelete– (id) => void
|
||||
* onSavedFilterPin – (id) => void
|
||||
*
|
||||
* -- Display sheet --
|
||||
* selectedProject – current project object (for Display sheet section)
|
||||
* onProjectSelect – (project) => void
|
||||
* selectedAssigneeFilter – 'anyone' | 'assigned_to_me' | 'available_for_me' | 'assigned_to_others'
|
||||
* onAssigneeFilterChange – (key) => void
|
||||
* selectedGroupBy – 'default' | 'due_date' | 'priority' | 'labels'
|
||||
* onGroupBySelect – (value) => void
|
||||
* viewMode – 'default' | 'compact' | 'calendar'
|
||||
* onToggleViewMode – (value?) => void
|
||||
*
|
||||
* -- Multi-select --
|
||||
* isMultiSelectMode – bool
|
||||
* onToggleMultiSelect – () => void
|
||||
*
|
||||
* -- Search --
|
||||
* searchTerm / onSearchChange / onSearchClose / searchInputRef
|
||||
* showKeyboardShortcuts
|
||||
*/
|
||||
const ChoreToolbar = ({
|
||||
// advanced filter
|
||||
members = [],
|
||||
labels = [],
|
||||
projects = [],
|
||||
tempFilter,
|
||||
tempFilterMeta,
|
||||
applyTempFilter,
|
||||
clearTempFilter,
|
||||
saveFilter,
|
||||
updateFilter,
|
||||
onFilterSaved,
|
||||
// result counts
|
||||
resultCount,
|
||||
totalCount,
|
||||
// clear all
|
||||
onClearAllFilters,
|
||||
// project (for Display sheet)
|
||||
selectedProject,
|
||||
onProjectSelect,
|
||||
// assignee (for Display sheet)
|
||||
selectedAssigneeFilter = 'anyone',
|
||||
onAssigneeFilterChange,
|
||||
// saved / custom
|
||||
savedFilters = [],
|
||||
activeFilterId,
|
||||
onSavedFilterClick,
|
||||
onSavedFilterEdit,
|
||||
onSavedFilterDelete,
|
||||
onSavedFilterPin,
|
||||
// grouping
|
||||
selectedGroupBy = 'default',
|
||||
onGroupBySelect,
|
||||
// view + multiselect
|
||||
viewMode = 'default',
|
||||
onToggleViewMode,
|
||||
isMultiSelectMode,
|
||||
onToggleMultiSelect,
|
||||
// search
|
||||
searchTerm,
|
||||
onSearchChange,
|
||||
onSearchClose,
|
||||
searchInputRef,
|
||||
showKeyboardShortcuts,
|
||||
}) => {
|
||||
const [filterSheetOpen, setFilterSheetOpen] = useState(false)
|
||||
const [displaySheetOpen, setDisplaySheetOpen] = useState(false)
|
||||
const [localSelections, setLocalSelections] = useState(defaultSelections())
|
||||
const [savingFilter, setSavingFilter] = useState(false)
|
||||
const [saveFilterName, setSaveFilterName] = useState('')
|
||||
const [saveMenuAnchorEl, setSaveMenuAnchorEl] = useState(null)
|
||||
const [editingSavedFilter, setEditingSavedFilter] = useState(null)
|
||||
const saveMenuRef = useRef(null)
|
||||
const activeConditions = selectionsToConditions(localSelections)
|
||||
|
||||
// ── badge counts ─────────────────────────────────────────────────────────────
|
||||
|
||||
const tempConditionCount = tempFilter?.conditions?.length || 0
|
||||
const savedFilterActive = activeFilterId != null ? 1 : 0
|
||||
const totalActiveCount = tempConditionCount + savedFilterActive
|
||||
const hasAnyActive = totalActiveCount > 0
|
||||
|
||||
// ── inline chip strip ────────────────────────────────────────────────────────
|
||||
|
||||
const inlineChips = []
|
||||
|
||||
const getConditionChipLabel = condition => {
|
||||
if (!condition?.type) return 'Filter'
|
||||
|
||||
const typeLabels = {
|
||||
assignee: 'Assignee',
|
||||
createdBy: 'Created By',
|
||||
status: 'Status',
|
||||
priority: 'Priority',
|
||||
label: 'Labels',
|
||||
project: 'Project',
|
||||
dueDate: 'Due Date',
|
||||
points: 'Points',
|
||||
}
|
||||
|
||||
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({
|
||||
key: `${savedFilterActive ? '__saved' : '__temp'}_${index}`,
|
||||
label: getConditionChipLabel(condition),
|
||||
onClear: () => {
|
||||
if (savedFilterActive) {
|
||||
onSavedFilterClick?.(activeFilterId)
|
||||
return
|
||||
}
|
||||
clearConditionAtIndex(index)
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
// ── open filter sheet ────────────────────────────────────────────────────────
|
||||
|
||||
const openFilterSheet = () => {
|
||||
if (tempFilter?.conditions?.length > 0) {
|
||||
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) {
|
||||
const sf = savedFilters.find(f => f.id === activeFilterId)
|
||||
setEditingSavedFilter(sf || null)
|
||||
setLocalSelections(
|
||||
sf?.conditions
|
||||
? conditionsToSelections(sf.conditions)
|
||||
: defaultSelections(),
|
||||
)
|
||||
} else {
|
||||
setEditingSavedFilter(null)
|
||||
setLocalSelections(defaultSelections())
|
||||
}
|
||||
setSavingFilter(false)
|
||||
setSaveFilterName('')
|
||||
setSaveMenuAnchorEl(null)
|
||||
setFilterSheetOpen(true)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!filterSheetOpen || savingFilter || activeConditions.length === 0) {
|
||||
setSaveMenuAnchorEl(null)
|
||||
}
|
||||
}, [filterSheetOpen, savingFilter, activeConditions.length])
|
||||
|
||||
// ── selection changes → apply temp filter immediately ────────────────────────
|
||||
|
||||
const handleSelectionsChange = updater => {
|
||||
setLocalSelections(prev => {
|
||||
const next = typeof updater === 'function' ? updater(prev) : updater
|
||||
const conditions = selectionsToConditions(next)
|
||||
if (conditions.length > 0) {
|
||||
applyTempFilter?.(
|
||||
{ conditions, operator: 'AND' },
|
||||
editingSavedFilter
|
||||
? {
|
||||
name: editingSavedFilter.name,
|
||||
description: editingSavedFilter.description,
|
||||
sourceFilterId: editingSavedFilter.id,
|
||||
sourceFilterName: editingSavedFilter.name,
|
||||
sourceFilterDescription: editingSavedFilter.description,
|
||||
sourceFilterColor: editingSavedFilter.color,
|
||||
isEditingSavedFilter: true,
|
||||
}
|
||||
: null,
|
||||
)
|
||||
} else {
|
||||
clearTempFilter?.()
|
||||
}
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
// ── save filter ───────────────────────────────────────────────────────────────
|
||||
|
||||
const handleSaveFilter = () => {
|
||||
const name = saveFilterName.trim()
|
||||
if (!name) return
|
||||
const conditions = selectionsToConditions(localSelections)
|
||||
if (conditions.length === 0) return
|
||||
|
||||
const usedColors = savedFilters.map(f => f.color)
|
||||
const color =
|
||||
FILTER_COLORS.find(c => !usedColors.includes(c.value))?.value ??
|
||||
FILTER_COLORS[0].value
|
||||
|
||||
saveFilter?.({ name, description: '', color, conditions, operator: 'AND' })?.then?.(() => {
|
||||
applyTempFilter?.({ conditions, operator: 'AND' }, { name })
|
||||
onFilterSaved?.(name)
|
||||
})
|
||||
|
||||
setSavingFilter(false)
|
||||
setSaveFilterName('')
|
||||
setFilterSheetOpen(false)
|
||||
}
|
||||
|
||||
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 ────────────────────────────────────────────────────
|
||||
|
||||
const filterActive = activeFilterId != null || tempConditionCount > 0
|
||||
const projectActive = selectedProject && selectedProject.id !== 'default' ? 1 : 0
|
||||
const assigneeActive = selectedAssigneeFilter !== 'anyone' ? 1 : 0
|
||||
const displayActive =
|
||||
selectedGroupBy !== 'default' ||
|
||||
viewMode !== 'default' ||
|
||||
projectActive > 0 ||
|
||||
assigneeActive > 0
|
||||
|
||||
const groupByOptions = [
|
||||
{ value: 'default', label: 'Smart' },
|
||||
{ value: 'due_date', label: 'Due Date' },
|
||||
{ value: 'priority', label: 'Priority' },
|
||||
{ value: 'labels', label: 'Labels' },
|
||||
]
|
||||
|
||||
const assigneeOptions = [
|
||||
{ value: 'anyone', label: 'Everyone' },
|
||||
{ value: 'assigned_to_me', label: 'Mine' },
|
||||
{ value: 'available_for_me', label: 'Available to me' },
|
||||
{ value: 'assigned_to_others', label: 'Others' },
|
||||
]
|
||||
|
||||
const viewOptions = [
|
||||
{ value: 'default', label: 'Cards', icon: <ViewAgenda sx={{ fontSize: 16 }} /> },
|
||||
{ value: 'compact', label: 'Compact', icon: <ViewComfy sx={{ fontSize: 16 }} /> },
|
||||
{ value: 'calendar', label: 'Calendar', icon: <CalendarMonth sx={{ fontSize: 16 }} /> },
|
||||
]
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* ── Row 1: main toolbar ─────────────────────────────────────────────── */}
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 0.5,
|
||||
justifyContent: 'space-between',
|
||||
}}
|
||||
>
|
||||
{/* Search takes available space */}
|
||||
<SearchBar
|
||||
value={searchTerm}
|
||||
onChange={onSearchChange}
|
||||
onClose={onSearchClose}
|
||||
showKeyboardShortcuts={showKeyboardShortcuts}
|
||||
inputRef={searchInputRef}
|
||||
/>
|
||||
|
||||
{/* Filter button */}
|
||||
<Badge
|
||||
badgeContent={totalActiveCount || null}
|
||||
color='primary'
|
||||
size='sm'
|
||||
anchorOrigin={{ vertical: 'top', horizontal: 'right' }}
|
||||
>
|
||||
<IconButton
|
||||
variant={hasAnyActive ? 'solid' : 'outlined'}
|
||||
color={hasAnyActive ? 'primary' : 'neutral'}
|
||||
size='sm'
|
||||
sx={{ height: 32, width: 32, borderRadius: '50%' }}
|
||||
onClick={openFilterSheet}
|
||||
title='Filters'
|
||||
>
|
||||
<FilterList />
|
||||
</IconButton>
|
||||
</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 */}
|
||||
<IconButton
|
||||
variant={displayActive ? 'solid' : 'outlined'}
|
||||
color={displayActive ? 'primary' : 'neutral'}
|
||||
size='sm'
|
||||
sx={{ height: 32, width: 32, borderRadius: '50%' }}
|
||||
onClick={() => setDisplaySheetOpen(true)}
|
||||
title='View & Group'
|
||||
>
|
||||
{viewMode === 'calendar' ? (
|
||||
<CalendarMonth />
|
||||
) : viewMode === 'compact' ? (
|
||||
<ViewModule />
|
||||
) : (
|
||||
<ViewAgenda />
|
||||
)}
|
||||
</IconButton>
|
||||
|
||||
{/* Multiselect */}
|
||||
<Box sx={{ position: 'relative', display: 'inline-flex' }}>
|
||||
<IconButton
|
||||
variant={isMultiSelectMode ? 'solid' : 'outlined'}
|
||||
color={isMultiSelectMode ? 'primary' : 'neutral'}
|
||||
size='sm'
|
||||
sx={{ height: 32, width: 32, borderRadius: '50%' }}
|
||||
onClick={onToggleMultiSelect}
|
||||
title={
|
||||
isMultiSelectMode
|
||||
? 'Exit multi-select (Ctrl+S)'
|
||||
: 'Multi-select (Ctrl+S)'
|
||||
}
|
||||
>
|
||||
{isMultiSelectMode ? <CheckBox /> : <CheckBoxOutlineBlank />}
|
||||
</IconButton>
|
||||
<KeyboardShortcutHint
|
||||
shortcut='S'
|
||||
show={showKeyboardShortcuts}
|
||||
sx={{ position: 'absolute', top: -8, right: -8, zIndex: 1000 }}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* ── Row 2: active filter chips ──────────────────────────────────────── */}
|
||||
{hasAnyActive && (
|
||||
<ActiveFilterChips
|
||||
chips={inlineChips}
|
||||
onOpen={openFilterSheet}
|
||||
onClearAll={() => {
|
||||
setLocalSelections(defaultSelections())
|
||||
onClearAllFilters?.()
|
||||
}}
|
||||
resultCount={resultCount}
|
||||
totalCount={totalCount}
|
||||
maxVisible={2}
|
||||
chipSize='md'
|
||||
clearButtonSize='sm'
|
||||
clearButtonSx={{ color: 'text.secondary' }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* ── Unified Filter bottom sheet ─────────────────────────────────────── */}
|
||||
<BottomSheetModal
|
||||
open={filterSheetOpen}
|
||||
onClose={() => {
|
||||
setSaveMenuAnchorEl(null)
|
||||
setFilterSheetOpen(false)
|
||||
}}
|
||||
maxHeight='92vh'
|
||||
title={
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Tune sx={{ fontSize: 20 }} />
|
||||
Filters
|
||||
{hasAnyActive && (
|
||||
<Chip size='sm' variant='solid' color='primary' sx={{ ml: 0.5 }}>
|
||||
{totalActiveCount}
|
||||
</Chip>
|
||||
)}
|
||||
</Box>
|
||||
}
|
||||
footer={
|
||||
savingFilter ? (
|
||||
<Box
|
||||
sx={{ display: 'flex', gap: 1, width: '100%', alignItems: 'center' }}
|
||||
>
|
||||
<Input
|
||||
size='sm'
|
||||
placeholder='Filter name…'
|
||||
value={saveFilterName}
|
||||
onChange={e => setSaveFilterName(e.target.value)}
|
||||
onKeyDown={e => e.key === 'Enter' && handleSaveFilter()}
|
||||
autoFocus
|
||||
sx={{ flex: 1 }}
|
||||
/>
|
||||
<Button
|
||||
size='sm'
|
||||
onClick={handleSaveFilter}
|
||||
disabled={!saveFilterName.trim()}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
<Button
|
||||
size='sm'
|
||||
variant='plain'
|
||||
color='neutral'
|
||||
onClick={() => setSavingFilter(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</Box>
|
||||
) : (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
gap: 1,
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant='plain'
|
||||
color='danger'
|
||||
size='sm'
|
||||
disabled={!hasAnyActive && activeConditions.length === 0}
|
||||
onClick={() => {
|
||||
setLocalSelections(defaultSelections())
|
||||
setSaveMenuAnchorEl(null)
|
||||
onClearAllFilters?.()
|
||||
setFilterSheetOpen(false)
|
||||
}}
|
||||
>
|
||||
Clear all
|
||||
</Button>
|
||||
|
||||
{activeConditions.length > 0 ? (
|
||||
<>
|
||||
<ButtonGroup variant='solid' color='primary'>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setSaveMenuAnchorEl(null)
|
||||
setFilterSheetOpen(false)
|
||||
}}
|
||||
sx={{ minWidth: 140 }}
|
||||
>
|
||||
{resultCount != null
|
||||
? `Show ${resultCount}`
|
||||
: 'Done'}
|
||||
</Button>
|
||||
<IconButton
|
||||
ref={saveMenuRef}
|
||||
onClick={e => setSaveMenuAnchorEl(e.currentTarget)}
|
||||
>
|
||||
<ArrowDropDown />
|
||||
</IconButton>
|
||||
</ButtonGroup>
|
||||
|
||||
<Menu
|
||||
anchorEl={saveMenuAnchorEl}
|
||||
open={Boolean(saveMenuAnchorEl)}
|
||||
onClose={() => setSaveMenuAnchorEl(null)}
|
||||
placement='top-end'
|
||||
sx={{ zIndex: Z_INDEX.MODAL_CONTENT + 10 }}
|
||||
>
|
||||
<MenuItem
|
||||
onClick={handleUpdateFilter}
|
||||
disabled={!editingSavedFilter}
|
||||
>
|
||||
<Save sx={{ fontSize: 16, mr: 1 }} />
|
||||
Save Filter
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
onClick={() => {
|
||||
setSaveMenuAnchorEl(null)
|
||||
setSaveFilterName(
|
||||
editingSavedFilter
|
||||
? `${editingSavedFilter.name} Copy`
|
||||
: '',
|
||||
)
|
||||
setSavingFilter(true)
|
||||
}}
|
||||
>
|
||||
<Save sx={{ fontSize: 16, mr: 1 }} />
|
||||
Save as New Filter
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
</>
|
||||
) : (
|
||||
<Button
|
||||
variant='solid'
|
||||
color='primary'
|
||||
onClick={() => {
|
||||
setSaveMenuAnchorEl(null)
|
||||
setFilterSheetOpen(false)
|
||||
}}
|
||||
sx={{ minWidth: 140 }}
|
||||
>
|
||||
Done
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0 }}>
|
||||
{/* Full advanced filter content */}
|
||||
<FilterBuilderContent
|
||||
selections={localSelections}
|
||||
onSelectionsChange={handleSelectionsChange}
|
||||
members={members}
|
||||
labels={labels}
|
||||
projects={projects}
|
||||
/>
|
||||
|
||||
{/* Saved filters section */}
|
||||
{savedFilters.length > 0 && (
|
||||
<>
|
||||
<Divider sx={{ my: 2.5 }} />
|
||||
<Typography level='title-sm' fontWeight={600} sx={{ mb: 1.5 }}>
|
||||
Saved Filters
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
|
||||
{savedFilters.map(filter => {
|
||||
const isActive = activeFilterId === filter.id
|
||||
return (
|
||||
<Chip
|
||||
key={filter.id}
|
||||
variant={isActive ? 'solid' : 'soft'}
|
||||
color='neutral'
|
||||
startDecorator={
|
||||
isActive ? (
|
||||
<Check sx={{ fontSize: 14 }} />
|
||||
) : (
|
||||
<Chip size='sm' variant='plain' color='neutral'>
|
||||
{filter.count ?? 0}
|
||||
</Chip>
|
||||
)
|
||||
}
|
||||
onClick={() => {
|
||||
onSavedFilterClick?.(filter.id)
|
||||
if (!isActive) setFilterSheetOpen(false)
|
||||
}}
|
||||
sx={{
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.15s ease',
|
||||
userSelect: 'none',
|
||||
'&:hover': { opacity: 0.85 },
|
||||
...(filter.color && !isActive
|
||||
? { borderColor: filter.color }
|
||||
: {}),
|
||||
}}
|
||||
>
|
||||
{filter.name}
|
||||
</Chip>
|
||||
)
|
||||
})}
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
</BottomSheetModal>
|
||||
|
||||
{/* ── Display bottom sheet (View + Group + Assignee + Project) ──────────── */}
|
||||
<BottomSheetModal
|
||||
open={displaySheetOpen}
|
||||
onClose={() => setDisplaySheetOpen(false)}
|
||||
title={
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<ViewAgenda sx={{ fontSize: 20 }} />
|
||||
Display
|
||||
</Box>
|
||||
}
|
||||
footer={
|
||||
<Button onClick={() => setDisplaySheetOpen(false)} sx={{ minWidth: 140 }}>
|
||||
Done
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0 }}>
|
||||
{/* View section */}
|
||||
<SectionHeader label='View' />
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
|
||||
{viewOptions.map(opt => (
|
||||
<Chip
|
||||
key={opt.value}
|
||||
variant={viewMode === opt.value ? 'solid' : 'soft'}
|
||||
color={viewMode === opt.value ? 'primary' : 'neutral'}
|
||||
startDecorator={
|
||||
viewMode === opt.value
|
||||
? <Check sx={{ fontSize: 14 }} />
|
||||
: opt.icon
|
||||
}
|
||||
onClick={() => onToggleViewMode?.(opt.value)}
|
||||
sx={{
|
||||
py: 0.64,
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.15s ease',
|
||||
userSelect: 'none',
|
||||
'&:hover': { opacity: 0.85 },
|
||||
}}
|
||||
>
|
||||
{opt.label}
|
||||
</Chip>
|
||||
))}
|
||||
</Box>
|
||||
|
||||
<Divider sx={{ my: 2.5 }} />
|
||||
|
||||
{/* Group by section */}
|
||||
<SectionHeader
|
||||
icon={<Sort />}
|
||||
label='Group by'
|
||||
badge={
|
||||
selectedGroupBy !== 'default'
|
||||
? groupByOptions.find(o => o.value === selectedGroupBy)?.label
|
||||
: null
|
||||
}
|
||||
/>
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
|
||||
{groupByOptions.map(opt => (
|
||||
<Chip
|
||||
key={opt.value}
|
||||
variant={selectedGroupBy === opt.value ? 'solid' : 'soft'}
|
||||
color={selectedGroupBy === opt.value ? 'primary' : 'neutral'}
|
||||
onClick={() => onGroupBySelect?.(opt.value)}
|
||||
sx={{
|
||||
py: 0.64,
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.15s ease',
|
||||
userSelect: 'none',
|
||||
'&:hover': { opacity: 0.85 },
|
||||
}}
|
||||
>
|
||||
{opt.label}
|
||||
</Chip>
|
||||
))}
|
||||
</Box>
|
||||
|
||||
{/* Show tasks for section */}
|
||||
<Divider sx={{ my: 2.5 }} />
|
||||
<SectionHeader
|
||||
icon={<FilterList />}
|
||||
label='Show tasks for'
|
||||
badge={
|
||||
selectedAssigneeFilter !== 'anyone'
|
||||
? assigneeOptions.find(o => o.value === selectedAssigneeFilter)?.label
|
||||
: null
|
||||
}
|
||||
/>
|
||||
<OptionChips
|
||||
options={assigneeOptions}
|
||||
selected={selectedAssigneeFilter}
|
||||
multi={false}
|
||||
onToggle={v => onAssigneeFilterChange?.(v)}
|
||||
/>
|
||||
|
||||
</Box>
|
||||
</BottomSheetModal>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default ChoreToolbar
|
||||
477
src/views/Chores/components/FilterBuilderContent.jsx
Normal file
477
src/views/Chores/components/FilterBuilderContent.jsx
Normal file
@@ -0,0 +1,477 @@
|
||||
import {
|
||||
CalendarMonth,
|
||||
Check,
|
||||
FolderOpen,
|
||||
Label,
|
||||
Person,
|
||||
PriorityHigh,
|
||||
Stars,
|
||||
TaskAlt,
|
||||
} from '@mui/icons-material'
|
||||
import { Avatar, Box, Chip, Divider, Input, Typography } from '@mui/joy'
|
||||
import Priorities from '../../../utils/Priorities'
|
||||
|
||||
export const DUE_DATE_OPTIONS = [
|
||||
{ value: 'isOverdue', label: 'Overdue', color: 'danger' },
|
||||
{ value: 'isDueToday', label: 'Today', color: 'warning' },
|
||||
{ value: 'isDueTomorrow', label: 'Tomorrow', color: 'primary' },
|
||||
{ value: 'isDueThisWeek', label: 'This Week', color: 'primary' },
|
||||
{ value: 'isDueThisMonth', label: 'This Month', color: 'neutral' },
|
||||
{ value: 'hasNoDueDate', label: 'No Due Date', color: 'neutral' },
|
||||
{ value: 'hasDueDate', label: 'Has Due Date', color: 'neutral' },
|
||||
]
|
||||
|
||||
export const POINTS_OPERATORS = [
|
||||
{ value: 'greaterThan', label: '>' },
|
||||
{ value: 'greaterThanOrEqual', label: '>=' },
|
||||
{ value: 'equals', label: '=' },
|
||||
{ value: 'lessThanOrEqual', label: '<=' },
|
||||
{ value: 'lessThan', label: '<' },
|
||||
]
|
||||
|
||||
export const CHORE_STATUSES = [
|
||||
{ value: 0, label: 'Active' },
|
||||
{ value: 1, label: 'Started' },
|
||||
{ value: 2, label: 'In Progress' },
|
||||
{ value: 3, label: 'Pending Approval' },
|
||||
]
|
||||
|
||||
export const defaultSelections = () => ({
|
||||
assignee: { operator: 'is', values: [] },
|
||||
createdBy: { operator: 'is', values: [] },
|
||||
status: { operator: 'is', values: [] },
|
||||
priority: { operator: 'is', values: [] },
|
||||
label: { operator: 'is', values: [] },
|
||||
project: { operator: 'is', values: [] },
|
||||
dueDate: { operator: null },
|
||||
points: { operator: 'greaterThan', value: 0, active: false },
|
||||
})
|
||||
|
||||
export const conditionsToSelections = conditions => {
|
||||
const sel = defaultSelections()
|
||||
if (!conditions) return sel
|
||||
conditions.forEach(c => {
|
||||
if (c.type === 'dueDate') {
|
||||
sel.dueDate = { operator: c.operator }
|
||||
} else if (c.type === 'points') {
|
||||
sel.points = { operator: c.operator, value: c.value ?? 0, active: true }
|
||||
} else if (c.type in sel) {
|
||||
sel[c.type] = {
|
||||
operator: c.operator ?? 'is',
|
||||
values: Array.isArray(c.value)
|
||||
? c.value
|
||||
: c.value != null
|
||||
? [c.value]
|
||||
: [],
|
||||
}
|
||||
}
|
||||
})
|
||||
return sel
|
||||
}
|
||||
|
||||
export const selectionsToConditions = selections => {
|
||||
const conditions = []
|
||||
;['assignee', 'createdBy', 'status', 'priority', 'label', 'project'].forEach(
|
||||
type => {
|
||||
if (selections[type].values?.length > 0) {
|
||||
conditions.push({
|
||||
type,
|
||||
operator: selections[type].operator,
|
||||
value: selections[type].values,
|
||||
})
|
||||
}
|
||||
},
|
||||
)
|
||||
if (selections.dueDate.operator) {
|
||||
conditions.push({
|
||||
type: 'dueDate',
|
||||
operator: selections.dueDate.operator,
|
||||
value: null,
|
||||
})
|
||||
}
|
||||
if (selections.points.active) {
|
||||
conditions.push({
|
||||
type: 'points',
|
||||
operator: selections.points.operator,
|
||||
value: selections.points.value,
|
||||
})
|
||||
}
|
||||
return conditions
|
||||
}
|
||||
|
||||
const SectionHeader = ({ icon, label, children }) => (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}>
|
||||
<Box
|
||||
sx={{
|
||||
color: 'text.secondary',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
'& svg': { fontSize: 18 },
|
||||
}}
|
||||
>
|
||||
{icon}
|
||||
</Box>
|
||||
<Typography level='title-sm' sx={{ fontWeight: 600 }}>
|
||||
{label}
|
||||
</Typography>
|
||||
{children}
|
||||
</Box>
|
||||
)
|
||||
|
||||
const IncludeExcludeToggle = ({
|
||||
value,
|
||||
onChange,
|
||||
labels = ['Include', 'Exclude'],
|
||||
}) => (
|
||||
<Box sx={{ display: 'flex', gap: 0.5, ml: 'auto' }}>
|
||||
{[
|
||||
{ op: 'is', label: labels[0] },
|
||||
{ op: 'isNot', label: labels[1] },
|
||||
].map(o => (
|
||||
<Chip
|
||||
key={o.op}
|
||||
size='sm'
|
||||
variant={value === o.op ? 'solid' : 'soft'}
|
||||
color={
|
||||
value === o.op ? (o.op === 'isNot' ? 'danger' : 'primary') : 'neutral'
|
||||
}
|
||||
onClick={() => onChange(o.op)}
|
||||
sx={{ cursor: 'pointer', userSelect: 'none', transition: 'all 0.15s ease' }}
|
||||
>
|
||||
{o.label}
|
||||
</Chip>
|
||||
))}
|
||||
</Box>
|
||||
)
|
||||
|
||||
/**
|
||||
* Reusable filter conditions UI used by both the filter sheet in ChoreToolbar
|
||||
* and the AdvancedFilterBuilder save modal.
|
||||
*
|
||||
* `onSelectionsChange` must accept either a new selections object or a
|
||||
* functional updater `prev => next` (same contract as React's setState setter).
|
||||
*/
|
||||
const FilterBuilderContent = ({
|
||||
selections,
|
||||
onSelectionsChange,
|
||||
members = [],
|
||||
labels = [],
|
||||
projects = [],
|
||||
}) => {
|
||||
const toggleValue = (type, value) =>
|
||||
onSelectionsChange(prev => {
|
||||
const cur = prev[type].values || []
|
||||
const next = cur.includes(value)
|
||||
? cur.filter(v => v !== value)
|
||||
: [...cur, value]
|
||||
return { ...prev, [type]: { ...prev[type], values: next } }
|
||||
})
|
||||
|
||||
const setOperator = (type, op) =>
|
||||
onSelectionsChange(prev => ({
|
||||
...prev,
|
||||
[type]: { ...prev[type], operator: op },
|
||||
}))
|
||||
|
||||
const toggleDueDate = op =>
|
||||
onSelectionsChange(prev => ({
|
||||
...prev,
|
||||
dueDate: { operator: prev.dueDate.operator === op ? null : op },
|
||||
}))
|
||||
|
||||
const setPointsOperator = op =>
|
||||
onSelectionsChange(prev => ({
|
||||
...prev,
|
||||
points: { ...prev.points, operator: op, active: true },
|
||||
}))
|
||||
|
||||
const setPointsValue = val =>
|
||||
onSelectionsChange(prev => ({
|
||||
...prev,
|
||||
points: { ...prev.points, value: val, active: val > 0 },
|
||||
}))
|
||||
|
||||
const chipRow = (type, options, getChipProps) => {
|
||||
const selected = selections[type].values || []
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
|
||||
{options.map(opt => {
|
||||
const isSelected = selected.includes(opt.value)
|
||||
const extra = getChipProps ? getChipProps(opt, isSelected) : {}
|
||||
return (
|
||||
<Chip
|
||||
key={opt.value}
|
||||
variant={isSelected ? 'solid' : 'soft'}
|
||||
color={isSelected ? (extra.color ?? 'primary') : 'neutral'}
|
||||
startDecorator={
|
||||
isSelected
|
||||
? <Check sx={{ fontSize: 14 }} />
|
||||
: (extra.startDecorator ?? null)
|
||||
}
|
||||
onClick={() => toggleValue(type, opt.value)}
|
||||
sx={{
|
||||
cursor: 'pointer',
|
||||
userSelect: 'none',
|
||||
transition: 'all 0.15s ease',
|
||||
}}
|
||||
>
|
||||
{opt.label}
|
||||
</Chip>
|
||||
)
|
||||
})}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
const personChipRow = type => {
|
||||
const selected = selections[type].values || []
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
|
||||
{members.map(m => {
|
||||
const isSelected = selected.includes(m.userId)
|
||||
return (
|
||||
<Chip
|
||||
key={m.userId}
|
||||
variant={isSelected ? 'solid' : 'soft'}
|
||||
color={isSelected ? 'primary' : 'neutral'}
|
||||
startDecorator={
|
||||
isSelected ? (
|
||||
<Check sx={{ fontSize: 14 }} />
|
||||
) : (
|
||||
<Avatar
|
||||
src={m.image}
|
||||
alt={m.displayName}
|
||||
sx={{ '--Avatar-size': '20px' }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
onClick={() => toggleValue(type, m.userId)}
|
||||
sx={{
|
||||
cursor: 'pointer',
|
||||
userSelect: 'none',
|
||||
transition: 'all 0.15s ease',
|
||||
}}
|
||||
>
|
||||
{m.displayName || m.username}
|
||||
</Chip>
|
||||
)
|
||||
})}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0 }}>
|
||||
{/* Assignee */}
|
||||
{members.length > 0 && (
|
||||
<>
|
||||
<SectionHeader icon={<Person />} label='Assignee'>
|
||||
<IncludeExcludeToggle
|
||||
value={selections.assignee.operator}
|
||||
onChange={op => setOperator('assignee', op)}
|
||||
/>
|
||||
</SectionHeader>
|
||||
{personChipRow('assignee')}
|
||||
<Divider sx={{ my: 2.5 }} />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Created By */}
|
||||
{members.length > 0 && (
|
||||
<>
|
||||
<SectionHeader icon={<Person />} label='Created By'>
|
||||
<IncludeExcludeToggle
|
||||
value={selections.createdBy.operator}
|
||||
onChange={op => setOperator('createdBy', op)}
|
||||
/>
|
||||
</SectionHeader>
|
||||
{personChipRow('createdBy')}
|
||||
<Divider sx={{ my: 2.5 }} />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Status */}
|
||||
<SectionHeader icon={<TaskAlt />} label='Status'>
|
||||
<IncludeExcludeToggle
|
||||
value={selections.status.operator}
|
||||
onChange={op => setOperator('status', op)}
|
||||
/>
|
||||
</SectionHeader>
|
||||
{chipRow('status', CHORE_STATUSES)}
|
||||
<Divider sx={{ my: 2.5 }} />
|
||||
|
||||
{/* Priority */}
|
||||
<SectionHeader icon={<PriorityHigh />} label='Priority'>
|
||||
<IncludeExcludeToggle
|
||||
value={selections.priority.operator}
|
||||
onChange={op => setOperator('priority', op)}
|
||||
/>
|
||||
</SectionHeader>
|
||||
{chipRow(
|
||||
'priority',
|
||||
Priorities.map(p => ({ value: p.value, label: p.name })),
|
||||
(opt, isSelected) => ({
|
||||
color: isSelected
|
||||
? (Priorities.find(p => p.value === opt.value)?.color || 'primary')
|
||||
: 'neutral',
|
||||
startDecorator: !isSelected
|
||||
? Priorities.find(p => p.value === opt.value)?.icon
|
||||
: null,
|
||||
}),
|
||||
)}
|
||||
<Divider sx={{ my: 2.5 }} />
|
||||
|
||||
{/* Due Date */}
|
||||
<SectionHeader icon={<CalendarMonth />} label='Due Date' />
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
|
||||
{DUE_DATE_OPTIONS.map(opt => {
|
||||
const isSelected = selections.dueDate.operator === opt.value
|
||||
return (
|
||||
<Chip
|
||||
key={opt.value}
|
||||
variant={isSelected ? 'solid' : 'soft'}
|
||||
color={isSelected ? (opt.color ?? 'primary') : 'neutral'}
|
||||
startDecorator={isSelected ? <Check sx={{ fontSize: 14 }} /> : null}
|
||||
onClick={() => toggleDueDate(opt.value)}
|
||||
sx={{
|
||||
cursor: 'pointer',
|
||||
userSelect: 'none',
|
||||
transition: 'all 0.15s ease',
|
||||
}}
|
||||
>
|
||||
{opt.label}
|
||||
</Chip>
|
||||
)
|
||||
})}
|
||||
</Box>
|
||||
<Divider sx={{ my: 2.5 }} />
|
||||
|
||||
{/* Labels */}
|
||||
{labels.length > 0 && (
|
||||
<>
|
||||
<SectionHeader icon={<Label />} label='Labels'>
|
||||
<IncludeExcludeToggle
|
||||
value={selections.label.operator}
|
||||
onChange={op => setOperator('label', op)}
|
||||
labels={['Has', "Doesn't Have"]}
|
||||
/>
|
||||
</SectionHeader>
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
|
||||
{labels.map(lbl => {
|
||||
const isSelected = selections.label.values.includes(lbl.id)
|
||||
return (
|
||||
<Chip
|
||||
key={lbl.id}
|
||||
variant={isSelected ? 'solid' : 'soft'}
|
||||
color='neutral'
|
||||
startDecorator={
|
||||
<Box
|
||||
sx={{
|
||||
width: 10,
|
||||
height: 10,
|
||||
borderRadius: '50%',
|
||||
bgcolor: lbl.color || '#90a4ae',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
}
|
||||
endDecorator={isSelected ? <Check sx={{ fontSize: 12 }} /> : null}
|
||||
onClick={() => toggleValue('label', lbl.id)}
|
||||
sx={{
|
||||
cursor: 'pointer',
|
||||
userSelect: 'none',
|
||||
transition: 'all 0.15s ease',
|
||||
...(isSelected && {
|
||||
outline: '2px solid',
|
||||
outlineColor: 'primary.400',
|
||||
}),
|
||||
}}
|
||||
>
|
||||
{lbl.name}
|
||||
</Chip>
|
||||
)
|
||||
})}
|
||||
</Box>
|
||||
<Divider sx={{ my: 2.5 }} />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Projects */}
|
||||
{projects.length > 0 && (
|
||||
<>
|
||||
<SectionHeader icon={<FolderOpen />} label='Projects'>
|
||||
<IncludeExcludeToggle
|
||||
value={selections.project.operator}
|
||||
onChange={op => setOperator('project', op)}
|
||||
/>
|
||||
</SectionHeader>
|
||||
{chipRow('project', [
|
||||
{ value: 'default', label: 'Default Project' },
|
||||
...projects
|
||||
.filter(p => p.id !== 'default')
|
||||
.map(p => ({ value: p.id, label: p.name })),
|
||||
])}
|
||||
<Divider sx={{ my: 2.5 }} />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Points */}
|
||||
<SectionHeader icon={<Stars />} label='Points' />
|
||||
<Box
|
||||
sx={{ display: 'flex', gap: 1, alignItems: 'center', flexWrap: 'wrap' }}
|
||||
>
|
||||
{POINTS_OPERATORS.map(op => (
|
||||
<Chip
|
||||
key={op.value}
|
||||
size='sm'
|
||||
variant={
|
||||
selections.points.operator === op.value && selections.points.active
|
||||
? 'solid'
|
||||
: 'soft'
|
||||
}
|
||||
color={
|
||||
selections.points.operator === op.value && selections.points.active
|
||||
? 'primary'
|
||||
: 'neutral'
|
||||
}
|
||||
onClick={() => setPointsOperator(op.value)}
|
||||
sx={{
|
||||
cursor: 'pointer',
|
||||
userSelect: 'none',
|
||||
fontFamily: 'monospace',
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
{op.label}
|
||||
</Chip>
|
||||
))}
|
||||
<Input
|
||||
type='number'
|
||||
size='sm'
|
||||
value={selections.points.value}
|
||||
onChange={e => setPointsValue(parseInt(e.target.value) || 0)}
|
||||
sx={{ width: 80 }}
|
||||
slotProps={{ input: { min: 0 } }}
|
||||
/>
|
||||
{selections.points.active && (
|
||||
<Chip
|
||||
size='sm'
|
||||
variant='soft'
|
||||
color='danger'
|
||||
onClick={() =>
|
||||
onSelectionsChange(prev => ({
|
||||
...prev,
|
||||
points: { ...prev.points, active: false, value: 0 },
|
||||
}))
|
||||
}
|
||||
sx={{ cursor: 'pointer' }}
|
||||
>
|
||||
Clear
|
||||
</Chip>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
export default FilterBuilderContent
|
||||
@@ -90,6 +90,8 @@ export const useCustomFilters = (chores, membersData, labels, projects) => {
|
||||
}, [chores, activeFilter, tempFilter, context])
|
||||
|
||||
const applyCustomFilter = useCallback(filterId => {
|
||||
setTempFilter(null)
|
||||
setTempFilterMeta(null)
|
||||
setActiveFilterId(filterId)
|
||||
}, [])
|
||||
|
||||
|
||||
@@ -8,11 +8,21 @@ import {
|
||||
import '@meauxt/react-swipeable-list/dist/styles.css'
|
||||
import {
|
||||
Analytics,
|
||||
CalendarMonth,
|
||||
Check,
|
||||
Checklist,
|
||||
EventBusy,
|
||||
EventNote,
|
||||
FilterList,
|
||||
Group,
|
||||
History,
|
||||
HourglassEmpty,
|
||||
Person,
|
||||
Redo,
|
||||
RunningWithErrors,
|
||||
Schedule,
|
||||
Star,
|
||||
ThumbDown,
|
||||
Timelapse,
|
||||
TrendingUp,
|
||||
} from '@mui/icons-material'
|
||||
@@ -22,8 +32,10 @@ import { Box, Button, Card, Container, Grid, Sheet, Typography } from '@mui/joy'
|
||||
import moment from 'moment'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { Link, useParams } from 'react-router-dom'
|
||||
import FilterBar from '../../components/common/FilterBar'
|
||||
import { useLocalization } from '../../contexts/LocalizationContext'
|
||||
import useConfirmationModal from '../../hooks/useConfirmationModal'
|
||||
import { useFilter } from '../../hooks/useFilter'
|
||||
import { usePendingCommands } from '../../hooks/usePendingCommands'
|
||||
import {
|
||||
useChoreHistory,
|
||||
@@ -35,6 +47,7 @@ import { useNotification } from '../../service/NotificationProvider'
|
||||
import { ChoreHistoryStatus } from '../../utils/Chores'
|
||||
import LoadingComponent from '../components/Loading'
|
||||
import EditHistoryModal from '../Modals/EditHistoryModal'
|
||||
import HistoryDetailModal from '../Modals/HistoryDetailModal'
|
||||
import ConfirmationModal from '../Modals/Inputs/ConfirmationModal'
|
||||
import NoteViewerModal from '../Modals/Inputs/NoteViewerModal'
|
||||
import HistoryCard from './HistoryCard'
|
||||
@@ -49,7 +62,8 @@ const ChoreHistory = () => {
|
||||
const { fmt } = useLocalization()
|
||||
const [showMoreInfoId, setShowMoreInfoId] = useState(null)
|
||||
const [noteViewerConfig, setNoteViewerConfig] = useState({ isOpen: false })
|
||||
const { showSuccess } = useNotification()
|
||||
const [detailModalConfig, setDetailModalConfig] = useState({ isOpen: false })
|
||||
const { showSuccess, showError } = useNotification()
|
||||
// React Query hooks
|
||||
const { data: choreHistoryData, isLoading } = useChoreHistory(choreId)
|
||||
const { data: circleMembersData } = useCircleMembers()
|
||||
@@ -77,6 +91,61 @@ const ChoreHistory = () => {
|
||||
}, {})
|
||||
}, [pendingCmds])
|
||||
|
||||
const filterDefs = useMemo(
|
||||
() => [
|
||||
{
|
||||
id: 'status',
|
||||
label: 'Status',
|
||||
type: 'multi-select',
|
||||
icon: <FilterList />,
|
||||
options: [
|
||||
{ value: ChoreHistoryStatus.COMPLETED, label: 'Completed', color: 'success', icon: <Check sx={{ fontSize: 14 }} /> },
|
||||
{ value: ChoreHistoryStatus.SKIPPED, label: 'Skipped', color: 'warning', icon: <Redo sx={{ fontSize: 14 }} /> },
|
||||
{ value: ChoreHistoryStatus.PENDING_APPROVAL, label: 'Pending', color: 'neutral', icon: <HourglassEmpty sx={{ fontSize: 14 }} /> },
|
||||
{ value: ChoreHistoryStatus.REJECTED, 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),
|
||||
},
|
||||
{
|
||||
id: 'hasNotes',
|
||||
label: 'Has Notes',
|
||||
type: 'boolean',
|
||||
icon: <EventNote />,
|
||||
filterFn: item => !!item.notes,
|
||||
},
|
||||
{
|
||||
id: 'completedBy',
|
||||
label: 'Completed By',
|
||||
type: 'multi-select',
|
||||
icon: <Person />,
|
||||
options: performers.map(p => ({
|
||||
value: p.userId,
|
||||
label: p.displayName,
|
||||
avatar: p.image,
|
||||
})),
|
||||
filterFn: (item, values) => values.includes(item.completedBy),
|
||||
},
|
||||
{
|
||||
id: 'dateRange',
|
||||
label: 'Completed At',
|
||||
type: 'date-range',
|
||||
icon: <CalendarMonth />,
|
||||
filterFn: (item, value) => {
|
||||
const performed = new Date(item.performedAt || item.updatedAt)
|
||||
if (value.from && performed < new Date(value.from)) return false
|
||||
if (value.to && performed > new Date(value.to)) return false
|
||||
return true
|
||||
},
|
||||
},
|
||||
],
|
||||
[performers],
|
||||
)
|
||||
|
||||
const { filteredData: filteredHistory, activeFilters, setFilter, clearAll, activeFilterCount } =
|
||||
useFilter(choreHistory, filterDefs)
|
||||
|
||||
const handleDelete = historyEntry => {
|
||||
showConfirmation(
|
||||
`Are you sure you want to delete this history record?`,
|
||||
@@ -224,9 +293,10 @@ const ChoreHistory = () => {
|
||||
}
|
||||
|
||||
return (
|
||||
<Container maxWidth='md'>
|
||||
<Container maxWidth='md' sx={{ px: 0 }}>
|
||||
{/* Enhanced Header Section */}
|
||||
<Box sx={{ mb: 4 }}>
|
||||
<Box sx={{ gap: 2, p: 2 }}>
|
||||
{/* <Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 2, p: 2 }}> */}
|
||||
{/* Statistics Cards Grid - Compact Design */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 3 }}>
|
||||
<History sx={{ fontSize: '1.5rem' }} />
|
||||
@@ -304,7 +374,9 @@ const ChoreHistory = () => {
|
||||
</Box>
|
||||
|
||||
{/* History Section Header */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 3 }}>
|
||||
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, p: 2 }}>
|
||||
|
||||
<Analytics sx={{ fontSize: '1.5rem' }} />
|
||||
<Typography
|
||||
level='title-md'
|
||||
@@ -313,14 +385,50 @@ const ChoreHistory = () => {
|
||||
Task Activity
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ px: 2 }}>
|
||||
<FilterBar
|
||||
filterDefs={filterDefs}
|
||||
activeFilters={activeFilters}
|
||||
onSetFilter={setFilter}
|
||||
onClearAll={clearAll}
|
||||
resultCount={filteredHistory.length}
|
||||
totalCount={choreHistory.length}
|
||||
/>
|
||||
</Box>
|
||||
{filteredHistory.length === 0 && activeFilterCount > 0 && (
|
||||
<Box
|
||||
sx={{
|
||||
textAlign: 'center',
|
||||
py: 6,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
gap: 1.5,
|
||||
}}
|
||||
>
|
||||
<FilterList sx={{ fontSize: '3rem', color: 'text.tertiary' }} />
|
||||
<Typography level='title-md' sx={{ color: 'text.secondary' }}>
|
||||
No results match your filters
|
||||
</Typography>
|
||||
<Typography level='body-sm' sx={{ color: 'text.tertiary' }}>
|
||||
Try adjusting or clearing the active filters.
|
||||
</Typography>
|
||||
<Button variant='soft' size='sm' onClick={clearAll} sx={{ mt: 0.5 }}>
|
||||
Clear filters
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{filteredHistory.length > 0 && (
|
||||
<Sheet
|
||||
variant='plain'
|
||||
sx={{ borderRadius: 'sm', boxShadow: 'md', overflow: 'hidden' }}
|
||||
sx={{ borderRadius: 'sm', overflow: 'hidden' }}
|
||||
>
|
||||
{/* Chore History List (Updated Style) */}
|
||||
|
||||
<SwipeableList type={ListType.IOS} fullSwipe={false}>
|
||||
{choreHistory.map((historyEntry, index) => (
|
||||
{filteredHistory.map((historyEntry, index) => (
|
||||
<SwipeableListItem
|
||||
key={historyEntry.id || index}
|
||||
swipeActionOpen={
|
||||
@@ -385,6 +493,19 @@ const ChoreHistory = () => {
|
||||
performers={performers}
|
||||
allHistory={choreHistory}
|
||||
index={index}
|
||||
onViewDetails={() => {
|
||||
setDetailModalConfig({
|
||||
isOpen: true,
|
||||
entry: historyEntry,
|
||||
performers,
|
||||
onClose: () => setDetailModalConfig({ isOpen: false }),
|
||||
onEdit: record => {
|
||||
setDetailModalConfig({ isOpen: false })
|
||||
setEditHistory(record)
|
||||
setIsEditModalOpen(true)
|
||||
},
|
||||
})
|
||||
}}
|
||||
pendingCommands={pendingByHistoryId[historyEntry.id] || []}
|
||||
onViewNote={notes => {
|
||||
setNoteViewerConfig({
|
||||
@@ -407,6 +528,7 @@ const ChoreHistory = () => {
|
||||
))}
|
||||
</SwipeableList>
|
||||
</Sheet>
|
||||
)}
|
||||
<EditHistoryModal
|
||||
config={{
|
||||
isOpen: isEditModalOpen,
|
||||
@@ -481,6 +603,7 @@ const ChoreHistory = () => {
|
||||
/>
|
||||
<ConfirmationModal config={confirmModalConfig} />
|
||||
<NoteViewerModal config={noteViewerConfig} />
|
||||
<HistoryDetailModal config={detailModalConfig} />
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,99 +1,47 @@
|
||||
import {
|
||||
AccessTime,
|
||||
CalendarMonth,
|
||||
Check,
|
||||
EventNote,
|
||||
HourglassEmpty,
|
||||
MoreVert,
|
||||
Person,
|
||||
Redo,
|
||||
RunningWithErrors,
|
||||
Schedule,
|
||||
ThumbDown,
|
||||
Timelapse,
|
||||
Toll,
|
||||
} from '@mui/icons-material'
|
||||
import { Avatar, Box, Chip, Grid, IconButton, Typography } from '@mui/joy'
|
||||
import { Avatar, Box, Card, Chip, IconButton, Typography } from '@mui/joy'
|
||||
import moment from 'moment'
|
||||
import { useLocalization } from '../../contexts/LocalizationContext'
|
||||
import { TASK_COLOR } from '../../utils/Colors.jsx'
|
||||
import PendingBadge from '../components/PendingBadge'
|
||||
|
||||
const getCompletedChip = historyEntry => {
|
||||
if (
|
||||
historyEntry.status === 0 ||
|
||||
historyEntry.status === 5 ||
|
||||
historyEntry.status === 6
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (!historyEntry.dueDate) {
|
||||
return null
|
||||
// <Chip
|
||||
// size='sm'
|
||||
// variant='soft'
|
||||
// color='neutral'
|
||||
// startDecorator={<CalendarViewDay />}
|
||||
// >
|
||||
// No Due Date
|
||||
// </Chip>
|
||||
}
|
||||
|
||||
const performedAt = moment(historyEntry.performedAt)
|
||||
const dueDate = moment(historyEntry.dueDate)
|
||||
// TODO: make this a config at some point
|
||||
const gracePeriod = 6 * 60 * 60 * 1000 // 6 hours in milliseconds
|
||||
|
||||
if (Math.abs(performedAt - dueDate) <= gracePeriod) {
|
||||
return (
|
||||
<Chip
|
||||
size='sm'
|
||||
variant='solid'
|
||||
sx={{ backgroundColor: TASK_COLOR.COMPLETED, color: 'white' }}
|
||||
startDecorator={<Check />}
|
||||
>
|
||||
On Time
|
||||
</Chip>
|
||||
)
|
||||
} else if (performedAt.isBefore(dueDate)) {
|
||||
return (
|
||||
<Chip
|
||||
size='sm'
|
||||
variant='soft'
|
||||
sx={{ backgroundColor: TASK_COLOR.SCHEDULED, color: 'white' }}
|
||||
startDecorator={<Check />}
|
||||
>
|
||||
Early
|
||||
</Chip>
|
||||
)
|
||||
} else {
|
||||
return (
|
||||
<Chip
|
||||
size='sm'
|
||||
variant='solid'
|
||||
sx={{ backgroundColor: TASK_COLOR.LATE, color: 'white' }}
|
||||
startDecorator={<Timelapse />}
|
||||
>
|
||||
Late
|
||||
</Chip>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const formatTime = seconds => {
|
||||
if (typeof seconds !== 'number' || isNaN(seconds) || seconds < 0) {
|
||||
return null
|
||||
}
|
||||
const hours = Math.floor(seconds / 3600)
|
||||
const minutes = Math.floor((seconds % 3600) / 60)
|
||||
const secs = seconds % 60
|
||||
return `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`
|
||||
if (typeof seconds !== 'number' || isNaN(seconds) || seconds < 0) return null
|
||||
const h = Math.floor(seconds / 3600)
|
||||
const m = Math.floor((seconds % 3600) / 60)
|
||||
const s = seconds % 60
|
||||
return `${h.toString().padStart(2, '0')}:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`
|
||||
}
|
||||
|
||||
const stripHtmlTags = html => {
|
||||
if (!html) return ''
|
||||
if (typeof document === 'undefined') {
|
||||
return String(html).replace(/<[^>]*>/g, '')
|
||||
}
|
||||
const div = document.createElement('div')
|
||||
div.innerHTML = html
|
||||
return div.textContent || div.innerText || ''
|
||||
}
|
||||
|
||||
const statusConfig = {
|
||||
0: { label: 'In Progress', color: 'primary', icon: <AccessTime /> },
|
||||
1: { label: 'Completed', color: 'success', icon: <Check /> },
|
||||
2: { label: 'Skipped', color: 'warning', icon: <Redo /> },
|
||||
3: { label: 'Pending Approval', color: 'neutral', icon: <HourglassEmpty /> },
|
||||
4: { label: 'Rejected', color: 'danger', icon: <ThumbDown /> },
|
||||
5: { label: 'Missed', color: 'danger', icon: <RunningWithErrors /> },
|
||||
6: { label: 'Rescheduled', color: 'warning', icon: <Schedule /> },
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact HistoryCard component - content only
|
||||
*/
|
||||
const HistoryCard = ({
|
||||
allHistory,
|
||||
performers,
|
||||
@@ -102,235 +50,137 @@ const HistoryCard = ({
|
||||
pendingCommands,
|
||||
onToggleActions,
|
||||
onViewNote,
|
||||
onViewDetails,
|
||||
}) => {
|
||||
const { fmt } = useLocalization()
|
||||
const performer = performers.find(p => p.userId === historyEntry.completedBy)
|
||||
const assignedTo = performers.find(p => p.userId === historyEntry.assignedTo)
|
||||
const config = statusConfig[historyEntry.status] ?? statusConfig[1]
|
||||
const displayLabel =
|
||||
historyEntry.status === 6 && !historyEntry.dueDate ? 'Scheduled' : config.label
|
||||
const actionDate = historyEntry.performedAt || historyEntry.updatedAt
|
||||
|
||||
const formatTimeDifference = (startDate, endDate) => {
|
||||
const diffInMinutes = moment(startDate).diff(endDate, 'minutes')
|
||||
let timeValue = diffInMinutes
|
||||
let unit = 'minute'
|
||||
const getTimingLine = () => {
|
||||
const { status, performedAt, dueDate } = historyEntry
|
||||
if (!dueDate) return null
|
||||
|
||||
if (diffInMinutes >= 60) {
|
||||
const diffInHours = moment(startDate).diff(endDate, 'hours')
|
||||
timeValue = diffInHours
|
||||
unit = 'hour'
|
||||
|
||||
if (diffInHours >= 24) {
|
||||
const diffInDays = moment(startDate).diff(endDate, 'days')
|
||||
timeValue = diffInDays
|
||||
unit = 'day'
|
||||
if (status === 6) {
|
||||
return `Was due ${moment(dueDate).format('MMM D')}`
|
||||
}
|
||||
if (status === 5) {
|
||||
return `Was due ${moment(dueDate).format('MMM D')}`
|
||||
}
|
||||
if ((status === 1 || status === 2 || status === 0) && performedAt) {
|
||||
const diffHours = moment(performedAt).diff(dueDate, 'hours')
|
||||
const abs = Math.abs(diffHours)
|
||||
if (abs <= 6) return null // chip already says "On Time"
|
||||
if (diffHours < 0) return abs >= 48 ? `${Math.floor(abs / 24)}d before due date` : `${abs}h before due date`
|
||||
return abs >= 48 ? `${Math.floor(abs / 24)}d after due date` : `${abs}h after due date`
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
return `${timeValue} ${unit}${timeValue !== 1 ? 's' : ''}`
|
||||
}
|
||||
const timingLine = getTimingLine()
|
||||
const noteLabel = historyEntry.status === 2 || historyEntry.status === 4 ? 'Reason' : 'Note'
|
||||
const plainTextNotes = historyEntry.notes ? stripHtmlTags(historyEntry.notes) : ''
|
||||
|
||||
const getStatusAvatar = () => {
|
||||
const statusMap = {
|
||||
0: { icon: <AccessTime />, color: 'primary' }, // Started
|
||||
1: { icon: <Check />, color: 'success' }, // Completed
|
||||
2: { icon: <Redo />, color: 'warning' }, // Skipped
|
||||
3: { icon: <HourglassEmpty />, color: 'neutral' }, // Pending Approval
|
||||
4: { icon: <ThumbDown />, color: 'danger' }, // Rejected
|
||||
5: { icon: <RunningWithErrors />, color: 'danger' }, // Missed
|
||||
6: { icon: <Schedule />, color: 'warning' }, // Rescheduled
|
||||
}
|
||||
const metaTextParts = [
|
||||
fmt.dateTime(actionDate),
|
||||
historyEntry.completedBy !== historyEntry.assignedTo && assignedTo
|
||||
? `Assigned to ${assignedTo.displayName}`
|
||||
: null,
|
||||
historyEntry?.duration > 0 ? `⏱ ${formatTime(historyEntry.duration)}` : null,
|
||||
historyEntry?.points > 0 ? `★ ${historyEntry.points} pt${historyEntry.points > 1 ? 's' : ''}` : null,
|
||||
].filter(Boolean)
|
||||
|
||||
const config = statusMap[historyEntry.status] || statusMap[1]
|
||||
return (
|
||||
<Box
|
||||
onClick={() => onViewDetails?.()}
|
||||
sx={{
|
||||
display: 'flex',
|
||||
minWidth: '100%',
|
||||
bgcolor: 'background.body',
|
||||
borderBottom: '1px solid',
|
||||
borderColor: 'divider',
|
||||
borderLeft: '3px solid',
|
||||
borderLeftColor: `${config.color}.400`,
|
||||
cursor: onViewDetails ? 'pointer' : 'default',
|
||||
'&:hover': onViewDetails ? { bgcolor: 'background.level1' } : {},
|
||||
}}
|
||||
>
|
||||
<Box sx={{ flex: 1, minWidth: 0, px: 2, py: 1.5 }}>
|
||||
{/* Status + timing chip */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 0.5 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75 }}>
|
||||
<Avatar
|
||||
size='sm'
|
||||
color={config.color}
|
||||
variant='soft'
|
||||
sx={{
|
||||
width: 24,
|
||||
height: 24,
|
||||
'& svg': { fontSize: '14px' },
|
||||
}}
|
||||
sx={{ width: 20, height: 20, '& svg': { fontSize: '11px' } }}
|
||||
>
|
||||
{config.icon}
|
||||
</Avatar>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
minHeight: 64,
|
||||
minWidth: '100%',
|
||||
px: 2,
|
||||
py: 1.5,
|
||||
bgcolor: 'background.body',
|
||||
borderBottom: '1px solid',
|
||||
borderColor: 'divider',
|
||||
}}
|
||||
>
|
||||
<Box sx={{ flex: 1, minWidth: 0 }}>
|
||||
<Grid container spacing={1} alignItems='center'>
|
||||
{/* First Row/Column: Status and Time Info */}
|
||||
<Grid xs={12} sm={8}>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 1,
|
||||
flexWrap: 'wrap',
|
||||
}}
|
||||
>
|
||||
{getStatusAvatar()}
|
||||
|
||||
<Typography
|
||||
level='body-sm'
|
||||
sx={{
|
||||
color: 'text.secondary',
|
||||
fontWeight: 'md',
|
||||
}}
|
||||
>
|
||||
{historyEntry.status === 0
|
||||
? 'In Progress'
|
||||
: historyEntry.status === 1
|
||||
? 'Completed'
|
||||
: historyEntry.status === 2
|
||||
? 'Skipped'
|
||||
: historyEntry.status === 3
|
||||
? 'Pending Approval'
|
||||
: historyEntry.status === 4
|
||||
? 'Rejected'
|
||||
: historyEntry.status === 5
|
||||
? 'Missed'
|
||||
: historyEntry.status === 6
|
||||
? 'Rescheduled'
|
||||
: 'Completed'}
|
||||
<Typography level='title-sm' fontWeight='lg' sx={{ color: `${config.color}.plainColor` }}>
|
||||
{displayLabel}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Chip size='sm' startDecorator={<EventNote />}>
|
||||
{fmt.dateTime(
|
||||
historyEntry.performedAt || historyEntry.updatedAt,
|
||||
{/* Timing relationship line */}
|
||||
{timingLine && (
|
||||
<Typography level='body-xs' sx={{ color: 'text.tertiary', mb: 0.25 }}>
|
||||
{timingLine}
|
||||
</Typography>
|
||||
)}
|
||||
</Chip>
|
||||
|
||||
<Box sx={{ display: 'flex', gap: 0.5 }}>
|
||||
{getCompletedChip(historyEntry)}
|
||||
</Box>
|
||||
</Box>
|
||||
</Grid>
|
||||
{/* Notes inline */}
|
||||
|
||||
{/* Second Row/Column: Completion Status (right side on desktop) */}
|
||||
<Grid xs={12} sm={4}>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
justifyContent: { xs: 'flex-start', sm: 'flex-end' },
|
||||
alignItems: 'center',
|
||||
gap: 1,
|
||||
}}
|
||||
{plainTextNotes && (
|
||||
<Card
|
||||
variant='soft'
|
||||
color='neutral'
|
||||
size='sm'
|
||||
sx={{ mt: 0.5, whiteSpace: 'pre-wrap', overflow: 'hidden', textOverflow: 'ellipsis' }}
|
||||
>
|
||||
{historyEntry.dueDate && (
|
||||
<Chip size='sm' startDecorator={<CalendarMonth />}>
|
||||
{fmt.dateTime(historyEntry.dueDate)}
|
||||
</Chip>
|
||||
<Typography
|
||||
level='body-xs'
|
||||
sx={{ color: 'text.secondary', fontStyle: 'italic', mb: 0.25, cursor: 'pointer' }}
|
||||
onClick={e => { e.stopPropagation(); onViewNote?.(historyEntry.notes) }}
|
||||
>
|
||||
{plainTextNotes.length > 80 ? `${plainTextNotes.slice(0, 80)}…` : plainTextNotes}
|
||||
</Typography>
|
||||
</Card>
|
||||
)}
|
||||
</Box>
|
||||
</Grid>
|
||||
|
||||
{/* Third Row: Performer and Assignment Info */}
|
||||
<Grid xs={12}>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 1,
|
||||
flexWrap: 'wrap',
|
||||
mt: 0.5,
|
||||
}}
|
||||
>
|
||||
{/* Metadata strip: performer chip + date + extras */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, mt: 0.5, flexWrap: 'wrap' }}>
|
||||
{performer && (
|
||||
<Chip
|
||||
size='sm'
|
||||
variant='solid'
|
||||
color='success'
|
||||
variant='soft'
|
||||
color='neutral'
|
||||
startDecorator={
|
||||
<Avatar
|
||||
src={performer?.image}
|
||||
alt={performer?.displayName}
|
||||
/>
|
||||
<Avatar src={performer.image} alt={performer.displayName} sx={{ width: 14, height: 14 }} />
|
||||
}
|
||||
>
|
||||
{performer?.displayName || 'Unknown'}
|
||||
{performer.displayName}
|
||||
</Chip>
|
||||
)}
|
||||
|
||||
{historyEntry.completedBy !== historyEntry.assignedTo &&
|
||||
assignedTo && (
|
||||
<Chip
|
||||
size='sm'
|
||||
variant='outlined'
|
||||
color='neutral'
|
||||
startDecorator={<Person />}
|
||||
>
|
||||
Assigned to {assignedTo.displayName}
|
||||
</Chip>
|
||||
)}
|
||||
|
||||
{historyEntry.notes && (
|
||||
<Chip
|
||||
size='sm'
|
||||
variant='plain'
|
||||
color='neutral'
|
||||
startDecorator={<EventNote />}
|
||||
sx={{
|
||||
maxWidth: '120px',
|
||||
overflow: 'hidden',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
onClick={e => {
|
||||
e.stopPropagation()
|
||||
onViewNote?.(historyEntry.notes)
|
||||
}}
|
||||
>
|
||||
Note
|
||||
</Chip>
|
||||
)}
|
||||
{/* add a duration chip if we have duration */}
|
||||
{historyEntry?.duration > 0 && (
|
||||
<Chip
|
||||
size='sm'
|
||||
variant='soft'
|
||||
color='primary'
|
||||
startDecorator={<AccessTime />}
|
||||
>
|
||||
{formatTime(historyEntry.duration)}
|
||||
</Chip>
|
||||
)}
|
||||
{historyEntry?.points > 0 && (
|
||||
<Chip
|
||||
size='sm'
|
||||
variant='solid'
|
||||
color='success'
|
||||
startDecorator={<Toll />}
|
||||
>
|
||||
{historyEntry.points} pt
|
||||
{historyEntry.points > 1 ? 's' : ''}
|
||||
</Chip>
|
||||
{metaTextParts.length > 0 && (
|
||||
<Typography level='body-xs' sx={{ color: 'text.tertiary' }}>
|
||||
{metaTextParts.join(' · ')}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Box>
|
||||
<Box>
|
||||
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', pr: 0.5 }} onClick={e => e.stopPropagation()}>
|
||||
{onToggleActions && (
|
||||
<IconButton
|
||||
color='neutral'
|
||||
variant='plain'
|
||||
size='sm'
|
||||
onClick={e => {
|
||||
e.stopPropagation()
|
||||
onToggleActions()
|
||||
}}
|
||||
onClick={e => { e.stopPropagation(); onToggleActions() }}
|
||||
>
|
||||
<MoreVert sx={{ fontSize: 18 }} />
|
||||
</IconButton>
|
||||
|
||||
230
src/views/Modals/HistoryDetailModal.jsx
Normal file
230
src/views/Modals/HistoryDetailModal.jsx
Normal file
@@ -0,0 +1,230 @@
|
||||
import {
|
||||
AccessTime,
|
||||
CalendarMonth,
|
||||
Check,
|
||||
Edit,
|
||||
HourglassEmpty,
|
||||
OpenInNew,
|
||||
Person,
|
||||
Redo,
|
||||
RunningWithErrors,
|
||||
Schedule,
|
||||
ThumbDown,
|
||||
Update,
|
||||
} from '@mui/icons-material'
|
||||
import { Avatar, Box, Button, Chip, Divider, Stack, Typography } from '@mui/joy'
|
||||
import moment from 'moment'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useLocalization } from '../../contexts/LocalizationContext'
|
||||
import { useResponsiveModal } from '../../hooks/useResponsiveModal'
|
||||
import { TASK_COLOR } from '../../utils/Colors.jsx'
|
||||
import RichTextEditor from '../components/RichTextEditor.jsx'
|
||||
|
||||
const STATUS_CONFIG = {
|
||||
0: { label: 'In Progress', color: 'primary', icon: <AccessTime /> },
|
||||
1: { label: 'Completed', color: 'success', icon: <Check /> },
|
||||
2: { label: 'Skipped', color: 'warning', icon: <Redo /> },
|
||||
3: { label: 'Pending Approval', color: 'neutral', icon: <HourglassEmpty /> },
|
||||
4: { label: 'Rejected', color: 'danger', icon: <ThumbDown /> },
|
||||
5: { label: 'Missed', color: 'danger', icon: <RunningWithErrors /> },
|
||||
6: { label: 'Rescheduled', color: 'warning', icon: <Schedule /> },
|
||||
}
|
||||
|
||||
const DetailRow = ({ icon, label, value, children }) => (
|
||||
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 1.5, py: 0.75 }}>
|
||||
<Box sx={{ color: 'text.tertiary', mt: 0.25, flexShrink: 0, display: 'flex' }}>{icon}</Box>
|
||||
<Box sx={{ flex: 1, minWidth: 0 }}>
|
||||
<Typography level='body-xs' sx={{ color: 'text.tertiary', mb: 0.15 }}>{label}</Typography>
|
||||
{children ?? (
|
||||
<Typography level='body-sm' sx={{ color: 'text.primary', fontWeight: 'md' }}>{value}</Typography>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
|
||||
const TimingBadge = ({ historyEntry }) => {
|
||||
if (!historyEntry.dueDate || !historyEntry.performedAt) return null
|
||||
if ([0, 5, 6].includes(historyEntry.status)) return null
|
||||
|
||||
const performedAt = moment(historyEntry.performedAt)
|
||||
const dueDate = moment(historyEntry.dueDate)
|
||||
const diffHours = performedAt.diff(dueDate, 'hours')
|
||||
const gracePeriod = 6 * 60 * 60 * 1000
|
||||
|
||||
if (Math.abs(performedAt - dueDate) <= gracePeriod) {
|
||||
return <Chip size='sm' variant='solid' sx={{ backgroundColor: TASK_COLOR.COMPLETED, color: 'white' }} startDecorator={<Check />}>On Time</Chip>
|
||||
} else if (performedAt.isBefore(dueDate)) {
|
||||
const abs = Math.abs(diffHours)
|
||||
const label = abs >= 48 ? `${Math.floor(abs / 24)}d early` : `${abs}h early`
|
||||
return <Chip size='sm' variant='soft' sx={{ backgroundColor: TASK_COLOR.SCHEDULED, color: 'white' }} startDecorator={<Check />}>{label}</Chip>
|
||||
} else {
|
||||
const abs = Math.abs(diffHours)
|
||||
const label = abs >= 48 ? `${Math.floor(abs / 24)}d late` : `${abs}h late`
|
||||
return <Chip size='sm' variant='solid' sx={{ backgroundColor: TASK_COLOR.LATE, color: 'white' }}>{label}</Chip>
|
||||
}
|
||||
}
|
||||
|
||||
function HistoryDetailModal({ config }) {
|
||||
const { ResponsiveModal } = useResponsiveModal()
|
||||
const { fmt } = useLocalization()
|
||||
const navigate = useNavigate()
|
||||
|
||||
const entry = config?.entry
|
||||
const performers = config?.performers ?? []
|
||||
|
||||
if (!entry) return null
|
||||
|
||||
const statusCfg = STATUS_CONFIG[entry.status] ?? STATUS_CONFIG[1]
|
||||
const isFirstSchedule = entry.status === 6 && !entry.dueDate
|
||||
const statusLabel = isFirstSchedule ? 'Scheduled' : statusCfg.label
|
||||
const performer = performers.find(p => p.userId === entry.completedBy)
|
||||
const assignedTo = performers.find(p => p.userId === entry.assignedTo)
|
||||
const isDifferentAssignee = entry.assignedTo && entry.completedBy !== entry.assignedTo
|
||||
|
||||
// updatedAt is only meaningful if it differs from performedAt by more than a minute
|
||||
const showUpdatedAt =
|
||||
entry.updatedAt &&
|
||||
entry.performedAt &&
|
||||
Math.abs(moment(entry.updatedAt).diff(entry.performedAt, 'minutes')) > 1
|
||||
|
||||
const formatDuration = seconds => {
|
||||
if (!seconds || seconds <= 0) return null
|
||||
const h = Math.floor(seconds / 3600)
|
||||
const m = Math.floor((seconds % 3600) / 60)
|
||||
const s = seconds % 60
|
||||
return `${h.toString().padStart(2, '0')}:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`
|
||||
}
|
||||
|
||||
return (
|
||||
<ResponsiveModal
|
||||
open={config?.isOpen}
|
||||
onClose={config?.onClose}
|
||||
title='Activity Detail'
|
||||
>
|
||||
{/* Status header */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1.5 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Avatar size='sm' color={statusCfg.color} variant='soft'>
|
||||
{statusCfg.icon}
|
||||
</Avatar>
|
||||
<Typography level='title-md' fontWeight='lg' sx={{ color: `${statusCfg.color}.plainColor` }}>
|
||||
{statusLabel}
|
||||
</Typography>
|
||||
</Box>
|
||||
<TimingBadge historyEntry={entry} />
|
||||
</Box>
|
||||
|
||||
<Divider sx={{ mb: 1.5 }} />
|
||||
|
||||
<Stack spacing={0}>
|
||||
{/* Who performed it */}
|
||||
{performer && (
|
||||
<DetailRow icon={<Check sx={{ fontSize: 16 }} />} label='Performed by'>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75 }}>
|
||||
<Avatar src={performer.image} alt={performer.displayName} size='sm' sx={{ width: 20, height: 20 }} />
|
||||
<Typography level='body-sm' fontWeight='md'>{performer.displayName}</Typography>
|
||||
</Box>
|
||||
</DetailRow>
|
||||
)}
|
||||
|
||||
{/* Assigned to (only if different) */}
|
||||
{isDifferentAssignee && assignedTo && (
|
||||
<DetailRow icon={<Person sx={{ fontSize: 16 }} />} label='Assigned to' value={assignedTo.displayName} />
|
||||
)}
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* Performed at */}
|
||||
{entry.performedAt && (
|
||||
<DetailRow
|
||||
icon={<AccessTime sx={{ fontSize: 16 }} />}
|
||||
label={isFirstSchedule ? 'Scheduled on' : entry.status === 6 ? 'Rescheduled on' : entry.status === 2 ? 'Skipped on' : 'Completed on'}
|
||||
value={fmt.dateTime(entry.performedAt)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Due date */}
|
||||
{entry.dueDate && (
|
||||
<DetailRow
|
||||
icon={<CalendarMonth sx={{ fontSize: 16 }} />}
|
||||
label={entry.status === 6 ? 'Previous due date' : entry.status === 5 ? 'Was due' : 'Due date'}
|
||||
value={fmt.dateTime(entry.dueDate)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Last updated (only if meaningfully different from performedAt) */}
|
||||
{showUpdatedAt && (
|
||||
<DetailRow
|
||||
icon={<Update sx={{ fontSize: 16 }} />}
|
||||
label='Last updated'
|
||||
value={fmt.dateTime(entry.updatedAt)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Duration */}
|
||||
{entry.duration > 0 && (
|
||||
<DetailRow
|
||||
icon={<Schedule sx={{ fontSize: 16 }} />}
|
||||
label='Duration'
|
||||
value={formatDuration(entry.duration)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Points */}
|
||||
{entry.points > 0 && (
|
||||
<DetailRow
|
||||
icon={<Typography sx={{ fontSize: 14 }}>★</Typography>}
|
||||
label='Points earned'
|
||||
value={`${entry.points} pt${entry.points > 1 ? 's' : ''}`}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Notes */}
|
||||
{entry.notes && (
|
||||
<>
|
||||
<Divider />
|
||||
<Box sx={{ pt: 1 }}>
|
||||
<Typography level='body-xs' sx={{ color: 'text.tertiary', mb: 0.5 }}>
|
||||
{entry.status === 2 || entry.status === 4 ? 'Reason' : 'Notes'}
|
||||
</Typography>
|
||||
<Box sx={{ overflowY: 'auto', maxHeight: '60vh' }}>
|
||||
<RichTextEditor value={entry.notes || ''} isEditable={false} />
|
||||
</Box>
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
{/* Action buttons */}
|
||||
<Box sx={{ display: 'flex', gap: 1, mt: 2, justifyContent: 'flex-end' }}>
|
||||
{entry.choreId && (
|
||||
<Button
|
||||
variant='soft'
|
||||
color='neutral'
|
||||
size='sm'
|
||||
startDecorator={<OpenInNew sx={{ fontSize: 16 }} />}
|
||||
onClick={() => {
|
||||
config?.onClose?.()
|
||||
navigate(`/chores/${entry.choreId}`)
|
||||
}}
|
||||
>
|
||||
Open Task
|
||||
</Button>
|
||||
)}
|
||||
{config?.onEdit && (
|
||||
<Button
|
||||
variant='soft'
|
||||
color='neutral'
|
||||
size='md'
|
||||
startDecorator={<Edit sx={{ fontSize: 16 }} />}
|
||||
onClick={() => config.onEdit(entry)}
|
||||
>
|
||||
Edit Entry
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
</ResponsiveModal>
|
||||
)
|
||||
}
|
||||
|
||||
export default HistoryDetailModal
|
||||
@@ -1,22 +1,21 @@
|
||||
import { Add, Delete } from '@mui/icons-material'
|
||||
import { Save } from '@mui/icons-material'
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Chip,
|
||||
IconButton,
|
||||
Divider,
|
||||
Input,
|
||||
List,
|
||||
ListItem,
|
||||
Option,
|
||||
Select,
|
||||
Textarea,
|
||||
Typography,
|
||||
} from '@mui/joy'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useResponsiveModal } from '../../../hooks/useResponsiveModal'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import BottomSheetModal from '../../../components/common/BottomSheetModal'
|
||||
import FilterBuilderContent, {
|
||||
conditionsToSelections,
|
||||
defaultSelections,
|
||||
selectionsToConditions,
|
||||
} from '../../Chores/components/FilterBuilderContent'
|
||||
import { FILTER_COLORS } from '../../../utils/Colors'
|
||||
import { applyFilter } from '../../../utils/FilterEngine'
|
||||
import Priorities from '../../../utils/Priorities'
|
||||
import { useFilters } from '../../Filters/FilterQueries'
|
||||
|
||||
const AdvancedFilterBuilder = ({
|
||||
@@ -30,527 +29,149 @@ const AdvancedFilterBuilder = ({
|
||||
userProfile = null,
|
||||
editingFilter = null,
|
||||
}) => {
|
||||
const { ResponsiveModal } = useResponsiveModal()
|
||||
const listContainerRef = useRef(null)
|
||||
const conditionRefs = useRef([])
|
||||
const [filterName, setFilterName] = useState('')
|
||||
const [filterDescription, setFilterDescription] = useState('')
|
||||
const [filterColor, setFilterColor] = useState(FILTER_COLORS[0].value)
|
||||
const [conditions, setConditions] = useState([
|
||||
{ type: 'assignee', operator: 'is', value: [] },
|
||||
])
|
||||
const [selections, setSelections] = useState(defaultSelections())
|
||||
const [error, setError] = useState('')
|
||||
const { data: existedFilters = [] } = useFilters()
|
||||
|
||||
const filterNameExists = (name, excludeId = null) => {
|
||||
return existedFilters.some(
|
||||
filter =>
|
||||
filter.name.toLowerCase() === name.toLowerCase() &&
|
||||
filter.id !== excludeId,
|
||||
const filterNameExists = (name, excludeId = null) =>
|
||||
existedFilters.some(
|
||||
f => f.name.toLowerCase() === name.toLowerCase() && f.id !== excludeId,
|
||||
)
|
||||
}
|
||||
|
||||
// Initialize refs array when conditions change
|
||||
useEffect(() => {
|
||||
conditionRefs.current = conditionRefs.current.slice(0, conditions.length)
|
||||
}, [conditions.length])
|
||||
|
||||
// Initialize state when editing a filter
|
||||
useEffect(() => {
|
||||
if (!isOpen) return
|
||||
if (editingFilter) {
|
||||
setFilterName(editingFilter.name)
|
||||
setFilterDescription(editingFilter.description || '')
|
||||
setFilterColor(editingFilter.color || FILTER_COLORS[0].value)
|
||||
setConditions(editingFilter.conditions || [])
|
||||
setError('')
|
||||
setSelections(conditionsToSelections(editingFilter.conditions))
|
||||
} else {
|
||||
setFilterName('')
|
||||
setFilterDescription('')
|
||||
// find color no filter has it :
|
||||
const potentialColor = FILTER_COLORS.find(
|
||||
color => !existedFilters.some(filter => filter.color === color.value),
|
||||
c => !existedFilters.some(f => f.color === c.value),
|
||||
)
|
||||
|
||||
setFilterColor(
|
||||
potentialColor ? potentialColor.value : FILTER_COLORS[0].value,
|
||||
)
|
||||
setConditions([{ type: 'assignee', operator: 'is', value: [] }])
|
||||
setError('')
|
||||
setFilterColor(potentialColor?.value ?? FILTER_COLORS[0].value)
|
||||
setSelections(defaultSelections())
|
||||
}
|
||||
setError('')
|
||||
}, [editingFilter, isOpen])
|
||||
|
||||
const conditions = useMemo(() => selectionsToConditions(selections), [selections])
|
||||
|
||||
const previewChores = useMemo(() => {
|
||||
const validConditions = conditions.filter(c => {
|
||||
if (c.type === 'dueDate' || c.type === 'points') return true
|
||||
return c.value && (Array.isArray(c.value) ? c.value.length > 0 : true)
|
||||
})
|
||||
|
||||
if (validConditions.length === 0) return []
|
||||
|
||||
const result = applyFilter(
|
||||
if (conditions.length === 0) return []
|
||||
return applyFilter(
|
||||
allChores,
|
||||
{ conditions: validConditions, operator: 'AND' },
|
||||
{
|
||||
userId: userProfile?.id,
|
||||
members,
|
||||
labels,
|
||||
projects,
|
||||
},
|
||||
{ conditions, operator: 'AND' },
|
||||
{ userId: userProfile?.id, members, labels, projects },
|
||||
)
|
||||
|
||||
return result
|
||||
}, [conditions, allChores, userProfile, members, labels, projects])
|
||||
|
||||
const previewCount = previewChores.length
|
||||
const previewOverdueCount = previewChores.filter(
|
||||
chore => chore.nextDueDate && new Date(chore.nextDueDate) < new Date(),
|
||||
c => c.nextDueDate && new Date(c.nextDueDate) < new Date(),
|
||||
).length
|
||||
|
||||
const addCondition = () => {
|
||||
setConditions([
|
||||
...conditions,
|
||||
{ type: 'assignee', operator: 'is', value: [] },
|
||||
])
|
||||
|
||||
// Scroll to the new condition after it's rendered
|
||||
setTimeout(() => {
|
||||
const newIndex = conditions.length
|
||||
const newConditionElement = conditionRefs.current[newIndex]
|
||||
if (newConditionElement && listContainerRef.current) {
|
||||
newConditionElement.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'nearest',
|
||||
})
|
||||
}
|
||||
}, 100)
|
||||
}
|
||||
|
||||
const removeCondition = index => {
|
||||
setConditions(conditions.filter((_, i) => i !== index))
|
||||
}
|
||||
|
||||
const updateCondition = (index, field, value) => {
|
||||
const updated = [...conditions]
|
||||
updated[index] = { ...updated[index], [field]: value }
|
||||
|
||||
if (field === 'type') {
|
||||
updated[index].value = []
|
||||
if (value === 'dueDate') {
|
||||
updated[index].operator = 'isOverdue'
|
||||
updated[index].value = null
|
||||
} else if (value === 'status') {
|
||||
updated[index].value = []
|
||||
} else if (value === 'points') {
|
||||
updated[index].operator = 'greaterThan'
|
||||
updated[index].value = 0
|
||||
}
|
||||
}
|
||||
|
||||
setConditions(updated)
|
||||
}
|
||||
const activeConditionCount = conditions.length
|
||||
|
||||
const handleSave = () => {
|
||||
if (!filterName.trim()) {
|
||||
setError('Please enter a filter name')
|
||||
return
|
||||
}
|
||||
|
||||
// Check for duplicate name, excluding current filter if editing
|
||||
if (filterNameExists(filterName.trim(), editingFilter?.id)) {
|
||||
setError('A filter with this name already exists')
|
||||
return
|
||||
}
|
||||
|
||||
const validConditions = conditions.filter(c => {
|
||||
if (c.type === 'dueDate' || c.type === 'points') return true
|
||||
return c.value && (Array.isArray(c.value) ? c.value.length > 0 : true)
|
||||
})
|
||||
|
||||
if (conditions.length === 0 || validConditions.length === 0) {
|
||||
setError('Please add at least one filter condition')
|
||||
if (conditions.length === 0) {
|
||||
setError('Please configure at least one filter condition')
|
||||
return
|
||||
}
|
||||
|
||||
const filterData = {
|
||||
onSave({
|
||||
name: filterName.trim(),
|
||||
description: filterDescription.trim(),
|
||||
description: editingFilter?.description ?? '',
|
||||
color: filterColor,
|
||||
conditions: validConditions,
|
||||
conditions,
|
||||
operator: 'AND',
|
||||
}
|
||||
|
||||
// Include ID if editing
|
||||
if (editingFilter) {
|
||||
filterData.id = editingFilter.id
|
||||
}
|
||||
|
||||
onSave(filterData)
|
||||
...(editingFilter ? { id: editingFilter.id } : {}),
|
||||
})
|
||||
onClose()
|
||||
}
|
||||
|
||||
const renderValueSelector = (condition, index) => {
|
||||
switch (condition.type) {
|
||||
case 'assignee':
|
||||
return (
|
||||
<Select
|
||||
multiple
|
||||
value={condition.value || []}
|
||||
onChange={(_, newValue) =>
|
||||
updateCondition(index, 'value', newValue)
|
||||
}
|
||||
placeholder='Select assignees'
|
||||
sx={{ width: '100%' }}
|
||||
slotProps={{
|
||||
listbox: {
|
||||
placement: 'bottom-start',
|
||||
disablePortal: false,
|
||||
},
|
||||
}}
|
||||
renderValue={selected => (
|
||||
<Box sx={{ display: 'flex', gap: 0.5, flexWrap: 'wrap' }}>
|
||||
{selected.map((selectedElement, idx) => {
|
||||
const value = selectedElement.value
|
||||
const member = members.find(
|
||||
m => String(m.userId) === String(value),
|
||||
)
|
||||
return (
|
||||
<Chip key={`${value}-${idx}`} size='sm'>
|
||||
{member?.displayName || member?.username || 'Unknown'}
|
||||
</Chip>
|
||||
)
|
||||
})}
|
||||
</Box>
|
||||
)}
|
||||
>
|
||||
{members.map((member, idx) => (
|
||||
<Option
|
||||
key={`member-${member.userId}-${idx}`}
|
||||
value={member.userId}
|
||||
>
|
||||
{member.displayName || member.username} ({member.userId})
|
||||
</Option>
|
||||
))}
|
||||
</Select>
|
||||
)
|
||||
|
||||
case 'createdBy':
|
||||
return (
|
||||
<Select
|
||||
multiple
|
||||
value={condition.value || []}
|
||||
onChange={(_, newValue) =>
|
||||
updateCondition(index, 'value', newValue)
|
||||
}
|
||||
placeholder='Select creators'
|
||||
sx={{ width: '100%' }}
|
||||
slotProps={{
|
||||
listbox: {
|
||||
placement: 'bottom-start',
|
||||
disablePortal: false,
|
||||
},
|
||||
}}
|
||||
renderValue={selected => (
|
||||
<Box sx={{ display: 'flex', gap: 0.5, flexWrap: 'wrap' }}>
|
||||
{selected.map((selectedElement, idx) => {
|
||||
const value = selectedElement.value
|
||||
|
||||
const member = members.find(
|
||||
m => String(m.userId) === String(value),
|
||||
)
|
||||
return (
|
||||
<Chip key={`${value}-${idx}`} size='sm'>
|
||||
{member?.displayName || member?.username || 'Unknown'}
|
||||
</Chip>
|
||||
)
|
||||
})}
|
||||
</Box>
|
||||
)}
|
||||
>
|
||||
{members.map((member, idx) => (
|
||||
<Option
|
||||
key={`creator-${member.userId}-${idx}`}
|
||||
value={member.userId}
|
||||
>
|
||||
{member.displayName || member.username}
|
||||
</Option>
|
||||
))}
|
||||
</Select>
|
||||
)
|
||||
|
||||
case 'priority':
|
||||
return (
|
||||
<Select
|
||||
multiple
|
||||
value={condition.value || []}
|
||||
onChange={(_, newValue) =>
|
||||
updateCondition(index, 'value', newValue)
|
||||
}
|
||||
placeholder='Select priorities'
|
||||
sx={{ width: '100%' }}
|
||||
slotProps={{
|
||||
listbox: {
|
||||
placement: 'bottom-start',
|
||||
disablePortal: false,
|
||||
},
|
||||
}}
|
||||
renderValue={selected => (
|
||||
<Box sx={{ display: 'flex', gap: 0.5, flexWrap: 'wrap' }}>
|
||||
{selected.map((selectedElement, idx) => {
|
||||
const value = selectedElement.value
|
||||
const priority = Priorities.find(p => p.value === value)
|
||||
return (
|
||||
<Chip key={`priority-${value}-${idx}`} size='sm'>
|
||||
{priority?.name || `Priority ${value}`}
|
||||
</Chip>
|
||||
)
|
||||
})}
|
||||
</Box>
|
||||
)}
|
||||
>
|
||||
{Priorities.map((priority, idx) => (
|
||||
<Option
|
||||
key={`priority-opt-${priority.value}-${idx}`}
|
||||
value={priority.value}
|
||||
>
|
||||
{priority.name}
|
||||
</Option>
|
||||
))}
|
||||
</Select>
|
||||
)
|
||||
|
||||
case 'label':
|
||||
return (
|
||||
<Select
|
||||
multiple
|
||||
value={condition.value || []}
|
||||
onChange={(_, newValue) =>
|
||||
updateCondition(index, 'value', newValue)
|
||||
}
|
||||
placeholder='Select labels'
|
||||
sx={{ width: '100%' }}
|
||||
slotProps={{
|
||||
listbox: {
|
||||
placement: 'bottom-start',
|
||||
disablePortal: false,
|
||||
},
|
||||
}}
|
||||
renderValue={selected => (
|
||||
<Box sx={{ display: 'flex', gap: 0.5, flexWrap: 'wrap' }}>
|
||||
{selected.map((selectedElement, idx) => {
|
||||
const value = selectedElement.value
|
||||
|
||||
const label = labels.find(l => String(l.id) === String(value))
|
||||
return (
|
||||
<Chip key={`label-chip-${value}-${idx}`} size='sm'>
|
||||
{label?.name || 'Unknown'}
|
||||
</Chip>
|
||||
)
|
||||
})}
|
||||
</Box>
|
||||
)}
|
||||
>
|
||||
{labels.map((label, idx) => (
|
||||
<Option key={`label-opt-${label.id}-${idx}`} value={label.id}>
|
||||
{label.name}
|
||||
</Option>
|
||||
))}
|
||||
</Select>
|
||||
)
|
||||
|
||||
case 'project':
|
||||
return (
|
||||
<Select
|
||||
multiple
|
||||
value={condition.value || []}
|
||||
onChange={(_, newValue) =>
|
||||
updateCondition(index, 'value', newValue)
|
||||
}
|
||||
placeholder='Select projects'
|
||||
sx={{ width: '100%' }}
|
||||
slotProps={{
|
||||
listbox: {
|
||||
placement: 'bottom-start',
|
||||
disablePortal: false,
|
||||
},
|
||||
}}
|
||||
renderValue={selected => (
|
||||
<Box sx={{ display: 'flex', gap: 0.5, flexWrap: 'wrap' }}>
|
||||
{selected.map((event, idx) => {
|
||||
const value = event.value
|
||||
if (value === 'default')
|
||||
return (
|
||||
<Chip key={`default-${idx}`} size='sm'>
|
||||
Default
|
||||
</Chip>
|
||||
)
|
||||
const project = projects.find(
|
||||
p => String(p.id) === String(value),
|
||||
)
|
||||
return (
|
||||
<Chip key={`project-chip-${value}-${idx}`} size='sm'>
|
||||
{project?.name || 'Unknown'}
|
||||
</Chip>
|
||||
)
|
||||
})}
|
||||
</Box>
|
||||
)}
|
||||
>
|
||||
<Option value='default'>Default Project</Option>
|
||||
{projects
|
||||
.filter(p => p.id !== 'default')
|
||||
.map((project, idx) => (
|
||||
<Option
|
||||
key={`project-opt-${project.id}-${idx}`}
|
||||
value={project.id}
|
||||
>
|
||||
{project.name}
|
||||
</Option>
|
||||
))}
|
||||
</Select>
|
||||
)
|
||||
|
||||
case 'status':
|
||||
return (
|
||||
<Select
|
||||
multiple
|
||||
value={condition.value || []}
|
||||
onChange={(_, newValue) =>
|
||||
updateCondition(index, 'value', newValue)
|
||||
}
|
||||
placeholder='Select statuses'
|
||||
sx={{ width: '100%' }}
|
||||
slotProps={{
|
||||
listbox: {
|
||||
placement: 'bottom-start',
|
||||
disablePortal: false,
|
||||
},
|
||||
}}
|
||||
renderValue={selected => (
|
||||
<Box sx={{ display: 'flex', gap: 0.5, flexWrap: 'wrap' }}>
|
||||
{selected.map((selectedElement, idx) => {
|
||||
const value = selectedElement.value
|
||||
const statusLabels = {
|
||||
0: 'Active',
|
||||
1: 'Started',
|
||||
2: 'In Progress',
|
||||
3: 'Pending Approval',
|
||||
}
|
||||
return (
|
||||
<Chip key={`status-chip-${value}-${idx}`} size='sm'>
|
||||
{statusLabels[value] || 'Unknown'}
|
||||
</Chip>
|
||||
)
|
||||
})}
|
||||
</Box>
|
||||
)}
|
||||
>
|
||||
<Option value={0}>Active</Option>
|
||||
<Option value={1}>Started</Option>
|
||||
<Option value={2}>In Progress</Option>
|
||||
<Option value={3}>Pending Approval</Option>
|
||||
</Select>
|
||||
)
|
||||
|
||||
case 'dueDate':
|
||||
return (
|
||||
<Select
|
||||
value={condition.operator}
|
||||
onChange={(_, newValue) =>
|
||||
updateCondition(index, 'operator', newValue)
|
||||
}
|
||||
sx={{ width: '100%' }}
|
||||
slotProps={{
|
||||
listbox: {
|
||||
placement: 'bottom-start',
|
||||
disablePortal: false,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Option value='isOverdue'>Is Overdue</Option>
|
||||
<Option value='isDueToday'>Is Due Today</Option>
|
||||
<Option value='isDueTomorrow'>Is Due Tomorrow</Option>
|
||||
<Option value='isDueThisWeek'>Is Due This Week</Option>
|
||||
<Option value='isDueThisMonth'>Is Due This Month</Option>
|
||||
<Option value='hasNoDueDate'>Has No Due Date</Option>
|
||||
<Option value='hasDueDate'>Has Due Date</Option>
|
||||
</Select>
|
||||
)
|
||||
|
||||
case 'points':
|
||||
return (
|
||||
<Box sx={{ display: 'flex', gap: 1, width: '100%' }}>
|
||||
<Select
|
||||
value={condition.operator}
|
||||
onChange={(_, newValue) =>
|
||||
updateCondition(index, 'operator', newValue)
|
||||
}
|
||||
sx={{ flex: 1 }}
|
||||
slotProps={{
|
||||
listbox: {
|
||||
placement: 'bottom-start',
|
||||
disablePortal: false,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Option value='equals'>Equals</Option>
|
||||
<Option value='greaterThan'>Greater Than</Option>
|
||||
<Option value='lessThan'>Less Than</Option>
|
||||
<Option value='greaterThanOrEqual'>Greater Than or Equal</Option>
|
||||
<Option value='lessThanOrEqual'>Less Than or Equal</Option>
|
||||
</Select>
|
||||
<Input
|
||||
type='number'
|
||||
value={condition.value ?? 0}
|
||||
onChange={e =>
|
||||
updateCondition(index, 'value', parseInt(e.target.value) || 0)
|
||||
}
|
||||
sx={{ flex: 1 }}
|
||||
slotProps={{
|
||||
input: {
|
||||
min: 0,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
)
|
||||
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<ResponsiveModal
|
||||
<BottomSheetModal
|
||||
open={isOpen}
|
||||
onClose={onClose}
|
||||
size='lg'
|
||||
fullWidth={true}
|
||||
title={editingFilter ? 'Edit Filter' : 'Create Advanced Filter'}
|
||||
footer={
|
||||
<Box sx={{ display: 'flex', gap: 1, justifyContent: 'flex-end' }}>
|
||||
<Button variant='outlined' color='neutral' onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant='solid' color='primary' onClick={handleSave}>
|
||||
Save
|
||||
</Button>
|
||||
maxHeight='92vh'
|
||||
title={
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
{editingFilter ? 'Edit Filter' : 'New Filter'}
|
||||
{activeConditionCount > 0 && (
|
||||
<Chip size='sm' variant='solid' color='primary'>
|
||||
{activeConditionCount} condition{activeConditionCount !== 1 ? 's' : ''}
|
||||
</Chip>
|
||||
)}
|
||||
</Box>
|
||||
}
|
||||
>
|
||||
footer={
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 2,
|
||||
height: '100%',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: 1,
|
||||
}}
|
||||
>
|
||||
<Box>
|
||||
<Typography level='body-sm' sx={{ mb: 1 }}>
|
||||
{/* Preview */}
|
||||
<Box sx={{ display: 'flex', gap: 1, flexShrink: 0 }}>
|
||||
{conditions.length > 0 ? (
|
||||
<>
|
||||
<Chip size='sm' variant='soft' color='neutral'>
|
||||
{previewCount} task{previewCount !== 1 ? 's' : ''}
|
||||
</Chip>
|
||||
{previewOverdueCount > 0 && (
|
||||
<Chip size='sm' variant='solid' color='danger'>
|
||||
{previewOverdueCount} overdue
|
||||
</Chip>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<Typography level='body-xs' sx={{ color: 'text.tertiary' }}>
|
||||
Add conditions to preview
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Actions */}
|
||||
<Box sx={{ display: 'flex', gap: 1 }}>
|
||||
<Button variant='plain' color='neutral' size='sm' onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant='solid'
|
||||
color='primary'
|
||||
size='sm'
|
||||
startDecorator={<Save sx={{ fontSize: 16 }} />}
|
||||
onClick={handleSave}
|
||||
>
|
||||
Save Filter
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
}
|
||||
>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0 }}>
|
||||
{/* Name */}
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Typography
|
||||
level='body-xs'
|
||||
sx={{ mb: 0.75, color: 'text.secondary', fontWeight: 600 }}
|
||||
>
|
||||
Filter Name
|
||||
</Typography>
|
||||
<Input
|
||||
placeholder='e.g. Important Tasks due soon, Tasks for John, etc.'
|
||||
placeholder='e.g. Overdue tasks for Alice'
|
||||
value={filterName}
|
||||
onChange={e => {
|
||||
setFilterName(e.target.value)
|
||||
@@ -560,253 +181,57 @@ const AdvancedFilterBuilder = ({
|
||||
autoFocus
|
||||
/>
|
||||
{error && (
|
||||
<Typography level='body-sm' color='danger' sx={{ mt: 0.5 }}>
|
||||
<Typography level='body-xs' color='danger' sx={{ mt: 0.5 }}>
|
||||
{error}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<Typography level='body-sm' sx={{ mb: 1 }}>
|
||||
Description (Optional)
|
||||
</Typography>
|
||||
<Textarea
|
||||
placeholder='Optional description for this filter...'
|
||||
value={filterDescription}
|
||||
onChange={e => setFilterDescription(e.target.value)}
|
||||
minRows={2}
|
||||
maxRows={3}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<Typography level='body-sm' sx={{ mb: 1 }}>
|
||||
Color
|
||||
</Typography>
|
||||
<Select
|
||||
value={filterColor}
|
||||
onChange={(_, value) => value && setFilterColor(value)}
|
||||
slotProps={{
|
||||
listbox: {
|
||||
placement: 'bottom-start',
|
||||
disablePortal: false,
|
||||
},
|
||||
}}
|
||||
renderValue={selected => (
|
||||
<Typography
|
||||
startDecorator={
|
||||
<Box
|
||||
sx={{
|
||||
width: 16,
|
||||
height: 16,
|
||||
borderRadius: '50%',
|
||||
background: selected.value,
|
||||
}}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{selected.label}
|
||||
</Typography>
|
||||
)}
|
||||
>
|
||||
{FILTER_COLORS.map(color => (
|
||||
<Option key={color.value} value={color.value}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Box
|
||||
sx={{
|
||||
width: 20,
|
||||
height: 20,
|
||||
borderRadius: '50%',
|
||||
background: color.value,
|
||||
}}
|
||||
/>
|
||||
<Typography>{color.name}</Typography>
|
||||
</Box>
|
||||
</Option>
|
||||
))}
|
||||
</Select>
|
||||
</Box>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
}}
|
||||
>
|
||||
<Typography level='body-sm' sx={{ mb: 1 }}>
|
||||
Filter Conditions (All must match)
|
||||
</Typography>
|
||||
|
||||
<List
|
||||
ref={listContainerRef}
|
||||
sx={{
|
||||
gap: 1,
|
||||
overflowY: 'auto',
|
||||
overflowX: 'hidden',
|
||||
maxHeight: { xs: '40vh', sm: '50vh' },
|
||||
pr: 0.5,
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
{conditions.map((condition, index) => (
|
||||
<ListItem
|
||||
key={index}
|
||||
ref={el => (conditionRefs.current[index] = el)}
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 1,
|
||||
p: 1.5,
|
||||
bgcolor: 'background.level1',
|
||||
borderRadius: 'sm',
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
width: '100%',
|
||||
}}
|
||||
>
|
||||
<Typography level='body-xs' color='neutral'>
|
||||
Condition {index + 1}
|
||||
</Typography>
|
||||
<IconButton
|
||||
size='sm'
|
||||
color='danger'
|
||||
variant='plain'
|
||||
onClick={() => removeCondition(index)}
|
||||
disabled={conditions.length === 1}
|
||||
>
|
||||
<Delete />
|
||||
</IconButton>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ width: '100%' }}>
|
||||
<Typography level='body-xs' sx={{ mb: 0.5 }}>
|
||||
Field
|
||||
</Typography>
|
||||
<Select
|
||||
value={condition.type}
|
||||
onChange={(_, newValue) =>
|
||||
updateCondition(index, 'type', newValue)
|
||||
}
|
||||
sx={{ width: '100%' }}
|
||||
slotProps={{
|
||||
listbox: {
|
||||
placement: 'bottom-start',
|
||||
disablePortal: false,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Option value='assignee'>Assignee</Option>
|
||||
<Option value='createdBy'>Created By</Option>
|
||||
<Option value='priority'>Priority</Option>
|
||||
<Option value='label'>Label</Option>
|
||||
<Option value='project'>Project</Option>
|
||||
<Option value='status'>Status</Option>
|
||||
<Option value='dueDate'>Due Date</Option>
|
||||
<Option value='points'>Points</Option>
|
||||
</Select>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ width: '100%' }}>
|
||||
<Typography level='body-xs' sx={{ mb: 0.5 }}>
|
||||
{condition.type === 'dueDate' || condition.type === 'points'
|
||||
? 'Condition'
|
||||
: 'Value'}
|
||||
</Typography>
|
||||
{renderValueSelector(condition, index)}
|
||||
</Box>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
|
||||
<Button
|
||||
size='sm'
|
||||
variant='outlined'
|
||||
startDecorator={<Add />}
|
||||
onClick={addCondition}
|
||||
sx={{ mt: 1 }}
|
||||
>
|
||||
Add Condition
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
mb: 1,
|
||||
}}
|
||||
>
|
||||
<Typography level='body-sm'>Preview</Typography>
|
||||
<Box sx={{ display: 'flex', gap: 1 }}>
|
||||
<Chip size='sm' variant='soft' color='neutral'>
|
||||
{previewCount} tasks
|
||||
</Chip>
|
||||
{previewOverdueCount > 0 && (
|
||||
<Chip size='sm' variant='solid' color='danger'>
|
||||
{previewOverdueCount} overdue
|
||||
</Chip>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
maxHeight: 150,
|
||||
overflowY: 'auto',
|
||||
overflowX: 'hidden',
|
||||
bgcolor: 'background.level1',
|
||||
p: 1,
|
||||
borderRadius: 'sm',
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
{previewCount === 0 ? (
|
||||
<Typography
|
||||
level='body-sm'
|
||||
color='neutral'
|
||||
sx={{ textAlign: 'center', py: 2 }}
|
||||
>
|
||||
No tasks match these filters
|
||||
</Typography>
|
||||
) : (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||
{previewChores.slice(0, 3).map(chore => (
|
||||
<Box
|
||||
key={chore.id}
|
||||
sx={{
|
||||
bgcolor: 'background.surface',
|
||||
p: 1,
|
||||
borderRadius: 'sm',
|
||||
}}
|
||||
>
|
||||
<Typography level='body-sm'>{chore.name}</Typography>
|
||||
</Box>
|
||||
))}
|
||||
{previewCount > 3 && (
|
||||
{/* Color */}
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Typography
|
||||
level='body-xs'
|
||||
color='neutral'
|
||||
sx={{ textAlign: 'center', mt: 0.5 }}
|
||||
sx={{ mb: 0.75, color: 'text.secondary', fontWeight: 600 }}
|
||||
>
|
||||
...and {previewCount - 3} more
|
||||
Color
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
|
||||
{FILTER_COLORS.map(c => (
|
||||
<Box
|
||||
key={c.value}
|
||||
title={c.name}
|
||||
onClick={() => setFilterColor(c.value)}
|
||||
sx={{
|
||||
width: 26,
|
||||
height: 26,
|
||||
borderRadius: '50%',
|
||||
background: c.value,
|
||||
cursor: 'pointer',
|
||||
outline:
|
||||
filterColor === c.value
|
||||
? '3px solid var(--joy-palette-primary-500)'
|
||||
: '2px solid transparent',
|
||||
outlineOffset: '2px',
|
||||
transition: 'all 0.15s ease',
|
||||
flexShrink: 0,
|
||||
'&:hover': { transform: 'scale(1.2)' },
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Divider sx={{ mb: 2.5 }} />
|
||||
|
||||
<FilterBuilderContent
|
||||
selections={selections}
|
||||
onSelectionsChange={setSelections}
|
||||
members={members}
|
||||
labels={labels}
|
||||
projects={projects}
|
||||
/>
|
||||
</Box>
|
||||
</ResponsiveModal>
|
||||
</BottomSheetModal>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -2,14 +2,17 @@ import { Cell, Pie, PieChart, Tooltip } from 'recharts'
|
||||
|
||||
import {
|
||||
AccessTime,
|
||||
CalendarMonth,
|
||||
Check,
|
||||
Checklist,
|
||||
EventBusy,
|
||||
EventNote,
|
||||
Group,
|
||||
HourglassEmpty,
|
||||
Person,
|
||||
Redo,
|
||||
RunningWithErrors,
|
||||
Schedule,
|
||||
Style,
|
||||
ThumbDown,
|
||||
Timeline,
|
||||
Toll,
|
||||
@@ -24,23 +27,27 @@ import {
|
||||
Divider,
|
||||
Grid,
|
||||
Link,
|
||||
Option,
|
||||
Select,
|
||||
Stack,
|
||||
Tab,
|
||||
TabList,
|
||||
Tabs,
|
||||
Typography,
|
||||
} 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 { useChores, useChoresHistory } from '../../queries/ChoreQueries'
|
||||
import {
|
||||
useChores,
|
||||
useChoresHistory,
|
||||
useDeleteChoreHistory,
|
||||
useUpdateChoreHistory,
|
||||
} from '../../queries/ChoreQueries'
|
||||
import EditHistoryModal from '../Modals/EditHistoryModal'
|
||||
import HistoryDetailModal from '../Modals/HistoryDetailModal'
|
||||
import NoteViewerModal from '../Modals/Inputs/NoteViewerModal'
|
||||
import { useCircleMembers, useUserProfile } from '../../queries/UserQueries.jsx'
|
||||
import { useLabels } from '../Labels/LabelQueries'
|
||||
import { ChoresGrouper } from '../../utils/Chores'
|
||||
import { COLORS, TASK_COLOR } from '../../utils/Colors.jsx'
|
||||
import { resolvePhotoURL } from '../../utils/Helpers.jsx'
|
||||
import LoadingComponent from '../components/Loading'
|
||||
|
||||
const groupByDate = history => {
|
||||
@@ -58,47 +65,57 @@ const groupByDate = history => {
|
||||
return aggregated
|
||||
}
|
||||
|
||||
const ChoreHistoryItem = ({ time, name, points, status, performer, notes, onViewNote }) => {
|
||||
const getStatusIcon = status => {
|
||||
switch (status) {
|
||||
case 0:
|
||||
return <AccessTime color='primary' />
|
||||
case 1:
|
||||
return <Check color='success' />
|
||||
case 2:
|
||||
return <Redo color='warning' />
|
||||
case 3:
|
||||
return <HourglassEmpty color='neutral' />
|
||||
case 4:
|
||||
return <ThumbDown color='error' />
|
||||
case 5:
|
||||
return <RunningWithErrors color='error' />
|
||||
case 6:
|
||||
return <Schedule color='warning' />
|
||||
default:
|
||||
return <Check color='success' />
|
||||
}
|
||||
const statusConfig = {
|
||||
0: { color: 'primary', icon: <AccessTime /> },
|
||||
1: { color: 'success', icon: <Check /> },
|
||||
2: { color: 'warning', icon: <Redo /> },
|
||||
3: { color: 'neutral', icon: <HourglassEmpty /> },
|
||||
4: { color: 'danger', icon: <ThumbDown /> },
|
||||
5: { color: 'danger', icon: <RunningWithErrors /> },
|
||||
6: { color: 'warning', icon: <Schedule /> },
|
||||
}
|
||||
|
||||
const ChoreHistoryItem = ({
|
||||
time,
|
||||
name,
|
||||
points,
|
||||
status,
|
||||
notes,
|
||||
onViewNote,
|
||||
onViewDetails,
|
||||
}) => {
|
||||
const cfg = statusConfig[status] ?? statusConfig[1]
|
||||
|
||||
return (
|
||||
<Stack direction='row' alignItems='center' spacing={2}>
|
||||
<Stack
|
||||
direction='row'
|
||||
alignItems='center'
|
||||
spacing={1}
|
||||
onClick={onViewDetails}
|
||||
sx={{
|
||||
cursor: onViewDetails ? 'pointer' : 'default',
|
||||
borderRadius: 'sm',
|
||||
'&:hover': onViewDetails
|
||||
? { backgroundColor: 'background.level1' }
|
||||
: {},
|
||||
}}
|
||||
>
|
||||
<Typography level='body-md' sx={{ minWidth: 80 }}>
|
||||
{time}
|
||||
</Typography>
|
||||
<Box
|
||||
<Avatar
|
||||
size='sm'
|
||||
color={cfg.color}
|
||||
variant='soft'
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
minWidth: 32,
|
||||
minHeight: 32,
|
||||
borderRadius: '50%',
|
||||
backgroundColor: 'background.level2',
|
||||
boxShadow: 'sm',
|
||||
width: 32,
|
||||
height: 32,
|
||||
flexShrink: 0,
|
||||
'& svg': { fontSize: '16px' },
|
||||
}}
|
||||
>
|
||||
{getStatusIcon(status)}
|
||||
</Box>
|
||||
{cfg.icon}
|
||||
</Avatar>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
@@ -143,25 +160,18 @@ const ChoreHistoryItem = ({ time, name, points, status, performer, notes, onView
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
const ChoreHistoryTimeline = ({ history, onViewNote }) => {
|
||||
const ChoreHistoryTimeline = ({
|
||||
history,
|
||||
performers,
|
||||
onViewNote,
|
||||
onViewDetails,
|
||||
}) => {
|
||||
const { fmt } = useLocalization()
|
||||
|
||||
const groupedHistory = groupByDate(history)
|
||||
|
||||
const sortedEntries = Object.entries(groupedHistory).sort(
|
||||
([a], [b]) => new Date(b) - new Date(a),
|
||||
)
|
||||
|
||||
return (
|
||||
<Container sx={{ p: 2 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 2 }}>
|
||||
<Timeline sx={{ fontSize: '1.5rem', color: 'primary.500' }} />
|
||||
<Typography level='h4' sx={{ fontWeight: 'lg', color: 'text.primary' }}>
|
||||
Activities Timeline
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ py: 2, width: '100%' }}>
|
||||
{Object.entries(groupedHistory).map(([date, items]) => (
|
||||
<Box key={date} sx={{ mb: 4 }}>
|
||||
<Typography level='title-sm' sx={{ mb: 0.5 }}>
|
||||
@@ -170,25 +180,21 @@ const ChoreHistoryTimeline = ({ history, onViewNote }) => {
|
||||
<Divider />
|
||||
<Stack spacing={1}>
|
||||
{items.map(record => (
|
||||
<>
|
||||
<ChoreHistoryItem
|
||||
key={record.id}
|
||||
|
||||
time={fmt.time(
|
||||
record.performedAt || record.updatedAt,
|
||||
)}
|
||||
time={fmt.time(record.performedAt || record.updatedAt)}
|
||||
name={record.choreName}
|
||||
points={record.points}
|
||||
status={record.status}
|
||||
notes={record.notes}
|
||||
onViewNote={onViewNote}
|
||||
onViewDetails={() => onViewDetails?.(record, performers)}
|
||||
/>
|
||||
</>
|
||||
))}
|
||||
</Stack>
|
||||
</Box>
|
||||
))}
|
||||
</Container>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -402,6 +408,11 @@ const UserActivites = () => {
|
||||
const [enrichedHistory, setEnrichedHistory] = React.useState([])
|
||||
const [selectedChart, setSelectedChart] = React.useState('history')
|
||||
const [noteViewerConfig, setNoteViewerConfig] = useState({ isOpen: false })
|
||||
const [detailModalConfig, setDetailModalConfig] = useState({ isOpen: false })
|
||||
const [editModalConfig, setEditModalConfig] = useState({ isOpen: false })
|
||||
const [editHistoryRecord, setEditHistoryRecord] = useState(null)
|
||||
const updateChoreHistory = useUpdateChoreHistory()
|
||||
const deleteChoreHistory = useDeleteChoreHistory()
|
||||
|
||||
const [historyPieChartData, setHistoryPieChartData] = React.useState([])
|
||||
const [choreDuePieChartData, setChoreDuePieChartData] = React.useState([])
|
||||
@@ -416,6 +427,7 @@ const UserActivites = () => {
|
||||
choresAssigneeBreakdownChartData,
|
||||
setChoresAssigneeBreakdownChartData,
|
||||
] = React.useState([])
|
||||
const { data: userLabels } = useLabels()
|
||||
const { data: choresData, isLoading: isChoresLoading } = useChores(true)
|
||||
const {
|
||||
data: choresHistory,
|
||||
@@ -432,6 +444,142 @@ const UserActivites = () => {
|
||||
}
|
||||
}, [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(() => {
|
||||
if (
|
||||
!isChoresHistoryLoading &&
|
||||
@@ -444,6 +592,7 @@ const UserActivites = () => {
|
||||
return {
|
||||
...item,
|
||||
choreName: chore?.name,
|
||||
labelsV2: chore?.labelsV2,
|
||||
}
|
||||
})
|
||||
setEnrichedHistory(enrichedHistory)
|
||||
@@ -823,222 +972,24 @@ const UserActivites = () => {
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 2 }}>
|
||||
{/* <EmojiEvents sx={{ fontSize: '2rem', color: '#FFD700' }} /> */}
|
||||
<Stack sx={{ flex: 1 }}>
|
||||
<Timeline sx={{ fontSize: '1.5rem' }} />
|
||||
<Typography
|
||||
level='h3'
|
||||
level='title-md'
|
||||
sx={{ fontWeight: 'lg', color: 'text.primary' }}
|
||||
>
|
||||
User Activities
|
||||
</Typography>
|
||||
<Typography level='body-sm' sx={{ color: 'text.secondary' }}>
|
||||
Overview of user activities and task statistics
|
||||
</Typography>
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
{/* Filter Controls - Always visible */}
|
||||
<Card
|
||||
variant='outlined'
|
||||
sx={{
|
||||
width: '100%',
|
||||
p: 2,
|
||||
mb: 3,
|
||||
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>
|
||||
Activities
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<FilterBar
|
||||
filterDefs={filterDefs}
|
||||
activeFilters={activeFilters}
|
||||
onSetFilter={handleSetFilter}
|
||||
onClearAll={handleClearAll}
|
||||
resultCount={filteredTimeline.length}
|
||||
totalCount={selectedHistory.length}
|
||||
/>
|
||||
|
||||
{/* Conditional Content Based on Data Availability */}
|
||||
{!choresData.res?.length > 0 || !choresHistory?.length > 0 ? (
|
||||
<Container
|
||||
@@ -1103,7 +1054,8 @@ const UserActivites = () => {
|
||||
{/* Left Side - Timeline (Mobile: Full width, Desktop: Flexible) */}
|
||||
<Box sx={{ flex: 1, minWidth: 0, width: '100%' }}>
|
||||
<ChoreHistoryTimeline
|
||||
history={selectedHistory}
|
||||
history={filteredTimeline}
|
||||
performers={circleUsers}
|
||||
onViewNote={notes => {
|
||||
setNoteViewerConfig({
|
||||
isOpen: true,
|
||||
@@ -1112,19 +1064,68 @@ const UserActivites = () => {
|
||||
onClose: () => setNoteViewerConfig({ isOpen: false }),
|
||||
})
|
||||
}}
|
||||
onViewDetails={(entry, performers) => {
|
||||
setDetailModalConfig({
|
||||
isOpen: true,
|
||||
entry,
|
||||
performers,
|
||||
onClose: () => setDetailModalConfig({ isOpen: false }),
|
||||
onEdit: record => {
|
||||
setDetailModalConfig(prev => ({ ...prev, isOpen: false }))
|
||||
setEditHistoryRecord(record)
|
||||
setEditModalConfig({
|
||||
isOpen: true,
|
||||
onClose: () => {
|
||||
setEditModalConfig({ isOpen: false })
|
||||
setEditHistoryRecord(null)
|
||||
},
|
||||
onSave: updated => {
|
||||
updateChoreHistory.mutate(
|
||||
{
|
||||
choreId: record.choreId,
|
||||
historyId: record.id,
|
||||
historyData: {
|
||||
performedAt: updated.performedAt,
|
||||
dueDate: updated.dueDate,
|
||||
notes: updated.notes,
|
||||
},
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
setEditModalConfig({ isOpen: false })
|
||||
setEditHistoryRecord(null)
|
||||
},
|
||||
},
|
||||
)
|
||||
},
|
||||
onDelete: () => {
|
||||
deleteChoreHistory.mutate(
|
||||
{ choreId: record.choreId, historyId: record.id },
|
||||
{
|
||||
onSuccess: () => {
|
||||
setEditModalConfig({ isOpen: false })
|
||||
setEditHistoryRecord(null)
|
||||
},
|
||||
},
|
||||
)
|
||||
},
|
||||
})
|
||||
},
|
||||
})
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* Right Sidebar - Charts (Mobile: Full width, Desktop: Fixed width + sticky) */}
|
||||
{/* Right Sidebar - Charts (Desktop only, hidden on mobile) */}
|
||||
<Box
|
||||
sx={{
|
||||
width: { xs: '100%', lg: '350px' },
|
||||
position: { xs: 'static', lg: 'sticky' },
|
||||
top: { lg: '60px' },
|
||||
alignSelf: { lg: 'flex-start' },
|
||||
maxHeight: { lg: 'calc(100vh - 40px)' },
|
||||
overflowY: { lg: 'auto' },
|
||||
order: { xs: -1, lg: 1 }, // Show charts first on mobile, last on desktop
|
||||
display: { xs: 'none', lg: 'block' },
|
||||
width: '350px',
|
||||
position: 'sticky',
|
||||
top: '60px',
|
||||
alignSelf: 'flex-start',
|
||||
maxHeight: 'calc(100vh - 40px)',
|
||||
overflowY: 'auto',
|
||||
}}
|
||||
>
|
||||
{/* Charts Container */}
|
||||
@@ -1263,6 +1264,11 @@ const UserActivites = () => {
|
||||
</>
|
||||
)}
|
||||
<NoteViewerModal config={noteViewerConfig} />
|
||||
<HistoryDetailModal config={detailModalConfig} />
|
||||
<EditHistoryModal
|
||||
config={editModalConfig}
|
||||
historyRecord={editHistoryRecord}
|
||||
/>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user