diff --git a/src/views/Chores/MyChores.jsx b/src/views/Chores/MyChores.jsx index 3554091..3ec6ae8 100644 --- a/src/views/Chores/MyChores.jsx +++ b/src/views/Chores/MyChores.jsx @@ -268,7 +268,6 @@ const MyChores = () => { const { filteredData: quickFilteredChores, - activeFilters: quickFilters, setFilter: setQuickFilter, clearAll: clearQuickFilters, hasActiveFilters: hasQuickFilters, @@ -948,30 +947,37 @@ const MyChores = () => { tempFilterMeta={tempFilterMeta} /> { - clearActiveFilter() - setQuickFilter(id, value) - setSelectedCalendarDate(null) - }} + members={membersData?.res || []} + labels={userLabels || []} + projects={projectsWithDefault} + tempFilter={tempFilter} + applyTempFilter={applyTempFilter} + clearTempFilter={clearTempFilter} + saveFilter={saveFilter} + onFilterSaved={name => + showSuccess({ + title: 'Filter Saved', + message: `"${name}" has been saved`, + }) + } onClearAllFilters={() => { clearQuickFilters() clearActiveFilter() setSelectedChoreFilterWithCache('anyone') - setSelectedProjectWithCache(projectsWithDefault.find(p => p.id === 'default') || null) + setSelectedProjectWithCache( + projectsWithDefault.find(p => p.id === 'default') || null, + ) updateFilterUrl(null, null) }} resultCount={ - hasQuickFilters || !!activeFilterId ? getFilteredChores.length : undefined + hasQuickFilters || hasFilterApplied + ? getFilteredChores.length + : undefined } totalCount={ - hasQuickFilters || !!activeFilterId ? projectFilteredChores.length : undefined - } - projects={ - !hasProjectConditions && !hasFilterApplied - ? projectsWithDefault - : [] + hasQuickFilters || hasFilterApplied + ? projectFilteredChores.length + : undefined } selectedProject={selectedProject} onProjectSelect={project => { @@ -1013,10 +1019,6 @@ const MyChores = () => { }} onSavedFilterDelete={deleteFilter} onSavedFilterPin={pinFilter} - onCreateAdvancedFilter={() => { - setShowAdvancedFilterBuilder(true) - setEditingFilter(null) - }} selectedGroupBy={selectedChoreSection} onGroupBySelect={value => { setSelectedChoreSectionWithCache(value) diff --git a/src/views/Chores/components/ChoreToolbarPrototype.jsx b/src/views/Chores/components/ChoreToolbarPrototype.jsx index 3430dca..42a7fe4 100644 --- a/src/views/Chores/components/ChoreToolbarPrototype.jsx +++ b/src/views/Chores/components/ChoreToolbarPrototype.jsx @@ -9,7 +9,8 @@ * NEW: [Search] [Filter(n)] [Group ▾] [View] [Multiselect] * + active filter chips appear inline next to Filter button * + Filter button opens ONE unified bottom sheet containing: - * Project · Assignee · Due Date · Priority · Labels · Saved Filters + * Assignee · Created By · Status · Priority · Due Date · Labels · Projects · Points + * + Saved Filters section * * How to try it: in MyChores.jsx, replace the toolbar block * and the two rows below it (FilterBar + FilterSection) with: @@ -17,7 +18,7 @@ */ import { - Add, + ArrowDropDown, CalendarMonth, Check, CheckBox, @@ -25,10 +26,8 @@ import { Close, FilterList, FolderOpen, - PriorityHigh, - Settings, + Save, Sort, - Style, Tune, ViewAgenda, ViewComfy, @@ -38,31 +37,28 @@ import { Badge, Box, Button, + ButtonGroup, Chip, Divider, IconButton, + Input, + Menu, + MenuItem, Typography, } from '@mui/joy' -import { useState } from 'react' +import { useRef, useState } from 'react' import BottomSheetModal from '../../../components/common/BottomSheetModal' +import { Z_INDEX } from '../../../constants/zIndex' import KeyboardShortcutHint from '../../../components/common/KeyboardShortcutHint' +import { FILTER_COLORS } from '../../../utils/Colors' +import FilterBuilderContent, { + conditionsToSelections, + defaultSelections, + selectionsToConditions, +} from './FilterBuilderContent' import SearchBar from './SearchBar' -// ─── helpers ───────────────────────────────────────────────────────────────── - -const chipLabel = (def, value) => { - if (!value || (Array.isArray(value) && value.length === 0)) return null - if (def.type === 'single-select') - return def.options?.find(o => o.value === value)?.label ?? null - if (def.type === 'multi-select' && Array.isArray(value)) { - if (value.length === 1) - return def.options?.find(o => o.value === value[0])?.label ?? def.label - return `${def.label} (${value.length})` - } - return null -} - -// ─── sub-components ────────────────────────────────────────────────────────── +// ─── sub-components for the Display sheet ──────────────────────────────────── const SectionHeader = ({ icon, label, badge }) => ( @@ -132,53 +128,67 @@ const OptionChips = ({ options, selected, multi, onToggle }) => ( /** * Props: - * filterDefs – same shape as FilterBar: [{ id, label, type, icon, options, filterFn }] - * PLUS two new built-in sections handled here: 'project' and 'assignee' - * activeFilters – { [id]: value } - * onSetFilter – (id, value | null) => void - * onClearAllFilters – () => void + * -- Advanced filter (filter sheet) -- + * members – circle members for Assignee / Created By sections + * labels – user labels for Labels section + * projects – projects list (projectsWithDefault) for Projects section + Display sheet + * tempFilter – current temp filter object { conditions, operator } or null + * applyTempFilter – (filter) => void — called immediately as selections change + * clearTempFilter – () => void + * saveFilter – (filterData) => Promise — saves as a named filter + * onFilterSaved – (name) => void — called after successful save (for notifications) + * + * -- Result counts -- * resultCount / totalCount * - * projects – [{ id, name }] — drives the Project section - * selectedProject – current project object - * onProjectSelect – (project) => void - * - * selectedAssigneeFilter – 'anyone' | 'assigned_to_me' | 'available_for_me' | 'assigned_to_others' - * onAssigneeFilterChange – (key) => void + * -- Clear all -- + * onClearAllFilters – () => void * + * -- Saved filters -- * savedFilters – [{ id, name, color, count, isPinned }] * activeFilterId – number | null * onSavedFilterClick – (id) => void * onSavedFilterEdit – (filter) => void * onSavedFilterDelete– (id) => void * onSavedFilterPin – (id) => void - * onCreateAdvancedFilter – () => void * + * -- Display sheet -- + * selectedProject – current project object (for Display sheet section) + * onProjectSelect – (project) => void + * selectedAssigneeFilter – 'anyone' | 'assigned_to_me' | 'available_for_me' | 'assigned_to_others' + * onAssigneeFilterChange – (key) => void * selectedGroupBy – 'default' | 'due_date' | 'priority' | 'labels' * onGroupBySelect – (value) => void - * * viewMode – 'default' | 'compact' | 'calendar' - * onToggleViewMode – () => void + * onToggleViewMode – (value?) => void * + * -- Multi-select -- * isMultiSelectMode – bool * onToggleMultiSelect – () => void * + * -- Search -- * searchTerm / onSearchChange / onSearchClose / searchInputRef * showKeyboardShortcuts */ const ChoreToolbar = ({ - // quick filters - filterDefs = [], - activeFilters = {}, - onSetFilter, - onClearAllFilters, + // advanced filter + members = [], + labels = [], + projects = [], + tempFilter, + applyTempFilter, + clearTempFilter, + saveFilter, + onFilterSaved, + // result counts resultCount, totalCount, - // project - projects = [], + // clear all + onClearAllFilters, + // project (for Display sheet) selectedProject, onProjectSelect, - // assignee + // assignee (for Display sheet) selectedAssigneeFilter = 'anyone', onAssigneeFilterChange, // saved / custom @@ -188,7 +198,6 @@ const ChoreToolbar = ({ onSavedFilterEdit, onSavedFilterDelete, onSavedFilterPin, - onCreateAdvancedFilter, // grouping selectedGroupBy = 'default', onGroupBySelect, @@ -206,46 +215,111 @@ const ChoreToolbar = ({ }) => { const [filterSheetOpen, setFilterSheetOpen] = useState(false) const [displaySheetOpen, setDisplaySheetOpen] = useState(false) + const [localSelections, setLocalSelections] = useState(defaultSelections()) + const [savingFilter, setSavingFilter] = useState(false) + const [saveFilterName, setSaveFilterName] = useState('') + const [saveMenuAnchorEl, setSaveMenuAnchorEl] = useState(null) + const saveMenuRef = useRef(null) - // ── count active filters for badge ────────────────────────────────────────── + // ── badge counts ───────────────────────────────────────────────────────────── - const quickFilterCount = filterDefs.filter(def => { - const v = activeFilters[def.id] - return v != null && !(Array.isArray(v) && v.length === 0) - }).length - - const projectActive = - selectedProject && selectedProject.id !== 'default' ? 1 : 0 - const assigneeActive = selectedAssigneeFilter !== 'anyone' ? 1 : 0 + const tempConditionCount = tempFilter?.conditions?.length || 0 const savedFilterActive = activeFilterId != null ? 1 : 0 - - const totalActiveCount = quickFilterCount + savedFilterActive + const totalActiveCount = tempConditionCount + savedFilterActive const hasAnyActive = totalActiveCount > 0 - // ── inline chip strip (max 2 visible + overflow) ───────────────────────────── + // ── inline chip strip ──────────────────────────────────────────────────────── const inlineChips = [] if (savedFilterActive) { const sf = savedFilters.find(f => f.id === activeFilterId) - if (sf) + if (sf) { inlineChips.push({ key: '__saved', label: sf.name, onClear: () => onSavedFilterClick?.(activeFilterId), }) + } + } else if (tempConditionCount > 0) { + inlineChips.push({ + key: '__temp', + label: `${tempConditionCount} condition${tempConditionCount !== 1 ? 's' : ''}`, + onClear: () => { + setLocalSelections(defaultSelections()) + clearTempFilter?.() + }, + }) } - filterDefs.forEach(def => { - const label = chipLabel(def, activeFilters[def.id]) - if (label) inlineChips.push({ key: def.id, label, onClear: () => onSetFilter(def.id, null) }) - }) + // ── open filter sheet ──────────────────────────────────────────────────────── - const MAX_CHIPS = 2 - const visibleChips = inlineChips.slice(0, MAX_CHIPS) - const overflow = inlineChips.length - MAX_CHIPS + const openFilterSheet = () => { + if (tempFilter?.conditions?.length > 0) { + setLocalSelections(conditionsToSelections(tempFilter.conditions)) + } else if (activeFilterId) { + const sf = savedFilters.find(f => f.id === activeFilterId) + setLocalSelections( + sf?.conditions + ? conditionsToSelections(sf.conditions) + : defaultSelections(), + ) + } else { + setLocalSelections(defaultSelections()) + } + setSavingFilter(false) + setSaveFilterName('') + setFilterSheetOpen(true) + } - // ── groupby options ────────────────────────────────────────────────────────── + // ── selection changes → apply temp filter immediately ──────────────────────── + + const handleSelectionsChange = updater => { + setLocalSelections(prev => { + const next = typeof updater === 'function' ? updater(prev) : updater + const conditions = selectionsToConditions(next) + if (conditions.length > 0) { + applyTempFilter?.({ conditions, operator: 'AND' }) + } else { + clearTempFilter?.() + } + return next + }) + } + + // ── save filter ─────────────────────────────────────────────────────────────── + + const handleSaveFilter = () => { + const name = saveFilterName.trim() + if (!name) return + const conditions = selectionsToConditions(localSelections) + if (conditions.length === 0) return + + const usedColors = savedFilters.map(f => f.color) + const color = + FILTER_COLORS.find(c => !usedColors.includes(c.value))?.value ?? + FILTER_COLORS[0].value + + saveFilter?.({ name, description: '', color, conditions, operator: 'AND' })?.then?.(() => { + applyTempFilter?.({ conditions, operator: 'AND' }, { name }) + onFilterSaved?.(name) + }) + + setSavingFilter(false) + setSaveFilterName('') + setFilterSheetOpen(false) + } + + // ── display sheet helpers ──────────────────────────────────────────────────── + + const filterActive = activeFilterId != null || tempConditionCount > 0 + const projectActive = selectedProject && selectedProject.id !== 'default' ? 1 : 0 + const assigneeActive = selectedAssigneeFilter !== 'anyone' ? 1 : 0 + const displayActive = + selectedGroupBy !== 'default' || + viewMode !== 'default' || + projectActive > 0 || + assigneeActive > 0 const groupByOptions = [ { value: 'default', label: 'Smart' }, @@ -254,8 +328,6 @@ const ChoreToolbar = ({ { value: 'labels', label: 'Labels' }, ] - // ── assignee options ───────────────────────────────────────────────────────── - const assigneeOptions = [ { value: 'anyone', label: 'Everyone' }, { value: 'assigned_to_me', label: 'Mine' }, @@ -263,28 +335,19 @@ const ChoreToolbar = ({ { value: 'assigned_to_others', label: 'Others' }, ] - // ── project options (show only if more than just Default) ─────────────────── - - const showProjectSection = projects.filter(p => p.id !== 'default').length > 0 - - // ── pinned saved filters ───────────────────────────────────────────────────── - - const pinnedFilters = savedFilters.filter(f => f.isPinned) - - // ── display active state (highlight button when non-default) ───────────────── - - const displayActive = - selectedGroupBy !== 'default' || - viewMode !== 'default' || - projectActive > 0 || - assigneeActive > 0 - const viewOptions = [ { value: 'default', label: 'Cards', icon: }, { value: 'compact', label: 'Compact', icon: }, { value: 'calendar', label: 'Calendar', icon: }, ] + // Only show project in Display sheet when no advanced filter is active + const showProjectInDisplay = + !filterActive && + projects.filter(p => p.id !== 'default').length > 0 + + const activeConditions = selectionsToConditions(localSelections) + return ( <> {/* ── Row 1: main toolbar ─────────────────────────────────────────────── */} @@ -317,7 +380,7 @@ const ChoreToolbar = ({ color={hasAnyActive ? 'primary' : 'neutral'} size='sm' sx={{ height: 32, width: 32, borderRadius: '50%' }} - onClick={() => setFilterSheetOpen(true)} + onClick={openFilterSheet} title='Filters' > @@ -350,7 +413,11 @@ const ChoreToolbar = ({ size='sm' sx={{ height: 32, width: 32, borderRadius: '50%' }} onClick={onToggleMultiSelect} - title={isMultiSelectMode ? 'Exit multi-select (Ctrl+S)' : 'Multi-select (Ctrl+S)'} + title={ + isMultiSelectMode + ? 'Exit multi-select (Ctrl+S)' + : 'Multi-select (Ctrl+S)' + } > {isMultiSelectMode ? : } @@ -362,7 +429,7 @@ const ChoreToolbar = ({ - {/* ── Row 2: active filter chips (only when something is active) ─────── */} + {/* ── Row 2: active filter chips ──────────────────────────────────────── */} {hasAnyActive && ( - {visibleChips.map(({ key, label, onClear }) => ( + {inlineChips.map(({ key, label, onClear }) => ( } - onClick={() => setFilterSheetOpen(true)} + onClick={openFilterSheet} sx={{ cursor: 'pointer', flexShrink: 0 }} > {label} ))} - {overflow > 0 && ( - setFilterSheetOpen(true)} - sx={{ cursor: 'pointer', flexShrink: 0 }} - > - +{overflow} more - - )} - {resultCount != null && totalCount != null && ( { + setLocalSelections(defaultSelections()) + onClearAllFilters?.() + }} > Clear all @@ -441,6 +499,7 @@ const ChoreToolbar = ({ setFilterSheetOpen(false)} + maxHeight='92vh' title={ @@ -453,91 +512,126 @@ const ChoreToolbar = ({ } footer={ - - - + + + ) : ( + - {resultCount != null - ? `Show ${resultCount} result${resultCount !== 1 ? 's' : ''}` - : 'Done'} - - + + + {activeConditions.length > 0 ? ( + <> + + + setSaveMenuAnchorEl(e.currentTarget)} + > + + + + + setSaveMenuAnchorEl(null)} + placement='top-end' + sx={{ zIndex: Z_INDEX.MODAL_CONTENT + 10 }} + > + { + setSaveMenuAnchorEl(null) + setSavingFilter(true) + }} + > + + Save as Filter + + + + ) : ( + + )} + + ) } > + {/* Full advanced filter content */} + - {/* ── Quick filter sections (Due Date / Priority / Labels etc.) ────── */} - {filterDefs.map(def => ( - - - - { - if (def.type === 'multi-select') { - const curr = activeFilters[def.id] || [] - const next = curr.includes(val) - ? curr.filter(v => v !== val) - : [...curr, val] - onSetFilter(def.id, next.length > 0 ? next : null) - } else { - onSetFilter(def.id, activeFilters[def.id] === val ? null : val) - } - }} - /> - - ))} - - {/* ── Saved / custom filters ──────────────────────────────────────── */} - {(pinnedFilters.length > 0 || savedFilters.length > 0) && ( + {/* Saved filters section */} + {savedFilters.length > 0 && ( <> - - - Saved Filters - - { - setFilterSheetOpen(false) - // navigate to /filters or open settings - }} - > - - - + + Saved Filters + {savedFilters.map(filter => { const isActive = activeFilterId === filter.id @@ -557,7 +651,7 @@ const ChoreToolbar = ({ } onClick={() => { onSavedFilterClick?.(filter.id) - setFilterSheetOpen(false) + if (!isActive) setFilterSheetOpen(false) }} sx={{ cursor: 'pointer', @@ -574,46 +668,12 @@ const ChoreToolbar = ({ ) })} - - - - )} - - {/* Edge case: no saved filters yet → still show "Create" CTA */} - {savedFilters.length === 0 && ( - <> - - )} - {/* ── Display bottom sheet (View + Group combined) ───────────────────── */} + {/* ── Display bottom sheet (View + Group + Assignee + Project) ──────────── */} setDisplaySheetOpen(false)} @@ -638,7 +698,11 @@ const ChoreToolbar = ({ key={opt.value} variant={viewMode === opt.value ? 'solid' : 'soft'} color={viewMode === opt.value ? 'primary' : 'neutral'} - startDecorator={viewMode === opt.value ? : opt.icon} + startDecorator={ + viewMode === opt.value + ? + : opt.icon + } onClick={() => onToggleViewMode?.(opt.value)} sx={{ py: 0.64, @@ -703,8 +767,8 @@ const ChoreToolbar = ({ onToggle={v => onAssigneeFilterChange?.(v)} /> - {/* Project section */} - {showProjectSection && ( + {/* Project section — only when no advanced filter is active */} + {showProjectInDisplay && ( <> ' }, + { 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 }) => ( + + + {icon} + + + {label} + + {children} + +) + +const IncludeExcludeToggle = ({ + value, + onChange, + labels = ['Include', 'Exclude'], +}) => ( + + {[ + { op: 'is', label: labels[0] }, + { op: 'isNot', label: labels[1] }, + ].map(o => ( + onChange(o.op)} + sx={{ cursor: 'pointer', userSelect: 'none', transition: 'all 0.15s ease' }} + > + {o.label} + + ))} + +) + +/** + * 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 ( + + {options.map(opt => { + const isSelected = selected.includes(opt.value) + const extra = getChipProps ? getChipProps(opt, isSelected) : {} + return ( + + : (extra.startDecorator ?? null) + } + onClick={() => toggleValue(type, opt.value)} + sx={{ + cursor: 'pointer', + userSelect: 'none', + transition: 'all 0.15s ease', + }} + > + {opt.label} + + ) + })} + + ) + } + + const personChipRow = type => { + const selected = selections[type].values || [] + return ( + + {members.map(m => { + const isSelected = selected.includes(m.userId) + return ( + + ) : ( + + ) + } + onClick={() => toggleValue(type, m.userId)} + sx={{ + cursor: 'pointer', + userSelect: 'none', + transition: 'all 0.15s ease', + }} + > + {m.displayName || m.username} + + ) + })} + + ) + } + + return ( + + {/* Assignee */} + {members.length > 0 && ( + <> + } label='Assignee'> + setOperator('assignee', op)} + /> + + {personChipRow('assignee')} + + + )} + + {/* Created By */} + {members.length > 0 && ( + <> + } label='Created By'> + setOperator('createdBy', op)} + /> + + {personChipRow('createdBy')} + + + )} + + {/* Status */} + } label='Status'> + setOperator('status', op)} + /> + + {chipRow('status', CHORE_STATUSES)} + + + {/* Priority */} + } label='Priority'> + setOperator('priority', op)} + /> + + {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, + }), + )} + + + {/* Due Date */} + } label='Due Date' /> + + {DUE_DATE_OPTIONS.map(opt => { + const isSelected = selections.dueDate.operator === opt.value + return ( + : null} + onClick={() => toggleDueDate(opt.value)} + sx={{ + cursor: 'pointer', + userSelect: 'none', + transition: 'all 0.15s ease', + }} + > + {opt.label} + + ) + })} + + + + {/* Labels */} + {labels.length > 0 && ( + <> + } label='Labels'> + setOperator('label', op)} + labels={['Has', "Doesn't Have"]} + /> + + + {labels.map(lbl => { + const isSelected = selections.label.values.includes(lbl.id) + return ( + + } + endDecorator={isSelected ? : 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} + + ) + })} + + + + )} + + {/* Projects */} + {projects.length > 0 && ( + <> + } label='Projects'> + setOperator('project', op)} + /> + + {chipRow('project', [ + { value: 'default', label: 'Default Project' }, + ...projects + .filter(p => p.id !== 'default') + .map(p => ({ value: p.id, label: p.name })), + ])} + + + )} + + {/* Points */} + } label='Points' /> + + {POINTS_OPERATORS.map(op => ( + setPointsOperator(op.value)} + sx={{ + cursor: 'pointer', + userSelect: 'none', + fontFamily: 'monospace', + fontWeight: 600, + }} + > + {op.label} + + ))} + setPointsValue(parseInt(e.target.value) || 0)} + sx={{ width: 80 }} + slotProps={{ input: { min: 0 } }} + /> + {selections.points.active && ( + + onSelectionsChange(prev => ({ + ...prev, + points: { ...prev.points, active: false, value: 0 }, + })) + } + sx={{ cursor: 'pointer' }} + > + Clear + + )} + + + ) +} + +export default FilterBuilderContent diff --git a/src/views/Modals/Inputs/AdvancedFilterBuilder.jsx b/src/views/Modals/Inputs/AdvancedFilterBuilder.jsx index 0973081..04bea0e 100644 --- a/src/views/Modals/Inputs/AdvancedFilterBuilder.jsx +++ b/src/views/Modals/Inputs/AdvancedFilterBuilder.jsx @@ -1,16 +1,5 @@ +import { Save } from '@mui/icons-material' import { - CalendarMonth, - Check, - FolderOpen, - Label, - Person, - PriorityHigh, - Save, - Stars, - TaskAlt, -} from '@mui/icons-material' -import { - Avatar, Box, Button, Chip, @@ -20,125 +9,15 @@ import { } from '@mui/joy' import { useEffect, useMemo, useState } from 'react' import BottomSheetModal from '../../../components/common/BottomSheetModal' +import FilterBuilderContent, { + conditionsToSelections, + defaultSelections, + selectionsToConditions, +} from '../../Chores/components/FilterBuilderContent' import { FILTER_COLORS } from '../../../utils/Colors' import { applyFilter } from '../../../utils/FilterEngine' -import Priorities from '../../../utils/Priorities' import { useFilters } from '../../Filters/FilterQueries' -const DUE_DATE_OPTIONS = [ - { value: 'isOverdue', label: 'Overdue', color: 'danger' }, - { value: 'isDueToday', label: 'Today', color: 'warning' }, - { value: 'isDueTomorrow', label: 'Tomorrow', color: 'primary' }, - { value: 'isDueThisWeek', label: 'This Week', color: 'primary' }, - { value: 'isDueThisMonth', label: 'This Month', color: 'neutral' }, - { value: 'hasNoDueDate', label: 'No Due Date', color: 'neutral' }, - { value: 'hasDueDate', label: 'Has Due Date', color: 'neutral' }, -] - -const POINTS_OPERATORS = [ - { value: 'greaterThan', label: '>' }, - { value: 'greaterThanOrEqual', label: '>=' }, - { value: 'equals', label: '=' }, - { value: 'lessThanOrEqual', label: '<=' }, - { value: 'lessThan', label: '<' }, -] - -const CHORE_STATUSES = [ - { value: 0, label: 'Active' }, - { value: 1, label: 'Started' }, - { value: 2, label: 'In Progress' }, - { value: 3, label: 'Pending Approval' }, -] - -const defaultSelections = () => ({ - assignee: { operator: 'is', values: [] }, - createdBy: { operator: 'is', values: [] }, - status: { operator: 'is', values: [] }, - priority: { operator: 'is', values: [] }, - label: { operator: 'is', values: [] }, - project: { operator: 'is', values: [] }, - dueDate: { operator: null }, - points: { operator: 'greaterThan', value: 0, active: false }, -}) - -const conditionsToSelections = conditions => { - const sel = defaultSelections() - if (!conditions) return sel - conditions.forEach(c => { - if (c.type === 'dueDate') { - sel.dueDate = { operator: c.operator } - } else if (c.type === 'points') { - sel.points = { operator: c.operator, value: c.value ?? 0, active: true } - } else if (c.type in sel) { - sel[c.type] = { - operator: c.operator ?? 'is', - values: Array.isArray(c.value) ? c.value : c.value != null ? [c.value] : [], - } - } - }) - return sel -} - -const selectionsToConditions = selections => { - const conditions = [] - ;['assignee', 'createdBy', 'status', 'priority', 'label', 'project'].forEach(type => { - if (selections[type].values?.length > 0) { - conditions.push({ - type, - operator: selections[type].operator, - value: selections[type].values, - }) - } - }) - if (selections.dueDate.operator) { - conditions.push({ type: 'dueDate', operator: selections.dueDate.operator, value: null }) - } - if (selections.points.active) { - conditions.push({ - type: 'points', - operator: selections.points.operator, - value: selections.points.value, - }) - } - return conditions -} - -const hasAnySelection = selections => selectionsToConditions(selections).length > 0 - -// ── Sub-components ─────────────────────────────────────────────────────────── - -const SectionHeader = ({ icon, label, children }) => ( - - - {icon} - - {label} - {children} - -) - -const IncludeExcludeToggle = ({ value, onChange, labels = ['Include', 'Exclude'] }) => ( - - {[ - { op: 'is', label: labels[0] }, - { op: 'isNot', label: labels[1] }, - ].map(o => ( - onChange(o.op)} - sx={{ cursor: 'pointer', userSelect: 'none', transition: 'all 0.15s ease' }} - > - {o.label} - - ))} - -) - -// ── Main Component ─────────────────────────────────────────────────────────── - const AdvancedFilterBuilder = ({ isOpen, onClose, @@ -194,32 +73,7 @@ const AdvancedFilterBuilder = ({ c => c.nextDueDate && new Date(c.nextDueDate) < new Date(), ).length - // ── Mutators ─────────────────────────────────────────────────────────────── - - const toggleValue = (type, value) => { - setSelections(prev => { - const cur = prev[type].values || [] - const next = cur.includes(value) ? cur.filter(v => v !== value) : [...cur, value] - return { ...prev, [type]: { ...prev[type], values: next } } - }) - } - - const setOperator = (type, op) => - setSelections(prev => ({ ...prev, [type]: { ...prev[type], operator: op } })) - - const toggleDueDate = op => - setSelections(prev => ({ - ...prev, - dueDate: { operator: prev.dueDate.operator === op ? null : op }, - })) - - const setPointsOperator = op => - setSelections(prev => ({ ...prev, points: { ...prev.points, operator: op, active: true } })) - - const setPointsValue = val => - setSelections(prev => ({ ...prev, points: { ...prev.points, value: val, active: val > 0 } })) - - // ── Save ─────────────────────────────────────────────────────────────────── + const activeConditionCount = conditions.length const handleSave = () => { if (!filterName.trim()) { @@ -245,65 +99,6 @@ const AdvancedFilterBuilder = ({ onClose() } - // ── Render helpers ───────────────────────────────────────────────────────── - - const chipRow = (type, options, getChipProps) => { - const selected = selections[type].values || [] - return ( - - {options.map(opt => { - const isSelected = selected.includes(opt.value) - const extra = getChipProps ? getChipProps(opt, isSelected) : {} - return ( - : (extra.startDecorator ?? null) - } - onClick={() => toggleValue(type, opt.value)} - sx={{ cursor: 'pointer', userSelect: 'none', transition: 'all 0.15s ease' }} - > - {opt.label} - - ) - })} - - ) - } - - const personChipRow = type => { - const selected = selections[type].values || [] - return ( - - {members.map(m => { - const isSelected = selected.includes(m.userId) - return ( - - ) : ( - - ) - } - onClick={() => toggleValue(type, m.userId)} - sx={{ cursor: 'pointer', userSelect: 'none', transition: 'all 0.15s ease' }} - > - {m.displayName || m.username} - - ) - })} - - ) - } - - const activeConditionCount = conditions.length - return ( } footer={ - + {/* Preview */} {conditions.length > 0 ? ( @@ -360,27 +162,37 @@ const AdvancedFilterBuilder = ({ } > - - {/* ── Name ─────────────────────────────────────────────────────────── */} + {/* Name */} - + Filter Name { setFilterName(e.target.value); setError('') }} + onChange={e => { + setFilterName(e.target.value) + setError('') + }} error={!!error} autoFocus /> {error && ( - {error} + + {error} + )} - {/* ── Color ────────────────────────────────────────────────────────── */} + {/* Color */} - + Color @@ -395,9 +207,10 @@ const AdvancedFilterBuilder = ({ borderRadius: '50%', background: c.value, cursor: 'pointer', - outline: filterColor === c.value - ? '3px solid var(--joy-palette-primary-500)' - : '2px solid transparent', + outline: + filterColor === c.value + ? '3px solid var(--joy-palette-primary-500)' + : '2px solid transparent', outlineOffset: '2px', transition: 'all 0.15s ease', flexShrink: 0, @@ -410,185 +223,13 @@ const AdvancedFilterBuilder = ({ - {/* ── Assignee ─────────────────────────────────────────────────────── */} - {members.length > 0 && ( - <> - } label='Assignee'> - setOperator('assignee', op)} - /> - - {personChipRow('assignee')} - - - )} - - {/* ── Created By ───────────────────────────────────────────────────── */} - {members.length > 0 && ( - <> - } label='Created By'> - setOperator('createdBy', op)} - /> - - {personChipRow('createdBy')} - - - )} - - {/* ── Status ───────────────────────────────────────────────────────── */} - } label='Status'> - setOperator('status', op)} - /> - - {chipRow('status', CHORE_STATUSES)} - - - {/* ── Priority ─────────────────────────────────────────────────────── */} - } label='Priority'> - setOperator('priority', op)} - /> - - {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, - }))} - - - {/* ── Due Date ─────────────────────────────────────────────────────── */} - } label='Due Date' /> - - {DUE_DATE_OPTIONS.map(opt => { - const isSelected = selections.dueDate.operator === opt.value - return ( - : null} - onClick={() => toggleDueDate(opt.value)} - sx={{ cursor: 'pointer', userSelect: 'none', transition: 'all 0.15s ease' }} - > - {opt.label} - - ) - })} - - - - {/* ── Labels ───────────────────────────────────────────────────────── */} - {labels.length > 0 && ( - <> - } label='Labels'> - setOperator('label', op)} - labels={['Has', "Doesn't Have"]} - /> - - - {labels.map(lbl => { - const isSelected = selections.label.values.includes(lbl.id) - return ( - - } - endDecorator={isSelected ? : 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} - - ) - })} - - - - )} - - {/* ── Projects ─────────────────────────────────────────────────────── */} - {projects.length > 0 && ( - <> - } label='Projects'> - setOperator('project', op)} - /> - - {chipRow( - 'project', - [ - { value: 'default', label: 'Default Project' }, - ...projects.filter(p => p.id !== 'default').map(p => ({ value: p.id, label: p.name })), - ], - )} - - - )} - - {/* ── Points ───────────────────────────────────────────────────────── */} - } label='Points' /> - - {POINTS_OPERATORS.map(op => ( - setPointsOperator(op.value)} - sx={{ cursor: 'pointer', userSelect: 'none', fontFamily: 'monospace', fontWeight: 600 }} - > - {op.label} - - ))} - setPointsValue(parseInt(e.target.value) || 0)} - sx={{ width: 80 }} - slotProps={{ input: { min: 0 } }} - /> - {selections.points.active && ( - setSelections(prev => ({ ...prev, points: { ...prev.points, active: false, value: 0 } }))} - sx={{ cursor: 'pointer' }} - > - Clear - - )} - - + )