Add More options to AddTaskModal to handle most of the things we have in editChore

This commit is contained in:
Mo Tarbin
2026-07-14 14:24:43 -04:00
parent 519b085dd2
commit 9c7cea23a7
2 changed files with 430 additions and 14 deletions

View File

@@ -25,6 +25,9 @@ import KeyboardShortcutHint from '../../components/common/KeyboardShortcutHint'
import { useDocumentScanner } from '../../hooks/useDocumentScanner'
import { localAIService } from '../../service/LocalAIService'
import { TASK_COLOR } from '../../utils/Colors'
import AdvancedOptionsSection, {
AdvancedOptionsTrigger,
} from './AdvancedOptionsSection'
import AssigneePickerField from './AssigneePickerField'
import AttachmentPickerField from './AttachmentPickerField'
import DueDatePickerField from './DueDatePickerField'
@@ -101,6 +104,11 @@ const TaskInput = ({ onChoreUpdate, isModalOpen, onClose }) => {
const [hasDescription, setHasDescription] = useState(false)
const [hasSubTasks, setHasSubTasks] = useState(false)
const [deadlineOffset, setDeadlineOffset] = useState(-1)
const [requireApproval, setRequireApproval] = useState(false)
const [completionWindow, setCompletionWindow] = useState(-1)
const [assignStrategy, setAssignStrategy] = useState('keep_last_assigned')
const [isPrivate, setIsPrivate] = useState(false)
const [showAdvanced, setShowAdvanced] = useState(false)
const [dueDateOnly, setDueDateOnly] = useState(null)
const [dueTime, setDueTime] = useState(null)
const [useCustomTime, setUseCustomTime] = useState(false)
@@ -599,6 +607,11 @@ const TaskInput = ({ onChoreUpdate, isModalOpen, onClose }) => {
setAssignees([])
setProjectId(getInitialProject())
setDeadlineOffset(-1)
setRequireApproval(false)
setCompletionWindow(-1)
setAssignStrategy('keep_last_assigned')
setIsPrivate(false)
setShowAdvanced(false)
setDueDateOnly(null)
setDueTime(null)
setUseCustomTime(false)
@@ -610,26 +623,22 @@ const TaskInput = ({ onChoreUpdate, isModalOpen, onClose }) => {
// Handle different assignee scenarios
let finalAssignees = assignees
let finalAssignedTo = null
let finalAssignStrategy = 'random'
let finalAssignStrategy = assignStrategy
if (isAnyoneTask) {
// @Anyone was explicitly used - anyone can do the task
finalAssignees = []
finalAssignedTo = null
finalAssignStrategy = 'no_assignee'
} else if (assignees.length === 0) {
// No assignees and no @Anyone - fallback to current user
finalAssignees = [{ userId: userProfile?.id }]
finalAssignedTo = userProfile?.id
finalAssignStrategy = 'keep_last_assigned'
finalAssignStrategy = assignStrategy
} else if (assignees.length === 1) {
// Single assignee
finalAssignedTo = assignees[0].userId
finalAssignStrategy = 'keep_last_assigned'
finalAssignStrategy = assignStrategy
} else {
// Multiple assignees
finalAssignedTo = null
finalAssignStrategy = 'random'
finalAssignStrategy = assignStrategy
}
const chore = {
@@ -644,6 +653,10 @@ const TaskInput = ({ onChoreUpdate, isModalOpen, onClose }) => {
priority: priority ? Number(priority) : 0,
points: points > -1 ? points : null,
deadlineOffset: deadlineOffset < 0 ? null : deadlineOffset,
completionWindow:
completionWindow < 0 || !dueDate ? null : completionWindow,
requireApproval: requireApproval,
isPrivate: isPrivate,
status: 0,
frequencyType: 'once',
frequencyMetadata: {},
@@ -715,7 +728,6 @@ const TaskInput = ({ onChoreUpdate, isModalOpen, onClose }) => {
footer={
<Box
sx={{
marginTop: 2,
display: 'flex',
flexDirection: 'row',
justifyContent: 'end',
@@ -960,41 +972,84 @@ const TaskInput = ({ onChoreUpdate, isModalOpen, onClose }) => {
/>
</Box>
<Box mt={2} sx={{ display: 'flex', flexDirection: 'row', gap: 1 }}>
<Box
sx={{
mt: 1,
display: 'flex',
flexDirection: 'row',
gap: 1.5,
flexWrap: 'wrap',
alignItems: 'center',
}}
>
{!hasDescription && (
<Button
startDecorator={<Add />}
size='sm'
variant='outlined'
color='neutral'
size='md'
startDecorator={<Add sx={{ fontSize: 18 }} />}
onClick={() => setHasDescription(true)}
endDecorator={
showKeyboardShortcuts && (
<KeyboardShortcutHint shortcut='E' />
)
}
sx={{ borderRadius: '128px', minHeight: 40, px: 1.25 }}
>
Description
</Button>
)}
{!hasSubTasks && (
<Button
startDecorator={<Add />}
size='sm'
variant='outlined'
color='neutral'
size='md'
startDecorator={<Add sx={{ fontSize: 18 }} />}
onClick={() => setHasSubTasks(true)}
endDecorator={
showKeyboardShortcuts && (
<KeyboardShortcutHint shortcut='J' />
)
}
sx={{ borderRadius: '128px', minHeight: 40, px: 1.25 }}
>
Subtasks
</Button>
)}
<AdvancedOptionsTrigger
open={showAdvanced}
onToggle={() => setShowAdvanced(v => !v)}
activeCount={
[
points > -1,
requireApproval,
completionWindow > -1,
deadlineOffset > -1,
].filter(Boolean).length
}
emptyDisplay={pickerEmptyDisplay}
/>
</Box>
<AdvancedOptionsSection
open={showAdvanced}
points={points}
onPointsChange={setPoints}
requireApproval={requireApproval}
onRequireApprovalChange={setRequireApproval}
completionWindow={completionWindow}
onCompletionWindowChange={setCompletionWindow}
deadlineOffset={deadlineOffset}
onDeadlineOffsetChange={setDeadlineOffset}
assignStrategy={assignStrategy}
onAssignStrategyChange={setAssignStrategy}
hasDueDate={!!dueDate}
hasMultipleAssignees={assignees.length > 1}
hasAssignees={assignees.length > 0}
isPrivate={isPrivate}
onIsPrivateChange={setIsPrivate}
/>
{hasDescription && (
<Box>
<Typography level='body-sm'>Description:</Typography>

View File

@@ -0,0 +1,361 @@
import {
Add,
Approval,
HourglassTop,
Lock,
MoreHoriz,
People,
Remove,
Timer,
} from '@mui/icons-material'
import {
Box,
IconButton,
Input,
Option,
Select,
Switch,
Typography,
} from '@mui/joy'
const STRATEGY_OPTIONS = [
{ value: 'keep_last_assigned', label: 'Keep same assignee' },
{ value: 'random', label: 'Random' },
{ value: 'least_completed', label: 'Least completed' },
{ value: 'round_robin', label: 'Round robin' },
]
const FieldRow = ({ label, description, children }) => (
<Box
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 2,
py: 0.75,
}}
>
<Box sx={{ minWidth: 0, flex: '1 1 auto' }}>
<Typography level='body-sm' fontWeight='md'>
{label}
</Typography>
{description && (
<Typography level='body-xs' textColor='text.tertiary' sx={{ mt: 0.25 }}>
{description}
</Typography>
)}
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flexShrink: 0 }}>
{children}
</Box>
</Box>
)
// Trigger button — place this inside the chip/action row
export const AdvancedOptionsTrigger = ({
open,
onToggle,
activeCount = 0,
emptyDisplay = 'icon-text',
}) => {
const showLabel = emptyDisplay === 'icon-text' || open || activeCount > 0
return (
<Box
sx={{ position: 'relative', display: 'inline-flex', alignItems: 'center' }}
>
<Box
component='button'
onClick={onToggle}
sx={{
display: 'inline-flex',
alignItems: 'center',
gap: showLabel ? 1 : 0,
border: '1px solid',
borderColor:
open || activeCount > 0
? 'primary.outlinedBorder'
: 'neutral.outlinedBorder',
bgcolor:
open || activeCount > 0 ? 'primary.softBg' : 'transparent',
color:
open || activeCount > 0 ? 'primary.softColor' : 'text.secondary',
borderRadius: '128px',
minHeight: 40,
px: showLabel ? 1.5 : 0.75,
cursor: 'pointer',
transition: 'all 0.2s ease-in-out',
'&:hover': {
bgcolor:
open || activeCount > 0
? 'primary.softHoverBg'
: 'neutral.softHoverBg',
borderColor:
open || activeCount > 0
? 'primary.outlinedHoverBorder'
: 'neutral.outlinedHoverBorder',
},
}}
>
<MoreHoriz sx={{ fontSize: 20 }} />
<Typography
level='body-sm'
sx={{
color: 'inherit',
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
maxWidth: showLabel ? 120 : 0,
opacity: showLabel ? 1 : 0,
transform: showLabel ? 'translateX(0)' : 'translateX(-4px)',
transition:
'max-width 0.25s ease-in-out, opacity 0.2s ease-in-out, transform 0.25s ease-in-out',
}}
>
More
</Typography>
</Box>
{activeCount > 0 && (
<Box
sx={{
position: 'absolute',
top: -6,
right: -8,
width: 16,
height: 16,
borderRadius: '50%',
bgcolor: 'primary.solidBg',
color: 'primary.solidColor',
fontSize: 10,
fontWeight: 700,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
pointerEvents: 'none',
}}
>
{activeCount}
</Box>
)}
</Box>
)
}
// Panel — place this as a sibling below the description/subtask sections
const AdvancedOptionsSection = ({
open,
points,
onPointsChange,
requireApproval,
onRequireApprovalChange,
completionWindow,
onCompletionWindowChange,
deadlineOffset,
onDeadlineOffsetChange,
assignStrategy,
onAssignStrategyChange,
isPrivate,
onIsPrivateChange,
hasDueDate,
hasMultipleAssignees,
hasAssignees,
}) => {
const displayPoints = points <= 0 ? 0 : points
const handleDecrement = () => {
const next = Math.max(0, displayPoints - 1)
onPointsChange(next === 0 ? -1 : next)
}
const handleIncrement = () => {
onPointsChange(displayPoints + 1)
}
const handlePointsInput = e => {
const v = parseInt(e.target.value)
if (isNaN(v) || v <= 0) {
onPointsChange(-1)
} else {
onPointsChange(Math.min(v, 9999))
}
}
return (
<Box
sx={{
display: 'grid',
gridTemplateRows: open ? '1fr' : '0fr',
opacity: open ? 1 : 0,
transition:
'grid-template-rows 0.25s ease-in-out, opacity 0.2s ease-in-out',
}}
>
<Box sx={{ overflow: 'hidden' }}>
<Box
sx={{
mt: 2,
px: 2,
py: 1,
borderRadius: 'md',
border: '1px solid',
borderColor: 'neutral.outlinedBorder',
bgcolor: 'background.level1',
display: 'flex',
flexDirection: 'column',
}}
>
{/* Points */}
<FieldRow
label='Points'
description='Award points for completing this task'
>
<IconButton
size='sm'
variant='outlined'
color='neutral'
onClick={handleDecrement}
disabled={displayPoints === 0}
>
<Remove sx={{ fontSize: 16 }} />
</IconButton>
<Input
type='number'
size='sm'
value={displayPoints === 0 ? '' : displayPoints}
placeholder='0'
onChange={handlePointsInput}
sx={{ width: 64 }}
slotProps={{
input: {
min: 0,
max: 9999,
style: { textAlign: 'center' },
},
}}
/>
<IconButton
size='sm'
variant='outlined'
color='neutral'
onClick={handleIncrement}
>
<Add sx={{ fontSize: 16 }} />
</IconButton>
</FieldRow>
{/* Require approval */}
<FieldRow
label='Require approval'
description='Task needs admin sign-off before it can be closed'
>
<Switch
size='sm'
checked={requireApproval}
onChange={e => onRequireApprovalChange(e.target.checked)}
/>
</FieldRow>
{/* Privacy */}
<FieldRow
label='Limited visibility'
description={
!hasAssignees
? 'Assign someone to enable limited visibility'
: 'Only you and assignees can see this task'
}
>
<Switch
size='sm'
checked={isPrivate}
disabled={!hasAssignees}
onChange={e => onIsPrivateChange(e.target.checked)}
/>
</FieldRow>
{/* Assignment strategy — only shown when there are multiple assignees */}
{hasMultipleAssignees && (
<FieldRow
label='Assign strategy'
description='How to pick the next assignee each recurrence'
>
<Select
size='sm'
value={assignStrategy}
onChange={(_, v) => onAssignStrategyChange(v)}
sx={{ minWidth: 190 }}
>
{STRATEGY_OPTIONS.map(opt => (
<Option key={opt.value} value={opt.value}>
{opt.label}
</Option>
))}
</Select>
</FieldRow>
)}
{/* Completion window and deadline — only when due date set */}
{hasDueDate ? (
<>
<FieldRow
label='Available from'
description='Hours before the due date the task becomes available'
>
<Input
type='number'
size='sm'
placeholder='—'
value={completionWindow > -1 ? completionWindow : ''}
onChange={e => {
const v = parseInt(e.target.value)
onCompletionWindowChange(isNaN(v) ? -1 : Math.max(0, v))
}}
endDecorator={
<Typography level='body-xs' textColor='text.tertiary'>
hrs
</Typography>
}
sx={{ width: 96 }}
slotProps={{ input: { min: 0 } }}
/>
</FieldRow>
<FieldRow
label='Expires after'
description='Hours after the due date when the task can no longer be completed'
>
<Input
type='number'
size='sm'
placeholder='—'
value={deadlineOffset > -1 ? deadlineOffset : ''}
onChange={e => {
const v = parseInt(e.target.value)
onDeadlineOffsetChange(isNaN(v) ? -1 : Math.max(0, v))
}}
endDecorator={
<Typography level='body-xs' textColor='text.tertiary'>
hrs
</Typography>
}
sx={{ width: 96 }}
slotProps={{ input: { min: 0 } }}
/>
</FieldRow>
</>
) : (
<Typography
level='body-xs'
textColor='text.tertiary'
sx={{ my: 0.5, fontStyle: 'italic' }}
>
Set a due date to configure completion window and deadline.
</Typography>
)}
</Box>
</Box>
</Box>
)
}
export default AdvancedOptionsSection