1355 lines
41 KiB
JavaScript
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('<', '<')
|
|
.replaceAll('>', '>')
|
|
.replaceAll('"', '"')
|
|
.replaceAll(''', "'")
|
|
.replaceAll('&', '&')
|
|
}
|
|
|
|
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
|