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 instanceof TypeError && error.message === 'Failed to fetch') ||
|
||||||
error?.name === 'AbortError')
|
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 => ({
|
const buildOfflineChore = task => ({
|
||||||
...task,
|
...task,
|
||||||
id: 'temp_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9),
|
id: 'temp_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9),
|
||||||
@@ -201,7 +219,7 @@ export const useCreateChore = () => {
|
|||||||
try {
|
try {
|
||||||
const resp = await CreateChore(newTask)
|
const resp = await CreateChore(newTask)
|
||||||
if (!resp || !resp.ok) {
|
if (!resp || !resp.ok) {
|
||||||
throw new Error('Failed to create chore')
|
throw await errorFromResponse(resp, 'Failed to create chore')
|
||||||
}
|
}
|
||||||
const createdChore = await resp.json()
|
const createdChore = await resp.json()
|
||||||
if (!createdChore) {
|
if (!createdChore) {
|
||||||
@@ -254,7 +272,7 @@ export const useUpdateChore = () => {
|
|||||||
try {
|
try {
|
||||||
const resp = await SaveChore(updatedChore)
|
const resp = await SaveChore(updatedChore)
|
||||||
if (!resp || !resp.ok) {
|
if (!resp || !resp.ok) {
|
||||||
throw new Error('Failed to save chore')
|
throw await errorFromResponse(resp, 'Failed to save chore')
|
||||||
}
|
}
|
||||||
const updatedChoreRes = await resp.json()
|
const updatedChoreRes = await resp.json()
|
||||||
if (!updatedChoreRes) {
|
if (!updatedChoreRes) {
|
||||||
|
|||||||
@@ -415,7 +415,9 @@ const ChoreEdit = () => {
|
|||||||
console.error('Failed to save chore:', error)
|
console.error('Failed to save chore:', error)
|
||||||
showError({
|
showError({
|
||||||
title: 'Save Failed',
|
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) {
|
if (anyone || assignableTo.length === 0) {
|
||||||
setAssignStrategy('no_assignee')
|
setAssignStrategy('no_assignee')
|
||||||
setAssignedTo(null)
|
setAssignedTo(null)
|
||||||
} else {
|
} else if (assignStrategy === 'no_assignee') {
|
||||||
if (!assignableTo.some(a => a.userId === assignedTo)) {
|
// user explicitly picked no_assignee while having assignees, keep it
|
||||||
setAssignedTo(assignableTo[0].userId)
|
// but there is nobody currently assigned
|
||||||
}
|
if (assignedTo !== null) {
|
||||||
if (assignStrategy === 'no_assignee') {
|
setAssignedTo(null)
|
||||||
setAssignStrategy(ASSIGN_STRATEGIES[2]) // default to least_completed
|
|
||||||
}
|
}
|
||||||
|
} else if (!assignableTo.some(a => a.userId === assignedTo)) {
|
||||||
|
setAssignedTo(assignableTo[0].userId)
|
||||||
}
|
}
|
||||||
}, [assignStrategy, assignedTo, assignableTo, anyone])
|
}, [assignStrategy, assignedTo, assignableTo, anyone])
|
||||||
|
|
||||||
@@ -1259,7 +1262,12 @@ const ChoreEdit = () => {
|
|||||||
|
|
||||||
{!anyone && assignableTo.length > 1 && (
|
{!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='h4'>Currently Assigned To</Typography>
|
||||||
<Typography level='body-md'>
|
<Typography level='body-md'>
|
||||||
Who is assigned the next due?
|
Who is assigned the next due?
|
||||||
|
|||||||
@@ -432,6 +432,16 @@ const ArchivedTasks = () => {
|
|||||||
setSelectedChores(newSelection)
|
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 = () => {
|
const selectAllVisibleChores = () => {
|
||||||
if (finalChores.length > 0) {
|
if (finalChores.length > 0) {
|
||||||
setSelectedChores(new Set(finalChores.map(c => c.id)))
|
setSelectedChores(new Set(finalChores.map(c => c.id)))
|
||||||
@@ -1051,6 +1061,7 @@ const ArchivedTasks = () => {
|
|||||||
isMultiSelectMode={isMultiSelectMode}
|
isMultiSelectMode={isMultiSelectMode}
|
||||||
selectedChores={selectedChores}
|
selectedChores={selectedChores}
|
||||||
toggleChoreSelection={toggleChoreSelection}
|
toggleChoreSelection={toggleChoreSelection}
|
||||||
|
onLongPressChore={enterMultiSelectWithChore}
|
||||||
/>
|
/>
|
||||||
</List>
|
</List>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -108,27 +108,29 @@ const ChoreCard = ({
|
|||||||
{getDueDateChipText(chore.nextDueDate, chore, timeFormat)}
|
{getDueDateChipText(chore.nextDueDate, chore, timeFormat)}
|
||||||
</Chip>
|
</Chip>
|
||||||
|
|
||||||
<Chip
|
{!['once', 'no_repeat'].includes(chore.frequencyType) && (
|
||||||
variant='soft'
|
<Chip
|
||||||
sx={{
|
variant='soft'
|
||||||
position: 'relative',
|
sx={{
|
||||||
top: 10,
|
position: 'relative',
|
||||||
zIndex: 3,
|
top: 10,
|
||||||
ml: 0.4,
|
zIndex: 3,
|
||||||
left: 10,
|
ml: 0.4,
|
||||||
}}
|
left: 10,
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{getFrequencyIcon(chore)}
|
<div
|
||||||
{getRecurrentChipText(chore)}
|
style={{
|
||||||
</div>
|
display: 'flex',
|
||||||
</Chip>
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{getFrequencyIcon(chore)}
|
||||||
|
{getRecurrentChipText(chore)}
|
||||||
|
</div>
|
||||||
|
</Chip>
|
||||||
|
)}
|
||||||
|
|
||||||
<Box sx={{ position: 'absolute', top: 10, right: 10, zIndex: 3 }}>
|
<Box sx={{ position: 'absolute', top: 10, right: 10, zIndex: 3 }}>
|
||||||
<PendingBadge commands={pendingCmds} />
|
<PendingBadge commands={pendingCmds} />
|
||||||
|
|||||||
@@ -18,9 +18,60 @@ import {
|
|||||||
} from '@mui/icons-material'
|
} from '@mui/icons-material'
|
||||||
import { Box, Typography } from '@mui/joy'
|
import { Box, Typography } from '@mui/joy'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import { useLongPress } from '../../hooks/useLongPress'
|
||||||
import ChoreCard from './ChoreCard'
|
import ChoreCard from './ChoreCard'
|
||||||
import CompactChoreCard from './CompactChoreCard'
|
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 = ({
|
const ChoreListView = ({
|
||||||
chores,
|
chores,
|
||||||
viewMode,
|
viewMode,
|
||||||
@@ -34,6 +85,7 @@ const ChoreListView = ({
|
|||||||
userProfile,
|
userProfile,
|
||||||
isOfficialInstance,
|
isOfficialInstance,
|
||||||
toggleMultiSelectMode,
|
toggleMultiSelectMode,
|
||||||
|
onLongPressChore,
|
||||||
showActions = true,
|
showActions = true,
|
||||||
}) => {
|
}) => {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
@@ -248,7 +300,7 @@ const ChoreListView = ({
|
|||||||
return (
|
return (
|
||||||
<SwipeableList type={ListType.IOS} fullSwipe={false}>
|
<SwipeableList type={ListType.IOS} fullSwipe={false}>
|
||||||
{chores.map(chore => (
|
{chores.map(chore => (
|
||||||
<SwipeableListItem
|
<ChoreSwipeableItem
|
||||||
key={chore.id}
|
key={chore.id}
|
||||||
trailingActions={getTrailingActions(chore)}
|
trailingActions={getTrailingActions(chore)}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@@ -258,9 +310,11 @@ const ChoreListView = ({
|
|||||||
navigate(`/chores/${chore.id}`)
|
navigate(`/chores/${chore.id}`)
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
longPressEnabled={Boolean(onLongPressChore)}
|
||||||
|
onLongPress={() => onLongPressChore?.(chore.id)}
|
||||||
>
|
>
|
||||||
{renderChoreCard(chore)}
|
{renderChoreCard(chore)}
|
||||||
</SwipeableListItem>
|
</ChoreSwipeableItem>
|
||||||
))}
|
))}
|
||||||
</SwipeableList>
|
</SwipeableList>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -84,7 +84,9 @@ const CompactChoreCard = ({
|
|||||||
const parts = []
|
const parts = []
|
||||||
|
|
||||||
// Frequency
|
// Frequency
|
||||||
parts.push(getRecurrentChipText(chore))
|
if (!['once', 'no_repeat'].includes(chore.frequencyType)) {
|
||||||
|
parts.push(getRecurrentChipText(chore))
|
||||||
|
}
|
||||||
|
|
||||||
// Assignee
|
// Assignee
|
||||||
if (chore.assignedTo) {
|
if (chore.assignedTo) {
|
||||||
@@ -408,7 +410,8 @@ const CompactChoreCard = ({
|
|||||||
|
|
||||||
{/* Line 2: Metadata */}
|
{/* Line 2: Metadata */}
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.25 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.25 }}>
|
||||||
{getFrequencyIcon(chore)}
|
{!['once', 'no_repeat'].includes(chore.frequencyType) &&
|
||||||
|
getFrequencyIcon(chore)}
|
||||||
<Typography
|
<Typography
|
||||||
level='body-xs'
|
level='body-xs'
|
||||||
color='text.secondary'
|
color='text.secondary'
|
||||||
|
|||||||
@@ -121,7 +121,7 @@ const MyChores = () => {
|
|||||||
const [confirmModelConfig, setConfirmModelConfig] = useState({})
|
const [confirmModelConfig, setConfirmModelConfig] = useState({})
|
||||||
|
|
||||||
const { selectedProject, projectsWithDefault, setSelectedProjectWithCache } =
|
const { selectedProject, projectsWithDefault, setSelectedProjectWithCache } =
|
||||||
useProjectFilter(projects)
|
useProjectFilter(projects, !projectsLoading)
|
||||||
|
|
||||||
const {
|
const {
|
||||||
searchTerm,
|
searchTerm,
|
||||||
@@ -143,6 +143,7 @@ const MyChores = () => {
|
|||||||
selectedChores,
|
selectedChores,
|
||||||
toggleMultiSelectMode,
|
toggleMultiSelectMode,
|
||||||
toggleChoreSelection,
|
toggleChoreSelection,
|
||||||
|
enterMultiSelectWithChore,
|
||||||
selectAllVisibleChores,
|
selectAllVisibleChores,
|
||||||
clearSelection,
|
clearSelection,
|
||||||
getSelectedChoresData,
|
getSelectedChoresData,
|
||||||
@@ -366,6 +367,7 @@ const MyChores = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
processEffectAsync()
|
processEffectAsync()
|
||||||
|
// throw new Error('Fake Error to test posthog')
|
||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
membersLoading,
|
membersLoading,
|
||||||
@@ -570,6 +572,7 @@ const MyChores = () => {
|
|||||||
handleBulkArchive,
|
handleBulkArchive,
|
||||||
handleBulkDelete,
|
handleBulkDelete,
|
||||||
handleBulkSkip,
|
handleBulkSkip,
|
||||||
|
handleBulkMoveToProject,
|
||||||
} = useChoreActions({
|
} = useChoreActions({
|
||||||
chores,
|
chores,
|
||||||
filteredChores,
|
filteredChores,
|
||||||
@@ -865,8 +868,8 @@ const MyChores = () => {
|
|||||||
[getFilteredChores],
|
[getFilteredChores],
|
||||||
)
|
)
|
||||||
|
|
||||||
const updateChores = newChore => {
|
const appendChore = (prev, newChore) => {
|
||||||
let newChores = [...chores, newChore]
|
let newChores = [...prev, newChore]
|
||||||
|
|
||||||
if (impersonatedUser) {
|
if (impersonatedUser) {
|
||||||
newChores = newChores.filter(
|
newChores = newChores.filter(
|
||||||
@@ -874,8 +877,15 @@ const MyChores = () => {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
setChores(newChores)
|
return newChores
|
||||||
setFilteredChores(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()
|
clearQuickFilters()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1046,12 +1056,22 @@ const MyChores = () => {
|
|||||||
<MultiSelectToolbar
|
<MultiSelectToolbar
|
||||||
isVisible={isMultiSelectMode}
|
isVisible={isMultiSelectMode}
|
||||||
selectedCount={selectedChores.size}
|
selectedCount={selectedChores.size}
|
||||||
onSelectAll={selectAllVisibleChores}
|
onSelectAll={() =>
|
||||||
|
selectAllVisibleChores(
|
||||||
|
searchTerm?.length > 0 || hasQuickFilters || activeFilterId
|
||||||
|
? getFilteredChores
|
||||||
|
: null,
|
||||||
|
choreSections,
|
||||||
|
openChoreSections,
|
||||||
|
)
|
||||||
|
}
|
||||||
onClear={clearSelection}
|
onClear={clearSelection}
|
||||||
onComplete={handleBulkComplete}
|
onComplete={handleBulkComplete}
|
||||||
onSkip={handleBulkSkip}
|
onSkip={handleBulkSkip}
|
||||||
onArchive={handleBulkArchive}
|
onArchive={handleBulkArchive}
|
||||||
onDelete={handleBulkDelete}
|
onDelete={handleBulkDelete}
|
||||||
|
onMoveToProject={handleBulkMoveToProject}
|
||||||
|
projects={projects}
|
||||||
showKeyboardShortcuts={showKeyboardShortcuts}
|
showKeyboardShortcuts={showKeyboardShortcuts}
|
||||||
selectAllDisabled={
|
selectAllDisabled={
|
||||||
searchTerm?.length > 0 || hasQuickFilters
|
searchTerm?.length > 0 || hasQuickFilters
|
||||||
@@ -1116,6 +1136,7 @@ const MyChores = () => {
|
|||||||
isMultiSelectMode={isMultiSelectMode}
|
isMultiSelectMode={isMultiSelectMode}
|
||||||
selectedChores={selectedChores}
|
selectedChores={selectedChores}
|
||||||
toggleChoreSelection={toggleChoreSelection}
|
toggleChoreSelection={toggleChoreSelection}
|
||||||
|
onLongPressChore={enterMultiSelectWithChore}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{viewMode === 'calendar' && (
|
{viewMode === 'calendar' && (
|
||||||
@@ -1293,6 +1314,7 @@ const MyChores = () => {
|
|||||||
isMultiSelectMode={isMultiSelectMode}
|
isMultiSelectMode={isMultiSelectMode}
|
||||||
selectedChores={selectedChores}
|
selectedChores={selectedChores}
|
||||||
toggleChoreSelection={toggleChoreSelection}
|
toggleChoreSelection={toggleChoreSelection}
|
||||||
|
onLongPressChore={enterMultiSelectWithChore}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
@@ -1373,6 +1395,7 @@ const MyChores = () => {
|
|||||||
isMultiSelectMode={isMultiSelectMode}
|
isMultiSelectMode={isMultiSelectMode}
|
||||||
selectedChores={selectedChores}
|
selectedChores={selectedChores}
|
||||||
toggleChoreSelection={toggleChoreSelection}
|
toggleChoreSelection={toggleChoreSelection}
|
||||||
|
onLongPressChore={enterMultiSelectWithChore}
|
||||||
/>
|
/>
|
||||||
</AccordionDetails>
|
</AccordionDetails>
|
||||||
</Accordion>
|
</Accordion>
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
import { Capacitor } from '@capacitor/core'
|
import { Capacitor } from '@capacitor/core'
|
||||||
import DateModal from '../../Modals/Inputs/DateModal'
|
import DateModal from '../../Modals/Inputs/DateModal'
|
||||||
|
import DueDatePickerModal, {
|
||||||
|
combineDueDate,
|
||||||
|
splitDueDate,
|
||||||
|
} from '../../components/DueDatePickerModal'
|
||||||
import NudgeModal from '../../Modals/Inputs/NudgeModal'
|
import NudgeModal from '../../Modals/Inputs/NudgeModal'
|
||||||
import SelectModal from '../../Modals/Inputs/SelectModal'
|
import SelectModal from '../../Modals/Inputs/SelectModal'
|
||||||
import TextModal from '../../Modals/Inputs/TextModal'
|
import TextModal from '../../Modals/Inputs/TextModal'
|
||||||
@@ -24,13 +28,16 @@ const ChoreModals = ({
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{activeModal === 'changeDueDate' && modalChore && (
|
{activeModal === 'changeDueDate' && modalChore && (
|
||||||
<DateModal
|
<DueDatePickerModal
|
||||||
isOpen={true}
|
open={true}
|
||||||
key={'changeDueDate' + modalChore.id}
|
key={'changeDueDate' + modalChore.id}
|
||||||
current={modalChore.nextDueDate}
|
|
||||||
title='Change due date'
|
title='Change due date'
|
||||||
|
{...splitDueDate(modalChore.nextDueDate)}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
onSave={onChangeDueDate}
|
onApply={parts =>
|
||||||
|
onChangeDueDate(combineDueDate(parts)?.toISOString() ?? null)
|
||||||
|
}
|
||||||
|
onRemove={() => onChangeDueDate(null)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -5,11 +5,39 @@ import {
|
|||||||
Close,
|
Close,
|
||||||
Delete,
|
Delete,
|
||||||
Done,
|
Done,
|
||||||
|
DriveFileMove,
|
||||||
SelectAll,
|
SelectAll,
|
||||||
SkipNext,
|
SkipNext,
|
||||||
} from '@mui/icons-material'
|
} 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 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 = ({
|
const MultiSelectToolbar = ({
|
||||||
isVisible,
|
isVisible,
|
||||||
@@ -20,9 +48,21 @@ const MultiSelectToolbar = ({
|
|||||||
onSkip,
|
onSkip,
|
||||||
onArchive,
|
onArchive,
|
||||||
onDelete,
|
onDelete,
|
||||||
|
onMoveToProject,
|
||||||
|
projects = [],
|
||||||
showKeyboardShortcuts,
|
showKeyboardShortcuts,
|
||||||
selectAllDisabled,
|
selectAllDisabled,
|
||||||
}) => {
|
}) => {
|
||||||
|
const [projectMenuAnchor, setProjectMenuAnchor] = useState(null)
|
||||||
|
const projectMenuRef = useRef(null)
|
||||||
|
|
||||||
|
const closeProjectMenu = () => setProjectMenuAnchor(null)
|
||||||
|
|
||||||
|
const handleMoveToProject = project => {
|
||||||
|
closeProjectMenu()
|
||||||
|
onMoveToProject?.(project)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
@@ -216,6 +256,63 @@ const MultiSelectToolbar = ({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Button>
|
</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
|
<Button
|
||||||
size='sm'
|
size='sm'
|
||||||
variant='soft'
|
variant='soft'
|
||||||
|
|||||||
@@ -1155,6 +1155,61 @@ export const useChoreActions = ({
|
|||||||
setConfirmModelConfig,
|
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 {
|
return {
|
||||||
handleChoreAction,
|
handleChoreAction,
|
||||||
handleChangeDueDate,
|
handleChangeDueDate,
|
||||||
@@ -1166,5 +1221,6 @@ export const useChoreActions = ({
|
|||||||
handleBulkArchive,
|
handleBulkArchive,
|
||||||
handleBulkDelete,
|
handleBulkDelete,
|
||||||
handleBulkSkip,
|
handleBulkSkip,
|
||||||
|
handleBulkMoveToProject,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,6 +26,20 @@ export const useMultiSelect = () => {
|
|||||||
[selectedChores],
|
[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(
|
const selectAllVisibleChores = useCallback(
|
||||||
(visibleChores, choreSections = [], openChoreSections = {}) => {
|
(visibleChores, choreSections = [], openChoreSections = {}) => {
|
||||||
let choresToSelect = []
|
let choresToSelect = []
|
||||||
@@ -42,7 +56,9 @@ export const useMultiSelect = () => {
|
|||||||
expandedChores.every(chore => selectedChores.has(chore.id))
|
expandedChores.every(chore => selectedChores.has(chore.id))
|
||||||
|
|
||||||
if (allExpandedSelected) {
|
if (allExpandedSelected) {
|
||||||
choresToSelect = choreSections.flatMap(section => section.content || [])
|
choresToSelect = choreSections.flatMap(
|
||||||
|
section => section.content || [],
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
choresToSelect = expandedChores
|
choresToSelect = expandedChores
|
||||||
}
|
}
|
||||||
@@ -80,6 +96,7 @@ export const useMultiSelect = () => {
|
|||||||
selectedChores,
|
selectedChores,
|
||||||
toggleMultiSelectMode,
|
toggleMultiSelectMode,
|
||||||
toggleChoreSelection,
|
toggleChoreSelection,
|
||||||
|
enterMultiSelectWithChore,
|
||||||
selectAllVisibleChores,
|
selectAllVisibleChores,
|
||||||
clearSelection,
|
clearSelection,
|
||||||
getSelectedChoresData,
|
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 [selectedProject, setSelectedProject] = useState(() => {
|
||||||
const saved = localStorage.getItem('selectedProject')
|
const saved = localStorage.getItem('selectedProject')
|
||||||
return saved ? JSON.parse(saved) : null
|
return saved ? JSON.parse(saved) : null
|
||||||
@@ -37,6 +37,20 @@ export const useProjectFilter = projects => {
|
|||||||
window.history.replaceState({}, '', newUrl)
|
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 {
|
return {
|
||||||
selectedProject,
|
selectedProject,
|
||||||
projectsWithDefault,
|
projectsWithDefault,
|
||||||
|
|||||||
@@ -222,7 +222,10 @@ const ProjectView = () => {
|
|||||||
const { data: userProfile } = useUserProfile()
|
const { data: userProfile } = useUserProfile()
|
||||||
const { data: chores = { res: [] } } = useChores(false) // false to exclude archived
|
const { data: chores = { res: [] } } = useChores(false) // false to exclude archived
|
||||||
const { data: projectsData = [], isLoading: projectsLoading } = useProjects()
|
const { data: projectsData = [], isLoading: projectsLoading } = useProjects()
|
||||||
const { setSelectedProjectWithCache } = useProjectFilter(projectsData)
|
const { setSelectedProjectWithCache } = useProjectFilter(
|
||||||
|
projectsData,
|
||||||
|
!projectsLoading,
|
||||||
|
)
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
|
||||||
const [userProjects, setUserProjects] = useState([])
|
const [userProjects, setUserProjects] = useState([])
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { useMediaQuery } from '@mui/material'
|
|||||||
import * as chrono from 'chrono-node'
|
import * as chrono from 'chrono-node'
|
||||||
import moment from 'moment'
|
import moment from 'moment'
|
||||||
import { useQueryClient } from '@tanstack/react-query'
|
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 { useResponsiveModal } from '../../hooks/useResponsiveModal'
|
||||||
import { useCreateChore } from '../../queries/ChoreQueries'
|
import { useCreateChore } from '../../queries/ChoreQueries'
|
||||||
import { useCircleMembers, useUserProfile } from '../../queries/UserQueries'
|
import { useCircleMembers, useUserProfile } from '../../queries/UserQueries'
|
||||||
@@ -43,7 +43,7 @@ import RepeatPickerField from './RepeatPickerField'
|
|||||||
import RichTextEditor from './RichTextEditor'
|
import RichTextEditor from './RichTextEditor'
|
||||||
import ScanPanel from './ScanToTask/ScanPanel'
|
import ScanPanel from './ScanToTask/ScanPanel'
|
||||||
import SubTasks from './SubTask'
|
import SubTasks from './SubTask'
|
||||||
import { buildChorePayload } from './VoiceToTask/parseVoiceTask'
|
import { buildChorePayload, parseVoiceTask } from './VoiceToTask/parseVoiceTask'
|
||||||
import VoicePanel from './VoiceToTask/VoicePanel'
|
import VoicePanel from './VoiceToTask/VoicePanel'
|
||||||
const getDefaultNotification = () => {
|
const getDefaultNotification = () => {
|
||||||
const storedDefault = localStorage.getItem('defaultNotificationTemplate')
|
const storedDefault = localStorage.getItem('defaultNotificationTemplate')
|
||||||
@@ -76,6 +76,11 @@ const TaskInput = ({ onChoreUpdate, isModalOpen, onClose, initialMode }) => {
|
|||||||
|
|
||||||
const { data: userProfile } = useUserProfile()
|
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(
|
const handleCreateLabel = useCallback(
|
||||||
name => {
|
name => {
|
||||||
const color =
|
const color =
|
||||||
@@ -145,6 +150,19 @@ const TaskInput = ({ onChoreUpdate, isModalOpen, onClose, initialMode }) => {
|
|||||||
const [llmAvailable, setLlmAvailable] = useState(false)
|
const [llmAvailable, setLlmAvailable] = useState(false)
|
||||||
const [showVoice, setShowVoice] = useState(false)
|
const [showVoice, setShowVoice] = useState(false)
|
||||||
const [voiceAvailable, setVoiceAvailable] = 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()
|
const { isNativeScanner } = useDocumentScanner()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -671,6 +689,7 @@ const TaskInput = ({ onChoreUpdate, isModalOpen, onClose, initialMode }) => {
|
|||||||
// Multiple voice-captured tasks: they were reviewed as cards in the panel,
|
// Multiple voice-captured tasks: they were reviewed as cards in the panel,
|
||||||
// so create them all directly.
|
// so create them all directly.
|
||||||
const handleVoiceCreateMany = async parsedTasks => {
|
const handleVoiceCreateMany = async parsedTasks => {
|
||||||
|
setCreatingVoiceTasks(true)
|
||||||
const notificationTemplates = getDefaultNotification()
|
const notificationTemplates = getDefaultNotification()
|
||||||
for (const parsed of parsedTasks) {
|
for (const parsed of parsedTasks) {
|
||||||
const chore = buildChorePayload(parsed, {
|
const chore = buildChorePayload(parsed, {
|
||||||
@@ -694,13 +713,40 @@ const TaskInput = ({ onChoreUpdate, isModalOpen, onClose, initialMode }) => {
|
|||||||
console.error('Error creating voice task:', error)
|
console.error('Error creating voice task:', error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
setCreatingVoiceTasks(false)
|
||||||
handleCloseModal(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 => {
|
const handleCloseModal = forceRefetch => {
|
||||||
onClose(forceRefetch)
|
onClose(forceRefetch)
|
||||||
setShowScan(false)
|
setShowScan(false)
|
||||||
setShowVoice(false)
|
setShowVoice(false)
|
||||||
|
setVoiceState({ segments: [], isListening: false })
|
||||||
|
setScanState({ phase: 'idle', primaryAction: null })
|
||||||
|
setCreatingVoiceTasks(false)
|
||||||
setTaskText('')
|
setTaskText('')
|
||||||
setTaskTitle('')
|
setTaskTitle('')
|
||||||
setDueDate(null)
|
setDueDate(null)
|
||||||
@@ -847,7 +893,38 @@ const TaskInput = ({ onChoreUpdate, isModalOpen, onClose, initialMode }) => {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Button>
|
</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 && (
|
{!showScan && !showVoice && (
|
||||||
<Button
|
<Button
|
||||||
variant='solid'
|
variant='solid'
|
||||||
@@ -1211,12 +1288,10 @@ const TaskInput = ({ onChoreUpdate, isModalOpen, onClose, initialMode }) => {
|
|||||||
|
|
||||||
{showVoice && (
|
{showVoice && (
|
||||||
<VoicePanel
|
<VoicePanel
|
||||||
open
|
userLabels={voiceLabels}
|
||||||
userLabels={userLabels || []}
|
members={voiceMembers}
|
||||||
members={circleMembers?.res || []}
|
|
||||||
userProfile={userProfile}
|
userProfile={userProfile}
|
||||||
onUseSingle={handleVoiceSingle}
|
onStateChange={setVoiceState}
|
||||||
onCreateMany={handleVoiceCreateMany}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -1226,10 +1301,12 @@ const TaskInput = ({ onChoreUpdate, isModalOpen, onClose, initialMode }) => {
|
|||||||
autoCapture={scanAutoCapture}
|
autoCapture={scanAutoCapture}
|
||||||
onTaskExtracted={handleTaskExtracted}
|
onTaskExtracted={handleTaskExtracted}
|
||||||
initialImageUrl={pendingPhotoUrl}
|
initialImageUrl={pendingPhotoUrl}
|
||||||
|
onStateChange={setScanState}
|
||||||
onClose={() => {
|
onClose={() => {
|
||||||
setShowScan(false)
|
setShowScan(false)
|
||||||
setScanAutoCapture(false)
|
setScanAutoCapture(false)
|
||||||
setPendingPhotoUrl(null)
|
setPendingPhotoUrl(null)
|
||||||
|
setScanState({ phase: 'idle', primaryAction: null })
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -26,6 +26,9 @@ import {
|
|||||||
Avatar,
|
Avatar,
|
||||||
Divider,
|
Divider,
|
||||||
IconButton,
|
IconButton,
|
||||||
|
List,
|
||||||
|
ListItem,
|
||||||
|
ListItemButton,
|
||||||
ListItemContent,
|
ListItemContent,
|
||||||
ListItemDecorator,
|
ListItemDecorator,
|
||||||
Menu,
|
Menu,
|
||||||
@@ -33,8 +36,10 @@ import {
|
|||||||
Tooltip,
|
Tooltip,
|
||||||
Typography,
|
Typography,
|
||||||
} from '@mui/joy'
|
} from '@mui/joy'
|
||||||
|
import { useMediaQuery } from '@mui/material'
|
||||||
import React, { useEffect, useState } from 'react'
|
import React, { useEffect, useState } from 'react'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import AppModal from '../../components/common/AppModal'
|
||||||
import LABEL_COLORS, {
|
import LABEL_COLORS, {
|
||||||
getTextColorFromBackgroundColor,
|
getTextColorFromBackgroundColor,
|
||||||
} from '../../utils/Colors'
|
} from '../../utils/Colors'
|
||||||
@@ -64,6 +69,8 @@ const ChoreActionMenu = ({
|
|||||||
const menuRef = React.useRef(null)
|
const menuRef = React.useRef(null)
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const { data: projects = [] } = useProjects()
|
const { data: projects = [] } = useProjects()
|
||||||
|
// Phone-only condition (matches AddTaskModal.jsx) — tablets/desktop keep the Menu
|
||||||
|
const isSmallScreen = useMediaQuery(theme => theme.breakpoints.down('sm'))
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
try {
|
try {
|
||||||
@@ -75,11 +82,19 @@ const ChoreActionMenu = ({
|
|||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (isSmallScreen) {
|
||||||
|
// AppModal owns its own backdrop/escape close behavior on small screens.
|
||||||
|
if (anchorEl && onOpen) {
|
||||||
|
onOpen()
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const handleMenuOutsideClick = event => {
|
const handleMenuOutsideClick = event => {
|
||||||
if (
|
if (
|
||||||
anchorEl &&
|
anchorEl &&
|
||||||
!anchorEl.contains(event.target) &&
|
!anchorEl.contains(event.target) &&
|
||||||
!menuRef.current.contains(event.target)
|
!menuRef.current?.contains(event.target)
|
||||||
) {
|
) {
|
||||||
handleMenuClose()
|
handleMenuClose()
|
||||||
}
|
}
|
||||||
@@ -92,7 +107,7 @@ const ChoreActionMenu = ({
|
|||||||
return () => {
|
return () => {
|
||||||
document.removeEventListener('mousedown', handleMenuOutsideClick)
|
document.removeEventListener('mousedown', handleMenuOutsideClick)
|
||||||
}
|
}
|
||||||
}, [anchorEl, onOpen])
|
}, [anchorEl, onOpen, isSmallScreen])
|
||||||
|
|
||||||
const handleMenuOpen = event => {
|
const handleMenuOpen = event => {
|
||||||
event.stopPropagation()
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<IconButton
|
<IconButton
|
||||||
@@ -249,280 +513,95 @@ const ChoreActionMenu = ({
|
|||||||
<MoreVert />
|
<MoreVert />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
|
|
||||||
<Menu
|
{isSmallScreen ? (
|
||||||
size='md'
|
<AppModal
|
||||||
ref={menuRef}
|
open={Boolean(anchorEl)}
|
||||||
anchorEl={anchorEl}
|
onClose={handleMenuClose}
|
||||||
open={Boolean(anchorEl)}
|
title={showProjectPicker ? 'Move to project' : chore?.name}
|
||||||
onClose={handleMenuClose}
|
mobilePresentation='sheet'
|
||||||
sx={{
|
showHandle
|
||||||
position: 'absolute',
|
contentSx={{ px: 0, pb: 1 }}
|
||||||
top: '100%',
|
>
|
||||||
left: '50%',
|
{showProjectPicker && (
|
||||||
}}
|
|
||||||
>
|
|
||||||
{showProjectPicker ? (
|
|
||||||
<>
|
|
||||||
<MenuItem
|
<MenuItem
|
||||||
onClick={e => {
|
onClick={() => setShowProjectPicker(false)}
|
||||||
e.stopPropagation()
|
sx={{ gap: 1, mx: 2, mb: 1 }}
|
||||||
setShowProjectPicker(false)
|
|
||||||
}}
|
|
||||||
sx={{ gap: 1 }}
|
|
||||||
>
|
>
|
||||||
<ArrowBack fontSize='small' />
|
<ArrowBack fontSize='small' />
|
||||||
<Typography level='body-sm' fontWeight={600}>
|
<Typography level='body-sm' fontWeight={600}>
|
||||||
Move to project
|
Back
|
||||||
</Typography>
|
</Typography>
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
<Divider />
|
)}
|
||||||
<MenuItem
|
<List sx={{ '--ListItem-radius': '8px', px: 1 }}>
|
||||||
onClick={e => {
|
{showProjectPicker
|
||||||
e.stopPropagation()
|
? renderModalProjectPicker()
|
||||||
handleMoveToProject({ id: null, name: 'Default Project' })
|
: renderModalActionItems()}
|
||||||
}}
|
</List>
|
||||||
>
|
</AppModal>
|
||||||
<ListItemDecorator>
|
) : (
|
||||||
{renderProjectAvatar(LABEL_COLORS[0].value, 'FolderOpen')}
|
<Menu
|
||||||
</ListItemDecorator>
|
size='md'
|
||||||
<ListItemContent>
|
ref={menuRef}
|
||||||
<Typography level='body-sm'>Default Project</Typography>
|
anchorEl={anchorEl}
|
||||||
</ListItemContent>
|
open={Boolean(anchorEl)}
|
||||||
</MenuItem>
|
onClose={handleMenuClose}
|
||||||
{projects.map(project => (
|
sx={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: '100%',
|
||||||
|
left: '50%',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{showProjectPicker ? (
|
||||||
|
<>
|
||||||
<MenuItem
|
<MenuItem
|
||||||
key={project.id}
|
|
||||||
onClick={e => {
|
onClick={e => {
|
||||||
e.stopPropagation()
|
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>
|
<ListItemDecorator>
|
||||||
{renderProjectAvatar(project.color, project.icon)}
|
{renderProjectAvatar(LABEL_COLORS[0].value, 'FolderOpen')}
|
||||||
</ListItemDecorator>
|
</ListItemDecorator>
|
||||||
<ListItemContent>
|
<ListItemContent>
|
||||||
<Typography level='body-sm'>{project.name}</Typography>
|
<Typography level='body-sm'>Default Project</Typography>
|
||||||
</ListItemContent>
|
</ListItemContent>
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
))}
|
{projects.map(project => (
|
||||||
</>
|
<MenuItem
|
||||||
) : (
|
key={project.id}
|
||||||
<>
|
|
||||||
<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'
|
|
||||||
onClick={e => {
|
onClick={e => {
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
handleQuickSchedule('today')
|
handleMoveToProject(project)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Today />
|
<ListItemDecorator>
|
||||||
</IconButton>
|
{renderProjectAvatar(project.color, project.icon)}
|
||||||
</Tooltip>
|
</ListItemDecorator>
|
||||||
<Tooltip title='Tomorrow' placement='top'>
|
<ListItemContent>
|
||||||
<IconButton
|
<Typography level='body-sm'>{project.name}</Typography>
|
||||||
size='sm'
|
</ListItemContent>
|
||||||
onClick={e => {
|
</MenuItem>
|
||||||
e.stopPropagation()
|
))}
|
||||||
handleQuickSchedule('tomorrow')
|
</>
|
||||||
}}
|
) : (
|
||||||
>
|
renderMenuActionItems()
|
||||||
<WbSunny />
|
)}
|
||||||
</IconButton>
|
</Menu>
|
||||||
</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>
|
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,32 +1,8 @@
|
|||||||
import {
|
import { CalendarMonth, Close } from '@mui/icons-material'
|
||||||
Bedtime,
|
import { Box, Button, IconButton, Typography } from '@mui/joy'
|
||||||
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 moment from 'moment'
|
import moment from 'moment'
|
||||||
import { useEffect, useMemo, useState } from 'react'
|
import { useMemo, useState } from 'react'
|
||||||
import Calendar from 'react-calendar'
|
import DueDatePickerModal from './DueDatePickerModal'
|
||||||
import ModalActions from '../../components/common/ModalActions'
|
|
||||||
import { useLocalization } from '../../contexts/LocalizationContext'
|
|
||||||
import { useResponsiveModal } from '../../hooks/useResponsiveModal'
|
|
||||||
|
|
||||||
const DueDatePickerField = ({
|
const DueDatePickerField = ({
|
||||||
dueDateOnly,
|
dueDateOnly,
|
||||||
@@ -40,106 +16,17 @@ const DueDatePickerField = ({
|
|||||||
size = 'sm',
|
size = 'sm',
|
||||||
}) => {
|
}) => {
|
||||||
const [isOpen, setIsOpen] = useState(false)
|
const [isOpen, setIsOpen] = useState(false)
|
||||||
const { ResponsiveModal } = useResponsiveModal()
|
|
||||||
const { firstDayOfWeek } = useLocalization()
|
|
||||||
|
|
||||||
// Local buffered state — only committed on Apply
|
const handleSave = ({
|
||||||
const [localDueDateOnly, setLocalDueDateOnly] = useState(dueDateOnly)
|
dueDateOnly: nextDate,
|
||||||
const [localDueTime, setLocalDueTime] = useState(dueTime)
|
dueTime: nextTime,
|
||||||
const [localUseCustomTime, setLocalUseCustomTime] = useState(useCustomTime)
|
useCustomTime: nextUseCustomTime,
|
||||||
|
}) => {
|
||||||
// Sync local state from props whenever the modal opens
|
onDueDateChange?.({ target: { value: nextDate || '' } })
|
||||||
useEffect(() => {
|
onUseCustomTimeChange?.(nextUseCustomTime)
|
||||||
if (isOpen) {
|
onDueTimeChange?.({
|
||||||
setLocalDueDateOnly(dueDateOnly)
|
target: { value: nextUseCustomTime && nextTime ? nextTime : '' },
|
||||||
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: '' } })
|
|
||||||
}
|
|
||||||
setIsOpen(false)
|
setIsOpen(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -227,385 +114,22 @@ const DueDatePickerField = ({
|
|||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<ResponsiveModal
|
<DueDatePickerModal
|
||||||
open={isOpen}
|
open={isOpen}
|
||||||
onClose={() => setIsOpen(false)}
|
onClose={() => setIsOpen(false)}
|
||||||
title='Due Date'
|
dueDateOnly={dueDateOnly}
|
||||||
fullWidth={false}
|
dueTime={dueTime}
|
||||||
footer={
|
useCustomTime={useCustomTime}
|
||||||
<ModalActions
|
onApply={handleSave}
|
||||||
tertiary={
|
onRemove={
|
||||||
hasDueDate
|
onClear
|
||||||
? {
|
? () => {
|
||||||
label: 'Remove',
|
onClear()
|
||||||
color: 'danger',
|
setIsOpen(false)
|
||||||
onClick: () => {
|
}
|
||||||
onClear?.()
|
: undefined
|
||||||
setIsOpen(false)
|
|
||||||
},
|
|
||||||
}
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
secondary={{ label: 'Cancel', onClick: () => setIsOpen(false) }}
|
|
||||||
primary={{ label: 'Apply', 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 = 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,
|
LinearProgress,
|
||||||
Typography,
|
Typography,
|
||||||
} from '@mui/joy'
|
} from '@mui/joy'
|
||||||
import { useEffect } from 'react'
|
import { useCallback, useEffect, useMemo } from 'react'
|
||||||
import { useScanToTask } from './useScanToTask'
|
import { useScanToTask } from './useScanToTask'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -20,8 +20,20 @@ import { useScanToTask } from './useScanToTask'
|
|||||||
*
|
*
|
||||||
* Flow: capture → (auto) processing → done [calls onTaskExtracted + onClose]
|
* Flow: capture → (auto) processing → done [calls onTaskExtracted + onClose]
|
||||||
* → error [retake or cancel]
|
* → 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 {
|
const {
|
||||||
isNativeScanner,
|
isNativeScanner,
|
||||||
phase,
|
phase,
|
||||||
@@ -76,6 +88,51 @@ const ScanPanel = ({ open, onTaskExtracted, onClose, initialImageUrl, autoCaptur
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [phase, taskResult])
|
}, [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
|
if (!open) return null
|
||||||
|
|
||||||
const isProcessing = phase === 'processing'
|
const isProcessing = phase === 'processing'
|
||||||
@@ -111,7 +168,10 @@ const ScanPanel = ({ open, onTaskExtracted, onClose, initialImageUrl, autoCaptur
|
|||||||
<DocumentScanner
|
<DocumentScanner
|
||||||
sx={{ fontSize: 56, color: 'white', opacity: 0.5, mb: 1 }}
|
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
|
Tap "Scan Document" to open the scanner
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
@@ -137,65 +197,38 @@ const ScanPanel = ({ open, onTaskExtracted, onClose, initialImageUrl, autoCaptur
|
|||||||
<CameraAlt
|
<CameraAlt
|
||||||
sx={{ fontSize: 48, color: 'white', opacity: 0.4, mb: 1 }}
|
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
|
Camera not available — use Upload instead
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Box
|
{/* Hidden when Upload is already the footer's primary action */}
|
||||||
sx={{
|
{(isNativeScanner || cameraAvailable) && (
|
||||||
px: 1.5,
|
<Box
|
||||||
py: 1,
|
sx={{
|
||||||
display: 'flex',
|
px: 1.5,
|
||||||
alignItems: 'center',
|
py: 1,
|
||||||
gap: 1,
|
display: 'flex',
|
||||||
}}
|
alignItems: 'center',
|
||||||
>
|
gap: 1,
|
||||||
<Button
|
}}
|
||||||
size='sm'
|
|
||||||
variant='plain'
|
|
||||||
color='neutral'
|
|
||||||
startDecorator={<PhotoCamera fontSize='small' />}
|
|
||||||
onClick={() => fileInputRef.current?.click()}
|
|
||||||
>
|
>
|
||||||
Upload
|
<Button
|
||||||
</Button>
|
size='sm'
|
||||||
<input
|
variant='plain'
|
||||||
ref={fileInputRef}
|
color='neutral'
|
||||||
type='file'
|
startDecorator={<PhotoCamera fontSize='small' />}
|
||||||
accept='image/*'
|
onClick={openFilePicker}
|
||||||
style={{ display: 'none' }}
|
>
|
||||||
onChange={handleFileSelect}
|
Upload
|
||||||
/>
|
</Button>
|
||||||
|
|
||||||
<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>
|
|
||||||
)
|
|
||||||
)}
|
|
||||||
</Box>
|
</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 }} />
|
<WarningAmber color='warning' sx={{ mt: 0.25, flexShrink: 0 }} />
|
||||||
<Typography level='body-sm'>{errorMsg}</Typography>
|
<Typography level='body-sm'>{errorMsg}</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
<Box sx={{ display: 'flex', gap: 1 }}>
|
|
||||||
<Button
|
|
||||||
size='sm'
|
|
||||||
variant='outlined'
|
|
||||||
color='neutral'
|
|
||||||
startDecorator={<Replay fontSize='small' />}
|
|
||||||
onClick={retake}
|
|
||||||
>
|
|
||||||
Retake
|
|
||||||
</Button>
|
|
||||||
</Box>
|
|
||||||
</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' }} />
|
<canvas ref={canvasRef} style={{ display: 'none' }} />
|
||||||
</Box>
|
</Box>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -401,18 +401,18 @@ const TaskPreviewCard = ({
|
|||||||
/**
|
/**
|
||||||
* Inline voice-to-task panel. Mounts inside AddTaskModal — no second modal.
|
* Inline voice-to-task panel. Mounts inside AddTaskModal — no second modal.
|
||||||
*
|
*
|
||||||
* Opens straight into hands-free listening. Pauses and spoken separators
|
* Mounted only while voice capture is active, and opens straight into
|
||||||
* ("also") split the transcript into task cards; tapping a card opens inline
|
* hands-free listening. Pauses and spoken separators ("also") split the
|
||||||
* pickers whose edits override the parsed values. A single captured task
|
* transcript into task cards; tapping a card opens inline pickers whose edits
|
||||||
* lands in the smart input for review; multiple are created directly.
|
* 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 = ({
|
const VoicePanel = ({
|
||||||
open,
|
|
||||||
userLabels = [],
|
userLabels = [],
|
||||||
members = [],
|
members = [],
|
||||||
userProfile,
|
userProfile,
|
||||||
onUseSingle,
|
onStateChange,
|
||||||
onCreateMany,
|
|
||||||
}) => {
|
}) => {
|
||||||
const {
|
const {
|
||||||
phase,
|
phase,
|
||||||
@@ -427,8 +427,6 @@ const VoicePanel = ({
|
|||||||
patchSegment,
|
patchSegment,
|
||||||
isNative,
|
isNative,
|
||||||
} = useVoiceToTask({ members, userLabels })
|
} = useVoiceToTask({ members, userLabels })
|
||||||
const [creating, setCreating] = useState(false)
|
|
||||||
const autoStartedRef = useRef(false)
|
|
||||||
const segmentsScrollRef = useRef(null)
|
const segmentsScrollRef = useRef(null)
|
||||||
|
|
||||||
const parseCtx = useMemo(
|
const parseCtx = useMemo(
|
||||||
@@ -441,14 +439,12 @@ const VoicePanel = ({
|
|||||||
[partialText, parseCtx],
|
[partialText, parseCtx],
|
||||||
)
|
)
|
||||||
|
|
||||||
// Start capturing the moment the panel opens — the mic tap that opened it
|
// Start capturing the moment the panel mounts — the mic tap that opened it
|
||||||
// is the only tap needed
|
// is the only tap needed. startHandsFree no-ops if already listening.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (open && !autoStartedRef.current) {
|
startHandsFree()
|
||||||
autoStartedRef.current = true
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
startHandsFree()
|
}, [])
|
||||||
}
|
|
||||||
}, [open, startHandsFree])
|
|
||||||
|
|
||||||
// Keep the newest captured task visible as more are added
|
// Keep the newest captured task visible as more are added
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -456,24 +452,14 @@ const VoicePanel = ({
|
|||||||
if (el) el.scrollTop = el.scrollHeight
|
if (el) el.scrollTop = el.scrollHeight
|
||||||
}, [segments.length])
|
}, [segments.length])
|
||||||
|
|
||||||
if (!open) return null
|
|
||||||
|
|
||||||
const isListening = phase === 'listening'
|
const isListening = phase === 'listening'
|
||||||
const showActions = segments.length > 0 && !isListening && !creating
|
|
||||||
|
|
||||||
const mergedTask = segment => ({
|
// The confirm action lives in the modal footer, so report the raw segments
|
||||||
...parseVoiceTask(segment.text, parseCtx),
|
// and whether the mic is live — that's all it needs to label and enable the
|
||||||
...(segment.overrides || {}),
|
// button. It parses the segments itself when the user confirms.
|
||||||
})
|
useEffect(() => {
|
||||||
|
onStateChange?.({ segments, isListening })
|
||||||
const handleCreateAll = async () => {
|
}, [segments, isListening, onStateChange])
|
||||||
setCreating(true)
|
|
||||||
try {
|
|
||||||
await onCreateMany(segments.map(mergedTask))
|
|
||||||
} finally {
|
|
||||||
setCreating(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const micCaption = isListening
|
const micCaption = isListening
|
||||||
? isLocked
|
? isLocked
|
||||||
@@ -642,47 +628,6 @@ const VoicePanel = ({
|
|||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</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>
|
</Box>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user