Files
donetick/src/views/ChoreEdit/ChoreView.jsx
2026-07-07 00:23:36 -04:00

1355 lines
41 KiB
JavaScript

import {
Archive,
AttachFile,
CalendarMonth,
Check,
Checklist,
Edit,
History,
HourglassEmpty,
LowPriority,
OpenInFull,
PeopleAlt,
Person,
PlayArrow,
SwitchAccessShortcut,
ThumbDown,
ThumbUp,
Unarchive,
} from '@mui/icons-material'
import {
Box,
Button,
Card,
CardContent,
Checkbox,
Chip,
Container,
Dropdown,
FormControl,
Grid,
IconButton,
Input,
Menu,
MenuButton,
MenuItem,
Sheet,
Typography,
} from '@mui/joy'
import { Divider } from '@mui/material'
import { useQueryClient } from '@tanstack/react-query'
import moment from 'moment'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useNavigate, useParams, useSearchParams } from 'react-router-dom'
import { useImpersonateUser } from '../../contexts/ImpersonateUserContext.jsx'
import { useLocalization } from '../../contexts/LocalizationContext'
import { usePendingCommands } from '../../hooks/usePendingCommands'
import {
useChoreDetails,
useChoreHistory,
} from '../../queries/ChoreQueries.jsx'
import {
useChoreTimer,
useDeleteTimeSession,
usePauseChore,
useResetChoreTimer,
useStartChore,
} from '../../queries/TimeQueries'
import { useCircleMembers, useUserProfile } from '../../queries/UserQueries.jsx'
import { useNotification } from '../../service/NotificationProvider'
import {
ChoreHistoryStatus,
ChoreStatus,
notInCompletionWindow,
} from '../../utils/Chores.jsx'
import { getTextColorFromBackgroundColor } from '../../utils/Colors.jsx'
import { commandQueue, CommandType } from '../../utils/CommandQueue'
import {
ApproveChore,
GetChoreDetailById,
MarkChoreComplete,
RejectChore,
SkipChore,
UnArchiveChore,
UndoChoreAction,
UpdateChorePriority,
} from '../../utils/Fetcher'
import { offlineDB } from '../../utils/OfflineDB'
import Priorities from '../../utils/Priorities'
import { getSafeBottomPadding } from '../../utils/SafeAreaUtils.js'
import AttachmentBrowserModal from '../Modals/Inputs/AttachmentBrowserModal'
import ConfirmationModal from '../Modals/Inputs/ConfirmationModal'
import NoteViewerModal from '../Modals/Inputs/NoteViewerModal'
import LoadingComponent from '../components/Loading.jsx'
import PendingBadge from '../components/PendingBadge'
import RichTextEditor from '../components/RichTextEditor.jsx'
import SubTasks from '../components/SubTask.jsx'
import TimePassedCard from './TimePassedCard.jsx'
import TimerSplitButton from './TimerSplitButton.jsx'
import { refreshSignedUrlsInHtml } from '../../utils/Helpers.jsx'
const isNetworkError = err =>
err instanceof TypeError && err.message === 'Failed to fetch'
const decodeHtmlEntities = value => {
if (typeof value !== 'string') return ''
return value
.replaceAll('&lt;', '<')
.replaceAll('&gt;', '>')
.replaceAll('&quot;', '"')
.replaceAll('&#39;', "'")
.replaceAll('&amp;', '&')
}
const hasHtmlTags = value => /<\/?[a-z][\s\S]*>/i.test(value)
const ChoreView = () => {
const { t } = useTranslation('chores')
const { fmt } = useLocalization()
const [chore, setChore] = useState({})
const navigate = useNavigate()
const [performers, setPerformers] = useState([])
const [infoCards, setInfoCards] = useState([])
const { choreId } = useParams()
const [note, setNote] = useState(null)
const queryClient = useQueryClient()
const { showSuccess, showError, showUndo } = useNotification()
const [searchParams] = useSearchParams()
const [completedDate, setCompletedDate] = useState(null)
const [confirmModelConfig, setConfirmModelConfig] = useState({
isOpen: false,
})
const [chorePriority, setChorePriority] = useState(null)
const [noteViewerConfig, setNoteViewerConfig] = useState({ isOpen: false })
const [timerActionConfig, setTimerActionConfig] = useState({ isOpen: false })
const [attachmentBrowserOpen, setAttachmentBrowserOpen] = useState(false)
const { data: circleMembersData, isLoading: isCircleMembersLoading } =
useCircleMembers()
const { data: userProfile } = useUserProfile()
const { impersonatedUser } = useImpersonateUser()
const { data: choreData, isLoading: isChoreLoading } =
useChoreDetails(choreId)
const { data: choreHistoryData } = useChoreHistory(choreId)
const { data: pendingCmds } = usePendingCommands(choreId)
const choreHistory = choreHistoryData?.res || []
const historyCompletionCount = choreHistory.filter(historyEntry => {
const status = Number(historyEntry?.status)
return status === ChoreHistoryStatus.COMPLETED
}).length
const completionCount = choreHistoryData
? historyCompletionCount
: chore.totalCompletedCount || 0
const startChore = useStartChore()
const pauseChore = usePauseChore()
const deleteTimeSession = useDeleteTimeSession()
const resetChoreTimer = useResetChoreTimer()
const { data: choreTimer } = useChoreTimer(choreId)
useEffect(() => {
if (!choreData || !choreData.res || !circleMembersData) {
return
}
setChore(choreData.res)
setChorePriority(Priorities.find(p => p.value === choreData.res.priority))
document.title = 'Donetick: ' + choreData.res.name
setPerformers(circleMembersData.res)
if (searchParams.get('auto_complete') === 'true') {
navigate({ search: '' }, { replace: true })
handleTaskCompletion()
}
}, [choreData, circleMembersData])
useEffect(() => {
if (chore && performers?.length > 0) {
const cards = [
{
size: 6,
icon: <PeopleAlt />,
title: t('choreView.assignment'),
text: `${t('choreView.assigned')}: ${
performers.find(p => p.userId === chore.assignedTo)?.displayName ||
t('choreView.na')
}`,
subtext: ` ${t('choreView.last')}: ${
chore.lastCompletedDate
? performers.find(p => p.userId === chore.lastCompletedBy)
?.displayName
: 'N/A'
}`,
},
{
size: 6,
icon: <CalendarMonth />,
title: t('choreView.schedule'),
text: `${t('choreView.due')}: ${
chore.nextDueDate
? moment(chore.nextDueDate).fromNow()
: t('choreView.na')
}`,
subtext: `${t('choreView.last')}: ${
chore.lastCompletedDate
? moment(chore.lastCompletedDate).fromNow()
: t('choreView.na')
}`,
subtext2:
chore.deadlineOffset > 0 && chore.nextDueDate
? `Deadline: ${moment(chore.nextDueDate).add(chore.deadlineOffset, 'seconds').fromNow()}`
: null,
},
{
size: 6,
icon: <Checklist />,
title: t('choreView.statistics'),
text: `${t('choreView.completed')}: ${completionCount} ${t('choreView.times')}`,
},
{
size: 6,
icon: <Person />,
title: t('choreView.details'),
subtext: `${t('choreView.createdBy')}: ${
performers.find(p => p.userId === chore.createdBy)?.displayName ||
t('choreView.na')
}`,
},
]
setInfoCards(cards)
}
}, [chore, performers, completionCount, t])
const handleUpdatePriority = priority => {
UpdateChorePriority(choreId, priority.value).then(response => {
if (response.ok) {
response.json().then(() => {
setChorePriority(priority)
queryClient.invalidateQueries(['chores'])
})
}
})
}
const handleTaskCompletion = async () => {
try {
const resp = await MarkChoreComplete(
choreId,
impersonatedUser
? { completedBy: impersonatedUser.userId, note }
: { note },
completedDate,
null,
)
if (resp.ok) {
const data = await resp.json()
setNote(null)
setChore(data.res)
queryClient.invalidateQueries(['chores'])
const detailResp = await GetChoreDetailById(choreId)
if (detailResp.ok) {
const detailData = await detailResp.json()
setChore(detailData.res)
}
showSuccess({
title: t('choreView.taskCompleted'),
message: t('choreView.taskCompletedMessage'),
undoAction: async () => {
try {
const undoResponse = await UndoChoreAction(choreId)
if (undoResponse.ok) {
const detailResponse = await GetChoreDetailById(choreId)
if (detailResponse.ok) {
const detailData = await detailResponse.json()
setChore(detailData.res)
queryClient.invalidateQueries(['chores'])
}
showUndo({
title: t('choreView.undoSuccessful'),
message: t('choreView.taskCompletionUndone'),
})
} else {
throw new Error('Failed to undo')
}
} catch (error) {
showError({
title: t('choreView.undoFailed'),
message: t('choreView.undoFailedMessage'),
})
}
},
})
}
} catch (error) {
if (isNetworkError(error)) {
const cmdId = await commandQueue.enqueue(
CommandType.COMPLETE_CHORE,
choreId,
{
id: choreId,
body: impersonatedUser
? { completedBy: impersonatedUser.userId, note }
: { note },
completedDate: completedDate || null,
performer: null,
},
)
await offlineDB.savePendingHistory({
id: -Date.now(),
choreId: Number(choreId),
completedBy: impersonatedUser?.userId || userProfile?.id || 0,
performedAt: completedDate || new Date().toISOString(),
dueDate: chore.nextDueDate || null,
notes: note || null,
status: 1,
points: chore.points || 0,
pending: true,
})
queryClient.invalidateQueries({ queryKey: ['pendingCommands'] })
showSuccess({
message: "You're offline — completion will sync when back online",
undoAction: async () => {
await commandQueue.cancel(cmdId)
queryClient.invalidateQueries({ queryKey: ['pendingCommands'] })
},
})
} else {
showError({
title: t('choreView.undoFailed'),
message: error?.message || 'Unable to complete task',
})
}
}
}
const handleSkippingTask = async () => {
try {
const response = await SkipChore(choreId)
if (response.ok) {
const data = await response.json()
setChore(data.res)
queryClient.invalidateQueries(['chores'])
showSuccess({
message: t('choreView.skipTask'),
undoAction: async () => {
try {
const undoResponse = await UndoChoreAction(choreId)
if (undoResponse.ok) {
const detailResponse = await GetChoreDetailById(choreId)
if (detailResponse.ok) {
const detailData = await detailResponse.json()
setChore(detailData.res)
queryClient.invalidateQueries(['chores'])
}
showUndo({
title: t('choreView.undoSuccessful'),
message: t('choreView.taskSkipUndone'),
})
} else {
throw new Error('Failed to undo')
}
} catch (error) {
showError({
title: t('choreView.undoFailed'),
message: t('choreView.undoFailedMessage'),
})
}
},
})
}
} catch (error) {
if (isNetworkError(error)) {
const cmdId = await commandQueue.enqueue(
CommandType.SKIP_CHORE,
choreId,
{ id: choreId },
)
queryClient.invalidateQueries({ queryKey: ['pendingCommands'] })
showSuccess({
message: "You're offline — skip will sync when back online",
undoAction: async () => {
await commandQueue.cancel(cmdId)
queryClient.invalidateQueries({ queryKey: ['pendingCommands'] })
},
})
} else {
showError({
title: t('choreView.undoFailed'),
message: error?.message || 'Unable to skip task',
})
}
}
}
const handleChoreStart = () => {
const startedChore = { ...chore, status: ChoreStatus.ACTIVE }
startChore.mutate(choreId, {
onSuccess: data => {
const newChore = {
...chore,
...data.res,
}
setChore(newChore)
},
onError: async error => {
if (isNetworkError(error)) {
const previousStatus = chore.status
const cmdId = await commandQueue.enqueue(
CommandType.START_CHORE,
choreId,
{ id: choreId },
)
setChore(startedChore)
queryClient.invalidateQueries({ queryKey: ['pendingCommands'] })
showSuccess({
message: "You're offline — start will sync when back online",
undoAction: async () => {
await commandQueue.cancel(cmdId)
queryClient.invalidateQueries({ queryKey: ['pendingCommands'] })
setChore({ ...chore, status: previousStatus })
},
})
return
}
showError({
title: t('choreView.undoFailed'),
message: error?.message || 'Unable to start task',
})
},
})
}
const handleChorePause = () => {
const pausedChore = { ...chore, status: ChoreStatus.PAUSED }
pauseChore.mutate(choreId, {
onSuccess: data => {
const newChore = {
...chore,
...data.res,
}
setChore(newChore)
},
onError: async error => {
if (isNetworkError(error)) {
const previousStatus = chore.status
const cmdId = await commandQueue.enqueue(
CommandType.PAUSE_CHORE,
choreId,
{ id: choreId },
)
setChore(pausedChore)
queryClient.invalidateQueries({ queryKey: ['pendingCommands'] })
showSuccess({
message: "You're offline — pause will sync when back online",
undoAction: async () => {
await commandQueue.cancel(cmdId)
queryClient.invalidateQueries({ queryKey: ['pendingCommands'] })
setChore({ ...chore, status: previousStatus })
},
})
return
}
showError({
title: t('choreView.undoFailed'),
message: error?.message || 'Unable to pause task',
})
},
})
}
const handleResetTimer = () => {
setTimerActionConfig({
isOpen: true,
title: t('choreView.resetTimer'),
message: t('choreView.resetTimerConfirmation'),
confirmText: t('choreView.resetTimer'),
cancelText: t('common:cancel'),
onClose: confirmed => {
if (confirmed) {
resetChoreTimer.mutate(choreId, {
onSuccess: data => {
const newChore = {
...chore,
...data.res,
}
setChore(newChore)
},
})
}
setTimerActionConfig({})
},
})
}
const handleClearAllTime = () => {
setTimerActionConfig({
isOpen: true,
title: t('choreView.clearAllTimeRecords'),
message: t('choreView.clearAllTimeConfirmation'),
confirmText: t('choreView.clearAllTimeRecords'),
cancelText: t('common:cancel'),
onClose: async confirmed => {
if (confirmed) {
if (choreTimer?.res?.id) {
deleteTimeSession.mutate(
{ choreId, sessionId: choreTimer.res.id },
{
onSuccess: data => {
const newChore = {
...chore,
...data.res,
}
setChore(newChore)
},
},
)
}
}
setTimerActionConfig({})
},
})
}
const handleApproveChore = () => {
ApproveChore(choreId).then(response => {
if (response.ok) {
response.json().then(data => {
setChore(data.res)
queryClient.invalidateQueries(['chores'])
})
}
})
}
const handleRejectChore = () => {
RejectChore(choreId).then(response => {
if (response.ok) {
response.json().then(data => {
setChore(data.res)
queryClient.invalidateQueries(['chores'])
})
}
})
}
const handleUnarchiveChore = async () => {
try {
const response = await UnArchiveChore(choreId)
if (response.ok) {
await offlineDB.saveChores([{ ...chore, isActive: true }])
setChore({ ...chore, isActive: true })
queryClient.invalidateQueries(['chores'])
}
} catch (error) {
const isNetworkError = err =>
err instanceof TypeError && err.message === 'Failed to fetch'
if (isNetworkError(error)) {
const cmdId = await commandQueue.enqueue(
CommandType.UNARCHIVE_CHORE,
choreId,
{ id: choreId },
)
await offlineDB.saveChores([
{ ...chore, isActive: true, _pending: 'unarchive' },
])
setChore({ ...chore, isActive: true })
queryClient.invalidateQueries({ queryKey: ['pendingCommands'] })
showSuccess({
message: "You're offline — restore will sync when back online",
undoAction: async () => {
await commandQueue.cancel(cmdId)
await offlineDB.saveChores([{ ...chore, isActive: false }])
setChore({ ...chore, isActive: false })
queryClient.invalidateQueries({ queryKey: ['pendingCommands'] })
},
})
} else {
showError({
title: 'Failed to restore',
message: error.message || 'Unable to restore task',
})
}
}
}
// Check if the current user can approve/reject (admin, manager, or task owner)
const canApproveReject = () => {
if (!circleMembersData?.res || !chore) return false
const currentUser = circleMembersData.res.find(
member => member.userId === (impersonatedUser?.userId || userProfile?.id),
)
// User can approve/reject if they are:
// 1. Admin or manager of the circle
// 2. Owner/creator of the task
return (
currentUser?.role === 'admin' ||
currentUser?.role === 'manager' ||
chore.createdBy === (impersonatedUser?.userId || userProfile?.id)
)
}
if (isChoreLoading || isCircleMembersLoading) {
// while loading the chore or circle members, return a loading state
return <LoadingComponent />
}
return (
<Container
maxWidth='sm'
sx={{
display: 'flex',
flexDirection: 'column',
// space between :
justifyContent: 'space-between',
// max height of the container:
maxHeight: 'calc(100vh - 500px)',
}}
>
<Box
sx={{
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
alignItems: 'center',
textAlign: 'center',
mb: 1,
}}
>
<Box
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: 1,
mt: 1,
mb: 0.5,
}}
>
<Typography level='h3'>{chore.name}</Typography>
<PendingBadge commands={pendingCmds} />
</Box>
{chore.isActive === false && (
<Chip
startDecorator={<Archive />}
size='md'
color='warning'
sx={{ mb: 1 }}
>
{t('choreView.archive')}
</Chip>
)}
<Chip startDecorator={<CalendarMonth />} size='md' sx={{ mb: 1 }}>
{chore.nextDueDate
? `${t('choreView.due')} ${fmt.dateTime(chore.nextDueDate)}`
: t('choreView.na')}
</Chip>
<Box
sx={{
display: 'flex',
flexDirection: 'row',
justifyContent: 'center',
alignItems: 'center',
mb: 1,
flexWrap: 'wrap',
gap: 0.5,
}}
>
{chore?.labelsV2?.map((label, index) => (
<Chip
key={index}
sx={{
backgroundColor: label?.color,
color: getTextColorFromBackgroundColor(label?.color),
}}
>
{label?.name}
</Chip>
))}
{chore?.attachments?.length > 0 && (
<Chip
startDecorator={<AttachFile />}
size='md'
variant='soft'
color='neutral'
onClick={() => setAttachmentBrowserOpen(true)}
sx={{ cursor: 'pointer' }}
>
{chore.attachments.length}{' '}
{chore.attachments.length === 1 ? 'attachment' : 'attachments'}
</Chip>
)}
</Box>
</Box>
<Box>
<Grid
container
spacing={1}
sx={{
mb: 1,
}}
>
{[ChoreStatus.ACTIVE, ChoreStatus.PAUSED].includes(chore.status) && (
<Grid xs={12}>
<TimePassedCard
chore={chore}
handleAction={action => {
if (action === 'pause') {
handleChorePause()
} else if (
action === 'resume' &&
!notInCompletionWindow(chore)
) {
handleChoreStart()
}
}}
onShowDetails={() => navigate(`/chores/${choreId}/timer`)}
/>
</Grid>
)}
{infoCards.map((card, index) => (
<Grid xs={6} sm={6} key={index}>
<Card
variant='soft'
sx={{
borderRadius: 'md',
boxShadow: 1,
px: 2,
py: 1,
minHeight: 90,
height: '100%',
// change from space-between to start:
justifyContent: 'start',
}}
>
<CardContent>
<Box
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'start',
mb: 0.5,
}}
>
{card.icon}
<Typography
level='body-md'
sx={{
ml: 1,
fontWeight: '500',
color: 'text.primary',
}}
>
{card.title}
</Typography>
</Box>
<Box>
<Typography
level='body-sm'
sx={{ color: 'text.secondary', lineHeight: 1.5 }}
>
{card.text}
</Typography>
<Typography
level='body-sm'
sx={{ color: 'text.secondary', lineHeight: 1.5 }}
>
{card.subtext}
</Typography>
{card.subtext2 && (
<Typography
level='body-sm'
sx={{ color: 'danger.plainColor', lineHeight: 1.5 }}
>
{card.subtext2}
</Typography>
)}
</Box>
</CardContent>
</Card>
</Grid>
))}
</Grid>
<Box
sx={{
display: 'flex',
flexDirection: 'row',
gap: 1,
alignContent: 'center',
justifyContent: 'center',
mb: 1,
}}
>
<Dropdown>
<MenuButton
disabled={chore.isActive === false}
color={
chorePriority?.name === 'P1'
? 'danger'
: chorePriority?.name === 'P2'
? 'warning'
: 'neutral'
}
sx={{
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
p: 1,
width: '100%',
}}
variant='plain'
>
{chorePriority ? chorePriority.icon : <LowPriority />}
{chorePriority ? chorePriority.name : t('choreView.noPriority')}
</MenuButton>
<Menu>
{Priorities.map((priority, index) => (
<MenuItem
sx={{
pr: 1,
py: 1,
}}
key={index}
onClick={() => {
handleUpdatePriority(priority)
}}
color={priority.color}
>
{priority.icon}
{priority.name}
</MenuItem>
))}
<Divider />
<MenuItem
sx={{
pr: 1,
py: 1,
}}
onClick={() => {
handleUpdatePriority({
name: t('choreView.noPriority'),
value: 0,
})
setChorePriority(null)
}}
>
{t('choreView.noPriority')}
</MenuItem>
</Menu>
</Dropdown>
<Button
size='sm'
color='neutral'
variant='plain'
fullWidth
onClick={() => {
navigate(`/chores/${choreId}/history`)
}}
sx={{
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
p: 1,
}}
>
<History />
{t('choreView.history')}
</Button>
<Button
size='sm'
color='neutral'
variant='plain'
fullWidth
sx={{
// top right of the card:
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
p: 1,
}}
onClick={() => {
navigate(`/chores/${choreId}/edit`)
}}
>
<Edit />
Edit
</Button>
</Box>
{chore.description && (
<>
<Typography level='title-md' sx={{ mb: 1 }}>
{t('choreView.description')}
</Typography>
<Sheet
variant='plain'
sx={{
p: 2,
borderRadius: 'lg',
mb: 1,
cursor: 'pointer',
}}
onClick={() => {
setNoteViewerConfig({
isOpen: true,
title: t('choreView.descriptionTitle'),
content: chore.description,
onClose: () => setNoteViewerConfig({ isOpen: false }),
})
}}
>
<IconButton
variant='plain'
size='sm'
sx={{
position: 'absolute',
bottom: 5,
right: 5,
}}
>
<OpenInFull />
</IconButton>
<Box
sx={{
maxHeight: '100px',
overflow: 'hidden',
}}
>
{(() => {
const raw = chore.description || ''
const shouldRenderHtml = hasHtmlTags(raw)
return shouldRenderHtml ? (
<Box
sx={{
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
}}
dangerouslySetInnerHTML={{ __html: refreshSignedUrlsInHtml(raw) }}
/>
) : (
<Typography
level='body-md'
sx={{
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
}}
>
{decodeHtmlEntities(raw)}
</Typography>
)
})()}
</Box>
</Sheet>
</>
)}
{chore.notes && (
<>
<Typography level='title-md' sx={{ mb: 1 }}>
{t('choreView.previousNoteLabel')}
</Typography>
<Sheet
variant='plain'
sx={{
p: 2,
borderRadius: 'lg',
mb: 1,
cursor: 'pointer',
}}
onClick={() => {
setNoteViewerConfig({
isOpen: true,
title: t('choreView.previousNote'),
content: chore.notes,
onClose: () => setNoteViewerConfig({ isOpen: false }),
})
}}
>
<IconButton
variant='plain'
size='sm'
sx={{
position: 'absolute',
bottom: 5,
right: 5,
}}
>
<OpenInFull />
</IconButton>
<Box
sx={{
maxHeight: '100px',
overflow: 'hidden',
}}
>
{(() => {
const raw = chore.notes || ''
const shouldRenderHtml = hasHtmlTags(raw)
return shouldRenderHtml ? (
<Box
sx={{
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
}}
dangerouslySetInnerHTML={{ __html: refreshSignedUrlsInHtml(raw) }}
/>
) : (
<Typography
level='body-md'
sx={{
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
}}
>
{decodeHtmlEntities(raw)}
</Typography>
)
})()}
</Box>
</Sheet>
</>
)}
{chore.subTasks && chore.subTasks.length > 0 && (
<Box sx={{ p: 0, m: 0, mb: 2 }}>
<Typography level='title-md' sx={{ mb: 1 }}>
{t('choreView.subtasksLabel')}
</Typography>
<Sheet
variant='plain'
sx={{
borderRadius: 'lg',
p: 1,
overflow: 'auto',
// maxHeight: '100px',
}}
>
<SubTasks
editMode={false}
performers={performers}
tasks={chore.subTasks}
setTasks={tasks => {
setChore({
...chore,
subTasks: tasks,
})
}}
choreId={choreId}
/>
</Sheet>
</Box>
)}
</Box>
<Card
sx={{
p: 2,
borderRadius: 'md',
boxShadow: 'sm',
paddingBottom: getSafeBottomPadding(2, '8px'),
}}
variant='soft'
>
<Typography level='body-md' sx={{ mb: 1 }}>
{t('choreView.taskActions')}
</Typography>
<FormControl size='sm'>
<Checkbox
checked={note !== null}
size='lg'
disabled={chore.isActive === false}
onChange={e => {
if (e.target.checked) {
setNote('')
} else {
setNote(null)
}
}}
overlay
label={
<Typography
level='body-sm'
sx={{
display: 'flex',
alignItems: 'center',
}}
>
{t('choreView.addNote')}
</Typography>
}
/>
</FormControl>
{note !== null && (
<Box sx={{ mb: 1 }}>
<Typography level='body-sm' sx={{ mb: 1 }}>
{t('choreView.additionalNotes')}
</Typography>
<RichTextEditor
value={note || ''}
onChange={setNote}
entityType={'chore_completion_note'}
placeholder={t('choreView.notePlaceholder')}
/>
</Box>
)}
<FormControl size='sm'>
<Checkbox
checked={completedDate !== null}
size='lg'
disabled={chore.isActive === false}
onChange={e => {
if (e.target.checked) {
setCompletedDate(
moment(new Date()).format('YYYY-MM-DDTHH:00:00'),
)
} else {
setCompletedDate(null)
}
}}
overlay
sx={
{
// my: 1,
}
}
label={
<Typography
level='body-sm'
sx={{
// center vertically
display: 'flex',
alignItems: 'center',
}}
>
{t('choreView.setCustomCompletionTime')}
</Typography>
}
/>
</FormControl>
{completedDate !== null && (
<Input
sx={{ mt: 1, mb: 1.5, width: 300 }}
type='datetime-local'
value={completedDate}
onChange={e => {
setCompletedDate(e.target.value)
}}
/>
)}
{chore.isActive === false ? (
// Archived chore - only show unarchive button
<Box
sx={{
display: 'flex',
flexDirection: 'column',
gap: 1,
alignContent: 'center',
justifyContent: 'center',
}}
>
<Button
fullWidth
size='lg'
onClick={handleUnarchiveChore}
color='primary'
startDecorator={<Unarchive />}
>
{t('choreView.unarchive')}
</Button>
</Box>
) : (
// Active chore - show all normal actions
<Box
sx={{
display: 'flex',
flexDirection: 'column',
gap: 1,
alignContent: 'center',
justifyContent: 'center',
}}
>
<Box
sx={{
display: 'flex',
flexDirection: 'row',
gap: 1,
alignContent: 'center',
justifyContent: 'center',
mb: 1,
}}
>
{chore.status === 3 ? (
// Pending approval: Show approve/reject for admins/managers/owners, grayed out button for others
canApproveReject() ? (
<>
<Button
fullWidth
size='lg'
onClick={handleApproveChore}
color='success'
startDecorator={<ThumbUp />}
sx={{
flex: 1,
}}
>
{t('choreView.approve')}
</Button>
<Button
fullWidth
size='lg'
onClick={handleRejectChore}
color='danger'
startDecorator={<ThumbDown />}
sx={{
flex: 1,
}}
>
<Box>{t('choreView.reject')}</Box>
</Button>
</>
) : (
<Button
fullWidth
size='lg'
disabled={true}
color='neutral'
startDecorator={<HourglassEmpty />}
>
<Box>{t('choreView.pendingApproval')}</Box>
</Button>
)
) : (
// Normal completion flow
<>
<Button
fullWidth
size='lg'
onClick={handleTaskCompletion}
disabled={
notInCompletionWindow(chore) || chore.isActive === false
}
color='success'
startDecorator={<Check />}
sx={{
flex: 4,
}}
>
<Box>{t('choreView.markAsDone')}</Box>
</Button>
<Button
fullWidth
size='lg'
onClick={() => {
setConfirmModelConfig({
isOpen: true,
title: t('choreView.skipTask'),
message: t('choreView.skipTaskConfirmation'),
confirmText: t('choreView.skip'),
cancelText: t('choreView.cancel'),
onClose: confirmed => {
if (confirmed) {
handleSkippingTask()
}
setConfirmModelConfig({})
},
})
}}
disabled={
notInCompletionWindow(chore) || chore.isActive === false
}
startDecorator={<SwitchAccessShortcut />}
sx={{
flex: 1,
}}
>
<Box>{t('choreView.skip')}</Box>
</Button>
</>
)}
</Box>
{notInCompletionWindow(chore) && (
<Typography
level='body-sm'
sx={{ color: 'warning.plainColor', textAlign: 'center', mb: 1 }}
>
Available to complete starting{' '}
{moment(chore.nextDueDate)
.subtract(chore.completionWindow, 'hours')
.format('MM/DD/YYYY hh:mm A')}
</Typography>
)}
{/* Timer Button - Show split button when timer is active, regular button otherwise */}
{[ChoreStatus.ACTIVE, ChoreStatus.PAUSED].includes(chore.status) ? (
<TimerSplitButton
disabled={
(chore.status === ChoreStatus.PAUSED &&
notInCompletionWindow(chore)) ||
chore.isActive === false
}
chore={chore}
onAction={action => {
if (action === 'pause') {
handleChorePause()
} else if (action === 'resume') {
handleChoreStart()
}
}}
onShowDetails={() => navigate(`/chores/${choreId}/timer`)}
onResetTimer={handleResetTimer}
onClearAllTime={handleClearAllTime}
fullWidth
/>
) : chore.status === ChoreStatus.PENDING_APPROVAL ? (
<></>
) : (
<Button
size='lg'
onClick={() => {
handleChoreStart()
}}
variant='soft'
color='success'
disabled={
notInCompletionWindow(chore) || chore.isActive === false
}
startDecorator={<PlayArrow />}
sx={{
flex: 1,
}}
>
{t('choreView.start')}
</Button>
)}
</Box>
)}
<ConfirmationModal config={confirmModelConfig} />
<ConfirmationModal config={timerActionConfig} />
<NoteViewerModal config={noteViewerConfig} />
<AttachmentBrowserModal
choreId={choreId}
isOpen={attachmentBrowserOpen}
onClose={() => setAttachmentBrowserOpen(false)}
/>
</Card>
</Container>
)
}
export default ChoreView