Enhance project management UI and handle bulk changes (#186)
* enable hold to select, remove once x1 chip, unify reschedule modal * project title still show fromwhen login from different account it's was cache in local storage so makdikngsure we do clean up * Fix: show the button for creating task on the modal instead of the panel * Bulk moving project, Move the button in scanner to the modal * - Handle bulk change for projects - allow no assignee being selected - choreactionmenu become modal on mobile
This commit is contained in:
120
src/hooks/useLongPress.js
Normal file
120
src/hooks/useLongPress.js
Normal file
@@ -0,0 +1,120 @@
|
||||
import { useCallback, useEffect, useRef } from 'react'
|
||||
|
||||
const DEFAULT_DELAY_MS = 450
|
||||
// Deliberately smaller than the swipe list's swipeStartThreshold (10px) so the
|
||||
// hold is abandoned before a swipe is even recognized.
|
||||
const MOVE_TOLERANCE_PX = 6
|
||||
|
||||
const haptic = async () => {
|
||||
try {
|
||||
const { Haptics, ImpactStyle } = await import('@capacitor/haptics')
|
||||
await Haptics.impact({ style: ImpactStyle.Medium })
|
||||
} catch {
|
||||
// no haptics on this platform
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Press-and-hold gesture that works for both touch and mouse.
|
||||
*
|
||||
* Returns `handlers` to spread on the element and a `cancel` function so the
|
||||
* owner can abandon a pending hold when another gesture wins (e.g. the swipe
|
||||
* list reports a swipe start). A press that drifts more than
|
||||
* MOVE_TOLERANCE_PX, scrolls, or gets cancelled by the browser never fires,
|
||||
* and the click that follows a successful hold is swallowed so the element's
|
||||
* normal click action doesn't also run.
|
||||
*/
|
||||
export const useLongPress = (
|
||||
onLongPress,
|
||||
{ delay = DEFAULT_DELAY_MS, enabled = true } = {},
|
||||
) => {
|
||||
const timerRef = useRef(null)
|
||||
const originRef = useRef(null)
|
||||
const firedRef = useRef(false)
|
||||
|
||||
const clear = useCallback(() => {
|
||||
if (timerRef.current) {
|
||||
clearTimeout(timerRef.current)
|
||||
timerRef.current = null
|
||||
}
|
||||
originRef.current = null
|
||||
}, [])
|
||||
|
||||
// Watch movement on the window rather than only on the element: while the
|
||||
// swipe list drags the row it translates under the finger, and the pointer
|
||||
// can end up over a different element than the one we started on.
|
||||
useEffect(() => {
|
||||
const handleWindowMove = event => {
|
||||
if (!timerRef.current || !originRef.current) return
|
||||
const point = event.touches?.[0] ?? event
|
||||
if (point.clientX === undefined) return
|
||||
const dx = Math.abs(point.clientX - originRef.current.x)
|
||||
const dy = Math.abs(point.clientY - originRef.current.y)
|
||||
if (dx > MOVE_TOLERANCE_PX || dy > MOVE_TOLERANCE_PX) {
|
||||
clear()
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('pointermove', handleWindowMove, {
|
||||
capture: true,
|
||||
passive: true,
|
||||
})
|
||||
window.addEventListener('touchmove', handleWindowMove, {
|
||||
capture: true,
|
||||
passive: true,
|
||||
})
|
||||
window.addEventListener('scroll', clear, { capture: true, passive: true })
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('pointermove', handleWindowMove, true)
|
||||
window.removeEventListener('touchmove', handleWindowMove, true)
|
||||
window.removeEventListener('scroll', clear, true)
|
||||
clear()
|
||||
}
|
||||
}, [clear])
|
||||
|
||||
const start = useCallback(
|
||||
event => {
|
||||
if (!enabled || !onLongPress) return
|
||||
// Ignore right/middle mouse buttons
|
||||
if (event.pointerType === 'mouse' && event.button !== 0) return
|
||||
|
||||
clear()
|
||||
firedRef.current = false
|
||||
originRef.current = { x: event.clientX, y: event.clientY }
|
||||
timerRef.current = setTimeout(() => {
|
||||
firedRef.current = true
|
||||
timerRef.current = null
|
||||
haptic()
|
||||
onLongPress(event)
|
||||
}, delay)
|
||||
},
|
||||
[enabled, onLongPress, delay, clear],
|
||||
)
|
||||
|
||||
const handlers = {
|
||||
onPointerDown: start,
|
||||
onPointerUp: clear,
|
||||
onPointerCancel: clear,
|
||||
onPointerLeave: clear,
|
||||
onDragStart: clear,
|
||||
// Swallow the click that the browser fires after the finger lifts
|
||||
onClickCapture: event => {
|
||||
if (firedRef.current) {
|
||||
firedRef.current = false
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
}
|
||||
},
|
||||
onContextMenu: event => {
|
||||
// A touch long-press otherwise pops the native context menu on top
|
||||
if (firedRef.current) {
|
||||
event.preventDefault()
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
return { handlers, cancel: clear }
|
||||
}
|
||||
|
||||
export default useLongPress
|
||||
@@ -67,6 +67,24 @@ const isNetworkError = error =>
|
||||
((error instanceof TypeError && error.message === 'Failed to fetch') ||
|
||||
error?.name === 'AbortError')
|
||||
|
||||
// The backend returns { error: "..." } on failures. Surface that message when
|
||||
// it is there, flagged so callers can tell it apart from our generic fallback.
|
||||
const errorFromResponse = async (resp, fallbackMessage) => {
|
||||
if (!resp) return new Error(fallbackMessage)
|
||||
let serverMessage = null
|
||||
try {
|
||||
const body = await resp.json()
|
||||
if (typeof body?.error === 'string' && body.error.trim() !== '') {
|
||||
serverMessage = body.error
|
||||
}
|
||||
} catch {
|
||||
// body was empty or not JSON, keep the fallback
|
||||
}
|
||||
const error = new Error(serverMessage || fallbackMessage)
|
||||
error.isServerMessage = Boolean(serverMessage)
|
||||
return error
|
||||
}
|
||||
|
||||
const buildOfflineChore = task => ({
|
||||
...task,
|
||||
id: 'temp_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9),
|
||||
@@ -201,7 +219,7 @@ export const useCreateChore = () => {
|
||||
try {
|
||||
const resp = await CreateChore(newTask)
|
||||
if (!resp || !resp.ok) {
|
||||
throw new Error('Failed to create chore')
|
||||
throw await errorFromResponse(resp, 'Failed to create chore')
|
||||
}
|
||||
const createdChore = await resp.json()
|
||||
if (!createdChore) {
|
||||
@@ -254,7 +272,7 @@ export const useUpdateChore = () => {
|
||||
try {
|
||||
const resp = await SaveChore(updatedChore)
|
||||
if (!resp || !resp.ok) {
|
||||
throw new Error('Failed to save chore')
|
||||
throw await errorFromResponse(resp, 'Failed to save chore')
|
||||
}
|
||||
const updatedChoreRes = await resp.json()
|
||||
if (!updatedChoreRes) {
|
||||
|
||||
@@ -415,7 +415,9 @@ const ChoreEdit = () => {
|
||||
console.error('Failed to save chore:', error)
|
||||
showError({
|
||||
title: 'Save Failed',
|
||||
message: 'Failed to save chore, please try again.',
|
||||
message: error?.isServerMessage
|
||||
? error.message
|
||||
: 'Failed to save chore, please try again.',
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -634,13 +636,14 @@ const ChoreEdit = () => {
|
||||
if (anyone || assignableTo.length === 0) {
|
||||
setAssignStrategy('no_assignee')
|
||||
setAssignedTo(null)
|
||||
} else {
|
||||
if (!assignableTo.some(a => a.userId === assignedTo)) {
|
||||
setAssignedTo(assignableTo[0].userId)
|
||||
}
|
||||
if (assignStrategy === 'no_assignee') {
|
||||
setAssignStrategy(ASSIGN_STRATEGIES[2]) // default to least_completed
|
||||
} else if (assignStrategy === 'no_assignee') {
|
||||
// user explicitly picked no_assignee while having assignees, keep it
|
||||
// but there is nobody currently assigned
|
||||
if (assignedTo !== null) {
|
||||
setAssignedTo(null)
|
||||
}
|
||||
} else if (!assignableTo.some(a => a.userId === assignedTo)) {
|
||||
setAssignedTo(assignableTo[0].userId)
|
||||
}
|
||||
}, [assignStrategy, assignedTo, assignableTo, anyone])
|
||||
|
||||
@@ -1259,7 +1262,12 @@ const ChoreEdit = () => {
|
||||
|
||||
{!anyone && assignableTo.length > 1 && (
|
||||
<>
|
||||
<Box mb={3}>
|
||||
<Box
|
||||
mb={3}
|
||||
sx={{
|
||||
display: assignStrategy === 'no_assignee' ? 'none' : 'block',
|
||||
}}
|
||||
>
|
||||
<Typography level='h4'>Currently Assigned To</Typography>
|
||||
<Typography level='body-md'>
|
||||
Who is assigned the next due?
|
||||
|
||||
@@ -432,6 +432,16 @@ const ArchivedTasks = () => {
|
||||
setSelectedChores(newSelection)
|
||||
}
|
||||
|
||||
// Press-and-hold on a task card enters multi-select with that task picked
|
||||
const enterMultiSelectWithChore = choreId => {
|
||||
if (!isMultiSelectMode) {
|
||||
setIsMultiSelectMode(true)
|
||||
setSelectedChores(new Set([choreId]))
|
||||
return
|
||||
}
|
||||
toggleChoreSelection(choreId)
|
||||
}
|
||||
|
||||
const selectAllVisibleChores = () => {
|
||||
if (finalChores.length > 0) {
|
||||
setSelectedChores(new Set(finalChores.map(c => c.id)))
|
||||
@@ -1051,6 +1061,7 @@ const ArchivedTasks = () => {
|
||||
isMultiSelectMode={isMultiSelectMode}
|
||||
selectedChores={selectedChores}
|
||||
toggleChoreSelection={toggleChoreSelection}
|
||||
onLongPressChore={enterMultiSelectWithChore}
|
||||
/>
|
||||
</List>
|
||||
</Box>
|
||||
|
||||
@@ -108,27 +108,29 @@ const ChoreCard = ({
|
||||
{getDueDateChipText(chore.nextDueDate, chore, timeFormat)}
|
||||
</Chip>
|
||||
|
||||
<Chip
|
||||
variant='soft'
|
||||
sx={{
|
||||
position: 'relative',
|
||||
top: 10,
|
||||
zIndex: 3,
|
||||
ml: 0.4,
|
||||
left: 10,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
{!['once', 'no_repeat'].includes(chore.frequencyType) && (
|
||||
<Chip
|
||||
variant='soft'
|
||||
sx={{
|
||||
position: 'relative',
|
||||
top: 10,
|
||||
zIndex: 3,
|
||||
ml: 0.4,
|
||||
left: 10,
|
||||
}}
|
||||
>
|
||||
{getFrequencyIcon(chore)}
|
||||
{getRecurrentChipText(chore)}
|
||||
</div>
|
||||
</Chip>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
{getFrequencyIcon(chore)}
|
||||
{getRecurrentChipText(chore)}
|
||||
</div>
|
||||
</Chip>
|
||||
)}
|
||||
|
||||
<Box sx={{ position: 'absolute', top: 10, right: 10, zIndex: 3 }}>
|
||||
<PendingBadge commands={pendingCmds} />
|
||||
|
||||
@@ -18,9 +18,60 @@ import {
|
||||
} from '@mui/icons-material'
|
||||
import { Box, Typography } from '@mui/joy'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useLongPress } from '../../hooks/useLongPress'
|
||||
import ChoreCard from './ChoreCard'
|
||||
import CompactChoreCard from './CompactChoreCard'
|
||||
|
||||
/**
|
||||
* One swipeable row. Owns the press-and-hold gesture (multi-select), which
|
||||
* can't live in the render loop because it needs a hook.
|
||||
*/
|
||||
const ChoreSwipeableItem = ({
|
||||
trailingActions,
|
||||
onClick,
|
||||
onLongPress,
|
||||
longPressEnabled,
|
||||
children,
|
||||
// SwipeableList clones its children to inject list-level config
|
||||
// (listType, fullSwipe, thresholds…), so it has to be passed through.
|
||||
...listProps
|
||||
}) => {
|
||||
const { handlers: longPressHandlers, cancel: cancelLongPress } = useLongPress(
|
||||
onLongPress,
|
||||
{ enabled: longPressEnabled },
|
||||
)
|
||||
|
||||
// The swipe list owns the gesture the moment it recognizes a drag — a hold
|
||||
// that turned into a swipe must not also open multi-select.
|
||||
const handleSwipeStart = () => {
|
||||
cancelLongPress()
|
||||
}
|
||||
|
||||
return (
|
||||
<SwipeableListItem
|
||||
{...listProps}
|
||||
trailingActions={trailingActions}
|
||||
onClick={onClick}
|
||||
onSwipeStart={handleSwipeStart}
|
||||
onSwipeProgress={cancelLongPress}
|
||||
>
|
||||
<Box
|
||||
{...longPressHandlers}
|
||||
sx={{
|
||||
width: '100%',
|
||||
// Keep a long press from selecting the task text / popping the
|
||||
// native callout on mobile
|
||||
userSelect: 'none',
|
||||
WebkitUserSelect: 'none',
|
||||
WebkitTouchCallout: 'none',
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</Box>
|
||||
</SwipeableListItem>
|
||||
)
|
||||
}
|
||||
|
||||
const ChoreListView = ({
|
||||
chores,
|
||||
viewMode,
|
||||
@@ -34,6 +85,7 @@ const ChoreListView = ({
|
||||
userProfile,
|
||||
isOfficialInstance,
|
||||
toggleMultiSelectMode,
|
||||
onLongPressChore,
|
||||
showActions = true,
|
||||
}) => {
|
||||
const navigate = useNavigate()
|
||||
@@ -248,7 +300,7 @@ const ChoreListView = ({
|
||||
return (
|
||||
<SwipeableList type={ListType.IOS} fullSwipe={false}>
|
||||
{chores.map(chore => (
|
||||
<SwipeableListItem
|
||||
<ChoreSwipeableItem
|
||||
key={chore.id}
|
||||
trailingActions={getTrailingActions(chore)}
|
||||
onClick={() => {
|
||||
@@ -258,9 +310,11 @@ const ChoreListView = ({
|
||||
navigate(`/chores/${chore.id}`)
|
||||
}
|
||||
}}
|
||||
longPressEnabled={Boolean(onLongPressChore)}
|
||||
onLongPress={() => onLongPressChore?.(chore.id)}
|
||||
>
|
||||
{renderChoreCard(chore)}
|
||||
</SwipeableListItem>
|
||||
</ChoreSwipeableItem>
|
||||
))}
|
||||
</SwipeableList>
|
||||
)
|
||||
|
||||
@@ -84,7 +84,9 @@ const CompactChoreCard = ({
|
||||
const parts = []
|
||||
|
||||
// Frequency
|
||||
parts.push(getRecurrentChipText(chore))
|
||||
if (!['once', 'no_repeat'].includes(chore.frequencyType)) {
|
||||
parts.push(getRecurrentChipText(chore))
|
||||
}
|
||||
|
||||
// Assignee
|
||||
if (chore.assignedTo) {
|
||||
@@ -408,7 +410,8 @@ const CompactChoreCard = ({
|
||||
|
||||
{/* Line 2: Metadata */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.25 }}>
|
||||
{getFrequencyIcon(chore)}
|
||||
{!['once', 'no_repeat'].includes(chore.frequencyType) &&
|
||||
getFrequencyIcon(chore)}
|
||||
<Typography
|
||||
level='body-xs'
|
||||
color='text.secondary'
|
||||
|
||||
@@ -121,7 +121,7 @@ const MyChores = () => {
|
||||
const [confirmModelConfig, setConfirmModelConfig] = useState({})
|
||||
|
||||
const { selectedProject, projectsWithDefault, setSelectedProjectWithCache } =
|
||||
useProjectFilter(projects)
|
||||
useProjectFilter(projects, !projectsLoading)
|
||||
|
||||
const {
|
||||
searchTerm,
|
||||
@@ -143,6 +143,7 @@ const MyChores = () => {
|
||||
selectedChores,
|
||||
toggleMultiSelectMode,
|
||||
toggleChoreSelection,
|
||||
enterMultiSelectWithChore,
|
||||
selectAllVisibleChores,
|
||||
clearSelection,
|
||||
getSelectedChoresData,
|
||||
@@ -366,6 +367,7 @@ const MyChores = () => {
|
||||
}
|
||||
|
||||
processEffectAsync()
|
||||
// throw new Error('Fake Error to test posthog')
|
||||
}
|
||||
}, [
|
||||
membersLoading,
|
||||
@@ -570,6 +572,7 @@ const MyChores = () => {
|
||||
handleBulkArchive,
|
||||
handleBulkDelete,
|
||||
handleBulkSkip,
|
||||
handleBulkMoveToProject,
|
||||
} = useChoreActions({
|
||||
chores,
|
||||
filteredChores,
|
||||
@@ -865,8 +868,8 @@ const MyChores = () => {
|
||||
[getFilteredChores],
|
||||
)
|
||||
|
||||
const updateChores = newChore => {
|
||||
let newChores = [...chores, newChore]
|
||||
const appendChore = (prev, newChore) => {
|
||||
let newChores = [...prev, newChore]
|
||||
|
||||
if (impersonatedUser) {
|
||||
newChores = newChores.filter(
|
||||
@@ -874,8 +877,15 @@ const MyChores = () => {
|
||||
)
|
||||
}
|
||||
|
||||
setChores(newChores)
|
||||
setFilteredChores(newChores)
|
||||
return newChores
|
||||
}
|
||||
|
||||
// Uses functional setState so back-to-back calls (e.g. creating several
|
||||
// voice-captured tasks in a row) each build on the latest state instead of
|
||||
// a closure snapshot taken before earlier calls landed.
|
||||
const updateChores = newChore => {
|
||||
setChores(prev => appendChore(prev, newChore))
|
||||
setFilteredChores(prev => appendChore(prev, newChore))
|
||||
clearQuickFilters()
|
||||
}
|
||||
|
||||
@@ -1046,12 +1056,22 @@ const MyChores = () => {
|
||||
<MultiSelectToolbar
|
||||
isVisible={isMultiSelectMode}
|
||||
selectedCount={selectedChores.size}
|
||||
onSelectAll={selectAllVisibleChores}
|
||||
onSelectAll={() =>
|
||||
selectAllVisibleChores(
|
||||
searchTerm?.length > 0 || hasQuickFilters || activeFilterId
|
||||
? getFilteredChores
|
||||
: null,
|
||||
choreSections,
|
||||
openChoreSections,
|
||||
)
|
||||
}
|
||||
onClear={clearSelection}
|
||||
onComplete={handleBulkComplete}
|
||||
onSkip={handleBulkSkip}
|
||||
onArchive={handleBulkArchive}
|
||||
onDelete={handleBulkDelete}
|
||||
onMoveToProject={handleBulkMoveToProject}
|
||||
projects={projects}
|
||||
showKeyboardShortcuts={showKeyboardShortcuts}
|
||||
selectAllDisabled={
|
||||
searchTerm?.length > 0 || hasQuickFilters
|
||||
@@ -1116,6 +1136,7 @@ const MyChores = () => {
|
||||
isMultiSelectMode={isMultiSelectMode}
|
||||
selectedChores={selectedChores}
|
||||
toggleChoreSelection={toggleChoreSelection}
|
||||
onLongPressChore={enterMultiSelectWithChore}
|
||||
/>
|
||||
)}
|
||||
{viewMode === 'calendar' && (
|
||||
@@ -1293,6 +1314,7 @@ const MyChores = () => {
|
||||
isMultiSelectMode={isMultiSelectMode}
|
||||
selectedChores={selectedChores}
|
||||
toggleChoreSelection={toggleChoreSelection}
|
||||
onLongPressChore={enterMultiSelectWithChore}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
@@ -1373,6 +1395,7 @@ const MyChores = () => {
|
||||
isMultiSelectMode={isMultiSelectMode}
|
||||
selectedChores={selectedChores}
|
||||
toggleChoreSelection={toggleChoreSelection}
|
||||
onLongPressChore={enterMultiSelectWithChore}
|
||||
/>
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { Capacitor } from '@capacitor/core'
|
||||
import DateModal from '../../Modals/Inputs/DateModal'
|
||||
import DueDatePickerModal, {
|
||||
combineDueDate,
|
||||
splitDueDate,
|
||||
} from '../../components/DueDatePickerModal'
|
||||
import NudgeModal from '../../Modals/Inputs/NudgeModal'
|
||||
import SelectModal from '../../Modals/Inputs/SelectModal'
|
||||
import TextModal from '../../Modals/Inputs/TextModal'
|
||||
@@ -24,13 +28,16 @@ const ChoreModals = ({
|
||||
return (
|
||||
<>
|
||||
{activeModal === 'changeDueDate' && modalChore && (
|
||||
<DateModal
|
||||
isOpen={true}
|
||||
<DueDatePickerModal
|
||||
open={true}
|
||||
key={'changeDueDate' + modalChore.id}
|
||||
current={modalChore.nextDueDate}
|
||||
title='Change due date'
|
||||
{...splitDueDate(modalChore.nextDueDate)}
|
||||
onClose={onClose}
|
||||
onSave={onChangeDueDate}
|
||||
onApply={parts =>
|
||||
onChangeDueDate(combineDueDate(parts)?.toISOString() ?? null)
|
||||
}
|
||||
onRemove={() => onChangeDueDate(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -5,11 +5,39 @@ import {
|
||||
Close,
|
||||
Delete,
|
||||
Done,
|
||||
DriveFileMove,
|
||||
SelectAll,
|
||||
SkipNext,
|
||||
} from '@mui/icons-material'
|
||||
import { Box, Button, Divider, Typography } from '@mui/joy'
|
||||
import {
|
||||
Avatar,
|
||||
Box,
|
||||
Button,
|
||||
Divider,
|
||||
ListItemContent,
|
||||
ListItemDecorator,
|
||||
Menu,
|
||||
MenuItem,
|
||||
Typography,
|
||||
} from '@mui/joy'
|
||||
import { useRef, useState } from 'react'
|
||||
import KeyboardShortcutHint from '../../../components/common/KeyboardShortcutHint'
|
||||
import LABEL_COLORS, {
|
||||
getTextColorFromBackgroundColor,
|
||||
} from '../../../utils/Colors'
|
||||
import { getIconComponent } from '../../../utils/ProjectIcons'
|
||||
|
||||
const renderProjectAvatar = (color, icon) => {
|
||||
const bg = color || LABEL_COLORS[0].value
|
||||
const IconComponent = getIconComponent(icon || 'FolderOpen')
|
||||
return (
|
||||
<Avatar size='sm' sx={{ width: 22, height: 22, backgroundColor: bg }}>
|
||||
<IconComponent
|
||||
sx={{ fontSize: 13, color: getTextColorFromBackgroundColor(bg) }}
|
||||
/>
|
||||
</Avatar>
|
||||
)
|
||||
}
|
||||
|
||||
const MultiSelectToolbar = ({
|
||||
isVisible,
|
||||
@@ -20,9 +48,21 @@ const MultiSelectToolbar = ({
|
||||
onSkip,
|
||||
onArchive,
|
||||
onDelete,
|
||||
onMoveToProject,
|
||||
projects = [],
|
||||
showKeyboardShortcuts,
|
||||
selectAllDisabled,
|
||||
}) => {
|
||||
const [projectMenuAnchor, setProjectMenuAnchor] = useState(null)
|
||||
const projectMenuRef = useRef(null)
|
||||
|
||||
const closeProjectMenu = () => setProjectMenuAnchor(null)
|
||||
|
||||
const handleMoveToProject = project => {
|
||||
closeProjectMenu()
|
||||
onMoveToProject?.(project)
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
@@ -216,6 +256,63 @@ const MultiSelectToolbar = ({
|
||||
/>
|
||||
)}
|
||||
</Button>
|
||||
{onMoveToProject && (
|
||||
<>
|
||||
<Button
|
||||
size='sm'
|
||||
variant='soft'
|
||||
color='neutral'
|
||||
ref={projectMenuRef}
|
||||
onClick={() =>
|
||||
setProjectMenuAnchor(prev =>
|
||||
prev ? null : projectMenuRef.current,
|
||||
)
|
||||
}
|
||||
startDecorator={<DriveFileMove />}
|
||||
disabled={selectedCount === 0}
|
||||
sx={{
|
||||
'--Button-paddingInline': { xs: '0.75rem', sm: '1rem' },
|
||||
}}
|
||||
title='Move selected tasks to a project'
|
||||
>
|
||||
Move
|
||||
</Button>
|
||||
<Menu
|
||||
size='md'
|
||||
anchorEl={projectMenuAnchor}
|
||||
open={Boolean(projectMenuAnchor)}
|
||||
onClose={closeProjectMenu}
|
||||
placement='bottom-end'
|
||||
>
|
||||
<MenuItem
|
||||
onClick={() =>
|
||||
handleMoveToProject({ id: null, name: 'Default Project' })
|
||||
}
|
||||
>
|
||||
<ListItemDecorator>
|
||||
{renderProjectAvatar(LABEL_COLORS[0].value, 'FolderOpen')}
|
||||
</ListItemDecorator>
|
||||
<ListItemContent>
|
||||
<Typography level='body-sm'>Default Project</Typography>
|
||||
</ListItemContent>
|
||||
</MenuItem>
|
||||
{projects.map(project => (
|
||||
<MenuItem
|
||||
key={project.id}
|
||||
onClick={() => handleMoveToProject(project)}
|
||||
>
|
||||
<ListItemDecorator>
|
||||
{renderProjectAvatar(project.color, project.icon)}
|
||||
</ListItemDecorator>
|
||||
<ListItemContent>
|
||||
<Typography level='body-sm'>{project.name}</Typography>
|
||||
</ListItemContent>
|
||||
</MenuItem>
|
||||
))}
|
||||
</Menu>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Button
|
||||
size='sm'
|
||||
variant='soft'
|
||||
|
||||
@@ -1155,6 +1155,61 @@ export const useChoreActions = ({
|
||||
setConfirmModelConfig,
|
||||
])
|
||||
|
||||
const handleBulkMoveToProject = useCallback(
|
||||
async project => {
|
||||
const selectedData = getSelectedChoresData(chores)
|
||||
if (selectedData.length === 0) return
|
||||
|
||||
const projectId = project?.id ?? null
|
||||
const movedTasks = []
|
||||
const failedTasks = []
|
||||
|
||||
for (const chore of selectedData) {
|
||||
try {
|
||||
const response = await SaveChore({ ...chore, projectId })
|
||||
if (response.ok) {
|
||||
movedTasks.push(chore)
|
||||
} else {
|
||||
failedTasks.push(chore)
|
||||
}
|
||||
} catch (error) {
|
||||
failedTasks.push(chore)
|
||||
}
|
||||
}
|
||||
|
||||
if (movedTasks.length > 0) {
|
||||
const movedIds = new Set(movedTasks.map(c => c.id))
|
||||
const applyMove = list =>
|
||||
list.map(c => (movedIds.has(c.id) ? { ...c, projectId } : c))
|
||||
setChores(applyMove)
|
||||
setFilteredChores(applyMove)
|
||||
showSuccess({
|
||||
title: 'Tasks Moved',
|
||||
message: `Moved ${movedTasks.length} task${movedTasks.length > 1 ? 's' : ''} to ${project?.name || 'Default Project'}.`,
|
||||
})
|
||||
}
|
||||
if (failedTasks.length > 0) {
|
||||
showError({
|
||||
title: 'Some Tasks Failed',
|
||||
message: `${failedTasks.length} task${failedTasks.length > 1 ? 's' : ''} could not be moved.`,
|
||||
})
|
||||
}
|
||||
|
||||
refetchChores()
|
||||
clearSelection()
|
||||
},
|
||||
[
|
||||
chores,
|
||||
getSelectedChoresData,
|
||||
setChores,
|
||||
setFilteredChores,
|
||||
showSuccess,
|
||||
showError,
|
||||
refetchChores,
|
||||
clearSelection,
|
||||
],
|
||||
)
|
||||
|
||||
return {
|
||||
handleChoreAction,
|
||||
handleChangeDueDate,
|
||||
@@ -1166,5 +1221,6 @@ export const useChoreActions = ({
|
||||
handleBulkArchive,
|
||||
handleBulkDelete,
|
||||
handleBulkSkip,
|
||||
handleBulkMoveToProject,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,6 +26,20 @@ export const useMultiSelect = () => {
|
||||
[selectedChores],
|
||||
)
|
||||
|
||||
// Entry point for press-and-hold on a task card: turn multi-select on (if it
|
||||
// isn't already) with that task selected.
|
||||
const enterMultiSelectWithChore = useCallback(
|
||||
choreId => {
|
||||
if (!isMultiSelectMode) {
|
||||
setIsMultiSelectMode(true)
|
||||
setSelectedChores(new Set([choreId]))
|
||||
return
|
||||
}
|
||||
toggleChoreSelection(choreId)
|
||||
},
|
||||
[isMultiSelectMode, toggleChoreSelection],
|
||||
)
|
||||
|
||||
const selectAllVisibleChores = useCallback(
|
||||
(visibleChores, choreSections = [], openChoreSections = {}) => {
|
||||
let choresToSelect = []
|
||||
@@ -42,7 +56,9 @@ export const useMultiSelect = () => {
|
||||
expandedChores.every(chore => selectedChores.has(chore.id))
|
||||
|
||||
if (allExpandedSelected) {
|
||||
choresToSelect = choreSections.flatMap(section => section.content || [])
|
||||
choresToSelect = choreSections.flatMap(
|
||||
section => section.content || [],
|
||||
)
|
||||
} else {
|
||||
choresToSelect = expandedChores
|
||||
}
|
||||
@@ -80,6 +96,7 @@ export const useMultiSelect = () => {
|
||||
selectedChores,
|
||||
toggleMultiSelectMode,
|
||||
toggleChoreSelection,
|
||||
enterMultiSelectWithChore,
|
||||
selectAllVisibleChores,
|
||||
clearSelection,
|
||||
getSelectedChoresData,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
|
||||
export const useProjectFilter = projects => {
|
||||
export const useProjectFilter = (projects, projectsLoaded = false) => {
|
||||
const [selectedProject, setSelectedProject] = useState(() => {
|
||||
const saved = localStorage.getItem('selectedProject')
|
||||
return saved ? JSON.parse(saved) : null
|
||||
@@ -37,6 +37,20 @@ export const useProjectFilter = projects => {
|
||||
window.history.replaceState({}, '', newUrl)
|
||||
}, [])
|
||||
|
||||
// The cached selection is a whole project object, so a stale one keeps
|
||||
// rendering its old name/color even though the project no longer belongs to
|
||||
// this account (deleted project, or a different user signing in on a device
|
||||
// where logout didn't get to clear storage). Once the real list has loaded,
|
||||
// drop any selection that isn't in it.
|
||||
useEffect(() => {
|
||||
if (!projectsLoaded) return
|
||||
if (!selectedProject || selectedProject.id === 'default') return
|
||||
|
||||
if (!projects.some(p => p.id === selectedProject.id)) {
|
||||
setSelectedProjectWithCache(null)
|
||||
}
|
||||
}, [projectsLoaded, projects, selectedProject, setSelectedProjectWithCache])
|
||||
|
||||
return {
|
||||
selectedProject,
|
||||
projectsWithDefault,
|
||||
|
||||
@@ -222,7 +222,10 @@ const ProjectView = () => {
|
||||
const { data: userProfile } = useUserProfile()
|
||||
const { data: chores = { res: [] } } = useChores(false) // false to exclude archived
|
||||
const { data: projectsData = [], isLoading: projectsLoading } = useProjects()
|
||||
const { setSelectedProjectWithCache } = useProjectFilter(projectsData)
|
||||
const { setSelectedProjectWithCache } = useProjectFilter(
|
||||
projectsData,
|
||||
!projectsLoading,
|
||||
)
|
||||
const navigate = useNavigate()
|
||||
|
||||
const [userProjects, setUserProjects] = useState([])
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useMediaQuery } from '@mui/material'
|
||||
import * as chrono from 'chrono-node'
|
||||
import moment from 'moment'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useResponsiveModal } from '../../hooks/useResponsiveModal'
|
||||
import { useCreateChore } from '../../queries/ChoreQueries'
|
||||
import { useCircleMembers, useUserProfile } from '../../queries/UserQueries'
|
||||
@@ -43,7 +43,7 @@ import RepeatPickerField from './RepeatPickerField'
|
||||
import RichTextEditor from './RichTextEditor'
|
||||
import ScanPanel from './ScanToTask/ScanPanel'
|
||||
import SubTasks from './SubTask'
|
||||
import { buildChorePayload } from './VoiceToTask/parseVoiceTask'
|
||||
import { buildChorePayload, parseVoiceTask } from './VoiceToTask/parseVoiceTask'
|
||||
import VoicePanel from './VoiceToTask/VoicePanel'
|
||||
const getDefaultNotification = () => {
|
||||
const storedDefault = localStorage.getItem('defaultNotificationTemplate')
|
||||
@@ -76,6 +76,11 @@ const TaskInput = ({ onChoreUpdate, isModalOpen, onClose, initialMode }) => {
|
||||
|
||||
const { data: userProfile } = useUserProfile()
|
||||
|
||||
// Stable identities for the voice panel: these queries are undefined while
|
||||
// loading, and a fresh [] each render would churn the panel's parse context
|
||||
const voiceLabels = useMemo(() => userLabels || [], [userLabels])
|
||||
const voiceMembers = useMemo(() => circleMembers?.res || [], [circleMembers])
|
||||
|
||||
const handleCreateLabel = useCallback(
|
||||
name => {
|
||||
const color =
|
||||
@@ -145,6 +150,19 @@ const TaskInput = ({ onChoreUpdate, isModalOpen, onClose, initialMode }) => {
|
||||
const [llmAvailable, setLlmAvailable] = useState(false)
|
||||
const [showVoice, setShowVoice] = useState(false)
|
||||
const [voiceAvailable, setVoiceAvailable] = useState(false)
|
||||
// Voice capture state, reported up by VoicePanel so the modal footer owns
|
||||
// the confirm action instead of the panel having its own button row
|
||||
const [voiceState, setVoiceState] = useState({
|
||||
segments: [],
|
||||
isListening: false,
|
||||
})
|
||||
const [creatingVoiceTasks, setCreatingVoiceTasks] = useState(false)
|
||||
// Same arrangement for the scan panel: it reports the action for its
|
||||
// current phase and the modal footer renders it
|
||||
const [scanState, setScanState] = useState({
|
||||
phase: 'idle',
|
||||
primaryAction: null,
|
||||
})
|
||||
const { isNativeScanner } = useDocumentScanner()
|
||||
|
||||
useEffect(() => {
|
||||
@@ -671,6 +689,7 @@ const TaskInput = ({ onChoreUpdate, isModalOpen, onClose, initialMode }) => {
|
||||
// Multiple voice-captured tasks: they were reviewed as cards in the panel,
|
||||
// so create them all directly.
|
||||
const handleVoiceCreateMany = async parsedTasks => {
|
||||
setCreatingVoiceTasks(true)
|
||||
const notificationTemplates = getDefaultNotification()
|
||||
for (const parsed of parsedTasks) {
|
||||
const chore = buildChorePayload(parsed, {
|
||||
@@ -694,13 +713,40 @@ const TaskInput = ({ onChoreUpdate, isModalOpen, onClose, initialMode }) => {
|
||||
console.error('Error creating voice task:', error)
|
||||
}
|
||||
}
|
||||
setCreatingVoiceTasks(false)
|
||||
handleCloseModal(false)
|
||||
}
|
||||
|
||||
// Footer confirm while the voice panel is open: one task lands in the smart
|
||||
// input for review, several are created straight away.
|
||||
const handleVoiceConfirm = () => {
|
||||
const { segments } = voiceState
|
||||
if (segments.length === 1) {
|
||||
handleVoiceSingle(segments[0].text, segments[0].overrides || {})
|
||||
} else if (segments.length > 1) {
|
||||
// Parse only at confirm time — the cards already parse for their own
|
||||
// display, so there's no need to keep a parsed copy in modal state
|
||||
const parseCtx = {
|
||||
userLabels: voiceLabels,
|
||||
members: voiceMembers,
|
||||
currentUserId: userProfile?.id,
|
||||
}
|
||||
handleVoiceCreateMany(
|
||||
segments.map(segment => ({
|
||||
...parseVoiceTask(segment.text, parseCtx),
|
||||
...(segment.overrides || {}),
|
||||
})),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCloseModal = forceRefetch => {
|
||||
onClose(forceRefetch)
|
||||
setShowScan(false)
|
||||
setShowVoice(false)
|
||||
setVoiceState({ segments: [], isListening: false })
|
||||
setScanState({ phase: 'idle', primaryAction: null })
|
||||
setCreatingVoiceTasks(false)
|
||||
setTaskText('')
|
||||
setTaskTitle('')
|
||||
setDueDate(null)
|
||||
@@ -847,7 +893,38 @@ const TaskInput = ({ onChoreUpdate, isModalOpen, onClose, initialMode }) => {
|
||||
/>
|
||||
)}
|
||||
</Button>
|
||||
{/* Sub-panels (voice/scan) own their own confirm action */}
|
||||
{showVoice && (
|
||||
<Button
|
||||
variant='solid'
|
||||
color='primary'
|
||||
loading={creatingVoiceTasks}
|
||||
disabled={
|
||||
voiceState.segments.length === 0 || voiceState.isListening
|
||||
}
|
||||
onClick={handleVoiceConfirm}
|
||||
>
|
||||
{creatingVoiceTasks
|
||||
? 'Creating…'
|
||||
: voiceState.segments.length > 1
|
||||
? `Create ${voiceState.segments.length} Tasks`
|
||||
: 'Use Task'}
|
||||
</Button>
|
||||
)}
|
||||
{showScan && scanState.primaryAction && (
|
||||
<Button
|
||||
variant='solid'
|
||||
color='primary'
|
||||
startDecorator={scanState.primaryAction.icon}
|
||||
onClick={scanState.primaryAction.onClick}
|
||||
>
|
||||
{scanState.primaryAction.label}
|
||||
</Button>
|
||||
)}
|
||||
{showScan && scanState.phase === 'processing' && (
|
||||
<Button variant='solid' color='primary' loading disabled>
|
||||
Processing
|
||||
</Button>
|
||||
)}
|
||||
{!showScan && !showVoice && (
|
||||
<Button
|
||||
variant='solid'
|
||||
@@ -1211,12 +1288,10 @@ const TaskInput = ({ onChoreUpdate, isModalOpen, onClose, initialMode }) => {
|
||||
|
||||
{showVoice && (
|
||||
<VoicePanel
|
||||
open
|
||||
userLabels={userLabels || []}
|
||||
members={circleMembers?.res || []}
|
||||
userLabels={voiceLabels}
|
||||
members={voiceMembers}
|
||||
userProfile={userProfile}
|
||||
onUseSingle={handleVoiceSingle}
|
||||
onCreateMany={handleVoiceCreateMany}
|
||||
onStateChange={setVoiceState}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -1226,10 +1301,12 @@ const TaskInput = ({ onChoreUpdate, isModalOpen, onClose, initialMode }) => {
|
||||
autoCapture={scanAutoCapture}
|
||||
onTaskExtracted={handleTaskExtracted}
|
||||
initialImageUrl={pendingPhotoUrl}
|
||||
onStateChange={setScanState}
|
||||
onClose={() => {
|
||||
setShowScan(false)
|
||||
setScanAutoCapture(false)
|
||||
setPendingPhotoUrl(null)
|
||||
setScanState({ phase: 'idle', primaryAction: null })
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -26,6 +26,9 @@ import {
|
||||
Avatar,
|
||||
Divider,
|
||||
IconButton,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemButton,
|
||||
ListItemContent,
|
||||
ListItemDecorator,
|
||||
Menu,
|
||||
@@ -33,8 +36,10 @@ import {
|
||||
Tooltip,
|
||||
Typography,
|
||||
} from '@mui/joy'
|
||||
import { useMediaQuery } from '@mui/material'
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import AppModal from '../../components/common/AppModal'
|
||||
import LABEL_COLORS, {
|
||||
getTextColorFromBackgroundColor,
|
||||
} from '../../utils/Colors'
|
||||
@@ -64,6 +69,8 @@ const ChoreActionMenu = ({
|
||||
const menuRef = React.useRef(null)
|
||||
const navigate = useNavigate()
|
||||
const { data: projects = [] } = useProjects()
|
||||
// Phone-only condition (matches AddTaskModal.jsx) — tablets/desktop keep the Menu
|
||||
const isSmallScreen = useMediaQuery(theme => theme.breakpoints.down('sm'))
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
@@ -75,11 +82,19 @@ const ChoreActionMenu = ({
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (isSmallScreen) {
|
||||
// AppModal owns its own backdrop/escape close behavior on small screens.
|
||||
if (anchorEl && onOpen) {
|
||||
onOpen()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const handleMenuOutsideClick = event => {
|
||||
if (
|
||||
anchorEl &&
|
||||
!anchorEl.contains(event.target) &&
|
||||
!menuRef.current.contains(event.target)
|
||||
!menuRef.current?.contains(event.target)
|
||||
) {
|
||||
handleMenuClose()
|
||||
}
|
||||
@@ -92,7 +107,7 @@ const ChoreActionMenu = ({
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleMenuOutsideClick)
|
||||
}
|
||||
}, [anchorEl, onOpen])
|
||||
}, [anchorEl, onOpen, isSmallScreen])
|
||||
|
||||
const handleMenuOpen = event => {
|
||||
event.stopPropagation()
|
||||
@@ -229,6 +244,255 @@ const ChoreActionMenu = ({
|
||||
)
|
||||
}
|
||||
|
||||
// Shared action list, rendered as MenuItems on large screens and as a
|
||||
// ListItemButton list inside an AppModal sheet on small screens.
|
||||
const actionItems = [
|
||||
{
|
||||
key: 'completeNote',
|
||||
icon: <NoteAdd />,
|
||||
label: 'Complete with note',
|
||||
onClick: () => {
|
||||
onCompleteWithNote?.()
|
||||
handleMenuClose()
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'completePast',
|
||||
icon: <Update />,
|
||||
label: 'Complete in past',
|
||||
onClick: () => {
|
||||
onCompleteWithPastDate?.()
|
||||
handleMenuClose()
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'skip',
|
||||
icon: <SwitchAccessShortcut />,
|
||||
label: 'Skip to next due date',
|
||||
onClick: handleSkip,
|
||||
},
|
||||
{
|
||||
key: 'delegate',
|
||||
icon: <RecordVoiceOver />,
|
||||
label: 'Delegate to someone else',
|
||||
onClick: () => {
|
||||
onChangeAssignee?.()
|
||||
handleMenuClose()
|
||||
},
|
||||
},
|
||||
isOfficialInstance && {
|
||||
key: 'nudge',
|
||||
icon: <Notifications />,
|
||||
label: 'Send nudge',
|
||||
onClick: () => {
|
||||
onNudge?.()
|
||||
handleMenuClose()
|
||||
},
|
||||
},
|
||||
{ key: 'divider-1', type: 'divider' },
|
||||
{
|
||||
key: 'history',
|
||||
icon: <ManageSearch />,
|
||||
label: 'History',
|
||||
onClick: handleHistory,
|
||||
},
|
||||
{ key: 'divider-2', type: 'divider' },
|
||||
{ key: 'quickSchedule', type: 'quickSchedule' },
|
||||
{ key: 'divider-3', type: 'divider' },
|
||||
{
|
||||
key: 'changeDueDate',
|
||||
icon: <MoreTime />,
|
||||
label: 'Change due date',
|
||||
onClick: () => {
|
||||
onChangeDueDate?.()
|
||||
handleMenuClose()
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'writeNfc',
|
||||
icon: <Nfc />,
|
||||
label: 'Write to NFC',
|
||||
onClick: () => {
|
||||
onWriteNFC?.()
|
||||
handleMenuClose()
|
||||
},
|
||||
},
|
||||
{ key: 'edit', icon: <Edit />, label: 'Edit', onClick: handleEdit },
|
||||
{ key: 'clone', icon: <CopyAll />, label: 'Clone', onClick: handleClone },
|
||||
{ key: 'view', icon: <ViewCarousel />, label: 'View', onClick: handleView },
|
||||
{
|
||||
key: 'archive',
|
||||
icon: chore.isActive ? <Archive /> : <Unarchive />,
|
||||
label: chore.isActive ? 'Archive' : 'Unarchive',
|
||||
onClick: handleArchive,
|
||||
color: 'neutral',
|
||||
},
|
||||
projects.length > 0 && {
|
||||
key: 'moveToProject',
|
||||
icon: <DriveFileMove />,
|
||||
label: 'Move to project',
|
||||
onClick: () => setShowProjectPicker(true),
|
||||
},
|
||||
{ key: 'divider-4', type: 'divider' },
|
||||
{
|
||||
key: 'delete',
|
||||
icon: <Delete />,
|
||||
label: 'Delete',
|
||||
onClick: handleDelete,
|
||||
color: 'danger',
|
||||
},
|
||||
].filter(Boolean)
|
||||
|
||||
const quickScheduleButtons = (
|
||||
<>
|
||||
<Tooltip title='Today' placement='top'>
|
||||
<IconButton
|
||||
size='sm'
|
||||
onClick={e => {
|
||||
e.stopPropagation()
|
||||
handleQuickSchedule('today')
|
||||
}}
|
||||
>
|
||||
<Today />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title='Tomorrow' placement='top'>
|
||||
<IconButton
|
||||
size='sm'
|
||||
onClick={e => {
|
||||
e.stopPropagation()
|
||||
handleQuickSchedule('tomorrow')
|
||||
}}
|
||||
>
|
||||
<WbSunny />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title='Weekend' placement='top'>
|
||||
<IconButton
|
||||
size='sm'
|
||||
onClick={e => {
|
||||
e.stopPropagation()
|
||||
handleQuickSchedule('weekend')
|
||||
}}
|
||||
>
|
||||
<Weekend />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title='Next week' placement='top'>
|
||||
<IconButton
|
||||
size='sm'
|
||||
onClick={e => {
|
||||
e.stopPropagation()
|
||||
handleQuickSchedule('next-week')
|
||||
}}
|
||||
>
|
||||
<NextWeek />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title='Remove due date' placement='top'>
|
||||
<IconButton
|
||||
size='sm'
|
||||
color='neutral'
|
||||
onClick={e => {
|
||||
e.stopPropagation()
|
||||
handleQuickSchedule('remove')
|
||||
}}
|
||||
>
|
||||
<Cancel />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</>
|
||||
)
|
||||
|
||||
const quickScheduleRowSx = {
|
||||
display: 'flex',
|
||||
justifyContent: 'space-around',
|
||||
alignItems: 'center',
|
||||
gap: 1,
|
||||
px: 1.5,
|
||||
py: 1,
|
||||
}
|
||||
|
||||
const renderMenuActionItems = () =>
|
||||
actionItems.map(item => {
|
||||
if (item.type === 'divider') return <Divider key={item.key} />
|
||||
if (item.type === 'quickSchedule') {
|
||||
return (
|
||||
<MenuItem
|
||||
key={item.key}
|
||||
sx={{
|
||||
...quickScheduleRowSx,
|
||||
cursor: 'default',
|
||||
'&:hover': { backgroundColor: 'transparent' },
|
||||
}}
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
{quickScheduleButtons}
|
||||
</MenuItem>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<MenuItem
|
||||
key={item.key}
|
||||
color={item.color}
|
||||
onClick={e => {
|
||||
e.stopPropagation()
|
||||
item.onClick()
|
||||
}}
|
||||
>
|
||||
{item.icon}
|
||||
{item.label}
|
||||
</MenuItem>
|
||||
)
|
||||
})
|
||||
|
||||
const renderModalActionItems = () =>
|
||||
actionItems.map(item => {
|
||||
if (item.type === 'divider') return <Divider key={item.key} />
|
||||
if (item.type === 'quickSchedule') {
|
||||
return (
|
||||
<ListItem key={item.key} sx={quickScheduleRowSx}>
|
||||
{quickScheduleButtons}
|
||||
</ListItem>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<ListItem key={item.key}>
|
||||
<ListItemButton color={item.color} onClick={() => item.onClick()}>
|
||||
<ListItemDecorator>{item.icon}</ListItemDecorator>
|
||||
<ListItemContent>{item.label}</ListItemContent>
|
||||
</ListItemButton>
|
||||
</ListItem>
|
||||
)
|
||||
})
|
||||
|
||||
const renderModalProjectPicker = () => (
|
||||
<List>
|
||||
<ListItem>
|
||||
<ListItemButton
|
||||
onClick={() =>
|
||||
handleMoveToProject({ id: null, name: 'Default Project' })
|
||||
}
|
||||
>
|
||||
<ListItemDecorator>
|
||||
{renderProjectAvatar(LABEL_COLORS[0].value, 'FolderOpen')}
|
||||
</ListItemDecorator>
|
||||
<ListItemContent>Default Project</ListItemContent>
|
||||
</ListItemButton>
|
||||
</ListItem>
|
||||
{projects.map(project => (
|
||||
<ListItem key={project.id}>
|
||||
<ListItemButton onClick={() => handleMoveToProject(project)}>
|
||||
<ListItemDecorator>
|
||||
{renderProjectAvatar(project.color, project.icon)}
|
||||
</ListItemDecorator>
|
||||
<ListItemContent>{project.name}</ListItemContent>
|
||||
</ListItemButton>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<IconButton
|
||||
@@ -249,280 +513,95 @@ const ChoreActionMenu = ({
|
||||
<MoreVert />
|
||||
</IconButton>
|
||||
|
||||
<Menu
|
||||
size='md'
|
||||
ref={menuRef}
|
||||
anchorEl={anchorEl}
|
||||
open={Boolean(anchorEl)}
|
||||
onClose={handleMenuClose}
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: '100%',
|
||||
left: '50%',
|
||||
}}
|
||||
>
|
||||
{showProjectPicker ? (
|
||||
<>
|
||||
{isSmallScreen ? (
|
||||
<AppModal
|
||||
open={Boolean(anchorEl)}
|
||||
onClose={handleMenuClose}
|
||||
title={showProjectPicker ? 'Move to project' : chore?.name}
|
||||
mobilePresentation='sheet'
|
||||
showHandle
|
||||
contentSx={{ px: 0, pb: 1 }}
|
||||
>
|
||||
{showProjectPicker && (
|
||||
<MenuItem
|
||||
onClick={e => {
|
||||
e.stopPropagation()
|
||||
setShowProjectPicker(false)
|
||||
}}
|
||||
sx={{ gap: 1 }}
|
||||
onClick={() => setShowProjectPicker(false)}
|
||||
sx={{ gap: 1, mx: 2, mb: 1 }}
|
||||
>
|
||||
<ArrowBack fontSize='small' />
|
||||
<Typography level='body-sm' fontWeight={600}>
|
||||
Move to project
|
||||
Back
|
||||
</Typography>
|
||||
</MenuItem>
|
||||
<Divider />
|
||||
<MenuItem
|
||||
onClick={e => {
|
||||
e.stopPropagation()
|
||||
handleMoveToProject({ id: null, name: 'Default Project' })
|
||||
}}
|
||||
>
|
||||
<ListItemDecorator>
|
||||
{renderProjectAvatar(LABEL_COLORS[0].value, 'FolderOpen')}
|
||||
</ListItemDecorator>
|
||||
<ListItemContent>
|
||||
<Typography level='body-sm'>Default Project</Typography>
|
||||
</ListItemContent>
|
||||
</MenuItem>
|
||||
{projects.map(project => (
|
||||
)}
|
||||
<List sx={{ '--ListItem-radius': '8px', px: 1 }}>
|
||||
{showProjectPicker
|
||||
? renderModalProjectPicker()
|
||||
: renderModalActionItems()}
|
||||
</List>
|
||||
</AppModal>
|
||||
) : (
|
||||
<Menu
|
||||
size='md'
|
||||
ref={menuRef}
|
||||
anchorEl={anchorEl}
|
||||
open={Boolean(anchorEl)}
|
||||
onClose={handleMenuClose}
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: '100%',
|
||||
left: '50%',
|
||||
}}
|
||||
>
|
||||
{showProjectPicker ? (
|
||||
<>
|
||||
<MenuItem
|
||||
key={project.id}
|
||||
onClick={e => {
|
||||
e.stopPropagation()
|
||||
handleMoveToProject(project)
|
||||
setShowProjectPicker(false)
|
||||
}}
|
||||
sx={{ gap: 1 }}
|
||||
>
|
||||
<ArrowBack fontSize='small' />
|
||||
<Typography level='body-sm' fontWeight={600}>
|
||||
Move to project
|
||||
</Typography>
|
||||
</MenuItem>
|
||||
<Divider />
|
||||
<MenuItem
|
||||
onClick={e => {
|
||||
e.stopPropagation()
|
||||
handleMoveToProject({ id: null, name: 'Default Project' })
|
||||
}}
|
||||
>
|
||||
<ListItemDecorator>
|
||||
{renderProjectAvatar(project.color, project.icon)}
|
||||
{renderProjectAvatar(LABEL_COLORS[0].value, 'FolderOpen')}
|
||||
</ListItemDecorator>
|
||||
<ListItemContent>
|
||||
<Typography level='body-sm'>{project.name}</Typography>
|
||||
<Typography level='body-sm'>Default Project</Typography>
|
||||
</ListItemContent>
|
||||
</MenuItem>
|
||||
))}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<MenuItem
|
||||
onClick={e => {
|
||||
e.stopPropagation()
|
||||
onCompleteWithNote?.()
|
||||
handleMenuClose()
|
||||
}}
|
||||
>
|
||||
<NoteAdd />
|
||||
Complete with note
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
onClick={e => {
|
||||
e.stopPropagation()
|
||||
onCompleteWithPastDate?.()
|
||||
handleMenuClose()
|
||||
}}
|
||||
>
|
||||
<Update />
|
||||
Complete in past
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
onClick={e => {
|
||||
e.stopPropagation()
|
||||
handleSkip()
|
||||
}}
|
||||
>
|
||||
<SwitchAccessShortcut />
|
||||
Skip to next due date
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
onClick={e => {
|
||||
e.stopPropagation()
|
||||
onChangeAssignee?.()
|
||||
handleMenuClose()
|
||||
}}
|
||||
>
|
||||
<RecordVoiceOver />
|
||||
Delegate to someone else
|
||||
</MenuItem>
|
||||
{isOfficialInstance && (
|
||||
<MenuItem
|
||||
onClick={e => {
|
||||
e.stopPropagation()
|
||||
onNudge?.()
|
||||
handleMenuClose()
|
||||
}}
|
||||
>
|
||||
<Notifications />
|
||||
Send nudge
|
||||
</MenuItem>
|
||||
)}
|
||||
<Divider />
|
||||
<MenuItem
|
||||
onClick={e => {
|
||||
e.stopPropagation()
|
||||
handleHistory()
|
||||
}}
|
||||
>
|
||||
<ManageSearch />
|
||||
History
|
||||
</MenuItem>
|
||||
<Divider />
|
||||
<MenuItem
|
||||
sx={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-around',
|
||||
alignItems: 'center',
|
||||
gap: 1,
|
||||
cursor: 'default',
|
||||
'&:hover': {
|
||||
backgroundColor: 'transparent',
|
||||
},
|
||||
}}
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
<Tooltip title='Today' placement='top'>
|
||||
<IconButton
|
||||
size='sm'
|
||||
{projects.map(project => (
|
||||
<MenuItem
|
||||
key={project.id}
|
||||
onClick={e => {
|
||||
e.stopPropagation()
|
||||
handleQuickSchedule('today')
|
||||
handleMoveToProject(project)
|
||||
}}
|
||||
>
|
||||
<Today />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title='Tomorrow' placement='top'>
|
||||
<IconButton
|
||||
size='sm'
|
||||
onClick={e => {
|
||||
e.stopPropagation()
|
||||
handleQuickSchedule('tomorrow')
|
||||
}}
|
||||
>
|
||||
<WbSunny />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title='Weekend' placement='top'>
|
||||
<IconButton
|
||||
size='sm'
|
||||
onClick={e => {
|
||||
e.stopPropagation()
|
||||
handleQuickSchedule('weekend')
|
||||
}}
|
||||
>
|
||||
<Weekend />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title='Next week' placement='top'>
|
||||
<IconButton
|
||||
size='sm'
|
||||
onClick={e => {
|
||||
e.stopPropagation()
|
||||
handleQuickSchedule('next-week')
|
||||
}}
|
||||
>
|
||||
<NextWeek />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title='Remove due date' placement='top'>
|
||||
<IconButton
|
||||
size='sm'
|
||||
color='neutral'
|
||||
onClick={e => {
|
||||
e.stopPropagation()
|
||||
handleQuickSchedule('remove')
|
||||
}}
|
||||
>
|
||||
<Cancel />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</MenuItem>
|
||||
<Divider />
|
||||
<MenuItem
|
||||
onClick={e => {
|
||||
e.stopPropagation()
|
||||
onChangeDueDate?.()
|
||||
handleMenuClose()
|
||||
}}
|
||||
>
|
||||
<MoreTime />
|
||||
Change due date
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
onClick={e => {
|
||||
e.stopPropagation()
|
||||
onWriteNFC?.()
|
||||
handleMenuClose()
|
||||
}}
|
||||
>
|
||||
<Nfc />
|
||||
Write to NFC
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
onClick={e => {
|
||||
e.stopPropagation()
|
||||
handleEdit()
|
||||
}}
|
||||
>
|
||||
<Edit />
|
||||
Edit
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
onClick={e => {
|
||||
e.stopPropagation()
|
||||
handleClone()
|
||||
}}
|
||||
>
|
||||
<CopyAll />
|
||||
Clone
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
onClick={e => {
|
||||
e.stopPropagation()
|
||||
handleView()
|
||||
}}
|
||||
>
|
||||
<ViewCarousel />
|
||||
View
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
onClick={e => {
|
||||
e.stopPropagation()
|
||||
handleArchive()
|
||||
}}
|
||||
color='neutral'
|
||||
>
|
||||
{chore.isActive ? <Archive /> : <Unarchive />}
|
||||
{chore.isActive ? 'Archive' : 'Unarchive'}
|
||||
</MenuItem>
|
||||
{projects.length > 0 && (
|
||||
<MenuItem
|
||||
onClick={e => {
|
||||
e.stopPropagation()
|
||||
setShowProjectPicker(true)
|
||||
}}
|
||||
>
|
||||
<DriveFileMove />
|
||||
Move to project
|
||||
</MenuItem>
|
||||
)}
|
||||
<Divider />
|
||||
<MenuItem
|
||||
onClick={e => {
|
||||
e.stopPropagation()
|
||||
handleDelete()
|
||||
}}
|
||||
color='danger'
|
||||
>
|
||||
<Delete />
|
||||
Delete
|
||||
</MenuItem>
|
||||
</>
|
||||
)}
|
||||
</Menu>
|
||||
<ListItemDecorator>
|
||||
{renderProjectAvatar(project.color, project.icon)}
|
||||
</ListItemDecorator>
|
||||
<ListItemContent>
|
||||
<Typography level='body-sm'>{project.name}</Typography>
|
||||
</ListItemContent>
|
||||
</MenuItem>
|
||||
))}
|
||||
</>
|
||||
) : (
|
||||
renderMenuActionItems()
|
||||
)}
|
||||
</Menu>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,32 +1,8 @@
|
||||
import {
|
||||
Bedtime,
|
||||
CalendarMonth,
|
||||
Close,
|
||||
EventNote,
|
||||
LightMode,
|
||||
NextWeek,
|
||||
NightsStay,
|
||||
Today,
|
||||
WbSunny,
|
||||
WbTwilight,
|
||||
Weekend,
|
||||
} from '@mui/icons-material'
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Checkbox,
|
||||
IconButton,
|
||||
Input,
|
||||
List,
|
||||
ListItem,
|
||||
Typography,
|
||||
} from '@mui/joy'
|
||||
import { CalendarMonth, Close } from '@mui/icons-material'
|
||||
import { Box, Button, IconButton, Typography } from '@mui/joy'
|
||||
import moment from 'moment'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import Calendar from 'react-calendar'
|
||||
import ModalActions from '../../components/common/ModalActions'
|
||||
import { useLocalization } from '../../contexts/LocalizationContext'
|
||||
import { useResponsiveModal } from '../../hooks/useResponsiveModal'
|
||||
import { useMemo, useState } from 'react'
|
||||
import DueDatePickerModal from './DueDatePickerModal'
|
||||
|
||||
const DueDatePickerField = ({
|
||||
dueDateOnly,
|
||||
@@ -40,106 +16,17 @@ const DueDatePickerField = ({
|
||||
size = 'sm',
|
||||
}) => {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const { ResponsiveModal } = useResponsiveModal()
|
||||
const { firstDayOfWeek } = useLocalization()
|
||||
|
||||
// Local buffered state — only committed on Apply
|
||||
const [localDueDateOnly, setLocalDueDateOnly] = useState(dueDateOnly)
|
||||
const [localDueTime, setLocalDueTime] = useState(dueTime)
|
||||
const [localUseCustomTime, setLocalUseCustomTime] = useState(useCustomTime)
|
||||
|
||||
// Sync local state from props whenever the modal opens
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setLocalDueDateOnly(dueDateOnly)
|
||||
setLocalDueTime(dueTime)
|
||||
setLocalUseCustomTime(useCustomTime)
|
||||
}
|
||||
}, [isOpen, dueDateOnly, dueTime, useCustomTime])
|
||||
|
||||
const calendarType =
|
||||
firstDayOfWeek === 1
|
||||
? 'iso8601'
|
||||
: firstDayOfWeek === 6
|
||||
? 'islamic'
|
||||
: 'gregory'
|
||||
|
||||
const pillListSx = {
|
||||
'--List-gap': '8px',
|
||||
'--ListItem-radius': '20px',
|
||||
}
|
||||
|
||||
const getQuickScheduleDate = option => {
|
||||
const now = new Date()
|
||||
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate())
|
||||
|
||||
switch (option) {
|
||||
case 'today':
|
||||
return today
|
||||
case 'tomorrow': {
|
||||
const tomorrow = new Date(today)
|
||||
tomorrow.setDate(today.getDate() + 1)
|
||||
return tomorrow
|
||||
}
|
||||
case 'weekend': {
|
||||
const weekend = new Date(today)
|
||||
const daysUntilSaturday = (6 - today.getDay() + 7) % 7 || 7
|
||||
weekend.setDate(today.getDate() + daysUntilSaturday)
|
||||
return weekend
|
||||
}
|
||||
case 'next-week': {
|
||||
const nextWeek = new Date(today)
|
||||
const daysUntilMonday = (1 - today.getDay() + 7) % 7 || 7
|
||||
nextWeek.setDate(today.getDate() + daysUntilMonday)
|
||||
return nextWeek
|
||||
}
|
||||
case 'next-month': {
|
||||
const nextMonth = new Date(today)
|
||||
nextMonth.setMonth(today.getMonth() + 1)
|
||||
return nextMonth
|
||||
}
|
||||
default:
|
||||
return today
|
||||
}
|
||||
}
|
||||
|
||||
const handleQuickSchedule = option => {
|
||||
const date = getQuickScheduleDate(option)
|
||||
setLocalDueDateOnly(date.toISOString().split('T')[0])
|
||||
}
|
||||
|
||||
const handleQuickTime = timeStr => {
|
||||
// Tap the active chip again to deselect it
|
||||
if (localUseCustomTime && localDueTime === timeStr) {
|
||||
setLocalUseCustomTime(false)
|
||||
setLocalDueTime(null)
|
||||
return
|
||||
}
|
||||
if (!localDueDateOnly) {
|
||||
setLocalDueDateOnly(new Date().toISOString().split('T')[0])
|
||||
}
|
||||
setLocalUseCustomTime(true)
|
||||
setLocalDueTime(timeStr)
|
||||
}
|
||||
|
||||
const handleCalendarChange = selected => {
|
||||
if (!selected || Array.isArray(selected)) return
|
||||
setLocalDueDateOnly(moment(selected).format('YYYY-MM-DD'))
|
||||
}
|
||||
|
||||
const handleLocalTimeInputChange = e => {
|
||||
setLocalUseCustomTime(true)
|
||||
setLocalDueTime(e.target.value)
|
||||
}
|
||||
|
||||
const handleSave = () => {
|
||||
onDueDateChange?.({ target: { value: localDueDateOnly || '' } })
|
||||
onUseCustomTimeChange?.(localUseCustomTime)
|
||||
if (localUseCustomTime && localDueTime) {
|
||||
onDueTimeChange?.({ target: { value: localDueTime } })
|
||||
} else {
|
||||
onDueTimeChange?.({ target: { value: '' } })
|
||||
}
|
||||
const handleSave = ({
|
||||
dueDateOnly: nextDate,
|
||||
dueTime: nextTime,
|
||||
useCustomTime: nextUseCustomTime,
|
||||
}) => {
|
||||
onDueDateChange?.({ target: { value: nextDate || '' } })
|
||||
onUseCustomTimeChange?.(nextUseCustomTime)
|
||||
onDueTimeChange?.({
|
||||
target: { value: nextUseCustomTime && nextTime ? nextTime : '' },
|
||||
})
|
||||
setIsOpen(false)
|
||||
}
|
||||
|
||||
@@ -227,385 +114,22 @@ const DueDatePickerField = ({
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<ResponsiveModal
|
||||
<DueDatePickerModal
|
||||
open={isOpen}
|
||||
onClose={() => setIsOpen(false)}
|
||||
title='Due Date'
|
||||
fullWidth={false}
|
||||
footer={
|
||||
<ModalActions
|
||||
tertiary={
|
||||
hasDueDate
|
||||
? {
|
||||
label: 'Remove',
|
||||
color: 'danger',
|
||||
onClick: () => {
|
||||
onClear?.()
|
||||
setIsOpen(false)
|
||||
},
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
secondary={{ label: 'Cancel', onClick: () => setIsOpen(false) }}
|
||||
primary={{ label: 'Apply', onClick: handleSave }}
|
||||
/>
|
||||
dueDateOnly={dueDateOnly}
|
||||
dueTime={dueTime}
|
||||
useCustomTime={useCustomTime}
|
||||
onApply={handleSave}
|
||||
onRemove={
|
||||
onClear
|
||||
? () => {
|
||||
onClear()
|
||||
setIsOpen(false)
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<Box sx={{ fontFamily: 'var(--joy-fontFamily-body)', maxWidth: 360 }}>
|
||||
{/* Date shortcuts */}
|
||||
<Typography
|
||||
level='body-xs'
|
||||
sx={{
|
||||
mb: 0.75,
|
||||
color: 'text.tertiary',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.05em',
|
||||
}}
|
||||
>
|
||||
Quick date
|
||||
</Typography>
|
||||
<List orientation='horizontal' wrap sx={{ ...pillListSx, mb: 1.5 }}>
|
||||
{[
|
||||
{
|
||||
key: 'today',
|
||||
label: 'Today',
|
||||
icon: <Today sx={{ fontSize: 14 }} />,
|
||||
},
|
||||
{
|
||||
key: 'tomorrow',
|
||||
label: 'Tomorrow',
|
||||
icon: <WbSunny sx={{ fontSize: 14 }} />,
|
||||
},
|
||||
{
|
||||
key: 'weekend',
|
||||
label: 'Weekend',
|
||||
icon: <Weekend sx={{ fontSize: 14 }} />,
|
||||
},
|
||||
{
|
||||
key: 'next-week',
|
||||
label: 'Next week',
|
||||
icon: <NextWeek sx={{ fontSize: 14 }} />,
|
||||
},
|
||||
{
|
||||
key: 'next-month',
|
||||
label: 'Next month',
|
||||
icon: <EventNote sx={{ fontSize: 14 }} />,
|
||||
},
|
||||
].map(opt => {
|
||||
const dateStr = getQuickScheduleDate(opt.key)
|
||||
.toISOString()
|
||||
.split('T')[0]
|
||||
return (
|
||||
<ListItem key={opt.key}>
|
||||
<Checkbox
|
||||
checked={localDueDateOnly === dateStr}
|
||||
onClick={() => handleQuickSchedule(opt.key)}
|
||||
overlay
|
||||
disableIcon
|
||||
variant='soft'
|
||||
label={
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 0.5,
|
||||
}}
|
||||
>
|
||||
{opt.icon}
|
||||
{opt.label}
|
||||
</Box>
|
||||
}
|
||||
/>
|
||||
</ListItem>
|
||||
)
|
||||
})}
|
||||
</List>
|
||||
|
||||
{/* Time shortcuts */}
|
||||
<Typography
|
||||
level='body-xs'
|
||||
sx={{
|
||||
mb: 0.75,
|
||||
color: 'text.tertiary',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.05em',
|
||||
}}
|
||||
>
|
||||
Quick time
|
||||
</Typography>
|
||||
<List orientation='horizontal' wrap sx={{ ...pillListSx, mb: 1.5 }}>
|
||||
{[
|
||||
{
|
||||
time: '09:00',
|
||||
label: 'Morning',
|
||||
icon: <LightMode sx={{ fontSize: 14 }} />,
|
||||
},
|
||||
{
|
||||
time: '12:00',
|
||||
label: 'Noon',
|
||||
icon: <WbSunny sx={{ fontSize: 14 }} />,
|
||||
},
|
||||
{
|
||||
time: '15:00',
|
||||
label: 'Afternoon',
|
||||
icon: <WbTwilight sx={{ fontSize: 14 }} />,
|
||||
},
|
||||
{
|
||||
time: '18:00',
|
||||
label: 'Evening',
|
||||
icon: <NightsStay sx={{ fontSize: 14 }} />,
|
||||
},
|
||||
{
|
||||
time: '22:00',
|
||||
label: 'Night',
|
||||
icon: <Bedtime sx={{ fontSize: 14 }} />,
|
||||
},
|
||||
].map(opt => (
|
||||
<ListItem key={opt.time}>
|
||||
<Checkbox
|
||||
checked={localUseCustomTime && localDueTime === opt.time}
|
||||
onClick={() => handleQuickTime(opt.time)}
|
||||
overlay
|
||||
disableIcon
|
||||
variant='soft'
|
||||
label={
|
||||
<Box
|
||||
sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}
|
||||
>
|
||||
{opt.icon}
|
||||
{opt.label}
|
||||
</Box>
|
||||
}
|
||||
/>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
mb: 1.5,
|
||||
borderRadius: 'md',
|
||||
border: '1px solid',
|
||||
borderColor: 'neutral.outlinedBorder',
|
||||
bgcolor: 'background.surface',
|
||||
p: 1,
|
||||
// Fix the height so switching views (month/year/decade) doesn't
|
||||
// cause layout shift — month view with 6 rows is the tallest.
|
||||
minHeight: 300,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
'& .react-calendar': {
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
},
|
||||
'& .react-calendar__viewContainer': {
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
},
|
||||
'& .react-calendar__month-view, & .react-calendar__year-view, & .react-calendar__decade-view, & .react-calendar__century-view':
|
||||
{
|
||||
flex: 1,
|
||||
},
|
||||
// Navigation row
|
||||
'& .react-calendar__navigation': {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '4px',
|
||||
mb: 1,
|
||||
},
|
||||
// All nav buttons — large tap targets
|
||||
'& .react-calendar__navigation button': {
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
borderRadius: '8px',
|
||||
color: 'var(--joy-palette-text-primary)',
|
||||
fontFamily: 'var(--joy-fontFamily-body)',
|
||||
fontSize: '0.875rem',
|
||||
fontWeight: 600,
|
||||
cursor: 'pointer',
|
||||
minHeight: '40px',
|
||||
minWidth: '40px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: '0 8px',
|
||||
transition: 'background 0.15s',
|
||||
'&:hover': {
|
||||
backgroundColor: 'var(--joy-palette-neutral-softBg)',
|
||||
},
|
||||
'&:disabled': {
|
||||
opacity: 0.35,
|
||||
cursor: 'default',
|
||||
},
|
||||
},
|
||||
// Label button (month/year text) takes remaining space
|
||||
'& .react-calendar__navigation__label': {
|
||||
flex: 1,
|
||||
fontSize: '0.9rem',
|
||||
fontWeight: 700,
|
||||
letterSpacing: '0.01em',
|
||||
},
|
||||
// Prev/next arrow buttons — slightly larger icon feel
|
||||
'& .react-calendar__navigation__prev-button, & .react-calendar__navigation__next-button':
|
||||
{
|
||||
fontSize: '1.75rem',
|
||||
},
|
||||
'& .react-calendar__navigation__prev2-button, & .react-calendar__navigation__next2-button':
|
||||
{
|
||||
fontSize: '1.4rem',
|
||||
},
|
||||
// Weekday headers
|
||||
'& .react-calendar__month-view__weekdays__weekday': {
|
||||
fontSize: '0.7rem',
|
||||
fontWeight: 600,
|
||||
color: 'var(--joy-palette-text-tertiary)',
|
||||
textAlign: 'center',
|
||||
padding: '4px 0',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.04em',
|
||||
},
|
||||
'& .react-calendar__month-view__weekdays__weekday abbr': {
|
||||
textDecoration: 'none',
|
||||
},
|
||||
// All tiles — shared base
|
||||
'& .react-calendar__tile': {
|
||||
border: 'none',
|
||||
background: 'none',
|
||||
color: 'var(--joy-palette-text-primary)',
|
||||
fontFamily: 'var(--joy-fontFamily-body)',
|
||||
fontSize: '0.8rem',
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
transition: 'background 0.15s',
|
||||
'&:hover': {
|
||||
background: 'var(--joy-palette-neutral-softBg)',
|
||||
},
|
||||
},
|
||||
// Day tiles only — circular
|
||||
'& .react-calendar__month-view__days .react-calendar__tile': {
|
||||
aspectRatio: '1',
|
||||
borderRadius: '50%',
|
||||
},
|
||||
// Month tiles (year view) — pill shape, no huge circle
|
||||
'& .react-calendar__year-view .react-calendar__tile': {
|
||||
borderRadius: '8px',
|
||||
padding: '10px 4px',
|
||||
fontSize: '0.875rem',
|
||||
},
|
||||
// Year tiles (decade view) — pill shape
|
||||
'& .react-calendar__decade-view .react-calendar__tile': {
|
||||
borderRadius: '8px',
|
||||
padding: '10px 4px',
|
||||
fontSize: '0.875rem',
|
||||
},
|
||||
// Century tiles — pill shape
|
||||
'& .react-calendar__century-view .react-calendar__tile': {
|
||||
borderRadius: '8px',
|
||||
padding: '10px 4px',
|
||||
fontSize: '0.875rem',
|
||||
},
|
||||
'& .react-calendar__tile--now': {
|
||||
border:
|
||||
'1.5px solid var(--joy-palette-primary-solidBg) !important',
|
||||
color: 'var(--joy-palette-primary-solidBg) !important',
|
||||
fontWeight: 700,
|
||||
background: 'none !important',
|
||||
},
|
||||
'& .react-calendar__tile--active, & .react-calendar__tile--active:hover':
|
||||
{
|
||||
background: 'var(--joy-palette-primary-solidBg) !important',
|
||||
color: 'var(--joy-palette-primary-solidColor) !important',
|
||||
fontWeight: 700,
|
||||
},
|
||||
'& .react-calendar__month-view__days__day--neighboringMonth': {
|
||||
color: 'var(--joy-palette-text-tertiary)',
|
||||
},
|
||||
'& .react-calendar__month-view__days': {
|
||||
display: 'grid !important',
|
||||
gridTemplateColumns: 'repeat(7, 1fr) !important',
|
||||
},
|
||||
'& .react-calendar__month-view__weekdays': {
|
||||
display: 'grid !important',
|
||||
gridTemplateColumns: 'repeat(7, 1fr) !important',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Calendar
|
||||
value={
|
||||
localDueDateOnly
|
||||
? new Date(`${localDueDateOnly}T00:00:00`)
|
||||
: null
|
||||
}
|
||||
calendarType={calendarType}
|
||||
onChange={handleCalendarChange}
|
||||
formatShortWeekday={(locale, date) =>
|
||||
['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa'][date.getDay()]
|
||||
}
|
||||
formatMonth={(locale, date) =>
|
||||
[
|
||||
'Jan',
|
||||
'Feb',
|
||||
'Mar',
|
||||
'Apr',
|
||||
'May',
|
||||
'Jun',
|
||||
'Jul',
|
||||
'Aug',
|
||||
'Sep',
|
||||
'Oct',
|
||||
'Nov',
|
||||
'Dec',
|
||||
][date.getMonth()]
|
||||
}
|
||||
/>
|
||||
</Box>
|
||||
<Typography
|
||||
level='body-xs'
|
||||
sx={{
|
||||
mb: 0.5,
|
||||
mt: 0.5,
|
||||
color: 'text.tertiary',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.05em',
|
||||
}}
|
||||
>
|
||||
Custom time
|
||||
</Typography>
|
||||
<Input
|
||||
type='time'
|
||||
size='sm'
|
||||
value={localUseCustomTime ? localDueTime || '' : ''}
|
||||
disabled={!localDueDateOnly}
|
||||
onChange={handleLocalTimeInputChange}
|
||||
sx={{ maxWidth: 200, mb: 1 }}
|
||||
slotProps={{ input: { style: { fontFamily: 'inherit' } } }}
|
||||
/>
|
||||
<Box sx={{ display: 'flex', gap: 0.75, mb: 0.5 }}>
|
||||
<Button
|
||||
size='sm'
|
||||
variant={!localUseCustomTime ? 'soft' : 'plain'}
|
||||
color='neutral'
|
||||
disabled={!localDueDateOnly}
|
||||
onClick={() => setLocalUseCustomTime(false)}
|
||||
>
|
||||
Anytime
|
||||
</Button>
|
||||
<Button
|
||||
size='sm'
|
||||
variant={localUseCustomTime ? 'soft' : 'plain'}
|
||||
color='neutral'
|
||||
disabled={!localDueDateOnly}
|
||||
onClick={() => setLocalUseCustomTime(true)}
|
||||
>
|
||||
Specific time
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
</ResponsiveModal>
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
544
src/views/components/DueDatePickerModal.jsx
Normal file
544
src/views/components/DueDatePickerModal.jsx
Normal file
@@ -0,0 +1,544 @@
|
||||
import {
|
||||
Bedtime,
|
||||
EventNote,
|
||||
LightMode,
|
||||
NextWeek,
|
||||
NightsStay,
|
||||
Today,
|
||||
WbSunny,
|
||||
WbTwilight,
|
||||
Weekend,
|
||||
} from '@mui/icons-material'
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Checkbox,
|
||||
Input,
|
||||
List,
|
||||
ListItem,
|
||||
Typography,
|
||||
} from '@mui/joy'
|
||||
import moment from 'moment'
|
||||
import { useEffect, useState } from 'react'
|
||||
import Calendar from 'react-calendar'
|
||||
import ModalActions from '../../components/common/ModalActions'
|
||||
import { useLocalization } from '../../contexts/LocalizationContext'
|
||||
import { useResponsiveModal } from '../../hooks/useResponsiveModal'
|
||||
|
||||
// Split a date-ish value (ISO string / Date) into the parts this picker edits.
|
||||
export const splitDueDate = value => {
|
||||
if (!value) {
|
||||
return { dueDateOnly: null, dueTime: null, useCustomTime: false }
|
||||
}
|
||||
const m = moment(value)
|
||||
if (!m.isValid()) {
|
||||
return { dueDateOnly: null, dueTime: null, useCustomTime: false }
|
||||
}
|
||||
const time = m.format('HH:mm')
|
||||
return {
|
||||
dueDateOnly: m.format('YYYY-MM-DD'),
|
||||
dueTime: time,
|
||||
// Midnight is how a date-only value round-trips, so treat it as "anytime"
|
||||
useCustomTime: time !== '00:00',
|
||||
}
|
||||
}
|
||||
|
||||
// Inverse of splitDueDate — returns a Date, or null when there is no due date.
|
||||
export const combineDueDate = ({ dueDateOnly, dueTime, useCustomTime }) => {
|
||||
if (!dueDateOnly) return null
|
||||
const time = useCustomTime && dueTime ? dueTime : '00:00'
|
||||
return moment(`${dueDateOnly} ${time}`, 'YYYY-MM-DD HH:mm').toDate()
|
||||
}
|
||||
|
||||
export const getQuickScheduleDate = option => {
|
||||
const now = new Date()
|
||||
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate())
|
||||
|
||||
switch (option) {
|
||||
case 'today':
|
||||
return today
|
||||
case 'tomorrow': {
|
||||
const tomorrow = new Date(today)
|
||||
tomorrow.setDate(today.getDate() + 1)
|
||||
return tomorrow
|
||||
}
|
||||
case 'weekend': {
|
||||
const weekend = new Date(today)
|
||||
const daysUntilSaturday = (6 - today.getDay() + 7) % 7 || 7
|
||||
weekend.setDate(today.getDate() + daysUntilSaturday)
|
||||
return weekend
|
||||
}
|
||||
case 'next-week': {
|
||||
const nextWeek = new Date(today)
|
||||
const daysUntilMonday = (1 - today.getDay() + 7) % 7 || 7
|
||||
nextWeek.setDate(today.getDate() + daysUntilMonday)
|
||||
return nextWeek
|
||||
}
|
||||
case 'next-month': {
|
||||
const nextMonth = new Date(today)
|
||||
nextMonth.setMonth(today.getMonth() + 1)
|
||||
return nextMonth
|
||||
}
|
||||
default:
|
||||
return today
|
||||
}
|
||||
}
|
||||
|
||||
const toDateKey = date => moment(date).format('YYYY-MM-DD')
|
||||
|
||||
/**
|
||||
* The shared due-date picker UI (quick dates, quick times, calendar, custom
|
||||
* time). Used both by DueDatePickerField and by anything that needs to
|
||||
* reschedule a task — task cards, swipe actions, action menus.
|
||||
*/
|
||||
const DueDatePickerModal = ({
|
||||
open,
|
||||
onClose,
|
||||
title = 'Due Date',
|
||||
dueDateOnly,
|
||||
dueTime,
|
||||
useCustomTime,
|
||||
onApply,
|
||||
onRemove,
|
||||
applyLabel = 'Apply',
|
||||
}) => {
|
||||
const { ResponsiveModal } = useResponsiveModal()
|
||||
const { firstDayOfWeek } = useLocalization()
|
||||
|
||||
// Local buffered state — only committed on Apply
|
||||
const [localDueDateOnly, setLocalDueDateOnly] = useState(dueDateOnly)
|
||||
const [localDueTime, setLocalDueTime] = useState(dueTime)
|
||||
const [localUseCustomTime, setLocalUseCustomTime] = useState(useCustomTime)
|
||||
|
||||
// Sync local state from props whenever the modal opens
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setLocalDueDateOnly(dueDateOnly)
|
||||
setLocalDueTime(dueTime)
|
||||
setLocalUseCustomTime(useCustomTime)
|
||||
}
|
||||
}, [open, dueDateOnly, dueTime, useCustomTime])
|
||||
|
||||
const calendarType =
|
||||
firstDayOfWeek === 1
|
||||
? 'iso8601'
|
||||
: firstDayOfWeek === 6
|
||||
? 'islamic'
|
||||
: 'gregory'
|
||||
|
||||
const pillListSx = {
|
||||
'--List-gap': '8px',
|
||||
'--ListItem-radius': '20px',
|
||||
}
|
||||
|
||||
const handleQuickSchedule = option => {
|
||||
setLocalDueDateOnly(toDateKey(getQuickScheduleDate(option)))
|
||||
}
|
||||
|
||||
const handleQuickTime = timeStr => {
|
||||
// Tap the active chip again to deselect it
|
||||
if (localUseCustomTime && localDueTime === timeStr) {
|
||||
setLocalUseCustomTime(false)
|
||||
setLocalDueTime(null)
|
||||
return
|
||||
}
|
||||
if (!localDueDateOnly) {
|
||||
setLocalDueDateOnly(toDateKey(new Date()))
|
||||
}
|
||||
setLocalUseCustomTime(true)
|
||||
setLocalDueTime(timeStr)
|
||||
}
|
||||
|
||||
const handleCalendarChange = selected => {
|
||||
if (!selected || Array.isArray(selected)) return
|
||||
setLocalDueDateOnly(moment(selected).format('YYYY-MM-DD'))
|
||||
}
|
||||
|
||||
const handleLocalTimeInputChange = e => {
|
||||
setLocalUseCustomTime(true)
|
||||
setLocalDueTime(e.target.value)
|
||||
}
|
||||
|
||||
const handleSave = () => {
|
||||
onApply?.({
|
||||
dueDateOnly: localDueDateOnly || null,
|
||||
dueTime: localUseCustomTime ? localDueTime || null : null,
|
||||
useCustomTime: Boolean(localUseCustomTime && localDueTime),
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<ResponsiveModal
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
title={title}
|
||||
fullWidth={false}
|
||||
footer={
|
||||
<ModalActions
|
||||
tertiary={
|
||||
onRemove && dueDateOnly
|
||||
? {
|
||||
label: 'Remove',
|
||||
color: 'danger',
|
||||
onClick: onRemove,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
secondary={{ label: 'Cancel', onClick: onClose }}
|
||||
primary={{ label: applyLabel, onClick: handleSave }}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Box sx={{ fontFamily: 'var(--joy-fontFamily-body)', maxWidth: 360 }}>
|
||||
{/* Date shortcuts */}
|
||||
<Typography
|
||||
level='body-xs'
|
||||
sx={{
|
||||
mb: 0.75,
|
||||
color: 'text.tertiary',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.05em',
|
||||
}}
|
||||
>
|
||||
Quick date
|
||||
</Typography>
|
||||
<List orientation='horizontal' wrap sx={{ ...pillListSx, mb: 1.5 }}>
|
||||
{[
|
||||
{
|
||||
key: 'today',
|
||||
label: 'Today',
|
||||
icon: <Today sx={{ fontSize: 14 }} />,
|
||||
},
|
||||
{
|
||||
key: 'tomorrow',
|
||||
label: 'Tomorrow',
|
||||
icon: <WbSunny sx={{ fontSize: 14 }} />,
|
||||
},
|
||||
{
|
||||
key: 'weekend',
|
||||
label: 'Weekend',
|
||||
icon: <Weekend sx={{ fontSize: 14 }} />,
|
||||
},
|
||||
{
|
||||
key: 'next-week',
|
||||
label: 'Next week',
|
||||
icon: <NextWeek sx={{ fontSize: 14 }} />,
|
||||
},
|
||||
{
|
||||
key: 'next-month',
|
||||
label: 'Next month',
|
||||
icon: <EventNote sx={{ fontSize: 14 }} />,
|
||||
},
|
||||
].map(opt => {
|
||||
const dateStr = toDateKey(getQuickScheduleDate(opt.key))
|
||||
return (
|
||||
<ListItem key={opt.key}>
|
||||
<Checkbox
|
||||
checked={localDueDateOnly === dateStr}
|
||||
onClick={() => handleQuickSchedule(opt.key)}
|
||||
overlay
|
||||
disableIcon
|
||||
variant='soft'
|
||||
label={
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 0.5,
|
||||
}}
|
||||
>
|
||||
{opt.icon}
|
||||
{opt.label}
|
||||
</Box>
|
||||
}
|
||||
/>
|
||||
</ListItem>
|
||||
)
|
||||
})}
|
||||
</List>
|
||||
|
||||
{/* Time shortcuts */}
|
||||
<Typography
|
||||
level='body-xs'
|
||||
sx={{
|
||||
mb: 0.75,
|
||||
color: 'text.tertiary',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.05em',
|
||||
}}
|
||||
>
|
||||
Quick time
|
||||
</Typography>
|
||||
<List orientation='horizontal' wrap sx={{ ...pillListSx, mb: 1.5 }}>
|
||||
{[
|
||||
{
|
||||
time: '09:00',
|
||||
label: 'Morning',
|
||||
icon: <LightMode sx={{ fontSize: 14 }} />,
|
||||
},
|
||||
{
|
||||
time: '12:00',
|
||||
label: 'Noon',
|
||||
icon: <WbSunny sx={{ fontSize: 14 }} />,
|
||||
},
|
||||
{
|
||||
time: '15:00',
|
||||
label: 'Afternoon',
|
||||
icon: <WbTwilight sx={{ fontSize: 14 }} />,
|
||||
},
|
||||
{
|
||||
time: '18:00',
|
||||
label: 'Evening',
|
||||
icon: <NightsStay sx={{ fontSize: 14 }} />,
|
||||
},
|
||||
{
|
||||
time: '22:00',
|
||||
label: 'Night',
|
||||
icon: <Bedtime sx={{ fontSize: 14 }} />,
|
||||
},
|
||||
].map(opt => (
|
||||
<ListItem key={opt.time}>
|
||||
<Checkbox
|
||||
checked={localUseCustomTime && localDueTime === opt.time}
|
||||
onClick={() => handleQuickTime(opt.time)}
|
||||
overlay
|
||||
disableIcon
|
||||
variant='soft'
|
||||
label={
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
||||
{opt.icon}
|
||||
{opt.label}
|
||||
</Box>
|
||||
}
|
||||
/>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
mb: 1.5,
|
||||
borderRadius: 'md',
|
||||
border: '1px solid',
|
||||
borderColor: 'neutral.outlinedBorder',
|
||||
bgcolor: 'background.surface',
|
||||
p: 1,
|
||||
// Fix the height so switching views (month/year/decade) doesn't
|
||||
// cause layout shift — month view with 6 rows is the tallest.
|
||||
minHeight: 300,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
'& .react-calendar': {
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
},
|
||||
'& .react-calendar__viewContainer': {
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
},
|
||||
'& .react-calendar__month-view, & .react-calendar__year-view, & .react-calendar__decade-view, & .react-calendar__century-view':
|
||||
{
|
||||
flex: 1,
|
||||
},
|
||||
// Navigation row
|
||||
'& .react-calendar__navigation': {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '4px',
|
||||
mb: 1,
|
||||
},
|
||||
// All nav buttons — large tap targets
|
||||
'& .react-calendar__navigation button': {
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
borderRadius: '8px',
|
||||
color: 'var(--joy-palette-text-primary)',
|
||||
fontFamily: 'var(--joy-fontFamily-body)',
|
||||
fontSize: '0.875rem',
|
||||
fontWeight: 600,
|
||||
cursor: 'pointer',
|
||||
minHeight: '40px',
|
||||
minWidth: '40px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: '0 8px',
|
||||
transition: 'background 0.15s',
|
||||
'&:hover': {
|
||||
backgroundColor: 'var(--joy-palette-neutral-softBg)',
|
||||
},
|
||||
'&:disabled': {
|
||||
opacity: 0.35,
|
||||
cursor: 'default',
|
||||
},
|
||||
},
|
||||
// Label button (month/year text) takes remaining space
|
||||
'& .react-calendar__navigation__label': {
|
||||
flex: 1,
|
||||
fontSize: '0.9rem',
|
||||
fontWeight: 700,
|
||||
letterSpacing: '0.01em',
|
||||
},
|
||||
// Prev/next arrow buttons — slightly larger icon feel
|
||||
'& .react-calendar__navigation__prev-button, & .react-calendar__navigation__next-button':
|
||||
{
|
||||
fontSize: '1.75rem',
|
||||
},
|
||||
'& .react-calendar__navigation__prev2-button, & .react-calendar__navigation__next2-button':
|
||||
{
|
||||
fontSize: '1.4rem',
|
||||
},
|
||||
// Weekday headers
|
||||
'& .react-calendar__month-view__weekdays__weekday': {
|
||||
fontSize: '0.7rem',
|
||||
fontWeight: 600,
|
||||
color: 'var(--joy-palette-text-tertiary)',
|
||||
textAlign: 'center',
|
||||
padding: '4px 0',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.04em',
|
||||
},
|
||||
'& .react-calendar__month-view__weekdays__weekday abbr': {
|
||||
textDecoration: 'none',
|
||||
},
|
||||
// All tiles — shared base
|
||||
'& .react-calendar__tile': {
|
||||
border: 'none',
|
||||
background: 'none',
|
||||
color: 'var(--joy-palette-text-primary)',
|
||||
fontFamily: 'var(--joy-fontFamily-body)',
|
||||
fontSize: '0.8rem',
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
transition: 'background 0.15s',
|
||||
'&:hover': {
|
||||
background: 'var(--joy-palette-neutral-softBg)',
|
||||
},
|
||||
},
|
||||
// Day tiles only — circular
|
||||
'& .react-calendar__month-view__days .react-calendar__tile': {
|
||||
aspectRatio: '1',
|
||||
borderRadius: '50%',
|
||||
},
|
||||
// Month tiles (year view) — pill shape, no huge circle
|
||||
'& .react-calendar__year-view .react-calendar__tile': {
|
||||
borderRadius: '8px',
|
||||
padding: '10px 4px',
|
||||
fontSize: '0.875rem',
|
||||
},
|
||||
// Year tiles (decade view) — pill shape
|
||||
'& .react-calendar__decade-view .react-calendar__tile': {
|
||||
borderRadius: '8px',
|
||||
padding: '10px 4px',
|
||||
fontSize: '0.875rem',
|
||||
},
|
||||
// Century tiles — pill shape
|
||||
'& .react-calendar__century-view .react-calendar__tile': {
|
||||
borderRadius: '8px',
|
||||
padding: '10px 4px',
|
||||
fontSize: '0.875rem',
|
||||
},
|
||||
'& .react-calendar__tile--now': {
|
||||
border:
|
||||
'1.5px solid var(--joy-palette-primary-solidBg) !important',
|
||||
color: 'var(--joy-palette-primary-solidBg) !important',
|
||||
fontWeight: 700,
|
||||
background: 'none !important',
|
||||
},
|
||||
'& .react-calendar__tile--active, & .react-calendar__tile--active:hover':
|
||||
{
|
||||
background: 'var(--joy-palette-primary-solidBg) !important',
|
||||
color: 'var(--joy-palette-primary-solidColor) !important',
|
||||
fontWeight: 700,
|
||||
},
|
||||
'& .react-calendar__month-view__days__day--neighboringMonth': {
|
||||
color: 'var(--joy-palette-text-tertiary)',
|
||||
},
|
||||
'& .react-calendar__month-view__days': {
|
||||
display: 'grid !important',
|
||||
gridTemplateColumns: 'repeat(7, 1fr) !important',
|
||||
},
|
||||
'& .react-calendar__month-view__weekdays': {
|
||||
display: 'grid !important',
|
||||
gridTemplateColumns: 'repeat(7, 1fr) !important',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Calendar
|
||||
value={
|
||||
localDueDateOnly ? new Date(`${localDueDateOnly}T00:00:00`) : null
|
||||
}
|
||||
calendarType={calendarType}
|
||||
onChange={handleCalendarChange}
|
||||
formatShortWeekday={(locale, date) =>
|
||||
['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa'][date.getDay()]
|
||||
}
|
||||
formatMonth={(locale, date) =>
|
||||
[
|
||||
'Jan',
|
||||
'Feb',
|
||||
'Mar',
|
||||
'Apr',
|
||||
'May',
|
||||
'Jun',
|
||||
'Jul',
|
||||
'Aug',
|
||||
'Sep',
|
||||
'Oct',
|
||||
'Nov',
|
||||
'Dec',
|
||||
][date.getMonth()]
|
||||
}
|
||||
/>
|
||||
</Box>
|
||||
<Typography
|
||||
level='body-xs'
|
||||
sx={{
|
||||
mb: 0.5,
|
||||
mt: 0.5,
|
||||
color: 'text.tertiary',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.05em',
|
||||
}}
|
||||
>
|
||||
Custom time
|
||||
</Typography>
|
||||
<Input
|
||||
type='time'
|
||||
size='sm'
|
||||
value={localUseCustomTime ? localDueTime || '' : ''}
|
||||
disabled={!localDueDateOnly}
|
||||
onChange={handleLocalTimeInputChange}
|
||||
sx={{ maxWidth: 200, mb: 1 }}
|
||||
slotProps={{ input: { style: { fontFamily: 'inherit' } } }}
|
||||
/>
|
||||
<Box sx={{ display: 'flex', gap: 0.75, mb: 0.5 }}>
|
||||
<Button
|
||||
size='sm'
|
||||
variant={!localUseCustomTime ? 'soft' : 'plain'}
|
||||
color='neutral'
|
||||
disabled={!localDueDateOnly}
|
||||
onClick={() => setLocalUseCustomTime(false)}
|
||||
>
|
||||
Anytime
|
||||
</Button>
|
||||
<Button
|
||||
size='sm'
|
||||
variant={localUseCustomTime ? 'soft' : 'plain'}
|
||||
color='neutral'
|
||||
disabled={!localDueDateOnly}
|
||||
onClick={() => setLocalUseCustomTime(true)}
|
||||
>
|
||||
Specific time
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
</ResponsiveModal>
|
||||
)
|
||||
}
|
||||
|
||||
export default DueDatePickerModal
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
LinearProgress,
|
||||
Typography,
|
||||
} from '@mui/joy'
|
||||
import { useEffect } from 'react'
|
||||
import { useCallback, useEffect, useMemo } from 'react'
|
||||
import { useScanToTask } from './useScanToTask'
|
||||
|
||||
/**
|
||||
@@ -20,8 +20,20 @@ import { useScanToTask } from './useScanToTask'
|
||||
*
|
||||
* Flow: capture → (auto) processing → done [calls onTaskExtracted + onClose]
|
||||
* → error [retake or cancel]
|
||||
*
|
||||
* The primary action (Capture / Scan Document / Retake) lives in the modal
|
||||
* footer alongside Cancel — the panel reports it up through onStateChange
|
||||
* rather than rendering its own button row. Upload stays inline because it
|
||||
* belongs to the capture surface and drives a hidden input in this subtree.
|
||||
*/
|
||||
const ScanPanel = ({ open, onTaskExtracted, onClose, initialImageUrl, autoCapture }) => {
|
||||
const ScanPanel = ({
|
||||
open,
|
||||
onTaskExtracted,
|
||||
onClose,
|
||||
onStateChange,
|
||||
initialImageUrl,
|
||||
autoCapture,
|
||||
}) => {
|
||||
const {
|
||||
isNativeScanner,
|
||||
phase,
|
||||
@@ -76,6 +88,51 @@ const ScanPanel = ({ open, onTaskExtracted, onClose, initialImageUrl, autoCaptur
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [phase, taskResult])
|
||||
|
||||
const openFilePicker = useCallback(
|
||||
() => fileInputRef.current?.click(),
|
||||
[fileInputRef],
|
||||
)
|
||||
|
||||
// The one action the footer renders for the current phase; null while
|
||||
// processing (nothing to do but wait) and when done (the panel closes)
|
||||
const primaryAction = useMemo(() => {
|
||||
if (phase === 'capture') {
|
||||
if (isNativeScanner) {
|
||||
return {
|
||||
label: 'Scan Document',
|
||||
icon: <DocumentScanner />,
|
||||
onClick: handleNativeScan,
|
||||
}
|
||||
}
|
||||
if (cameraAvailable) {
|
||||
return { label: 'Capture', icon: <CameraAlt />, onClick: capture }
|
||||
}
|
||||
// No camera on this device — Upload is the only way forward, so it
|
||||
// graduates from the inline secondary to the footer's primary
|
||||
return {
|
||||
label: 'Upload Photo',
|
||||
icon: <PhotoCamera />,
|
||||
onClick: openFilePicker,
|
||||
}
|
||||
}
|
||||
if (phase === 'error') {
|
||||
return { label: 'Retake', icon: <Replay />, onClick: retake }
|
||||
}
|
||||
return null
|
||||
}, [
|
||||
phase,
|
||||
isNativeScanner,
|
||||
cameraAvailable,
|
||||
capture,
|
||||
handleNativeScan,
|
||||
retake,
|
||||
openFilePicker,
|
||||
])
|
||||
|
||||
useEffect(() => {
|
||||
onStateChange?.({ phase, primaryAction })
|
||||
}, [phase, primaryAction, onStateChange])
|
||||
|
||||
if (!open) return null
|
||||
|
||||
const isProcessing = phase === 'processing'
|
||||
@@ -111,7 +168,10 @@ const ScanPanel = ({ open, onTaskExtracted, onClose, initialImageUrl, autoCaptur
|
||||
<DocumentScanner
|
||||
sx={{ fontSize: 56, color: 'white', opacity: 0.5, mb: 1 }}
|
||||
/>
|
||||
<Typography level='body-sm' sx={{ color: 'white', opacity: 0.6 }}>
|
||||
<Typography
|
||||
level='body-sm'
|
||||
sx={{ color: 'white', opacity: 0.6 }}
|
||||
>
|
||||
Tap "Scan Document" to open the scanner
|
||||
</Typography>
|
||||
</Box>
|
||||
@@ -137,65 +197,38 @@ const ScanPanel = ({ open, onTaskExtracted, onClose, initialImageUrl, autoCaptur
|
||||
<CameraAlt
|
||||
sx={{ fontSize: 48, color: 'white', opacity: 0.4, mb: 1 }}
|
||||
/>
|
||||
<Typography level='body-sm' sx={{ color: 'white', opacity: 0.6 }}>
|
||||
<Typography
|
||||
level='body-sm'
|
||||
sx={{ color: 'white', opacity: 0.6 }}
|
||||
>
|
||||
Camera not available — use Upload instead
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
px: 1.5,
|
||||
py: 1,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 1,
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
size='sm'
|
||||
variant='plain'
|
||||
color='neutral'
|
||||
startDecorator={<PhotoCamera fontSize='small' />}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
{/* Hidden when Upload is already the footer's primary action */}
|
||||
{(isNativeScanner || cameraAvailable) && (
|
||||
<Box
|
||||
sx={{
|
||||
px: 1.5,
|
||||
py: 1,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 1,
|
||||
}}
|
||||
>
|
||||
Upload
|
||||
</Button>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type='file'
|
||||
accept='image/*'
|
||||
style={{ display: 'none' }}
|
||||
onChange={handleFileSelect}
|
||||
/>
|
||||
|
||||
<Box sx={{ ml: 'auto', display: 'flex', gap: 1 }}>
|
||||
{isNativeScanner ? (
|
||||
<Button
|
||||
size='sm'
|
||||
variant='solid'
|
||||
color='primary'
|
||||
startDecorator={<DocumentScanner fontSize='small' />}
|
||||
onClick={handleNativeScan}
|
||||
>
|
||||
Scan Document
|
||||
</Button>
|
||||
) : (
|
||||
cameraAvailable && (
|
||||
<Button
|
||||
size='sm'
|
||||
variant='solid'
|
||||
color='primary'
|
||||
startDecorator={<CameraAlt fontSize='small' />}
|
||||
onClick={capture}
|
||||
>
|
||||
Capture
|
||||
</Button>
|
||||
)
|
||||
)}
|
||||
<Button
|
||||
size='sm'
|
||||
variant='plain'
|
||||
color='neutral'
|
||||
startDecorator={<PhotoCamera fontSize='small' />}
|
||||
onClick={openFilePicker}
|
||||
>
|
||||
Upload
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -280,24 +313,22 @@ const ScanPanel = ({ open, onTaskExtracted, onClose, initialImageUrl, autoCaptur
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 1, mb: 2 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 1 }}>
|
||||
<WarningAmber color='warning' sx={{ mt: 0.25, flexShrink: 0 }} />
|
||||
<Typography level='body-sm'>{errorMsg}</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', gap: 1 }}>
|
||||
<Button
|
||||
size='sm'
|
||||
variant='outlined'
|
||||
color='neutral'
|
||||
startDecorator={<Replay fontSize='small' />}
|
||||
onClick={retake}
|
||||
>
|
||||
Retake
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Kept outside the phase branches so the footer's Upload action can
|
||||
reach it even when no capture surface is rendered */}
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type='file'
|
||||
accept='image/*'
|
||||
style={{ display: 'none' }}
|
||||
onChange={handleFileSelect}
|
||||
/>
|
||||
<canvas ref={canvasRef} style={{ display: 'none' }} />
|
||||
</Box>
|
||||
)
|
||||
|
||||
@@ -401,18 +401,18 @@ const TaskPreviewCard = ({
|
||||
/**
|
||||
* Inline voice-to-task panel. Mounts inside AddTaskModal — no second modal.
|
||||
*
|
||||
* Opens straight into hands-free listening. Pauses and spoken separators
|
||||
* ("also") split the transcript into task cards; tapping a card opens inline
|
||||
* pickers whose edits override the parsed values. A single captured task
|
||||
* lands in the smart input for review; multiple are created directly.
|
||||
* Mounted only while voice capture is active, and opens straight into
|
||||
* hands-free listening. Pauses and spoken separators ("also") split the
|
||||
* transcript into task cards; tapping a card opens inline pickers whose edits
|
||||
* override the parsed values. The confirm action lives in the modal footer
|
||||
* alongside Cancel — this panel only reports its state up through
|
||||
* onStateChange so the modal can label and enable that button.
|
||||
*/
|
||||
const VoicePanel = ({
|
||||
open,
|
||||
userLabels = [],
|
||||
members = [],
|
||||
userProfile,
|
||||
onUseSingle,
|
||||
onCreateMany,
|
||||
onStateChange,
|
||||
}) => {
|
||||
const {
|
||||
phase,
|
||||
@@ -427,8 +427,6 @@ const VoicePanel = ({
|
||||
patchSegment,
|
||||
isNative,
|
||||
} = useVoiceToTask({ members, userLabels })
|
||||
const [creating, setCreating] = useState(false)
|
||||
const autoStartedRef = useRef(false)
|
||||
const segmentsScrollRef = useRef(null)
|
||||
|
||||
const parseCtx = useMemo(
|
||||
@@ -441,14 +439,12 @@ const VoicePanel = ({
|
||||
[partialText, parseCtx],
|
||||
)
|
||||
|
||||
// Start capturing the moment the panel opens — the mic tap that opened it
|
||||
// is the only tap needed
|
||||
// Start capturing the moment the panel mounts — the mic tap that opened it
|
||||
// is the only tap needed. startHandsFree no-ops if already listening.
|
||||
useEffect(() => {
|
||||
if (open && !autoStartedRef.current) {
|
||||
autoStartedRef.current = true
|
||||
startHandsFree()
|
||||
}
|
||||
}, [open, startHandsFree])
|
||||
startHandsFree()
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
// Keep the newest captured task visible as more are added
|
||||
useEffect(() => {
|
||||
@@ -456,24 +452,14 @@ const VoicePanel = ({
|
||||
if (el) el.scrollTop = el.scrollHeight
|
||||
}, [segments.length])
|
||||
|
||||
if (!open) return null
|
||||
|
||||
const isListening = phase === 'listening'
|
||||
const showActions = segments.length > 0 && !isListening && !creating
|
||||
|
||||
const mergedTask = segment => ({
|
||||
...parseVoiceTask(segment.text, parseCtx),
|
||||
...(segment.overrides || {}),
|
||||
})
|
||||
|
||||
const handleCreateAll = async () => {
|
||||
setCreating(true)
|
||||
try {
|
||||
await onCreateMany(segments.map(mergedTask))
|
||||
} finally {
|
||||
setCreating(false)
|
||||
}
|
||||
}
|
||||
// The confirm action lives in the modal footer, so report the raw segments
|
||||
// and whether the mic is live — that's all it needs to label and enable the
|
||||
// button. It parses the segments itself when the user confirms.
|
||||
useEffect(() => {
|
||||
onStateChange?.({ segments, isListening })
|
||||
}, [segments, isListening, onStateChange])
|
||||
|
||||
const micCaption = isListening
|
||||
? isLocked
|
||||
@@ -642,47 +628,6 @@ const VoicePanel = ({
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* ── Footer — dismissing is the modal's Cancel; this owns confirm only ── */}
|
||||
{(creating || showActions) && (
|
||||
<Box
|
||||
sx={{
|
||||
px: 1.5,
|
||||
py: 1,
|
||||
display: 'flex',
|
||||
justifyContent: 'flex-end',
|
||||
gap: 1,
|
||||
borderTop: '1px solid',
|
||||
borderColor: 'divider',
|
||||
}}
|
||||
>
|
||||
{creating ? (
|
||||
<Button size='sm' variant='solid' color='primary' loading>
|
||||
Creating…
|
||||
</Button>
|
||||
) : segments.length === 1 ? (
|
||||
<Button
|
||||
size='sm'
|
||||
variant='solid'
|
||||
color='primary'
|
||||
onClick={() =>
|
||||
onUseSingle(segments[0].text, segments[0].overrides || {})
|
||||
}
|
||||
>
|
||||
Use Task
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
size='sm'
|
||||
variant='solid'
|
||||
color='primary'
|
||||
onClick={handleCreateAll}
|
||||
>
|
||||
Create {segments.length} Tasks
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user