1168 lines
35 KiB
JavaScript
1168 lines
35 KiB
JavaScript
import {
|
|
Archive,
|
|
CheckBox,
|
|
CheckBoxOutlineBlank,
|
|
Close,
|
|
Delete,
|
|
FilterList,
|
|
Label,
|
|
Person,
|
|
PriorityHigh,
|
|
SearchOff,
|
|
SelectAll,
|
|
Unarchive,
|
|
ViewAgenda,
|
|
ViewModule,
|
|
} from '@mui/icons-material'
|
|
import {
|
|
Badge,
|
|
Box,
|
|
Button,
|
|
Container,
|
|
Divider,
|
|
IconButton,
|
|
Input,
|
|
List,
|
|
Stack,
|
|
Typography,
|
|
} from '@mui/joy'
|
|
import { useQueryClient } from '@tanstack/react-query'
|
|
import Fuse from 'fuse.js'
|
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
|
import { useTranslation } from 'react-i18next'
|
|
import { useNavigate } from 'react-router-dom'
|
|
|
|
import EmptyState from '../../components/common/EmptyState'
|
|
import FilterBar from '../../components/common/FilterBar'
|
|
import KeyboardShortcutHint from '../../components/common/KeyboardShortcutHint'
|
|
import SortAndFilterMenu from '../../components/common/SortAndFilterMenu'
|
|
import { useImpersonateUser } from '../../contexts/ImpersonateUserContext.jsx'
|
|
import { useFilter } from '../../hooks/useFilter'
|
|
import { useUnArchiveChore } from '../../queries/ChoreQueries'
|
|
import { useCircleMembers, useUserProfile } from '../../queries/UserQueries'
|
|
import { useNotification } from '../../service/NotificationProvider'
|
|
import { commandQueue, CommandType } from '../../utils/CommandQueue'
|
|
import { DeleteChore, GetArchivedChores } from '../../utils/Fetcher'
|
|
import { offlineDB } from '../../utils/OfflineDB'
|
|
import { isOfflineFeatureEnabled } from '../../utils/OfflineFeatureToggle'
|
|
import Priorities from '../../utils/Priorities'
|
|
import LoadingComponent from '../components/Loading'
|
|
import ConfirmationModal from '../Modals/Inputs/ConfirmationModal'
|
|
import ChoreCard from './ChoreCard'
|
|
import ChoreListView from './ChoreListView.jsx'
|
|
import CompactChoreCard from './CompactChoreCard'
|
|
import MultiSelectHelp from './MultiSelectHelp'
|
|
|
|
const sortByUpdatedAtDesc = chores =>
|
|
(chores || []).sort((a, b) => {
|
|
const dateA = new Date(a.updatedAt || 0)
|
|
const dateB = new Date(b.updatedAt || 0)
|
|
return dateB - dateA
|
|
})
|
|
|
|
const applyPendingArchivedState = async chores => {
|
|
const pending = await commandQueue.getPending()
|
|
|
|
const pendingArchiveIds = new Set(
|
|
pending
|
|
.filter(cmd => cmd.commandType === CommandType.ARCHIVE_CHORE)
|
|
.map(cmd => String(cmd.entityId)),
|
|
)
|
|
const pendingUnarchiveIds = new Set(
|
|
pending
|
|
.filter(cmd => cmd.commandType === CommandType.UNARCHIVE_CHORE)
|
|
.map(cmd => String(cmd.entityId)),
|
|
)
|
|
const pendingDeleteIds = new Set(
|
|
pending
|
|
.filter(cmd => cmd.commandType === CommandType.DELETE_CHORE)
|
|
.map(cmd => String(cmd.entityId)),
|
|
)
|
|
|
|
return (chores || [])
|
|
.filter(chore => !pendingDeleteIds.has(String(chore.id)))
|
|
.filter(chore => {
|
|
const id = String(chore.id)
|
|
if (pendingUnarchiveIds.has(id)) return false
|
|
return chore.isActive === false || pendingArchiveIds.has(id)
|
|
})
|
|
.map(chore => {
|
|
const id = String(chore.id)
|
|
if (pendingArchiveIds.has(id)) {
|
|
return { ...chore, isActive: false, _pending: 'archive' }
|
|
}
|
|
return chore
|
|
})
|
|
}
|
|
|
|
const ArchivedTasks = () => {
|
|
const { t } = useTranslation('chores')
|
|
const { data: userProfile, isLoading: isUserProfileLoading } =
|
|
useUserProfile()
|
|
const { showError, showSuccess } = useNotification()
|
|
const { impersonatedUser } = useImpersonateUser()
|
|
const queryClient = useQueryClient()
|
|
const unArchiveChore = useUnArchiveChore()
|
|
const [archivedChores, setArchivedChores] = useState([])
|
|
const [filteredChores, setFilteredChores] = useState([])
|
|
const [searchTerm, setSearchTerm] = useState('')
|
|
const [performers, setPerformers] = useState([])
|
|
const navigate = useNavigate()
|
|
const [viewMode, setViewMode] = useState(
|
|
localStorage.getItem('archivedChoreCardViewMode') || 'default',
|
|
)
|
|
const [isLoading, setIsLoading] = useState(true)
|
|
const [showKeyboardShortcuts, setShowKeyboardShortcuts] = useState(false)
|
|
const searchInputRef = useRef(null)
|
|
|
|
// Multi-select state
|
|
const [isMultiSelectMode, setIsMultiSelectMode] = useState(false)
|
|
const [selectedChores, setSelectedChores] = useState(new Set())
|
|
const [confirmModelConfig, setConfirmModelConfig] = useState({})
|
|
|
|
const { data: membersData, isLoading: membersLoading } = useCircleMembers()
|
|
|
|
// Unique labels present across all archived chores
|
|
const availableLabels = useMemo(() => {
|
|
const seen = {}
|
|
archivedChores.forEach(c => {
|
|
c.labelsV2?.forEach(l => {
|
|
seen[l.id] = l
|
|
})
|
|
})
|
|
return Object.values(seen)
|
|
}, [archivedChores])
|
|
|
|
const filterDefs = useMemo(
|
|
() => [
|
|
{
|
|
id: 'assignee',
|
|
label: t('toolbar.type.assignee'),
|
|
type: 'multi-select',
|
|
icon: <Person />,
|
|
options: performers.map(p => ({
|
|
value: p.userId,
|
|
label: p.displayName,
|
|
avatar: p.image,
|
|
})),
|
|
filterFn: (item, values) => values.includes(item.assignedTo),
|
|
},
|
|
{
|
|
id: 'priority',
|
|
label: t('priority'),
|
|
type: 'multi-select',
|
|
icon: <PriorityHigh />,
|
|
options: Priorities.map(p => ({
|
|
value: p.value,
|
|
label: p.name,
|
|
color: p.color || 'neutral',
|
|
icon: p.icon,
|
|
})),
|
|
filterFn: (item, values) => values.includes(item.priority ?? 0),
|
|
},
|
|
...(availableLabels.length > 0
|
|
? [
|
|
{
|
|
id: 'label',
|
|
label: t('labels.label'),
|
|
type: 'multi-select',
|
|
icon: <Label />,
|
|
options: availableLabels.map(l => ({
|
|
value: l.id,
|
|
label: l.name,
|
|
icon: (
|
|
<Box
|
|
component='span'
|
|
sx={{
|
|
display: 'inline-block',
|
|
width: 10,
|
|
height: 10,
|
|
borderRadius: '50%',
|
|
bgcolor: l.color || '#90a4ae',
|
|
flexShrink: 0,
|
|
}}
|
|
/>
|
|
),
|
|
})),
|
|
filterFn: (item, values) =>
|
|
item.labelsV2?.some(l => values.includes(l.id)) ?? false,
|
|
},
|
|
]
|
|
: []),
|
|
{
|
|
id: 'archivedAt',
|
|
label: t('archived.archivedDate'),
|
|
type: 'date-range',
|
|
icon: <Archive />,
|
|
filterFn: (item, value) => {
|
|
const date = new Date(item.updatedAt)
|
|
if (value.from && date < new Date(value.from)) return false
|
|
if (value.to && date > new Date(value.to)) return false
|
|
return true
|
|
},
|
|
},
|
|
],
|
|
[performers, availableLabels, t],
|
|
)
|
|
|
|
const {
|
|
activeFilterCount,
|
|
activeFilters,
|
|
clearAll,
|
|
filteredData: filteredByBar,
|
|
hasActiveFilters,
|
|
setFilter,
|
|
} = useFilter(filteredChores, filterDefs)
|
|
|
|
const [isFilterSheetOpen, setIsFilterSheetOpen] = useState(false)
|
|
|
|
const [sortBy, setSortBy] = useState(
|
|
() => localStorage.getItem('archivedChoresSortBy') || 'archivedAt',
|
|
)
|
|
const [sortDirection, setSortDirection] = useState(
|
|
() => localStorage.getItem('archivedChoresSortDirection') || 'desc',
|
|
)
|
|
|
|
useEffect(() => {
|
|
localStorage.setItem('archivedChoresSortBy', sortBy)
|
|
localStorage.setItem('archivedChoresSortDirection', sortDirection)
|
|
}, [sortBy, sortDirection])
|
|
|
|
const finalChores = useMemo(() => {
|
|
const direction = sortDirection === 'desc' ? -1 : 1
|
|
return [...filteredByBar].sort((a, b) => {
|
|
switch (sortBy) {
|
|
case 'name':
|
|
return direction * (a.name || '').localeCompare(b.name || '')
|
|
case 'priority':
|
|
return direction * ((a.priority ?? 0) - (b.priority ?? 0))
|
|
case 'dueDate': {
|
|
const aDue = new Date(a.nextDueDate || 0).getTime()
|
|
const bDue = new Date(b.nextDueDate || 0).getTime()
|
|
return direction * (aDue - bDue)
|
|
}
|
|
case 'archivedAt':
|
|
default: {
|
|
const aDate = new Date(a.updatedAt || 0).getTime()
|
|
const bDate = new Date(b.updatedAt || 0).getTime()
|
|
return direction * (aDate - bDate)
|
|
}
|
|
}
|
|
})
|
|
}, [filteredByBar, sortBy, sortDirection])
|
|
|
|
useEffect(() => {
|
|
const loadArchivedChores = async () => {
|
|
if (!membersLoading && userProfile) {
|
|
setPerformers(membersData.res)
|
|
try {
|
|
const response = await GetArchivedChores()
|
|
const data = await response.json()
|
|
if (data?.res?.length) {
|
|
await offlineDB.saveChores(data.res)
|
|
}
|
|
const archivedWithPending = await applyPendingArchivedState(
|
|
data?.res || [],
|
|
)
|
|
const sortedChores = sortByUpdatedAtDesc(archivedWithPending)
|
|
setArchivedChores(sortedChores)
|
|
setFilteredChores(sortedChores)
|
|
} catch (error) {
|
|
try {
|
|
const cached = await offlineDB.getChores(true)
|
|
const archivedWithPending = await applyPendingArchivedState(cached)
|
|
const sortedChores = sortByUpdatedAtDesc(archivedWithPending)
|
|
setArchivedChores(sortedChores)
|
|
setFilteredChores(sortedChores)
|
|
} catch {
|
|
showError({
|
|
title: t('archived.loadFailTitle'),
|
|
message: t('archived.loadFailMsg'),
|
|
})
|
|
}
|
|
} finally {
|
|
setIsLoading(false)
|
|
}
|
|
}
|
|
}
|
|
loadArchivedChores()
|
|
}, [membersLoading, userProfile, membersData])
|
|
|
|
// Keyboard shortcuts
|
|
useEffect(() => {
|
|
const handleKeyDown = event => {
|
|
const isHoldingCmdOrCtrl = event.ctrlKey || event.metaKey
|
|
|
|
if (isHoldingCmdOrCtrl) {
|
|
setShowKeyboardShortcuts(true)
|
|
}
|
|
|
|
// Ctrl/Cmd + S Toggle Multi-select mode
|
|
if (isHoldingCmdOrCtrl && event.key === 's') {
|
|
event.preventDefault()
|
|
toggleMultiSelectMode()
|
|
return
|
|
}
|
|
|
|
// Ctrl/Cmd + A to select all
|
|
if (
|
|
isHoldingCmdOrCtrl &&
|
|
!event.shiftKey &&
|
|
event.key === 'a' &&
|
|
!['INPUT', 'TEXTAREA'].includes(document.activeElement.tagName)
|
|
) {
|
|
event.preventDefault()
|
|
if (!isMultiSelectMode) {
|
|
setIsMultiSelectMode(true)
|
|
setTimeout(() => {
|
|
selectAllVisibleChores()
|
|
}, 0)
|
|
} else {
|
|
selectAllVisibleChores()
|
|
}
|
|
}
|
|
|
|
// Multi-select keyboard shortcuts
|
|
if (isMultiSelectMode) {
|
|
// Escape to clear selection or exit multi-select mode
|
|
if (event.key === 'Escape') {
|
|
event.preventDefault()
|
|
if (selectedChores.size > 0) {
|
|
clearSelection()
|
|
} else {
|
|
setIsMultiSelectMode(false)
|
|
}
|
|
return
|
|
}
|
|
|
|
// "r" key for bulk restore (unarchive)
|
|
if (
|
|
isHoldingCmdOrCtrl &&
|
|
event.key === 'r' &&
|
|
selectedChores.size > 0
|
|
) {
|
|
event.preventDefault()
|
|
handleBulkRestore()
|
|
return
|
|
}
|
|
|
|
// "e" key for bulk delete
|
|
if (
|
|
isHoldingCmdOrCtrl &&
|
|
event.key === 'e' &&
|
|
selectedChores.size > 0
|
|
) {
|
|
event.preventDefault()
|
|
handleBulkDelete()
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
const handleKeyUp = event => {
|
|
if (!event.ctrlKey && !event.metaKey) {
|
|
setShowKeyboardShortcuts(false)
|
|
}
|
|
}
|
|
|
|
document.addEventListener('keydown', handleKeyDown)
|
|
document.addEventListener('keyup', handleKeyUp)
|
|
return () => {
|
|
document.removeEventListener('keydown', handleKeyDown)
|
|
document.removeEventListener('keyup', handleKeyUp)
|
|
}
|
|
}, [isMultiSelectMode, selectedChores.size])
|
|
|
|
const toggleViewMode = () => {
|
|
const modes = ['default', 'compact']
|
|
const currentIndex = modes.indexOf(viewMode)
|
|
const nextIndex = (currentIndex + 1) % modes.length
|
|
const newMode = modes[nextIndex]
|
|
setViewMode(newMode)
|
|
localStorage.setItem('archivedChoreCardViewMode', newMode)
|
|
}
|
|
|
|
const searchOptions = {
|
|
keys: ['name', 'raw_label'],
|
|
includeScore: true,
|
|
isCaseSensitive: false,
|
|
findAllMatches: true,
|
|
}
|
|
|
|
const fuse = new Fuse(
|
|
archivedChores.map(c => ({
|
|
...c,
|
|
raw_label: c.labelsV2?.map(c => c.name).join(' '),
|
|
})),
|
|
searchOptions,
|
|
)
|
|
|
|
const handleSearchChange = e => {
|
|
const search = e.target.value
|
|
if (search === '') {
|
|
setFilteredChores(archivedChores)
|
|
setSearchTerm('')
|
|
return
|
|
}
|
|
|
|
const term = search.toLowerCase()
|
|
setSearchTerm(term)
|
|
setFilteredChores(fuse.search(term).map(result => result.item))
|
|
}
|
|
|
|
const handleSearchClose = () => {
|
|
setSearchTerm('')
|
|
setFilteredChores(archivedChores)
|
|
searchInputRef.current?.blur()
|
|
}
|
|
|
|
const handleChoreUpdated = (updatedChore, event) => {
|
|
if (event === 'unarchive') {
|
|
// Remove from archived list when unarchived
|
|
const newArchivedChores = archivedChores.filter(
|
|
chore => chore.id !== updatedChore.id,
|
|
)
|
|
const newFilteredChores = filteredChores.filter(
|
|
chore => chore.id !== updatedChore.id,
|
|
)
|
|
setArchivedChores(newArchivedChores)
|
|
setFilteredChores(newFilteredChores)
|
|
|
|
showSuccess({
|
|
title: t('archived.restoredTitle'),
|
|
message: t('archived.restoredMsg'),
|
|
})
|
|
}
|
|
}
|
|
|
|
const handleChoreDeleted = deletedChore => {
|
|
const newArchivedChores = archivedChores.filter(
|
|
chore => chore.id !== deletedChore.id,
|
|
)
|
|
const newFilteredChores = filteredChores.filter(
|
|
chore => chore.id !== deletedChore.id,
|
|
)
|
|
setArchivedChores(newArchivedChores)
|
|
setFilteredChores(newFilteredChores)
|
|
|
|
showSuccess({
|
|
title: t('archived.deletedTitle'),
|
|
message: t('archived.deletedMsg'),
|
|
})
|
|
}
|
|
|
|
// Multi-select helper functions
|
|
const toggleMultiSelectMode = () => {
|
|
const newMode = !isMultiSelectMode
|
|
setIsMultiSelectMode(newMode)
|
|
|
|
if (!newMode) {
|
|
setSelectedChores(new Set())
|
|
}
|
|
}
|
|
|
|
const toggleChoreSelection = choreId => {
|
|
const newSelection = new Set(selectedChores)
|
|
if (newSelection.has(choreId)) {
|
|
newSelection.delete(choreId)
|
|
} else {
|
|
newSelection.add(choreId)
|
|
}
|
|
setSelectedChores(newSelection)
|
|
}
|
|
|
|
// Press-and-hold on a task card enters multi-select with that task picked
|
|
const enterMultiSelectWithChore = choreId => {
|
|
if (!isMultiSelectMode) {
|
|
setIsMultiSelectMode(true)
|
|
setSelectedChores(new Set([choreId]))
|
|
return
|
|
}
|
|
toggleChoreSelection(choreId)
|
|
}
|
|
|
|
const selectAllVisibleChores = () => {
|
|
if (finalChores.length > 0) {
|
|
setSelectedChores(new Set(finalChores.map(c => c.id)))
|
|
}
|
|
}
|
|
|
|
const clearSelection = () => {
|
|
if (selectedChores.size === 0) {
|
|
setIsMultiSelectMode(false)
|
|
return
|
|
}
|
|
setSelectedChores(new Set())
|
|
}
|
|
|
|
const getSelectedChoresData = () => {
|
|
return Array.from(selectedChores)
|
|
.map(id => archivedChores.find(chore => chore.id === id))
|
|
.filter(Boolean)
|
|
}
|
|
|
|
// Bulk operations
|
|
const handleBulkRestore = async () => {
|
|
const selectedData = getSelectedChoresData()
|
|
if (selectedData.length === 0) return
|
|
|
|
setConfirmModelConfig({
|
|
isOpen: true,
|
|
title: t('archived.restoreTasksTitle'),
|
|
confirmText: t('archived.restore'),
|
|
cancelText: t('common:cancel'),
|
|
message: t('archived.restoreConfirm', { count: selectedData.length }),
|
|
onClose: async isConfirmed => {
|
|
if (isConfirmed === true) {
|
|
try {
|
|
const restoredTasks = []
|
|
const failedTasks = []
|
|
|
|
const isNetworkError = err =>
|
|
isOfflineFeatureEnabled() &&
|
|
err instanceof TypeError &&
|
|
err.message === 'Failed to fetch'
|
|
const queuedTasks = []
|
|
|
|
for (const chore of selectedData) {
|
|
try {
|
|
await new Promise((resolve, reject) => {
|
|
unArchiveChore.mutate(chore.id, {
|
|
onSuccess: data => {
|
|
restoredTasks.push(chore)
|
|
resolve(data)
|
|
},
|
|
onError: async error => {
|
|
if (isNetworkError(error)) {
|
|
await commandQueue.enqueue(
|
|
CommandType.UNARCHIVE_CHORE,
|
|
chore.id,
|
|
{ id: chore.id },
|
|
)
|
|
await offlineDB.saveChores([
|
|
{ ...chore, isActive: true, _pending: 'unarchive' },
|
|
])
|
|
queuedTasks.push(chore)
|
|
resolve()
|
|
} else {
|
|
failedTasks.push(chore)
|
|
reject(error)
|
|
}
|
|
},
|
|
})
|
|
})
|
|
} catch (error) {
|
|
// Error already handled in onError callback
|
|
}
|
|
}
|
|
|
|
const allRestored = [...restoredTasks, ...queuedTasks]
|
|
if (allRestored.length > 0) {
|
|
const offlineNote =
|
|
queuedTasks.length > 0
|
|
? ' (queued — will sync when back online)'
|
|
: ''
|
|
// Remove from archived view optimistically for both online and queued
|
|
const restoredIds = new Set(allRestored.map(c => c.id))
|
|
const newArchivedChores = archivedChores.filter(
|
|
c => !restoredIds.has(c.id),
|
|
)
|
|
const newFilteredChores = filteredChores.filter(
|
|
c => !restoredIds.has(c.id),
|
|
)
|
|
setArchivedChores(newArchivedChores)
|
|
setFilteredChores(newFilteredChores)
|
|
if (queuedTasks.length > 0) {
|
|
queryClient.invalidateQueries({ queryKey: ['pendingCommands'] })
|
|
}
|
|
showSuccess({
|
|
title: t('archived.restoredBulkTitle'),
|
|
message: t('archived.restoredCount', {
|
|
count: allRestored.length,
|
|
offline: offlineNote,
|
|
}),
|
|
})
|
|
}
|
|
|
|
if (failedTasks.length > 0) {
|
|
showError({
|
|
title: t('archived.someFailedTitle'),
|
|
message: t('archived.restoreFailCount', {
|
|
count: failedTasks.length,
|
|
}),
|
|
})
|
|
}
|
|
|
|
clearSelection()
|
|
} catch (error) {
|
|
showError({
|
|
title: t('archived.bulkRestoreFailTitle'),
|
|
message: t('archived.unexpectedError'),
|
|
})
|
|
}
|
|
}
|
|
setConfirmModelConfig({})
|
|
},
|
|
})
|
|
}
|
|
|
|
const handleBulkDelete = async () => {
|
|
const selectedData = getSelectedChoresData()
|
|
if (selectedData.length === 0) return
|
|
|
|
setConfirmModelConfig({
|
|
isOpen: true,
|
|
title: t('archived.deleteTasksTitle'),
|
|
confirmText: t('archived.delete'),
|
|
cancelText: t('common:cancel'),
|
|
message: t('archived.deleteConfirm', { count: selectedData.length }),
|
|
onClose: async isConfirmed => {
|
|
if (isConfirmed === true) {
|
|
try {
|
|
const deletedTasks = []
|
|
const failedTasks = []
|
|
|
|
for (const chore of selectedData) {
|
|
try {
|
|
await DeleteChore(chore.id)
|
|
deletedTasks.push(chore)
|
|
} catch (error) {
|
|
failedTasks.push(chore)
|
|
}
|
|
}
|
|
|
|
if (deletedTasks.length > 0) {
|
|
showSuccess({
|
|
title: t('archived.deletedBulkTitle'),
|
|
message: t('archived.deletedCount', {
|
|
count: deletedTasks.length,
|
|
}),
|
|
})
|
|
|
|
const deletedIds = new Set(deletedTasks.map(c => c.id))
|
|
const newArchivedChores = archivedChores.filter(
|
|
c => !deletedIds.has(c.id),
|
|
)
|
|
const newFilteredChores = filteredChores.filter(
|
|
c => !deletedIds.has(c.id),
|
|
)
|
|
setArchivedChores(newArchivedChores)
|
|
setFilteredChores(newFilteredChores)
|
|
}
|
|
|
|
if (failedTasks.length > 0) {
|
|
showError({
|
|
title: t('archived.someFailedTitle'),
|
|
message: t('archived.deleteFailCount', {
|
|
count: failedTasks.length,
|
|
}),
|
|
})
|
|
}
|
|
|
|
clearSelection()
|
|
} catch (error) {
|
|
showError({
|
|
title: t('archived.bulkDeleteFailTitle'),
|
|
message: t('archived.unexpectedError'),
|
|
})
|
|
}
|
|
}
|
|
setConfirmModelConfig({})
|
|
},
|
|
})
|
|
}
|
|
|
|
// Helper function to render the appropriate card component
|
|
const renderChoreCard = (chore, key) => {
|
|
const CardComponent = viewMode === 'compact' ? CompactChoreCard : ChoreCard
|
|
return (
|
|
<CardComponent
|
|
key={key || chore.id}
|
|
chore={chore}
|
|
onChoreUpdate={handleChoreUpdated}
|
|
onChoreRemove={handleChoreDeleted}
|
|
performers={performers}
|
|
viewOnly={false}
|
|
showActions={false}
|
|
// Multi-select props
|
|
isMultiSelectMode={isMultiSelectMode}
|
|
isSelected={selectedChores.has(chore.id)}
|
|
onSelectionToggle={() => toggleChoreSelection(chore.id)}
|
|
/>
|
|
)
|
|
}
|
|
|
|
if (isUserProfileLoading || performers.length === 0 || isLoading) {
|
|
return <LoadingComponent />
|
|
}
|
|
|
|
return (
|
|
<Container maxWidth='md'>
|
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 2 }}>
|
|
{/* <EmojiEvents sx={{ fontSize: '2rem', color: '#FFD700' }} /> */}
|
|
<Stack sx={{ flex: 1 }}>
|
|
<Typography
|
|
level='h3'
|
|
sx={{ fontWeight: 'lg', color: 'text.primary' }}
|
|
>
|
|
{t('archived.title')}
|
|
</Typography>
|
|
<Typography level='body-sm' sx={{ color: 'text.secondary' }}>
|
|
{t('archived.subtitle')}
|
|
</Typography>
|
|
</Stack>
|
|
</Box>
|
|
{/* Header */}
|
|
{/* <Box
|
|
sx={{
|
|
display: 'flex',
|
|
justifyContent: 'space-between',
|
|
alignItems: 'center',
|
|
mb: 2,
|
|
pt: 2,
|
|
}}
|
|
>
|
|
<Typography
|
|
level='h3'
|
|
sx={{ display: 'flex', alignItems: 'center', gap: 1 }}
|
|
>
|
|
<Archive />
|
|
{t('archived.heading')}
|
|
</Typography>
|
|
<Button
|
|
variant='outlined'
|
|
color='neutral'
|
|
startDecorator={<Close />}
|
|
onClick={() => navigate('/chores')}
|
|
sx={{ ml: 'auto' }}
|
|
>
|
|
Close
|
|
</Button>
|
|
</Box> */}
|
|
|
|
{/* Search and Controls */}
|
|
<Box
|
|
sx={{
|
|
display: 'flex',
|
|
justifyContent: 'space-between',
|
|
alignContent: 'center',
|
|
alignItems: 'center',
|
|
gap: 0.5,
|
|
mb: 2,
|
|
}}
|
|
>
|
|
<Input
|
|
slotProps={{ input: { ref: searchInputRef } }}
|
|
placeholder={t('archived.searchPlaceholder')}
|
|
value={searchTerm}
|
|
fullWidth
|
|
sx={{
|
|
borderRadius: 24,
|
|
height: 24,
|
|
borderColor: 'text.disabled',
|
|
padding: 1,
|
|
}}
|
|
onChange={handleSearchChange}
|
|
endDecorator={
|
|
searchTerm && (
|
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
|
<KeyboardShortcutHint
|
|
shortcut='X'
|
|
show={showKeyboardShortcuts}
|
|
/>
|
|
<IconButton
|
|
variant='plain'
|
|
size='sm'
|
|
onClick={handleSearchClose}
|
|
sx={{ borderRadius: '50%' }}
|
|
>
|
|
<Close />
|
|
</IconButton>
|
|
</Box>
|
|
)
|
|
}
|
|
/>
|
|
|
|
{/* Filter Sheet Trigger */}
|
|
<Badge
|
|
badgeContent={activeFilterCount || null}
|
|
color='primary'
|
|
size='sm'
|
|
anchorOrigin={{ vertical: 'top', horizontal: 'right' }}
|
|
sx={{ display: 'flex', alignItems: 'center' }}
|
|
>
|
|
<IconButton
|
|
variant={hasActiveFilters ? 'solid' : 'outlined'}
|
|
color={hasActiveFilters ? 'primary' : 'neutral'}
|
|
size='sm'
|
|
sx={{
|
|
height: 32,
|
|
width: 32,
|
|
borderRadius: '50%',
|
|
}}
|
|
onClick={() => setIsFilterSheetOpen(true)}
|
|
title='Filters'
|
|
>
|
|
<FilterList />
|
|
</IconButton>
|
|
</Badge>
|
|
|
|
{/* Sort Menu */}
|
|
<SortAndFilterMenu
|
|
sortOptions={[
|
|
{ name: 'Archived date', value: 'archivedAt' },
|
|
{ name: 'Name', value: 'name' },
|
|
{ name: 'Priority', value: 'priority' },
|
|
{ name: 'Due date', value: 'dueDate' },
|
|
]}
|
|
selectedSort={sortBy}
|
|
onSortChange={setSortBy}
|
|
sortDirection={sortDirection}
|
|
onSortDirectionChange={setSortDirection}
|
|
isActive={sortBy !== 'archivedAt' || sortDirection !== 'desc'}
|
|
/>
|
|
|
|
{/* View Mode Toggle Button */}
|
|
<IconButton
|
|
variant='outlined'
|
|
color='neutral'
|
|
size='sm'
|
|
sx={{
|
|
height: 32,
|
|
width: 32,
|
|
borderRadius: '50%',
|
|
}}
|
|
onClick={toggleViewMode}
|
|
title={
|
|
viewMode === 'default'
|
|
? t('archived.switchToCompact')
|
|
: t('archived.switchToCard')
|
|
}
|
|
>
|
|
{viewMode === 'default' ? <ViewAgenda /> : <ViewModule />}
|
|
</IconButton>
|
|
|
|
{/* Multi-select Toggle Button */}
|
|
<Box sx={{ position: 'relative', display: 'inline-flex' }}>
|
|
<IconButton
|
|
variant={isMultiSelectMode ? 'solid' : 'outlined'}
|
|
color={isMultiSelectMode ? 'primary' : 'neutral'}
|
|
size='sm'
|
|
sx={{
|
|
height: 32,
|
|
width: 32,
|
|
borderRadius: '50%',
|
|
}}
|
|
onClick={toggleMultiSelectMode}
|
|
title={
|
|
isMultiSelectMode
|
|
? t('archived.exitMultiSelect')
|
|
: t('archived.enableMultiSelect')
|
|
}
|
|
>
|
|
{isMultiSelectMode ? <CheckBox /> : <CheckBoxOutlineBlank />}
|
|
</IconButton>
|
|
<KeyboardShortcutHint
|
|
shortcut='S'
|
|
show={showKeyboardShortcuts}
|
|
sx={{
|
|
position: 'absolute',
|
|
top: -8,
|
|
right: -8,
|
|
zIndex: 1000,
|
|
}}
|
|
/>
|
|
</Box>
|
|
</Box>
|
|
|
|
<FilterBar
|
|
filterDefs={filterDefs}
|
|
activeFilters={activeFilters}
|
|
onSetFilter={setFilter}
|
|
onClearAll={clearAll}
|
|
resultCount={finalChores.length}
|
|
totalCount={filteredChores.length}
|
|
showTrigger={false}
|
|
open={isFilterSheetOpen}
|
|
onOpenChange={setIsFilterSheetOpen}
|
|
/>
|
|
|
|
{/* Multi-select Toolbar */}
|
|
{isMultiSelectMode && (
|
|
<Box
|
|
sx={{
|
|
position: 'sticky',
|
|
top: 0,
|
|
zIndex: 1000,
|
|
mb: 2,
|
|
}}
|
|
>
|
|
<Box
|
|
sx={{
|
|
backgroundColor: 'background.surface',
|
|
backdropFilter: 'blur(8px)',
|
|
borderRadius: 'lg',
|
|
p: 2,
|
|
border: '1px solid',
|
|
borderColor: 'divider',
|
|
boxShadow: 'm',
|
|
gap: 2,
|
|
display: 'flex',
|
|
flexDirection: {
|
|
sm: 'column',
|
|
md: 'row',
|
|
},
|
|
alignItems: {
|
|
xs: 'stretch',
|
|
sm: 'center',
|
|
},
|
|
justifyContent: {
|
|
xs: 'center',
|
|
sm: 'space-between',
|
|
},
|
|
}}
|
|
>
|
|
{/* Selection Info and Controls */}
|
|
<Box
|
|
sx={{
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
gap: 2,
|
|
flexWrap: {
|
|
xs: 'wrap',
|
|
sm: 'nowrap',
|
|
},
|
|
justifyContent: {
|
|
xs: 'center',
|
|
sm: 'flex-start',
|
|
},
|
|
}}
|
|
>
|
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
|
<CheckBox sx={{ color: 'primary.500' }} />
|
|
<Typography level='body-sm' fontWeight='md'>
|
|
{t('archived.selected', { count: selectedChores.size })}
|
|
</Typography>
|
|
</Box>
|
|
|
|
<Divider
|
|
orientation='vertical'
|
|
sx={{
|
|
display: { xs: 'none', sm: 'block' },
|
|
}}
|
|
/>
|
|
|
|
<Box sx={{ display: 'flex', gap: 1 }}>
|
|
<Button
|
|
size='sm'
|
|
variant='outlined'
|
|
onClick={selectAllVisibleChores}
|
|
startDecorator={<SelectAll />}
|
|
disabled={selectedChores.size === finalChores.length}
|
|
sx={{
|
|
minWidth: 'auto',
|
|
'--Button-paddingInline': '0.75rem',
|
|
position: 'relative',
|
|
}}
|
|
title={t('archived.selectAllTitle')}
|
|
>
|
|
{t('archived.all')}
|
|
{showKeyboardShortcuts && (
|
|
<KeyboardShortcutHint
|
|
shortcut='A'
|
|
sx={{
|
|
position: 'absolute',
|
|
top: -8,
|
|
right: -8,
|
|
zIndex: 1000,
|
|
}}
|
|
/>
|
|
)}
|
|
</Button>
|
|
<Button
|
|
size='sm'
|
|
variant='outlined'
|
|
onClick={clearSelection}
|
|
startDecorator={
|
|
selectedChores.size === 0 ? (
|
|
<Close />
|
|
) : (
|
|
<CheckBoxOutlineBlank />
|
|
)
|
|
}
|
|
sx={{
|
|
minWidth: 'auto',
|
|
'--Button-paddingInline': '0.75rem',
|
|
position: 'relative',
|
|
}}
|
|
title={
|
|
selectedChores.size === 0
|
|
? t('archived.closeMultiSelect')
|
|
: t('archived.clearMultiSelect')
|
|
}
|
|
>
|
|
{selectedChores.size === 0
|
|
? t('archived.close')
|
|
: t('archived.clear')}
|
|
{showKeyboardShortcuts && (
|
|
<KeyboardShortcutHint
|
|
withCtrl={false}
|
|
shortcut='Esc'
|
|
sx={{
|
|
position: 'absolute',
|
|
top: -8,
|
|
right: -8,
|
|
zIndex: 1000,
|
|
}}
|
|
/>
|
|
)}
|
|
</Button>
|
|
</Box>
|
|
</Box>
|
|
|
|
{/* Action Buttons */}
|
|
<Box
|
|
sx={{
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
gap: 1,
|
|
flexWrap: {
|
|
xs: 'wrap',
|
|
sm: 'nowrap',
|
|
},
|
|
justifyContent: {
|
|
xs: 'center',
|
|
sm: 'flex-end',
|
|
},
|
|
}}
|
|
>
|
|
<Button
|
|
size='sm'
|
|
variant='solid'
|
|
color='success'
|
|
onClick={handleBulkRestore}
|
|
startDecorator={<Unarchive />}
|
|
disabled={selectedChores.size === 0}
|
|
sx={{
|
|
'--Button-paddingInline': { xs: '0.75rem', sm: '1rem' },
|
|
position: 'relative',
|
|
}}
|
|
title={t('archived.restoreSelectedTitle')}
|
|
>
|
|
{t('archived.restore')}
|
|
{showKeyboardShortcuts && selectedChores.size > 0 && (
|
|
<KeyboardShortcutHint
|
|
shortcut='R'
|
|
sx={{
|
|
position: 'absolute',
|
|
top: -8,
|
|
right: -8,
|
|
zIndex: 1000,
|
|
}}
|
|
/>
|
|
)}
|
|
</Button>
|
|
|
|
<Button
|
|
size='sm'
|
|
variant='soft'
|
|
color='danger'
|
|
onClick={handleBulkDelete}
|
|
startDecorator={<Delete />}
|
|
disabled={selectedChores.size === 0}
|
|
sx={{
|
|
'--Button-paddingInline': { xs: '0.75rem', sm: '1rem' },
|
|
position: 'relative',
|
|
}}
|
|
title={t('archived.deleteSelectedTitle')}
|
|
>
|
|
{t('archived.delete')}
|
|
{showKeyboardShortcuts && selectedChores.size > 0 && (
|
|
<KeyboardShortcutHint
|
|
shortcut='E'
|
|
sx={{
|
|
position: 'absolute',
|
|
top: -8,
|
|
right: -8,
|
|
zIndex: 1000,
|
|
}}
|
|
/>
|
|
)}
|
|
</Button>
|
|
</Box>
|
|
</Box>
|
|
</Box>
|
|
)}
|
|
|
|
{/* Content */}
|
|
{finalChores.length === 0 ? (
|
|
searchTerm || hasActiveFilters ? (
|
|
<EmptyState
|
|
variant='no-results'
|
|
fullHeight
|
|
icon={<SearchOff />}
|
|
title='No archived tasks match'
|
|
description={
|
|
searchTerm
|
|
? `Nothing in the archive matches "${searchTerm}".`
|
|
: 'There are archived tasks, but none fit the filters that are currently on.'
|
|
}
|
|
primaryAction={
|
|
searchTerm
|
|
? {
|
|
label: t('archived.clearSearch'),
|
|
onClick: handleSearchClose,
|
|
}
|
|
: { label: t('archived.clearFilters'), onClick: clearAll }
|
|
}
|
|
secondaryAction={
|
|
searchTerm && hasActiveFilters
|
|
? { label: t('archived.clearFilters'), onClick: clearAll }
|
|
: undefined
|
|
}
|
|
/>
|
|
) : (
|
|
<EmptyState
|
|
fullHeight
|
|
icon={<Archive />}
|
|
title='Nothing archived'
|
|
description='Archiving hides a task without deleting it. Anything you archive from your task list shows up here, ready to restore.'
|
|
primaryAction={{ label: 'Back to tasks', to: '/chores' }}
|
|
/>
|
|
)
|
|
) : (
|
|
<Box>
|
|
<Typography level='body-sm' color='text.secondary' sx={{ mb: 2 }}>
|
|
{t('archived.count', { count: finalChores.length })}
|
|
{searchTerm && t('archived.matching', { term: searchTerm })}
|
|
</Typography>
|
|
|
|
<List sx={{ gap: viewMode === 'compact' ? 0 : 1 }}>
|
|
<ChoreListView
|
|
chores={finalChores}
|
|
// viewOnly={true}
|
|
showActions={false}
|
|
viewMode={viewMode}
|
|
membersData={membersData}
|
|
isMultiSelectMode={isMultiSelectMode}
|
|
selectedChores={selectedChores}
|
|
toggleChoreSelection={toggleChoreSelection}
|
|
onLongPressChore={enterMultiSelectWithChore}
|
|
/>
|
|
</List>
|
|
</Box>
|
|
)}
|
|
|
|
{/* Multi-select Help */}
|
|
<MultiSelectHelp isVisible={isMultiSelectMode} />
|
|
|
|
{/* Confirmation Modal */}
|
|
{confirmModelConfig?.isOpen && (
|
|
<ConfirmationModal config={confirmModelConfig} />
|
|
)}
|
|
</Container>
|
|
)
|
|
}
|
|
|
|
export default ArchivedTasks
|