653 lines
21 KiB
JavaScript
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
|