Files
donetick/src/views/History/ChoreHistory.jsx

653 lines
21 KiB
JavaScript

import {
Type as ListType,
SwipeableList,
SwipeableListItem,
SwipeAction,
TrailingActions,
} from '@meauxt/react-swipeable-list'
import '@meauxt/react-swipeable-list/dist/styles.css'
import {
Analytics,
CalendarMonth,
Check,
Checklist,
EventBusy,
EventNote,
FilterList,
Group,
History,
HourglassEmpty,
Person,
Redo,
RunningWithErrors,
Schedule,
Star,
ThumbDown,
Timelapse,
TrendingUp,
} from '@mui/icons-material'
import DeleteIcon from '@mui/icons-material/Delete'
import EditIcon from '@mui/icons-material/Edit'
import { Box, Button, Card, Container, Grid, Sheet, Typography } from '@mui/joy'
import moment from 'moment'
import { useEffect, useMemo, useState } from 'react'
import { Link, useParams } from 'react-router-dom'
import FilterBar from '../../components/common/FilterBar'
import { useLocalization } from '../../contexts/LocalizationContext'
import useConfirmationModal from '../../hooks/useConfirmationModal'
import { useFilter } from '../../hooks/useFilter'
import { usePendingCommands } from '../../hooks/usePendingCommands'
import {
useChoreHistory,
useDeleteChoreHistory,
useUpdateChoreHistory,
} from '../../queries/ChoreQueries'
import { useCircleMembers } from '../../queries/UserQueries'
import { useNotification } from '../../service/NotificationProvider'
import { ChoreHistoryStatus } from '../../utils/Chores'
import LoadingComponent from '../components/Loading'
import EditHistoryModal from '../Modals/EditHistoryModal'
import HistoryDetailModal from '../Modals/HistoryDetailModal'
import ConfirmationModal from '../Modals/Inputs/ConfirmationModal'
import NoteViewerModal from '../Modals/Inputs/NoteViewerModal'
import HistoryCard from './HistoryCard'
const ChoreHistory = () => {
const [userHistory, setUserHistory] = useState([])
const [historyInfo, setHistoryInfo] = useState([])
const { choreId } = useParams()
const [isEditModalOpen, setIsEditModalOpen] = useState(false)
const [editHistory, setEditHistory] = useState(null)
const { confirmModalConfig, showConfirmation } = useConfirmationModal()
const { fmt } = useLocalization()
const [showMoreInfoId, setShowMoreInfoId] = useState(null)
const [noteViewerConfig, setNoteViewerConfig] = useState({ isOpen: false })
const [detailModalConfig, setDetailModalConfig] = useState({ isOpen: false })
const { showSuccess, showError } = useNotification()
// React Query hooks
const { data: choreHistoryData, isLoading } = useChoreHistory(choreId)
const { data: circleMembersData } = useCircleMembers()
const updateChoreHistory = useUpdateChoreHistory()
const deleteChoreHistory = useDeleteChoreHistory()
const { data: pendingCmds } = usePendingCommands(choreId)
const choreHistory = choreHistoryData?.res || []
const performers = circleMembersData?.res || []
const pendingByHistoryId = useMemo(() => {
if (!pendingCmds?.length) return {}
return pendingCmds.reduce((acc, cmd) => {
if (
cmd.commandType !== 'update_chore_history' &&
cmd.commandType !== 'delete_chore_history'
) {
return acc
}
const historyId =
cmd?.payload?.historyId ?? Number(String(cmd.entityId).split(':')[1])
if (!historyId) return acc
if (!acc[historyId]) acc[historyId] = []
acc[historyId].push(cmd)
return acc
}, {})
}, [pendingCmds])
const filterDefs = useMemo(
() => [
{
id: 'status',
label: 'Status',
type: 'multi-select',
icon: <FilterList />,
options: [
{
value: ChoreHistoryStatus.COMPLETED,
label: 'Completed',
color: 'success',
icon: <Check sx={{ fontSize: 14 }} />,
},
{
value: ChoreHistoryStatus.SKIPPED,
label: 'Skipped',
color: 'warning',
icon: <Redo sx={{ fontSize: 14 }} />,
},
{
value: ChoreHistoryStatus.PENDING_APPROVAL,
label: 'Pending',
color: 'neutral',
icon: <HourglassEmpty sx={{ fontSize: 14 }} />,
},
{
value: ChoreHistoryStatus.REJECTED,
label: 'Rejected',
color: 'danger',
icon: <ThumbDown sx={{ fontSize: 14 }} />,
},
{
value: 5,
label: 'Missed',
color: 'danger',
icon: <RunningWithErrors sx={{ fontSize: 14 }} />,
},
{
value: 6,
label: 'Rescheduled',
color: 'warning',
icon: <Schedule sx={{ fontSize: 14 }} />,
},
],
filterFn: (item, values) => values.includes(item.status),
},
{
id: 'hasNotes',
label: 'Has Notes',
type: 'boolean',
icon: <EventNote />,
filterFn: item => !!item.notes,
},
{
id: 'completedBy',
label: 'Completed By',
type: 'multi-select',
icon: <Person />,
options: performers.map(p => ({
value: p.userId,
label: p.displayName,
avatar: p.image,
})),
filterFn: (item, values) => values.includes(item.completedBy),
},
{
id: 'dateRange',
label: 'Completed At',
type: 'date-range',
icon: <CalendarMonth />,
filterFn: (item, value) => {
const performed = new Date(item.performedAt || item.updatedAt)
if (value.from && performed < new Date(value.from)) return false
if (value.to && performed > new Date(value.to)) return false
return true
},
},
],
[performers],
)
const {
filteredData: filteredHistory,
activeFilters,
setFilter,
clearAll,
activeFilterCount,
} = useFilter(choreHistory, filterDefs)
const sortedHistory = useMemo(
() =>
[...filteredHistory].sort(
(a, b) =>
new Date(b.performedAt || b.updatedAt) -
new Date(a.performedAt || a.updatedAt),
),
[filteredHistory],
)
const handleDelete = historyEntry => {
showConfirmation(
`Are you sure you want to delete this history record?`,
'Delete History Record',
() => {
deleteChoreHistory.mutate({
choreId,
historyId: historyEntry.id,
})
},
'Delete',
'Cancel',
'danger',
)
}
const handleEdit = historyEntry => {
setIsEditModalOpen(true)
setEditHistory(historyEntry)
}
useEffect(() => {
if (choreHistory.length > 0 && performers.length > 0) {
const newUserChoreHistory = {}
choreHistory.forEach(historyEntry => {
const userId = historyEntry.completedBy
newUserChoreHistory[userId] = (newUserChoreHistory[userId] || 0) + 1
})
setUserHistory(newUserChoreHistory)
updateHistoryInfo(choreHistory, newUserChoreHistory, performers)
}
}, [choreHistory, performers])
const updateHistoryInfo = (histories, userHistories, performers) => {
// average delay for task completaion from due date:
const averageDelay =
histories.reduce((acc, chore) => {
if (chore.dueDate && chore.performedAt) {
// Only consider chores with a due date
return acc + moment(chore.performedAt).diff(chore.dueDate, 'hours')
}
return acc
}, 0) / histories.filter(chore => chore.dueDate).length
const averageDelayMoment = moment.duration(averageDelay, 'hours')
const maximumDelay = histories.reduce((acc, chore) => {
if (chore.dueDate) {
// Only consider chores with a due date
const delay = moment(chore.performedAt).diff(chore.dueDate, 'hours')
return delay > acc ? delay : acc
}
return acc
}, 0)
const maxDelayMoment = moment.duration(maximumDelay, 'hours')
// find max value in userHistories:
const userCompletedByMost = Object.keys(userHistories).reduce((a, b) =>
userHistories[a] > userHistories[b] ? a : b,
)
const historyInfo = [
{
icon: <Checklist />,
text: 'All Completed',
subtext: `${histories.filter(h => h.status === ChoreHistoryStatus.COMPLETED || h.status === ChoreHistoryStatus.SKIPPED).length} times`,
},
{
icon: <TrendingUp />,
text: 'Average Timing',
subtext: moment.duration(averageDelayMoment).isValid()
? moment.duration(averageDelayMoment).humanize()
: 'On time',
},
{
icon: <Timelapse />,
text: 'Longest Delay',
subtext: moment.duration(maxDelayMoment).isValid()
? moment.duration(maxDelayMoment).humanize()
: 'Never late',
},
{
icon: <Star />,
text: 'Completed Most',
subtext: `${
performers.find(p => p.userId === Number(userCompletedByMost))
?.displayName || 'Unknown'
}`,
},
{
icon: <Group />,
text: 'Members Involved',
subtext: `${Object.keys(userHistories).length} members`,
},
{
icon: <Analytics />,
text: 'Last Completed',
subtext: `${
performers.find(p => p.userId === Number(histories[0].completedBy))
?.displayName || 'Unknown'
}`,
},
]
setHistoryInfo(historyInfo)
}
if (isLoading) {
return <LoadingComponent />
}
if (!choreHistory.length) {
return (
<Container
maxWidth='md'
sx={{
textAlign: 'center',
display: 'flex',
// make sure the content is centered vertically:
alignItems: 'center',
justifyContent: 'center',
flexDirection: 'column',
height: '50vh',
}}
>
<EventBusy
sx={{
fontSize: '6rem',
// color: 'text.disabled',
mb: 1,
}}
/>
<Typography level='h3' gutterBottom>
No History Yet
</Typography>
<Typography level='body1'>
You haven't completed any tasks. Once you start finishing tasks,
they'll show up here.
</Typography>
<Button variant='soft' sx={{ mt: 2 }}>
<Link to='/chores'>Go back to chores</Link>
</Button>
</Container>
)
}
return (
<Container maxWidth='md' sx={{ px: 0 }}>
{/* Enhanced Header Section */}
<Box sx={{ gap: 2, p: 2 }}>
{/* <Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 2, p: 2 }}> */}
{/* Statistics Cards Grid - Compact Design */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 3 }}>
<History sx={{ fontSize: '1.5rem' }} />
<Typography
level='title-md'
sx={{ fontWeight: 'lg', color: 'text.primary' }}
>
Task Summary
</Typography>
</Box>
<Grid container spacing={0.5} sx={{ mb: 2 }}>
{historyInfo.map((info, index) => (
<Grid item xs={4} sm={2} key={index}>
<Card
variant='soft'
sx={{
borderRadius: 'sm',
p: 1,
height: 85,
textAlign: 'center',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'space-between',
overflow: 'hidden',
}}
>
<Box sx={{ opacity: 0.8, flexShrink: 0 }}>{info.icon}</Box>
<Box
sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: 0.25,
flex: 1,
justifyContent: 'center',
}}
>
<Typography
level='body-xs'
sx={{
fontWeight: '600',
color: 'text.primary',
textAlign: 'center',
lineHeight: 1.1,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
width: '100%',
fontSize: '0.75rem',
}}
>
{info.text}
</Typography>
<Typography
level='body-xs'
sx={{
color: 'text.secondary',
textAlign: 'center',
lineHeight: 1.1,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
width: '100%',
fontSize: '0.7rem',
}}
>
{info.subtext || '--'}
</Typography>
</Box>
</Card>
</Grid>
))}
</Grid>
</Box>
{/* History Section Header */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, p: 2 }}>
<Analytics sx={{ fontSize: '1.5rem' }} />
<Typography
level='title-md'
sx={{ fontWeight: 'lg', color: 'text.primary' }}
>
Task Activity
</Typography>
</Box>
<Box sx={{ px: 2 }}>
<FilterBar
filterDefs={filterDefs}
activeFilters={activeFilters}
onSetFilter={setFilter}
onClearAll={clearAll}
resultCount={filteredHistory.length}
totalCount={choreHistory.length}
/>
</Box>
{sortedHistory.length === 0 && activeFilterCount > 0 && (
<Box
sx={{
textAlign: 'center',
py: 6,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: 1.5,
}}
>
<FilterList sx={{ fontSize: '3rem', color: 'text.tertiary' }} />
<Typography level='title-md' sx={{ color: 'text.secondary' }}>
No results match your filters
</Typography>
<Typography level='body-sm' sx={{ color: 'text.tertiary' }}>
Try adjusting or clearing the active filters.
</Typography>
<Button variant='soft' size='sm' onClick={clearAll} sx={{ mt: 0.5 }}>
Clear filters
</Button>
</Box>
)}
{sortedHistory.length > 0 && (
<Sheet variant='plain' sx={{ borderRadius: 'sm', overflow: 'hidden' }}>
{/* Chore History List (Updated Style) */}
<SwipeableList type={ListType.IOS} fullSwipe={false}>
{sortedHistory.map((historyEntry, index) => (
<SwipeableListItem
key={historyEntry.id || index}
swipeActionOpen={
showMoreInfoId === (historyEntry.id || index)
? 'trailing'
: null
}
trailingActions={
<TrailingActions>
<Box
sx={{
display: 'flex',
boxShadow: 'inset 2px 0 4px rgba(0,0,0,0.06)',
zIndex: 0,
}}
>
<SwipeAction onClick={() => handleEdit(historyEntry)}>
<Box
sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
bgcolor: 'neutral.softBg',
color: 'neutral.700',
px: 3,
height: '100%',
width: '100%',
}}
>
<EditIcon sx={{ fontSize: 20 }} />
<Typography level='body-xs' sx={{ mt: 0.5 }}>
Edit
</Typography>
</Box>
</SwipeAction>
<SwipeAction onClick={() => handleDelete(historyEntry)}>
<Box
sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
bgcolor: 'danger.softBg',
color: 'danger.700',
px: 3,
height: '100%',
}}
>
<DeleteIcon sx={{ fontSize: 20 }} />
<Typography level='body-xs' sx={{ mt: 0.5 }}>
Delete
</Typography>
</Box>
</SwipeAction>
</Box>
</TrailingActions>
}
>
<HistoryCard
historyEntry={historyEntry}
performers={performers}
allHistory={choreHistory}
index={index}
onViewDetails={() => {
setDetailModalConfig({
isOpen: true,
entry: historyEntry,
performers,
onClose: () => setDetailModalConfig({ isOpen: false }),
onEdit: record => {
setDetailModalConfig({ isOpen: false })
setEditHistory(record)
setIsEditModalOpen(true)
},
})
}}
pendingCommands={pendingByHistoryId[historyEntry.id] || []}
onViewNote={notes => {
setNoteViewerConfig({
isOpen: true,
title: `Updated at ${fmt.dateTime(historyEntry.updatedAt)}`,
content: notes,
onClose: () => setNoteViewerConfig({ isOpen: false }),
})
}}
onToggleActions={() => {
const id = historyEntry.id || index
if (showMoreInfoId === id) {
setShowMoreInfoId(null)
} else {
setShowMoreInfoId(id)
}
}}
/>
</SwipeableListItem>
))}
</SwipeableList>
</Sheet>
)}
<EditHistoryModal
config={{
isOpen: isEditModalOpen,
onClose: () => {
setIsEditModalOpen(false)
setEditHistory(null)
},
onSave: updated => {
if (!editHistory?.id) return
updateChoreHistory.mutate(
{
choreId,
historyId: editHistory.id,
historyData: {
performedAt: updated.performedAt,
dueDate: updated.dueDate,
notes: updated.notes,
},
},
{
onSuccess: data => {
setIsEditModalOpen(false)
setEditHistory(null)
if (data?.queued) {
showSuccess({
title: 'History Update Queued',
message:
'You are offline. The history update will sync when connection is restored.',
})
} else {
showSuccess({
title: 'History Updated',
message: `The history record has been updated successfully.`,
})
}
},
onError: error => {
console.error('Failed to update chore history:', error)
},
},
)
},
onDelete: () => {
if (!editHistory?.id) return
deleteChoreHistory.mutate(
{
choreId,
historyId: editHistory.id,
},
{
onSuccess: data => {
setIsEditModalOpen(false)
setEditHistory(null)
if (data?.queued) {
showSuccess({
title: 'History Delete Queued',
message:
'You are offline. The history delete will sync when connection is restored.',
})
} else {
showSuccess({
title: 'History Deleted',
message: `The history record has been deleted successfully.`,
})
}
},
},
)
},
}}
historyRecord={editHistory}
/>
<ConfirmationModal config={confirmModalConfig} />
<NoteViewerModal config={noteViewerConfig} />
<HistoryDetailModal config={detailModalConfig} />
</Container>
)
}
export default ChoreHistory