From b2949b2dd96241c6057d521981d62cb7dbd9ac1f Mon Sep 17 00:00:00 2001 From: Mohamad Tarbin Date: Sun, 2 Aug 2026 16:14:52 -0400 Subject: [PATCH] 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 --- src/hooks/useLongPress.js | 120 ++++ src/queries/ChoreQueries.jsx | 22 +- src/views/ChoreEdit/ChoreEdit.jsx | 24 +- src/views/Chores/ArchivedTasks.jsx | 11 + src/views/Chores/ChoreCard.jsx | 40 +- src/views/Chores/ChoreListView.jsx | 58 +- src/views/Chores/CompactChoreCard.jsx | 7 +- src/views/Chores/MyChores.jsx | 35 +- src/views/Chores/components/ChoreModals.jsx | 15 +- .../Chores/components/MultiSelectToolbar.jsx | 99 ++- src/views/Chores/hooks/useChoreActions.js | 56 ++ src/views/Chores/hooks/useMultiSelect.js | 19 +- src/views/Chores/hooks/useProjectFilter.js | 18 +- src/views/Projects/ProjectView.jsx | 5 +- src/views/components/AddTaskModal.jsx | 93 ++- src/views/components/ChoreActionMenu.jsx | 591 ++++++++++-------- src/views/components/DueDatePickerField.jsx | 530 +--------------- src/views/components/DueDatePickerModal.jsx | 544 ++++++++++++++++ src/views/components/ScanToTask/ScanPanel.jsx | 163 +++-- .../components/VoiceToTask/VoicePanel.jsx | 91 +-- 20 files changed, 1587 insertions(+), 954 deletions(-) create mode 100644 src/hooks/useLongPress.js create mode 100644 src/views/components/DueDatePickerModal.jsx diff --git a/src/hooks/useLongPress.js b/src/hooks/useLongPress.js new file mode 100644 index 0000000..0ef8ab2 --- /dev/null +++ b/src/hooks/useLongPress.js @@ -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 diff --git a/src/queries/ChoreQueries.jsx b/src/queries/ChoreQueries.jsx index 38cdb1f..599aa27 100644 --- a/src/queries/ChoreQueries.jsx +++ b/src/queries/ChoreQueries.jsx @@ -67,6 +67,24 @@ const isNetworkError = error => ((error instanceof TypeError && error.message === 'Failed to fetch') || error?.name === 'AbortError') +// The backend returns { error: "..." } on failures. Surface that message when +// it is there, flagged so callers can tell it apart from our generic fallback. +const errorFromResponse = async (resp, fallbackMessage) => { + if (!resp) return new Error(fallbackMessage) + let serverMessage = null + try { + const body = await resp.json() + if (typeof body?.error === 'string' && body.error.trim() !== '') { + serverMessage = body.error + } + } catch { + // body was empty or not JSON, keep the fallback + } + const error = new Error(serverMessage || fallbackMessage) + error.isServerMessage = Boolean(serverMessage) + return error +} + const buildOfflineChore = task => ({ ...task, id: 'temp_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9), @@ -201,7 +219,7 @@ export const useCreateChore = () => { try { const resp = await CreateChore(newTask) if (!resp || !resp.ok) { - throw new Error('Failed to create chore') + throw await errorFromResponse(resp, 'Failed to create chore') } const createdChore = await resp.json() if (!createdChore) { @@ -254,7 +272,7 @@ export const useUpdateChore = () => { try { const resp = await SaveChore(updatedChore) if (!resp || !resp.ok) { - throw new Error('Failed to save chore') + throw await errorFromResponse(resp, 'Failed to save chore') } const updatedChoreRes = await resp.json() if (!updatedChoreRes) { diff --git a/src/views/ChoreEdit/ChoreEdit.jsx b/src/views/ChoreEdit/ChoreEdit.jsx index 52afcf7..142a93c 100644 --- a/src/views/ChoreEdit/ChoreEdit.jsx +++ b/src/views/ChoreEdit/ChoreEdit.jsx @@ -415,7 +415,9 @@ const ChoreEdit = () => { console.error('Failed to save chore:', error) showError({ title: 'Save Failed', - message: 'Failed to save chore, please try again.', + message: error?.isServerMessage + ? error.message + : 'Failed to save chore, please try again.', }) }) } @@ -634,13 +636,14 @@ const ChoreEdit = () => { if (anyone || assignableTo.length === 0) { setAssignStrategy('no_assignee') setAssignedTo(null) - } else { - if (!assignableTo.some(a => a.userId === assignedTo)) { - setAssignedTo(assignableTo[0].userId) - } - if (assignStrategy === 'no_assignee') { - setAssignStrategy(ASSIGN_STRATEGIES[2]) // default to least_completed + } else if (assignStrategy === 'no_assignee') { + // user explicitly picked no_assignee while having assignees, keep it + // but there is nobody currently assigned + if (assignedTo !== null) { + setAssignedTo(null) } + } else if (!assignableTo.some(a => a.userId === assignedTo)) { + setAssignedTo(assignableTo[0].userId) } }, [assignStrategy, assignedTo, assignableTo, anyone]) @@ -1259,7 +1262,12 @@ const ChoreEdit = () => { {!anyone && assignableTo.length > 1 && ( <> - + Currently Assigned To Who is assigned the next due? diff --git a/src/views/Chores/ArchivedTasks.jsx b/src/views/Chores/ArchivedTasks.jsx index a2b1d10..dc79a35 100644 --- a/src/views/Chores/ArchivedTasks.jsx +++ b/src/views/Chores/ArchivedTasks.jsx @@ -432,6 +432,16 @@ const ArchivedTasks = () => { setSelectedChores(newSelection) } + // Press-and-hold on a task card enters multi-select with that task picked + const enterMultiSelectWithChore = choreId => { + if (!isMultiSelectMode) { + setIsMultiSelectMode(true) + setSelectedChores(new Set([choreId])) + return + } + toggleChoreSelection(choreId) + } + const selectAllVisibleChores = () => { if (finalChores.length > 0) { setSelectedChores(new Set(finalChores.map(c => c.id))) @@ -1051,6 +1061,7 @@ const ArchivedTasks = () => { isMultiSelectMode={isMultiSelectMode} selectedChores={selectedChores} toggleChoreSelection={toggleChoreSelection} + onLongPressChore={enterMultiSelectWithChore} /> diff --git a/src/views/Chores/ChoreCard.jsx b/src/views/Chores/ChoreCard.jsx index 8c955d3..263eba8 100644 --- a/src/views/Chores/ChoreCard.jsx +++ b/src/views/Chores/ChoreCard.jsx @@ -108,27 +108,29 @@ const ChoreCard = ({ {getDueDateChipText(chore.nextDueDate, chore, timeFormat)} - -
- {getFrequencyIcon(chore)} - {getRecurrentChipText(chore)} -
-
+
+ {getFrequencyIcon(chore)} + {getRecurrentChipText(chore)} +
+ + )} diff --git a/src/views/Chores/ChoreListView.jsx b/src/views/Chores/ChoreListView.jsx index 5825a24..69b5abb 100644 --- a/src/views/Chores/ChoreListView.jsx +++ b/src/views/Chores/ChoreListView.jsx @@ -18,9 +18,60 @@ import { } from '@mui/icons-material' import { Box, Typography } from '@mui/joy' import { useNavigate } from 'react-router-dom' +import { useLongPress } from '../../hooks/useLongPress' import ChoreCard from './ChoreCard' import CompactChoreCard from './CompactChoreCard' +/** + * One swipeable row. Owns the press-and-hold gesture (multi-select), which + * can't live in the render loop because it needs a hook. + */ +const ChoreSwipeableItem = ({ + trailingActions, + onClick, + onLongPress, + longPressEnabled, + children, + // SwipeableList clones its children to inject list-level config + // (listType, fullSwipe, thresholds…), so it has to be passed through. + ...listProps +}) => { + const { handlers: longPressHandlers, cancel: cancelLongPress } = useLongPress( + onLongPress, + { enabled: longPressEnabled }, + ) + + // The swipe list owns the gesture the moment it recognizes a drag — a hold + // that turned into a swipe must not also open multi-select. + const handleSwipeStart = () => { + cancelLongPress() + } + + return ( + + + {children} + + + ) +} + const ChoreListView = ({ chores, viewMode, @@ -34,6 +85,7 @@ const ChoreListView = ({ userProfile, isOfficialInstance, toggleMultiSelectMode, + onLongPressChore, showActions = true, }) => { const navigate = useNavigate() @@ -248,7 +300,7 @@ const ChoreListView = ({ return ( {chores.map(chore => ( - { @@ -258,9 +310,11 @@ const ChoreListView = ({ navigate(`/chores/${chore.id}`) } }} + longPressEnabled={Boolean(onLongPressChore)} + onLongPress={() => onLongPressChore?.(chore.id)} > {renderChoreCard(chore)} - + ))} ) diff --git a/src/views/Chores/CompactChoreCard.jsx b/src/views/Chores/CompactChoreCard.jsx index f92d55d..e760086 100644 --- a/src/views/Chores/CompactChoreCard.jsx +++ b/src/views/Chores/CompactChoreCard.jsx @@ -84,7 +84,9 @@ const CompactChoreCard = ({ const parts = [] // Frequency - parts.push(getRecurrentChipText(chore)) + if (!['once', 'no_repeat'].includes(chore.frequencyType)) { + parts.push(getRecurrentChipText(chore)) + } // Assignee if (chore.assignedTo) { @@ -408,7 +410,8 @@ const CompactChoreCard = ({ {/* Line 2: Metadata */} - {getFrequencyIcon(chore)} + {!['once', 'no_repeat'].includes(chore.frequencyType) && + getFrequencyIcon(chore)} { const [confirmModelConfig, setConfirmModelConfig] = useState({}) const { selectedProject, projectsWithDefault, setSelectedProjectWithCache } = - useProjectFilter(projects) + useProjectFilter(projects, !projectsLoading) const { searchTerm, @@ -143,6 +143,7 @@ const MyChores = () => { selectedChores, toggleMultiSelectMode, toggleChoreSelection, + enterMultiSelectWithChore, selectAllVisibleChores, clearSelection, getSelectedChoresData, @@ -366,6 +367,7 @@ const MyChores = () => { } processEffectAsync() + // throw new Error('Fake Error to test posthog') } }, [ membersLoading, @@ -570,6 +572,7 @@ const MyChores = () => { handleBulkArchive, handleBulkDelete, handleBulkSkip, + handleBulkMoveToProject, } = useChoreActions({ chores, filteredChores, @@ -865,8 +868,8 @@ const MyChores = () => { [getFilteredChores], ) - const updateChores = newChore => { - let newChores = [...chores, newChore] + const appendChore = (prev, newChore) => { + let newChores = [...prev, newChore] if (impersonatedUser) { newChores = newChores.filter( @@ -874,8 +877,15 @@ const MyChores = () => { ) } - setChores(newChores) - setFilteredChores(newChores) + return newChores + } + + // Uses functional setState so back-to-back calls (e.g. creating several + // voice-captured tasks in a row) each build on the latest state instead of + // a closure snapshot taken before earlier calls landed. + const updateChores = newChore => { + setChores(prev => appendChore(prev, newChore)) + setFilteredChores(prev => appendChore(prev, newChore)) clearQuickFilters() } @@ -1046,12 +1056,22 @@ const MyChores = () => { + selectAllVisibleChores( + searchTerm?.length > 0 || hasQuickFilters || activeFilterId + ? getFilteredChores + : null, + choreSections, + openChoreSections, + ) + } onClear={clearSelection} onComplete={handleBulkComplete} onSkip={handleBulkSkip} onArchive={handleBulkArchive} onDelete={handleBulkDelete} + onMoveToProject={handleBulkMoveToProject} + projects={projects} showKeyboardShortcuts={showKeyboardShortcuts} selectAllDisabled={ searchTerm?.length > 0 || hasQuickFilters @@ -1116,6 +1136,7 @@ const MyChores = () => { isMultiSelectMode={isMultiSelectMode} selectedChores={selectedChores} toggleChoreSelection={toggleChoreSelection} + onLongPressChore={enterMultiSelectWithChore} /> )} {viewMode === 'calendar' && ( @@ -1293,6 +1314,7 @@ const MyChores = () => { isMultiSelectMode={isMultiSelectMode} selectedChores={selectedChores} toggleChoreSelection={toggleChoreSelection} + onLongPressChore={enterMultiSelectWithChore} /> )} @@ -1373,6 +1395,7 @@ const MyChores = () => { isMultiSelectMode={isMultiSelectMode} selectedChores={selectedChores} toggleChoreSelection={toggleChoreSelection} + onLongPressChore={enterMultiSelectWithChore} /> diff --git a/src/views/Chores/components/ChoreModals.jsx b/src/views/Chores/components/ChoreModals.jsx index 4f31ad0..cc23cb0 100644 --- a/src/views/Chores/components/ChoreModals.jsx +++ b/src/views/Chores/components/ChoreModals.jsx @@ -1,5 +1,9 @@ import { Capacitor } from '@capacitor/core' import DateModal from '../../Modals/Inputs/DateModal' +import DueDatePickerModal, { + combineDueDate, + splitDueDate, +} from '../../components/DueDatePickerModal' import NudgeModal from '../../Modals/Inputs/NudgeModal' import SelectModal from '../../Modals/Inputs/SelectModal' import TextModal from '../../Modals/Inputs/TextModal' @@ -24,13 +28,16 @@ const ChoreModals = ({ return ( <> {activeModal === 'changeDueDate' && modalChore && ( - + onChangeDueDate(combineDueDate(parts)?.toISOString() ?? null) + } + onRemove={() => onChangeDueDate(null)} /> )} diff --git a/src/views/Chores/components/MultiSelectToolbar.jsx b/src/views/Chores/components/MultiSelectToolbar.jsx index 975c828..cc8334b 100644 --- a/src/views/Chores/components/MultiSelectToolbar.jsx +++ b/src/views/Chores/components/MultiSelectToolbar.jsx @@ -5,11 +5,39 @@ import { Close, Delete, Done, + DriveFileMove, SelectAll, SkipNext, } from '@mui/icons-material' -import { Box, Button, Divider, Typography } from '@mui/joy' +import { + Avatar, + Box, + Button, + Divider, + ListItemContent, + ListItemDecorator, + Menu, + MenuItem, + Typography, +} from '@mui/joy' +import { useRef, useState } from 'react' import KeyboardShortcutHint from '../../../components/common/KeyboardShortcutHint' +import LABEL_COLORS, { + getTextColorFromBackgroundColor, +} from '../../../utils/Colors' +import { getIconComponent } from '../../../utils/ProjectIcons' + +const renderProjectAvatar = (color, icon) => { + const bg = color || LABEL_COLORS[0].value + const IconComponent = getIconComponent(icon || 'FolderOpen') + return ( + + + + ) +} const MultiSelectToolbar = ({ isVisible, @@ -20,9 +48,21 @@ const MultiSelectToolbar = ({ onSkip, onArchive, onDelete, + onMoveToProject, + projects = [], showKeyboardShortcuts, selectAllDisabled, }) => { + const [projectMenuAnchor, setProjectMenuAnchor] = useState(null) + const projectMenuRef = useRef(null) + + const closeProjectMenu = () => setProjectMenuAnchor(null) + + const handleMoveToProject = project => { + closeProjectMenu() + onMoveToProject?.(project) + } + return ( )} + {onMoveToProject && ( + <> + + + + handleMoveToProject({ id: null, name: 'Default Project' }) + } + > + + {renderProjectAvatar(LABEL_COLORS[0].value, 'FolderOpen')} + + + Default Project + + + {projects.map(project => ( + handleMoveToProject(project)} + > + + {renderProjectAvatar(project.color, project.icon)} + + + {project.name} + + + ))} + + + )} + - {/* Sub-panels (voice/scan) own their own confirm action */} + {showVoice && ( + + )} + {showScan && scanState.primaryAction && ( + + )} + {showScan && scanState.phase === 'processing' && ( + + )} {!showScan && !showVoice && ( - - - - + /> ) } diff --git a/src/views/components/DueDatePickerModal.jsx b/src/views/components/DueDatePickerModal.jsx new file mode 100644 index 0000000..c0f47da --- /dev/null +++ b/src/views/components/DueDatePickerModal.jsx @@ -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 ( + + } + > + + {/* Date shortcuts */} + + Quick date + + + {[ + { + key: 'today', + label: 'Today', + icon: , + }, + { + key: 'tomorrow', + label: 'Tomorrow', + icon: , + }, + { + key: 'weekend', + label: 'Weekend', + icon: , + }, + { + key: 'next-week', + label: 'Next week', + icon: , + }, + { + key: 'next-month', + label: 'Next month', + icon: , + }, + ].map(opt => { + const dateStr = toDateKey(getQuickScheduleDate(opt.key)) + return ( + + handleQuickSchedule(opt.key)} + overlay + disableIcon + variant='soft' + label={ + + {opt.icon} + {opt.label} + + } + /> + + ) + })} + + + {/* Time shortcuts */} + + Quick time + + + {[ + { + time: '09:00', + label: 'Morning', + icon: , + }, + { + time: '12:00', + label: 'Noon', + icon: , + }, + { + time: '15:00', + label: 'Afternoon', + icon: , + }, + { + time: '18:00', + label: 'Evening', + icon: , + }, + { + time: '22:00', + label: 'Night', + icon: , + }, + ].map(opt => ( + + handleQuickTime(opt.time)} + overlay + disableIcon + variant='soft' + label={ + + {opt.icon} + {opt.label} + + } + /> + + ))} + + + + + ['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()] + } + /> + + + Custom time + + + + + + + + + ) +} + +export default DueDatePickerModal diff --git a/src/views/components/ScanToTask/ScanPanel.jsx b/src/views/components/ScanToTask/ScanPanel.jsx index be7aa02..3cf41a8 100644 --- a/src/views/components/ScanToTask/ScanPanel.jsx +++ b/src/views/components/ScanToTask/ScanPanel.jsx @@ -12,7 +12,7 @@ import { LinearProgress, Typography, } from '@mui/joy' -import { useEffect } from 'react' +import { useCallback, useEffect, useMemo } from 'react' import { useScanToTask } from './useScanToTask' /** @@ -20,8 +20,20 @@ import { useScanToTask } from './useScanToTask' * * Flow: capture → (auto) processing → done [calls onTaskExtracted + onClose] * → error [retake or cancel] + * + * The primary action (Capture / Scan Document / Retake) lives in the modal + * footer alongside Cancel — the panel reports it up through onStateChange + * rather than rendering its own button row. Upload stays inline because it + * belongs to the capture surface and drives a hidden input in this subtree. */ -const ScanPanel = ({ open, onTaskExtracted, onClose, initialImageUrl, autoCapture }) => { +const ScanPanel = ({ + open, + onTaskExtracted, + onClose, + onStateChange, + initialImageUrl, + autoCapture, +}) => { const { isNativeScanner, phase, @@ -76,6 +88,51 @@ const ScanPanel = ({ open, onTaskExtracted, onClose, initialImageUrl, autoCaptur // eslint-disable-next-line react-hooks/exhaustive-deps }, [phase, taskResult]) + const openFilePicker = useCallback( + () => fileInputRef.current?.click(), + [fileInputRef], + ) + + // The one action the footer renders for the current phase; null while + // processing (nothing to do but wait) and when done (the panel closes) + const primaryAction = useMemo(() => { + if (phase === 'capture') { + if (isNativeScanner) { + return { + label: 'Scan Document', + icon: , + onClick: handleNativeScan, + } + } + if (cameraAvailable) { + return { label: 'Capture', icon: , 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: , + onClick: openFilePicker, + } + } + if (phase === 'error') { + return { label: 'Retake', icon: , onClick: retake } + } + return null + }, [ + phase, + isNativeScanner, + cameraAvailable, + capture, + handleNativeScan, + retake, + openFilePicker, + ]) + + useEffect(() => { + onStateChange?.({ phase, primaryAction }) + }, [phase, primaryAction, onStateChange]) + if (!open) return null const isProcessing = phase === 'processing' @@ -111,7 +168,10 @@ const ScanPanel = ({ open, onTaskExtracted, onClose, initialImageUrl, autoCaptur - + Tap "Scan Document" to open the scanner
@@ -137,65 +197,38 @@ const ScanPanel = ({ open, onTaskExtracted, onClose, initialImageUrl, autoCaptur - + Camera not available — use Upload instead )} - - - - - - {isNativeScanner ? ( - - ) : ( - cameraAvailable && ( - - ) - )} + - + )} )} @@ -280,24 +313,22 @@ const ScanPanel = ({ open, onTaskExtracted, onClose, initialImageUrl, autoCaptur }} /> )} - + {errorMsg} - - - )} + {/* Kept outside the phase branches so the footer's Upload action can + reach it even when no capture surface is rendered */} + ) diff --git a/src/views/components/VoiceToTask/VoicePanel.jsx b/src/views/components/VoiceToTask/VoicePanel.jsx index 7d6bae3..12288c2 100644 --- a/src/views/components/VoiceToTask/VoicePanel.jsx +++ b/src/views/components/VoiceToTask/VoicePanel.jsx @@ -401,18 +401,18 @@ const TaskPreviewCard = ({ /** * Inline voice-to-task panel. Mounts inside AddTaskModal — no second modal. * - * Opens straight into hands-free listening. Pauses and spoken separators - * ("also") split the transcript into task cards; tapping a card opens inline - * pickers whose edits override the parsed values. A single captured task - * lands in the smart input for review; multiple are created directly. + * Mounted only while voice capture is active, and opens straight into + * hands-free listening. Pauses and spoken separators ("also") split the + * transcript into task cards; tapping a card opens inline pickers whose edits + * override the parsed values. The confirm action lives in the modal footer + * alongside Cancel — this panel only reports its state up through + * onStateChange so the modal can label and enable that button. */ const VoicePanel = ({ - open, userLabels = [], members = [], userProfile, - onUseSingle, - onCreateMany, + onStateChange, }) => { const { phase, @@ -427,8 +427,6 @@ const VoicePanel = ({ patchSegment, isNative, } = useVoiceToTask({ members, userLabels }) - const [creating, setCreating] = useState(false) - const autoStartedRef = useRef(false) const segmentsScrollRef = useRef(null) const parseCtx = useMemo( @@ -441,14 +439,12 @@ const VoicePanel = ({ [partialText, parseCtx], ) - // Start capturing the moment the panel opens — the mic tap that opened it - // is the only tap needed + // Start capturing the moment the panel mounts — the mic tap that opened it + // is the only tap needed. startHandsFree no-ops if already listening. useEffect(() => { - if (open && !autoStartedRef.current) { - autoStartedRef.current = true - startHandsFree() - } - }, [open, startHandsFree]) + startHandsFree() + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) // Keep the newest captured task visible as more are added useEffect(() => { @@ -456,24 +452,14 @@ const VoicePanel = ({ if (el) el.scrollTop = el.scrollHeight }, [segments.length]) - if (!open) return null - const isListening = phase === 'listening' - const showActions = segments.length > 0 && !isListening && !creating - const mergedTask = segment => ({ - ...parseVoiceTask(segment.text, parseCtx), - ...(segment.overrides || {}), - }) - - const handleCreateAll = async () => { - setCreating(true) - try { - await onCreateMany(segments.map(mergedTask)) - } finally { - setCreating(false) - } - } + // The confirm action lives in the modal footer, so report the raw segments + // and whether the mic is live — that's all it needs to label and enable the + // button. It parses the segments itself when the user confirms. + useEffect(() => { + onStateChange?.({ segments, isListening }) + }, [segments, isListening, onStateChange]) const micCaption = isListening ? isLocked @@ -642,47 +628,6 @@ const VoicePanel = ({ )} - - {/* ── Footer — dismissing is the modal's Cancel; this owns confirm only ── */} - {(creating || showActions) && ( - - {creating ? ( - - ) : segments.length === 1 ? ( - - ) : ( - - )} - - )} ) }