feat: add custom filter chips and filter section components
- Implemented CustomFilterChips component for displaying and managing custom filters with context menu options for pinning, editing, and deleting filters. - Created FilterSection component to integrate CustomFilterChips and provide UI for creating advanced filters and displaying active filters. - Introduced useCustomFilters hook to manage custom filter logic, including loading, saving, updating, and deleting filters. - Added AdvancedFilterBuilder modal for creating and editing advanced filters with dynamic condition handling. - Implemented SaveFilterModal for saving filters with preview functionality and validation for filter names. - Enhanced useChoreFilters and useProjectFilter hooks for improved filter management and state handling.
This commit is contained in:
281
src/utils/CustomFilterStorage.js
Normal file
281
src/utils/CustomFilterStorage.js
Normal file
@@ -0,0 +1,281 @@
|
|||||||
|
/**
|
||||||
|
* Custom Filter Storage - Manages saving/loading custom filters
|
||||||
|
*
|
||||||
|
* This module handles CRUD operations for custom filters.
|
||||||
|
* Currently uses localStorage, but designed to easily migrate to backend API.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'customFilters'
|
||||||
|
const MAX_FILTERS = 20 // Limit to prevent localStorage overflow
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a unique filter ID
|
||||||
|
*/
|
||||||
|
const generateFilterId = () => {
|
||||||
|
return `filter_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all saved filters
|
||||||
|
* @returns {Array} - Array of filter objects
|
||||||
|
*/
|
||||||
|
export const getSavedFilters = () => {
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem(STORAGE_KEY)
|
||||||
|
if (!stored) return []
|
||||||
|
|
||||||
|
const filters = JSON.parse(stored)
|
||||||
|
|
||||||
|
// Ensure filters have required fields
|
||||||
|
return filters.filter(f => f.id && f.name && f.conditions)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading saved filters:', error)
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save a new filter
|
||||||
|
* @param {Object} filter - The filter to save
|
||||||
|
* @returns {Object} - The saved filter with generated ID and metadata
|
||||||
|
*/
|
||||||
|
export const saveFilter = (filter) => {
|
||||||
|
try {
|
||||||
|
const filters = getSavedFilters()
|
||||||
|
|
||||||
|
// Check limit
|
||||||
|
if (filters.length >= MAX_FILTERS) {
|
||||||
|
throw new Error(`Maximum of ${MAX_FILTERS} filters allowed. Please delete some filters first.`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create new filter with metadata
|
||||||
|
const newFilter = {
|
||||||
|
id: generateFilterId(),
|
||||||
|
name: filter.name,
|
||||||
|
icon: filter.icon || null,
|
||||||
|
conditions: filter.conditions,
|
||||||
|
operator: filter.operator || 'AND',
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
isPinned: filter.isPinned || false,
|
||||||
|
usageCount: 0,
|
||||||
|
lastUsedAt: null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add to filters array
|
||||||
|
const updatedFilters = [...filters, newFilter]
|
||||||
|
|
||||||
|
// Save to localStorage
|
||||||
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(updatedFilters))
|
||||||
|
|
||||||
|
return newFilter
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error saving filter:', error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update an existing filter
|
||||||
|
* @param {string} filterId - The ID of the filter to update
|
||||||
|
* @param {Object} updates - The fields to update
|
||||||
|
* @returns {Object} - The updated filter
|
||||||
|
*/
|
||||||
|
export const updateFilter = (filterId, updates) => {
|
||||||
|
try {
|
||||||
|
const filters = getSavedFilters()
|
||||||
|
const filterIndex = filters.findIndex(f => f.id === filterId)
|
||||||
|
|
||||||
|
if (filterIndex === -1) {
|
||||||
|
throw new Error('Filter not found')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update filter
|
||||||
|
const updatedFilter = {
|
||||||
|
...filters[filterIndex],
|
||||||
|
...updates,
|
||||||
|
updatedAt: new Date().toISOString()
|
||||||
|
}
|
||||||
|
|
||||||
|
filters[filterIndex] = updatedFilter
|
||||||
|
|
||||||
|
// Save to localStorage
|
||||||
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(filters))
|
||||||
|
|
||||||
|
return updatedFilter
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating filter:', error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a filter
|
||||||
|
* @param {string} filterId - The ID of the filter to delete
|
||||||
|
* @returns {boolean} - Success status
|
||||||
|
*/
|
||||||
|
export const deleteFilter = (filterId) => {
|
||||||
|
try {
|
||||||
|
const filters = getSavedFilters()
|
||||||
|
const updatedFilters = filters.filter(f => f.id !== filterId)
|
||||||
|
|
||||||
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(updatedFilters))
|
||||||
|
|
||||||
|
return true
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error deleting filter:', error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a single filter by ID
|
||||||
|
* @param {string} filterId - The ID of the filter
|
||||||
|
* @returns {Object|null} - The filter object or null if not found
|
||||||
|
*/
|
||||||
|
export const getFilterById = (filterId) => {
|
||||||
|
const filters = getSavedFilters()
|
||||||
|
return filters.find(f => f.id === filterId) || null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Increment usage count for a filter
|
||||||
|
* @param {string} filterId - The ID of the filter
|
||||||
|
*/
|
||||||
|
export const trackFilterUsage = (filterId) => {
|
||||||
|
try {
|
||||||
|
const filters = getSavedFilters()
|
||||||
|
const filterIndex = filters.findIndex(f => f.id === filterId)
|
||||||
|
|
||||||
|
if (filterIndex !== -1) {
|
||||||
|
filters[filterIndex].usageCount = (filters[filterIndex].usageCount || 0) + 1
|
||||||
|
filters[filterIndex].lastUsedAt = new Date().toISOString()
|
||||||
|
|
||||||
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(filters))
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error tracking filter usage:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggle pin status of a filter
|
||||||
|
* @param {string} filterId - The ID of the filter
|
||||||
|
* @returns {boolean} - New pin status
|
||||||
|
*/
|
||||||
|
export const toggleFilterPin = (filterId) => {
|
||||||
|
try {
|
||||||
|
const filters = getSavedFilters()
|
||||||
|
const filterIndex = filters.findIndex(f => f.id === filterId)
|
||||||
|
|
||||||
|
if (filterIndex === -1) {
|
||||||
|
throw new Error('Filter not found')
|
||||||
|
}
|
||||||
|
|
||||||
|
filters[filterIndex].isPinned = !filters[filterIndex].isPinned
|
||||||
|
filters[filterIndex].updatedAt = new Date().toISOString()
|
||||||
|
|
||||||
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(filters))
|
||||||
|
|
||||||
|
return filters[filterIndex].isPinned
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error toggling filter pin:', error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get filters sorted by usage (most used first)
|
||||||
|
* @returns {Array} - Sorted filters
|
||||||
|
*/
|
||||||
|
export const getFiltersByUsage = () => {
|
||||||
|
const filters = getSavedFilters()
|
||||||
|
return filters.sort((a, b) => (b.usageCount || 0) - (a.usageCount || 0))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get pinned filters
|
||||||
|
* @returns {Array} - Pinned filters
|
||||||
|
*/
|
||||||
|
export const getPinnedFilters = () => {
|
||||||
|
const filters = getSavedFilters()
|
||||||
|
return filters.filter(f => f.isPinned)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if filter name already exists
|
||||||
|
* @param {string} name - The filter name to check
|
||||||
|
* @param {string} excludeId - Optional ID to exclude from check (for updates)
|
||||||
|
* @returns {boolean} - Whether the name exists
|
||||||
|
*/
|
||||||
|
export const filterNameExists = (name, excludeId = null) => {
|
||||||
|
const filters = getSavedFilters()
|
||||||
|
return filters.some(f =>
|
||||||
|
f.name.toLowerCase() === name.toLowerCase() &&
|
||||||
|
f.id !== excludeId
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Export filters as JSON (for backup/sharing)
|
||||||
|
* @returns {string} - JSON string of all filters
|
||||||
|
*/
|
||||||
|
export const exportFilters = () => {
|
||||||
|
const filters = getSavedFilters()
|
||||||
|
return JSON.stringify(filters, null, 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Import filters from JSON
|
||||||
|
* @param {string} jsonString - JSON string of filters
|
||||||
|
* @returns {number} - Number of filters imported
|
||||||
|
*/
|
||||||
|
export const importFilters = (jsonString) => {
|
||||||
|
try {
|
||||||
|
const importedFilters = JSON.parse(jsonString)
|
||||||
|
|
||||||
|
if (!Array.isArray(importedFilters)) {
|
||||||
|
throw new Error('Invalid filter format')
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingFilters = getSavedFilters()
|
||||||
|
|
||||||
|
// Generate new IDs for imported filters to avoid conflicts
|
||||||
|
const newFilters = importedFilters.map(filter => ({
|
||||||
|
...filter,
|
||||||
|
id: generateFilterId(),
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
usageCount: 0,
|
||||||
|
lastUsedAt: null
|
||||||
|
}))
|
||||||
|
|
||||||
|
const allFilters = [...existingFilters, ...newFilters]
|
||||||
|
|
||||||
|
// Check limit
|
||||||
|
if (allFilters.length > MAX_FILTERS) {
|
||||||
|
throw new Error(`Import would exceed maximum of ${MAX_FILTERS} filters`)
|
||||||
|
}
|
||||||
|
|
||||||
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(allFilters))
|
||||||
|
|
||||||
|
return newFilters.length
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error importing filters:', error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear all filters (use with caution!)
|
||||||
|
* @returns {boolean} - Success status
|
||||||
|
*/
|
||||||
|
export const clearAllFilters = () => {
|
||||||
|
try {
|
||||||
|
localStorage.removeItem(STORAGE_KEY)
|
||||||
|
return true
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error clearing filters:', error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
369
src/utils/FilterEngine.js
Normal file
369
src/utils/FilterEngine.js
Normal file
@@ -0,0 +1,369 @@
|
|||||||
|
/**
|
||||||
|
* Filter Engine - Evaluates filter conditions against chores
|
||||||
|
*
|
||||||
|
* This engine takes saved filter conditions and applies them to chores.
|
||||||
|
* It supports various operators and combines conditions with AND/OR logic.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Evaluate a single condition against a chore
|
||||||
|
* @param {Object} chore - The chore to evaluate
|
||||||
|
* @param {Object} condition - The condition to check
|
||||||
|
* @param {Object} context - Additional context (userId, members, etc.)
|
||||||
|
* @returns {boolean} - Whether the chore matches the condition
|
||||||
|
*/
|
||||||
|
export const evaluateCondition = (chore, condition, context = {}) => {
|
||||||
|
const { type, operator, value } = condition
|
||||||
|
|
||||||
|
switch (type) {
|
||||||
|
case 'assignee':
|
||||||
|
return evaluateAssignee(chore, operator, value, context)
|
||||||
|
|
||||||
|
case 'createdBy':
|
||||||
|
return evaluateCreatedBy(chore, operator, value, context)
|
||||||
|
|
||||||
|
case 'priority':
|
||||||
|
return evaluatePriority(chore, operator, value)
|
||||||
|
|
||||||
|
case 'status':
|
||||||
|
return evaluateStatus(chore, operator, value)
|
||||||
|
|
||||||
|
case 'dueDate':
|
||||||
|
return evaluateDueDate(chore, operator, value)
|
||||||
|
|
||||||
|
case 'label':
|
||||||
|
return evaluateLabel(chore, operator, value)
|
||||||
|
|
||||||
|
case 'project':
|
||||||
|
return evaluateProject(chore, operator, value)
|
||||||
|
|
||||||
|
default:
|
||||||
|
console.warn(`Unknown condition type: ${type}`)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Evaluate assignee condition
|
||||||
|
*/
|
||||||
|
const evaluateAssignee = (chore, operator, value, context) => {
|
||||||
|
const { userId } = context
|
||||||
|
|
||||||
|
// Handle special values
|
||||||
|
if (value === 'me' && userId) {
|
||||||
|
const isAssignedToMe =
|
||||||
|
String(chore.assignedTo) === String(userId) ||
|
||||||
|
chore.assignees?.some(a => String(a.userId) === String(userId))
|
||||||
|
return operator === 'is' ? isAssignedToMe : !isAssignedToMe
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value === 'others' && userId) {
|
||||||
|
const isAssignedToOthers =
|
||||||
|
String(chore.assignedTo) !== String(userId) &&
|
||||||
|
!chore.assignees?.some(a => String(a.userId) === String(userId))
|
||||||
|
return operator === 'is' ? isAssignedToOthers : !isAssignedToOthers
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value === 'anyone') {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle specific user IDs (can be array for multi-select)
|
||||||
|
const userIds = Array.isArray(value) ? value : [value]
|
||||||
|
const isAssigned = userIds.some(
|
||||||
|
id =>
|
||||||
|
String(chore.assignedTo) === String(id) ||
|
||||||
|
chore.assignees?.some(a => String(a.userId) === String(id)),
|
||||||
|
)
|
||||||
|
|
||||||
|
return operator === 'is' ? isAssigned : !isAssigned
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Evaluate created by condition
|
||||||
|
*/
|
||||||
|
const evaluateCreatedBy = (chore, operator, value, context) => {
|
||||||
|
const { userId } = context
|
||||||
|
|
||||||
|
if (value === 'me' && userId) {
|
||||||
|
return operator === 'is'
|
||||||
|
? String(chore.createdBy) === String(userId)
|
||||||
|
: String(chore.createdBy) !== String(userId)
|
||||||
|
}
|
||||||
|
|
||||||
|
const creatorIds = Array.isArray(value) ? value : [value]
|
||||||
|
const isCreatedBy = creatorIds.some(
|
||||||
|
id => String(id) === String(chore.createdBy),
|
||||||
|
)
|
||||||
|
|
||||||
|
return operator === 'is' ? isCreatedBy : !isCreatedBy
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Evaluate priority condition
|
||||||
|
*/
|
||||||
|
const evaluatePriority = (chore, operator, value) => {
|
||||||
|
const priorities = Array.isArray(value) ? value : [value]
|
||||||
|
const chorePriority = chore.priority || 0
|
||||||
|
|
||||||
|
switch (operator) {
|
||||||
|
case 'is':
|
||||||
|
return priorities.some(p => Number(p) === Number(chorePriority))
|
||||||
|
case 'isNot':
|
||||||
|
return !priorities.some(p => Number(p) === Number(chorePriority))
|
||||||
|
case 'greaterThan':
|
||||||
|
return Number(chorePriority) > Number(value)
|
||||||
|
case 'lessThan':
|
||||||
|
return Number(chorePriority) < Number(value)
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Evaluate status condition
|
||||||
|
*/
|
||||||
|
const evaluateStatus = (chore, operator, value) => {
|
||||||
|
const statuses = Array.isArray(value) ? value : [value]
|
||||||
|
const choreStatus = chore.status || 0
|
||||||
|
|
||||||
|
switch (operator) {
|
||||||
|
case 'is':
|
||||||
|
return statuses.some(s => Number(s) === Number(choreStatus))
|
||||||
|
case 'isNot':
|
||||||
|
return !statuses.some(s => Number(s) === Number(choreStatus))
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Evaluate due date condition
|
||||||
|
*/
|
||||||
|
const evaluateDueDate = (chore, operator, value) => {
|
||||||
|
const { nextDueDate } = chore
|
||||||
|
|
||||||
|
// Handle "no due date" case
|
||||||
|
if (operator === 'hasNoDueDate') {
|
||||||
|
return nextDueDate === null || nextDueDate === undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
if (operator === 'hasDueDate') {
|
||||||
|
return nextDueDate !== null && nextDueDate !== undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!nextDueDate) return false
|
||||||
|
|
||||||
|
const dueDate = new Date(nextDueDate)
|
||||||
|
const now = new Date()
|
||||||
|
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate())
|
||||||
|
const tomorrow = new Date(today)
|
||||||
|
tomorrow.setDate(tomorrow.getDate() + 1)
|
||||||
|
|
||||||
|
switch (operator) {
|
||||||
|
case 'isOverdue':
|
||||||
|
return dueDate < now
|
||||||
|
|
||||||
|
case 'isDueToday':
|
||||||
|
return dueDate.toDateString() === today.toDateString()
|
||||||
|
|
||||||
|
case 'isDueTomorrow':
|
||||||
|
return dueDate.toDateString() === tomorrow.toDateString()
|
||||||
|
|
||||||
|
case 'isDueThisWeek': {
|
||||||
|
const nextWeek = new Date(today)
|
||||||
|
nextWeek.setDate(nextWeek.getDate() + 7)
|
||||||
|
return dueDate >= today && dueDate < nextWeek
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'isDueThisMonth': {
|
||||||
|
return (
|
||||||
|
dueDate.getMonth() === today.getMonth() &&
|
||||||
|
dueDate.getFullYear() === today.getFullYear()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'before': {
|
||||||
|
const targetDate = value === 'today' ? today : new Date(value)
|
||||||
|
return dueDate < targetDate
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'after': {
|
||||||
|
const targetDate = value === 'today' ? today : new Date(value)
|
||||||
|
return dueDate > targetDate
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'between': {
|
||||||
|
const [start, end] = value
|
||||||
|
return dueDate >= new Date(start) && dueDate <= new Date(end)
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Evaluate label condition
|
||||||
|
*/
|
||||||
|
const evaluateLabel = (chore, operator, value) => {
|
||||||
|
const labelIds = Array.isArray(value) ? value : [value]
|
||||||
|
const choreLabels = chore.labelsV2 || []
|
||||||
|
|
||||||
|
const hasLabel = labelIds.some(labelId =>
|
||||||
|
choreLabels.some(l => String(l.id) === String(labelId)),
|
||||||
|
)
|
||||||
|
|
||||||
|
switch (operator) {
|
||||||
|
case 'has':
|
||||||
|
case 'is':
|
||||||
|
return hasLabel
|
||||||
|
case 'doesNotHave':
|
||||||
|
case 'isNot':
|
||||||
|
return !hasLabel
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Evaluate project condition
|
||||||
|
*/
|
||||||
|
const evaluateProject = (chore, operator, value) => {
|
||||||
|
const { projectId } = chore
|
||||||
|
|
||||||
|
// Convert to array for consistent handling
|
||||||
|
const projectIds = Array.isArray(value) ? value : [value]
|
||||||
|
|
||||||
|
// Check if 'default' is in the selection
|
||||||
|
const includesDefault = projectIds.includes('default')
|
||||||
|
|
||||||
|
// Check if chore is in default project (no projectId or projectId is 'default')
|
||||||
|
const choreIsDefault =
|
||||||
|
!projectId || projectId === null || projectId === 'default'
|
||||||
|
|
||||||
|
// Check if chore matches any of the non-default project IDs
|
||||||
|
const otherProjectIds = projectIds.filter(id => id !== 'default')
|
||||||
|
const matchesOtherProject = otherProjectIds.some(
|
||||||
|
id => String(projectId) === String(id),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Chore matches if it's default and default is selected, OR if it matches any other selected project
|
||||||
|
const isInProject = (includesDefault && choreIsDefault) || matchesOtherProject
|
||||||
|
|
||||||
|
return operator === 'is' ? isInProject : !isInProject
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply a complete filter (with multiple conditions) to chores
|
||||||
|
* @param {Array} chores - The chores to filter
|
||||||
|
* @param {Object} filter - The filter with conditions
|
||||||
|
* @param {Object} context - Additional context (userId, members, etc.)
|
||||||
|
* @returns {Array} - Filtered chores
|
||||||
|
*/
|
||||||
|
export const applyFilter = (chores, filter, context = {}) => {
|
||||||
|
if (!filter || !filter.conditions || filter.conditions.length === 0) {
|
||||||
|
return chores
|
||||||
|
}
|
||||||
|
|
||||||
|
const { conditions, operator = 'AND' } = filter
|
||||||
|
|
||||||
|
return chores.filter(chore => {
|
||||||
|
if (operator === 'OR') {
|
||||||
|
// At least one condition must match
|
||||||
|
return conditions.some(condition =>
|
||||||
|
evaluateCondition(chore, condition, context),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
// All conditions must match (AND)
|
||||||
|
return conditions.every(condition =>
|
||||||
|
evaluateCondition(chore, condition, context),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get count of chores matching a filter
|
||||||
|
* @param {Array} chores - The chores to count
|
||||||
|
* @param {Object} filter - The filter to apply
|
||||||
|
* @param {Object} context - Additional context
|
||||||
|
* @returns {number} - Count of matching chores
|
||||||
|
*/
|
||||||
|
export const getFilterCount = (chores, filter, context = {}) => {
|
||||||
|
return applyFilter(chores, filter, context).length
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get count of overdue chores matching a filter
|
||||||
|
* @param {Array} chores - The chores to count
|
||||||
|
* @param {Object} filter - The filter to apply
|
||||||
|
* @param {Object} context - Additional context
|
||||||
|
* @returns {number} - Count of overdue chores
|
||||||
|
*/
|
||||||
|
export const getFilterOverdueCount = (chores, filter, context = {}) => {
|
||||||
|
const filtered = applyFilter(chores, filter, context)
|
||||||
|
return filtered.filter(chore => {
|
||||||
|
if (!chore.nextDueDate) return false
|
||||||
|
return new Date(chore.nextDueDate) < new Date()
|
||||||
|
}).length
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate if a filter is still valid
|
||||||
|
* (e.g., checks if referenced users/labels/projects still exist)
|
||||||
|
* @param {Object} filter - The filter to validate
|
||||||
|
* @param {Object} context - Context with available users, labels, projects
|
||||||
|
* @returns {Object} - { isValid: boolean, issues: Array }
|
||||||
|
*/
|
||||||
|
export const validateFilter = (filter, context = {}) => {
|
||||||
|
const { members = [], labels = [], projects = [] } = context
|
||||||
|
const issues = []
|
||||||
|
|
||||||
|
if (!filter.conditions || filter.conditions.length === 0) {
|
||||||
|
issues.push('Filter has no conditions')
|
||||||
|
return { isValid: false, issues }
|
||||||
|
}
|
||||||
|
|
||||||
|
filter.conditions.forEach((condition, index) => {
|
||||||
|
const { type, value } = condition
|
||||||
|
|
||||||
|
// Check assignee references
|
||||||
|
if (type === 'assignee' && !['me', 'others', 'anyone'].includes(value)) {
|
||||||
|
const userIds = Array.isArray(value) ? value : [value]
|
||||||
|
const invalidUsers = userIds.filter(
|
||||||
|
id => !members.some(m => m.id === id || m.userId === id),
|
||||||
|
)
|
||||||
|
if (invalidUsers.length > 0) {
|
||||||
|
issues.push(`Condition ${index + 1}: User(s) no longer exist`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check label references
|
||||||
|
if (type === 'label') {
|
||||||
|
const labelIds = Array.isArray(value) ? value : [value]
|
||||||
|
const invalidLabels = labelIds.filter(
|
||||||
|
id => !labels.some(l => l.id === id),
|
||||||
|
)
|
||||||
|
if (invalidLabels.length > 0) {
|
||||||
|
issues.push(`Condition ${index + 1}: Label(s) no longer exist`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check project references
|
||||||
|
if (type === 'project' && value !== 'default') {
|
||||||
|
const projectIds = Array.isArray(value) ? value : [value]
|
||||||
|
const invalidProjects = projectIds.filter(
|
||||||
|
id => !projects.some(p => p.id === id || p.id === Number(id)),
|
||||||
|
)
|
||||||
|
if (invalidProjects.length > 0) {
|
||||||
|
issues.push(`Condition ${index + 1}: Project(s) no longer exist`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
isValid: issues.length === 0,
|
||||||
|
issues,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -59,7 +59,20 @@ import TaskInput from '../components/AddTaskModal'
|
|||||||
import CalendarDual from '../components/CalendarDual'
|
import CalendarDual from '../components/CalendarDual'
|
||||||
import CalendarMonthly from '../components/CalendarMonthly.jsx'
|
import CalendarMonthly from '../components/CalendarMonthly.jsx'
|
||||||
import ProjectSelector from '../components/ProjectSelector'
|
import ProjectSelector from '../components/ProjectSelector'
|
||||||
|
import AdvancedFilterBuilder from '../Modals/Inputs/AdvancedFilterBuilder'
|
||||||
|
import SaveFilterModal from '../Modals/Inputs/SaveFilterModal'
|
||||||
import { useProjects } from '../Projects/ProjectQueries.js'
|
import { useProjects } from '../Projects/ProjectQueries.js'
|
||||||
|
import ChoreModals from './components/ChoreModals'
|
||||||
|
import FilterSection from './components/FilterSection'
|
||||||
|
import MultiSelectToolbar from './components/MultiSelectToolbar'
|
||||||
|
import SearchBar from './components/SearchBar'
|
||||||
|
import { useChoreActions } from './hooks/useChoreActions'
|
||||||
|
import { useChoreFilters } from './hooks/useChoreFilters'
|
||||||
|
import { useChoreModals } from './hooks/useChoreModals'
|
||||||
|
import { useCustomFilters } from './hooks/useCustomFilters'
|
||||||
|
import { useKeyboardShortcuts } from './hooks/useKeyboardShortcuts'
|
||||||
|
import { useMultiSelect } from './hooks/useMultiSelect'
|
||||||
|
import { useProjectFilter } from './hooks/useProjectFilter'
|
||||||
import {
|
import {
|
||||||
canScheduleNotification,
|
canScheduleNotification,
|
||||||
scheduleChoreNotification,
|
scheduleChoreNotification,
|
||||||
@@ -67,15 +80,6 @@ import {
|
|||||||
import NotificationAccessSnackbar from './NotificationAccessSnackbar'
|
import NotificationAccessSnackbar from './NotificationAccessSnackbar'
|
||||||
import Sidepanel from './Sidepanel'
|
import Sidepanel from './Sidepanel'
|
||||||
import SortAndGrouping from './SortAndGrouping'
|
import SortAndGrouping from './SortAndGrouping'
|
||||||
import ChoreModals from './components/ChoreModals'
|
|
||||||
import MultiSelectToolbar from './components/MultiSelectToolbar'
|
|
||||||
import SearchBar from './components/SearchBar'
|
|
||||||
import { useChoreActions } from './hooks/useChoreActions'
|
|
||||||
import { useChoreFilters } from './hooks/useChoreFilters'
|
|
||||||
import { useChoreModals } from './hooks/useChoreModals'
|
|
||||||
import { useKeyboardShortcuts } from './hooks/useKeyboardShortcuts'
|
|
||||||
import { useMultiSelect } from './hooks/useMultiSelect'
|
|
||||||
import { useProjectFilter } from './hooks/useProjectFilter'
|
|
||||||
|
|
||||||
const MyChores = () => {
|
const MyChores = () => {
|
||||||
const { data: userProfile, isLoading: isUserProfileLoading } =
|
const { data: userProfile, isLoading: isUserProfileLoading } =
|
||||||
@@ -130,6 +134,7 @@ const MyChores = () => {
|
|||||||
selectedChoreFilter,
|
selectedChoreFilter,
|
||||||
projectFilteredChores,
|
projectFilteredChores,
|
||||||
searchFilteredChores,
|
searchFilteredChores,
|
||||||
|
nonProjectFilteredChores,
|
||||||
setSearchTerm,
|
setSearchTerm,
|
||||||
setSearchFilter,
|
setSearchFilter,
|
||||||
setSelectedChoreFilterWithCache,
|
setSelectedChoreFilterWithCache,
|
||||||
@@ -154,6 +159,31 @@ const MyChores = () => {
|
|||||||
const { activeModal, modalChore, modalData, openModal, closeModal } =
|
const { activeModal, modalChore, modalData, openModal, closeModal } =
|
||||||
useChoreModals()
|
useChoreModals()
|
||||||
|
|
||||||
|
const {
|
||||||
|
savedFilters,
|
||||||
|
activeFilter,
|
||||||
|
activeFilterId,
|
||||||
|
filteredChores: customFilteredChores,
|
||||||
|
applyCustomFilter,
|
||||||
|
clearActiveFilter,
|
||||||
|
saveFilter,
|
||||||
|
updateFilter,
|
||||||
|
deleteFilter,
|
||||||
|
pinFilter,
|
||||||
|
createFilterFromCurrentState,
|
||||||
|
hasProjectConditions,
|
||||||
|
} = useCustomFilters(
|
||||||
|
nonProjectFilteredChores,
|
||||||
|
membersData?.res,
|
||||||
|
userLabels,
|
||||||
|
projectsWithDefault,
|
||||||
|
)
|
||||||
|
|
||||||
|
const [showSaveFilterModal, setShowSaveFilterModal] = useState(false)
|
||||||
|
const [showAdvancedFilterBuilder, setShowAdvancedFilterBuilder] =
|
||||||
|
useState(false)
|
||||||
|
const [editingFilter, setEditingFilter] = useState(null)
|
||||||
|
|
||||||
const processedChores = useMemo(() => {
|
const processedChores = useMemo(() => {
|
||||||
if (!choresData?.res) {
|
if (!choresData?.res) {
|
||||||
return []
|
return []
|
||||||
@@ -298,6 +328,61 @@ const MyChores = () => {
|
|||||||
}
|
}
|
||||||
}, [searchInputFocus])
|
}, [searchInputFocus])
|
||||||
|
|
||||||
|
// Read and apply filters from URL parameters
|
||||||
|
useEffect(() => {
|
||||||
|
if (!chores.length || !savedFilters.length) return
|
||||||
|
|
||||||
|
// Check for filterId (camelCase) or filter_id (snake_case) for advanced filters
|
||||||
|
const filterId =
|
||||||
|
searchParams.get('filterId') || searchParams.get('filter_id')
|
||||||
|
const oldFilter = searchParams.get('filter')
|
||||||
|
|
||||||
|
// Handle advanced filter parameter
|
||||||
|
if (filterId && !activeFilterId) {
|
||||||
|
const filter = savedFilters.find(f => f.id === filterId)
|
||||||
|
if (filter) {
|
||||||
|
applyCustomFilter(filterId)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle legacy filter parameter (e.g., filter=unplanned)
|
||||||
|
if (oldFilter && searchFilter === 'All' && !activeFilterId) {
|
||||||
|
const filterMap = {
|
||||||
|
unplanned: 'No Due Date',
|
||||||
|
overdue: 'Overdue',
|
||||||
|
today: 'Due today',
|
||||||
|
week: 'Due in week',
|
||||||
|
later: 'Due Later',
|
||||||
|
pending: 'Pending Approval',
|
||||||
|
}
|
||||||
|
|
||||||
|
const filterName = filterMap[oldFilter.toLowerCase()]
|
||||||
|
if (filterName && FILTERS[filterName]) {
|
||||||
|
const filtered = FILTERS[filterName](
|
||||||
|
selectedProject ? projectFilteredChores : chores,
|
||||||
|
)
|
||||||
|
setFilteredChores(filtered)
|
||||||
|
setSearchFilter(filterName)
|
||||||
|
setViewMode('default')
|
||||||
|
setSelectedCalendarDate(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
searchParams,
|
||||||
|
chores,
|
||||||
|
searchFilter,
|
||||||
|
activeFilterId,
|
||||||
|
savedFilters,
|
||||||
|
applyCustomFilter,
|
||||||
|
selectedProject,
|
||||||
|
projectFilteredChores,
|
||||||
|
setSearchFilter,
|
||||||
|
setFilteredChores,
|
||||||
|
setViewMode,
|
||||||
|
setSelectedCalendarDate,
|
||||||
|
])
|
||||||
|
|
||||||
const {
|
const {
|
||||||
handleChoreAction,
|
handleChoreAction,
|
||||||
handleChangeDueDate,
|
handleChangeDueDate,
|
||||||
@@ -384,7 +469,8 @@ const MyChores = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleLabelFiltering = chipClicked => {
|
const handleLabelFiltering = chipClicked => {
|
||||||
// Start with project-filtered chores as base
|
clearActiveFilter()
|
||||||
|
|
||||||
const baseChores = selectedProject ? projectFilteredChores : chores
|
const baseChores = selectedProject ? projectFilteredChores : chores
|
||||||
|
|
||||||
if (chipClicked.label) {
|
if (chipClicked.label) {
|
||||||
@@ -404,10 +490,30 @@ const MyChores = () => {
|
|||||||
setFilteredChores(priorityFiltered)
|
setFilteredChores(priorityFiltered)
|
||||||
setSearchFilter('Priority: ' + priority)
|
setSearchFilter('Priority: ' + priority)
|
||||||
}
|
}
|
||||||
// Clear selected calendar date when filters change
|
|
||||||
setSelectedCalendarDate(null)
|
setSelectedCalendarDate(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Helper to update URL with filter parameters
|
||||||
|
const updateFilterUrl = (filterType, filterValue) => {
|
||||||
|
const params = new URLSearchParams(searchParams)
|
||||||
|
|
||||||
|
// Clear existing filter params
|
||||||
|
params.delete('filter')
|
||||||
|
params.delete('filterId')
|
||||||
|
params.delete('filter_id')
|
||||||
|
|
||||||
|
// Set new filter param (use filterId for advanced filters)
|
||||||
|
if (filterType && filterValue) {
|
||||||
|
params.set(filterType, filterValue)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Always navigate with params (preserves project param)
|
||||||
|
const paramString = params.toString()
|
||||||
|
Navigate(paramString ? `/chores?${paramString}` : '/chores', {
|
||||||
|
replace: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
const searchOptions = useMemo(
|
const searchOptions = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
keys: ['name', 'raw_label'],
|
keys: ['name', 'raw_label'],
|
||||||
@@ -433,6 +539,7 @@ const MyChores = () => {
|
|||||||
)
|
)
|
||||||
|
|
||||||
const handleSearchChange = e => {
|
const handleSearchChange = e => {
|
||||||
|
clearActiveFilter()
|
||||||
if (searchFilter !== 'All') {
|
if (searchFilter !== 'All') {
|
||||||
setSearchFilter('All')
|
setSearchFilter('All')
|
||||||
}
|
}
|
||||||
@@ -440,7 +547,6 @@ const MyChores = () => {
|
|||||||
if (search === '') {
|
if (search === '') {
|
||||||
setFilteredChores(selectedProject ? projectFilteredChores : chores)
|
setFilteredChores(selectedProject ? projectFilteredChores : chores)
|
||||||
setSearchTerm('')
|
setSearchTerm('')
|
||||||
// Clear selected calendar date when search changes
|
|
||||||
setSelectedCalendarDate(null)
|
setSelectedCalendarDate(null)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -514,6 +620,10 @@ const MyChores = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const getFilteredChores = useMemo(() => {
|
const getFilteredChores = useMemo(() => {
|
||||||
|
if (activeFilterId) {
|
||||||
|
return customFilteredChores
|
||||||
|
}
|
||||||
|
|
||||||
let baseChores = projectFilteredChores
|
let baseChores = projectFilteredChores
|
||||||
|
|
||||||
if (searchTerm?.length > 0 || searchFilter !== 'All') {
|
if (searchTerm?.length > 0 || searchFilter !== 'All') {
|
||||||
@@ -535,7 +645,14 @@ const MyChores = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return baseChores
|
return baseChores
|
||||||
}, [projectFilteredChores, searchTerm, searchFilter, filteredChores])
|
}, [
|
||||||
|
activeFilterId,
|
||||||
|
customFilteredChores,
|
||||||
|
projectFilteredChores,
|
||||||
|
searchTerm,
|
||||||
|
searchFilter,
|
||||||
|
filteredChores,
|
||||||
|
])
|
||||||
|
|
||||||
const getChoresForDate = useCallback(
|
const getChoresForDate = useCallback(
|
||||||
date => {
|
date => {
|
||||||
@@ -622,14 +739,13 @@ const MyChores = () => {
|
|||||||
mouseClickHandler={handleMenuOutsideClick}
|
mouseClickHandler={handleMenuOutsideClick}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Project Selector - Show only if there are multiple projects */}
|
{/* Project Selector - Hidden when active filter has project conditions */}
|
||||||
{projectsWithDefault.length > 1 && (
|
{projectsWithDefault.length > 1 && !hasProjectConditions && (
|
||||||
<ProjectSelector
|
<ProjectSelector
|
||||||
selectedProject={selectedProject?.name || 'Default Project'}
|
selectedProject={selectedProject?.name || 'Default Project'}
|
||||||
onProjectSelect={project => {
|
onProjectSelect={project => {
|
||||||
setSelectedProjectWithCache(project)
|
setSelectedProjectWithCache(project)
|
||||||
// setFilteredChores(chores)
|
clearActiveFilter()
|
||||||
// setSearchFilter('All')
|
|
||||||
}}
|
}}
|
||||||
showKeyboardShortcuts={showKeyboardShortcuts}
|
showKeyboardShortcuts={showKeyboardShortcuts}
|
||||||
/>
|
/>
|
||||||
@@ -782,6 +898,20 @@ const MyChores = () => {
|
|||||||
setFilteredChores(filteredChores)
|
setFilteredChores(filteredChores)
|
||||||
setSearchFilter(filter)
|
setSearchFilter(filter)
|
||||||
handleFilterMenuClose()
|
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}
|
{filter}
|
||||||
@@ -810,6 +940,7 @@ const MyChores = () => {
|
|||||||
selectedProject ? projectFilteredChores : chores,
|
selectedProject ? projectFilteredChores : chores,
|
||||||
)
|
)
|
||||||
setSearchFilter('All')
|
setSearchFilter('All')
|
||||||
|
updateFilterUrl(null, null)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Cancel All Filters
|
Cancel All Filters
|
||||||
@@ -831,6 +962,7 @@ const MyChores = () => {
|
|||||||
setSearchTerm('')
|
setSearchTerm('')
|
||||||
setFilteredChores(chores)
|
setFilteredChores(chores)
|
||||||
setSearchFilter('All')
|
setSearchFilter('All')
|
||||||
|
updateFilterUrl(null, null)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<CancelRounded />
|
<CancelRounded />
|
||||||
@@ -838,6 +970,42 @@ const MyChores = () => {
|
|||||||
</div>
|
</div>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
|
{/* Custom Filters Section */}
|
||||||
|
<FilterSection
|
||||||
|
savedFilters={savedFilters}
|
||||||
|
activeFilterId={activeFilterId}
|
||||||
|
activeFilter={activeFilter}
|
||||||
|
hasProjectConditions={hasProjectConditions}
|
||||||
|
onFilterClick={filterId => {
|
||||||
|
if (activeFilterId === filterId) {
|
||||||
|
clearActiveFilter()
|
||||||
|
updateFilterUrl(null, null)
|
||||||
|
} else {
|
||||||
|
setSearchFilter('All')
|
||||||
|
setSearchTerm('')
|
||||||
|
setFilteredChores([])
|
||||||
|
|
||||||
|
// 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 => {
|
||||||
|
setEditingFilter(filter)
|
||||||
|
setShowAdvancedFilterBuilder(true)
|
||||||
|
}}
|
||||||
|
onClearActiveFilter={clearActiveFilter}
|
||||||
|
onCreateAdvancedFilter={() => setShowAdvancedFilterBuilder(true)}
|
||||||
|
updateFilterUrl={updateFilterUrl}
|
||||||
|
/>
|
||||||
|
|
||||||
<MultiSelectToolbar
|
<MultiSelectToolbar
|
||||||
isVisible={isMultiSelectMode}
|
isVisible={isMultiSelectMode}
|
||||||
selectedCount={selectedChores.size}
|
selectedCount={selectedChores.size}
|
||||||
@@ -868,6 +1036,7 @@ const MyChores = () => {
|
|||||||
selectedProject ? projectFilteredChores : chores,
|
selectedProject ? projectFilteredChores : chores,
|
||||||
)
|
)
|
||||||
setSearchFilter('All')
|
setSearchFilter('All')
|
||||||
|
updateFilterUrl(null, null)
|
||||||
}}
|
}}
|
||||||
endDecorator={<CancelRounded />}
|
endDecorator={<CancelRounded />}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@@ -875,14 +1044,15 @@ const MyChores = () => {
|
|||||||
selectedProject ? projectFilteredChores : chores,
|
selectedProject ? projectFilteredChores : chores,
|
||||||
)
|
)
|
||||||
setSearchFilter('All')
|
setSearchFilter('All')
|
||||||
|
updateFilterUrl(null, null)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Additional Filter: {searchFilter}
|
Additional Filter: {searchFilter}
|
||||||
</Chip>
|
</Chip>
|
||||||
)}
|
)}
|
||||||
{/* Show "Nothing scheduled" when appropriate based on current view mode */}
|
{/* Show "Nothing scheduled" when appropriate based on current view mode */}
|
||||||
{(searchTerm?.length > 0 || searchFilter !== 'All'
|
{(searchTerm?.length > 0 || searchFilter !== 'All' || activeFilterId
|
||||||
? filteredChores.length === 0
|
? getFilteredChores.length === 0
|
||||||
: projectFilteredChores.length === 0) &&
|
: projectFilteredChores.length === 0) &&
|
||||||
// only if not in calendar view:
|
// only if not in calendar view:
|
||||||
viewMode !== 'calendar' && (
|
viewMode !== 'calendar' && (
|
||||||
@@ -909,10 +1079,9 @@ const MyChores = () => {
|
|||||||
<>
|
<>
|
||||||
<Button
|
<Button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
// Reset search and filters to show all chores in current project
|
|
||||||
setSearchFilter('All')
|
setSearchFilter('All')
|
||||||
setSearchTerm('')
|
setSearchTerm('')
|
||||||
// Clear any manual filteredChores and let the memo handle it
|
clearActiveFilter()
|
||||||
}}
|
}}
|
||||||
variant='outlined'
|
variant='outlined'
|
||||||
color='neutral'
|
color='neutral'
|
||||||
@@ -923,7 +1092,7 @@ const MyChores = () => {
|
|||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
{(searchTerm?.length > 0 || searchFilter !== 'All') &&
|
{(searchTerm?.length > 0 || searchFilter !== 'All' || activeFilterId) &&
|
||||||
viewMode !== 'calendar' &&
|
viewMode !== 'calendar' &&
|
||||||
getFilteredChores.map(chore =>
|
getFilteredChores.map(chore =>
|
||||||
renderChoreCard(chore, `filtered-${chore.id}`),
|
renderChoreCard(chore, `filtered-${chore.id}`),
|
||||||
@@ -947,17 +1116,15 @@ const MyChores = () => {
|
|||||||
color='danger'
|
color='danger'
|
||||||
size='lg'
|
size='lg'
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
// Update URL for navigation context
|
// Update state directly for immediate smooth transition
|
||||||
Navigate('/chores?filter=overdue', {
|
|
||||||
replace: false,
|
|
||||||
})
|
|
||||||
|
|
||||||
// Also update state directly for immediate smooth transition
|
|
||||||
const overdueChores = FILTERS['Overdue'](getFilteredChores)
|
const overdueChores = FILTERS['Overdue'](getFilteredChores)
|
||||||
setFilteredChores(overdueChores)
|
setFilteredChores(overdueChores)
|
||||||
setSearchFilter('Overdue')
|
setSearchFilter('Overdue')
|
||||||
setViewMode('default')
|
setViewMode('default')
|
||||||
setSelectedCalendarDate(null)
|
setSelectedCalendarDate(null)
|
||||||
|
|
||||||
|
// Update URL
|
||||||
|
updateFilterUrl('filter', 'overdue')
|
||||||
}}
|
}}
|
||||||
sx={{
|
sx={{
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
@@ -981,18 +1148,16 @@ const MyChores = () => {
|
|||||||
color='neutral'
|
color='neutral'
|
||||||
size='lg'
|
size='lg'
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
// Update URL for navigation context
|
// Update state directly for immediate smooth transition
|
||||||
Navigate('/chores?filter=unplanned', {
|
|
||||||
replace: false,
|
|
||||||
})
|
|
||||||
|
|
||||||
// Also update state directly for immediate smooth transition
|
|
||||||
const unplannedChores =
|
const unplannedChores =
|
||||||
FILTERS['No Due Date'](getFilteredChores)
|
FILTERS['No Due Date'](getFilteredChores)
|
||||||
setFilteredChores(unplannedChores)
|
setFilteredChores(unplannedChores)
|
||||||
setSearchFilter('No Due Date')
|
setSearchFilter('No Due Date')
|
||||||
setViewMode('default')
|
setViewMode('default')
|
||||||
setSelectedCalendarDate(null)
|
setSelectedCalendarDate(null)
|
||||||
|
|
||||||
|
// Update URL
|
||||||
|
updateFilterUrl('filter', 'unplanned')
|
||||||
}}
|
}}
|
||||||
sx={{
|
sx={{
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
@@ -1015,18 +1180,16 @@ const MyChores = () => {
|
|||||||
variant='soft'
|
variant='soft'
|
||||||
size='lg'
|
size='lg'
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
// Update URL for navigation context
|
// Update state directly for immediate smooth transition
|
||||||
Navigate('/chores?filter=pending-approval', {
|
|
||||||
replace: true,
|
|
||||||
})
|
|
||||||
|
|
||||||
// Also update state directly for immediate smooth transition
|
|
||||||
const pendingApprovalChores =
|
const pendingApprovalChores =
|
||||||
FILTERS['Pending Approval'](getFilteredChores)
|
FILTERS['Pending Approval'](getFilteredChores)
|
||||||
setFilteredChores(pendingApprovalChores)
|
setFilteredChores(pendingApprovalChores)
|
||||||
setSearchFilter('Pending Approval')
|
setSearchFilter('Pending Approval')
|
||||||
setViewMode('default')
|
setViewMode('default')
|
||||||
setSelectedCalendarDate(null)
|
setSelectedCalendarDate(null)
|
||||||
|
|
||||||
|
// Update URL
|
||||||
|
updateFilterUrl('filter', 'pending')
|
||||||
}}
|
}}
|
||||||
sx={{
|
sx={{
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
@@ -1121,6 +1284,7 @@ const MyChores = () => {
|
|||||||
)}
|
)}
|
||||||
{searchTerm.length === 0 &&
|
{searchTerm.length === 0 &&
|
||||||
searchFilter === 'All' &&
|
searchFilter === 'All' &&
|
||||||
|
!activeFilterId &&
|
||||||
viewMode !== 'calendar' && (
|
viewMode !== 'calendar' && (
|
||||||
<AccordionGroup transition='0.2s ease' disableDivider>
|
<AccordionGroup transition='0.2s ease' disableDivider>
|
||||||
{choreSections.map((section, index) => {
|
{choreSections.map((section, index) => {
|
||||||
@@ -1302,6 +1466,76 @@ const MyChores = () => {
|
|||||||
onNudge={handleNudge}
|
onNudge={handleNudge}
|
||||||
onClose={closeModal}
|
onClose={closeModal}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Save Filter Modal */}
|
||||||
|
<SaveFilterModal
|
||||||
|
isOpen={showSaveFilterModal}
|
||||||
|
onClose={() => setShowSaveFilterModal(false)}
|
||||||
|
onSave={filter => {
|
||||||
|
saveFilter(filter)
|
||||||
|
showSuccess({
|
||||||
|
title: 'Filter Saved',
|
||||||
|
message: `"${filter.name}" has been saved successfully`,
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
filterData={createFilterFromCurrentState({
|
||||||
|
selectedProject,
|
||||||
|
selectedChoreFilter,
|
||||||
|
searchFilter,
|
||||||
|
})}
|
||||||
|
previewChores={
|
||||||
|
activeFilterId ? customFilteredChores : searchFilteredChores
|
||||||
|
}
|
||||||
|
previewCount={
|
||||||
|
activeFilterId
|
||||||
|
? customFilteredChores.length
|
||||||
|
: searchFilteredChores.length
|
||||||
|
}
|
||||||
|
previewOverdueCount={
|
||||||
|
(activeFilterId ? customFilteredChores : searchFilteredChores).filter(
|
||||||
|
chore =>
|
||||||
|
chore.nextDueDate && new Date(chore.nextDueDate) < new Date(),
|
||||||
|
).length
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Advanced Filter Builder */}
|
||||||
|
<AdvancedFilterBuilder
|
||||||
|
isOpen={showAdvancedFilterBuilder}
|
||||||
|
onClose={() => {
|
||||||
|
setShowAdvancedFilterBuilder(false)
|
||||||
|
setEditingFilter(null)
|
||||||
|
}}
|
||||||
|
onSave={filter => {
|
||||||
|
if (filter.id) {
|
||||||
|
// Update existing filter
|
||||||
|
updateFilter(filter.id, {
|
||||||
|
name: filter.name,
|
||||||
|
conditions: filter.conditions,
|
||||||
|
operator: filter.operator,
|
||||||
|
})
|
||||||
|
showSuccess({
|
||||||
|
title: 'Filter Updated',
|
||||||
|
message: `"${filter.name}" has been updated successfully`,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
// Create new filter
|
||||||
|
saveFilter(filter)
|
||||||
|
showSuccess({
|
||||||
|
title: 'Advanced Filter Created',
|
||||||
|
message: `"${filter.name}" has been created successfully`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
setShowAdvancedFilterBuilder(false)
|
||||||
|
setEditingFilter(null)
|
||||||
|
}}
|
||||||
|
members={membersData?.res || []}
|
||||||
|
labels={userLabels || []}
|
||||||
|
projects={projectsWithDefault}
|
||||||
|
allChores={searchFilteredChores}
|
||||||
|
userProfile={userProfile}
|
||||||
|
editingFilter={editingFilter}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
182
src/views/Chores/components/CustomFilterChips.jsx
Normal file
182
src/views/Chores/components/CustomFilterChips.jsx
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
import { Delete, Edit, Star, StarBorder, Warning } from '@mui/icons-material'
|
||||||
|
import { Box, Chip, Menu, MenuItem, Tooltip, Typography } from '@mui/joy'
|
||||||
|
import { useState } from 'react'
|
||||||
|
|
||||||
|
const CustomFilterChips = ({
|
||||||
|
filters = [],
|
||||||
|
activeFilterId,
|
||||||
|
onFilterClick,
|
||||||
|
onFilterDelete,
|
||||||
|
onFilterPin,
|
||||||
|
onFilterEdit,
|
||||||
|
}) => {
|
||||||
|
const [menuAnchor, setMenuAnchor] = useState(null)
|
||||||
|
const [selectedFilter, setSelectedFilter] = useState(null)
|
||||||
|
|
||||||
|
if (filters.length === 0) return null
|
||||||
|
|
||||||
|
const handleContextMenu = (event, filter) => {
|
||||||
|
event.preventDefault()
|
||||||
|
event.stopPropagation()
|
||||||
|
setMenuAnchor(event.currentTarget)
|
||||||
|
setSelectedFilter(filter)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleMenuClose = () => {
|
||||||
|
setMenuAnchor(null)
|
||||||
|
setSelectedFilter(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDelete = () => {
|
||||||
|
if (selectedFilter) {
|
||||||
|
onFilterDelete(selectedFilter.id)
|
||||||
|
}
|
||||||
|
handleMenuClose()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlePin = () => {
|
||||||
|
if (selectedFilter) {
|
||||||
|
onFilterPin(selectedFilter.id)
|
||||||
|
}
|
||||||
|
handleMenuClose()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleEdit = () => {
|
||||||
|
if (selectedFilter && onFilterEdit) {
|
||||||
|
onFilterEdit(selectedFilter)
|
||||||
|
}
|
||||||
|
handleMenuClose()
|
||||||
|
}
|
||||||
|
|
||||||
|
const sortedFilters = [...filters].sort((a, b) => {
|
||||||
|
if (a.isPinned && !b.isPinned) return -1
|
||||||
|
if (!a.isPinned && b.isPinned) return 1
|
||||||
|
return (b.usageCount || 0) - (a.usageCount || 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
gap: 1,
|
||||||
|
overflowX: 'auto',
|
||||||
|
py: 1,
|
||||||
|
'&::-webkit-scrollbar': {
|
||||||
|
height: 6,
|
||||||
|
},
|
||||||
|
'&::-webkit-scrollbar-thumb': {
|
||||||
|
backgroundColor: 'neutral.400',
|
||||||
|
borderRadius: 3,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{sortedFilters.map(filter => {
|
||||||
|
const isActive = activeFilterId === filter.id
|
||||||
|
const hasWarning = !filter.isValid
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tooltip
|
||||||
|
key={filter.id}
|
||||||
|
title={
|
||||||
|
hasWarning
|
||||||
|
? `Filter has issues: ${filter.validationIssues?.join(', ')}`
|
||||||
|
: `${filter.count} tasks${filter.overdueCount > 0 ? ` (${filter.overdueCount} overdue)` : ''}`
|
||||||
|
}
|
||||||
|
placement='bottom'
|
||||||
|
>
|
||||||
|
<Chip
|
||||||
|
variant={isActive ? 'solid' : 'soft'}
|
||||||
|
color={hasWarning ? 'warning' : isActive ? 'primary' : 'neutral'}
|
||||||
|
size='lg'
|
||||||
|
onClick={() => !hasWarning && onFilterClick(filter.id)}
|
||||||
|
onContextMenu={e => handleContextMenu(e, filter)}
|
||||||
|
sx={{
|
||||||
|
cursor: hasWarning ? 'not-allowed' : 'pointer',
|
||||||
|
transition: 'all 0.2s ease',
|
||||||
|
px: 1.5,
|
||||||
|
py: 0.5,
|
||||||
|
opacity: hasWarning ? 0.7 : 1,
|
||||||
|
}}
|
||||||
|
startDecorator={
|
||||||
|
<Box sx={{ display: 'flex', gap: 0.5, alignItems: 'center' }}>
|
||||||
|
{filter.isPinned && (
|
||||||
|
<Star sx={{ fontSize: '0.9rem', color: 'warning.500' }} />
|
||||||
|
)}
|
||||||
|
<Chip
|
||||||
|
size='sm'
|
||||||
|
variant='solid'
|
||||||
|
color={
|
||||||
|
hasWarning ? 'warning' : isActive ? 'primary' : 'neutral'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{filter.count}
|
||||||
|
</Chip>
|
||||||
|
</Box>
|
||||||
|
}
|
||||||
|
endDecorator={
|
||||||
|
<Box sx={{ display: 'flex', gap: 0.5, alignItems: 'center' }}>
|
||||||
|
{hasWarning && <Warning sx={{ fontSize: '1rem' }} />}
|
||||||
|
{!hasWarning && filter.overdueCount > 0 && (
|
||||||
|
<Chip size='sm' variant='solid' color='danger'>
|
||||||
|
{filter.overdueCount}
|
||||||
|
</Chip>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Typography
|
||||||
|
level='body-sm'
|
||||||
|
fontWeight={isActive ? 'md' : 'normal'}
|
||||||
|
sx={{
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
maxWidth: 200,
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{filter.name}
|
||||||
|
</Typography>
|
||||||
|
</Chip>
|
||||||
|
</Tooltip>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
|
||||||
|
<Menu
|
||||||
|
anchorEl={menuAnchor}
|
||||||
|
open={Boolean(menuAnchor)}
|
||||||
|
onClose={handleMenuClose}
|
||||||
|
placement='bottom-start'
|
||||||
|
>
|
||||||
|
{selectedFilter && (
|
||||||
|
<>
|
||||||
|
<MenuItem onClick={handlePin}>
|
||||||
|
{selectedFilter.isPinned ? (
|
||||||
|
<>
|
||||||
|
<StarBorder sx={{ mr: 1 }} />
|
||||||
|
Unpin filter
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Star sx={{ mr: 1 }} />
|
||||||
|
Pin filter
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</MenuItem>
|
||||||
|
{onFilterEdit && (
|
||||||
|
<MenuItem onClick={handleEdit}>
|
||||||
|
<Edit sx={{ mr: 1 }} />
|
||||||
|
Edit filter
|
||||||
|
</MenuItem>
|
||||||
|
)}
|
||||||
|
<MenuItem onClick={handleDelete} color='danger'>
|
||||||
|
<Delete sx={{ mr: 1 }} />
|
||||||
|
Delete filter
|
||||||
|
</MenuItem>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Menu>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default CustomFilterChips
|
||||||
85
src/views/Chores/components/FilterSection.jsx
Normal file
85
src/views/Chores/components/FilterSection.jsx
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
import { Add, CancelRounded } from '@mui/icons-material'
|
||||||
|
import { Box, Button, Chip, IconButton } from '@mui/joy'
|
||||||
|
import CustomFilterChips from './CustomFilterChips'
|
||||||
|
|
||||||
|
const FilterSection = ({
|
||||||
|
savedFilters,
|
||||||
|
activeFilterId,
|
||||||
|
activeFilter,
|
||||||
|
hasProjectConditions,
|
||||||
|
onFilterClick,
|
||||||
|
onFilterDelete,
|
||||||
|
onFilterPin,
|
||||||
|
onFilterEdit,
|
||||||
|
onClearActiveFilter,
|
||||||
|
onCreateAdvancedFilter,
|
||||||
|
updateFilterUrl,
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Custom Filter Chips */}
|
||||||
|
{savedFilters.length > 0 && (
|
||||||
|
<Box sx={{ mt: 1 }}>
|
||||||
|
<CustomFilterChips
|
||||||
|
filters={savedFilters}
|
||||||
|
activeFilterId={activeFilterId}
|
||||||
|
onFilterClick={onFilterClick}
|
||||||
|
onFilterDelete={onFilterDelete}
|
||||||
|
onFilterPin={onFilterPin}
|
||||||
|
onFilterEdit={onFilterEdit}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Create Advanced Filter Button */}
|
||||||
|
{!activeFilterId && (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
mt: 1,
|
||||||
|
display: 'flex',
|
||||||
|
gap: 1,
|
||||||
|
justifyContent: 'flex-start',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
size='sm'
|
||||||
|
variant='outlined'
|
||||||
|
color='primary'
|
||||||
|
startDecorator={<Add />}
|
||||||
|
onClick={onCreateAdvancedFilter}
|
||||||
|
>
|
||||||
|
Create Advanced Filter
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Active Custom Filter Display */}
|
||||||
|
{activeFilter && (
|
||||||
|
<Box sx={{ mt: 1 }}>
|
||||||
|
<Chip
|
||||||
|
color='primary'
|
||||||
|
variant='soft'
|
||||||
|
size='lg'
|
||||||
|
endDecorator={
|
||||||
|
<IconButton size='sm' onClick={onClearActiveFilter}>
|
||||||
|
<CancelRounded />
|
||||||
|
</IconButton>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Filter: {activeFilter.name} ({activeFilter.count} tasks
|
||||||
|
{activeFilter.overdueCount > 0 &&
|
||||||
|
`, ${activeFilter.overdueCount} overdue`}
|
||||||
|
)
|
||||||
|
{hasProjectConditions && (
|
||||||
|
<Chip size='sm' sx={{ ml: 1 }} variant='solid' color='primary'>
|
||||||
|
Cross-Project
|
||||||
|
</Chip>
|
||||||
|
)}
|
||||||
|
</Chip>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default FilterSection
|
||||||
@@ -62,6 +62,45 @@ export const useChoreFilters = ({
|
|||||||
selectedChoreFilter,
|
selectedChoreFilter,
|
||||||
])
|
])
|
||||||
|
|
||||||
|
// Non-project-filtered chores for custom filters that may have their own project conditions
|
||||||
|
const nonProjectFilteredChores = useMemo(() => {
|
||||||
|
let baseChores = chores
|
||||||
|
|
||||||
|
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.toLowerCase()).map(result => result.item)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (impersonatedUser) {
|
||||||
|
baseChores = baseChores.filter(
|
||||||
|
chore => chore.assignedTo === impersonatedUser.userId,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return baseChores.filter(
|
||||||
|
ChoreFilters(impersonatedUser?.userId || userProfile?.id)[
|
||||||
|
selectedChoreFilter
|
||||||
|
],
|
||||||
|
)
|
||||||
|
}, [
|
||||||
|
searchTerm,
|
||||||
|
chores,
|
||||||
|
impersonatedUser,
|
||||||
|
userProfile?.id,
|
||||||
|
selectedChoreFilter,
|
||||||
|
])
|
||||||
|
|
||||||
const setSelectedChoreFilterWithCache = useCallback(value => {
|
const setSelectedChoreFilterWithCache = useCallback(value => {
|
||||||
setSelectedChoreFilter(value)
|
setSelectedChoreFilter(value)
|
||||||
localStorage.setItem('selectedChoreFilter', value)
|
localStorage.setItem('selectedChoreFilter', value)
|
||||||
@@ -78,6 +117,7 @@ export const useChoreFilters = ({
|
|||||||
selectedChoreFilter,
|
selectedChoreFilter,
|
||||||
projectFilteredChores,
|
projectFilteredChores,
|
||||||
searchFilteredChores,
|
searchFilteredChores,
|
||||||
|
nonProjectFilteredChores,
|
||||||
setSearchTerm,
|
setSearchTerm,
|
||||||
setSearchFilter,
|
setSearchFilter,
|
||||||
setSelectedChoreFilter,
|
setSelectedChoreFilter,
|
||||||
|
|||||||
240
src/views/Chores/hooks/useCustomFilters.js
Normal file
240
src/views/Chores/hooks/useCustomFilters.js
Normal file
@@ -0,0 +1,240 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||||
|
import { useUserProfile } from '../../../queries/UserQueries'
|
||||||
|
import {
|
||||||
|
applyFilter,
|
||||||
|
getFilterCount,
|
||||||
|
getFilterOverdueCount,
|
||||||
|
validateFilter,
|
||||||
|
} from '../../../utils/FilterEngine'
|
||||||
|
import {
|
||||||
|
deleteFilter as deleteFilterStorage,
|
||||||
|
getFilterById,
|
||||||
|
getSavedFilters,
|
||||||
|
saveFilter as saveFilterStorage,
|
||||||
|
toggleFilterPin,
|
||||||
|
trackFilterUsage,
|
||||||
|
updateFilter as updateFilterStorage,
|
||||||
|
} from '../../../utils/CustomFilterStorage'
|
||||||
|
|
||||||
|
export const useCustomFilters = (chores, membersData, labels, projects) => {
|
||||||
|
const { data: userProfile } = useUserProfile()
|
||||||
|
const [savedFilters, setSavedFilters] = useState([])
|
||||||
|
const [activeFilterId, setActiveFilterId] = useState(null)
|
||||||
|
|
||||||
|
const loadFilters = useCallback(() => {
|
||||||
|
const filters = getSavedFilters()
|
||||||
|
setSavedFilters(filters)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadFilters()
|
||||||
|
}, [loadFilters])
|
||||||
|
|
||||||
|
const context = useMemo(
|
||||||
|
() => ({
|
||||||
|
userId: userProfile?.id,
|
||||||
|
members: membersData || [],
|
||||||
|
labels: labels || [],
|
||||||
|
projects: projects || [],
|
||||||
|
}),
|
||||||
|
[userProfile?.id, membersData, labels, projects],
|
||||||
|
)
|
||||||
|
|
||||||
|
const filtersWithCounts = useMemo(() => {
|
||||||
|
if (!chores || !Array.isArray(chores)) return []
|
||||||
|
|
||||||
|
return savedFilters.map(filter => {
|
||||||
|
const validation = validateFilter(filter, context)
|
||||||
|
const count = validation.isValid
|
||||||
|
? getFilterCount(chores, filter, context)
|
||||||
|
: 0
|
||||||
|
const overdueCount = validation.isValid
|
||||||
|
? getFilterOverdueCount(chores, filter, context)
|
||||||
|
: 0
|
||||||
|
|
||||||
|
return {
|
||||||
|
...filter,
|
||||||
|
count,
|
||||||
|
overdueCount,
|
||||||
|
isValid: validation.isValid,
|
||||||
|
validationIssues: validation.issues,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}, [savedFilters, chores, context])
|
||||||
|
|
||||||
|
const activeFilter = useMemo(() => {
|
||||||
|
if (!activeFilterId) return null
|
||||||
|
return filtersWithCounts.find(f => f.id === activeFilterId)
|
||||||
|
}, [activeFilterId, filtersWithCounts])
|
||||||
|
|
||||||
|
// Check if active filter has project conditions
|
||||||
|
const hasProjectConditions = useMemo(() => {
|
||||||
|
if (!activeFilter || !activeFilter.conditions) return false
|
||||||
|
return activeFilter.conditions.some(c => c.type === 'project')
|
||||||
|
}, [activeFilter])
|
||||||
|
|
||||||
|
const filteredChores = useMemo(() => {
|
||||||
|
if (!activeFilter || !activeFilter.isValid) {
|
||||||
|
return chores
|
||||||
|
}
|
||||||
|
return applyFilter(chores, activeFilter, context)
|
||||||
|
}, [chores, activeFilter, context])
|
||||||
|
|
||||||
|
const applyCustomFilter = useCallback(
|
||||||
|
filterId => {
|
||||||
|
setActiveFilterId(filterId)
|
||||||
|
trackFilterUsage(filterId)
|
||||||
|
loadFilters()
|
||||||
|
},
|
||||||
|
[loadFilters],
|
||||||
|
)
|
||||||
|
|
||||||
|
const clearActiveFilter = useCallback(() => {
|
||||||
|
setActiveFilterId(null)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const saveFilter = useCallback(
|
||||||
|
filter => {
|
||||||
|
const savedFilter = saveFilterStorage(filter)
|
||||||
|
loadFilters()
|
||||||
|
return savedFilter
|
||||||
|
},
|
||||||
|
[loadFilters],
|
||||||
|
)
|
||||||
|
|
||||||
|
const updateFilter = useCallback(
|
||||||
|
(filterId, updates) => {
|
||||||
|
const updated = updateFilterStorage(filterId, updates)
|
||||||
|
loadFilters()
|
||||||
|
return updated
|
||||||
|
},
|
||||||
|
[loadFilters],
|
||||||
|
)
|
||||||
|
|
||||||
|
const deleteFilter = useCallback(
|
||||||
|
filterId => {
|
||||||
|
if (activeFilterId === filterId) {
|
||||||
|
setActiveFilterId(null)
|
||||||
|
}
|
||||||
|
deleteFilterStorage(filterId)
|
||||||
|
loadFilters()
|
||||||
|
},
|
||||||
|
[activeFilterId, loadFilters],
|
||||||
|
)
|
||||||
|
|
||||||
|
const pinFilter = useCallback(
|
||||||
|
filterId => {
|
||||||
|
toggleFilterPin(filterId)
|
||||||
|
loadFilters()
|
||||||
|
},
|
||||||
|
[loadFilters],
|
||||||
|
)
|
||||||
|
|
||||||
|
const createFilterFromCurrentState = useCallback(
|
||||||
|
currentState => {
|
||||||
|
const conditions = []
|
||||||
|
|
||||||
|
if (currentState.selectedProject) {
|
||||||
|
conditions.push({
|
||||||
|
type: 'project',
|
||||||
|
operator: 'is',
|
||||||
|
value: currentState.selectedProject.id,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
currentState.selectedChoreFilter &&
|
||||||
|
currentState.selectedChoreFilter !== 'anyone'
|
||||||
|
) {
|
||||||
|
const filterMap = {
|
||||||
|
assigned_to_me: { type: 'assignee', operator: 'is', value: 'me' },
|
||||||
|
assigned_to_others: {
|
||||||
|
type: 'assignee',
|
||||||
|
operator: 'is',
|
||||||
|
value: 'others',
|
||||||
|
},
|
||||||
|
created_by_me: { type: 'createdBy', operator: 'is', value: 'me' },
|
||||||
|
}
|
||||||
|
|
||||||
|
const condition = filterMap[currentState.selectedChoreFilter]
|
||||||
|
if (condition) {
|
||||||
|
conditions.push(condition)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentState.searchFilter && currentState.searchFilter !== 'All') {
|
||||||
|
if (currentState.searchFilter.startsWith('Priority: ')) {
|
||||||
|
const priority = parseInt(
|
||||||
|
currentState.searchFilter.replace('Priority: ', ''),
|
||||||
|
)
|
||||||
|
conditions.push({
|
||||||
|
type: 'priority',
|
||||||
|
operator: 'is',
|
||||||
|
value: priority,
|
||||||
|
})
|
||||||
|
} else if (currentState.searchFilter.startsWith('Label: ')) {
|
||||||
|
const labelName = currentState.searchFilter.replace('Label: ', '')
|
||||||
|
const label = labels?.find(l => l.name === labelName)
|
||||||
|
if (label) {
|
||||||
|
conditions.push({
|
||||||
|
type: 'label',
|
||||||
|
operator: 'has',
|
||||||
|
value: label.id,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} else if (currentState.searchFilter === 'Overdue') {
|
||||||
|
conditions.push({
|
||||||
|
type: 'dueDate',
|
||||||
|
operator: 'isOverdue',
|
||||||
|
value: null,
|
||||||
|
})
|
||||||
|
} else if (currentState.searchFilter === 'Due today') {
|
||||||
|
conditions.push({
|
||||||
|
type: 'dueDate',
|
||||||
|
operator: 'isDueToday',
|
||||||
|
value: null,
|
||||||
|
})
|
||||||
|
} else if (currentState.searchFilter === 'Due in week') {
|
||||||
|
conditions.push({
|
||||||
|
type: 'dueDate',
|
||||||
|
operator: 'isDueThisWeek',
|
||||||
|
value: null,
|
||||||
|
})
|
||||||
|
} else if (currentState.searchFilter === 'No Due Date') {
|
||||||
|
conditions.push({
|
||||||
|
type: 'dueDate',
|
||||||
|
operator: 'hasNoDueDate',
|
||||||
|
value: null,
|
||||||
|
})
|
||||||
|
} else if (currentState.searchFilter === 'Pending Approval') {
|
||||||
|
conditions.push({
|
||||||
|
type: 'status',
|
||||||
|
operator: 'is',
|
||||||
|
value: 3,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
conditions,
|
||||||
|
operator: 'AND',
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[labels],
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
savedFilters: filtersWithCounts,
|
||||||
|
activeFilter,
|
||||||
|
activeFilterId,
|
||||||
|
filteredChores,
|
||||||
|
applyCustomFilter,
|
||||||
|
clearActiveFilter,
|
||||||
|
saveFilter,
|
||||||
|
updateFilter,
|
||||||
|
deleteFilter,
|
||||||
|
pinFilter,
|
||||||
|
createFilterFromCurrentState,
|
||||||
|
hasProjectConditions,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useMemo, useCallback } from 'react'
|
import { useCallback, useMemo, useState } from 'react'
|
||||||
|
|
||||||
export const useProjectFilter = projects => {
|
export const useProjectFilter = projects => {
|
||||||
const [selectedProject, setSelectedProject] = useState(() => {
|
const [selectedProject, setSelectedProject] = useState(() => {
|
||||||
|
|||||||
594
src/views/Modals/Inputs/AdvancedFilterBuilder.jsx
Normal file
594
src/views/Modals/Inputs/AdvancedFilterBuilder.jsx
Normal file
@@ -0,0 +1,594 @@
|
|||||||
|
import { Add, Delete, Save } from '@mui/icons-material'
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Chip,
|
||||||
|
IconButton,
|
||||||
|
Input,
|
||||||
|
List,
|
||||||
|
ListItem,
|
||||||
|
Option,
|
||||||
|
Select,
|
||||||
|
Typography,
|
||||||
|
} from '@mui/joy'
|
||||||
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
|
import { useResponsiveModal } from '../../../hooks/useResponsiveModal'
|
||||||
|
import { filterNameExists } from '../../../utils/CustomFilterStorage'
|
||||||
|
import { applyFilter } from '../../../utils/FilterEngine'
|
||||||
|
import Priorities from '../../../utils/Priorities'
|
||||||
|
|
||||||
|
const AdvancedFilterBuilder = ({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
onSave,
|
||||||
|
members = [],
|
||||||
|
labels = [],
|
||||||
|
projects = [],
|
||||||
|
allChores = [],
|
||||||
|
userProfile = null,
|
||||||
|
editingFilter = null,
|
||||||
|
}) => {
|
||||||
|
const { ResponsiveModal } = useResponsiveModal()
|
||||||
|
const [filterName, setFilterName] = useState('')
|
||||||
|
const [conditions, setConditions] = useState([
|
||||||
|
{ type: 'assignee', operator: 'is', value: [] },
|
||||||
|
])
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
|
// Initialize state when editing a filter
|
||||||
|
useEffect(() => {
|
||||||
|
if (editingFilter) {
|
||||||
|
setFilterName(editingFilter.name)
|
||||||
|
setConditions(editingFilter.conditions || [])
|
||||||
|
setError('')
|
||||||
|
} else {
|
||||||
|
setFilterName('')
|
||||||
|
setConditions([{ type: 'assignee', operator: 'is', value: [] }])
|
||||||
|
setError('')
|
||||||
|
}
|
||||||
|
}, [editingFilter, isOpen])
|
||||||
|
|
||||||
|
const previewChores = useMemo(() => {
|
||||||
|
const validConditions = conditions.filter(c => {
|
||||||
|
if (c.type === 'dueDate') return true
|
||||||
|
return c.value && (Array.isArray(c.value) ? c.value.length > 0 : true)
|
||||||
|
})
|
||||||
|
|
||||||
|
if (validConditions.length === 0) return []
|
||||||
|
|
||||||
|
const result = applyFilter(
|
||||||
|
allChores,
|
||||||
|
{ conditions: validConditions, 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(),
|
||||||
|
).length
|
||||||
|
|
||||||
|
const addCondition = () => {
|
||||||
|
setConditions([
|
||||||
|
...conditions,
|
||||||
|
{ type: 'assignee', operator: 'is', value: [] },
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
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 = [3]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setConditions(updated)
|
||||||
|
}
|
||||||
|
|
||||||
|
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') 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')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const filterData = {
|
||||||
|
name: filterName.trim(),
|
||||||
|
conditions: validConditions,
|
||||||
|
operator: 'AND',
|
||||||
|
}
|
||||||
|
|
||||||
|
// Include ID if editing
|
||||||
|
if (editingFilter) {
|
||||||
|
filterData.id = editingFilter.id
|
||||||
|
}
|
||||||
|
|
||||||
|
onSave(filterData)
|
||||||
|
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%' }}
|
||||||
|
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%' }}
|
||||||
|
renderValue={selected => (
|
||||||
|
<Box sx={{ display: 'flex', gap: 0.5, flexWrap: 'wrap' }}>
|
||||||
|
{selected.map((selectedElement, idx) => {
|
||||||
|
const value = selectedElement.value
|
||||||
|
|
||||||
|
if (value === 'me')
|
||||||
|
return (
|
||||||
|
<Chip key={`${value}-${idx}`} size='sm'>
|
||||||
|
Me
|
||||||
|
</Chip>
|
||||||
|
)
|
||||||
|
const member = members.find(
|
||||||
|
m => String(m.userId) === String(value),
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
<Chip key={`${value}-${idx}`} size='sm'>
|
||||||
|
{member?.displayName || member?.username || 'Unknown'}
|
||||||
|
</Chip>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Option value='me'>Me</Option>
|
||||||
|
{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%' }}
|
||||||
|
renderValue={selected => (
|
||||||
|
<Box sx={{ display: 'flex', gap: 0.5, flexWrap: 'wrap' }}>
|
||||||
|
{selected.map((value, idx) => (
|
||||||
|
<Chip key={`priority-${value}-${idx}`} size='sm'>
|
||||||
|
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%' }}
|
||||||
|
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%' }}
|
||||||
|
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
|
||||||
|
value={condition.value?.[0] || 3}
|
||||||
|
onChange={(_, newValue) =>
|
||||||
|
updateCondition(index, 'value', [newValue])
|
||||||
|
}
|
||||||
|
sx={{ width: '100%' }}
|
||||||
|
>
|
||||||
|
<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%' }}
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
)
|
||||||
|
|
||||||
|
default:
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ResponsiveModal open={isOpen} onClose={onClose} size='md'>
|
||||||
|
<Typography level='h4' sx={{ mb: 2 }}>
|
||||||
|
{editingFilter ? 'Edit Filter' : 'Create Advanced Filter'}
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: 2,
|
||||||
|
height: '100%',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box>
|
||||||
|
<Typography level='body-sm' sx={{ mb: 1 }}>
|
||||||
|
Filter Name
|
||||||
|
</Typography>
|
||||||
|
<Input
|
||||||
|
placeholder='e.g., High Priority Tasks for Team'
|
||||||
|
value={filterName}
|
||||||
|
onChange={e => {
|
||||||
|
setFilterName(e.target.value)
|
||||||
|
setError('')
|
||||||
|
}}
|
||||||
|
error={!!error}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
{error && (
|
||||||
|
<Typography level='body-sm' color='danger' sx={{ mt: 0.5 }}>
|
||||||
|
{error}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</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
|
||||||
|
sx={{
|
||||||
|
gap: 1,
|
||||||
|
overflowY: 'auto',
|
||||||
|
maxHeight: { xs: '40vh', sm: '50vh' },
|
||||||
|
pr: 0.5,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{conditions.map((condition, index) => (
|
||||||
|
<ListItem
|
||||||
|
key={index}
|
||||||
|
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%' }}
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
</Select>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box sx={{ width: '100%' }}>
|
||||||
|
<Typography level='body-xs' sx={{ mb: 0.5 }}>
|
||||||
|
{condition.type === 'dueDate' ? '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',
|
||||||
|
bgcolor: 'background.level1',
|
||||||
|
p: 1,
|
||||||
|
borderRadius: 'sm',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{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 && (
|
||||||
|
<Typography
|
||||||
|
level='body-xs'
|
||||||
|
color='neutral'
|
||||||
|
sx={{ textAlign: 'center', mt: 0.5 }}
|
||||||
|
>
|
||||||
|
...and {previewCount - 3} more
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<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}
|
||||||
|
startDecorator={<Save />}
|
||||||
|
>
|
||||||
|
{editingFilter ? 'Update Filter' : 'Save Filter'}
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</ResponsiveModal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AdvancedFilterBuilder
|
||||||
263
src/views/Modals/Inputs/SaveFilterModal.jsx
Normal file
263
src/views/Modals/Inputs/SaveFilterModal.jsx
Normal file
@@ -0,0 +1,263 @@
|
|||||||
|
import { Save, Star, StarBorder } from '@mui/icons-material'
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Chip,
|
||||||
|
Input,
|
||||||
|
Modal,
|
||||||
|
ModalClose,
|
||||||
|
ModalDialog,
|
||||||
|
Typography,
|
||||||
|
} from '@mui/joy'
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { filterNameExists } from '../../../utils/CustomFilterStorage'
|
||||||
|
import CompactChoreCard from '../../Chores/CompactChoreCard'
|
||||||
|
|
||||||
|
const SaveFilterModal = ({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
onSave,
|
||||||
|
filterData,
|
||||||
|
previewChores = [],
|
||||||
|
previewCount = 0,
|
||||||
|
previewOverdueCount = 0,
|
||||||
|
}) => {
|
||||||
|
const [filterName, setFilterName] = useState('')
|
||||||
|
const [isPinned, setIsPinned] = useState(false)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
|
const handleSave = () => {
|
||||||
|
if (!filterName.trim()) {
|
||||||
|
setError('Please enter a filter name')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filterNameExists(filterName.trim())) {
|
||||||
|
setError('A filter with this name already exists')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const newFilter = {
|
||||||
|
...filterData,
|
||||||
|
name: filterName.trim(),
|
||||||
|
isPinned,
|
||||||
|
}
|
||||||
|
|
||||||
|
onSave(newFilter)
|
||||||
|
onClose()
|
||||||
|
}
|
||||||
|
|
||||||
|
const getConditionLabel = condition => {
|
||||||
|
switch (condition.type) {
|
||||||
|
case 'assignee':
|
||||||
|
if (condition.value === 'me') return 'Assigned to me'
|
||||||
|
if (condition.value === 'others') return 'Assigned to others'
|
||||||
|
return 'Specific assignee'
|
||||||
|
|
||||||
|
case 'createdBy':
|
||||||
|
if (condition.value === 'me') return 'Created by me'
|
||||||
|
return 'Created by specific user'
|
||||||
|
|
||||||
|
case 'priority':
|
||||||
|
return `Priority ${condition.value}`
|
||||||
|
|
||||||
|
case 'status':
|
||||||
|
return condition.value === 3 ? 'Pending approval' : `Status ${condition.value}`
|
||||||
|
|
||||||
|
case 'dueDate':
|
||||||
|
if (condition.operator === 'isOverdue') return 'Overdue'
|
||||||
|
if (condition.operator === 'isDueToday') return 'Due today'
|
||||||
|
if (condition.operator === 'isDueThisWeek') return 'Due this week'
|
||||||
|
if (condition.operator === 'hasNoDueDate') return 'No due date'
|
||||||
|
return 'Due date condition'
|
||||||
|
|
||||||
|
case 'label':
|
||||||
|
return 'Has label'
|
||||||
|
|
||||||
|
case 'project':
|
||||||
|
if (condition.value === 'default') return 'Default project'
|
||||||
|
return 'Specific project'
|
||||||
|
|
||||||
|
default:
|
||||||
|
return condition.type
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal open={isOpen} onClose={onClose}>
|
||||||
|
<ModalDialog
|
||||||
|
sx={{
|
||||||
|
maxWidth: 500,
|
||||||
|
width: '90%',
|
||||||
|
maxHeight: '90vh',
|
||||||
|
overflow: 'auto',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ModalClose />
|
||||||
|
<Typography level='h4' sx={{ mb: 2 }}>
|
||||||
|
Save Filter
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||||
|
<Box>
|
||||||
|
<Typography level='body-sm' sx={{ mb: 1 }}>
|
||||||
|
Filter Name
|
||||||
|
</Typography>
|
||||||
|
<Input
|
||||||
|
placeholder='e.g., My High Priority Tasks'
|
||||||
|
value={filterName}
|
||||||
|
onChange={e => {
|
||||||
|
setFilterName(e.target.value)
|
||||||
|
setError('')
|
||||||
|
}}
|
||||||
|
error={!!error}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
{error && (
|
||||||
|
<Typography level='body-sm' color='danger' sx={{ mt: 0.5 }}>
|
||||||
|
{error}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box>
|
||||||
|
<Typography level='body-sm' sx={{ mb: 1 }}>
|
||||||
|
Filter Conditions
|
||||||
|
</Typography>
|
||||||
|
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap' }}>
|
||||||
|
{filterData.conditions.length === 0 ? (
|
||||||
|
<Typography level='body-sm' color='neutral'>
|
||||||
|
No filters applied
|
||||||
|
</Typography>
|
||||||
|
) : (
|
||||||
|
filterData.conditions.map((condition, index) => (
|
||||||
|
<Chip
|
||||||
|
key={index}
|
||||||
|
variant='soft'
|
||||||
|
color='neutral'
|
||||||
|
size='sm'
|
||||||
|
>
|
||||||
|
{getConditionLabel(condition)}
|
||||||
|
</Chip>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</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: 200,
|
||||||
|
overflowY: 'auto',
|
||||||
|
bgcolor: 'background.level1',
|
||||||
|
p: 1,
|
||||||
|
borderRadius: 'sm',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{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 && (
|
||||||
|
<Typography
|
||||||
|
level='body-xs'
|
||||||
|
color='neutral'
|
||||||
|
sx={{ textAlign: 'center', mt: 0.5 }}
|
||||||
|
>
|
||||||
|
...and {previewCount - 3} more
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 1,
|
||||||
|
cursor: 'pointer',
|
||||||
|
p: 1,
|
||||||
|
borderRadius: 'sm',
|
||||||
|
'&:hover': {
|
||||||
|
bgcolor: 'background.level1',
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
onClick={() => setIsPinned(!isPinned)}
|
||||||
|
>
|
||||||
|
{isPinned ? (
|
||||||
|
<Star color='warning' />
|
||||||
|
) : (
|
||||||
|
<StarBorder color='neutral' />
|
||||||
|
)}
|
||||||
|
<Box>
|
||||||
|
<Typography level='body-sm' fontWeight='md'>
|
||||||
|
Pin this filter
|
||||||
|
</Typography>
|
||||||
|
<Typography level='body-xs' color='neutral'>
|
||||||
|
Pinned filters appear first in the list
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<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}
|
||||||
|
startDecorator={<Save />}
|
||||||
|
disabled={!filterName.trim() || filterData.conditions.length === 0}
|
||||||
|
>
|
||||||
|
Save Filter
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</ModalDialog>
|
||||||
|
</Modal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SaveFilterModal
|
||||||
Reference in New Issue
Block a user