Merge branch 'develop' into feature/ja-localization
This commit is contained in:
30
src/App.jsx
30
src/App.jsx
@@ -1,14 +1,18 @@
|
||||
import NavBar from '@/views/components/NavBar'
|
||||
import { Button, Typography, useColorScheme } from '@mui/joy'
|
||||
import Tracker from '@openreplay/tracker'
|
||||
import { useCallback, useEffect } from 'react'
|
||||
import { Outlet } from 'react-router-dom'
|
||||
import { useRegisterSW } from 'virtual:pwa-register/react'
|
||||
import { registerCapacitorListeners } from './CapacitorListener'
|
||||
import PageTransition from './components/animations/PageTransition'
|
||||
import { ImpersonateUserProvider } from './contexts/ImpersonateUserContext'
|
||||
import SSEProvider from './contexts/SSEContext'
|
||||
import { AuthProvider } from './hooks/useAuth.jsx'
|
||||
|
||||
import useStatusBar from './hooks/useStatusBar'
|
||||
import { useResource } from './queries/ResourceQueries'
|
||||
import './styles/safe-area.css'
|
||||
|
||||
import SSEProvider from './contexts/SSEContext'
|
||||
import { useNotification } from './service/NotificationProvider'
|
||||
|
||||
import { useSyncOnReconnect } from './hooks/useSyncOnReconnect'
|
||||
@@ -25,19 +29,16 @@ const remove = className => {
|
||||
// TODO: Update the interval to at 60 minutes
|
||||
const intervalMS = 5 * 60 * 1000 // 5 minutes
|
||||
|
||||
const startOpenReplay = () => {
|
||||
if (!import.meta.env.VITE_OPENREPLAY_PROJECT_KEY) return
|
||||
const tracker = new Tracker({
|
||||
projectKey: import.meta.env.VITE_OPENREPLAY_PROJECT_KEY,
|
||||
})
|
||||
tracker.start()
|
||||
}
|
||||
|
||||
const AppContent = () => {
|
||||
const { showNotification } = useNotification()
|
||||
useSyncOnReconnect()
|
||||
|
||||
// Initialize status bar with theme-aware configuration
|
||||
useStatusBar()
|
||||
|
||||
|
||||
const {
|
||||
offlineReady: [offlineReady, setOfflineReady], // eslint-disable-line no-unused-vars
|
||||
needRefresh: [needRefresh, setNeedRefresh],
|
||||
updateServiceWorker,
|
||||
} = useRegisterSW({
|
||||
@@ -96,10 +97,11 @@ const AppContent = () => {
|
||||
}
|
||||
|
||||
function App() {
|
||||
// startOpenReplay()
|
||||
|
||||
const resource = useResource() // eslint-disable-line no-unused-vars
|
||||
const { mode, systemMode } = useColorScheme()
|
||||
|
||||
// startOpenReplay()
|
||||
|
||||
const setThemeClass = useCallback(() => {
|
||||
const value = JSON.parse(localStorage.getItem('themeMode')) || mode
|
||||
|
||||
@@ -126,7 +128,7 @@ function App() {
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<>
|
||||
<div>
|
||||
<NetworkBanner />
|
||||
|
||||
<AuthProvider>
|
||||
@@ -134,7 +136,7 @@ function App() {
|
||||
<AppContent />
|
||||
</SSEProvider>
|
||||
</AuthProvider>
|
||||
</>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,35 @@ import { PushNotifications } from '@capacitor/push-notifications'
|
||||
import { focusManager } from '@tanstack/react-query'
|
||||
import { RegisterDeviceToken } from './utils/Fetcher'
|
||||
|
||||
// NFC chore deep link: donetick://chores/123?auto_complete=true
|
||||
const handleNFCChoreDeepLink = url => {
|
||||
try {
|
||||
const urlObj = new URL(url)
|
||||
// donetick://chores/123 → host='chores', pathname='/123'
|
||||
const choreId = urlObj.pathname.slice(1)
|
||||
const autoComplete = urlObj.searchParams.get('auto_complete')
|
||||
const path = `/chores/${choreId}${autoComplete ? '?auto_complete=' + autoComplete : ''}`
|
||||
|
||||
// getLaunchUrl() persists across every WebView reload caused by window.location.href.
|
||||
// If we're already on the target page, skip to avoid an infinite reload loop.
|
||||
if (window.location.pathname + window.location.search === path) return
|
||||
|
||||
console.log('[NFC] navigating to', path)
|
||||
window.location.href = path
|
||||
} catch (error) {
|
||||
console.error('[NFC] Error handling chore deep link:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const handleUrlOpen = url => {
|
||||
console.log('[NFC] handleUrlOpen:', url)
|
||||
if (url.startsWith('donetick://chores/')) {
|
||||
handleNFCChoreDeepLink(url)
|
||||
} else if (url.startsWith('donetick://auth/')) {
|
||||
handleOAuthDeepLink(url)
|
||||
}
|
||||
}
|
||||
|
||||
// OAuth callback handler for deep links
|
||||
const handleOAuthDeepLink = async url => {
|
||||
console.log('OAuth deep link received:', url)
|
||||
@@ -215,16 +244,20 @@ const registerCapacitorListeners = () => {
|
||||
return
|
||||
}
|
||||
localNotificationListenerRegistration()
|
||||
|
||||
// Register deep link handler for OAuth and other deep links
|
||||
mobileApp.addListener('appUrlOpen', event => {
|
||||
console.log('App URL opened:', event.url)
|
||||
|
||||
// Handle OAuth callback
|
||||
if (event.url.startsWith('donetick://auth/')) {
|
||||
handleOAuthDeepLink(event.url)
|
||||
|
||||
// Cold-start: app was launched by tapping an NFC tag (or other deep link)
|
||||
mobileApp.getLaunchUrl().then(result => {
|
||||
if (result?.url) {
|
||||
console.log('[NFC] getLaunchUrl:', result.url)
|
||||
handleUrlOpen(result.url)
|
||||
}
|
||||
})
|
||||
|
||||
// Foreground / singleTask resume: app was already running when the tag was tapped
|
||||
mobileApp.addListener('appUrlOpen', event => {
|
||||
console.log('[NFC] appUrlOpen:', event.url)
|
||||
handleUrlOpen(event.url)
|
||||
})
|
||||
|
||||
mobileApp.addListener('appStateChange', ({ isActive }) => {
|
||||
focusManager.setFocused(isActive)
|
||||
@@ -233,6 +266,9 @@ const registerCapacitorListeners = () => {
|
||||
mobileApp.addListener('backButton', ({ canGoBack }) => {
|
||||
if (canGoBack) {
|
||||
window.history.back()
|
||||
} else if (window.location.pathname !== '/') {
|
||||
// No history (e.g. app launched directly to a chore via NFC) — go home
|
||||
window.location.href = '/'
|
||||
} else {
|
||||
mobileApp.exitApp()
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ import Input from '@mui/joy/Input'
|
||||
import Option from '@mui/joy/Option'
|
||||
import Select from '@mui/joy/Select'
|
||||
import Typography from '@mui/joy/Typography'
|
||||
import { useCallback, useEffect, useState, useRef } from 'react'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { NOTIFICATION_TYPE, TASK_COLOR } from '../utils/Colors'
|
||||
import { TIME_UNITS } from '../utils/DurationUtils'
|
||||
|
||||
@@ -512,6 +512,7 @@ const NotificationTemplate = ({
|
||||
'--Badge-fontSize': '0.7rem',
|
||||
'--Badge-paddingX': '5px',
|
||||
top: 10,
|
||||
left: 10,
|
||||
'& .MuiBadge-badge': {
|
||||
background: colors.bgColor,
|
||||
color: 'white',
|
||||
|
||||
@@ -32,7 +32,7 @@ import { useImpersonateUser } from '../contexts/ImpersonateUserContext'
|
||||
import useStickyState from '../hooks/useStickyState'
|
||||
import { useCircleMembers, useUserProfile } from '../queries/UserQueries'
|
||||
import { apiClient } from '../utils/ApiClient'
|
||||
import { isPlusAccount } from '../utils/Helpers'
|
||||
import { isPlusAccount, resolvePhotoURL } from '../utils/Helpers'
|
||||
import UserModal from '../views/Modals/Inputs/UserModal'
|
||||
import SubscriptionModal from './SubscriptionModal'
|
||||
|
||||
@@ -116,7 +116,7 @@ const UserProfileAvatar = () => {
|
||||
{isImpersonating ? (
|
||||
<Box sx={{ position: 'relative' }}>
|
||||
<Avatar
|
||||
src={currentUser?.image || currentUser?.avatar}
|
||||
src={resolvePhotoURL(currentUser?.image)}
|
||||
alt={currentUser?.displayName || currentUser?.name}
|
||||
size='md'
|
||||
sx={{
|
||||
@@ -127,7 +127,7 @@ const UserProfileAvatar = () => {
|
||||
}}
|
||||
/>
|
||||
<Avatar
|
||||
src={userProfile?.image || userProfile?.avatar}
|
||||
src={resolvePhotoURL(userProfile?.image || userProfile?.avatar)}
|
||||
alt={userProfile?.displayName || userProfile?.name}
|
||||
size='sm'
|
||||
sx={{
|
||||
@@ -162,7 +162,7 @@ const UserProfileAvatar = () => {
|
||||
</Box>
|
||||
) : (
|
||||
<Avatar
|
||||
src={currentUser?.image || currentUser?.avatar}
|
||||
src={resolvePhotoURL(currentUser?.image || currentUser?.avatar)}
|
||||
alt={currentUser?.displayName || currentUser?.name}
|
||||
size='md'
|
||||
sx={{
|
||||
@@ -189,7 +189,7 @@ const UserProfileAvatar = () => {
|
||||
<Sheet sx={{ p: 2, borderRadius: 'var(--joy-radius-sm)', mb: 1 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||
<Avatar
|
||||
src={currentUser?.image || currentUser?.avatar}
|
||||
src={resolvePhotoURL(currentUser?.image || currentUser?.avatar)}
|
||||
alt={currentUser?.displayName || currentUser?.name}
|
||||
size='lg'
|
||||
sx={{
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Modal, ModalDialog, ModalOverflow, Typography } from '@mui/joy'
|
||||
import { Modal, ModalClose, ModalDialog, ModalOverflow, Typography } from '@mui/joy'
|
||||
import { Z_INDEX } from '../../constants/zIndex'
|
||||
|
||||
/**
|
||||
@@ -78,6 +78,7 @@ const FadeModal = ({
|
||||
},
|
||||
}}
|
||||
>
|
||||
<ModalClose />
|
||||
{title && (
|
||||
<Typography level='title-lg' sx={{ fontWeight: 600, mb: 2 }}>
|
||||
{title}
|
||||
|
||||
506
src/components/common/FilterBar.jsx
Normal file
506
src/components/common/FilterBar.jsx
Normal file
@@ -0,0 +1,506 @@
|
||||
import { Check, FilterList, Tune } from '@mui/icons-material'
|
||||
import {
|
||||
Avatar,
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Chip,
|
||||
Divider,
|
||||
Input,
|
||||
Typography,
|
||||
} from '@mui/joy'
|
||||
import { useState } from 'react'
|
||||
import BottomSheetModal from './BottomSheetModal'
|
||||
import ActiveFilterChips from './filter/ActiveFilterChips'
|
||||
|
||||
/**
|
||||
* Reusable filter bar component.
|
||||
*
|
||||
* Props:
|
||||
* filterDefs - array of filter definitions:
|
||||
* { id, label, type ('multi-select'|'single-select'|'boolean'|'date-range'),
|
||||
* icon, options?, defaultValue?, filterFn }
|
||||
* options item: { value, label, color?, icon?, avatar? }
|
||||
* defaultValue: if the active value equals this, no chip is shown
|
||||
* date-range value shape: { preset?, from?: ISO string, to?: ISO string }
|
||||
* activeFilters - current filter state object { [id]: value }
|
||||
* onSetFilter - (filterId, value | null) => void
|
||||
* onClearAll - () => void
|
||||
* resultCount - optional number shown in "Show N results" button
|
||||
* totalCount - optional total for "N of M" label
|
||||
*/
|
||||
|
||||
// ── Date range presets (no moment dependency — pure Date) ────────────────────
|
||||
|
||||
const d = (date, h = 0, m = 0, s = 0, ms = 0) =>
|
||||
new Date(date.getFullYear(), date.getMonth(), date.getDate(), h, m, s, ms)
|
||||
|
||||
const DATE_RANGE_PRESETS = [
|
||||
{
|
||||
value: 'today',
|
||||
label: 'Today',
|
||||
getRange: () => {
|
||||
const t = d(new Date())
|
||||
return { from: t.toISOString(), to: d(new Date(), 23, 59, 59, 999).toISOString() }
|
||||
},
|
||||
},
|
||||
{
|
||||
value: 'yesterday',
|
||||
label: 'Yesterday',
|
||||
getRange: () => {
|
||||
const t = d(new Date())
|
||||
const y = new Date(t); y.setDate(t.getDate() - 1)
|
||||
return { from: d(y).toISOString(), to: d(y, 23, 59, 59, 999).toISOString() }
|
||||
},
|
||||
},
|
||||
{
|
||||
value: 'this-week',
|
||||
label: 'This Week',
|
||||
getRange: () => {
|
||||
const t = d(new Date())
|
||||
const start = new Date(t); start.setDate(t.getDate() - t.getDay())
|
||||
const end = new Date(start); end.setDate(start.getDate() + 6)
|
||||
return { from: d(start).toISOString(), to: d(end, 23, 59, 59, 999).toISOString() }
|
||||
},
|
||||
},
|
||||
{
|
||||
value: 'last-7-days',
|
||||
label: 'Last 7 Days',
|
||||
getRange: () => {
|
||||
const t = d(new Date())
|
||||
const start = new Date(t); start.setDate(t.getDate() - 6)
|
||||
return { from: d(start).toISOString(), to: d(new Date(), 23, 59, 59, 999).toISOString() }
|
||||
},
|
||||
},
|
||||
{
|
||||
value: 'this-month',
|
||||
label: 'This Month',
|
||||
getRange: () => {
|
||||
const n = new Date()
|
||||
const start = new Date(n.getFullYear(), n.getMonth(), 1)
|
||||
const end = new Date(n.getFullYear(), n.getMonth() + 1, 0)
|
||||
return { from: start.toISOString(), to: d(end, 23, 59, 59, 999).toISOString() }
|
||||
},
|
||||
},
|
||||
{
|
||||
value: 'last-30-days',
|
||||
label: 'Last 30 Days',
|
||||
getRange: () => {
|
||||
const t = d(new Date())
|
||||
const start = new Date(t); start.setDate(t.getDate() - 29)
|
||||
return { from: d(start).toISOString(), to: d(new Date(), 23, 59, 59, 999).toISOString() }
|
||||
},
|
||||
},
|
||||
{
|
||||
value: 'last-3-months',
|
||||
label: 'Last 3 Months',
|
||||
getRange: () => {
|
||||
const t = d(new Date())
|
||||
const start = new Date(t); start.setMonth(t.getMonth() - 3)
|
||||
return { from: d(start).toISOString(), to: d(new Date(), 23, 59, 59, 999).toISOString() }
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
const toInputDate = iso => (iso ? iso.split('T')[0] : '')
|
||||
|
||||
const fmtDisplayDate = iso => {
|
||||
if (!iso) return null
|
||||
const dt = new Date(iso)
|
||||
return dt.toLocaleDateString(undefined, { month: 'short', day: 'numeric' })
|
||||
}
|
||||
|
||||
// ── Component ────────────────────────────────────────────────────────────────
|
||||
|
||||
const FilterBar = ({
|
||||
filterDefs,
|
||||
activeFilters,
|
||||
onSetFilter,
|
||||
onClearAll,
|
||||
resultCount,
|
||||
totalCount,
|
||||
}) => {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
|
||||
// ── Active count ───────────────────────────────────────────────────────────
|
||||
|
||||
const activeFilterCount = filterDefs.filter(def => {
|
||||
const value = activeFilters[def.id]
|
||||
if (value === undefined || value === null) return false
|
||||
if (def.defaultValue !== undefined && value === def.defaultValue) return false
|
||||
if (Array.isArray(value) && value.length === 0) return false
|
||||
if (def.type === 'date-range') return !!(value?.from || value?.to)
|
||||
return true
|
||||
}).length
|
||||
|
||||
const hasActive = activeFilterCount > 0
|
||||
|
||||
const selectableChipSx = {
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.15s ease',
|
||||
userSelect: 'none',
|
||||
alignItems: 'center',
|
||||
'& .MuiChip-startDecorator': {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
mr: 0.5,
|
||||
},
|
||||
'& .MuiChip-label': {
|
||||
lineHeight: 1.2,
|
||||
},
|
||||
'&:hover': { opacity: 0.85 },
|
||||
}
|
||||
|
||||
const sectionBadgeChipSx = {
|
||||
ml: 'auto',
|
||||
fontSize: '0.7rem',
|
||||
minHeight: 22,
|
||||
py: 0.25,
|
||||
px: 0.75,
|
||||
alignItems: 'center',
|
||||
'& .MuiChip-label': {
|
||||
lineHeight: 1.2,
|
||||
px: 0,
|
||||
},
|
||||
}
|
||||
|
||||
const modalCountChipSx = {
|
||||
ml: 0.5,
|
||||
minHeight: 22,
|
||||
py: 0.25,
|
||||
px: 0.75,
|
||||
alignItems: 'center',
|
||||
'& .MuiChip-label': {
|
||||
lineHeight: 1.2,
|
||||
px: 0,
|
||||
},
|
||||
}
|
||||
|
||||
// ── Chip labels for inline bar ─────────────────────────────────────────────
|
||||
|
||||
const getActiveChipLabel = def => {
|
||||
const value = activeFilters[def.id]
|
||||
if (value === undefined || value === null) return null
|
||||
|
||||
if (def.type === 'single-select') {
|
||||
if (def.defaultValue !== undefined && value === def.defaultValue) return null
|
||||
return def.options?.find(o => o.value === value)?.label ?? def.label
|
||||
}
|
||||
|
||||
if (def.type === 'boolean') return def.label
|
||||
|
||||
if (def.type === 'multi-select' && Array.isArray(value) && value.length > 0) {
|
||||
if (value.length === 1) {
|
||||
return def.options?.find(o => o.value === value[0])?.label ?? def.label
|
||||
}
|
||||
return `${def.label} (${value.length})`
|
||||
}
|
||||
|
||||
if (def.type === 'date-range') {
|
||||
if (!value?.from && !value?.to) return null
|
||||
if (value.preset) {
|
||||
return DATE_RANGE_PRESETS.find(p => p.value === value.preset)?.label ?? 'Date Range'
|
||||
}
|
||||
const from = fmtDisplayDate(value.from)
|
||||
const to = fmtDisplayDate(value.to)
|
||||
if (from && to) return `${from} – ${to}`
|
||||
if (from) return `From ${from}`
|
||||
if (to) return `Until ${to}`
|
||||
return null
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
// ── Handlers ───────────────────────────────────────────────────────────────
|
||||
|
||||
const handleMultiToggle = (defId, optValue) => {
|
||||
const current = activeFilters[defId] || []
|
||||
const next = current.includes(optValue)
|
||||
? current.filter(v => v !== optValue)
|
||||
: [...current, optValue]
|
||||
onSetFilter(defId, next.length > 0 ? next : null)
|
||||
}
|
||||
|
||||
const handleSingleToggle = (defId, optValue) => {
|
||||
onSetFilter(defId, activeFilters[defId] === optValue ? null : optValue)
|
||||
}
|
||||
|
||||
const handleBoolToggle = defId => {
|
||||
onSetFilter(defId, activeFilters[defId] ? null : true)
|
||||
}
|
||||
|
||||
const handleDateRangePreset = (defId, presetValue) => {
|
||||
const current = activeFilters[defId] || {}
|
||||
if (current.preset === presetValue) {
|
||||
onSetFilter(defId, null)
|
||||
return
|
||||
}
|
||||
const preset = DATE_RANGE_PRESETS.find(p => p.value === presetValue)
|
||||
onSetFilter(defId, { preset: presetValue, ...preset.getRange() })
|
||||
}
|
||||
|
||||
const handleDateRangeInput = (defId, field, dateStr) => {
|
||||
const current = activeFilters[defId] || {}
|
||||
if (!dateStr) {
|
||||
const next = { ...current, preset: null, [field]: null }
|
||||
onSetFilter(defId, next.from || next.to ? next : null)
|
||||
} else {
|
||||
const iso =
|
||||
field === 'to'
|
||||
? new Date(dateStr + 'T23:59:59').toISOString()
|
||||
: new Date(dateStr + 'T00:00:00').toISOString()
|
||||
onSetFilter(defId, { ...current, preset: null, [field]: iso })
|
||||
}
|
||||
}
|
||||
|
||||
// ── Render ─────────────────────────────────────────────────────────────────
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* ── Inline bar ─────────────────────────────────────── */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flexWrap: 'wrap', mb: 2 }}>
|
||||
<Badge
|
||||
badgeContent={activeFilterCount || null}
|
||||
color='primary'
|
||||
size='sm'
|
||||
anchorOrigin={{ vertical: 'top', horizontal: 'right' }}
|
||||
sx={{ display: 'flex', alignItems: 'center' }}
|
||||
>
|
||||
<Button
|
||||
size='md'
|
||||
variant={hasActive ? 'solid' : 'outlined'}
|
||||
color={hasActive ? 'primary' : 'neutral'}
|
||||
startDecorator={<FilterList sx={{ fontSize: 16 }} />}
|
||||
onClick={() => setIsOpen(true)}
|
||||
sx={{
|
||||
borderRadius: 'xl',
|
||||
py: 0.5,
|
||||
px: 1,
|
||||
gap: 0.5,
|
||||
alignItems: 'center',
|
||||
'& .MuiButton-startDecorator': {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
mr: 0.5,
|
||||
},
|
||||
}}
|
||||
>
|
||||
Filters
|
||||
</Button>
|
||||
</Badge>
|
||||
|
||||
<ActiveFilterChips
|
||||
chips={filterDefs
|
||||
.map(def => ({ def, label: getActiveChipLabel(def) }))
|
||||
.filter(({ label }) => !!label)
|
||||
.map(({ def, label }) => ({
|
||||
key: def.id,
|
||||
label,
|
||||
onClear: () => onSetFilter(def.id, null),
|
||||
}))}
|
||||
onOpen={() => setIsOpen(true)}
|
||||
onClearAll={hasActive ? onClearAll : undefined}
|
||||
resultCount={hasActive ? resultCount : undefined}
|
||||
totalCount={hasActive ? totalCount : undefined}
|
||||
maxVisible={2}
|
||||
chipSize='md'
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* ── Bottom sheet ────────────────────────────────────── */}
|
||||
<BottomSheetModal
|
||||
open={isOpen}
|
||||
onClose={() => setIsOpen(false)}
|
||||
title={
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Tune sx={{ fontSize: 20 }} />
|
||||
Filters
|
||||
{hasActive && (
|
||||
<Chip size='sm' variant='solid' color='primary' sx={modalCountChipSx}>
|
||||
{activeFilterCount}
|
||||
</Chip>
|
||||
)}
|
||||
</Box>
|
||||
}
|
||||
footer={
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 1 }}>
|
||||
<Button
|
||||
variant='plain'
|
||||
color='danger'
|
||||
size='sm'
|
||||
disabled={!hasActive}
|
||||
onClick={onClearAll}
|
||||
>
|
||||
Clear all
|
||||
</Button>
|
||||
<Button onClick={() => setIsOpen(false)} sx={{ minWidth: 140 }}>
|
||||
{resultCount !== undefined
|
||||
? `Show ${resultCount} result${resultCount !== 1 ? 's' : ''}`
|
||||
: 'Done'}
|
||||
</Button>
|
||||
</Box>
|
||||
}
|
||||
>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0 }}>
|
||||
{filterDefs.map((def, idx) => (
|
||||
<Box key={def.id}>
|
||||
{idx > 0 && <Divider sx={{ my: 2.5 }} />}
|
||||
|
||||
{/* Section header */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}>
|
||||
{def.icon && (
|
||||
<Box sx={{ color: 'text.secondary', display: 'flex', alignItems: 'center', '& svg': { fontSize: 18 } }}>
|
||||
{def.icon}
|
||||
</Box>
|
||||
)}
|
||||
<Typography level='title-sm' sx={{ fontWeight: 600 }}>
|
||||
{def.label}
|
||||
</Typography>
|
||||
|
||||
{/* active badge in header */}
|
||||
{def.type === 'multi-select' && (activeFilters[def.id]?.length ?? 0) > 0 && (
|
||||
<Chip size='sm' variant='solid' color='primary' sx={sectionBadgeChipSx}>
|
||||
{activeFilters[def.id].length} selected
|
||||
</Chip>
|
||||
)}
|
||||
{def.type === 'single-select' && activeFilters[def.id] != null && (() => {
|
||||
const opt = def.options?.find(o => o.value === activeFilters[def.id])
|
||||
return opt ? (
|
||||
<Chip size='sm' variant='solid' color='primary' sx={sectionBadgeChipSx}>
|
||||
{opt.label}
|
||||
</Chip>
|
||||
) : null
|
||||
})()}
|
||||
{def.type === 'date-range' && getActiveChipLabel(def) && (
|
||||
<Chip size='sm' variant='solid' color='primary' sx={sectionBadgeChipSx}>
|
||||
{getActiveChipLabel(def)}
|
||||
</Chip>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* multi-select */}
|
||||
{def.type === 'multi-select' && (
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
|
||||
{def.options?.map(opt => {
|
||||
const isSelected = (activeFilters[def.id] || []).includes(opt.value)
|
||||
return (
|
||||
<Chip
|
||||
key={opt.value}
|
||||
variant={isSelected ? 'solid' : 'soft'}
|
||||
color={isSelected ? (opt.color ?? 'primary') : 'neutral'}
|
||||
startDecorator={
|
||||
opt.avatar ? (
|
||||
<Avatar src={opt.avatar} alt={opt.label} sx={{ '--Avatar-size': '20px' }} />
|
||||
) : isSelected ? (
|
||||
<Check sx={{ fontSize: 14 }} />
|
||||
) : (opt.icon ?? null)
|
||||
}
|
||||
onClick={() => handleMultiToggle(def.id, opt.value)}
|
||||
sx={selectableChipSx}
|
||||
>
|
||||
{opt.label}
|
||||
</Chip>
|
||||
)
|
||||
})}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* single-select */}
|
||||
{def.type === 'single-select' && (
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
|
||||
{def.options?.map(opt => {
|
||||
const isSelected = activeFilters[def.id] === opt.value
|
||||
return (
|
||||
<Chip
|
||||
key={opt.value}
|
||||
variant={isSelected ? 'solid' : 'soft'}
|
||||
color={isSelected ? (opt.color ?? 'primary') : 'neutral'}
|
||||
startDecorator={
|
||||
opt.avatar ? (
|
||||
<Avatar src={opt.avatar} alt={opt.label} sx={{ '--Avatar-size': '20px' }} />
|
||||
) : isSelected ? (
|
||||
<Check sx={{ fontSize: 14 }} />
|
||||
) : (opt.icon ?? null)
|
||||
}
|
||||
onClick={() => handleSingleToggle(def.id, opt.value)}
|
||||
sx={selectableChipSx}
|
||||
>
|
||||
{opt.label}
|
||||
</Chip>
|
||||
)
|
||||
})}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* boolean */}
|
||||
{def.type === 'boolean' && (
|
||||
<Chip
|
||||
variant={activeFilters[def.id] ? 'solid' : 'soft'}
|
||||
color={activeFilters[def.id] ? 'primary' : 'neutral'}
|
||||
startDecorator={activeFilters[def.id] ? <Check sx={{ fontSize: 14 }} /> : null}
|
||||
onClick={() => handleBoolToggle(def.id)}
|
||||
sx={selectableChipSx}
|
||||
>
|
||||
{def.label}
|
||||
</Chip>
|
||||
)}
|
||||
|
||||
{/* date-range */}
|
||||
{def.type === 'date-range' && (() => {
|
||||
const val = activeFilters[def.id] || {}
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
|
||||
{/* Preset chips */}
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
|
||||
{DATE_RANGE_PRESETS.map(preset => {
|
||||
const isSelected = val.preset === preset.value
|
||||
return (
|
||||
<Chip
|
||||
key={preset.value}
|
||||
variant={isSelected ? 'solid' : 'soft'}
|
||||
color={isSelected ? 'primary' : 'neutral'}
|
||||
startDecorator={isSelected ? <Check sx={{ fontSize: 14 }} /> : null}
|
||||
onClick={() => handleDateRangePreset(def.id, preset.value)}
|
||||
sx={selectableChipSx}
|
||||
>
|
||||
{preset.label}
|
||||
</Chip>
|
||||
)
|
||||
})}
|
||||
</Box>
|
||||
|
||||
{/* Custom date inputs */}
|
||||
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center' }}>
|
||||
<Input
|
||||
type='date'
|
||||
size='sm'
|
||||
value={toInputDate(val.from)}
|
||||
onChange={e => handleDateRangeInput(def.id, 'from', e.target.value)}
|
||||
slotProps={{ input: { max: toInputDate(val.to) || undefined } }}
|
||||
sx={{ flex: 1, fontSize: '0.8rem' }}
|
||||
/>
|
||||
<Typography level='body-xs' sx={{ color: 'text.tertiary', flexShrink: 0 }}>
|
||||
–
|
||||
</Typography>
|
||||
<Input
|
||||
type='date'
|
||||
size='sm'
|
||||
value={toInputDate(val.to)}
|
||||
onChange={e => handleDateRangeInput(def.id, 'to', e.target.value)}
|
||||
slotProps={{ input: { min: toInputDate(val.from) || undefined } }}
|
||||
sx={{ flex: 1, fontSize: '0.8rem' }}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
})()}
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
</BottomSheetModal>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default FilterBar
|
||||
131
src/components/common/filter/ActiveFilterChips.jsx
Normal file
131
src/components/common/filter/ActiveFilterChips.jsx
Normal file
@@ -0,0 +1,131 @@
|
||||
import { Close } from '@mui/icons-material'
|
||||
import { Box, Button, Chip, Typography } from '@mui/joy'
|
||||
|
||||
const ActiveFilterChips = ({
|
||||
chips = [],
|
||||
onOpen,
|
||||
onClearAll,
|
||||
resultCount,
|
||||
totalCount,
|
||||
maxVisible = 2,
|
||||
chipSize = 'md',
|
||||
clearButtonSize = 'sm',
|
||||
clearButtonSx,
|
||||
containerSx,
|
||||
chipSx,
|
||||
overflowChipSx,
|
||||
resultSx,
|
||||
}) => {
|
||||
if (!chips.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
const visible = chips.slice(0, maxVisible)
|
||||
const overflow = chips.length - maxVisible
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 1,
|
||||
flexWrap: 'nowrap',
|
||||
overflowX: 'auto',
|
||||
py: 0.5,
|
||||
'&::-webkit-scrollbar': { display: 'none' },
|
||||
scrollbarWidth: 'none',
|
||||
...containerSx,
|
||||
}}
|
||||
>
|
||||
{visible.map(({ key, label, onClear, color = 'primary' }) => (
|
||||
<Chip
|
||||
key={key}
|
||||
size={chipSize}
|
||||
variant='soft'
|
||||
color={color}
|
||||
endDecorator={
|
||||
<Close
|
||||
sx={{ cursor: 'pointer', fontSize: chipSize === 'sm' ? 12 : 16 }}
|
||||
onClick={e => {
|
||||
e.stopPropagation()
|
||||
onClear?.()
|
||||
}}
|
||||
/>
|
||||
}
|
||||
onClick={onOpen}
|
||||
sx={{
|
||||
cursor: 'pointer',
|
||||
flexShrink: 0,
|
||||
transition: 'all 0.15s ease',
|
||||
alignItems: 'center',
|
||||
'& .MuiChip-endDecorator': {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
ml: 0.5,
|
||||
},
|
||||
'& .MuiChip-label': {
|
||||
lineHeight: 1.2,
|
||||
},
|
||||
'&:hover': { opacity: 0.85 },
|
||||
...chipSx,
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</Chip>
|
||||
))}
|
||||
|
||||
{overflow > 0 && (
|
||||
<Chip
|
||||
size={chipSize}
|
||||
variant='soft'
|
||||
color='neutral'
|
||||
onClick={onOpen}
|
||||
sx={{
|
||||
cursor: 'pointer',
|
||||
flexShrink: 0,
|
||||
transition: 'all 0.15s ease',
|
||||
'&:hover': { opacity: 0.85 },
|
||||
...overflowChipSx,
|
||||
}}
|
||||
>
|
||||
+{overflow} more
|
||||
</Chip>
|
||||
)}
|
||||
|
||||
{resultCount != null && totalCount != null && (
|
||||
<Typography
|
||||
level='body-xs'
|
||||
sx={{
|
||||
color: 'text.tertiary',
|
||||
ml: 'auto',
|
||||
flexShrink: 0,
|
||||
...resultSx,
|
||||
}}
|
||||
>
|
||||
{resultCount} / {totalCount}
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
{onClearAll && (
|
||||
<Button
|
||||
size={clearButtonSize}
|
||||
variant='plain'
|
||||
color='neutral'
|
||||
onClick={onClearAll}
|
||||
sx={{
|
||||
px: 0.5,
|
||||
fontSize: chipSize === 'sm' ? '0.72rem' : '0.75rem',
|
||||
color: 'text.secondary',
|
||||
minHeight: 0,
|
||||
flexShrink: 0,
|
||||
...clearButtonSx,
|
||||
}}
|
||||
>
|
||||
Clear all
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
export default ActiveFilterChips
|
||||
@@ -21,11 +21,13 @@ export const TIME_FORMATS = {
|
||||
export const RTL_LANGUAGES = ['ar', 'he', 'fa', 'ur']
|
||||
|
||||
export const AVAILABLE_LANGUAGES = [
|
||||
{ code: 'de', name: 'German', nativeName: 'Deutsch' },
|
||||
{ code: 'en', name: 'English', nativeName: 'English' },
|
||||
{ code: 'es', name: 'Spanish', nativeName: 'Español' },
|
||||
{ code: 'fr', name: 'French', nativeName: 'Français' },
|
||||
{ code: 'nl', name: 'Dutch', nativeName: 'Nederlands' },
|
||||
{ code: 'ja', name: 'Japanese', nativeName: '日本語' },
|
||||
{ code: 'pt', name: 'Portuguese (Brazil)', nativeName: 'Português (Brasil)' },
|
||||
]
|
||||
|
||||
export const LocalizationProvider = ({ children }) => {
|
||||
|
||||
@@ -1,47 +1,83 @@
|
||||
import { Network } from '@capacitor/network'
|
||||
import { isOfflineFeatureEnabled } from '../utils/OfflineFeatureToggle'
|
||||
|
||||
class NetworkManager {
|
||||
constructor() {
|
||||
this.isOnline = true
|
||||
this.isNetworkOn = null
|
||||
this.init()
|
||||
this.deviceOnline = true
|
||||
this.serverReachable = true
|
||||
this.offlineReason = null // 'device' | 'server' | null
|
||||
this.connectionStatusListeners = []
|
||||
this.queueSyncListeners = []
|
||||
this.lastChecked = null
|
||||
this.offlineSince = null
|
||||
this.init()
|
||||
}
|
||||
|
||||
// Effective online status: both device network AND server must be reachable
|
||||
get isOnline() {
|
||||
return this.deviceOnline && this.serverReachable
|
||||
}
|
||||
|
||||
// Alias for backward compatibility (DeveloperSettings uses this)
|
||||
get isNetworkOn() {
|
||||
return this.deviceOnline
|
||||
}
|
||||
|
||||
async init() {
|
||||
const status = await Network.getStatus()
|
||||
this.isNetworkOn = status.connected
|
||||
this.deviceOnline = status.connected
|
||||
this.lastChecked = Date.now()
|
||||
if (!status.connected) {
|
||||
this.offlineReason = 'device'
|
||||
this.offlineSince = Date.now()
|
||||
}
|
||||
|
||||
Network.addListener('networkStatusChange', status => {
|
||||
if (this.isNetworkOn !== status.connected) {
|
||||
this.isNetworkOn = status.connected
|
||||
if (this.deviceOnline !== status.connected) {
|
||||
this.deviceOnline = status.connected
|
||||
this.lastChecked = Date.now()
|
||||
this.isOnline = status.connected
|
||||
|
||||
if (!status.connected) {
|
||||
this.offlineReason = 'device'
|
||||
this.offlineSince = Date.now()
|
||||
} else {
|
||||
// Device came back online — update reason based on server state
|
||||
this.offlineReason = this.serverReachable ? null : 'server'
|
||||
}
|
||||
this.notifyConnectionStatus()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
setOffline() {
|
||||
if (this.isOnline === true) {
|
||||
this.isOnline = false
|
||||
// Called when a fetch() response is received (any HTTP status = server is up)
|
||||
setServerReachable() {
|
||||
if (!this.serverReachable) {
|
||||
this.serverReachable = true
|
||||
this.offlineReason = this.deviceOnline ? null : 'device'
|
||||
this.notifyConnectionStatus()
|
||||
this.offlineSince = Date.now() // Record the time when we went offline
|
||||
}
|
||||
}
|
||||
setOnline() {
|
||||
if (this.isOnline === false) {
|
||||
this.isOnline = true
|
||||
|
||||
// Called when fetch() throws a network error (server unreachable)
|
||||
// Only takes effect when offline mode is enabled
|
||||
setServerUnreachable() {
|
||||
if (!isOfflineFeatureEnabled()) return
|
||||
if (this.serverReachable) {
|
||||
this.serverReachable = false
|
||||
this.offlineReason = 'server'
|
||||
this.offlineSince = Date.now()
|
||||
this.notifyConnectionStatus()
|
||||
}
|
||||
}
|
||||
|
||||
// Legacy methods kept for compatibility
|
||||
setOffline() {
|
||||
this.setServerUnreachable()
|
||||
}
|
||||
setOnline() {
|
||||
this.setServerReachable()
|
||||
}
|
||||
|
||||
notifyConnectionStatus() {
|
||||
this.connectionStatusListeners.forEach(callback => {
|
||||
callback(this.isOnline)
|
||||
@@ -63,7 +99,6 @@ class NetworkManager {
|
||||
)
|
||||
}
|
||||
registerBackendSyncListener(callback) {
|
||||
// if callback is not in the list already, add it
|
||||
if (!this.queueSyncListeners.includes(callback)) {
|
||||
this.queueSyncListeners.push(callback)
|
||||
}
|
||||
|
||||
@@ -39,7 +39,7 @@ export const AuthProvider = ({ children }) => {
|
||||
// Ensure apiClient is initialized with the correct URL
|
||||
await apiClient.init()
|
||||
const currentBaseURL = apiClient.getApiURL()
|
||||
|
||||
|
||||
const isNative =
|
||||
typeof window !== 'undefined' && window.Capacitor?.isNativePlatform?.()
|
||||
|
||||
@@ -57,8 +57,8 @@ export const AuthProvider = ({ children }) => {
|
||||
const response = await fetch(`${currentBaseURL}/auth/login`, config)
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json()
|
||||
return { success: false, error: error.message || 'Login failed' }
|
||||
const res = await response.json()
|
||||
return { success: false, error: res?.error || 'Login failed' }
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
50
src/hooks/useDocumentScanner.js
Normal file
50
src/hooks/useDocumentScanner.js
Normal file
@@ -0,0 +1,50 @@
|
||||
import { Capacitor } from '@capacitor/core'
|
||||
|
||||
/**
|
||||
* Normalizes a raw image string from the native document scanner into a
|
||||
* format that can be used as an <img> src and passed to Tesseract.js.
|
||||
*
|
||||
* Android returns file:// or absolute paths → convert via Capacitor.convertFileSrc
|
||||
* iOS returns raw base64 (no data: prefix) → prepend the data URI scheme
|
||||
*/
|
||||
function normalizeScannedImage(raw) {
|
||||
if (!raw) return null
|
||||
if (raw.startsWith('data:')) return raw
|
||||
if (raw.startsWith('http://') || raw.startsWith('https://') || raw.startsWith('content://')) return raw
|
||||
if (raw.startsWith('/') || raw.startsWith('file://')) return Capacitor.convertFileSrc(raw)
|
||||
// iOS base64 without prefix
|
||||
return `data:image/jpeg;base64,${raw}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for native document scanning via @capgo/capacitor-document-scanner.
|
||||
*
|
||||
* On native: opens the OS document scanner (edge detection, perspective correction).
|
||||
* On web: `scanDocument` returns null — callers should fall back to their own camera UI.
|
||||
*/
|
||||
export function useDocumentScanner() {
|
||||
const isNativeScanner = Capacitor.isNativePlatform()
|
||||
|
||||
const scanDocument = async ({ maxDocuments = 1, quality = 90, letUserAdjustCrop = true } = {}) => {
|
||||
if (!isNativeScanner) return { image: null, cancelled: false }
|
||||
|
||||
try {
|
||||
const { DocumentScanner } = await import('@capgo/capacitor-document-scanner')
|
||||
const { scannedImages } = await DocumentScanner.scanDocument({
|
||||
croppedImageQuality: quality,
|
||||
maxNumDocuments: maxDocuments,
|
||||
letUserAdjustCrop,
|
||||
})
|
||||
|
||||
if (!scannedImages?.length) return { image: null, cancelled: true }
|
||||
|
||||
const normalized = normalizeScannedImage(scannedImages[0])
|
||||
return { image: normalized, cancelled: false }
|
||||
} catch (e) {
|
||||
console.error('[DocumentScanner] scan failed:', e)
|
||||
return { image: null, cancelled: false, error: e.message }
|
||||
}
|
||||
}
|
||||
|
||||
return { isNativeScanner, scanDocument }
|
||||
}
|
||||
92
src/hooks/useFileUpload.js
Normal file
92
src/hooks/useFileUpload.js
Normal file
@@ -0,0 +1,92 @@
|
||||
import imageCompression from 'browser-image-compression'
|
||||
import { useCallback } from 'react'
|
||||
import { useUserProfile } from '../queries/UserQueries'
|
||||
import { useNotification } from '../service/NotificationProvider'
|
||||
import { apiClient } from '../utils/ApiClient'
|
||||
import { isPlusAccount, resolvePhotoURL } from '../utils/Helpers'
|
||||
|
||||
export const useFileUpload = ({ entityType = 'chore_attachment', entityId, draftId } = {}) => {
|
||||
const { showError } = useNotification()
|
||||
const { data: userProfile } = useUserProfile()
|
||||
|
||||
const uploadFile = useCallback(
|
||||
async file => {
|
||||
if (!isPlusAccount(userProfile)) {
|
||||
showError({
|
||||
title: 'Plus Feature',
|
||||
message:
|
||||
'Image uploads are not available in the Basic plan. Upgrade to Plus to add images to your content.',
|
||||
})
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
const compressionOptions = {
|
||||
maxSizeMB: entityType === 'profile' ? 0.5 : 1,
|
||||
maxWidthOrHeight: entityType === 'profile' ? 320 : 1200,
|
||||
useWebWorker: true,
|
||||
fileType: 'image/jpeg',
|
||||
}
|
||||
|
||||
const compressedFile = await imageCompression(file, compressionOptions)
|
||||
const compressedJpegFile = new File(
|
||||
[compressedFile],
|
||||
`${file.name.split('.')[0]}.jpg`,
|
||||
{ type: 'image/jpeg' },
|
||||
)
|
||||
|
||||
const formData = new FormData()
|
||||
formData.append('file', compressedJpegFile)
|
||||
formData.append('entityType', entityType)
|
||||
if (entityId) formData.append('entityId', String(entityId))
|
||||
if (draftId) formData.append('draftId', draftId)
|
||||
|
||||
const response = await apiClient.upload('/assets/chore', formData)
|
||||
|
||||
if (response.status === 507) {
|
||||
showError({
|
||||
title: 'Storage Quota Exceeded',
|
||||
message: 'You have exceeded your quota for uploading files.',
|
||||
})
|
||||
return null
|
||||
} else if (response.status === 413) {
|
||||
showError({
|
||||
title: 'File Too Large',
|
||||
message: 'The file you are trying to upload is too large.',
|
||||
})
|
||||
return null
|
||||
} else if (response.status === 403 && !isPlusAccount(userProfile)) {
|
||||
showError({
|
||||
title: 'Upgrade Required',
|
||||
message: 'Image uploads are only available for Plus accounts.',
|
||||
})
|
||||
return null
|
||||
} else if (response.status === 403) {
|
||||
showError({
|
||||
title: 'Permission Denied',
|
||||
message: 'You do not have permission to upload files.',
|
||||
})
|
||||
return null
|
||||
} else if (!response.ok) {
|
||||
showError({
|
||||
title: 'Upload Failed',
|
||||
message: 'Failed to upload image.',
|
||||
})
|
||||
return null
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
return resolvePhotoURL(data.url || data.sign)
|
||||
} catch {
|
||||
showError({
|
||||
title: 'Upload Failed',
|
||||
message: 'An error occurred while processing the image.',
|
||||
})
|
||||
return null
|
||||
}
|
||||
},
|
||||
[entityType, entityId, draftId, showError, userProfile],
|
||||
)
|
||||
|
||||
return { uploadFile, isPlus: isPlusAccount(userProfile) }
|
||||
}
|
||||
59
src/hooks/useFilter.js
Normal file
59
src/hooks/useFilter.js
Normal file
@@ -0,0 +1,59 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
|
||||
/**
|
||||
* Generic client-side filter hook.
|
||||
*
|
||||
* @param {Array} data - the full list to filter
|
||||
* @param {Array} filterDefs - array of filter definitions (see FilterBar)
|
||||
* @returns {{ filteredData, activeFilters, setFilter, clearAll, activeFilterCount, hasActiveFilters }}
|
||||
*
|
||||
* Each filterDef must include:
|
||||
* id - unique string key
|
||||
* type - 'multi-select' | 'boolean'
|
||||
* filterFn - (item, filterValue) => boolean
|
||||
*/
|
||||
export const useFilter = (data, filterDefs) => {
|
||||
const [activeFilters, setActiveFilters] = useState({})
|
||||
|
||||
const setFilter = (filterId, value) => {
|
||||
setActiveFilters(prev => {
|
||||
const isEmpty =
|
||||
value === null ||
|
||||
value === undefined ||
|
||||
(Array.isArray(value) && value.length === 0)
|
||||
|
||||
if (isEmpty) {
|
||||
const { [filterId]: _removed, ...rest } = prev
|
||||
return rest
|
||||
}
|
||||
return { ...prev, [filterId]: value }
|
||||
})
|
||||
}
|
||||
|
||||
const clearAll = () => setActiveFilters({})
|
||||
|
||||
const filteredData = useMemo(() => {
|
||||
if (!data) return []
|
||||
if (!Object.keys(activeFilters).length) return data
|
||||
|
||||
return data.filter(item =>
|
||||
filterDefs.every(def => {
|
||||
const value = activeFilters[def.id]
|
||||
if (value === undefined || value === null) return true
|
||||
if (Array.isArray(value) && value.length === 0) return true
|
||||
return def.filterFn(item, value)
|
||||
}),
|
||||
)
|
||||
}, [data, activeFilters, filterDefs])
|
||||
|
||||
const activeFilterCount = Object.keys(activeFilters).length
|
||||
|
||||
return {
|
||||
filteredData,
|
||||
activeFilters,
|
||||
setFilter,
|
||||
clearAll,
|
||||
activeFilterCount,
|
||||
hasActiveFilters: activeFilterCount > 0,
|
||||
}
|
||||
}
|
||||
56
src/hooks/useStatusBar.js
Normal file
56
src/hooks/useStatusBar.js
Normal file
@@ -0,0 +1,56 @@
|
||||
import { useColorScheme } from '@mui/joy'
|
||||
import { useEffect } from 'react'
|
||||
import statusBarManager from '../utils/StatusBarManager'
|
||||
|
||||
/**
|
||||
* Custom hook to manage status bar integration with Joy UI themes
|
||||
* This hook automatically syncs the status bar style with the current theme
|
||||
*/
|
||||
export const useStatusBar = () => {
|
||||
const { mode, systemMode } = useColorScheme()
|
||||
|
||||
useEffect(() => {
|
||||
// Initialize status bar on mount
|
||||
const initializeStatusBar = async () => {
|
||||
await statusBarManager.initialize(mode)
|
||||
}
|
||||
|
||||
initializeStatusBar()
|
||||
|
||||
// Cleanup on unmount
|
||||
return () => {
|
||||
statusBarManager.cleanup()
|
||||
}
|
||||
}, [mode]) // Include mode dependency
|
||||
|
||||
useEffect(() => {
|
||||
// Update status bar when theme changes
|
||||
const updateStatusBarTheme = async () => {
|
||||
let resolvedTheme = mode
|
||||
|
||||
// Handle system mode by using the detected system theme
|
||||
if (mode === 'system') {
|
||||
resolvedTheme = systemMode || 'light'
|
||||
}
|
||||
|
||||
// Update the status bar with the resolved theme
|
||||
await statusBarManager.updateResolvedTheme(resolvedTheme)
|
||||
|
||||
// Also update the base theme for future reference
|
||||
await statusBarManager.setTheme(mode)
|
||||
|
||||
// Notify any custom listeners
|
||||
statusBarManager.notifyThemeChange(resolvedTheme)
|
||||
}
|
||||
|
||||
updateStatusBarTheme()
|
||||
}, [mode, systemMode]) // Update when either mode or systemMode changes
|
||||
|
||||
return {
|
||||
statusBarManager,
|
||||
currentTheme: mode,
|
||||
resolvedTheme: mode === 'system' ? systemMode : mode,
|
||||
}
|
||||
}
|
||||
|
||||
export default useStatusBar
|
||||
@@ -8,7 +8,8 @@ import { isOfflineFeatureEnabled } from '../utils/OfflineFeatureToggle'
|
||||
import { syncEngine } from '../utils/SyncEngine'
|
||||
import { networkManager } from './NetworkManager'
|
||||
|
||||
const PENDING_POLL_MS = 30_000 // retry pending commands every 30s
|
||||
export const PENDING_POLL_MS = 30_000 // retry pending commands every 30s
|
||||
export const SERVER_PROBE_MS = 15_000 // probe server when marked unreachable but device has network
|
||||
const CACHE_REFRESH_MS = 5 * 60_000 // refresh IDB cache every 5 min while online
|
||||
|
||||
export function useSyncOnReconnect() {
|
||||
@@ -18,6 +19,7 @@ export function useSyncOnReconnect() {
|
||||
useEffect(() => {
|
||||
let pendingPollInterval
|
||||
let cacheRefreshInterval
|
||||
let serverProbeInterval
|
||||
let resumeListener
|
||||
let networkListener
|
||||
const handleVisibilityChange = () => {
|
||||
@@ -77,13 +79,27 @@ export function useSyncOnReconnect() {
|
||||
cacheRefreshInterval = setInterval(() => {
|
||||
runSync()
|
||||
}, CACHE_REFRESH_MS)
|
||||
|
||||
// 6. Probe server every 15s when server is unreachable but device has network
|
||||
serverProbeInterval = setInterval(async () => {
|
||||
if (!networkManager.isOnline && networkManager.deviceOnline) {
|
||||
await runSync()
|
||||
}
|
||||
}, SERVER_PROBE_MS)
|
||||
}
|
||||
|
||||
const runSync = async () => {
|
||||
if (!isOfflineFeatureEnabled()) return
|
||||
const wasOffline = !networkManager.isOnline
|
||||
const didSync = await syncEngine.sync()
|
||||
if (didSync) {
|
||||
queryClient.invalidateQueries()
|
||||
// After recovery from server-unreachable, run a second pass to flush
|
||||
// any commands that were skipped while offline
|
||||
if (wasOffline && networkManager.isOnline) {
|
||||
const didSync2 = await syncEngine.sync()
|
||||
if (didSync2) queryClient.invalidateQueries()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -98,6 +114,10 @@ export function useSyncOnReconnect() {
|
||||
clearInterval(cacheRefreshInterval)
|
||||
}
|
||||
|
||||
if (serverProbeInterval) {
|
||||
clearInterval(serverProbeInterval)
|
||||
}
|
||||
|
||||
if (networkListener) {
|
||||
networkManager.unregisterNetworkListener(networkListener)
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
CreateChore,
|
||||
DeleteChore,
|
||||
DeleteChoreHistory,
|
||||
GetChoreAttachments,
|
||||
GetChoreByID,
|
||||
GetChoreDetailById,
|
||||
GetChoreHistory,
|
||||
@@ -58,7 +59,8 @@ const mergePendingCreates = async chores => {
|
||||
}
|
||||
|
||||
const isNetworkError = error =>
|
||||
error instanceof TypeError && error.message === 'Failed to fetch'
|
||||
(error instanceof TypeError && error.message === 'Failed to fetch') ||
|
||||
error?.name === 'AbortError'
|
||||
|
||||
const buildOfflineChore = task => ({
|
||||
...task,
|
||||
@@ -191,13 +193,7 @@ export const useCreateChore = () => {
|
||||
if (!createdChore) {
|
||||
throw new Error('Failed to get created chore data')
|
||||
}
|
||||
// Successfully created the chore on the server, return the created chore
|
||||
// update the local chores cache with the new chore:
|
||||
queryClient.setQueryData(['chores', false], oldData => {
|
||||
if (!oldData) return { res: [createdChore.res] }
|
||||
return { res: [...oldData.res, createdChore.res] }
|
||||
})
|
||||
return createdChore.res
|
||||
return { ...newTask, id: createdChore.res }
|
||||
} catch (error) {
|
||||
if (isNetworkError(error)) {
|
||||
return queueOfflineCreate(newTask)
|
||||
@@ -258,7 +254,7 @@ export const useUpdateChore = () => {
|
||||
),
|
||||
}
|
||||
})
|
||||
return updatedChoreRes?.res || updatedChoreRes
|
||||
return updatedChoreRes?.res || updatedChore
|
||||
} catch (error) {
|
||||
if (isNetworkError(error)) {
|
||||
return queueOfflineUpdate()
|
||||
@@ -577,7 +573,45 @@ export const useMarkChoreComplete = () => {
|
||||
})
|
||||
return { res: { _pending: 'complete' } }
|
||||
}
|
||||
return MarkChoreComplete(choreId, body, completedDate, performer)
|
||||
|
||||
const queueOfflineComplete = async () => {
|
||||
await commandQueue.enqueue(CommandType.COMPLETE_CHORE, choreId, {
|
||||
id: choreId,
|
||||
body,
|
||||
completedDate,
|
||||
performer,
|
||||
})
|
||||
await offlineDB.savePendingHistory({
|
||||
id: -Date.now(),
|
||||
choreId: Number(choreId),
|
||||
completedBy: body?.completedBy || 0,
|
||||
performedAt: completedDate || new Date().toISOString(),
|
||||
notes: body?.note || null,
|
||||
status: 1,
|
||||
points: 0,
|
||||
pending: true,
|
||||
})
|
||||
queryClient.setQueryData(['chores'], oldData => {
|
||||
if (!oldData) return oldData
|
||||
return {
|
||||
res: oldData.res.map(chore =>
|
||||
chore.id === choreId
|
||||
? { ...chore, _pending: 'complete' }
|
||||
: chore,
|
||||
),
|
||||
}
|
||||
})
|
||||
return { res: { _pending: 'complete' } }
|
||||
}
|
||||
|
||||
try {
|
||||
return await MarkChoreComplete(choreId, body, completedDate, performer)
|
||||
} catch (error) {
|
||||
if (isNetworkError(error)) {
|
||||
return queueOfflineComplete()
|
||||
}
|
||||
throw error
|
||||
}
|
||||
},
|
||||
onSuccess: (_, { choreId }) => {
|
||||
queryClient.invalidateQueries(['chores'])
|
||||
@@ -608,7 +642,26 @@ export const useSkipChore = () => {
|
||||
})
|
||||
return { res: { _pending: 'skip' } }
|
||||
}
|
||||
return SkipChore(choreId)
|
||||
|
||||
try {
|
||||
return await SkipChore(choreId)
|
||||
} catch (error) {
|
||||
if (isNetworkError(error)) {
|
||||
await commandQueue.enqueue(CommandType.SKIP_CHORE, choreId, {
|
||||
id: choreId,
|
||||
})
|
||||
queryClient.setQueryData(['chores'], oldData => {
|
||||
if (!oldData) return oldData
|
||||
return {
|
||||
res: oldData.res.map(chore =>
|
||||
chore.id === choreId ? { ...chore, _pending: 'skip' } : chore,
|
||||
),
|
||||
}
|
||||
})
|
||||
return { res: { _pending: 'skip' } }
|
||||
}
|
||||
throw error
|
||||
}
|
||||
},
|
||||
onSuccess: (_, choreId) => {
|
||||
queryClient.invalidateQueries(['chores'])
|
||||
@@ -644,3 +697,19 @@ export const useRejectChore = () => {
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export const useChoreAttachments = (choreId, hasAttachments = true) => {
|
||||
return useQuery({
|
||||
queryKey: ['choreAttachments', choreId],
|
||||
queryFn: async () => {
|
||||
const response = await GetChoreAttachments(choreId)
|
||||
if (response && response.ok) {
|
||||
return await response.json()
|
||||
}
|
||||
throw new Error('Failed to fetch attachments')
|
||||
},
|
||||
enabled: !!choreId && hasAttachments,
|
||||
staleTime: 10 * 60 * 1000,
|
||||
gcTime: 15 * 60 * 1000,
|
||||
})
|
||||
}
|
||||
|
||||
72
src/service/AIPromptCache.js
Normal file
72
src/service/AIPromptCache.js
Normal file
@@ -0,0 +1,72 @@
|
||||
const ENABLED_KEY = 'ai_prompt_cache_enabled'
|
||||
const ENTRY_PREFIX = 'ai_prompt_cache_'
|
||||
const INDEX_KEY = 'ai_prompt_cache_index'
|
||||
|
||||
function djb2(str) {
|
||||
let hash = 5381
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
hash = ((hash << 5) + hash) ^ str.charCodeAt(i)
|
||||
hash = hash >>> 0
|
||||
}
|
||||
return hash.toString(36)
|
||||
}
|
||||
|
||||
export function isCacheEnabled() {
|
||||
try {
|
||||
return localStorage.getItem(ENABLED_KEY) === 'true'
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export function setCacheEnabled(enabled) {
|
||||
try {
|
||||
localStorage.setItem(ENABLED_KEY, String(enabled))
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
export function hashContent(content) {
|
||||
return djb2(typeof content === 'string' ? content : JSON.stringify(content))
|
||||
}
|
||||
|
||||
function getIndex() {
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem(INDEX_KEY) || '[]')
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
export function getCached(hash) {
|
||||
if (!isCacheEnabled()) return null
|
||||
try {
|
||||
const raw = localStorage.getItem(ENTRY_PREFIX + hash)
|
||||
return raw ? JSON.parse(raw) : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function setCached(hash, value) {
|
||||
if (!isCacheEnabled()) return
|
||||
try {
|
||||
localStorage.setItem(ENTRY_PREFIX + hash, JSON.stringify(value))
|
||||
const index = getIndex()
|
||||
if (!index.includes(hash)) {
|
||||
index.push(hash)
|
||||
localStorage.setItem(INDEX_KEY, JSON.stringify(index))
|
||||
}
|
||||
} catch { /* storage full, ignore */ }
|
||||
}
|
||||
|
||||
export function getCacheStats() {
|
||||
return { count: getIndex().length }
|
||||
}
|
||||
|
||||
export function clearCache() {
|
||||
const index = getIndex()
|
||||
index.forEach(h => {
|
||||
try { localStorage.removeItem(ENTRY_PREFIX + h) } catch { /* ignore */ }
|
||||
})
|
||||
try { localStorage.removeItem(INDEX_KEY) } catch { /* ignore */ }
|
||||
}
|
||||
141
src/service/LocalAIService.js
Normal file
141
src/service/LocalAIService.js
Normal file
@@ -0,0 +1,141 @@
|
||||
import { Capacitor } from '@capacitor/core'
|
||||
import { getCached, hashContent, setCached } from './AIPromptCache'
|
||||
|
||||
// Native-only local AI service using @capacitor/local-llm.
|
||||
// On web, all methods return 'unavailable' / null — no WebLLM.
|
||||
class LocalAIService {
|
||||
constructor() {
|
||||
this._availability = null
|
||||
this._sessionId = 'donetick-summary'
|
||||
this._warmedUp = false
|
||||
}
|
||||
|
||||
get isNative() {
|
||||
return Capacitor.isNativePlatform()
|
||||
}
|
||||
|
||||
async checkAvailability() {
|
||||
if (!this.isNative) {
|
||||
this._availability = 'unavailable'
|
||||
return 'unavailable'
|
||||
}
|
||||
try {
|
||||
const { LocalLLM } = await import('@capacitor/local-llm')
|
||||
const { status } = await LocalLLM.systemAvailability()
|
||||
this._availability = status
|
||||
return status
|
||||
} catch (e) {
|
||||
this._availability = 'unavailable'
|
||||
return 'unavailable'
|
||||
}
|
||||
}
|
||||
|
||||
async getStatus() {
|
||||
if (this._availability !== null) return this._availability
|
||||
return this.checkAvailability()
|
||||
}
|
||||
|
||||
async isAvailable() {
|
||||
return (await this.getStatus()) === 'available'
|
||||
}
|
||||
|
||||
resetAvailability() {
|
||||
this._availability = null
|
||||
}
|
||||
|
||||
async download(onStatusChange) {
|
||||
if (!this.isNative) return
|
||||
try {
|
||||
const { LocalLLM } = await import('@capacitor/local-llm')
|
||||
if (onStatusChange) {
|
||||
LocalLLM.addListener('systemAvailabilityChange', ({ status }) => {
|
||||
this._availability = status
|
||||
onStatusChange(status)
|
||||
})
|
||||
}
|
||||
await LocalLLM.download()
|
||||
} catch {
|
||||
// download not available on iOS, ignore
|
||||
}
|
||||
}
|
||||
|
||||
async warmup() {
|
||||
if (this._warmedUp || !this.isNative) return
|
||||
try {
|
||||
const { LocalLLM } = await import('@capacitor/local-llm')
|
||||
await LocalLLM.warmup({ sessionId: this._sessionId })
|
||||
this._warmedUp = true
|
||||
} catch {
|
||||
// non-fatal
|
||||
}
|
||||
}
|
||||
|
||||
async _nativePrompt(text) {
|
||||
await this.warmup()
|
||||
try {
|
||||
const { LocalLLM } = await import('@capacitor/local-llm')
|
||||
const { text: out } = await LocalLLM.prompt({ prompt: text, sessionId: this._sessionId })
|
||||
return out?.trim() || null
|
||||
} finally {
|
||||
try {
|
||||
const { LocalLLM } = await import('@capacitor/local-llm')
|
||||
await LocalLLM.endSession({ sessionId: this._sessionId })
|
||||
this._warmedUp = false
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
|
||||
// Plain chat — no tools. Returns answer string or null.
|
||||
async plainChat(messages) {
|
||||
const available = await this.isAvailable()
|
||||
if (!available) return null
|
||||
|
||||
const cacheHash = hashContent(['plain', ...messages])
|
||||
const cached = getCached(cacheHash)
|
||||
if (cached) return cached
|
||||
|
||||
if (!this.isNative) return null
|
||||
|
||||
try {
|
||||
const systemMsg = messages.find(m => m.role === 'system')?.content || ''
|
||||
const userMsg = messages.find(m => m.role === 'user')?.content || ''
|
||||
const result = await this._nativePrompt(`${systemMsg}\n\nUser: ${userMsg}\nAssistant:`)
|
||||
if (result) setCached(cacheHash, result)
|
||||
return result
|
||||
} catch (e) {
|
||||
console.error('[LocalAI] plainChat() failed:', e)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// Returns the summary string or null if LLM is unavailable
|
||||
async summarize(prompt) {
|
||||
const available = await this.isAvailable()
|
||||
if (!available) return null
|
||||
|
||||
const cacheHash = hashContent(prompt)
|
||||
const cached = getCached(cacheHash)
|
||||
if (cached) return cached
|
||||
|
||||
if (!this.isNative) return null
|
||||
|
||||
try {
|
||||
await this.warmup()
|
||||
const { LocalLLM } = await import('@capacitor/local-llm')
|
||||
const { text } = await LocalLLM.prompt({ prompt, sessionId: this._sessionId })
|
||||
const result = text?.trim() || null
|
||||
if (result) setCached(cacheHash, result)
|
||||
return result
|
||||
} catch {
|
||||
return null
|
||||
} finally {
|
||||
try {
|
||||
const { LocalLLM } = await import('@capacitor/local-llm')
|
||||
await LocalLLM.endSession({ sessionId: this._sessionId })
|
||||
this._warmedUp = false
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const localAIService = new LocalAIService()
|
||||
@@ -1,11 +1,136 @@
|
||||
import { CapacitorNfc } from '@capgo/capacitor-nfc'
|
||||
|
||||
// Encodes a URL into an NDEF URI record (TNF=0x01, type='U')
|
||||
const buildUriRecord = url => {
|
||||
const encoder = new TextEncoder()
|
||||
let prefixByte = 0x00
|
||||
let uriStr = url
|
||||
if (url.startsWith('https://')) {
|
||||
prefixByte = 0x04
|
||||
uriStr = url.slice(8)
|
||||
} else if (url.startsWith('http://')) {
|
||||
prefixByte = 0x03
|
||||
uriStr = url.slice(7)
|
||||
}
|
||||
return {
|
||||
tnf: 0x01,
|
||||
type: [0x55],
|
||||
id: [],
|
||||
payload: [prefixByte, ...Array.from(encoder.encode(uriStr))],
|
||||
}
|
||||
}
|
||||
|
||||
// Decodes a URL from an NDEF URI record payload. Returns null if not a URI record.
|
||||
export const decodeNdefUrl = record => {
|
||||
if (!record || record.tnf !== 0x01) return null
|
||||
if (record.type.length !== 1 || record.type[0] !== 0x55) return null
|
||||
const payload = record.payload
|
||||
if (!payload || payload.length === 0) return null
|
||||
const prefixes = [
|
||||
'',
|
||||
'http://www.',
|
||||
'https://www.',
|
||||
'http://',
|
||||
'https://',
|
||||
'tel:',
|
||||
'mailto:',
|
||||
]
|
||||
const prefix = prefixes[payload[0]] ?? ''
|
||||
const uri = new TextDecoder().decode(new Uint8Array(payload.slice(1)))
|
||||
return prefix + uri
|
||||
}
|
||||
|
||||
// Starts a native NFC write session. Calls onWaiting once scanning is active,
|
||||
// then onSuccess or onError when the write completes. Returns a cancel function.
|
||||
export const startNativeNFCWrite = async (url, { onWaiting, onSuccess, onError }) => {
|
||||
let listener = null
|
||||
let done = false
|
||||
|
||||
const cleanup = async () => {
|
||||
if (listener) {
|
||||
await listener.remove()
|
||||
listener = null
|
||||
}
|
||||
await CapacitorNfc.stopScanning().catch(() => {})
|
||||
}
|
||||
|
||||
try {
|
||||
listener = await CapacitorNfc.addListener('nfcEvent', async () => {
|
||||
if (done) return
|
||||
done = true
|
||||
try {
|
||||
await CapacitorNfc.write({ records: [buildUriRecord(url)] })
|
||||
await cleanup()
|
||||
onSuccess()
|
||||
} catch (err) {
|
||||
await cleanup()
|
||||
onError(err.message || 'Failed to write to NFC tag')
|
||||
}
|
||||
})
|
||||
|
||||
await CapacitorNfc.startScanning({
|
||||
alertMessage: 'Hold your device near the NFC tag to write',
|
||||
invalidateAfterFirstRead: true,
|
||||
// Without FLAG_READER_SKIP_NDEF_CHECK (0x80), Android enumerates
|
||||
// Ndef/NdefFormatable tech so the plugin can format blank tags on write.
|
||||
androidReaderModeFlags: 0x0f, // NFC_A | NFC_B | NFC_F | NFC_V
|
||||
})
|
||||
onWaiting()
|
||||
return cleanup
|
||||
} catch (err) {
|
||||
await cleanup()
|
||||
onError(err.message || 'Failed to start NFC session')
|
||||
return async () => {}
|
||||
}
|
||||
}
|
||||
|
||||
// Starts a native NFC scan session for reading. Calls onTag(url) when a URL
|
||||
// NDEF record is found, or onError on failure. Returns a cancel function.
|
||||
export const startNativeScan = async ({ onTag, onError }) => {
|
||||
let listener = null
|
||||
let done = false
|
||||
|
||||
const cleanup = async () => {
|
||||
if (listener) {
|
||||
await listener.remove()
|
||||
listener = null
|
||||
}
|
||||
await CapacitorNfc.stopScanning().catch(() => {})
|
||||
}
|
||||
|
||||
try {
|
||||
listener = await CapacitorNfc.addListener('nfcEvent', async event => {
|
||||
if (done) return
|
||||
const records = event.tag?.ndefMessage ?? []
|
||||
for (const record of records) {
|
||||
const url = decodeNdefUrl(record)
|
||||
if (url) {
|
||||
done = true
|
||||
await cleanup()
|
||||
onTag(url)
|
||||
return
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
await CapacitorNfc.startScanning({
|
||||
alertMessage: 'Hold your device near the NFC tag',
|
||||
invalidateAfterFirstRead: true,
|
||||
})
|
||||
return cleanup
|
||||
} catch (err) {
|
||||
await cleanup()
|
||||
onError(err.message || 'Failed to start NFC session')
|
||||
return async () => {}
|
||||
}
|
||||
}
|
||||
|
||||
// Legacy default export for web/PWA (NDEFReader API)
|
||||
const writeToNFC = async url => {
|
||||
if ('NDEFReader' in window) {
|
||||
try {
|
||||
const ndef = new window.NDEFReader()
|
||||
await ndef.write({
|
||||
records: [{ recordType: 'url', data: url }],
|
||||
})
|
||||
alert('URL written to NFC tag successfully!')
|
||||
await ndef.write({ records: [{ recordType: 'url', data: url }] })
|
||||
} catch (error) {
|
||||
console.error('Error writing to NFC tag:', error)
|
||||
alert('Error writing to NFC tag. Please try again.')
|
||||
|
||||
127
src/styles/safe-area.css
Normal file
127
src/styles/safe-area.css
Normal file
@@ -0,0 +1,127 @@
|
||||
/*
|
||||
* Safe Area CSS Utilities
|
||||
* These utilities provide consistent safe area handling across the app
|
||||
* using CSS custom properties set by StatusBarManager
|
||||
*/
|
||||
|
||||
:root {
|
||||
/* Fallback values for safe area insets when not on native platforms */
|
||||
--safe-area-inset-top: 0px;
|
||||
--safe-area-inset-right: 0px;
|
||||
--safe-area-inset-bottom: 0px;
|
||||
--safe-area-inset-left: 0px;
|
||||
}
|
||||
|
||||
/* Utility classes for safe area handling */
|
||||
.safe-area-top {
|
||||
padding-top: var(--safe-area-inset-top);
|
||||
}
|
||||
|
||||
.safe-area-right {
|
||||
padding-right: var(--safe-area-inset-right);
|
||||
}
|
||||
|
||||
.safe-area-bottom {
|
||||
padding-bottom: var(--safe-area-inset-bottom);
|
||||
}
|
||||
|
||||
.safe-area-left {
|
||||
padding-left: var(--safe-area-inset-left);
|
||||
}
|
||||
|
||||
.safe-area-x {
|
||||
padding-left: var(--safe-area-inset-left);
|
||||
padding-right: var(--safe-area-inset-right);
|
||||
}
|
||||
|
||||
.safe-area-y {
|
||||
padding-top: var(--safe-area-inset-top);
|
||||
padding-bottom: var(--safe-area-inset-bottom);
|
||||
}
|
||||
|
||||
.safe-area-all {
|
||||
padding-top: var(--safe-area-inset-top);
|
||||
padding-right: var(--safe-area-inset-right);
|
||||
padding-bottom: var(--safe-area-inset-bottom);
|
||||
padding-left: var(--safe-area-inset-left);
|
||||
}
|
||||
|
||||
/* Margin variants */
|
||||
.safe-margin-top {
|
||||
margin-top: var(--safe-area-inset-top);
|
||||
}
|
||||
|
||||
.safe-margin-right {
|
||||
margin-right: var(--safe-area-inset-right);
|
||||
}
|
||||
|
||||
.safe-margin-bottom {
|
||||
margin-bottom: var(--safe-area-inset-bottom);
|
||||
}
|
||||
|
||||
.safe-margin-left {
|
||||
margin-left: var(--safe-area-inset-left);
|
||||
}
|
||||
|
||||
.safe-margin-x {
|
||||
margin-left: var(--safe-area-inset-left);
|
||||
margin-right: var(--safe-area-inset-right);
|
||||
}
|
||||
|
||||
.safe-margin-y {
|
||||
margin-top: var(--safe-area-inset-top);
|
||||
margin-bottom: var(--safe-area-inset-bottom);
|
||||
}
|
||||
|
||||
.safe-margin-all {
|
||||
margin-top: var(--safe-area-inset-top);
|
||||
margin-right: var(--safe-area-inset-right);
|
||||
margin-bottom: var(--safe-area-inset-bottom);
|
||||
margin-left: var(--safe-area-inset-left);
|
||||
}
|
||||
|
||||
/* Height utilities that account for safe areas */
|
||||
.min-h-screen-safe {
|
||||
min-height: calc(100vh - var(--safe-area-inset-top) - var(--safe-area-inset-bottom));
|
||||
}
|
||||
|
||||
.h-screen-safe {
|
||||
height: calc(100vh - var(--safe-area-inset-top) - var(--safe-area-inset-bottom));
|
||||
}
|
||||
|
||||
/* Top positioning that accounts for safe area */
|
||||
.top-safe {
|
||||
top: var(--safe-area-inset-top);
|
||||
}
|
||||
|
||||
/* Bottom positioning that accounts for safe area */
|
||||
.bottom-safe {
|
||||
bottom: var(--safe-area-inset-bottom);
|
||||
}
|
||||
|
||||
/* Fixed positioning utilities that respect safe areas */
|
||||
.fixed-top-safe {
|
||||
position: fixed;
|
||||
top: var(--safe-area-inset-top);
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 1030;
|
||||
}
|
||||
|
||||
.fixed-bottom-safe {
|
||||
position: fixed;
|
||||
bottom: var(--safe-area-inset-bottom);
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 1030;
|
||||
}
|
||||
|
||||
/* Container that provides full safe area coverage */
|
||||
.container-safe {
|
||||
padding-top: var(--safe-area-inset-top);
|
||||
padding-right: var(--safe-area-inset-right);
|
||||
padding-bottom: var(--safe-area-inset-bottom);
|
||||
padding-left: var(--safe-area-inset-left);
|
||||
min-height: 100vh;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Preferences } from '@capacitor/preferences'
|
||||
import { API_URL } from '../Config'
|
||||
import { networkManager } from '../hooks/NetworkManager'
|
||||
import { logout, RefreshToken } from './Fetcher'
|
||||
import {
|
||||
clearAllTokens,
|
||||
@@ -17,7 +18,7 @@ class ApiClient {
|
||||
}
|
||||
|
||||
async init(force = false) {
|
||||
if (!force && this.initPromise) {
|
||||
if (this.initPromise && !force) {
|
||||
return this.initPromise
|
||||
}
|
||||
|
||||
@@ -25,7 +26,9 @@ class ApiClient {
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
this.initPromise = this._doInit()
|
||||
this.initPromise = this._doInit().finally(() => {
|
||||
this.initPromise = null
|
||||
})
|
||||
return this.initPromise
|
||||
}
|
||||
|
||||
@@ -144,15 +147,22 @@ class ApiClient {
|
||||
async request(endpoint, options = {}) {
|
||||
await this.init()
|
||||
const url = `${this.customServerURL}${endpoint}`
|
||||
|
||||
// Abort after 10s so a dead/unreachable server doesn't hang the UI
|
||||
const controller = new AbortController()
|
||||
const timeoutId = setTimeout(() => controller.abort(), 10_000)
|
||||
|
||||
const config = {
|
||||
// credentials: 'include',
|
||||
...options,
|
||||
headers: this.getHeaders(options.headers),
|
||||
signal: options.signal ?? controller.signal,
|
||||
}
|
||||
|
||||
try {
|
||||
// 1. Initial Request
|
||||
let response = await fetch(url, config)
|
||||
clearTimeout(timeoutId)
|
||||
|
||||
// 2. Check for 401 (Unauthorized)
|
||||
if (response.status === 401) {
|
||||
@@ -220,6 +230,9 @@ class ApiClient {
|
||||
|
||||
return response
|
||||
} catch (error) {
|
||||
clearTimeout(timeoutId)
|
||||
// fetch() threw = network-level failure or timeout — mark server unreachable
|
||||
networkManager.setServerUnreachable()
|
||||
console.error('Request failed', error)
|
||||
throw error
|
||||
}
|
||||
|
||||
@@ -25,6 +25,67 @@ export const ChoreStatus = Object.freeze({
|
||||
PAUSED: 2,
|
||||
PENDING_APPROVAL: 3,
|
||||
})
|
||||
|
||||
const getDateGroupKey = dueDate =>
|
||||
moment(dueDate).startOf('day').format('YYYY-MM-DD')
|
||||
|
||||
const getDateGroupName = dateKey =>
|
||||
moment(dateKey, 'YYYY-MM-DD').format('dddd, MMM D')
|
||||
|
||||
const getDateGroupColor = dateKey => {
|
||||
const today = moment().startOf('day')
|
||||
const tomorrow = moment().add(1, 'day').startOf('day')
|
||||
const groupDate = moment(dateKey, 'YYYY-MM-DD')
|
||||
|
||||
if (groupDate.isBefore(today)) {
|
||||
return TASK_COLOR.OVERDUE
|
||||
}
|
||||
if (groupDate.isSame(today)) {
|
||||
return TASK_COLOR.TODAY
|
||||
}
|
||||
if (groupDate.isSame(tomorrow)) {
|
||||
return TASK_COLOR.TOMORROW
|
||||
}
|
||||
if (groupDate.isBefore(moment(today).add(8, 'days'))) {
|
||||
return TASK_COLOR.NEXT_7_DAYS
|
||||
}
|
||||
if (groupDate.isSame(today, 'month')) {
|
||||
return TASK_COLOR.LATER_THIS_MONTH
|
||||
}
|
||||
return TASK_COLOR.FUTURE
|
||||
}
|
||||
|
||||
const buildActualDateGroups = chores => {
|
||||
const groupedByDate = {}
|
||||
const anytime = []
|
||||
|
||||
chores.forEach(chore => {
|
||||
if (!chore.nextDueDate) {
|
||||
anytime.push(chore)
|
||||
return
|
||||
}
|
||||
|
||||
const dateKey = getDateGroupKey(chore.nextDueDate)
|
||||
if (!groupedByDate[dateKey]) {
|
||||
groupedByDate[dateKey] = []
|
||||
}
|
||||
groupedByDate[dateKey].push(chore)
|
||||
})
|
||||
|
||||
const dateGroups = Object.keys(groupedByDate)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
moment(a, 'YYYY-MM-DD').valueOf() - moment(b, 'YYYY-MM-DD').valueOf(),
|
||||
)
|
||||
.map(dateKey => ({
|
||||
name: getDateGroupName(dateKey),
|
||||
content: groupedByDate[dateKey],
|
||||
color: getDateGroupColor(dateKey),
|
||||
}))
|
||||
|
||||
return { dateGroups, anytime }
|
||||
}
|
||||
|
||||
export const ChoresGrouper = (groupBy, chores, filter) => {
|
||||
if (filter) {
|
||||
chores = chores.filter(chore => filter(chore))
|
||||
@@ -34,7 +95,7 @@ export const ChoresGrouper = (groupBy, chores, filter) => {
|
||||
chores.sort(ChoreSorter)
|
||||
var groups = []
|
||||
switch (groupBy) {
|
||||
case 'default':
|
||||
case 'default': {
|
||||
// same as due_date but hide empty groups: and if status is 1 or 2 have seperated catigory as Started:
|
||||
var groupRaw = {
|
||||
PendingApproval: [],
|
||||
@@ -147,82 +208,21 @@ export const ChoresGrouper = (groupBy, chores, filter) => {
|
||||
})
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
case 'due_date':
|
||||
var groupRaw = {
|
||||
Today: [],
|
||||
Tomorrow: [],
|
||||
'Next 7 Days': [],
|
||||
'Later This Month': [],
|
||||
Future: [],
|
||||
Overdue: [],
|
||||
Anytime: [],
|
||||
}
|
||||
chores.forEach(chore => {
|
||||
if (chore.nextDueDate === null) {
|
||||
groupRaw['Anytime'].push(chore)
|
||||
} else if (new Date(chore.nextDueDate) < new Date()) {
|
||||
groupRaw['Overdue'].push(chore)
|
||||
} else if (
|
||||
new Date(chore.nextDueDate).toDateString() ===
|
||||
new Date().toDateString()
|
||||
) {
|
||||
groupRaw['Today'].push(chore)
|
||||
} else if (
|
||||
new Date(chore.nextDueDate).toDateString() ===
|
||||
new Date(Date.now() + 24 * 60 * 60 * 1000).toDateString()
|
||||
) {
|
||||
groupRaw['Tomorrow'].push(chore)
|
||||
} else if (
|
||||
new Date(chore.nextDueDate) <
|
||||
new Date(Date.now() + 8 * 24 * 60 * 60 * 1000) &&
|
||||
new Date(chore.nextDueDate) >
|
||||
new Date(Date.now() + 24 * 60 * 60 * 1000)
|
||||
) {
|
||||
groupRaw['Next 7 Days'].push(chore)
|
||||
} else if (
|
||||
new Date(chore.nextDueDate).getMonth() === new Date().getMonth() &&
|
||||
new Date(chore.nextDueDate).getFullYear() === new Date().getFullYear()
|
||||
) {
|
||||
groupRaw['Later This Month'].push(chore)
|
||||
} else {
|
||||
groupRaw['Future'].push(chore)
|
||||
}
|
||||
})
|
||||
groups = [
|
||||
{
|
||||
name: 'Overdue',
|
||||
content: groupRaw['Overdue'],
|
||||
color: TASK_COLOR.OVERDUE,
|
||||
},
|
||||
{ name: 'Today', content: groupRaw['Today'], color: TASK_COLOR.TODAY },
|
||||
{
|
||||
name: 'Tomorrow',
|
||||
content: groupRaw['Tomorrow'],
|
||||
color: TASK_COLOR.TOMORROW,
|
||||
},
|
||||
{
|
||||
name: 'Next 7 Days',
|
||||
content: groupRaw['Next 7 Days'],
|
||||
color: TASK_COLOR.NEXT_7_DAYS,
|
||||
},
|
||||
{
|
||||
name: 'Later This Month',
|
||||
content: groupRaw['Later This Month'],
|
||||
color: TASK_COLOR.LATER_THIS_MONTH,
|
||||
},
|
||||
{
|
||||
name: 'Future',
|
||||
content: groupRaw['Future'],
|
||||
color: TASK_COLOR.FUTURE,
|
||||
},
|
||||
{
|
||||
case 'due_date': {
|
||||
var { dateGroups: dueDateGroups, anytime: dueAnytime } =
|
||||
buildActualDateGroups(chores)
|
||||
groups = [...dueDateGroups]
|
||||
if (dueAnytime.length > 0) {
|
||||
groups.push({
|
||||
name: 'Anytime',
|
||||
content: groupRaw['Anytime'],
|
||||
content: dueAnytime,
|
||||
color: TASK_COLOR.ANYTIME,
|
||||
},
|
||||
]
|
||||
})
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'priority':
|
||||
groupRaw = {
|
||||
p1: [],
|
||||
|
||||
@@ -728,6 +728,30 @@ const DeleteUser = (password, confirmation, transferOptions = []) => {
|
||||
})
|
||||
}
|
||||
|
||||
const UploadChoreAttachment = (file, entityType, { entityId, draftId } = {}) => {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
formData.append('entityType', entityType)
|
||||
if (entityId != null) formData.append('entityId', String(entityId))
|
||||
if (draftId != null) formData.append('draftId', draftId)
|
||||
return apiClient.upload('/assets/chore', formData)
|
||||
}
|
||||
|
||||
const GetChoreAttachments = choreId => {
|
||||
return Fetch(`/chores/${choreId}/attachments`, {
|
||||
method: 'GET',
|
||||
headers: HEADERS(),
|
||||
})
|
||||
}
|
||||
|
||||
const DeleteChoreAttachment = (choreId, filePath) => {
|
||||
return Fetch(`/chores/${choreId}/attachments`, {
|
||||
method: 'DELETE',
|
||||
headers: HEADERS(),
|
||||
body: JSON.stringify({ file_path: filePath }),
|
||||
})
|
||||
}
|
||||
|
||||
const CreateBackup = (encryptionKey, includeAssets = true, backupName = '') => {
|
||||
return Fetch(`/backup/create`, {
|
||||
method: 'POST',
|
||||
@@ -933,6 +957,9 @@ const TrackFilterUsage = id => {
|
||||
|
||||
export {
|
||||
AcceptCircleMemberRequest,
|
||||
DeleteChoreAttachment,
|
||||
GetChoreAttachments,
|
||||
UploadChoreAttachment,
|
||||
ApproveChore,
|
||||
ArchiveChore,
|
||||
CancelSubscription,
|
||||
|
||||
@@ -10,9 +10,114 @@ const resolvePhotoURL = url => {
|
||||
if (url.startsWith('http') || url.startsWith('https')) {
|
||||
return url
|
||||
}
|
||||
if (url.startsWith('assets')) {
|
||||
return apiClient.getAssetURL(url)
|
||||
}
|
||||
return url
|
||||
return apiClient.getAssetURL(url)
|
||||
}
|
||||
export { isPlusAccount, resolvePhotoURL }
|
||||
|
||||
// Detect cloud storage pre-signed URLs (S3, GCS, Azure) that carry expiry params.
|
||||
const isCloudSignedUrl = url => {
|
||||
if (!url) return false
|
||||
try {
|
||||
return (
|
||||
url.includes('X-Amz-Signature') ||
|
||||
url.includes('X-Amz-Expires') ||
|
||||
url.includes('X-Goog-Expires') ||
|
||||
url.includes('expires')
|
||||
)
|
||||
} catch(e) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Extract the storage key from a cloud signed URL so we can route it through
|
||||
// the backend proxy (which re-signs on every request and never expires).
|
||||
//
|
||||
// Handles:
|
||||
// Virtual-hosted S3: https://{bucket}.s3[.region].amazonaws.com/{key}?...
|
||||
// Path-style S3: https://s3[.region].amazonaws.com/{bucket}/{key}?...
|
||||
// Cloudflare R2: https://{bucket}.{accountid}.r2.cloudflarestorage.com/{key}?...
|
||||
// GCS: https://storage.googleapis.com/{bucket}/{key}?...
|
||||
// Azure Blob: https://{account}.blob.core.windows.net/{container}/{blob}?...
|
||||
//
|
||||
// The app stores files under an "assets/" prefix in the bucket but the backend
|
||||
// proxy already mounts at /assets/, so we strip that leading segment when present.
|
||||
const extractStorageKey = url => {
|
||||
try {
|
||||
const u = new URL(url)
|
||||
const host = u.hostname
|
||||
const rawPath = u.pathname.replace(/^\//, '')
|
||||
|
||||
let key
|
||||
|
||||
if (host.endsWith('.r2.cloudflarestorage.com')) {
|
||||
// Virtual-hosted R2: bucket is in the host, key is the full path
|
||||
key = rawPath
|
||||
} else if (host.endsWith('.amazonaws.com')) {
|
||||
if (host.startsWith('s3') || host.includes('.s3.')) {
|
||||
// Path-style S3: first segment is the bucket — strip it
|
||||
key = rawPath.split('/').slice(1).join('/')
|
||||
} else {
|
||||
// Virtual-hosted S3: bucket is in the host, path is the key
|
||||
key = rawPath
|
||||
}
|
||||
} else if (host === 'storage.googleapis.com') {
|
||||
// First segment is the bucket
|
||||
key = rawPath.split('/').slice(1).join('/')
|
||||
} else if (host.endsWith('.blob.core.windows.net')) {
|
||||
// First segment is the container name
|
||||
key = rawPath.split('/').slice(1).join('/')
|
||||
} else {
|
||||
key = rawPath
|
||||
}
|
||||
|
||||
// The bucket stores files under an "assets/" prefix; the backend /assets/
|
||||
// endpoint already adds that prefix, so strip it to avoid duplication.
|
||||
if (key.startsWith('assets/')) {
|
||||
key = key.slice('assets/'.length)
|
||||
}
|
||||
|
||||
return key || null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// Scan an HTML string for <img> tags whose src is a cloud signed URL and
|
||||
// replace them with backend proxy URLs (which generate fresh signed URLs on
|
||||
// each request). Returns the patched HTML, or the original if nothing changed.
|
||||
const refreshSignedUrlsInHtml = html => {
|
||||
if (!html) return html
|
||||
if (
|
||||
!html.includes('dt-data-path') &&
|
||||
!html.includes('X-Amz-') &&
|
||||
!html.includes('X-Goog-') &&
|
||||
!html.includes('sig') &&
|
||||
!html.includes('.blob.core.windows.net')
|
||||
) {
|
||||
return html
|
||||
}
|
||||
const parser = new DOMParser()
|
||||
const doc = parser.parseFromString(html, 'text/html')
|
||||
const imgs = doc.querySelectorAll('img[src]')
|
||||
let changed = false
|
||||
|
||||
imgs.forEach(img => {
|
||||
const stablePath = img.getAttribute('dt-data-path')
|
||||
const src = img.getAttribute('src')
|
||||
let nextSrc = src
|
||||
|
||||
if (stablePath) {
|
||||
nextSrc = resolvePhotoURL(stablePath)
|
||||
} else if (isCloudSignedUrl(src)) {
|
||||
nextSrc = resolvePhotoURL(extractStorageKey(src))
|
||||
}
|
||||
|
||||
if (nextSrc && nextSrc !== src) {
|
||||
img.setAttribute('src', nextSrc)
|
||||
changed = true
|
||||
}
|
||||
})
|
||||
|
||||
return changed ? doc.body.innerHTML : html
|
||||
}
|
||||
|
||||
export { isPlusAccount, refreshSignedUrlsInHtml, resolvePhotoURL }
|
||||
|
||||
@@ -26,17 +26,32 @@ class SQLiteBackend {
|
||||
constructor() {
|
||||
this.db = null
|
||||
this.initialized = false
|
||||
this._initPromise = null
|
||||
}
|
||||
|
||||
async init() {
|
||||
if (this.initialized) return
|
||||
// Return the in-flight promise if init is already underway (prevents double createConnection)
|
||||
if (this._initPromise) return this._initPromise
|
||||
|
||||
this.db = await CapacitorSQLite.createConnection({
|
||||
database: DB_NAME,
|
||||
version: DB_VERSION,
|
||||
encrypted: false,
|
||||
mode: 'no-encryption',
|
||||
this._initPromise = this._doInit().finally(() => {
|
||||
this._initPromise = null
|
||||
})
|
||||
return this._initPromise
|
||||
}
|
||||
|
||||
async _doInit() {
|
||||
try {
|
||||
this.db = await CapacitorSQLite.createConnection({
|
||||
database: DB_NAME,
|
||||
version: DB_VERSION,
|
||||
encrypted: false,
|
||||
mode: 'no-encryption',
|
||||
})
|
||||
} catch (err) {
|
||||
// Connection already open (e.g. React StrictMode double-mount) — reuse it
|
||||
if (!err?.message?.includes('already exists')) throw err
|
||||
}
|
||||
await CapacitorSQLite.open({ database: DB_NAME })
|
||||
|
||||
await CapacitorSQLite.execute({
|
||||
|
||||
@@ -26,7 +26,7 @@ class StatusBarManager {
|
||||
}
|
||||
|
||||
try {
|
||||
// Configure basic status bar settings - use overlay: true for precise control
|
||||
// Configure basic status bar settings
|
||||
await StatusBar.setOverlaysWebView({ overlay: false })
|
||||
await StatusBar.show()
|
||||
|
||||
|
||||
@@ -55,6 +55,8 @@ class SyncEngine {
|
||||
// Step 3: Delta sync from server
|
||||
await this._deltaSync()
|
||||
|
||||
// Sync succeeded — server is reachable (only sync success restores online status)
|
||||
networkManager.setServerReachable()
|
||||
this._notify({ syncing: false, lastSync: Date.now() })
|
||||
return true
|
||||
} catch (err) {
|
||||
@@ -182,7 +184,7 @@ class SyncEngine {
|
||||
let hasMore = true
|
||||
let currentCursor = cursor
|
||||
|
||||
while (hasMore && networkManager.isOnline) {
|
||||
while (hasMore && networkManager.deviceOnline) {
|
||||
// Use apiClient.get which handles auth and returns a fetch Response
|
||||
const response = await apiClient.get(
|
||||
`/sync/changes?since=${currentCursor}`,
|
||||
|
||||
@@ -9,6 +9,8 @@ import { Link, useNavigate, useParams } from 'react-router-dom'
|
||||
import { useUserProfile } from '../../queries/UserQueries'
|
||||
import { apiClient } from '../../utils/ApiClient'
|
||||
import { GetUserProfile } from '../../utils/Fetcher'
|
||||
import { saveTokens } from '../../utils/TokenStorage'
|
||||
import MFAVerificationModal from './MFAVerificationModal'
|
||||
|
||||
const AuthenticationLoading = () => {
|
||||
const { data: userProfile, refetch: refetchUserProfile } = useUserProfile()
|
||||
@@ -17,6 +19,8 @@ const AuthenticationLoading = () => {
|
||||
const [message, setMessage] = useState('Authenticating')
|
||||
const [subMessage, setSubMessage] = useState('Please wait')
|
||||
const [status, setStatus] = useState('pending')
|
||||
const [mfaModalOpen, setMfaModalOpen] = useState(false)
|
||||
const [mfaSessionToken, setMfaSessionToken] = useState('')
|
||||
const { provider } = useParams()
|
||||
useEffect(() => {
|
||||
if (provider === 'oauth2' && !hasCalledHandleOAuth2.current) {
|
||||
@@ -43,6 +47,29 @@ const AuthenticationLoading = () => {
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const handleMFASuccess = async data => {
|
||||
await saveTokens({
|
||||
accessToken: data.token,
|
||||
accessTokenExpiry: data.expire,
|
||||
refreshToken: data.refresh_token,
|
||||
refreshTokenExpiry: data.refresh_token_expiry,
|
||||
})
|
||||
|
||||
setMfaModalOpen(false)
|
||||
setMfaSessionToken('')
|
||||
|
||||
getUserProfileAndNavigateToHome()
|
||||
}
|
||||
|
||||
const handleMFAClose = () => {
|
||||
setMfaModalOpen(false)
|
||||
setMfaSessionToken('')
|
||||
setMessage('Authentication failed')
|
||||
setSubMessage('Two-factor authentication was cancelled')
|
||||
setStatus('error')
|
||||
}
|
||||
|
||||
const handleOAuth2 = async () => {
|
||||
// get provider from params:
|
||||
const urlParams = new URLSearchParams(window.location.search)
|
||||
@@ -64,37 +91,71 @@ const AuthenticationLoading = () => {
|
||||
const redirectURI = Capacitor.isNativePlatform()
|
||||
? 'donetick://auth/oauth2'
|
||||
: `${window.location.origin}/auth/oauth2`
|
||||
fetch(`${baseURL}/auth/oauth2/callback`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
code,
|
||||
state: returnedState,
|
||||
redirect_uri: redirectURI,
|
||||
}),
|
||||
}).then(response => {
|
||||
if (response.status === 200) {
|
||||
return response.json().then(data => {
|
||||
localStorage.setItem('token', data.token)
|
||||
localStorage.setItem('token_expiry', data.expire)
|
||||
try {
|
||||
const response = await fetch(`${baseURL}/auth/oauth2/callback`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
code,
|
||||
state: returnedState,
|
||||
redirect_uri: redirectURI,
|
||||
}),
|
||||
})
|
||||
|
||||
const redirectUrl = Cookies.get('ca_redirect')
|
||||
if (redirectUrl) {
|
||||
Cookies.remove('ca_redirect')
|
||||
Navigate(redirectUrl)
|
||||
} else {
|
||||
getUserProfileAndNavigateToHome()
|
||||
}
|
||||
})
|
||||
} else {
|
||||
if (!response.ok) {
|
||||
console.error('Authentication failed')
|
||||
setMessage('Authentication failed')
|
||||
setSubMessage('Please try again')
|
||||
setStatus('error')
|
||||
return
|
||||
}
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (data.mfaRequired) {
|
||||
if (!data.sessionToken) {
|
||||
setMessage('Authentication failed')
|
||||
setSubMessage('MFA session is missing. Please try again')
|
||||
setStatus('error')
|
||||
return
|
||||
}
|
||||
|
||||
setMfaSessionToken(data.sessionToken)
|
||||
setMfaModalOpen(true)
|
||||
setMessage('Two-Factor Authentication Required')
|
||||
setSubMessage('Please verify your login to continue')
|
||||
return
|
||||
}
|
||||
|
||||
if (!data.token && !data.access_token) {
|
||||
setMessage('Authentication failed')
|
||||
setSubMessage('No valid authentication token returned')
|
||||
setStatus('error')
|
||||
return
|
||||
}
|
||||
|
||||
await saveTokens({
|
||||
accessToken: data.token || data.access_token,
|
||||
accessTokenExpiry: data.expire || data.access_token_expiry,
|
||||
refreshToken: data.refresh_token,
|
||||
refreshTokenExpiry: data.refresh_token_expiry,
|
||||
})
|
||||
|
||||
const redirectUrl = Cookies.get('ca_redirect')
|
||||
if (redirectUrl) {
|
||||
Cookies.remove('ca_redirect')
|
||||
Navigate(redirectUrl)
|
||||
} else {
|
||||
getUserProfileAndNavigateToHome()
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Authentication request failed', error)
|
||||
setMessage('Authentication failed')
|
||||
setSubMessage('Please try again')
|
||||
setStatus('error')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -138,6 +199,17 @@ const AuthenticationLoading = () => {
|
||||
<Link to='/login'>Go back Login</Link>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<MFAVerificationModal
|
||||
open={mfaModalOpen}
|
||||
onClose={handleMFAClose}
|
||||
sessionToken={mfaSessionToken}
|
||||
onSuccess={handleMFASuccess}
|
||||
onError={() => {
|
||||
setMessage('Authentication failed')
|
||||
setSubMessage('Two-factor authentication failed. Please try again')
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</Container>
|
||||
)
|
||||
|
||||
@@ -1,17 +1,32 @@
|
||||
import { Preferences } from '@capacitor/preferences'
|
||||
import { Box, Button, Container, Input, Sheet, Typography } from '@mui/joy'
|
||||
import CheckCircleOutlineIcon from '@mui/icons-material/CheckCircleOutline'
|
||||
import ErrorOutlineIcon from '@mui/icons-material/ErrorOutline'
|
||||
import WifiIcon from '@mui/icons-material/Wifi'
|
||||
import {
|
||||
Alert,
|
||||
Box,
|
||||
Button,
|
||||
CircularProgress,
|
||||
Container,
|
||||
Input,
|
||||
Sheet,
|
||||
Typography,
|
||||
} from '@mui/joy'
|
||||
import React from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { API_URL } from '../../Config'
|
||||
import Logo from '../../Logo'
|
||||
import { useResource } from '../../queries/ResourceQueries'
|
||||
import { useNotification } from '../../service/NotificationProvider'
|
||||
import { apiClient } from '../../utils/ApiClient'
|
||||
|
||||
const CONNECTION_TIMEOUT_MS = 8000
|
||||
|
||||
const LoginSettings = () => {
|
||||
const Navigate = useNavigate()
|
||||
const { refetch: refetchResource } = useResource()
|
||||
const [serverURL, setServerURL] = React.useState('')
|
||||
const { showError } = useNotification()
|
||||
const [status, setStatus] = React.useState('idle') // 'idle' | 'testing' | 'success' | 'error'
|
||||
const [errorMessage, setErrorMessage] = React.useState('')
|
||||
|
||||
React.useEffect(() => {
|
||||
Preferences.get({ key: 'customServerUrl' }).then(result => {
|
||||
@@ -19,10 +34,95 @@ const LoginSettings = () => {
|
||||
})
|
||||
}, [])
|
||||
|
||||
const isValidServerURL = () => {
|
||||
return serverURL.match(/^(http|https):\/\/[^ "]+$/)
|
||||
const isValidURL = url => {
|
||||
return /^(http|https):\/\/[^ "]+$/.test(url.trim())
|
||||
}
|
||||
|
||||
const testConnection = async url => {
|
||||
const controller = new AbortController()
|
||||
const timeoutId = setTimeout(
|
||||
() => controller.abort(),
|
||||
CONNECTION_TIMEOUT_MS,
|
||||
)
|
||||
try {
|
||||
const testURL = url.replace(/\/+$/, '') + '/api/v1/resource'
|
||||
const response = await fetch(testURL, {
|
||||
method: 'GET',
|
||||
signal: controller.signal,
|
||||
})
|
||||
clearTimeout(timeoutId)
|
||||
// Any HTTP response (even 401/404) means the server is reachable
|
||||
if (response.status < 500) {
|
||||
return { ok: true }
|
||||
}
|
||||
return {
|
||||
ok: false,
|
||||
message: `Server responded with error ${response.status}. Please check your Donetick server.`,
|
||||
}
|
||||
} catch (err) {
|
||||
clearTimeout(timeoutId)
|
||||
if (err.name === 'AbortError') {
|
||||
return {
|
||||
ok: false,
|
||||
message: `Connection timed out after ${CONNECTION_TIMEOUT_MS / 1000}s. Check the URL and ensure the server is running.`,
|
||||
}
|
||||
}
|
||||
return {
|
||||
ok: false,
|
||||
message:
|
||||
'Unable to reach the server. Check the URL, port, and network connection.',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
const trimmedURL = serverURL.trim()
|
||||
|
||||
if (trimmedURL === '') {
|
||||
await Preferences.set({ key: 'customServerUrl', value: API_URL })
|
||||
Navigate('/login')
|
||||
return
|
||||
}
|
||||
|
||||
if (!isValidURL(trimmedURL)) {
|
||||
setStatus('error')
|
||||
setErrorMessage(
|
||||
'Invalid URL format. Include the protocol (http:// or https://) and port if needed.',
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
setStatus('testing')
|
||||
setErrorMessage('')
|
||||
|
||||
const result = await testConnection(trimmedURL)
|
||||
|
||||
if (!result.ok) {
|
||||
setStatus('error')
|
||||
setErrorMessage(result.message)
|
||||
return
|
||||
}
|
||||
|
||||
await Preferences.set({ key: 'customServerUrl', value: trimmedURL })
|
||||
await apiClient.init(true)
|
||||
refetchResource()
|
||||
setStatus('success')
|
||||
|
||||
setTimeout(() => {
|
||||
Navigate('/login')
|
||||
}, 1200)
|
||||
}
|
||||
|
||||
const handleURLChange = e => {
|
||||
setServerURL(e.target.value)
|
||||
if (status !== 'idle') {
|
||||
setStatus('idle')
|
||||
setErrorMessage('')
|
||||
}
|
||||
}
|
||||
|
||||
const isTesting = status === 'testing'
|
||||
|
||||
return (
|
||||
<Container component='main' maxWidth='xs'>
|
||||
<Box
|
||||
@@ -38,7 +138,6 @@ const LoginSettings = () => {
|
||||
sx={{
|
||||
mt: 1,
|
||||
width: '100%',
|
||||
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
@@ -51,13 +150,7 @@ const LoginSettings = () => {
|
||||
|
||||
<Typography level='h2'>
|
||||
Done
|
||||
<span
|
||||
style={{
|
||||
color: '#06b6d4',
|
||||
}}
|
||||
>
|
||||
tick
|
||||
</span>
|
||||
<span style={{ color: '#06b6d4' }}>tick</span>
|
||||
</Typography>
|
||||
|
||||
<Typography level='body2' alignSelf={'start'} mt={4}>
|
||||
@@ -71,9 +164,22 @@ const LoginSettings = () => {
|
||||
name='serverURL'
|
||||
autoFocus
|
||||
value={serverURL}
|
||||
onChange={e => {
|
||||
setServerURL(e.target.value)
|
||||
}}
|
||||
onChange={handleURLChange}
|
||||
disabled={isTesting}
|
||||
color={
|
||||
status === 'success'
|
||||
? 'success'
|
||||
: status === 'error'
|
||||
? 'danger'
|
||||
: 'neutral'
|
||||
}
|
||||
endDecorator={
|
||||
status === 'success' ? (
|
||||
<CheckCircleOutlineIcon color='success' fontSize='small' />
|
||||
) : status === 'error' ? (
|
||||
<ErrorOutlineIcon color='error' fontSize='small' />
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
|
||||
<Typography mt={1} level='body-xs'>
|
||||
@@ -81,72 +187,68 @@ const LoginSettings = () => {
|
||||
own self-hosted Donetick server.
|
||||
</Typography>
|
||||
<Typography mt={1} level='body-xs'>
|
||||
Please ensure to include the protocol (http:// or https://) and the
|
||||
port number if necessary (default Donetick port is 2021).
|
||||
Include the protocol (http:// or https://) and port if necessary
|
||||
(default Donetick port is 2021).
|
||||
</Typography>
|
||||
|
||||
{status === 'error' && (
|
||||
<Alert
|
||||
color='danger'
|
||||
variant='soft'
|
||||
startDecorator={<ErrorOutlineIcon />}
|
||||
sx={{ mt: 2, width: '100%' }}
|
||||
>
|
||||
{errorMessage}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{status === 'success' && (
|
||||
<Alert
|
||||
color='success'
|
||||
variant='soft'
|
||||
startDecorator={<CheckCircleOutlineIcon />}
|
||||
sx={{ mt: 2, width: '100%' }}
|
||||
>
|
||||
Connected! Redirecting to login...
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{status === 'testing' && (
|
||||
<Alert
|
||||
color='neutral'
|
||||
variant='soft'
|
||||
startDecorator={<WifiIcon />}
|
||||
sx={{ mt: 2, width: '100%' }}
|
||||
>
|
||||
Testing connection to server...
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Button
|
||||
fullWidth
|
||||
size='lg'
|
||||
variant='solid'
|
||||
sx={{
|
||||
width: '100%',
|
||||
mt: 3,
|
||||
mb: 2,
|
||||
border: 'moccasin',
|
||||
borderRadius: '8px',
|
||||
}}
|
||||
onClick={() => {
|
||||
if (serverURL === '') {
|
||||
Preferences.set({
|
||||
key: 'customServerUrl',
|
||||
value: API_URL,
|
||||
}).then(() => {
|
||||
Navigate('/login')
|
||||
})
|
||||
return
|
||||
}
|
||||
if (!isValidServerURL()) {
|
||||
showError({
|
||||
title: 'Invalid Server URL',
|
||||
message:
|
||||
'Please enter a valid server URL with protocol (http:// or https://)',
|
||||
})
|
||||
return
|
||||
}
|
||||
Preferences.set({
|
||||
key: 'customServerUrl',
|
||||
value: serverURL,
|
||||
}).then(async () => {
|
||||
// apiClient.customServerURL = serverURL + '/api/v1's
|
||||
// Force re-initialization to reload from Preferences
|
||||
await apiClient.init(true)
|
||||
// refetch resource queries to update the API URL
|
||||
refetchResource()
|
||||
Navigate('/login')
|
||||
})
|
||||
}}
|
||||
disabled={isTesting || status === 'success'}
|
||||
sx={{ width: '100%', mt: 2, mb: 2, borderRadius: '8px' }}
|
||||
onClick={handleSave}
|
||||
startDecorator={
|
||||
isTesting ? <CircularProgress size='sm' /> : undefined
|
||||
}
|
||||
>
|
||||
Save
|
||||
{isTesting ? 'Testing...' : 'Save & Connect'}
|
||||
</Button>
|
||||
<Button
|
||||
fullWidth
|
||||
size='lg'
|
||||
variant='soft'
|
||||
color='danger'
|
||||
sx={{
|
||||
width: '100%',
|
||||
|
||||
mb: 2,
|
||||
border: 'moccasin',
|
||||
borderRadius: '8px',
|
||||
}}
|
||||
onClick={() => {
|
||||
Preferences.set({ key: 'customServerUrl', value: API_URL }).then(
|
||||
() => {
|
||||
refetchResource()
|
||||
Navigate('/login')
|
||||
},
|
||||
)
|
||||
disabled={isTesting}
|
||||
sx={{ width: '100%', mb: 2, borderRadius: '8px' }}
|
||||
onClick={async () => {
|
||||
await Preferences.set({ key: 'customServerUrl', value: API_URL })
|
||||
await apiClient.init(true)
|
||||
refetchResource()
|
||||
Navigate('/login')
|
||||
}}
|
||||
>
|
||||
Cancel and Reset
|
||||
|
||||
@@ -1,4 +1,12 @@
|
||||
import { Add, ArrowDropDown, HorizontalRule, Save } from '@mui/icons-material'
|
||||
import {
|
||||
Add,
|
||||
ArrowDropDown,
|
||||
AttachFile,
|
||||
Delete,
|
||||
HorizontalRule,
|
||||
Save,
|
||||
UploadFile,
|
||||
} from '@mui/icons-material'
|
||||
import {
|
||||
Avatar,
|
||||
Box,
|
||||
@@ -43,8 +51,13 @@ import {
|
||||
import { useCircleMembers, useUserProfile } from '../../queries/UserQueries.jsx'
|
||||
import { useNotification } from '../../service/NotificationProvider'
|
||||
import { getTextColorFromBackgroundColor } from '../../utils/Colors.jsx'
|
||||
import { GetAllCircleMembers, GetThings } from '../../utils/Fetcher'
|
||||
import { isPlusAccount } from '../../utils/Helpers'
|
||||
import {
|
||||
DeleteChoreAttachment,
|
||||
GetAllCircleMembers,
|
||||
GetThings,
|
||||
UploadChoreAttachment,
|
||||
} from '../../utils/Fetcher'
|
||||
import { isPlusAccount, resolvePhotoURL } from '../../utils/Helpers'
|
||||
import Priorities from '../../utils/Priorities.jsx'
|
||||
import { getIconComponent } from '../../utils/ProjectIcons'
|
||||
import { getSafeBottomPadding } from '../../utils/SafeAreaUtils.js'
|
||||
@@ -53,6 +66,7 @@ import LoadingComponent from '../components/Loading.jsx'
|
||||
import RichTextEditor from '../components/RichTextEditor.jsx'
|
||||
import SubTasks from '../components/SubTask.jsx'
|
||||
import { useLabels } from '../Labels/LabelQueries'
|
||||
import AttachmentViewerModal from '../Modals/Inputs/AttachmentViewerModal'
|
||||
import ConfirmationModal from '../Modals/Inputs/ConfirmationModal'
|
||||
import LabelModal from '../Modals/Inputs/LabelModal'
|
||||
import { useProjects } from '../Projects/ProjectQueries'
|
||||
@@ -83,7 +97,8 @@ const ChoreEdit = () => {
|
||||
const [name, setName] = useState('')
|
||||
const [description, setDescription] = useState('')
|
||||
const [confirmModelConfig, setConfirmModelConfig] = useState({})
|
||||
const [assignees, setAssignees] = useState([])
|
||||
const [anyone, setAnyone] = useState(false)
|
||||
const [assignableTo, setAssignableTo] = useState([])
|
||||
const [performers, setPerformers] = useState([])
|
||||
const [assignStrategy, setAssignStrategy] = useState(ASSIGN_STRATEGIES[2])
|
||||
const [dueDate, setDueDate] = useState(null)
|
||||
@@ -116,7 +131,13 @@ const ChoreEdit = () => {
|
||||
const [createdBy, setCreatedBy] = useState(0)
|
||||
const [errors, setErrors] = useState({})
|
||||
const [attemptToSave, setAttemptToSave] = useState(false)
|
||||
const [draftId] = useState(() => crypto.randomUUID())
|
||||
const [attachments, setAttachments] = useState([])
|
||||
const [isUploadingAttachment, setIsUploadingAttachment] = useState(false)
|
||||
const [addLabelModalOpen, setAddLabelModalOpen] = useState(false)
|
||||
const [attachmentViewerConfig, setAttachmentViewerConfig] = useState({
|
||||
isOpen: false,
|
||||
})
|
||||
const [showSavePrivacyDefault, setShowSavePrivacyDefault] = useState(false)
|
||||
const [privacySaved, setPrivacySaved] = useState(false)
|
||||
const [showSaveNotificationDefault, setShowSaveNotificationDefault] =
|
||||
@@ -158,6 +179,7 @@ const ChoreEdit = () => {
|
||||
|
||||
const Navigate = useNavigate()
|
||||
|
||||
const assignees = anyone ? performers : assignableTo
|
||||
const HandleValidateChore = () => {
|
||||
const errors = {}
|
||||
|
||||
@@ -330,6 +352,7 @@ const ChoreEdit = () => {
|
||||
if (searchParams.get('clone') === 'true') {
|
||||
newChoreId = null
|
||||
}
|
||||
const assignees = anyone ? [] : assignableTo
|
||||
const chore = {
|
||||
id: Number(newChoreId),
|
||||
name: name,
|
||||
@@ -359,6 +382,7 @@ const ChoreEdit = () => {
|
||||
deadlineOffset: deadlineOffset < 0 ? null : deadlineOffset,
|
||||
priority: priority,
|
||||
projectId: projectId === 'default' ? null : projectId,
|
||||
draftId: newChoreId > 0 ? undefined : draftId,
|
||||
}
|
||||
let SaveFunction = createChoreMutation.mutateAsync
|
||||
if (newChoreId > 0) {
|
||||
@@ -419,15 +443,29 @@ const ChoreEdit = () => {
|
||||
setIsNotificable(JSON.parse(defaultNotificationSetting))
|
||||
}
|
||||
|
||||
const defaultAnyoneSetting = localStorage.getItem('defaultAnyoneSetting')
|
||||
if (defaultAnyoneSetting != null) {
|
||||
const savedAnyone = JSON.parse(defaultAnyoneSetting)
|
||||
setAnyone(savedAnyone)
|
||||
}
|
||||
|
||||
const defaultAssigneeSetting = localStorage.getItem(
|
||||
'defaultAssigneeSetting',
|
||||
)
|
||||
if (defaultAssigneeSetting !== null) {
|
||||
const savedAssignees = JSON.parse(defaultAssigneeSetting)
|
||||
setAssignees(savedAssignees)
|
||||
setAssignableTo(savedAssignees)
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
useEffect(() => {
|
||||
const anyoneSetting = localStorage.getItem('defaultAnyoneSetting')
|
||||
const anyoneDirty = anyoneSetting !== JSON.stringify(anyone)
|
||||
const assigneeSetting = localStorage.getItem('defaultAssigneeSetting')
|
||||
const assigneeDirty = assigneeSetting !== JSON.stringify(assignableTo)
|
||||
const dirty = anyoneDirty || (!anyone && assigneeDirty)
|
||||
setShowSaveAssigneeDefault(dirty)
|
||||
}, [anyone, assignableTo])
|
||||
|
||||
// Keyboard shortcuts
|
||||
useEffect(() => {
|
||||
@@ -477,7 +515,8 @@ const ChoreEdit = () => {
|
||||
setChore(data.res)
|
||||
setName(data.res.name ? data.res.name : '')
|
||||
setDescription(data.res.description ? data.res.description : '')
|
||||
setAssignees(data.res.assignees ? data.res.assignees : [])
|
||||
setAssignableTo(data.res.assignees ? data.res.assignees : [])
|
||||
setAnyone((data.res.assignees?.length || 0) === 0)
|
||||
setAssignedTo(data.res.assignedTo)
|
||||
setFrequencyType(data.res.frequencyType ? data.res.frequencyType : 'once')
|
||||
|
||||
@@ -558,6 +597,7 @@ const ChoreEdit = () => {
|
||||
|
||||
setCreatedBy(data.res.createdBy)
|
||||
setUpdatedBy(data.res.updatedBy)
|
||||
setAttachments(data.res.attachments || [])
|
||||
}
|
||||
}, [choreData, isChoreLoading, searchParams])
|
||||
|
||||
@@ -591,13 +631,15 @@ const ChoreEdit = () => {
|
||||
if (assignees.length === 0) {
|
||||
setAssignStrategy('no_assignee')
|
||||
setAssignedTo(null)
|
||||
} else if (assignees.length === 1) {
|
||||
setAssignedTo(assignees[0].userId)
|
||||
} else {
|
||||
if (!assignees.some(a => a.userId === assignedTo)) {
|
||||
setAssignedTo(assignees[0].userId)
|
||||
}
|
||||
if (assignStrategy === 'no_assignee') {
|
||||
setAssignStrategy(ASSIGN_STRATEGIES[2]) // default to least_completed
|
||||
}
|
||||
}
|
||||
}, [assignees, assignStrategy])
|
||||
}, [assignStrategy, assignedTo, assignees])
|
||||
|
||||
// useEffect(() => {
|
||||
// if (performers.length > 0 && assignees.length === 0 && userProfile) {
|
||||
@@ -614,7 +656,7 @@ const ChoreEdit = () => {
|
||||
if (attemptToSave) {
|
||||
HandleValidateChore()
|
||||
}
|
||||
}, [assignees, name, frequencyMetadata, attemptToSave, dueDate])
|
||||
}, [assignableTo, name, frequencyMetadata, attemptToSave, dueDate])
|
||||
|
||||
const handleDelete = () => {
|
||||
setConfirmModelConfig({
|
||||
@@ -921,6 +963,175 @@ const ChoreEdit = () => {
|
||||
/>
|
||||
</Card>
|
||||
</Box>
|
||||
|
||||
<Box mt={3}>
|
||||
<Typography level='h4'>Attachments</Typography>
|
||||
<Typography level='body-md'>Files attached to this task</Typography>
|
||||
<Card variant='outlined' sx={{ mt: 2, p: 1.5 }}>
|
||||
{attachments.length > 0 && (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1, mb: 1.5 }}>
|
||||
{attachments.map((att, idx) => (
|
||||
<Box
|
||||
key={att.file_path || idx}
|
||||
onClick={() => {
|
||||
const url = resolvePhotoURL(att.sign || att.file_path)
|
||||
const ext = (att.file_name || '')
|
||||
.split('.')
|
||||
.pop()
|
||||
.toLowerCase()
|
||||
const isImage = [
|
||||
'jpg',
|
||||
'jpeg',
|
||||
'png',
|
||||
'gif',
|
||||
'webp',
|
||||
'bmp',
|
||||
'svg',
|
||||
].includes(ext)
|
||||
if (isImage) {
|
||||
setAttachmentViewerConfig({
|
||||
isOpen: true,
|
||||
url,
|
||||
fileName: att.file_name,
|
||||
onClose: () =>
|
||||
setAttachmentViewerConfig({ isOpen: false }),
|
||||
})
|
||||
} else {
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = att.file_name || 'attachment'
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
}
|
||||
}}
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 1,
|
||||
p: 1,
|
||||
borderRadius: 'sm',
|
||||
border: '1px solid',
|
||||
borderColor: 'neutral.outlinedBorder',
|
||||
cursor: 'pointer',
|
||||
'&:hover': { bgcolor: 'neutral.softHoverBg' },
|
||||
}}
|
||||
>
|
||||
<AttachFile sx={{ fontSize: 18, color: 'neutral.500' }} />
|
||||
<Typography
|
||||
level='body-sm'
|
||||
sx={{ flex: 1, wordBreak: 'break-all' }}
|
||||
>
|
||||
{att.file_name}
|
||||
</Typography>
|
||||
{att.size_bytes && (
|
||||
<Typography level='body-xs' color='neutral'>
|
||||
{(att.size_bytes / 1024).toFixed(1)} KB
|
||||
</Typography>
|
||||
)}
|
||||
{choreId && (
|
||||
<IconButton
|
||||
size='sm'
|
||||
variant='plain'
|
||||
color='danger'
|
||||
onClick={event => {
|
||||
event.stopPropagation()
|
||||
DeleteChoreAttachment(choreId, att.file_path)
|
||||
.then(() => {
|
||||
setAttachments(prev =>
|
||||
prev.filter(a => a.file_path !== att.file_path),
|
||||
)
|
||||
})
|
||||
.catch(() => {
|
||||
showError({
|
||||
title: 'Delete Failed',
|
||||
message: 'Failed to delete attachment.',
|
||||
})
|
||||
})
|
||||
}}
|
||||
>
|
||||
<Delete sx={{ fontSize: 18 }} />
|
||||
</IconButton>
|
||||
)}
|
||||
{!choreId && (
|
||||
<IconButton
|
||||
size='sm'
|
||||
variant='plain'
|
||||
color='danger'
|
||||
onClick={event => {
|
||||
event.stopPropagation()
|
||||
setAttachments(prev =>
|
||||
prev.filter((_, i) => i !== idx),
|
||||
)
|
||||
}}
|
||||
>
|
||||
<Delete sx={{ fontSize: 18 }} />
|
||||
</IconButton>
|
||||
)}
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
<Button
|
||||
component='label'
|
||||
variant='outlined'
|
||||
color='neutral'
|
||||
size='sm'
|
||||
startDecorator={
|
||||
isUploadingAttachment ? null : <UploadFile />
|
||||
}
|
||||
loading={isUploadingAttachment}
|
||||
sx={{ alignSelf: 'flex-start' }}
|
||||
>
|
||||
Upload File
|
||||
<input
|
||||
type='file'
|
||||
hidden
|
||||
onChange={async e => {
|
||||
const file = e.target.files[0]
|
||||
if (!file) return
|
||||
setIsUploadingAttachment(true)
|
||||
try {
|
||||
const response = choreId
|
||||
? await UploadChoreAttachment(file, 'chore_attachment', {
|
||||
entityId: choreId,
|
||||
})
|
||||
: await UploadChoreAttachment(
|
||||
file,
|
||||
'chore_attachment_draft',
|
||||
{ draftId },
|
||||
)
|
||||
if (!response.ok) {
|
||||
showError({
|
||||
title: 'Upload Failed',
|
||||
message: 'Failed to upload attachment.',
|
||||
})
|
||||
return
|
||||
}
|
||||
const data = await response.json()
|
||||
setAttachments(prev => [
|
||||
...prev,
|
||||
{
|
||||
file_path: data.path,
|
||||
file_name: data.file_name,
|
||||
size_bytes: data.size_bytes,
|
||||
sign: data.sign,
|
||||
},
|
||||
])
|
||||
} catch {
|
||||
showError({
|
||||
title: 'Upload Failed',
|
||||
message: 'Failed to upload attachment.',
|
||||
})
|
||||
} finally {
|
||||
setIsUploadingAttachment(false)
|
||||
e.target.value = ''
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Button>
|
||||
</Card>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Section 2: Assignment & Responsibility */}
|
||||
@@ -941,9 +1152,9 @@ const ChoreEdit = () => {
|
||||
|
||||
<ListItem key={'anyone'}>
|
||||
<Checkbox
|
||||
checked={assignees.length === 0}
|
||||
checked={anyone}
|
||||
onClick={() => {
|
||||
setAssignees([])
|
||||
setAnyone(!anyone)
|
||||
setIsPrivate(false)
|
||||
}}
|
||||
overlay
|
||||
@@ -956,19 +1167,25 @@ const ChoreEdit = () => {
|
||||
{performers?.map((item, index) => (
|
||||
<ListItem key={item.id}>
|
||||
<Checkbox
|
||||
checked={
|
||||
assignees.find(a => a.userId == item.userId) != null
|
||||
}
|
||||
checked={assignableTo.some(a => a.userId == item.userId)}
|
||||
disabled={anyone}
|
||||
onClick={() => {
|
||||
if (anyone) {
|
||||
setAnyone(false)
|
||||
setAssignableTo([{ userId: item.userId }])
|
||||
return
|
||||
}
|
||||
const assignees = assignableTo
|
||||
const setAssignees = setAssignableTo
|
||||
if (assignees.some(a => a.userId === item.userId)) {
|
||||
const newAssignees = assignees.filter(
|
||||
a => a.userId !== item.userId,
|
||||
)
|
||||
setAnyone(newAssignees.length === 0)
|
||||
setAssignees(newAssignees)
|
||||
} else {
|
||||
setAssignees([...assignees, { userId: item.userId }])
|
||||
}
|
||||
setShowSaveAssigneeDefault(true)
|
||||
}}
|
||||
overlay
|
||||
disableIcon
|
||||
@@ -998,9 +1215,13 @@ const ChoreEdit = () => {
|
||||
},
|
||||
}}
|
||||
onClick={() => {
|
||||
localStorage.setItem(
|
||||
'defaultAnyoneSetting',
|
||||
JSON.stringify(anyone),
|
||||
)
|
||||
localStorage.setItem(
|
||||
'defaultAssigneeSetting',
|
||||
JSON.stringify(assignees),
|
||||
JSON.stringify(assignableTo),
|
||||
)
|
||||
setShowSaveAssigneeDefault(false)
|
||||
}}
|
||||
@@ -1026,17 +1247,12 @@ const ChoreEdit = () => {
|
||||
}
|
||||
disabled={assignees.length === 0}
|
||||
value={assignedTo > -1 ? assignedTo : null}
|
||||
onChange={(_, selectedUserId) => setAssignedTo(selectedUserId)}
|
||||
>
|
||||
{performers
|
||||
?.filter(p => assignees.find(a => a.userId == p.userId))
|
||||
?.filter(p => assignees.some(a => a.userId == p.userId))
|
||||
.map((item, index) => (
|
||||
<Option
|
||||
value={item.userId}
|
||||
key={item.displayName}
|
||||
onClick={() => {
|
||||
setAssignedTo(item.userId)
|
||||
}}
|
||||
>
|
||||
<Option value={item.userId} key={item.displayName}>
|
||||
{item.displayName}
|
||||
</Option>
|
||||
))}
|
||||
@@ -1707,6 +1923,7 @@ const ChoreEdit = () => {
|
||||
)}
|
||||
</Button>
|
||||
</Sheet>
|
||||
<AttachmentViewerModal config={attachmentViewerConfig} />
|
||||
<ConfirmationModal config={confirmModelConfig} />
|
||||
{addLabelModalOpen && (
|
||||
<LabelModal
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
Archive,
|
||||
AttachFile,
|
||||
CalendarMonth,
|
||||
Check,
|
||||
Checklist,
|
||||
@@ -78,6 +79,7 @@ import {
|
||||
import { offlineDB } from '../../utils/OfflineDB'
|
||||
import Priorities from '../../utils/Priorities'
|
||||
import { getSafeBottomPadding } from '../../utils/SafeAreaUtils.js'
|
||||
import AttachmentBrowserModal from '../Modals/Inputs/AttachmentBrowserModal'
|
||||
import ConfirmationModal from '../Modals/Inputs/ConfirmationModal'
|
||||
import NoteViewerModal from '../Modals/Inputs/NoteViewerModal'
|
||||
import LoadingComponent from '../components/Loading.jsx'
|
||||
@@ -86,6 +88,7 @@ import RichTextEditor from '../components/RichTextEditor.jsx'
|
||||
import SubTasks from '../components/SubTask.jsx'
|
||||
import TimePassedCard from './TimePassedCard.jsx'
|
||||
import TimerSplitButton from './TimerSplitButton.jsx'
|
||||
import { refreshSignedUrlsInHtml } from '../../utils/Helpers.jsx'
|
||||
|
||||
const isNetworkError = err =>
|
||||
err instanceof TypeError && err.message === 'Failed to fetch'
|
||||
@@ -124,6 +127,7 @@ const ChoreView = () => {
|
||||
const [chorePriority, setChorePriority] = useState(null)
|
||||
const [noteViewerConfig, setNoteViewerConfig] = useState({ isOpen: false })
|
||||
const [timerActionConfig, setTimerActionConfig] = useState({ isOpen: false })
|
||||
const [attachmentBrowserOpen, setAttachmentBrowserOpen] = useState(false)
|
||||
const { data: circleMembersData, isLoading: isCircleMembersLoading } =
|
||||
useCircleMembers()
|
||||
const { data: userProfile } = useUserProfile()
|
||||
@@ -132,6 +136,7 @@ const ChoreView = () => {
|
||||
const { data: choreData, isLoading: isChoreLoading } =
|
||||
useChoreDetails(choreId)
|
||||
const { data: choreHistoryData } = useChoreHistory(choreId)
|
||||
|
||||
const { data: pendingCmds } = usePendingCommands(choreId)
|
||||
|
||||
const choreHistory = choreHistoryData?.res || []
|
||||
@@ -158,8 +163,8 @@ const ChoreView = () => {
|
||||
document.title = 'Donetick: ' + choreData.res.name
|
||||
|
||||
setPerformers(circleMembersData.res)
|
||||
const auto_complete = searchParams.get('auto_complete')
|
||||
if (auto_complete === 'true') {
|
||||
if (searchParams.get('auto_complete') === 'true') {
|
||||
navigate({ search: '' }, { replace: true })
|
||||
handleTaskCompletion()
|
||||
}
|
||||
}, [choreData, circleMembersData])
|
||||
@@ -651,16 +656,14 @@ const ChoreView = () => {
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
mb: 1,
|
||||
flexWrap: 'wrap',
|
||||
gap: 0.5,
|
||||
}}
|
||||
>
|
||||
{chore?.labelsV2?.map((label, index) => (
|
||||
<Chip
|
||||
key={index}
|
||||
sx={{
|
||||
position: 'relative',
|
||||
ml: index === 0 ? 0 : 0.5,
|
||||
top: 2,
|
||||
zIndex: 1,
|
||||
backgroundColor: label?.color,
|
||||
color: getTextColorFromBackgroundColor(label?.color),
|
||||
}}
|
||||
@@ -668,6 +671,20 @@ const ChoreView = () => {
|
||||
{label?.name}
|
||||
</Chip>
|
||||
))}
|
||||
|
||||
{chore?.attachments?.length > 0 && (
|
||||
<Chip
|
||||
startDecorator={<AttachFile />}
|
||||
size='md'
|
||||
variant='soft'
|
||||
color='neutral'
|
||||
onClick={() => setAttachmentBrowserOpen(true)}
|
||||
sx={{ cursor: 'pointer' }}
|
||||
>
|
||||
{chore.attachments.length}{' '}
|
||||
{chore.attachments.length === 1 ? 'attachment' : 'attachments'}
|
||||
</Chip>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
@@ -918,7 +935,7 @@ const ChoreView = () => {
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-word',
|
||||
}}
|
||||
dangerouslySetInnerHTML={{ __html: raw }}
|
||||
dangerouslySetInnerHTML={{ __html: refreshSignedUrlsInHtml(raw) }}
|
||||
/>
|
||||
) : (
|
||||
<Typography
|
||||
@@ -986,7 +1003,7 @@ const ChoreView = () => {
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-word',
|
||||
}}
|
||||
dangerouslySetInnerHTML={{ __html: raw }}
|
||||
dangerouslySetInnerHTML={{ __html: refreshSignedUrlsInHtml(raw) }}
|
||||
/>
|
||||
) : (
|
||||
<Typography
|
||||
@@ -1324,6 +1341,11 @@ const ChoreView = () => {
|
||||
<ConfirmationModal config={confirmModelConfig} />
|
||||
<ConfirmationModal config={timerActionConfig} />
|
||||
<NoteViewerModal config={noteViewerConfig} />
|
||||
<AttachmentBrowserModal
|
||||
choreId={choreId}
|
||||
isOpen={attachmentBrowserOpen}
|
||||
onClose={() => setAttachmentBrowserOpen(false)}
|
||||
/>
|
||||
</Card>
|
||||
</Container>
|
||||
)
|
||||
|
||||
@@ -108,7 +108,7 @@ const generateSchedulePreview = (metadata, formatTimeFn) => {
|
||||
return `Every ${dayNames} at ${timeStr}`
|
||||
}
|
||||
|
||||
const RepeatOnSections = ({
|
||||
export const RepeatOnSections = ({
|
||||
frequencyType,
|
||||
frequency,
|
||||
onFrequencyUpdate,
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
import {
|
||||
Archive,
|
||||
CheckBox,
|
||||
CheckBoxOutlineBlank,
|
||||
Close,
|
||||
Delete,
|
||||
SelectAll,
|
||||
Unarchive,
|
||||
ViewAgenda,
|
||||
ViewModule,
|
||||
Archive,
|
||||
CheckBox,
|
||||
CheckBoxOutlineBlank,
|
||||
Close,
|
||||
Delete,
|
||||
Label,
|
||||
Person,
|
||||
PriorityHigh,
|
||||
SelectAll,
|
||||
Unarchive,
|
||||
ViewAgenda,
|
||||
ViewModule,
|
||||
} from '@mui/icons-material'
|
||||
import {
|
||||
Box,
|
||||
@@ -22,15 +25,18 @@ import {
|
||||
} from '@mui/joy'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import Fuse from 'fuse.js'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import FilterBar from '../../components/common/FilterBar'
|
||||
import KeyboardShortcutHint from '../../components/common/KeyboardShortcutHint'
|
||||
import { useImpersonateUser } from '../../contexts/ImpersonateUserContext.jsx'
|
||||
import { useFilter } from '../../hooks/useFilter'
|
||||
import { useUnArchiveChore } from '../../queries/ChoreQueries'
|
||||
import { useCircleMembers, useUserProfile } from '../../queries/UserQueries'
|
||||
import { useNotification } from '../../service/NotificationProvider'
|
||||
import { commandQueue, CommandType } from '../../utils/CommandQueue'
|
||||
import { DeleteChore, GetArchivedChores } from '../../utils/Fetcher'
|
||||
import Priorities from '../../utils/Priorities'
|
||||
import { offlineDB } from '../../utils/OfflineDB'
|
||||
import LoadingComponent from '../components/Loading'
|
||||
import ConfirmationModal from '../Modals/Inputs/ConfirmationModal'
|
||||
@@ -107,6 +113,95 @@ const ArchivedTasks = () => {
|
||||
|
||||
const { data: membersData, isLoading: membersLoading } = useCircleMembers()
|
||||
|
||||
// Unique labels present across all archived chores
|
||||
const availableLabels = useMemo(() => {
|
||||
const seen = {}
|
||||
archivedChores.forEach(c => {
|
||||
c.labelsV2?.forEach(l => { seen[l.id] = l })
|
||||
})
|
||||
return Object.values(seen)
|
||||
}, [archivedChores])
|
||||
|
||||
const filterDefs = useMemo(
|
||||
() => [
|
||||
{
|
||||
id: 'assignee',
|
||||
label: 'Assignee',
|
||||
type: 'multi-select',
|
||||
icon: <Person />,
|
||||
options: performers.map(p => ({
|
||||
value: p.userId,
|
||||
label: p.displayName,
|
||||
avatar: p.image,
|
||||
})),
|
||||
filterFn: (item, values) => values.includes(item.assignedTo),
|
||||
},
|
||||
{
|
||||
id: 'priority',
|
||||
label: 'Priority',
|
||||
type: 'multi-select',
|
||||
icon: <PriorityHigh />,
|
||||
options: Priorities.map(p => ({
|
||||
value: p.value,
|
||||
label: p.name,
|
||||
color: p.color || 'neutral',
|
||||
icon: p.icon,
|
||||
})),
|
||||
filterFn: (item, values) => values.includes(item.priority ?? 0),
|
||||
},
|
||||
...(availableLabels.length > 0
|
||||
? [
|
||||
{
|
||||
id: 'label',
|
||||
label: 'Labels',
|
||||
type: 'multi-select',
|
||||
icon: <Label />,
|
||||
options: availableLabels.map(l => ({
|
||||
value: l.id,
|
||||
label: l.name,
|
||||
icon: (
|
||||
<Box
|
||||
component='span'
|
||||
sx={{
|
||||
display: 'inline-block',
|
||||
width: 10,
|
||||
height: 10,
|
||||
borderRadius: '50%',
|
||||
bgcolor: l.color || '#90a4ae',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
),
|
||||
})),
|
||||
filterFn: (item, values) =>
|
||||
item.labelsV2?.some(l => values.includes(l.id)) ?? false,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
id: 'archivedAt',
|
||||
label: 'Archived Date',
|
||||
type: 'date-range',
|
||||
icon: <Archive />,
|
||||
filterFn: (item, value) => {
|
||||
const date = new Date(item.updatedAt)
|
||||
if (value.from && date < new Date(value.from)) return false
|
||||
if (value.to && date > new Date(value.to)) return false
|
||||
return true
|
||||
},
|
||||
},
|
||||
],
|
||||
[performers, availableLabels],
|
||||
)
|
||||
|
||||
const {
|
||||
filteredData: finalChores,
|
||||
activeFilters,
|
||||
setFilter,
|
||||
clearAll,
|
||||
hasActiveFilters,
|
||||
} = useFilter(filteredChores, filterDefs)
|
||||
|
||||
useEffect(() => {
|
||||
const loadArchivedChores = async () => {
|
||||
if (!membersLoading && userProfile) {
|
||||
@@ -335,11 +430,8 @@ const ArchivedTasks = () => {
|
||||
}
|
||||
|
||||
const selectAllVisibleChores = () => {
|
||||
const visibleChores =
|
||||
searchTerm?.length > 0 ? filteredChores : archivedChores
|
||||
if (visibleChores.length > 0) {
|
||||
const allIds = new Set(visibleChores.map(chore => chore.id))
|
||||
setSelectedChores(allIds)
|
||||
if (finalChores.length > 0) {
|
||||
setSelectedChores(new Set(finalChores.map(c => c.id)))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -673,6 +765,15 @@ const ArchivedTasks = () => {
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<FilterBar
|
||||
filterDefs={filterDefs}
|
||||
activeFilters={activeFilters}
|
||||
onSetFilter={setFilter}
|
||||
onClearAll={clearAll}
|
||||
resultCount={finalChores.length}
|
||||
totalCount={filteredChores.length}
|
||||
/>
|
||||
|
||||
{/* Multi-select Toolbar */}
|
||||
{isMultiSelectMode && (
|
||||
<Box
|
||||
@@ -745,7 +846,7 @@ const ArchivedTasks = () => {
|
||||
variant='outlined'
|
||||
onClick={selectAllVisibleChores}
|
||||
startDecorator={<SelectAll />}
|
||||
disabled={selectedChores.size === filteredChores.length}
|
||||
disabled={selectedChores.size === finalChores.length}
|
||||
sx={{
|
||||
minWidth: 'auto',
|
||||
'--Button-paddingInline': '0.75rem',
|
||||
@@ -876,7 +977,7 @@ const ArchivedTasks = () => {
|
||||
)}
|
||||
|
||||
{/* Content */}
|
||||
{filteredChores.length === 0 ? (
|
||||
{finalChores.length === 0 ? (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
@@ -886,42 +987,43 @@ const ArchivedTasks = () => {
|
||||
height: '50vh',
|
||||
}}
|
||||
>
|
||||
<Archive
|
||||
sx={{
|
||||
fontSize: '4rem',
|
||||
mb: 1,
|
||||
color: 'text.tertiary',
|
||||
}}
|
||||
/>
|
||||
<Archive sx={{ fontSize: '4rem', mb: 1, color: 'text.tertiary' }} />
|
||||
<Typography level='title-md' gutterBottom>
|
||||
{searchTerm ? 'No archived tasks found' : 'No archived tasks'}
|
||||
{searchTerm || hasActiveFilters
|
||||
? 'No archived tasks found'
|
||||
: 'No archived tasks'}
|
||||
</Typography>
|
||||
<Typography level='body-sm' color='text.secondary' sx={{ mb: 2 }}>
|
||||
{searchTerm
|
||||
? 'Try adjusting your search terms'
|
||||
{searchTerm || hasActiveFilters
|
||||
? 'Try adjusting your search or filters'
|
||||
: 'Archived tasks will appear here when you archive them from the main task list'}
|
||||
</Typography>
|
||||
{searchTerm && (
|
||||
<Button
|
||||
onClick={handleSearchClose}
|
||||
variant='outlined'
|
||||
color='neutral'
|
||||
>
|
||||
Clear search
|
||||
</Button>
|
||||
{(searchTerm || hasActiveFilters) && (
|
||||
<Box sx={{ display: 'flex', gap: 1 }}>
|
||||
{searchTerm && (
|
||||
<Button onClick={handleSearchClose} variant='outlined' color='neutral'>
|
||||
Clear search
|
||||
</Button>
|
||||
)}
|
||||
{hasActiveFilters && (
|
||||
<Button onClick={clearAll} variant='outlined' color='neutral'>
|
||||
Clear filters
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
) : (
|
||||
<Box>
|
||||
<Typography level='body-sm' color='text.secondary' sx={{ mb: 2 }}>
|
||||
{filteredChores.length} archived task
|
||||
{filteredChores.length !== 1 ? 's' : ''}
|
||||
{finalChores.length} archived task
|
||||
{finalChores.length !== 1 ? 's' : ''}
|
||||
{searchTerm && ` matching "${searchTerm}"`}
|
||||
</Typography>
|
||||
|
||||
<List sx={{ gap: viewMode === 'compact' ? 0 : 1 }}>
|
||||
<ChoreListView
|
||||
chores={filteredChores}
|
||||
chores={finalChores}
|
||||
// viewOnly={true}
|
||||
showActions={false}
|
||||
viewMode={viewMode}
|
||||
|
||||
@@ -190,7 +190,11 @@ const scheduleChoreNotification = async (
|
||||
for (let i = 0; i < chores.length; i++) {
|
||||
const chore = chores[i]
|
||||
try {
|
||||
if (chore.notification === false || chore.nextDueDate === null) {
|
||||
if (
|
||||
chore.notification === false ||
|
||||
chore.nextDueDate === null ||
|
||||
chore.isActive === false
|
||||
) {
|
||||
continue
|
||||
}
|
||||
scheduleNotificationFromTemplate(
|
||||
|
||||
@@ -2,18 +2,12 @@ import {
|
||||
Add,
|
||||
Bolt,
|
||||
CalendarMonth,
|
||||
CancelRounded,
|
||||
CheckBox,
|
||||
CheckBoxOutlineBlank,
|
||||
EditCalendar,
|
||||
ExpandCircleDown,
|
||||
Grain,
|
||||
PriorityHigh,
|
||||
Sort,
|
||||
Style,
|
||||
ViewAgenda,
|
||||
ViewModule,
|
||||
} from '@mui/icons-material'
|
||||
import Logo from '../../Logo'
|
||||
import {
|
||||
Accordion,
|
||||
AccordionDetails,
|
||||
@@ -24,9 +18,6 @@ import {
|
||||
Container,
|
||||
Divider,
|
||||
IconButton,
|
||||
List,
|
||||
Menu,
|
||||
MenuItem,
|
||||
Typography,
|
||||
} from '@mui/joy'
|
||||
import Fuse from 'fuse.js'
|
||||
@@ -43,6 +34,7 @@ import IconButtonWithMenu from './IconButtonWithMenu'
|
||||
import { useMediaQuery } from '@mui/material'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import KeyboardShortcutHint from '../../components/common/KeyboardShortcutHint'
|
||||
import { useFilter } from '../../hooks/useFilter'
|
||||
import { useImpersonateUser } from '../../contexts/ImpersonateUserContext.jsx'
|
||||
import { useCircleMembers, useUserProfile } from '../../queries/UserQueries'
|
||||
import {
|
||||
@@ -55,15 +47,13 @@ import { getSafeBottom } from '../../utils/SafeAreaUtils.js'
|
||||
import TaskInput from '../components/AddTaskModal'
|
||||
import CalendarDual from '../components/CalendarDual'
|
||||
import CalendarMonthly from '../components/CalendarMonthly.jsx'
|
||||
import ProjectSelector from '../components/ProjectSelector'
|
||||
import AdvancedFilterBuilder from '../Modals/Inputs/AdvancedFilterBuilder'
|
||||
import { useProjects } from '../Projects/ProjectQueries.js'
|
||||
import ChoreListView from './ChoreListView.jsx'
|
||||
import ChoreToolbar from './components/ChoreToolbarPrototype'
|
||||
import ChoreModals from './components/ChoreModals'
|
||||
import FilterSection from './components/FilterSection'
|
||||
import MultiSelectToolbar from './components/MultiSelectToolbar'
|
||||
import MyChoreHeader from './components/MyChoreHeader'
|
||||
import SearchBar from './components/SearchBar'
|
||||
import { useChoreActions } from './hooks/useChoreActions'
|
||||
import { useChoreFilters } from './hooks/useChoreFilters'
|
||||
import { useChoreModals } from './hooks/useChoreModals'
|
||||
@@ -78,7 +68,6 @@ import {
|
||||
import NotificationAccessSnackbar from './NotificationAccessSnackbar'
|
||||
import Sidepanel from './Sidepanel'
|
||||
import { INSIGHT_FILTER_DEFS } from './SmartInsightsCard'
|
||||
import SortAndGrouping from './SortAndGrouping'
|
||||
|
||||
const MyChores = () => {
|
||||
const { data: userProfile, isLoading: isUserProfileLoading } =
|
||||
@@ -107,7 +96,6 @@ const MyChores = () => {
|
||||
const [chores, setChores] = useState([])
|
||||
const [filteredChores, setFilteredChores] = useState([])
|
||||
const [choreSections, setChoreSections] = useState([])
|
||||
const [showSearchFilter, setShowSearchFilter] = useState(false)
|
||||
const [addTaskModalOpen, setAddTaskModalOpen] = useState(false)
|
||||
const [taskInputFocus, setTaskInputFocus] = useState(0)
|
||||
const searchInputRef = useRef(null)
|
||||
@@ -135,15 +123,12 @@ const MyChores = () => {
|
||||
|
||||
const {
|
||||
searchTerm,
|
||||
searchFilter,
|
||||
selectedChoreFilter,
|
||||
projectFilteredChores,
|
||||
searchFilteredChores,
|
||||
nonProjectFilteredChores,
|
||||
setSearchTerm,
|
||||
setSearchFilter,
|
||||
setSelectedChoreFilterWithCache,
|
||||
clearFilters,
|
||||
} = useChoreFilters({
|
||||
chores,
|
||||
selectedProject,
|
||||
@@ -193,6 +178,102 @@ const MyChores = () => {
|
||||
useState(false)
|
||||
const [editingFilter, setEditingFilter] = useState(null)
|
||||
|
||||
const quickFilterDefs = useMemo(
|
||||
() => [
|
||||
{
|
||||
id: 'status',
|
||||
label: 'Due Date',
|
||||
type: 'single-select',
|
||||
icon: <CalendarMonth />,
|
||||
options: [
|
||||
{ value: 'Overdue', label: 'Overdue', color: 'danger' },
|
||||
{ value: 'Due today', label: 'Due Today', color: 'warning' },
|
||||
{ value: 'Due in week', label: 'Due This Week' },
|
||||
{ value: 'Due Later', label: 'Due Later' },
|
||||
{ value: 'No Due Date', label: 'No Due Date' },
|
||||
{ value: 'Pending Approval', label: 'Pending Approval' },
|
||||
],
|
||||
filterFn: (item, value) => {
|
||||
const now = new Date()
|
||||
const d = item.nextDueDate ? new Date(item.nextDueDate) : null
|
||||
switch (value) {
|
||||
case 'Overdue':
|
||||
return d !== null && d < now
|
||||
case 'Due today':
|
||||
return d !== null && d.toDateString() === now.toDateString()
|
||||
case 'Due in week':
|
||||
return (
|
||||
d !== null &&
|
||||
d < new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000) &&
|
||||
d > now
|
||||
)
|
||||
case 'Due Later':
|
||||
return (
|
||||
d !== null &&
|
||||
d > new Date(now.getTime() + 24 * 60 * 60 * 1000)
|
||||
)
|
||||
case 'No Due Date':
|
||||
return item.nextDueDate === null
|
||||
case 'Pending Approval':
|
||||
return item.status === 3
|
||||
default:
|
||||
return true
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'priority',
|
||||
label: 'Priority',
|
||||
type: 'multi-select',
|
||||
icon: <PriorityHigh />,
|
||||
options: Priorities.map(p => ({
|
||||
value: p.value,
|
||||
label: p.name,
|
||||
color: p.color || 'neutral',
|
||||
icon: p.icon,
|
||||
})),
|
||||
filterFn: (item, values) => values.includes(item.priority ?? 0),
|
||||
},
|
||||
...(userLabels?.length > 0
|
||||
? [
|
||||
{
|
||||
id: 'label',
|
||||
label: 'Labels',
|
||||
type: 'multi-select',
|
||||
icon: <Style />,
|
||||
options: userLabels.map(l => ({
|
||||
value: l.id,
|
||||
label: l.name,
|
||||
icon: (
|
||||
<Box
|
||||
component='span'
|
||||
sx={{
|
||||
display: 'inline-block',
|
||||
width: 10,
|
||||
height: 10,
|
||||
borderRadius: '50%',
|
||||
bgcolor: l.color || '#90a4ae',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
),
|
||||
})),
|
||||
filterFn: (item, values) =>
|
||||
item.labelsV2?.some(l => values.includes(l.id)) ?? false,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
],
|
||||
[userLabels],
|
||||
)
|
||||
|
||||
const {
|
||||
filteredData: quickFilteredChores,
|
||||
setFilter: setQuickFilter,
|
||||
clearAll: clearQuickFilters,
|
||||
hasActiveFilters: hasQuickFilters,
|
||||
} = useFilter(projectFilteredChores, quickFilterDefs)
|
||||
|
||||
const processedChores = useMemo(() => {
|
||||
if (!choresData?.res) {
|
||||
return []
|
||||
@@ -222,9 +303,9 @@ const MyChores = () => {
|
||||
if (tempFilter || activeFilterId) {
|
||||
// Advanced/custom filter active
|
||||
choresToGroup = customFilteredChores
|
||||
} else if (searchFilter !== 'All') {
|
||||
// Quick filter active (Overdue, Due today, Label, Priority, etc.)
|
||||
choresToGroup = filteredChores
|
||||
} else if (hasQuickFilters) {
|
||||
// Quick filter active (Due date, Priority, Labels)
|
||||
choresToGroup = quickFilteredChores
|
||||
} else if (!selectedProject || selectedProject.id === 'default') {
|
||||
// No project selected or default project: only show tasks without a projectId
|
||||
choresToGroup = chores.filter(chore => !chore.projectId)
|
||||
@@ -244,11 +325,11 @@ const MyChores = () => {
|
||||
return sections
|
||||
}, [
|
||||
chores,
|
||||
filteredChores,
|
||||
quickFilteredChores,
|
||||
customFilteredChores,
|
||||
tempFilter,
|
||||
activeFilterId,
|
||||
searchFilter,
|
||||
hasQuickFilters,
|
||||
selectedChoreSection,
|
||||
selectedChoreFilter,
|
||||
selectedProject,
|
||||
@@ -398,7 +479,7 @@ const MyChores = () => {
|
||||
}
|
||||
|
||||
// Handle legacy filter parameter (e.g., filter=unplanned)
|
||||
if (oldFilter && searchFilter === 'All' && !activeFilterId) {
|
||||
if (oldFilter && !hasQuickFilters && !activeFilterId) {
|
||||
const filterMap = {
|
||||
unplanned: 'No Due Date',
|
||||
overdue: 'Overdue',
|
||||
@@ -409,12 +490,8 @@ const MyChores = () => {
|
||||
}
|
||||
|
||||
const filterName = filterMap[oldFilter.toLowerCase()]
|
||||
if (filterName && FILTERS[filterName]) {
|
||||
const filtered = FILTERS[filterName](
|
||||
selectedProject ? projectFilteredChores : chores,
|
||||
)
|
||||
setFilteredChores(filtered)
|
||||
setSearchFilter(filterName)
|
||||
if (filterName) {
|
||||
setQuickFilter('status', filterName)
|
||||
setViewMode('default')
|
||||
setSelectedCalendarDate(null)
|
||||
}
|
||||
@@ -422,7 +499,7 @@ const MyChores = () => {
|
||||
}, [
|
||||
searchParams,
|
||||
chores,
|
||||
searchFilter,
|
||||
hasQuickFilters,
|
||||
activeFilterId,
|
||||
savedFilters,
|
||||
applyCustomFilter,
|
||||
@@ -430,8 +507,7 @@ const MyChores = () => {
|
||||
clearActiveFilter,
|
||||
selectedProject,
|
||||
projectFilteredChores,
|
||||
setSearchFilter,
|
||||
setFilteredChores,
|
||||
setQuickFilter,
|
||||
setViewMode,
|
||||
setSelectedCalendarDate,
|
||||
])
|
||||
@@ -496,13 +572,47 @@ const MyChores = () => {
|
||||
clearSelection,
|
||||
})
|
||||
|
||||
const getFilteredChores = useMemo(() => {
|
||||
if (activeFilterId || tempFilter) {
|
||||
return customFilteredChores
|
||||
}
|
||||
|
||||
const baseChores = hasQuickFilters
|
||||
? quickFilteredChores
|
||||
: projectFilteredChores
|
||||
|
||||
if (searchTerm?.length > 0) {
|
||||
const searchableChores = baseChores.map(c => ({
|
||||
...c,
|
||||
raw_label: c.labelsV2?.map(l => l.name).join(' '),
|
||||
}))
|
||||
const fuse = new Fuse(searchableChores, {
|
||||
keys: ['name', 'raw_label'],
|
||||
includeScore: true,
|
||||
isCaseSensitive: false,
|
||||
findAllMatches: true,
|
||||
})
|
||||
return fuse.search(searchTerm).map(result => result.item)
|
||||
}
|
||||
|
||||
return baseChores
|
||||
}, [
|
||||
activeFilterId,
|
||||
tempFilter,
|
||||
customFilteredChores,
|
||||
hasQuickFilters,
|
||||
quickFilteredChores,
|
||||
projectFilteredChores,
|
||||
searchTerm,
|
||||
])
|
||||
|
||||
const { showKeyboardShortcuts } = useKeyboardShortcuts({
|
||||
isMultiSelectMode,
|
||||
selectedChores,
|
||||
addTaskModalOpen,
|
||||
searchTerm,
|
||||
searchFilter,
|
||||
filteredChores,
|
||||
searchFilter: hasQuickFilters || searchTerm?.length > 0 ? 'filtered' : 'All',
|
||||
filteredChores: getFilteredChores,
|
||||
choreSections,
|
||||
openChoreSections,
|
||||
handlers: {
|
||||
@@ -553,25 +663,10 @@ const MyChores = () => {
|
||||
|
||||
const handleLabelFiltering = chipClicked => {
|
||||
clearActiveFilter()
|
||||
|
||||
const baseChores = selectedProject ? projectFilteredChores : chores
|
||||
|
||||
if (chipClicked.label) {
|
||||
const label = chipClicked.label
|
||||
const labelFiltered = baseChores.filter(chore =>
|
||||
chore.labelsV2.some(
|
||||
l => l.id === label.id && l.created_by === label.created_by,
|
||||
),
|
||||
)
|
||||
setFilteredChores(labelFiltered)
|
||||
setSearchFilter('Label: ' + label.name)
|
||||
setQuickFilter('label', [chipClicked.label.id])
|
||||
} else if (chipClicked.priority) {
|
||||
const priority = chipClicked.priority
|
||||
const priorityFiltered = baseChores.filter(
|
||||
chore => chore.priority === priority,
|
||||
)
|
||||
setFilteredChores(priorityFiltered)
|
||||
setSearchFilter('Priority: ' + priority)
|
||||
setQuickFilter('priority', [chipClicked.priority])
|
||||
}
|
||||
setSelectedCalendarDate(null)
|
||||
}
|
||||
@@ -623,8 +718,8 @@ const MyChores = () => {
|
||||
|
||||
const handleSearchChange = e => {
|
||||
clearActiveFilter()
|
||||
if (searchFilter !== 'All') {
|
||||
setSearchFilter('All')
|
||||
if (hasQuickFilters) {
|
||||
clearQuickFilters()
|
||||
}
|
||||
const search = e.target.value
|
||||
if (search === '') {
|
||||
@@ -672,14 +767,13 @@ const MyChores = () => {
|
||||
localStorage.setItem('openChoreSections', JSON.stringify(value))
|
||||
}
|
||||
|
||||
const toggleViewMode = () => {
|
||||
const modes = ['default', 'compact', 'calendar']
|
||||
const currentIndex = modes.indexOf(viewMode)
|
||||
const nextIndex = (currentIndex + 1) % modes.length
|
||||
const newMode = modes[nextIndex]
|
||||
const toggleViewMode = value => {
|
||||
const newMode = value ?? (() => {
|
||||
const modes = ['default', 'compact', 'calendar']
|
||||
return modes[(modes.indexOf(viewMode) + 1) % modes.length]
|
||||
})()
|
||||
setViewMode(newMode)
|
||||
localStorage.setItem('choreCardViewMode', newMode)
|
||||
|
||||
if (newMode !== 'calendar') {
|
||||
setSelectedCalendarDate(null)
|
||||
}
|
||||
@@ -740,42 +834,6 @@ const MyChores = () => {
|
||||
// )
|
||||
// }
|
||||
|
||||
const getFilteredChores = useMemo(() => {
|
||||
if (activeFilterId || tempFilter) {
|
||||
return customFilteredChores
|
||||
}
|
||||
|
||||
let baseChores = projectFilteredChores
|
||||
|
||||
if (searchTerm?.length > 0 || searchFilter !== 'All') {
|
||||
if (searchTerm?.length > 0) {
|
||||
const projectFilteredForSearch = baseChores.map(c => ({
|
||||
...c,
|
||||
raw_label: c.labelsV2?.map(l => l.name).join(' '),
|
||||
}))
|
||||
const fuse = new Fuse(projectFilteredForSearch, {
|
||||
keys: ['name', 'raw_label'],
|
||||
includeScore: true,
|
||||
isCaseSensitive: false,
|
||||
findAllMatches: true,
|
||||
})
|
||||
return fuse.search(searchTerm).map(result => result.item)
|
||||
} else if (searchFilter !== 'All') {
|
||||
return filteredChores
|
||||
}
|
||||
}
|
||||
|
||||
return baseChores
|
||||
}, [
|
||||
activeFilterId,
|
||||
tempFilter,
|
||||
customFilteredChores,
|
||||
projectFilteredChores,
|
||||
searchTerm,
|
||||
searchFilter,
|
||||
filteredChores,
|
||||
])
|
||||
|
||||
const getChoresForDate = useCallback(
|
||||
date => {
|
||||
const filteredChoresData = getFilteredChores
|
||||
@@ -800,7 +858,7 @@ const MyChores = () => {
|
||||
|
||||
setChores(newChores)
|
||||
setFilteredChores(newChores)
|
||||
setSearchFilter('All')
|
||||
clearQuickFilters()
|
||||
}
|
||||
|
||||
// Show error state when API is unreachable
|
||||
@@ -875,322 +933,96 @@ const MyChores = () => {
|
||||
tempFilter={tempFilter}
|
||||
tempFilterMeta={tempFilterMeta}
|
||||
/>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignContent: 'center',
|
||||
alignItems: 'center',
|
||||
gap: 0.5,
|
||||
<ChoreToolbar
|
||||
members={membersData?.res || []}
|
||||
labels={userLabels || []}
|
||||
projects={projectsWithDefault}
|
||||
tempFilter={tempFilter}
|
||||
tempFilterMeta={tempFilterMeta}
|
||||
applyTempFilter={applyTempFilter}
|
||||
clearTempFilter={clearTempFilter}
|
||||
saveFilter={saveFilter}
|
||||
updateFilter={updateFilter}
|
||||
onFilterSaved={name =>
|
||||
showSuccess({
|
||||
title: 'Filter Saved',
|
||||
message: `"${name}" has been saved`,
|
||||
})
|
||||
}
|
||||
onClearAllFilters={() => {
|
||||
clearQuickFilters()
|
||||
clearActiveFilter()
|
||||
setSelectedChoreFilterWithCache('anyone')
|
||||
setSelectedProjectWithCache(
|
||||
projectsWithDefault.find(p => p.id === 'default') || null,
|
||||
)
|
||||
updateFilterUrl(null, null)
|
||||
}}
|
||||
>
|
||||
<SearchBar
|
||||
value={searchTerm}
|
||||
onChange={handleSearchChange}
|
||||
onClose={handleSearchClose}
|
||||
onFocus={() => setShowSearchFilter(true)}
|
||||
showKeyboardShortcuts={showKeyboardShortcuts}
|
||||
inputRef={searchInputRef}
|
||||
/>
|
||||
|
||||
<SortAndGrouping
|
||||
title='Group by'
|
||||
k={'icon-menu-group-by'}
|
||||
icon={<Sort />}
|
||||
selectedItem={selectedChoreSection}
|
||||
selectedFilter={selectedChoreFilter}
|
||||
setFilter={filter => {
|
||||
setSelectedChoreFilterWithCache(filter)
|
||||
// Clear active custom filter when quick filter is applied
|
||||
if (activeFilterId) {
|
||||
clearActiveFilter()
|
||||
updateFilterUrl(null, null)
|
||||
}
|
||||
}}
|
||||
onItemSelect={selected => {
|
||||
setSelectedChoreSectionWithCache(selected.value)
|
||||
setFilteredChores(chores)
|
||||
setSearchFilter('All')
|
||||
}}
|
||||
onCreateNewFilter={() => {
|
||||
setShowAdvancedFilterBuilder(true)
|
||||
setEditingFilter(null)
|
||||
}}
|
||||
mouseClickHandler={handleMenuOutsideClick}
|
||||
/>
|
||||
|
||||
{/* Project Selector - Hidden when active filter has project conditions */}
|
||||
{projectsWithDefault.length > 1 &&
|
||||
!hasProjectConditions &&
|
||||
!hasFilterApplied && (
|
||||
<ProjectSelector
|
||||
selectedProject={selectedProject?.name || 'Default Project'}
|
||||
onProjectSelect={project => {
|
||||
setSelectedProjectWithCache(project)
|
||||
clearActiveFilter()
|
||||
}}
|
||||
showKeyboardShortcuts={showKeyboardShortcuts}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* View Mode Toggle Button */}
|
||||
<IconButton
|
||||
variant='outlined'
|
||||
color='neutral'
|
||||
size='sm'
|
||||
sx={{
|
||||
height: 32,
|
||||
width: 32,
|
||||
borderRadius: '50%',
|
||||
}}
|
||||
onClick={toggleViewMode}
|
||||
title={
|
||||
viewMode === 'default'
|
||||
? 'Switch to Compact View'
|
||||
: viewMode === 'compact'
|
||||
? 'Switch to Calendar View'
|
||||
: 'Switch to Card View'
|
||||
resultCount={
|
||||
hasQuickFilters || hasFilterApplied
|
||||
? getFilteredChores.length
|
||||
: undefined
|
||||
}
|
||||
totalCount={
|
||||
hasQuickFilters || hasFilterApplied
|
||||
? projectFilteredChores.length
|
||||
: undefined
|
||||
}
|
||||
selectedProject={selectedProject}
|
||||
onProjectSelect={project => {
|
||||
setSelectedProjectWithCache(project)
|
||||
clearActiveFilter()
|
||||
}}
|
||||
selectedAssigneeFilter={selectedChoreFilter}
|
||||
onAssigneeFilterChange={filter => {
|
||||
setSelectedChoreFilterWithCache(filter)
|
||||
if (activeFilterId) {
|
||||
clearActiveFilter()
|
||||
updateFilterUrl(null, null)
|
||||
}
|
||||
>
|
||||
{viewMode === 'default' ? (
|
||||
<ViewAgenda />
|
||||
) : viewMode === 'compact' ? (
|
||||
<CalendarMonth />
|
||||
) : (
|
||||
<ViewModule />
|
||||
)}
|
||||
</IconButton>
|
||||
|
||||
{/* Multi-select Toggle Button */}
|
||||
<Box sx={{ position: 'relative', display: 'inline-flex' }}>
|
||||
<IconButton
|
||||
variant={isMultiSelectMode ? 'solid' : 'outlined'}
|
||||
color={isMultiSelectMode ? 'primary' : 'neutral'}
|
||||
size='sm'
|
||||
sx={{
|
||||
height: 32,
|
||||
width: 32,
|
||||
borderRadius: '50%',
|
||||
}}
|
||||
onClick={toggleMultiSelectMode}
|
||||
title={
|
||||
isMultiSelectMode
|
||||
? 'Exit Multi-select Mode (Ctrl+S)'
|
||||
: 'Enable Multi-select Mode (Ctrl+S)'
|
||||
}
|
||||
>
|
||||
{isMultiSelectMode ? <CheckBox /> : <CheckBoxOutlineBlank />}
|
||||
</IconButton>
|
||||
<KeyboardShortcutHint
|
||||
shortcut='S'
|
||||
show={showKeyboardShortcuts}
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: -8,
|
||||
right: -8,
|
||||
zIndex: 1000,
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Search Filter with animation */}
|
||||
<Box
|
||||
sx={{
|
||||
overflow: 'hidden',
|
||||
transition: 'all 0.3s ease-in-out',
|
||||
maxHeight: showSearchFilter ? '150px' : '0',
|
||||
opacity: showSearchFilter ? 1 : 0,
|
||||
transform: showSearchFilter ? 'translateY(0)' : 'translateY(-10px)',
|
||||
marginBottom: showSearchFilter ? 1 : 0,
|
||||
}}
|
||||
>
|
||||
<div className='flex gap-4'>
|
||||
<div className='grid flex-1 grid-cols-3 gap-4'>
|
||||
<IconButtonWithMenu
|
||||
label={' Priority'}
|
||||
k={'icon-menu-priority-filter'}
|
||||
icon={<PriorityHigh />}
|
||||
options={Priorities}
|
||||
selectedItem={searchFilter}
|
||||
onItemSelect={selected => {
|
||||
handleLabelFiltering({ priority: selected.value })
|
||||
}}
|
||||
mouseClickHandler={handleMenuOutsideClick}
|
||||
isActive={searchFilter.startsWith('Priority: ')}
|
||||
/>
|
||||
|
||||
<IconButtonWithMenu
|
||||
k={'icon-menu-labels-filter'}
|
||||
label={' Labels'}
|
||||
icon={<Style />}
|
||||
options={userLabels}
|
||||
selectedItem={searchFilter}
|
||||
onItemSelect={selected => {
|
||||
handleLabelFiltering({ label: selected })
|
||||
}}
|
||||
isActive={searchFilter.startsWith('Label: ')}
|
||||
mouseClickHandler={handleMenuOutsideClick}
|
||||
useChips
|
||||
/>
|
||||
|
||||
<Button
|
||||
onClick={handleFilterMenuOpen}
|
||||
variant='outlined'
|
||||
startDecorator={<Grain />}
|
||||
color={
|
||||
searchFilter && FILTERS[searchFilter] && searchFilter != 'All'
|
||||
? 'primary'
|
||||
: 'neutral'
|
||||
}
|
||||
size='sm'
|
||||
sx={{
|
||||
height: 24,
|
||||
borderRadius: 24,
|
||||
}}
|
||||
>
|
||||
{' Other'}
|
||||
</Button>
|
||||
|
||||
<List
|
||||
orientation='horizontal'
|
||||
wrap
|
||||
sx={{
|
||||
mt: 0.2,
|
||||
}}
|
||||
>
|
||||
<Menu
|
||||
ref={menuRef}
|
||||
anchorEl={anchorEl}
|
||||
open={Boolean(anchorEl)}
|
||||
onClose={handleFilterMenuClose}
|
||||
>
|
||||
{Object.keys(FILTERS).map((filter, index) => (
|
||||
<MenuItem
|
||||
key={`filter-list-${filter}-${index}`}
|
||||
onClick={() => {
|
||||
const filterFunction = FILTERS[filter]
|
||||
const baseChores = selectedProject
|
||||
? projectFilteredChores
|
||||
: chores
|
||||
const filteredChores =
|
||||
filterFunction.length === 2
|
||||
? filterFunction(baseChores, userProfile?.id)
|
||||
: filterFunction(baseChores)
|
||||
setFilteredChores(filteredChores)
|
||||
setSearchFilter(filter)
|
||||
handleFilterMenuClose()
|
||||
|
||||
// Update URL with legacy filter parameter
|
||||
const filterMap = {
|
||||
'No Due Date': 'unplanned',
|
||||
Overdue: 'overdue',
|
||||
'Due today': 'today',
|
||||
'Due in week': 'week',
|
||||
'Due Later': 'later',
|
||||
'Pending Approval': 'pending',
|
||||
}
|
||||
const urlFilter = filterMap[filter]
|
||||
if (urlFilter) {
|
||||
updateFilterUrl('filter', urlFilter)
|
||||
}
|
||||
}}
|
||||
>
|
||||
{filter}
|
||||
<Chip
|
||||
color={searchFilter === filter ? 'primary' : 'neutral'}
|
||||
>
|
||||
{(() => {
|
||||
const baseChores = selectedProject
|
||||
? projectFilteredChores
|
||||
: chores
|
||||
return FILTERS[filter].length === 2
|
||||
? FILTERS[filter](baseChores, userProfile?.id)
|
||||
.length
|
||||
: FILTERS[filter](baseChores).length
|
||||
})()}
|
||||
</Chip>
|
||||
</MenuItem>
|
||||
))}
|
||||
|
||||
{searchFilter.startsWith('Label: ') ||
|
||||
(searchFilter.startsWith('Priority: ') && (
|
||||
<MenuItem
|
||||
key={`filter-list-cancel-all-filters`}
|
||||
onClick={() => {
|
||||
setFilteredChores(
|
||||
selectedProject ? projectFilteredChores : chores,
|
||||
)
|
||||
setSearchFilter('All')
|
||||
updateFilterUrl(null, null)
|
||||
}}
|
||||
>
|
||||
Cancel All Filters
|
||||
</MenuItem>
|
||||
))}
|
||||
</Menu>
|
||||
</List>
|
||||
</div>
|
||||
<IconButton
|
||||
variant='outlined'
|
||||
color='neutral'
|
||||
size='sm'
|
||||
sx={{
|
||||
height: 24,
|
||||
borderRadius: 24,
|
||||
}}
|
||||
onClick={() => {
|
||||
setShowSearchFilter(false)
|
||||
setSearchTerm('')
|
||||
setFilteredChores(chores)
|
||||
setSearchFilter('All')
|
||||
updateFilterUrl(null, null)
|
||||
}}
|
||||
>
|
||||
<CancelRounded />
|
||||
</IconButton>
|
||||
</div>
|
||||
</Box>
|
||||
|
||||
{/* Custom Filters Section */}
|
||||
<FilterSection
|
||||
savedFilters={savedFilters}
|
||||
activeFilterId={activeFilterId}
|
||||
activeFilter={activeFilter}
|
||||
hasProjectConditions={hasProjectConditions}
|
||||
onFilterClick={filterId => {
|
||||
onSavedFilterClick={filterId => {
|
||||
if (activeFilterId === filterId) {
|
||||
clearActiveFilter()
|
||||
updateFilterUrl(null, null)
|
||||
} else {
|
||||
setSearchFilter('All')
|
||||
clearQuickFilters()
|
||||
setSearchTerm('')
|
||||
setFilteredChores([])
|
||||
|
||||
// Reset quick filter to 'anyone' when custom filter is applied
|
||||
if (selectedChoreFilter !== 'anyone') {
|
||||
setSelectedChoreFilterWithCache('anyone')
|
||||
}
|
||||
|
||||
// Clear project selection if the filter has project conditions
|
||||
const filter = savedFilters.find(f => f.id === filterId)
|
||||
if (filter?.conditions?.some(c => c.type === 'project')) {
|
||||
setSelectedProjectWithCache(null)
|
||||
}
|
||||
|
||||
applyCustomFilter(filterId)
|
||||
updateFilterUrl('filterId', filterId)
|
||||
}
|
||||
}}
|
||||
onFilterDelete={deleteFilter}
|
||||
onFilterPin={pinFilter}
|
||||
onFilterEdit={filter => {
|
||||
onSavedFilterEdit={filter => {
|
||||
setEditingFilter(filter)
|
||||
setShowAdvancedFilterBuilder(true)
|
||||
}}
|
||||
onClearActiveFilter={clearActiveFilter}
|
||||
onCreateAdvancedFilter={() => setShowAdvancedFilterBuilder(true)}
|
||||
updateFilterUrl={updateFilterUrl}
|
||||
onSavedFilterDelete={deleteFilter}
|
||||
onSavedFilterPin={pinFilter}
|
||||
selectedGroupBy={selectedChoreSection}
|
||||
onGroupBySelect={value => {
|
||||
setSelectedChoreSectionWithCache(value)
|
||||
setFilteredChores(chores)
|
||||
clearQuickFilters()
|
||||
}}
|
||||
viewMode={viewMode}
|
||||
onToggleViewMode={toggleViewMode}
|
||||
isMultiSelectMode={isMultiSelectMode}
|
||||
onToggleMultiSelect={toggleMultiSelectMode}
|
||||
searchTerm={searchTerm}
|
||||
onSearchChange={handleSearchChange}
|
||||
onSearchClose={handleSearchClose}
|
||||
searchInputRef={searchInputRef}
|
||||
showKeyboardShortcuts={showKeyboardShortcuts}
|
||||
/>
|
||||
|
||||
<MultiSelectToolbar
|
||||
@@ -1204,41 +1036,15 @@ const MyChores = () => {
|
||||
onDelete={handleBulkDelete}
|
||||
showKeyboardShortcuts={showKeyboardShortcuts}
|
||||
selectAllDisabled={
|
||||
searchTerm?.length > 0 || searchFilter !== 'All'
|
||||
? selectedChores.size === filteredChores.length
|
||||
searchTerm?.length > 0 || hasQuickFilters
|
||||
? selectedChores.size === getFilteredChores.length
|
||||
: selectedChores.size ===
|
||||
choreSections.flatMap(s => s.content || []).length
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Additional Filters Display */}
|
||||
{searchFilter !== 'All' && (
|
||||
<Chip
|
||||
level='title-md'
|
||||
gutterBottom
|
||||
color='warning'
|
||||
label={searchFilter}
|
||||
onDelete={() => {
|
||||
setFilteredChores(
|
||||
selectedProject ? projectFilteredChores : chores,
|
||||
)
|
||||
setSearchFilter('All')
|
||||
updateFilterUrl(null, null)
|
||||
}}
|
||||
endDecorator={<CancelRounded />}
|
||||
onClick={() => {
|
||||
setFilteredChores(
|
||||
selectedProject ? projectFilteredChores : chores,
|
||||
)
|
||||
setSearchFilter('All')
|
||||
updateFilterUrl(null, null)
|
||||
}}
|
||||
>
|
||||
Additional Filter: {searchFilter}
|
||||
</Chip>
|
||||
)}
|
||||
{/* Show "Nothing scheduled" when appropriate based on current view mode */}
|
||||
{(searchTerm?.length > 0 || searchFilter !== 'All' || activeFilterId
|
||||
{(searchTerm?.length > 0 || hasQuickFilters || activeFilterId
|
||||
? getFilteredChores.length === 0
|
||||
: projectFilteredChores.length === 0) &&
|
||||
// only if not in calendar view:
|
||||
@@ -1266,10 +1072,9 @@ const MyChores = () => {
|
||||
<>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setSearchFilter('All')
|
||||
clearQuickFilters()
|
||||
setSearchTerm('')
|
||||
clearActiveFilter()
|
||||
// reset project and filters :
|
||||
setSelectedProjectWithCache(null)
|
||||
updateFilterUrl(null, null)
|
||||
}}
|
||||
@@ -1604,6 +1409,7 @@ const MyChores = () => {
|
||||
/>
|
||||
</IconButton>
|
||||
<IconButton
|
||||
data-testid='open-add-task-modal'
|
||||
color='primary'
|
||||
variant='soft'
|
||||
sx={{
|
||||
@@ -1719,59 +1525,4 @@ const MyChores = () => {
|
||||
)
|
||||
}
|
||||
|
||||
const FILTERS = {
|
||||
All: function (chores) {
|
||||
return chores
|
||||
},
|
||||
Overdue: function (chores) {
|
||||
return chores.filter(chore => {
|
||||
if (chore.nextDueDate === null) return false
|
||||
return new Date(chore.nextDueDate) < new Date()
|
||||
})
|
||||
},
|
||||
'Due today': function (chores) {
|
||||
return chores.filter(chore => {
|
||||
return (
|
||||
new Date(chore.nextDueDate).toDateString() === new Date().toDateString()
|
||||
)
|
||||
})
|
||||
},
|
||||
'Due in week': function (chores) {
|
||||
return chores.filter(chore => {
|
||||
return (
|
||||
new Date(chore.nextDueDate) <
|
||||
new Date(Date.now() + 7 * 24 * 60 * 60 * 1000) &&
|
||||
new Date(chore.nextDueDate) > new Date()
|
||||
)
|
||||
})
|
||||
},
|
||||
'Due Later': function (chores) {
|
||||
return chores.filter(chore => {
|
||||
return (
|
||||
new Date(chore.nextDueDate) > new Date(Date.now() + 24 * 60 * 60 * 1000)
|
||||
)
|
||||
})
|
||||
},
|
||||
'Created By Me': function (chores, userID) {
|
||||
return chores.filter(chore => {
|
||||
return chore.createdBy === userID
|
||||
})
|
||||
},
|
||||
'Assigned To Me': function (chores, userID) {
|
||||
return chores.filter(chore => {
|
||||
return chore.assignedTo === userID
|
||||
})
|
||||
},
|
||||
'No Due Date': function (chores) {
|
||||
return chores.filter(chore => {
|
||||
return chore.nextDueDate === null
|
||||
})
|
||||
},
|
||||
'Pending Approval': function (chores) {
|
||||
return chores.filter(chore => {
|
||||
return chore.status === 3
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
export default MyChores
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
import { Capacitor } from '@capacitor/core'
|
||||
import DateModal from '../../Modals/Inputs/DateModal'
|
||||
import NudgeModal from '../../Modals/Inputs/NudgeModal'
|
||||
import SelectModal from '../../Modals/Inputs/SelectModal'
|
||||
import TextModal from '../../Modals/Inputs/TextModal'
|
||||
import WriteNFCModal from '../../Modals/Inputs/WriteNFCModal'
|
||||
|
||||
const getNFCUrl = choreId =>
|
||||
Capacitor.getPlatform() === 'android'
|
||||
? `donetick://chores/${choreId}`
|
||||
: `${window.location.origin}/chores/${choreId}`
|
||||
|
||||
const ChoreModals = ({
|
||||
activeModal,
|
||||
modalChore,
|
||||
@@ -65,12 +71,13 @@ const ChoreModals = ({
|
||||
<WriteNFCModal
|
||||
config={{
|
||||
isOpen: true,
|
||||
url: `${window.location.origin}/chores/${modalChore.id}`,
|
||||
url: getNFCUrl(modalChore.id),
|
||||
onClose: onClose,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
{activeModal === 'nudge' && modalChore && (
|
||||
<NudgeModal
|
||||
config={{
|
||||
|
||||
920
src/views/Chores/components/ChoreToolbarPrototype.jsx
Normal file
920
src/views/Chores/components/ChoreToolbarPrototype.jsx
Normal file
@@ -0,0 +1,920 @@
|
||||
/**
|
||||
* PROTOTYPE – Unified Chore Toolbar
|
||||
*
|
||||
* Proposed design to replace the current 3-surface layout:
|
||||
* OLD: [Search] [Sort+Group+AssigneeFilter+CreateFilter] [ProjectSelector] [View] [Multiselect]
|
||||
* + FilterBar (Due Date / Priority / Labels chips row)
|
||||
* + FilterSection (saved/pinned filter chips row)
|
||||
*
|
||||
* NEW: [Search] [Filter(n)] [Group ▾] [View] [Multiselect]
|
||||
* + active filter chips appear inline next to Filter button
|
||||
* + Filter button opens ONE unified bottom sheet containing:
|
||||
* Assignee · Created By · Status · Priority · Due Date · Labels · Projects · Points
|
||||
* + Saved Filters section
|
||||
*
|
||||
* How to try it: in MyChores.jsx, replace the <Box sx={{display:'flex'...}}> toolbar block
|
||||
* and the two rows below it (FilterBar + FilterSection) with:
|
||||
* <ChoreToolbar ... />
|
||||
*/
|
||||
|
||||
import {
|
||||
ArrowDropDown,
|
||||
CalendarMonth,
|
||||
Check,
|
||||
CheckBox,
|
||||
CheckBoxOutlineBlank,
|
||||
FilterList,
|
||||
Save,
|
||||
Sort,
|
||||
Tune,
|
||||
ViewAgenda,
|
||||
ViewComfy,
|
||||
ViewModule,
|
||||
} from '@mui/icons-material'
|
||||
import {
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
ButtonGroup,
|
||||
Chip,
|
||||
Divider,
|
||||
IconButton,
|
||||
Input,
|
||||
Menu,
|
||||
MenuItem,
|
||||
Typography,
|
||||
} from '@mui/joy'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import BottomSheetModal from '../../../components/common/BottomSheetModal'
|
||||
import ActiveFilterChips from '../../../components/common/filter/ActiveFilterChips'
|
||||
import { Z_INDEX } from '../../../constants/zIndex'
|
||||
import KeyboardShortcutHint from '../../../components/common/KeyboardShortcutHint'
|
||||
import { FILTER_COLORS } from '../../../utils/Colors'
|
||||
import Priorities from '../../../utils/Priorities'
|
||||
import FilterBuilderContent, {
|
||||
CHORE_STATUSES,
|
||||
DUE_DATE_OPTIONS,
|
||||
POINTS_OPERATORS,
|
||||
conditionsToSelections,
|
||||
defaultSelections,
|
||||
selectionsToConditions,
|
||||
} from './FilterBuilderContent'
|
||||
import SearchBar from './SearchBar'
|
||||
import ProjectSelector from '../../components/ProjectSelector'
|
||||
|
||||
// ─── sub-components for the Display sheet ────────────────────────────────────
|
||||
|
||||
const SectionHeader = ({ icon, label, badge }) => (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}>
|
||||
{icon && (
|
||||
<Box
|
||||
sx={{
|
||||
color: 'text.secondary',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
'& svg': { fontSize: 18 },
|
||||
}}
|
||||
>
|
||||
{icon}
|
||||
</Box>
|
||||
)}
|
||||
<Typography level='title-sm' fontWeight={600}>
|
||||
{label}
|
||||
</Typography>
|
||||
{badge != null && (
|
||||
<Chip
|
||||
size='sm'
|
||||
variant='solid'
|
||||
color='primary'
|
||||
sx={{ ml: 'auto', fontSize: '0.7rem', height: 20 }}
|
||||
>
|
||||
{badge}
|
||||
</Chip>
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
|
||||
const OptionChips = ({ options, selected, multi, onToggle }) => (
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
|
||||
{options.map(opt => {
|
||||
const isSelected = multi
|
||||
? (selected || []).includes(opt.value)
|
||||
: selected === opt.value
|
||||
return (
|
||||
<Chip
|
||||
key={opt.value}
|
||||
variant={isSelected ? 'solid' : 'soft'}
|
||||
color={isSelected ? opt.color ?? 'primary' : 'neutral'}
|
||||
startDecorator={
|
||||
opt.icon != null
|
||||
? isSelected
|
||||
? <Check sx={{ fontSize: 14 }} />
|
||||
: opt.icon
|
||||
: undefined
|
||||
}
|
||||
onClick={() => onToggle(opt.value)}
|
||||
sx={{
|
||||
py: 0.64,
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.15s ease',
|
||||
userSelect: 'none',
|
||||
'&:hover': { opacity: 0.85 },
|
||||
}}
|
||||
>
|
||||
{opt.label}
|
||||
</Chip>
|
||||
)
|
||||
})}
|
||||
</Box>
|
||||
)
|
||||
|
||||
// ─── main component ───────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Props:
|
||||
* -- Advanced filter (filter sheet) --
|
||||
* members – circle members for Assignee / Created By sections
|
||||
* labels – user labels for Labels section
|
||||
* projects – projects list (projectsWithDefault) for Projects section + Display sheet
|
||||
* tempFilter – current temp filter object { conditions, operator } or null
|
||||
* tempFilterMeta – metadata for temp filter, including saved-filter edit source when applicable
|
||||
* applyTempFilter – (filter) => void — called immediately as selections change
|
||||
* clearTempFilter – () => void
|
||||
* saveFilter – (filterData) => Promise — saves as a named filter
|
||||
* updateFilter – (filterId, filterData) => Promise — updates an existing saved filter
|
||||
* onFilterSaved – (name) => void — called after successful save (for notifications)
|
||||
*
|
||||
* -- Result counts --
|
||||
* resultCount / totalCount
|
||||
*
|
||||
* -- Clear all --
|
||||
* onClearAllFilters – () => void
|
||||
*
|
||||
* -- Saved filters --
|
||||
* savedFilters – [{ id, name, color, count, isPinned }]
|
||||
* activeFilterId – number | null
|
||||
* onSavedFilterClick – (id) => void
|
||||
* onSavedFilterEdit – (filter) => void
|
||||
* onSavedFilterDelete– (id) => void
|
||||
* onSavedFilterPin – (id) => void
|
||||
*
|
||||
* -- Display sheet --
|
||||
* selectedProject – current project object (for Display sheet section)
|
||||
* onProjectSelect – (project) => void
|
||||
* selectedAssigneeFilter – 'anyone' | 'assigned_to_me' | 'available_for_me' | 'assigned_to_others'
|
||||
* onAssigneeFilterChange – (key) => void
|
||||
* selectedGroupBy – 'default' | 'due_date' | 'priority' | 'labels'
|
||||
* onGroupBySelect – (value) => void
|
||||
* viewMode – 'default' | 'compact' | 'calendar'
|
||||
* onToggleViewMode – (value?) => void
|
||||
*
|
||||
* -- Multi-select --
|
||||
* isMultiSelectMode – bool
|
||||
* onToggleMultiSelect – () => void
|
||||
*
|
||||
* -- Search --
|
||||
* searchTerm / onSearchChange / onSearchClose / searchInputRef
|
||||
* showKeyboardShortcuts
|
||||
*/
|
||||
const ChoreToolbar = ({
|
||||
// advanced filter
|
||||
members = [],
|
||||
labels = [],
|
||||
projects = [],
|
||||
tempFilter,
|
||||
tempFilterMeta,
|
||||
applyTempFilter,
|
||||
clearTempFilter,
|
||||
saveFilter,
|
||||
updateFilter,
|
||||
onFilterSaved,
|
||||
// result counts
|
||||
resultCount,
|
||||
totalCount,
|
||||
// clear all
|
||||
onClearAllFilters,
|
||||
// project (for Display sheet)
|
||||
selectedProject,
|
||||
onProjectSelect,
|
||||
// assignee (for Display sheet)
|
||||
selectedAssigneeFilter = 'anyone',
|
||||
onAssigneeFilterChange,
|
||||
// saved / custom
|
||||
savedFilters = [],
|
||||
activeFilterId,
|
||||
onSavedFilterClick,
|
||||
onSavedFilterEdit,
|
||||
onSavedFilterDelete,
|
||||
onSavedFilterPin,
|
||||
// grouping
|
||||
selectedGroupBy = 'default',
|
||||
onGroupBySelect,
|
||||
// view + multiselect
|
||||
viewMode = 'default',
|
||||
onToggleViewMode,
|
||||
isMultiSelectMode,
|
||||
onToggleMultiSelect,
|
||||
// search
|
||||
searchTerm,
|
||||
onSearchChange,
|
||||
onSearchClose,
|
||||
searchInputRef,
|
||||
showKeyboardShortcuts,
|
||||
}) => {
|
||||
const [filterSheetOpen, setFilterSheetOpen] = useState(false)
|
||||
const [displaySheetOpen, setDisplaySheetOpen] = useState(false)
|
||||
const [localSelections, setLocalSelections] = useState(defaultSelections())
|
||||
const [savingFilter, setSavingFilter] = useState(false)
|
||||
const [saveFilterName, setSaveFilterName] = useState('')
|
||||
const [saveMenuAnchorEl, setSaveMenuAnchorEl] = useState(null)
|
||||
const [editingSavedFilter, setEditingSavedFilter] = useState(null)
|
||||
const saveMenuRef = useRef(null)
|
||||
const activeConditions = selectionsToConditions(localSelections)
|
||||
|
||||
// ── badge counts ─────────────────────────────────────────────────────────────
|
||||
|
||||
const tempConditionCount = tempFilter?.conditions?.length || 0
|
||||
const savedFilterActive = activeFilterId != null ? 1 : 0
|
||||
const totalActiveCount = tempConditionCount + savedFilterActive
|
||||
const hasAnyActive = totalActiveCount > 0
|
||||
|
||||
// ── inline chip strip ────────────────────────────────────────────────────────
|
||||
|
||||
const inlineChips = []
|
||||
|
||||
const getConditionChipLabel = condition => {
|
||||
if (!condition?.type) return 'Filter'
|
||||
|
||||
const typeLabels = {
|
||||
assignee: 'Assignee',
|
||||
createdBy: 'Created By',
|
||||
status: 'Status',
|
||||
priority: 'Priority',
|
||||
label: 'Labels',
|
||||
project: 'Project',
|
||||
dueDate: 'Due Date',
|
||||
points: 'Points',
|
||||
}
|
||||
|
||||
const typeLabel = typeLabels[condition.type] || 'Filter'
|
||||
const prefix = condition.operator === 'isNot' ? 'Not ' : ''
|
||||
|
||||
if (condition.type === 'dueDate') {
|
||||
const dueDateLabel =
|
||||
DUE_DATE_OPTIONS.find(o => o.value === condition.operator)?.label ||
|
||||
'Custom'
|
||||
return `${typeLabel}: ${dueDateLabel}`
|
||||
}
|
||||
|
||||
if (condition.type === 'points') {
|
||||
const pointsOp =
|
||||
POINTS_OPERATORS.find(o => o.value === condition.operator)?.label ||
|
||||
condition.operator ||
|
||||
''
|
||||
return `${typeLabel} ${pointsOp} ${condition.value ?? 0}`
|
||||
}
|
||||
|
||||
const rawValues = Array.isArray(condition.value)
|
||||
? condition.value
|
||||
: condition.value != null
|
||||
? [condition.value]
|
||||
: []
|
||||
|
||||
const resolveLabel = value => {
|
||||
if (condition.type === 'assignee' || condition.type === 'createdBy') {
|
||||
const member = members.find(m => m.userId === value)
|
||||
return member?.displayName || member?.username || String(value)
|
||||
}
|
||||
if (condition.type === 'status') {
|
||||
return CHORE_STATUSES.find(s => s.value === value)?.label || String(value)
|
||||
}
|
||||
if (condition.type === 'priority') {
|
||||
return Priorities.find(p => p.value === value)?.name || String(value)
|
||||
}
|
||||
if (condition.type === 'label') {
|
||||
return labels.find(l => l.id === value)?.name || String(value)
|
||||
}
|
||||
if (condition.type === 'project') {
|
||||
if (value === 'default') return 'Default Project'
|
||||
return projects.find(p => p.id === value)?.name || String(value)
|
||||
}
|
||||
return String(value)
|
||||
}
|
||||
|
||||
if (rawValues.length === 0) {
|
||||
return `${prefix}${typeLabel}`
|
||||
}
|
||||
|
||||
if (rawValues.length === 1) {
|
||||
return `${prefix}${typeLabel}: ${resolveLabel(rawValues[0])}`
|
||||
}
|
||||
|
||||
return `${prefix}${typeLabel} (${rawValues.length})`
|
||||
}
|
||||
|
||||
const clearConditionAtIndex = index => {
|
||||
const nextConditions = (tempFilter?.conditions || []).filter(
|
||||
(_condition, conditionIndex) => conditionIndex !== index,
|
||||
)
|
||||
|
||||
if (nextConditions.length === 0) {
|
||||
setLocalSelections(defaultSelections())
|
||||
clearTempFilter?.()
|
||||
return
|
||||
}
|
||||
|
||||
const nextFilter = {
|
||||
...tempFilter,
|
||||
operator: tempFilter?.operator || 'AND',
|
||||
conditions: nextConditions,
|
||||
}
|
||||
|
||||
setLocalSelections(conditionsToSelections(nextConditions))
|
||||
applyTempFilter?.(nextFilter)
|
||||
}
|
||||
|
||||
const activeSavedFilter = savedFilterActive
|
||||
? savedFilters.find(f => f.id === activeFilterId)
|
||||
: null
|
||||
|
||||
const activeChipConditions = savedFilterActive
|
||||
? activeSavedFilter?.conditions || []
|
||||
: tempFilter?.conditions || []
|
||||
|
||||
activeChipConditions.forEach((condition, index) => {
|
||||
inlineChips.push({
|
||||
key: `${savedFilterActive ? '__saved' : '__temp'}_${index}`,
|
||||
label: getConditionChipLabel(condition),
|
||||
onClear: () => {
|
||||
if (savedFilterActive) {
|
||||
onSavedFilterClick?.(activeFilterId)
|
||||
return
|
||||
}
|
||||
clearConditionAtIndex(index)
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
// ── open filter sheet ────────────────────────────────────────────────────────
|
||||
|
||||
const openFilterSheet = () => {
|
||||
if (tempFilter?.conditions?.length > 0) {
|
||||
setLocalSelections(conditionsToSelections(tempFilter.conditions))
|
||||
if (tempFilterMeta?.sourceFilterId) {
|
||||
const sourceFilter =
|
||||
savedFilters.find(f => f.id === tempFilterMeta.sourceFilterId) ||
|
||||
null
|
||||
setEditingSavedFilter(
|
||||
sourceFilter ||
|
||||
(tempFilterMeta.sourceFilterId
|
||||
? {
|
||||
id: tempFilterMeta.sourceFilterId,
|
||||
name: tempFilterMeta.sourceFilterName,
|
||||
description: tempFilterMeta.sourceFilterDescription,
|
||||
color: tempFilterMeta.sourceFilterColor,
|
||||
}
|
||||
: null),
|
||||
)
|
||||
} else {
|
||||
setEditingSavedFilter(null)
|
||||
}
|
||||
} else if (activeFilterId) {
|
||||
const sf = savedFilters.find(f => f.id === activeFilterId)
|
||||
setEditingSavedFilter(sf || null)
|
||||
setLocalSelections(
|
||||
sf?.conditions
|
||||
? conditionsToSelections(sf.conditions)
|
||||
: defaultSelections(),
|
||||
)
|
||||
} else {
|
||||
setEditingSavedFilter(null)
|
||||
setLocalSelections(defaultSelections())
|
||||
}
|
||||
setSavingFilter(false)
|
||||
setSaveFilterName('')
|
||||
setSaveMenuAnchorEl(null)
|
||||
setFilterSheetOpen(true)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!filterSheetOpen || savingFilter || activeConditions.length === 0) {
|
||||
setSaveMenuAnchorEl(null)
|
||||
}
|
||||
}, [filterSheetOpen, savingFilter, activeConditions.length])
|
||||
|
||||
// ── selection changes → apply temp filter immediately ────────────────────────
|
||||
|
||||
const handleSelectionsChange = updater => {
|
||||
setLocalSelections(prev => {
|
||||
const next = typeof updater === 'function' ? updater(prev) : updater
|
||||
const conditions = selectionsToConditions(next)
|
||||
if (conditions.length > 0) {
|
||||
applyTempFilter?.(
|
||||
{ conditions, operator: 'AND' },
|
||||
editingSavedFilter
|
||||
? {
|
||||
name: editingSavedFilter.name,
|
||||
description: editingSavedFilter.description,
|
||||
sourceFilterId: editingSavedFilter.id,
|
||||
sourceFilterName: editingSavedFilter.name,
|
||||
sourceFilterDescription: editingSavedFilter.description,
|
||||
sourceFilterColor: editingSavedFilter.color,
|
||||
isEditingSavedFilter: true,
|
||||
}
|
||||
: null,
|
||||
)
|
||||
} else {
|
||||
clearTempFilter?.()
|
||||
}
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
// ── save filter ───────────────────────────────────────────────────────────────
|
||||
|
||||
const handleSaveFilter = () => {
|
||||
const name = saveFilterName.trim()
|
||||
if (!name) return
|
||||
const conditions = selectionsToConditions(localSelections)
|
||||
if (conditions.length === 0) return
|
||||
|
||||
const usedColors = savedFilters.map(f => f.color)
|
||||
const color =
|
||||
FILTER_COLORS.find(c => !usedColors.includes(c.value))?.value ??
|
||||
FILTER_COLORS[0].value
|
||||
|
||||
saveFilter?.({ name, description: '', color, conditions, operator: 'AND' })?.then?.(() => {
|
||||
applyTempFilter?.({ conditions, operator: 'AND' }, { name })
|
||||
onFilterSaved?.(name)
|
||||
})
|
||||
|
||||
setSavingFilter(false)
|
||||
setSaveFilterName('')
|
||||
setFilterSheetOpen(false)
|
||||
}
|
||||
|
||||
const handleUpdateFilter = () => {
|
||||
if (!editingSavedFilter?.id || !updateFilter) return
|
||||
|
||||
const conditions = selectionsToConditions(localSelections)
|
||||
if (conditions.length === 0) return
|
||||
|
||||
updateFilter(
|
||||
editingSavedFilter.id,
|
||||
{
|
||||
name: editingSavedFilter.name,
|
||||
description: editingSavedFilter.description || '',
|
||||
color: editingSavedFilter.color,
|
||||
conditions,
|
||||
operator: 'AND',
|
||||
},
|
||||
)?.then?.(() => {
|
||||
clearTempFilter?.()
|
||||
onSavedFilterClick?.(editingSavedFilter.id)
|
||||
onFilterSaved?.(editingSavedFilter.name)
|
||||
})
|
||||
|
||||
setSaveMenuAnchorEl(null)
|
||||
setFilterSheetOpen(false)
|
||||
}
|
||||
|
||||
// ── display sheet helpers ────────────────────────────────────────────────────
|
||||
|
||||
const filterActive = activeFilterId != null || tempConditionCount > 0
|
||||
const projectActive = selectedProject && selectedProject.id !== 'default' ? 1 : 0
|
||||
const assigneeActive = selectedAssigneeFilter !== 'anyone' ? 1 : 0
|
||||
const displayActive =
|
||||
selectedGroupBy !== 'default' ||
|
||||
viewMode !== 'default' ||
|
||||
projectActive > 0 ||
|
||||
assigneeActive > 0
|
||||
|
||||
const groupByOptions = [
|
||||
{ value: 'default', label: 'Smart' },
|
||||
{ value: 'due_date', label: 'Due Date' },
|
||||
{ value: 'priority', label: 'Priority' },
|
||||
{ value: 'labels', label: 'Labels' },
|
||||
]
|
||||
|
||||
const assigneeOptions = [
|
||||
{ value: 'anyone', label: 'Everyone' },
|
||||
{ value: 'assigned_to_me', label: 'Mine' },
|
||||
{ value: 'available_for_me', label: 'Available to me' },
|
||||
{ value: 'assigned_to_others', label: 'Others' },
|
||||
]
|
||||
|
||||
const viewOptions = [
|
||||
{ value: 'default', label: 'Cards', icon: <ViewAgenda sx={{ fontSize: 16 }} /> },
|
||||
{ value: 'compact', label: 'Compact', icon: <ViewComfy sx={{ fontSize: 16 }} /> },
|
||||
{ value: 'calendar', label: 'Calendar', icon: <CalendarMonth sx={{ fontSize: 16 }} /> },
|
||||
]
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* ── Row 1: main toolbar ─────────────────────────────────────────────── */}
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 0.5,
|
||||
justifyContent: 'space-between',
|
||||
}}
|
||||
>
|
||||
{/* Search takes available space */}
|
||||
<SearchBar
|
||||
value={searchTerm}
|
||||
onChange={onSearchChange}
|
||||
onClose={onSearchClose}
|
||||
showKeyboardShortcuts={showKeyboardShortcuts}
|
||||
inputRef={searchInputRef}
|
||||
/>
|
||||
|
||||
{/* Filter button */}
|
||||
<Badge
|
||||
badgeContent={totalActiveCount || null}
|
||||
color='primary'
|
||||
size='sm'
|
||||
anchorOrigin={{ vertical: 'top', horizontal: 'right' }}
|
||||
>
|
||||
<IconButton
|
||||
variant={hasAnyActive ? 'solid' : 'outlined'}
|
||||
color={hasAnyActive ? 'primary' : 'neutral'}
|
||||
size='sm'
|
||||
sx={{ height: 32, width: 32, borderRadius: '50%' }}
|
||||
onClick={openFilterSheet}
|
||||
title='Filters'
|
||||
>
|
||||
<FilterList />
|
||||
</IconButton>
|
||||
</Badge>
|
||||
|
||||
{/* Project selector */}
|
||||
{!filterActive && projects.filter(p => p.id !== 'default').length > 0 && (
|
||||
<ProjectSelector
|
||||
selectedProject={selectedProject?.name || 'Default Project'}
|
||||
onProjectSelect={onProjectSelect}
|
||||
showKeyboardShortcuts={showKeyboardShortcuts}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Display button — View + Group combined */}
|
||||
<IconButton
|
||||
variant={displayActive ? 'solid' : 'outlined'}
|
||||
color={displayActive ? 'primary' : 'neutral'}
|
||||
size='sm'
|
||||
sx={{ height: 32, width: 32, borderRadius: '50%' }}
|
||||
onClick={() => setDisplaySheetOpen(true)}
|
||||
title='View & Group'
|
||||
>
|
||||
{viewMode === 'calendar' ? (
|
||||
<CalendarMonth />
|
||||
) : viewMode === 'compact' ? (
|
||||
<ViewModule />
|
||||
) : (
|
||||
<ViewAgenda />
|
||||
)}
|
||||
</IconButton>
|
||||
|
||||
{/* Multiselect */}
|
||||
<Box sx={{ position: 'relative', display: 'inline-flex' }}>
|
||||
<IconButton
|
||||
variant={isMultiSelectMode ? 'solid' : 'outlined'}
|
||||
color={isMultiSelectMode ? 'primary' : 'neutral'}
|
||||
size='sm'
|
||||
sx={{ height: 32, width: 32, borderRadius: '50%' }}
|
||||
onClick={onToggleMultiSelect}
|
||||
title={
|
||||
isMultiSelectMode
|
||||
? 'Exit multi-select (Ctrl+S)'
|
||||
: 'Multi-select (Ctrl+S)'
|
||||
}
|
||||
>
|
||||
{isMultiSelectMode ? <CheckBox /> : <CheckBoxOutlineBlank />}
|
||||
</IconButton>
|
||||
<KeyboardShortcutHint
|
||||
shortcut='S'
|
||||
show={showKeyboardShortcuts}
|
||||
sx={{ position: 'absolute', top: -8, right: -8, zIndex: 1000 }}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* ── Row 2: active filter chips ──────────────────────────────────────── */}
|
||||
{hasAnyActive && (
|
||||
<ActiveFilterChips
|
||||
chips={inlineChips}
|
||||
onOpen={openFilterSheet}
|
||||
onClearAll={() => {
|
||||
setLocalSelections(defaultSelections())
|
||||
onClearAllFilters?.()
|
||||
}}
|
||||
resultCount={resultCount}
|
||||
totalCount={totalCount}
|
||||
maxVisible={2}
|
||||
chipSize='md'
|
||||
clearButtonSize='sm'
|
||||
clearButtonSx={{ color: 'text.secondary' }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* ── Unified Filter bottom sheet ─────────────────────────────────────── */}
|
||||
<BottomSheetModal
|
||||
open={filterSheetOpen}
|
||||
onClose={() => {
|
||||
setSaveMenuAnchorEl(null)
|
||||
setFilterSheetOpen(false)
|
||||
}}
|
||||
maxHeight='92vh'
|
||||
title={
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Tune sx={{ fontSize: 20 }} />
|
||||
Filters
|
||||
{hasAnyActive && (
|
||||
<Chip size='sm' variant='solid' color='primary' sx={{ ml: 0.5 }}>
|
||||
{totalActiveCount}
|
||||
</Chip>
|
||||
)}
|
||||
</Box>
|
||||
}
|
||||
footer={
|
||||
savingFilter ? (
|
||||
<Box
|
||||
sx={{ display: 'flex', gap: 1, width: '100%', alignItems: 'center' }}
|
||||
>
|
||||
<Input
|
||||
size='sm'
|
||||
placeholder='Filter name…'
|
||||
value={saveFilterName}
|
||||
onChange={e => setSaveFilterName(e.target.value)}
|
||||
onKeyDown={e => e.key === 'Enter' && handleSaveFilter()}
|
||||
autoFocus
|
||||
sx={{ flex: 1 }}
|
||||
/>
|
||||
<Button
|
||||
size='sm'
|
||||
onClick={handleSaveFilter}
|
||||
disabled={!saveFilterName.trim()}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
<Button
|
||||
size='sm'
|
||||
variant='plain'
|
||||
color='neutral'
|
||||
onClick={() => setSavingFilter(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</Box>
|
||||
) : (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
gap: 1,
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant='plain'
|
||||
color='danger'
|
||||
size='sm'
|
||||
disabled={!hasAnyActive && activeConditions.length === 0}
|
||||
onClick={() => {
|
||||
setLocalSelections(defaultSelections())
|
||||
setSaveMenuAnchorEl(null)
|
||||
onClearAllFilters?.()
|
||||
setFilterSheetOpen(false)
|
||||
}}
|
||||
>
|
||||
Clear all
|
||||
</Button>
|
||||
|
||||
{activeConditions.length > 0 ? (
|
||||
<>
|
||||
<ButtonGroup variant='solid' color='primary'>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setSaveMenuAnchorEl(null)
|
||||
setFilterSheetOpen(false)
|
||||
}}
|
||||
sx={{ minWidth: 140 }}
|
||||
>
|
||||
{resultCount != null
|
||||
? `Show ${resultCount}`
|
||||
: 'Done'}
|
||||
</Button>
|
||||
<IconButton
|
||||
ref={saveMenuRef}
|
||||
onClick={e => setSaveMenuAnchorEl(e.currentTarget)}
|
||||
>
|
||||
<ArrowDropDown />
|
||||
</IconButton>
|
||||
</ButtonGroup>
|
||||
|
||||
<Menu
|
||||
anchorEl={saveMenuAnchorEl}
|
||||
open={Boolean(saveMenuAnchorEl)}
|
||||
onClose={() => setSaveMenuAnchorEl(null)}
|
||||
placement='top-end'
|
||||
sx={{ zIndex: Z_INDEX.MODAL_CONTENT + 10 }}
|
||||
>
|
||||
<MenuItem
|
||||
onClick={handleUpdateFilter}
|
||||
disabled={!editingSavedFilter}
|
||||
>
|
||||
<Save sx={{ fontSize: 16, mr: 1 }} />
|
||||
Save Filter
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
onClick={() => {
|
||||
setSaveMenuAnchorEl(null)
|
||||
setSaveFilterName(
|
||||
editingSavedFilter
|
||||
? `${editingSavedFilter.name} Copy`
|
||||
: '',
|
||||
)
|
||||
setSavingFilter(true)
|
||||
}}
|
||||
>
|
||||
<Save sx={{ fontSize: 16, mr: 1 }} />
|
||||
Save as New Filter
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
</>
|
||||
) : (
|
||||
<Button
|
||||
variant='solid'
|
||||
color='primary'
|
||||
onClick={() => {
|
||||
setSaveMenuAnchorEl(null)
|
||||
setFilterSheetOpen(false)
|
||||
}}
|
||||
sx={{ minWidth: 140 }}
|
||||
>
|
||||
Done
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0 }}>
|
||||
{/* Full advanced filter content */}
|
||||
<FilterBuilderContent
|
||||
selections={localSelections}
|
||||
onSelectionsChange={handleSelectionsChange}
|
||||
members={members}
|
||||
labels={labels}
|
||||
projects={projects}
|
||||
/>
|
||||
|
||||
{/* Saved filters section */}
|
||||
{savedFilters.length > 0 && (
|
||||
<>
|
||||
<Divider sx={{ my: 2.5 }} />
|
||||
<Typography level='title-sm' fontWeight={600} sx={{ mb: 1.5 }}>
|
||||
Saved Filters
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
|
||||
{savedFilters.map(filter => {
|
||||
const isActive = activeFilterId === filter.id
|
||||
return (
|
||||
<Chip
|
||||
key={filter.id}
|
||||
variant={isActive ? 'solid' : 'soft'}
|
||||
color='neutral'
|
||||
startDecorator={
|
||||
isActive ? (
|
||||
<Check sx={{ fontSize: 14 }} />
|
||||
) : (
|
||||
<Chip size='sm' variant='plain' color='neutral'>
|
||||
{filter.count ?? 0}
|
||||
</Chip>
|
||||
)
|
||||
}
|
||||
onClick={() => {
|
||||
onSavedFilterClick?.(filter.id)
|
||||
if (!isActive) setFilterSheetOpen(false)
|
||||
}}
|
||||
sx={{
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.15s ease',
|
||||
userSelect: 'none',
|
||||
'&:hover': { opacity: 0.85 },
|
||||
...(filter.color && !isActive
|
||||
? { borderColor: filter.color }
|
||||
: {}),
|
||||
}}
|
||||
>
|
||||
{filter.name}
|
||||
</Chip>
|
||||
)
|
||||
})}
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
</BottomSheetModal>
|
||||
|
||||
{/* ── Display bottom sheet (View + Group + Assignee + Project) ──────────── */}
|
||||
<BottomSheetModal
|
||||
open={displaySheetOpen}
|
||||
onClose={() => setDisplaySheetOpen(false)}
|
||||
title={
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<ViewAgenda sx={{ fontSize: 20 }} />
|
||||
Display
|
||||
</Box>
|
||||
}
|
||||
footer={
|
||||
<Button onClick={() => setDisplaySheetOpen(false)} sx={{ minWidth: 140 }}>
|
||||
Done
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0 }}>
|
||||
{/* View section */}
|
||||
<SectionHeader label='View' />
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
|
||||
{viewOptions.map(opt => (
|
||||
<Chip
|
||||
key={opt.value}
|
||||
variant={viewMode === opt.value ? 'solid' : 'soft'}
|
||||
color={viewMode === opt.value ? 'primary' : 'neutral'}
|
||||
startDecorator={
|
||||
viewMode === opt.value
|
||||
? <Check sx={{ fontSize: 14 }} />
|
||||
: opt.icon
|
||||
}
|
||||
onClick={() => onToggleViewMode?.(opt.value)}
|
||||
sx={{
|
||||
py: 0.64,
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.15s ease',
|
||||
userSelect: 'none',
|
||||
'&:hover': { opacity: 0.85 },
|
||||
}}
|
||||
>
|
||||
{opt.label}
|
||||
</Chip>
|
||||
))}
|
||||
</Box>
|
||||
|
||||
<Divider sx={{ my: 2.5 }} />
|
||||
|
||||
{/* Group by section */}
|
||||
<SectionHeader
|
||||
icon={<Sort />}
|
||||
label='Group by'
|
||||
badge={
|
||||
selectedGroupBy !== 'default'
|
||||
? groupByOptions.find(o => o.value === selectedGroupBy)?.label
|
||||
: null
|
||||
}
|
||||
/>
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
|
||||
{groupByOptions.map(opt => (
|
||||
<Chip
|
||||
key={opt.value}
|
||||
variant={selectedGroupBy === opt.value ? 'solid' : 'soft'}
|
||||
color={selectedGroupBy === opt.value ? 'primary' : 'neutral'}
|
||||
onClick={() => onGroupBySelect?.(opt.value)}
|
||||
sx={{
|
||||
py: 0.64,
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.15s ease',
|
||||
userSelect: 'none',
|
||||
'&:hover': { opacity: 0.85 },
|
||||
}}
|
||||
>
|
||||
{opt.label}
|
||||
</Chip>
|
||||
))}
|
||||
</Box>
|
||||
|
||||
{/* Show tasks for section */}
|
||||
<Divider sx={{ my: 2.5 }} />
|
||||
<SectionHeader
|
||||
icon={<FilterList />}
|
||||
label='Show tasks for'
|
||||
badge={
|
||||
selectedAssigneeFilter !== 'anyone'
|
||||
? assigneeOptions.find(o => o.value === selectedAssigneeFilter)?.label
|
||||
: null
|
||||
}
|
||||
/>
|
||||
<OptionChips
|
||||
options={assigneeOptions}
|
||||
selected={selectedAssigneeFilter}
|
||||
multi={false}
|
||||
onToggle={v => onAssigneeFilterChange?.(v)}
|
||||
/>
|
||||
|
||||
</Box>
|
||||
</BottomSheetModal>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default ChoreToolbar
|
||||
@@ -9,11 +9,11 @@ import {
|
||||
import {
|
||||
Box,
|
||||
Chip,
|
||||
IconButton,
|
||||
Menu,
|
||||
MenuItem,
|
||||
Tooltip,
|
||||
Typography,
|
||||
IconButton,
|
||||
} from '@mui/joy'
|
||||
import { useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
@@ -116,6 +116,8 @@ const CustomFilterChips = ({
|
||||
px: 1.0,
|
||||
py: 0.5,
|
||||
height: 32,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
|
||||
opacity: hasWarning ? 0.7 : isActive ? 1 : 0.85,
|
||||
...(hasCustomColor && {
|
||||
@@ -185,6 +187,10 @@ const CustomFilterChips = ({
|
||||
maxWidth: 100,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
height: '100%',
|
||||
lineHeight: 1,
|
||||
...(hasCustomColor && {
|
||||
color: textColor,
|
||||
}),
|
||||
|
||||
477
src/views/Chores/components/FilterBuilderContent.jsx
Normal file
477
src/views/Chores/components/FilterBuilderContent.jsx
Normal file
@@ -0,0 +1,477 @@
|
||||
import {
|
||||
CalendarMonth,
|
||||
Check,
|
||||
FolderOpen,
|
||||
Label,
|
||||
Person,
|
||||
PriorityHigh,
|
||||
Stars,
|
||||
TaskAlt,
|
||||
} from '@mui/icons-material'
|
||||
import { Avatar, Box, Chip, Divider, Input, Typography } from '@mui/joy'
|
||||
import Priorities from '../../../utils/Priorities'
|
||||
|
||||
export const DUE_DATE_OPTIONS = [
|
||||
{ value: 'isOverdue', label: 'Overdue', color: 'danger' },
|
||||
{ value: 'isDueToday', label: 'Today', color: 'warning' },
|
||||
{ value: 'isDueTomorrow', label: 'Tomorrow', color: 'primary' },
|
||||
{ value: 'isDueThisWeek', label: 'This Week', color: 'primary' },
|
||||
{ value: 'isDueThisMonth', label: 'This Month', color: 'neutral' },
|
||||
{ value: 'hasNoDueDate', label: 'No Due Date', color: 'neutral' },
|
||||
{ value: 'hasDueDate', label: 'Has Due Date', color: 'neutral' },
|
||||
]
|
||||
|
||||
export const POINTS_OPERATORS = [
|
||||
{ value: 'greaterThan', label: '>' },
|
||||
{ value: 'greaterThanOrEqual', label: '>=' },
|
||||
{ value: 'equals', label: '=' },
|
||||
{ value: 'lessThanOrEqual', label: '<=' },
|
||||
{ value: 'lessThan', label: '<' },
|
||||
]
|
||||
|
||||
export const CHORE_STATUSES = [
|
||||
{ value: 0, label: 'Active' },
|
||||
{ value: 1, label: 'Started' },
|
||||
{ value: 2, label: 'In Progress' },
|
||||
{ value: 3, label: 'Pending Approval' },
|
||||
]
|
||||
|
||||
export const defaultSelections = () => ({
|
||||
assignee: { operator: 'is', values: [] },
|
||||
createdBy: { operator: 'is', values: [] },
|
||||
status: { operator: 'is', values: [] },
|
||||
priority: { operator: 'is', values: [] },
|
||||
label: { operator: 'is', values: [] },
|
||||
project: { operator: 'is', values: [] },
|
||||
dueDate: { operator: null },
|
||||
points: { operator: 'greaterThan', value: 0, active: false },
|
||||
})
|
||||
|
||||
export const conditionsToSelections = conditions => {
|
||||
const sel = defaultSelections()
|
||||
if (!conditions) return sel
|
||||
conditions.forEach(c => {
|
||||
if (c.type === 'dueDate') {
|
||||
sel.dueDate = { operator: c.operator }
|
||||
} else if (c.type === 'points') {
|
||||
sel.points = { operator: c.operator, value: c.value ?? 0, active: true }
|
||||
} else if (c.type in sel) {
|
||||
sel[c.type] = {
|
||||
operator: c.operator ?? 'is',
|
||||
values: Array.isArray(c.value)
|
||||
? c.value
|
||||
: c.value != null
|
||||
? [c.value]
|
||||
: [],
|
||||
}
|
||||
}
|
||||
})
|
||||
return sel
|
||||
}
|
||||
|
||||
export const selectionsToConditions = selections => {
|
||||
const conditions = []
|
||||
;['assignee', 'createdBy', 'status', 'priority', 'label', 'project'].forEach(
|
||||
type => {
|
||||
if (selections[type].values?.length > 0) {
|
||||
conditions.push({
|
||||
type,
|
||||
operator: selections[type].operator,
|
||||
value: selections[type].values,
|
||||
})
|
||||
}
|
||||
},
|
||||
)
|
||||
if (selections.dueDate.operator) {
|
||||
conditions.push({
|
||||
type: 'dueDate',
|
||||
operator: selections.dueDate.operator,
|
||||
value: null,
|
||||
})
|
||||
}
|
||||
if (selections.points.active) {
|
||||
conditions.push({
|
||||
type: 'points',
|
||||
operator: selections.points.operator,
|
||||
value: selections.points.value,
|
||||
})
|
||||
}
|
||||
return conditions
|
||||
}
|
||||
|
||||
const SectionHeader = ({ icon, label, children }) => (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}>
|
||||
<Box
|
||||
sx={{
|
||||
color: 'text.secondary',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
'& svg': { fontSize: 18 },
|
||||
}}
|
||||
>
|
||||
{icon}
|
||||
</Box>
|
||||
<Typography level='title-sm' sx={{ fontWeight: 600 }}>
|
||||
{label}
|
||||
</Typography>
|
||||
{children}
|
||||
</Box>
|
||||
)
|
||||
|
||||
const IncludeExcludeToggle = ({
|
||||
value,
|
||||
onChange,
|
||||
labels = ['Include', 'Exclude'],
|
||||
}) => (
|
||||
<Box sx={{ display: 'flex', gap: 0.5, ml: 'auto' }}>
|
||||
{[
|
||||
{ op: 'is', label: labels[0] },
|
||||
{ op: 'isNot', label: labels[1] },
|
||||
].map(o => (
|
||||
<Chip
|
||||
key={o.op}
|
||||
size='sm'
|
||||
variant={value === o.op ? 'solid' : 'soft'}
|
||||
color={
|
||||
value === o.op ? (o.op === 'isNot' ? 'danger' : 'primary') : 'neutral'
|
||||
}
|
||||
onClick={() => onChange(o.op)}
|
||||
sx={{ cursor: 'pointer', userSelect: 'none', transition: 'all 0.15s ease' }}
|
||||
>
|
||||
{o.label}
|
||||
</Chip>
|
||||
))}
|
||||
</Box>
|
||||
)
|
||||
|
||||
/**
|
||||
* Reusable filter conditions UI used by both the filter sheet in ChoreToolbar
|
||||
* and the AdvancedFilterBuilder save modal.
|
||||
*
|
||||
* `onSelectionsChange` must accept either a new selections object or a
|
||||
* functional updater `prev => next` (same contract as React's setState setter).
|
||||
*/
|
||||
const FilterBuilderContent = ({
|
||||
selections,
|
||||
onSelectionsChange,
|
||||
members = [],
|
||||
labels = [],
|
||||
projects = [],
|
||||
}) => {
|
||||
const toggleValue = (type, value) =>
|
||||
onSelectionsChange(prev => {
|
||||
const cur = prev[type].values || []
|
||||
const next = cur.includes(value)
|
||||
? cur.filter(v => v !== value)
|
||||
: [...cur, value]
|
||||
return { ...prev, [type]: { ...prev[type], values: next } }
|
||||
})
|
||||
|
||||
const setOperator = (type, op) =>
|
||||
onSelectionsChange(prev => ({
|
||||
...prev,
|
||||
[type]: { ...prev[type], operator: op },
|
||||
}))
|
||||
|
||||
const toggleDueDate = op =>
|
||||
onSelectionsChange(prev => ({
|
||||
...prev,
|
||||
dueDate: { operator: prev.dueDate.operator === op ? null : op },
|
||||
}))
|
||||
|
||||
const setPointsOperator = op =>
|
||||
onSelectionsChange(prev => ({
|
||||
...prev,
|
||||
points: { ...prev.points, operator: op, active: true },
|
||||
}))
|
||||
|
||||
const setPointsValue = val =>
|
||||
onSelectionsChange(prev => ({
|
||||
...prev,
|
||||
points: { ...prev.points, value: val, active: val > 0 },
|
||||
}))
|
||||
|
||||
const chipRow = (type, options, getChipProps) => {
|
||||
const selected = selections[type].values || []
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
|
||||
{options.map(opt => {
|
||||
const isSelected = selected.includes(opt.value)
|
||||
const extra = getChipProps ? getChipProps(opt, isSelected) : {}
|
||||
return (
|
||||
<Chip
|
||||
key={opt.value}
|
||||
variant={isSelected ? 'solid' : 'soft'}
|
||||
color={isSelected ? (extra.color ?? 'primary') : 'neutral'}
|
||||
startDecorator={
|
||||
isSelected
|
||||
? <Check sx={{ fontSize: 14 }} />
|
||||
: (extra.startDecorator ?? null)
|
||||
}
|
||||
onClick={() => toggleValue(type, opt.value)}
|
||||
sx={{
|
||||
cursor: 'pointer',
|
||||
userSelect: 'none',
|
||||
transition: 'all 0.15s ease',
|
||||
}}
|
||||
>
|
||||
{opt.label}
|
||||
</Chip>
|
||||
)
|
||||
})}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
const personChipRow = type => {
|
||||
const selected = selections[type].values || []
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
|
||||
{members.map(m => {
|
||||
const isSelected = selected.includes(m.userId)
|
||||
return (
|
||||
<Chip
|
||||
key={m.userId}
|
||||
variant={isSelected ? 'solid' : 'soft'}
|
||||
color={isSelected ? 'primary' : 'neutral'}
|
||||
startDecorator={
|
||||
isSelected ? (
|
||||
<Check sx={{ fontSize: 14 }} />
|
||||
) : (
|
||||
<Avatar
|
||||
src={m.image}
|
||||
alt={m.displayName}
|
||||
sx={{ '--Avatar-size': '20px' }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
onClick={() => toggleValue(type, m.userId)}
|
||||
sx={{
|
||||
cursor: 'pointer',
|
||||
userSelect: 'none',
|
||||
transition: 'all 0.15s ease',
|
||||
}}
|
||||
>
|
||||
{m.displayName || m.username}
|
||||
</Chip>
|
||||
)
|
||||
})}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0 }}>
|
||||
{/* Assignee */}
|
||||
{members.length > 0 && (
|
||||
<>
|
||||
<SectionHeader icon={<Person />} label='Assignee'>
|
||||
<IncludeExcludeToggle
|
||||
value={selections.assignee.operator}
|
||||
onChange={op => setOperator('assignee', op)}
|
||||
/>
|
||||
</SectionHeader>
|
||||
{personChipRow('assignee')}
|
||||
<Divider sx={{ my: 2.5 }} />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Created By */}
|
||||
{members.length > 0 && (
|
||||
<>
|
||||
<SectionHeader icon={<Person />} label='Created By'>
|
||||
<IncludeExcludeToggle
|
||||
value={selections.createdBy.operator}
|
||||
onChange={op => setOperator('createdBy', op)}
|
||||
/>
|
||||
</SectionHeader>
|
||||
{personChipRow('createdBy')}
|
||||
<Divider sx={{ my: 2.5 }} />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Status */}
|
||||
<SectionHeader icon={<TaskAlt />} label='Status'>
|
||||
<IncludeExcludeToggle
|
||||
value={selections.status.operator}
|
||||
onChange={op => setOperator('status', op)}
|
||||
/>
|
||||
</SectionHeader>
|
||||
{chipRow('status', CHORE_STATUSES)}
|
||||
<Divider sx={{ my: 2.5 }} />
|
||||
|
||||
{/* Priority */}
|
||||
<SectionHeader icon={<PriorityHigh />} label='Priority'>
|
||||
<IncludeExcludeToggle
|
||||
value={selections.priority.operator}
|
||||
onChange={op => setOperator('priority', op)}
|
||||
/>
|
||||
</SectionHeader>
|
||||
{chipRow(
|
||||
'priority',
|
||||
Priorities.map(p => ({ value: p.value, label: p.name })),
|
||||
(opt, isSelected) => ({
|
||||
color: isSelected
|
||||
? (Priorities.find(p => p.value === opt.value)?.color || 'primary')
|
||||
: 'neutral',
|
||||
startDecorator: !isSelected
|
||||
? Priorities.find(p => p.value === opt.value)?.icon
|
||||
: null,
|
||||
}),
|
||||
)}
|
||||
<Divider sx={{ my: 2.5 }} />
|
||||
|
||||
{/* Due Date */}
|
||||
<SectionHeader icon={<CalendarMonth />} label='Due Date' />
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
|
||||
{DUE_DATE_OPTIONS.map(opt => {
|
||||
const isSelected = selections.dueDate.operator === opt.value
|
||||
return (
|
||||
<Chip
|
||||
key={opt.value}
|
||||
variant={isSelected ? 'solid' : 'soft'}
|
||||
color={isSelected ? (opt.color ?? 'primary') : 'neutral'}
|
||||
startDecorator={isSelected ? <Check sx={{ fontSize: 14 }} /> : null}
|
||||
onClick={() => toggleDueDate(opt.value)}
|
||||
sx={{
|
||||
cursor: 'pointer',
|
||||
userSelect: 'none',
|
||||
transition: 'all 0.15s ease',
|
||||
}}
|
||||
>
|
||||
{opt.label}
|
||||
</Chip>
|
||||
)
|
||||
})}
|
||||
</Box>
|
||||
<Divider sx={{ my: 2.5 }} />
|
||||
|
||||
{/* Labels */}
|
||||
{labels.length > 0 && (
|
||||
<>
|
||||
<SectionHeader icon={<Label />} label='Labels'>
|
||||
<IncludeExcludeToggle
|
||||
value={selections.label.operator}
|
||||
onChange={op => setOperator('label', op)}
|
||||
labels={['Has', "Doesn't Have"]}
|
||||
/>
|
||||
</SectionHeader>
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
|
||||
{labels.map(lbl => {
|
||||
const isSelected = selections.label.values.includes(lbl.id)
|
||||
return (
|
||||
<Chip
|
||||
key={lbl.id}
|
||||
variant={isSelected ? 'solid' : 'soft'}
|
||||
color='neutral'
|
||||
startDecorator={
|
||||
<Box
|
||||
sx={{
|
||||
width: 10,
|
||||
height: 10,
|
||||
borderRadius: '50%',
|
||||
bgcolor: lbl.color || '#90a4ae',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
}
|
||||
endDecorator={isSelected ? <Check sx={{ fontSize: 12 }} /> : null}
|
||||
onClick={() => toggleValue('label', lbl.id)}
|
||||
sx={{
|
||||
cursor: 'pointer',
|
||||
userSelect: 'none',
|
||||
transition: 'all 0.15s ease',
|
||||
...(isSelected && {
|
||||
outline: '2px solid',
|
||||
outlineColor: 'primary.400',
|
||||
}),
|
||||
}}
|
||||
>
|
||||
{lbl.name}
|
||||
</Chip>
|
||||
)
|
||||
})}
|
||||
</Box>
|
||||
<Divider sx={{ my: 2.5 }} />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Projects */}
|
||||
{projects.length > 0 && (
|
||||
<>
|
||||
<SectionHeader icon={<FolderOpen />} label='Projects'>
|
||||
<IncludeExcludeToggle
|
||||
value={selections.project.operator}
|
||||
onChange={op => setOperator('project', op)}
|
||||
/>
|
||||
</SectionHeader>
|
||||
{chipRow('project', [
|
||||
{ value: 'default', label: 'Default Project' },
|
||||
...projects
|
||||
.filter(p => p.id !== 'default')
|
||||
.map(p => ({ value: p.id, label: p.name })),
|
||||
])}
|
||||
<Divider sx={{ my: 2.5 }} />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Points */}
|
||||
<SectionHeader icon={<Stars />} label='Points' />
|
||||
<Box
|
||||
sx={{ display: 'flex', gap: 1, alignItems: 'center', flexWrap: 'wrap' }}
|
||||
>
|
||||
{POINTS_OPERATORS.map(op => (
|
||||
<Chip
|
||||
key={op.value}
|
||||
size='sm'
|
||||
variant={
|
||||
selections.points.operator === op.value && selections.points.active
|
||||
? 'solid'
|
||||
: 'soft'
|
||||
}
|
||||
color={
|
||||
selections.points.operator === op.value && selections.points.active
|
||||
? 'primary'
|
||||
: 'neutral'
|
||||
}
|
||||
onClick={() => setPointsOperator(op.value)}
|
||||
sx={{
|
||||
cursor: 'pointer',
|
||||
userSelect: 'none',
|
||||
fontFamily: 'monospace',
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
{op.label}
|
||||
</Chip>
|
||||
))}
|
||||
<Input
|
||||
type='number'
|
||||
size='sm'
|
||||
value={selections.points.value}
|
||||
onChange={e => setPointsValue(parseInt(e.target.value) || 0)}
|
||||
sx={{ width: 80 }}
|
||||
slotProps={{ input: { min: 0 } }}
|
||||
/>
|
||||
{selections.points.active && (
|
||||
<Chip
|
||||
size='sm'
|
||||
variant='soft'
|
||||
color='danger'
|
||||
onClick={() =>
|
||||
onSelectionsChange(prev => ({
|
||||
...prev,
|
||||
points: { ...prev.points, active: false, value: 0 },
|
||||
}))
|
||||
}
|
||||
sx={{ cursor: 'pointer' }}
|
||||
>
|
||||
Clear
|
||||
</Chip>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
export default FilterBuilderContent
|
||||
@@ -9,12 +9,10 @@ const MyChoreHeader = ({
|
||||
tempFilter,
|
||||
tempFilterMeta,
|
||||
}) => {
|
||||
if (
|
||||
!activeFilterId &&
|
||||
!tempFilter &&
|
||||
(!selectedProject || selectedProject.id === 'default')
|
||||
)
|
||||
return null
|
||||
const isVisible =
|
||||
!!activeFilterId ||
|
||||
!!tempFilter ||
|
||||
(!!selectedProject && selectedProject.id !== 'default')
|
||||
|
||||
const renderIcon = () => {
|
||||
if (tempFilter) {
|
||||
@@ -53,18 +51,41 @@ const MyChoreHeader = ({
|
||||
: activeFilter?.description || selectedProject?.description
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 2 }}>
|
||||
{renderIcon()}
|
||||
<Stack sx={{ flex: 1 }}>
|
||||
<Typography level='h3' sx={{ fontWeight: 'lg', color: 'text.primary' }}>
|
||||
{name}
|
||||
</Typography>
|
||||
{description && (
|
||||
<Typography level='body-sm' sx={{ color: 'text.secondary' }}>
|
||||
{description}
|
||||
<Box
|
||||
sx={{
|
||||
overflow: 'hidden',
|
||||
maxHeight: isVisible ? '120px' : '0',
|
||||
opacity: isVisible ? 1 : 0,
|
||||
transform: isVisible ? 'translateY(0)' : 'translateY(-8px)',
|
||||
transition:
|
||||
'max-height 0.3s ease-in-out, opacity 0.3s ease-in-out, transform 0.3s ease-in-out',
|
||||
marginBottom: isVisible ? 2 : 0,
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||
{renderIcon()}
|
||||
<Stack sx={{ flex: 1 }}>
|
||||
<Typography
|
||||
level='h3'
|
||||
sx={{ fontWeight: 'lg', color: 'text.primary' }}
|
||||
>
|
||||
{name}
|
||||
</Typography>
|
||||
)}
|
||||
</Stack>
|
||||
<Box
|
||||
sx={{
|
||||
overflow: 'hidden',
|
||||
maxHeight: description ? '40px' : '0',
|
||||
opacity: description ? 1 : 0,
|
||||
transition:
|
||||
'max-height 0.3s ease-in-out, opacity 0.3s ease-in-out',
|
||||
}}
|
||||
>
|
||||
<Typography level='body-sm' sx={{ color: 'text.secondary' }}>
|
||||
{description}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
MarkChoreComplete,
|
||||
NudgeChore,
|
||||
RejectChore,
|
||||
SaveChore,
|
||||
SkipChore,
|
||||
UndoChoreAction,
|
||||
UpdateChoreAssignee,
|
||||
@@ -420,8 +421,8 @@ export const useChoreActions = ({
|
||||
c => c.id !== chore.id,
|
||||
)
|
||||
setChores(newChores)
|
||||
updateChoreInState(chore.id, 'deleted')
|
||||
setFilteredChores(newFilteredChores)
|
||||
queryClient.invalidateQueries(['chores'])
|
||||
showSuccess({
|
||||
title: 'Task Deleted',
|
||||
message: 'The task has been deleted successfully.',
|
||||
@@ -471,7 +472,7 @@ export const useChoreActions = ({
|
||||
await new Promise((resolve, reject) => {
|
||||
archiveChore.mutate(chore.id, {
|
||||
onSuccess: data => {
|
||||
updateChoreInState(data, 'archive')
|
||||
updateChoreInState(chore, 'archive')
|
||||
resolve(data)
|
||||
},
|
||||
onError: async error => {
|
||||
@@ -664,6 +665,28 @@ export const useChoreActions = ({
|
||||
}
|
||||
break
|
||||
|
||||
case 'moveToProject': {
|
||||
const project = extraData?.project
|
||||
const projectId = project?.id === null ? null : project?.id
|
||||
const updatedChore = { ...chore, projectId }
|
||||
try {
|
||||
const response = await SaveChore(updatedChore)
|
||||
if (response.ok) {
|
||||
updateChoreInState(updatedChore, 'moved-to-project')
|
||||
showSuccess({
|
||||
title: 'Task Moved',
|
||||
message: `Task moved to ${project?.name || 'Default Project'}.`,
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
showError({
|
||||
title: 'Failed to move task',
|
||||
message: error?.message || 'Unable to move task to project',
|
||||
})
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
case 'completeWithNote':
|
||||
case 'completeWithPastDate':
|
||||
case 'changeAssignee':
|
||||
|
||||
@@ -90,6 +90,8 @@ export const useCustomFilters = (chores, membersData, labels, projects) => {
|
||||
}, [chores, activeFilter, tempFilter, context])
|
||||
|
||||
const applyCustomFilter = useCallback(filterId => {
|
||||
setTempFilter(null)
|
||||
setTempFilterMeta(null)
|
||||
setActiveFilterId(filterId)
|
||||
}, [])
|
||||
|
||||
|
||||
@@ -1,52 +1,226 @@
|
||||
import { HomeRounded, Login } from '@mui/icons-material'
|
||||
import { Box, Button, CircularProgress, Container, Typography } from '@mui/joy'
|
||||
import { Link } from 'react-router-dom'
|
||||
import Logo from '../Logo' // Adjust the import path as necessary
|
||||
import {
|
||||
BugReportRounded,
|
||||
CloudOffRounded,
|
||||
ContentCopyRounded,
|
||||
ErrorRounded,
|
||||
ExpandMoreRounded,
|
||||
HomeRounded,
|
||||
LockRounded,
|
||||
RefreshRounded,
|
||||
SearchOffRounded,
|
||||
} from '@mui/icons-material'
|
||||
import { Box, Button, IconButton, Snackbar, Typography } from '@mui/joy'
|
||||
import { useState } from 'react'
|
||||
import { Link, useRouteError } from 'react-router-dom'
|
||||
|
||||
const getErrorKind = error => {
|
||||
if (!error)
|
||||
return { label: 'Unknown Error', color: 'danger', Icon: ErrorRounded }
|
||||
const status = error?.status ?? error?.response?.status
|
||||
if (status === 404)
|
||||
return {
|
||||
label: '404 · Not Found',
|
||||
color: 'warning',
|
||||
Icon: SearchOffRounded,
|
||||
}
|
||||
if (status === 401 || status === 403)
|
||||
return {
|
||||
label: `${status} · Unauthorized`,
|
||||
color: 'warning',
|
||||
Icon: LockRounded,
|
||||
}
|
||||
if (status >= 500)
|
||||
return {
|
||||
label: `${status} · Server Error`,
|
||||
color: 'danger',
|
||||
Icon: CloudOffRounded,
|
||||
}
|
||||
if (error?.name === 'TypeError')
|
||||
return { label: 'Runtime Error', color: 'danger', Icon: BugReportRounded }
|
||||
if (error?.name === 'SyntaxError')
|
||||
return { label: 'Syntax Error', color: 'danger', Icon: BugReportRounded }
|
||||
return { label: 'Unexpected Error', color: 'danger', Icon: ErrorRounded }
|
||||
}
|
||||
|
||||
const safeMessage = error => {
|
||||
const msg = error?.message ?? error?.statusText
|
||||
if (!msg || msg === '[object Object]') return null
|
||||
return msg
|
||||
}
|
||||
|
||||
const buildErrorText = (error, url) => {
|
||||
const lines = [
|
||||
`URL: ${url}`,
|
||||
`Time: ${new Date().toISOString()}`,
|
||||
`Error: ${safeMessage(error) ?? String(error)}`,
|
||||
]
|
||||
if (error?.stack) lines.push(`\nStack:\n${error.stack}`)
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
const Error = () => {
|
||||
const error = useRouteError()
|
||||
const [showDetails, setShowDetails] = useState(false)
|
||||
const [copied, setCopied] = useState(false)
|
||||
|
||||
const { color, Icon } = getErrorKind(error)
|
||||
const message = safeMessage(error)
|
||||
const url = window.location.href
|
||||
|
||||
const handleCopy = () => {
|
||||
navigator.clipboard.writeText(buildErrorText(error, url)).then(() => {
|
||||
setCopied(true)
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<Container className='flex h-full items-center justify-center'>
|
||||
<Box
|
||||
sx={{
|
||||
position: 'relative',
|
||||
minHeight: '100dvh',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
px: 3,
|
||||
py: 6,
|
||||
maxWidth: 440,
|
||||
mx: 'auto',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{/* Decorative dots */}
|
||||
<Box
|
||||
className='flex flex-col items-center justify-center'
|
||||
sx={{
|
||||
minHeight: '80vh',
|
||||
position: 'absolute',
|
||||
top: '14%',
|
||||
left: '6%',
|
||||
width: 14,
|
||||
height: 14,
|
||||
borderRadius: '50%',
|
||||
bgcolor: `${color}.100`,
|
||||
opacity: 0.7,
|
||||
}}
|
||||
/>
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: '22%',
|
||||
right: '8%',
|
||||
width: 9,
|
||||
height: 9,
|
||||
borderRadius: '50%',
|
||||
bgcolor: `${color}.200`,
|
||||
}}
|
||||
/>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
bottom: '28%',
|
||||
right: '6%',
|
||||
width: 7,
|
||||
height: 7,
|
||||
borderRadius: '50%',
|
||||
bgcolor: `${color}.100`,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Icon with concentric rings */}
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
mb: 4,
|
||||
}}
|
||||
>
|
||||
<CircularProgress
|
||||
value={100}
|
||||
color='danger' // Set the color to 'error' for danger color
|
||||
sx={{ '--CircularProgress-size': '200px' }}
|
||||
>
|
||||
<Logo />
|
||||
</CircularProgress>
|
||||
<Box
|
||||
className='flex items-center gap-2'
|
||||
sx={{
|
||||
fontWeight: 700,
|
||||
fontSize: 24,
|
||||
mt: 2,
|
||||
width: 172,
|
||||
height: 172,
|
||||
borderRadius: '50%',
|
||||
border: '1.5px solid',
|
||||
borderColor: `${color}.100`,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
Ops, something went wrong
|
||||
</Box>
|
||||
<Typography level='body-md' fontWeight={500} textAlign={'center'}>
|
||||
if you think this is a mistake, please contact us or{' '}
|
||||
<a
|
||||
href='https://github.com/donetick/donetick/issues/new'
|
||||
style={{
|
||||
textDecoration: 'underline',
|
||||
<Box
|
||||
sx={{
|
||||
width: 128,
|
||||
height: 128,
|
||||
borderRadius: '50%',
|
||||
border: '1.5px solid',
|
||||
borderColor: `${color}.200`,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
open issue here
|
||||
</a>{' '}
|
||||
</Typography>
|
||||
<Box
|
||||
sx={{
|
||||
width: 84,
|
||||
height: 84,
|
||||
borderRadius: '50%',
|
||||
bgcolor: `${color}.50`,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<Icon sx={{ fontSize: 42, color: `${color}.500` }} />
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Title */}
|
||||
<Typography
|
||||
level='h3'
|
||||
fontWeight={700}
|
||||
textAlign='center'
|
||||
sx={{ mb: 1.5 }}
|
||||
>
|
||||
Something went wrong
|
||||
</Typography>
|
||||
|
||||
{/* Error message */}
|
||||
<Typography
|
||||
level='body-sm'
|
||||
textAlign='center'
|
||||
sx={{
|
||||
color: 'text.secondary',
|
||||
mb: 4,
|
||||
maxWidth: 320,
|
||||
minHeight: '2.5em',
|
||||
wordBreak: 'break-word',
|
||||
}}
|
||||
>
|
||||
{message ??
|
||||
'An unexpected error occurred. Try reloading — it usually fixes it.'}
|
||||
</Typography>
|
||||
|
||||
{/* Primary CTA */}
|
||||
<Button
|
||||
variant='solid'
|
||||
color='primary'
|
||||
size='lg'
|
||||
startDecorator={<RefreshRounded />}
|
||||
onClick={() => window.location.reload()}
|
||||
sx={{ width: '100%', mb: 2 }}
|
||||
>
|
||||
Try again
|
||||
</Button>
|
||||
|
||||
{/* Secondary actions */}
|
||||
<Box sx={{ display: 'flex', gap: 3, mb: 5 }}>
|
||||
<Button
|
||||
component={Link}
|
||||
to='/chores'
|
||||
variant='outlined'
|
||||
color='primary'
|
||||
sx={{ mt: 4 }}
|
||||
variant='plain'
|
||||
color='neutral'
|
||||
size='lg'
|
||||
startDecorator={<HomeRounded />}
|
||||
>
|
||||
@@ -55,16 +229,108 @@ const Error = () => {
|
||||
<Button
|
||||
component={Link}
|
||||
to='/login'
|
||||
variant='outlined'
|
||||
color='primary'
|
||||
sx={{ mt: 1 }}
|
||||
variant='plain'
|
||||
color='neutral'
|
||||
size='lg'
|
||||
startDecorator={<Login />}
|
||||
>
|
||||
Login
|
||||
</Button>
|
||||
</Box>
|
||||
</Container>
|
||||
|
||||
{/* Report hint + collapsible details */}
|
||||
<Box
|
||||
sx={{
|
||||
width: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
level='body-xs'
|
||||
textAlign='center'
|
||||
sx={{ color: 'text.tertiary', mb: 1.5 }}
|
||||
>
|
||||
If this keeps happening,{' '}
|
||||
<a
|
||||
href='https://github.com/donetick/donetick/issues/new'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
style={{ textDecoration: 'underline' }}
|
||||
>
|
||||
open an issue
|
||||
</a>{' '}
|
||||
and include the error details below.
|
||||
</Typography>
|
||||
|
||||
{(error?.stack || message) && (
|
||||
<>
|
||||
<Button
|
||||
variant='plain'
|
||||
color='neutral'
|
||||
size='sm'
|
||||
onClick={() => setShowDetails(v => !v)}
|
||||
endDecorator={
|
||||
<ExpandMoreRounded
|
||||
sx={{
|
||||
transition: 'transform 0.2s',
|
||||
transform: showDetails ? 'rotate(180deg)' : 'rotate(0deg)',
|
||||
}}
|
||||
/>
|
||||
}
|
||||
sx={{ mb: 1 }}
|
||||
>
|
||||
{showDetails ? 'Hide' : 'Show'} error details
|
||||
</Button>
|
||||
|
||||
{showDetails && (
|
||||
<Box
|
||||
sx={{
|
||||
position: 'relative',
|
||||
bgcolor: 'background.level2',
|
||||
borderRadius: 'sm',
|
||||
p: 2,
|
||||
width: '100%',
|
||||
}}
|
||||
>
|
||||
<IconButton
|
||||
size='sm'
|
||||
variant='plain'
|
||||
color='neutral'
|
||||
onClick={handleCopy}
|
||||
sx={{ position: 'absolute', top: 8, right: 8 }}
|
||||
title='Copy to clipboard'
|
||||
>
|
||||
<ContentCopyRounded fontSize='small' />
|
||||
</IconButton>
|
||||
<Typography
|
||||
level='body-xs'
|
||||
sx={{
|
||||
fontFamily: 'monospace',
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-all',
|
||||
pr: 4,
|
||||
color: 'text.secondary',
|
||||
}}
|
||||
>
|
||||
{buildErrorText(error, url)}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Snackbar
|
||||
open={copied}
|
||||
autoHideDuration={2500}
|
||||
onClose={() => setCopied(false)}
|
||||
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
|
||||
size='sm'
|
||||
>
|
||||
Error details copied to clipboard
|
||||
</Snackbar>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -8,11 +8,21 @@ import {
|
||||
import '@meauxt/react-swipeable-list/dist/styles.css'
|
||||
import {
|
||||
Analytics,
|
||||
CalendarMonth,
|
||||
Check,
|
||||
Checklist,
|
||||
EventBusy,
|
||||
EventNote,
|
||||
FilterList,
|
||||
Group,
|
||||
History,
|
||||
HourglassEmpty,
|
||||
Person,
|
||||
Redo,
|
||||
RunningWithErrors,
|
||||
Schedule,
|
||||
Star,
|
||||
ThumbDown,
|
||||
Timelapse,
|
||||
TrendingUp,
|
||||
} from '@mui/icons-material'
|
||||
@@ -22,8 +32,10 @@ import { Box, Button, Card, Container, Grid, Sheet, Typography } from '@mui/joy'
|
||||
import moment from 'moment'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { Link, useParams } from 'react-router-dom'
|
||||
import FilterBar from '../../components/common/FilterBar'
|
||||
import { useLocalization } from '../../contexts/LocalizationContext'
|
||||
import useConfirmationModal from '../../hooks/useConfirmationModal'
|
||||
import { useFilter } from '../../hooks/useFilter'
|
||||
import { usePendingCommands } from '../../hooks/usePendingCommands'
|
||||
import {
|
||||
useChoreHistory,
|
||||
@@ -35,6 +47,7 @@ import { useNotification } from '../../service/NotificationProvider'
|
||||
import { ChoreHistoryStatus } from '../../utils/Chores'
|
||||
import LoadingComponent from '../components/Loading'
|
||||
import EditHistoryModal from '../Modals/EditHistoryModal'
|
||||
import HistoryDetailModal from '../Modals/HistoryDetailModal'
|
||||
import ConfirmationModal from '../Modals/Inputs/ConfirmationModal'
|
||||
import NoteViewerModal from '../Modals/Inputs/NoteViewerModal'
|
||||
import HistoryCard from './HistoryCard'
|
||||
@@ -49,7 +62,8 @@ const ChoreHistory = () => {
|
||||
const { fmt } = useLocalization()
|
||||
const [showMoreInfoId, setShowMoreInfoId] = useState(null)
|
||||
const [noteViewerConfig, setNoteViewerConfig] = useState({ isOpen: false })
|
||||
const { showSuccess } = useNotification()
|
||||
const [detailModalConfig, setDetailModalConfig] = useState({ isOpen: false })
|
||||
const { showSuccess, showError } = useNotification()
|
||||
// React Query hooks
|
||||
const { data: choreHistoryData, isLoading } = useChoreHistory(choreId)
|
||||
const { data: circleMembersData } = useCircleMembers()
|
||||
@@ -77,6 +91,61 @@ const ChoreHistory = () => {
|
||||
}, {})
|
||||
}, [pendingCmds])
|
||||
|
||||
const filterDefs = useMemo(
|
||||
() => [
|
||||
{
|
||||
id: 'status',
|
||||
label: 'Status',
|
||||
type: 'multi-select',
|
||||
icon: <FilterList />,
|
||||
options: [
|
||||
{ value: ChoreHistoryStatus.COMPLETED, label: 'Completed', color: 'success', icon: <Check sx={{ fontSize: 14 }} /> },
|
||||
{ value: ChoreHistoryStatus.SKIPPED, label: 'Skipped', color: 'warning', icon: <Redo sx={{ fontSize: 14 }} /> },
|
||||
{ value: ChoreHistoryStatus.PENDING_APPROVAL, label: 'Pending', color: 'neutral', icon: <HourglassEmpty sx={{ fontSize: 14 }} /> },
|
||||
{ value: ChoreHistoryStatus.REJECTED, label: 'Rejected', color: 'danger', icon: <ThumbDown sx={{ fontSize: 14 }} /> },
|
||||
{ value: 5, label: 'Missed', color: 'danger', icon: <RunningWithErrors sx={{ fontSize: 14 }} /> },
|
||||
{ value: 6, label: 'Rescheduled', color: 'warning', icon: <Schedule sx={{ fontSize: 14 }} /> },
|
||||
],
|
||||
filterFn: (item, values) => values.includes(item.status),
|
||||
},
|
||||
{
|
||||
id: 'hasNotes',
|
||||
label: 'Has Notes',
|
||||
type: 'boolean',
|
||||
icon: <EventNote />,
|
||||
filterFn: item => !!item.notes,
|
||||
},
|
||||
{
|
||||
id: 'completedBy',
|
||||
label: 'Completed By',
|
||||
type: 'multi-select',
|
||||
icon: <Person />,
|
||||
options: performers.map(p => ({
|
||||
value: p.userId,
|
||||
label: p.displayName,
|
||||
avatar: p.image,
|
||||
})),
|
||||
filterFn: (item, values) => values.includes(item.completedBy),
|
||||
},
|
||||
{
|
||||
id: 'dateRange',
|
||||
label: 'Completed At',
|
||||
type: 'date-range',
|
||||
icon: <CalendarMonth />,
|
||||
filterFn: (item, value) => {
|
||||
const performed = new Date(item.performedAt || item.updatedAt)
|
||||
if (value.from && performed < new Date(value.from)) return false
|
||||
if (value.to && performed > new Date(value.to)) return false
|
||||
return true
|
||||
},
|
||||
},
|
||||
],
|
||||
[performers],
|
||||
)
|
||||
|
||||
const { filteredData: filteredHistory, activeFilters, setFilter, clearAll, activeFilterCount } =
|
||||
useFilter(choreHistory, filterDefs)
|
||||
|
||||
const handleDelete = historyEntry => {
|
||||
showConfirmation(
|
||||
`Are you sure you want to delete this history record?`,
|
||||
@@ -224,9 +293,10 @@ const ChoreHistory = () => {
|
||||
}
|
||||
|
||||
return (
|
||||
<Container maxWidth='md'>
|
||||
<Container maxWidth='md' sx={{ px: 0 }}>
|
||||
{/* Enhanced Header Section */}
|
||||
<Box sx={{ mb: 4 }}>
|
||||
<Box sx={{ gap: 2, p: 2 }}>
|
||||
{/* <Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 2, p: 2 }}> */}
|
||||
{/* Statistics Cards Grid - Compact Design */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 3 }}>
|
||||
<History sx={{ fontSize: '1.5rem' }} />
|
||||
@@ -304,7 +374,9 @@ const ChoreHistory = () => {
|
||||
</Box>
|
||||
|
||||
{/* History Section Header */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 3 }}>
|
||||
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, p: 2 }}>
|
||||
|
||||
<Analytics sx={{ fontSize: '1.5rem' }} />
|
||||
<Typography
|
||||
level='title-md'
|
||||
@@ -313,14 +385,50 @@ const ChoreHistory = () => {
|
||||
Task Activity
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ px: 2 }}>
|
||||
<FilterBar
|
||||
filterDefs={filterDefs}
|
||||
activeFilters={activeFilters}
|
||||
onSetFilter={setFilter}
|
||||
onClearAll={clearAll}
|
||||
resultCount={filteredHistory.length}
|
||||
totalCount={choreHistory.length}
|
||||
/>
|
||||
</Box>
|
||||
{filteredHistory.length === 0 && activeFilterCount > 0 && (
|
||||
<Box
|
||||
sx={{
|
||||
textAlign: 'center',
|
||||
py: 6,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
gap: 1.5,
|
||||
}}
|
||||
>
|
||||
<FilterList sx={{ fontSize: '3rem', color: 'text.tertiary' }} />
|
||||
<Typography level='title-md' sx={{ color: 'text.secondary' }}>
|
||||
No results match your filters
|
||||
</Typography>
|
||||
<Typography level='body-sm' sx={{ color: 'text.tertiary' }}>
|
||||
Try adjusting or clearing the active filters.
|
||||
</Typography>
|
||||
<Button variant='soft' size='sm' onClick={clearAll} sx={{ mt: 0.5 }}>
|
||||
Clear filters
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{filteredHistory.length > 0 && (
|
||||
<Sheet
|
||||
variant='plain'
|
||||
sx={{ borderRadius: 'sm', boxShadow: 'md', overflow: 'hidden' }}
|
||||
sx={{ borderRadius: 'sm', overflow: 'hidden' }}
|
||||
>
|
||||
{/* Chore History List (Updated Style) */}
|
||||
|
||||
<SwipeableList type={ListType.IOS} fullSwipe={false}>
|
||||
{choreHistory.map((historyEntry, index) => (
|
||||
{filteredHistory.map((historyEntry, index) => (
|
||||
<SwipeableListItem
|
||||
key={historyEntry.id || index}
|
||||
swipeActionOpen={
|
||||
@@ -385,6 +493,19 @@ const ChoreHistory = () => {
|
||||
performers={performers}
|
||||
allHistory={choreHistory}
|
||||
index={index}
|
||||
onViewDetails={() => {
|
||||
setDetailModalConfig({
|
||||
isOpen: true,
|
||||
entry: historyEntry,
|
||||
performers,
|
||||
onClose: () => setDetailModalConfig({ isOpen: false }),
|
||||
onEdit: record => {
|
||||
setDetailModalConfig({ isOpen: false })
|
||||
setEditHistory(record)
|
||||
setIsEditModalOpen(true)
|
||||
},
|
||||
})
|
||||
}}
|
||||
pendingCommands={pendingByHistoryId[historyEntry.id] || []}
|
||||
onViewNote={notes => {
|
||||
setNoteViewerConfig({
|
||||
@@ -407,6 +528,7 @@ const ChoreHistory = () => {
|
||||
))}
|
||||
</SwipeableList>
|
||||
</Sheet>
|
||||
)}
|
||||
<EditHistoryModal
|
||||
config={{
|
||||
isOpen: isEditModalOpen,
|
||||
@@ -481,7 +603,8 @@ const ChoreHistory = () => {
|
||||
/>
|
||||
<ConfirmationModal config={confirmModalConfig} />
|
||||
<NoteViewerModal config={noteViewerConfig} />
|
||||
</Container>
|
||||
<HistoryDetailModal config={detailModalConfig} />
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,99 +1,47 @@
|
||||
import {
|
||||
AccessTime,
|
||||
CalendarMonth,
|
||||
Check,
|
||||
EventNote,
|
||||
HourglassEmpty,
|
||||
MoreVert,
|
||||
Person,
|
||||
Redo,
|
||||
RunningWithErrors,
|
||||
Schedule,
|
||||
ThumbDown,
|
||||
Timelapse,
|
||||
Toll,
|
||||
} from '@mui/icons-material'
|
||||
import { Avatar, Box, Chip, Grid, IconButton, Typography } from '@mui/joy'
|
||||
import { Avatar, Box, Card, Chip, IconButton, Typography } from '@mui/joy'
|
||||
import moment from 'moment'
|
||||
import { useLocalization } from '../../contexts/LocalizationContext'
|
||||
import { TASK_COLOR } from '../../utils/Colors.jsx'
|
||||
import PendingBadge from '../components/PendingBadge'
|
||||
|
||||
const getCompletedChip = historyEntry => {
|
||||
if (
|
||||
historyEntry.status === 0 ||
|
||||
historyEntry.status === 5 ||
|
||||
historyEntry.status === 6
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (!historyEntry.dueDate) {
|
||||
return null
|
||||
// <Chip
|
||||
// size='sm'
|
||||
// variant='soft'
|
||||
// color='neutral'
|
||||
// startDecorator={<CalendarViewDay />}
|
||||
// >
|
||||
// No Due Date
|
||||
// </Chip>
|
||||
}
|
||||
|
||||
const performedAt = moment(historyEntry.performedAt)
|
||||
const dueDate = moment(historyEntry.dueDate)
|
||||
// TODO: make this a config at some point
|
||||
const gracePeriod = 6 * 60 * 60 * 1000 // 6 hours in milliseconds
|
||||
|
||||
if (Math.abs(performedAt - dueDate) <= gracePeriod) {
|
||||
return (
|
||||
<Chip
|
||||
size='sm'
|
||||
variant='solid'
|
||||
sx={{ backgroundColor: TASK_COLOR.COMPLETED, color: 'white' }}
|
||||
startDecorator={<Check />}
|
||||
>
|
||||
On Time
|
||||
</Chip>
|
||||
)
|
||||
} else if (performedAt.isBefore(dueDate)) {
|
||||
return (
|
||||
<Chip
|
||||
size='sm'
|
||||
variant='soft'
|
||||
sx={{ backgroundColor: TASK_COLOR.SCHEDULED, color: 'white' }}
|
||||
startDecorator={<Check />}
|
||||
>
|
||||
Early
|
||||
</Chip>
|
||||
)
|
||||
} else {
|
||||
return (
|
||||
<Chip
|
||||
size='sm'
|
||||
variant='solid'
|
||||
sx={{ backgroundColor: TASK_COLOR.LATE, color: 'white' }}
|
||||
startDecorator={<Timelapse />}
|
||||
>
|
||||
Late
|
||||
</Chip>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const formatTime = seconds => {
|
||||
if (typeof seconds !== 'number' || isNaN(seconds) || seconds < 0) {
|
||||
return null
|
||||
}
|
||||
const hours = Math.floor(seconds / 3600)
|
||||
const minutes = Math.floor((seconds % 3600) / 60)
|
||||
const secs = seconds % 60
|
||||
return `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`
|
||||
if (typeof seconds !== 'number' || isNaN(seconds) || seconds < 0) return null
|
||||
const h = Math.floor(seconds / 3600)
|
||||
const m = Math.floor((seconds % 3600) / 60)
|
||||
const s = seconds % 60
|
||||
return `${h.toString().padStart(2, '0')}:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`
|
||||
}
|
||||
|
||||
const stripHtmlTags = html => {
|
||||
if (!html) return ''
|
||||
if (typeof document === 'undefined') {
|
||||
return String(html).replace(/<[^>]*>/g, '')
|
||||
}
|
||||
const div = document.createElement('div')
|
||||
div.innerHTML = html
|
||||
return div.textContent || div.innerText || ''
|
||||
}
|
||||
|
||||
const statusConfig = {
|
||||
0: { label: 'In Progress', color: 'primary', icon: <AccessTime /> },
|
||||
1: { label: 'Completed', color: 'success', icon: <Check /> },
|
||||
2: { label: 'Skipped', color: 'warning', icon: <Redo /> },
|
||||
3: { label: 'Pending Approval', color: 'neutral', icon: <HourglassEmpty /> },
|
||||
4: { label: 'Rejected', color: 'danger', icon: <ThumbDown /> },
|
||||
5: { label: 'Missed', color: 'danger', icon: <RunningWithErrors /> },
|
||||
6: { label: 'Rescheduled', color: 'warning', icon: <Schedule /> },
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact HistoryCard component - content only
|
||||
*/
|
||||
const HistoryCard = ({
|
||||
allHistory,
|
||||
performers,
|
||||
@@ -102,235 +50,137 @@ const HistoryCard = ({
|
||||
pendingCommands,
|
||||
onToggleActions,
|
||||
onViewNote,
|
||||
onViewDetails,
|
||||
}) => {
|
||||
const { fmt } = useLocalization()
|
||||
const performer = performers.find(p => p.userId === historyEntry.completedBy)
|
||||
const assignedTo = performers.find(p => p.userId === historyEntry.assignedTo)
|
||||
const config = statusConfig[historyEntry.status] ?? statusConfig[1]
|
||||
const displayLabel =
|
||||
historyEntry.status === 6 && !historyEntry.dueDate ? 'Scheduled' : config.label
|
||||
const actionDate = historyEntry.performedAt || historyEntry.updatedAt
|
||||
|
||||
const formatTimeDifference = (startDate, endDate) => {
|
||||
const diffInMinutes = moment(startDate).diff(endDate, 'minutes')
|
||||
let timeValue = diffInMinutes
|
||||
let unit = 'minute'
|
||||
const getTimingLine = () => {
|
||||
const { status, performedAt, dueDate } = historyEntry
|
||||
if (!dueDate) return null
|
||||
|
||||
if (diffInMinutes >= 60) {
|
||||
const diffInHours = moment(startDate).diff(endDate, 'hours')
|
||||
timeValue = diffInHours
|
||||
unit = 'hour'
|
||||
|
||||
if (diffInHours >= 24) {
|
||||
const diffInDays = moment(startDate).diff(endDate, 'days')
|
||||
timeValue = diffInDays
|
||||
unit = 'day'
|
||||
}
|
||||
if (status === 6) {
|
||||
return `Was due ${moment(dueDate).format('MMM D')}`
|
||||
}
|
||||
|
||||
return `${timeValue} ${unit}${timeValue !== 1 ? 's' : ''}`
|
||||
if (status === 5) {
|
||||
return `Was due ${moment(dueDate).format('MMM D')}`
|
||||
}
|
||||
if ((status === 1 || status === 2 || status === 0) && performedAt) {
|
||||
const diffHours = moment(performedAt).diff(dueDate, 'hours')
|
||||
const abs = Math.abs(diffHours)
|
||||
if (abs <= 6) return null // chip already says "On Time"
|
||||
if (diffHours < 0) return abs >= 48 ? `${Math.floor(abs / 24)}d before due date` : `${abs}h before due date`
|
||||
return abs >= 48 ? `${Math.floor(abs / 24)}d after due date` : `${abs}h after due date`
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const getStatusAvatar = () => {
|
||||
const statusMap = {
|
||||
0: { icon: <AccessTime />, color: 'primary' }, // Started
|
||||
1: { icon: <Check />, color: 'success' }, // Completed
|
||||
2: { icon: <Redo />, color: 'warning' }, // Skipped
|
||||
3: { icon: <HourglassEmpty />, color: 'neutral' }, // Pending Approval
|
||||
4: { icon: <ThumbDown />, color: 'danger' }, // Rejected
|
||||
5: { icon: <RunningWithErrors />, color: 'danger' }, // Missed
|
||||
6: { icon: <Schedule />, color: 'warning' }, // Rescheduled
|
||||
}
|
||||
const timingLine = getTimingLine()
|
||||
const noteLabel = historyEntry.status === 2 || historyEntry.status === 4 ? 'Reason' : 'Note'
|
||||
const plainTextNotes = historyEntry.notes ? stripHtmlTags(historyEntry.notes) : ''
|
||||
|
||||
const config = statusMap[historyEntry.status] || statusMap[1]
|
||||
return (
|
||||
<Avatar
|
||||
size='sm'
|
||||
color={config.color}
|
||||
variant='soft'
|
||||
sx={{
|
||||
width: 24,
|
||||
height: 24,
|
||||
'& svg': { fontSize: '14px' },
|
||||
}}
|
||||
>
|
||||
{config.icon}
|
||||
</Avatar>
|
||||
)
|
||||
}
|
||||
const metaTextParts = [
|
||||
fmt.dateTime(actionDate),
|
||||
historyEntry.completedBy !== historyEntry.assignedTo && assignedTo
|
||||
? `Assigned to ${assignedTo.displayName}`
|
||||
: null,
|
||||
historyEntry?.duration > 0 ? `⏱ ${formatTime(historyEntry.duration)}` : null,
|
||||
historyEntry?.points > 0 ? `★ ${historyEntry.points} pt${historyEntry.points > 1 ? 's' : ''}` : null,
|
||||
].filter(Boolean)
|
||||
|
||||
return (
|
||||
<Box
|
||||
onClick={() => onViewDetails?.()}
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
minHeight: 64,
|
||||
minWidth: '100%',
|
||||
px: 2,
|
||||
py: 1.5,
|
||||
bgcolor: 'background.body',
|
||||
borderBottom: '1px solid',
|
||||
borderColor: 'divider',
|
||||
borderLeft: '3px solid',
|
||||
borderLeftColor: `${config.color}.400`,
|
||||
cursor: onViewDetails ? 'pointer' : 'default',
|
||||
'&:hover': onViewDetails ? { bgcolor: 'background.level1' } : {},
|
||||
}}
|
||||
>
|
||||
<Box sx={{ flex: 1, minWidth: 0 }}>
|
||||
<Grid container spacing={1} alignItems='center'>
|
||||
{/* First Row/Column: Status and Time Info */}
|
||||
<Grid xs={12} sm={8}>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 1,
|
||||
flexWrap: 'wrap',
|
||||
}}
|
||||
<Box sx={{ flex: 1, minWidth: 0, px: 2, py: 1.5 }}>
|
||||
{/* Status + timing chip */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 0.5 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75 }}>
|
||||
<Avatar
|
||||
size='sm'
|
||||
color={config.color}
|
||||
variant='soft'
|
||||
sx={{ width: 20, height: 20, '& svg': { fontSize: '11px' } }}
|
||||
>
|
||||
{getStatusAvatar()}
|
||||
{config.icon}
|
||||
</Avatar>
|
||||
<Typography level='title-sm' fontWeight='lg' sx={{ color: `${config.color}.plainColor` }}>
|
||||
{displayLabel}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Typography
|
||||
level='body-sm'
|
||||
sx={{
|
||||
color: 'text.secondary',
|
||||
fontWeight: 'md',
|
||||
}}
|
||||
>
|
||||
{historyEntry.status === 0
|
||||
? 'In Progress'
|
||||
: historyEntry.status === 1
|
||||
? 'Completed'
|
||||
: historyEntry.status === 2
|
||||
? 'Skipped'
|
||||
: historyEntry.status === 3
|
||||
? 'Pending Approval'
|
||||
: historyEntry.status === 4
|
||||
? 'Rejected'
|
||||
: historyEntry.status === 5
|
||||
? 'Missed'
|
||||
: historyEntry.status === 6
|
||||
? 'Rescheduled'
|
||||
: 'Completed'}
|
||||
</Typography>
|
||||
{/* Timing relationship line */}
|
||||
{timingLine && (
|
||||
<Typography level='body-xs' sx={{ color: 'text.tertiary', mb: 0.25 }}>
|
||||
{timingLine}
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
<Chip size='sm' startDecorator={<EventNote />}>
|
||||
{fmt.dateTime(
|
||||
historyEntry.performedAt || historyEntry.updatedAt,
|
||||
)}
|
||||
</Chip>
|
||||
{/* Notes inline */}
|
||||
|
||||
<Box sx={{ display: 'flex', gap: 0.5 }}>
|
||||
{getCompletedChip(historyEntry)}
|
||||
</Box>
|
||||
</Box>
|
||||
</Grid>
|
||||
{plainTextNotes && (
|
||||
<Card
|
||||
variant='soft'
|
||||
color='neutral'
|
||||
size='sm'
|
||||
sx={{ mt: 0.5, whiteSpace: 'pre-wrap', overflow: 'hidden', textOverflow: 'ellipsis' }}
|
||||
>
|
||||
<Typography
|
||||
level='body-xs'
|
||||
sx={{ color: 'text.secondary', fontStyle: 'italic', mb: 0.25, cursor: 'pointer' }}
|
||||
onClick={e => { e.stopPropagation(); onViewNote?.(historyEntry.notes) }}
|
||||
>
|
||||
{plainTextNotes.length > 80 ? `${plainTextNotes.slice(0, 80)}…` : plainTextNotes}
|
||||
</Typography>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Second Row/Column: Completion Status (right side on desktop) */}
|
||||
<Grid xs={12} sm={4}>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
justifyContent: { xs: 'flex-start', sm: 'flex-end' },
|
||||
alignItems: 'center',
|
||||
gap: 1,
|
||||
}}
|
||||
{/* Metadata strip: performer chip + date + extras */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, mt: 0.5, flexWrap: 'wrap' }}>
|
||||
{performer && (
|
||||
<Chip
|
||||
size='sm'
|
||||
variant='soft'
|
||||
color='neutral'
|
||||
startDecorator={
|
||||
<Avatar src={performer.image} alt={performer.displayName} sx={{ width: 14, height: 14 }} />
|
||||
}
|
||||
>
|
||||
{historyEntry.dueDate && (
|
||||
<Chip size='sm' startDecorator={<CalendarMonth />}>
|
||||
{fmt.dateTime(historyEntry.dueDate)}
|
||||
</Chip>
|
||||
)}
|
||||
</Box>
|
||||
</Grid>
|
||||
|
||||
{/* Third Row: Performer and Assignment Info */}
|
||||
<Grid xs={12}>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 1,
|
||||
flexWrap: 'wrap',
|
||||
mt: 0.5,
|
||||
}}
|
||||
>
|
||||
{performer && (
|
||||
<Chip
|
||||
size='sm'
|
||||
variant='solid'
|
||||
color='success'
|
||||
startDecorator={
|
||||
<Avatar
|
||||
src={performer?.image}
|
||||
alt={performer?.displayName}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{performer?.displayName || 'Unknown'}
|
||||
</Chip>
|
||||
)}
|
||||
|
||||
{historyEntry.completedBy !== historyEntry.assignedTo &&
|
||||
assignedTo && (
|
||||
<Chip
|
||||
size='sm'
|
||||
variant='outlined'
|
||||
color='neutral'
|
||||
startDecorator={<Person />}
|
||||
>
|
||||
Assigned to {assignedTo.displayName}
|
||||
</Chip>
|
||||
)}
|
||||
|
||||
{historyEntry.notes && (
|
||||
<Chip
|
||||
size='sm'
|
||||
variant='plain'
|
||||
color='neutral'
|
||||
startDecorator={<EventNote />}
|
||||
sx={{
|
||||
maxWidth: '120px',
|
||||
overflow: 'hidden',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
onClick={e => {
|
||||
e.stopPropagation()
|
||||
onViewNote?.(historyEntry.notes)
|
||||
}}
|
||||
>
|
||||
Note
|
||||
</Chip>
|
||||
)}
|
||||
{/* add a duration chip if we have duration */}
|
||||
{historyEntry?.duration > 0 && (
|
||||
<Chip
|
||||
size='sm'
|
||||
variant='soft'
|
||||
color='primary'
|
||||
startDecorator={<AccessTime />}
|
||||
>
|
||||
{formatTime(historyEntry.duration)}
|
||||
</Chip>
|
||||
)}
|
||||
{historyEntry?.points > 0 && (
|
||||
<Chip
|
||||
size='sm'
|
||||
variant='solid'
|
||||
color='success'
|
||||
startDecorator={<Toll />}
|
||||
>
|
||||
{historyEntry.points} pt
|
||||
{historyEntry.points > 1 ? 's' : ''}
|
||||
</Chip>
|
||||
)}
|
||||
</Box>
|
||||
</Grid>
|
||||
</Grid>
|
||||
{performer.displayName}
|
||||
</Chip>
|
||||
)}
|
||||
{metaTextParts.length > 0 && (
|
||||
<Typography level='body-xs' sx={{ color: 'text.tertiary' }}>
|
||||
{metaTextParts.join(' · ')}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
<Box>
|
||||
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', pr: 0.5 }} onClick={e => e.stopPropagation()}>
|
||||
{onToggleActions && (
|
||||
<IconButton
|
||||
color='neutral'
|
||||
variant='plain'
|
||||
size='sm'
|
||||
onClick={e => {
|
||||
e.stopPropagation()
|
||||
onToggleActions()
|
||||
}}
|
||||
onClick={e => { e.stopPropagation(); onToggleActions() }}
|
||||
>
|
||||
<MoreVert sx={{ fontSize: 18 }} />
|
||||
</IconButton>
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
import { Card, Grid, Typography } from '@mui/joy'
|
||||
import moment from 'moment'
|
||||
import { useState } from 'react'
|
||||
import ChoreCard from '../Chores/ChoreCard'
|
||||
|
||||
const DemoMyChore = () => {
|
||||
const [selectedCalendarDate, setSelectedCalendarDate] = useState(null)
|
||||
|
||||
const cards = [
|
||||
{
|
||||
id: 12,
|
||||
name: '♻️ Take out recycle ',
|
||||
frequencyType: 'days_of_the_week',
|
||||
frequency: 1,
|
||||
priority: 1,
|
||||
frequencyMetadata:
|
||||
'{"days":["thursday"],"time":"2024-07-07T22:00:00-04:00"}',
|
||||
nextDueDate: moment().add(1, 'days').hour(8).minute(0).toISOString(),
|
||||
@@ -96,6 +100,17 @@ const DemoMyChore = () => {
|
||||
]
|
||||
|
||||
const users = [{ displayName: 'Me', id: 1, userId: 1 }]
|
||||
|
||||
// Helper function to get chores for a specific date
|
||||
const getChoresForDate = date => {
|
||||
return cards.filter(chore => {
|
||||
if (!chore.nextDueDate) return false
|
||||
const choreDate = new Date(chore.nextDueDate).toLocaleDateString()
|
||||
const selectedDate = date.toLocaleDateString()
|
||||
return choreDate === selectedDate
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Grid item xs={12} sm={5} data-aos-first-tasks-list>
|
||||
|
||||
@@ -17,6 +17,10 @@ import TabletInstallationSection from './TabletInstallationSection'
|
||||
const Landing = () => {
|
||||
const Navigate = useNavigate()
|
||||
useEffect(() => {
|
||||
// if the host is https://app.donetick.com/ then redirect to https://app.donetick.com/my/chores:
|
||||
if (window.location.host === 'app.donetick.com') {
|
||||
Navigate('/chores')
|
||||
}
|
||||
AOS.init({
|
||||
once: false, // whether animation should happen only once - while scrolling down
|
||||
})
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import ipad_screenshot from '@/assets/ipad_dashbard_calendar.png'
|
||||
import { Box, Container, Typography } from '@mui/joy'
|
||||
|
||||
const TabletInstallationSection = () => {
|
||||
return (
|
||||
<Container maxWidth='xl' sx={{ py: { xs: 6, sm: 8, md: 12 } }}>
|
||||
|
||||
230
src/views/Modals/HistoryDetailModal.jsx
Normal file
230
src/views/Modals/HistoryDetailModal.jsx
Normal file
@@ -0,0 +1,230 @@
|
||||
import {
|
||||
AccessTime,
|
||||
CalendarMonth,
|
||||
Check,
|
||||
Edit,
|
||||
HourglassEmpty,
|
||||
OpenInNew,
|
||||
Person,
|
||||
Redo,
|
||||
RunningWithErrors,
|
||||
Schedule,
|
||||
ThumbDown,
|
||||
Update,
|
||||
} from '@mui/icons-material'
|
||||
import { Avatar, Box, Button, Chip, Divider, Stack, Typography } from '@mui/joy'
|
||||
import moment from 'moment'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useLocalization } from '../../contexts/LocalizationContext'
|
||||
import { useResponsiveModal } from '../../hooks/useResponsiveModal'
|
||||
import { TASK_COLOR } from '../../utils/Colors.jsx'
|
||||
import RichTextEditor from '../components/RichTextEditor.jsx'
|
||||
|
||||
const STATUS_CONFIG = {
|
||||
0: { label: 'In Progress', color: 'primary', icon: <AccessTime /> },
|
||||
1: { label: 'Completed', color: 'success', icon: <Check /> },
|
||||
2: { label: 'Skipped', color: 'warning', icon: <Redo /> },
|
||||
3: { label: 'Pending Approval', color: 'neutral', icon: <HourglassEmpty /> },
|
||||
4: { label: 'Rejected', color: 'danger', icon: <ThumbDown /> },
|
||||
5: { label: 'Missed', color: 'danger', icon: <RunningWithErrors /> },
|
||||
6: { label: 'Rescheduled', color: 'warning', icon: <Schedule /> },
|
||||
}
|
||||
|
||||
const DetailRow = ({ icon, label, value, children }) => (
|
||||
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 1.5, py: 0.75 }}>
|
||||
<Box sx={{ color: 'text.tertiary', mt: 0.25, flexShrink: 0, display: 'flex' }}>{icon}</Box>
|
||||
<Box sx={{ flex: 1, minWidth: 0 }}>
|
||||
<Typography level='body-xs' sx={{ color: 'text.tertiary', mb: 0.15 }}>{label}</Typography>
|
||||
{children ?? (
|
||||
<Typography level='body-sm' sx={{ color: 'text.primary', fontWeight: 'md' }}>{value}</Typography>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
|
||||
const TimingBadge = ({ historyEntry }) => {
|
||||
if (!historyEntry.dueDate || !historyEntry.performedAt) return null
|
||||
if ([0, 5, 6].includes(historyEntry.status)) return null
|
||||
|
||||
const performedAt = moment(historyEntry.performedAt)
|
||||
const dueDate = moment(historyEntry.dueDate)
|
||||
const diffHours = performedAt.diff(dueDate, 'hours')
|
||||
const gracePeriod = 6 * 60 * 60 * 1000
|
||||
|
||||
if (Math.abs(performedAt - dueDate) <= gracePeriod) {
|
||||
return <Chip size='sm' variant='solid' sx={{ backgroundColor: TASK_COLOR.COMPLETED, color: 'white' }} startDecorator={<Check />}>On Time</Chip>
|
||||
} else if (performedAt.isBefore(dueDate)) {
|
||||
const abs = Math.abs(diffHours)
|
||||
const label = abs >= 48 ? `${Math.floor(abs / 24)}d early` : `${abs}h early`
|
||||
return <Chip size='sm' variant='soft' sx={{ backgroundColor: TASK_COLOR.SCHEDULED, color: 'white' }} startDecorator={<Check />}>{label}</Chip>
|
||||
} else {
|
||||
const abs = Math.abs(diffHours)
|
||||
const label = abs >= 48 ? `${Math.floor(abs / 24)}d late` : `${abs}h late`
|
||||
return <Chip size='sm' variant='solid' sx={{ backgroundColor: TASK_COLOR.LATE, color: 'white' }}>{label}</Chip>
|
||||
}
|
||||
}
|
||||
|
||||
function HistoryDetailModal({ config }) {
|
||||
const { ResponsiveModal } = useResponsiveModal()
|
||||
const { fmt } = useLocalization()
|
||||
const navigate = useNavigate()
|
||||
|
||||
const entry = config?.entry
|
||||
const performers = config?.performers ?? []
|
||||
|
||||
if (!entry) return null
|
||||
|
||||
const statusCfg = STATUS_CONFIG[entry.status] ?? STATUS_CONFIG[1]
|
||||
const isFirstSchedule = entry.status === 6 && !entry.dueDate
|
||||
const statusLabel = isFirstSchedule ? 'Scheduled' : statusCfg.label
|
||||
const performer = performers.find(p => p.userId === entry.completedBy)
|
||||
const assignedTo = performers.find(p => p.userId === entry.assignedTo)
|
||||
const isDifferentAssignee = entry.assignedTo && entry.completedBy !== entry.assignedTo
|
||||
|
||||
// updatedAt is only meaningful if it differs from performedAt by more than a minute
|
||||
const showUpdatedAt =
|
||||
entry.updatedAt &&
|
||||
entry.performedAt &&
|
||||
Math.abs(moment(entry.updatedAt).diff(entry.performedAt, 'minutes')) > 1
|
||||
|
||||
const formatDuration = seconds => {
|
||||
if (!seconds || seconds <= 0) return null
|
||||
const h = Math.floor(seconds / 3600)
|
||||
const m = Math.floor((seconds % 3600) / 60)
|
||||
const s = seconds % 60
|
||||
return `${h.toString().padStart(2, '0')}:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`
|
||||
}
|
||||
|
||||
return (
|
||||
<ResponsiveModal
|
||||
open={config?.isOpen}
|
||||
onClose={config?.onClose}
|
||||
title='Activity Detail'
|
||||
>
|
||||
{/* Status header */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1.5 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Avatar size='sm' color={statusCfg.color} variant='soft'>
|
||||
{statusCfg.icon}
|
||||
</Avatar>
|
||||
<Typography level='title-md' fontWeight='lg' sx={{ color: `${statusCfg.color}.plainColor` }}>
|
||||
{statusLabel}
|
||||
</Typography>
|
||||
</Box>
|
||||
<TimingBadge historyEntry={entry} />
|
||||
</Box>
|
||||
|
||||
<Divider sx={{ mb: 1.5 }} />
|
||||
|
||||
<Stack spacing={0}>
|
||||
{/* Who performed it */}
|
||||
{performer && (
|
||||
<DetailRow icon={<Check sx={{ fontSize: 16 }} />} label='Performed by'>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75 }}>
|
||||
<Avatar src={performer.image} alt={performer.displayName} size='sm' sx={{ width: 20, height: 20 }} />
|
||||
<Typography level='body-sm' fontWeight='md'>{performer.displayName}</Typography>
|
||||
</Box>
|
||||
</DetailRow>
|
||||
)}
|
||||
|
||||
{/* Assigned to (only if different) */}
|
||||
{isDifferentAssignee && assignedTo && (
|
||||
<DetailRow icon={<Person sx={{ fontSize: 16 }} />} label='Assigned to' value={assignedTo.displayName} />
|
||||
)}
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* Performed at */}
|
||||
{entry.performedAt && (
|
||||
<DetailRow
|
||||
icon={<AccessTime sx={{ fontSize: 16 }} />}
|
||||
label={isFirstSchedule ? 'Scheduled on' : entry.status === 6 ? 'Rescheduled on' : entry.status === 2 ? 'Skipped on' : 'Completed on'}
|
||||
value={fmt.dateTime(entry.performedAt)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Due date */}
|
||||
{entry.dueDate && (
|
||||
<DetailRow
|
||||
icon={<CalendarMonth sx={{ fontSize: 16 }} />}
|
||||
label={entry.status === 6 ? 'Previous due date' : entry.status === 5 ? 'Was due' : 'Due date'}
|
||||
value={fmt.dateTime(entry.dueDate)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Last updated (only if meaningfully different from performedAt) */}
|
||||
{showUpdatedAt && (
|
||||
<DetailRow
|
||||
icon={<Update sx={{ fontSize: 16 }} />}
|
||||
label='Last updated'
|
||||
value={fmt.dateTime(entry.updatedAt)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Duration */}
|
||||
{entry.duration > 0 && (
|
||||
<DetailRow
|
||||
icon={<Schedule sx={{ fontSize: 16 }} />}
|
||||
label='Duration'
|
||||
value={formatDuration(entry.duration)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Points */}
|
||||
{entry.points > 0 && (
|
||||
<DetailRow
|
||||
icon={<Typography sx={{ fontSize: 14 }}>★</Typography>}
|
||||
label='Points earned'
|
||||
value={`${entry.points} pt${entry.points > 1 ? 's' : ''}`}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Notes */}
|
||||
{entry.notes && (
|
||||
<>
|
||||
<Divider />
|
||||
<Box sx={{ pt: 1 }}>
|
||||
<Typography level='body-xs' sx={{ color: 'text.tertiary', mb: 0.5 }}>
|
||||
{entry.status === 2 || entry.status === 4 ? 'Reason' : 'Notes'}
|
||||
</Typography>
|
||||
<Box sx={{ overflowY: 'auto', maxHeight: '60vh' }}>
|
||||
<RichTextEditor value={entry.notes || ''} isEditable={false} />
|
||||
</Box>
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
{/* Action buttons */}
|
||||
<Box sx={{ display: 'flex', gap: 1, mt: 2, justifyContent: 'flex-end' }}>
|
||||
{entry.choreId && (
|
||||
<Button
|
||||
variant='soft'
|
||||
color='neutral'
|
||||
size='sm'
|
||||
startDecorator={<OpenInNew sx={{ fontSize: 16 }} />}
|
||||
onClick={() => {
|
||||
config?.onClose?.()
|
||||
navigate(`/chores/${entry.choreId}`)
|
||||
}}
|
||||
>
|
||||
Open Task
|
||||
</Button>
|
||||
)}
|
||||
{config?.onEdit && (
|
||||
<Button
|
||||
variant='soft'
|
||||
color='neutral'
|
||||
size='md'
|
||||
startDecorator={<Edit sx={{ fontSize: 16 }} />}
|
||||
onClick={() => config.onEdit(entry)}
|
||||
>
|
||||
Edit Entry
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
</ResponsiveModal>
|
||||
)
|
||||
}
|
||||
|
||||
export default HistoryDetailModal
|
||||
@@ -1,22 +1,21 @@
|
||||
import { Add, Delete } from '@mui/icons-material'
|
||||
import { Save } from '@mui/icons-material'
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Chip,
|
||||
IconButton,
|
||||
Divider,
|
||||
Input,
|
||||
List,
|
||||
ListItem,
|
||||
Option,
|
||||
Select,
|
||||
Textarea,
|
||||
Typography,
|
||||
} from '@mui/joy'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useResponsiveModal } from '../../../hooks/useResponsiveModal'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import BottomSheetModal from '../../../components/common/BottomSheetModal'
|
||||
import FilterBuilderContent, {
|
||||
conditionsToSelections,
|
||||
defaultSelections,
|
||||
selectionsToConditions,
|
||||
} from '../../Chores/components/FilterBuilderContent'
|
||||
import { FILTER_COLORS } from '../../../utils/Colors'
|
||||
import { applyFilter } from '../../../utils/FilterEngine'
|
||||
import Priorities from '../../../utils/Priorities'
|
||||
import { useFilters } from '../../Filters/FilterQueries'
|
||||
|
||||
const AdvancedFilterBuilder = ({
|
||||
@@ -30,527 +29,149 @@ const AdvancedFilterBuilder = ({
|
||||
userProfile = null,
|
||||
editingFilter = null,
|
||||
}) => {
|
||||
const { ResponsiveModal } = useResponsiveModal()
|
||||
const listContainerRef = useRef(null)
|
||||
const conditionRefs = useRef([])
|
||||
const [filterName, setFilterName] = useState('')
|
||||
const [filterDescription, setFilterDescription] = useState('')
|
||||
const [filterColor, setFilterColor] = useState(FILTER_COLORS[0].value)
|
||||
const [conditions, setConditions] = useState([
|
||||
{ type: 'assignee', operator: 'is', value: [] },
|
||||
])
|
||||
const [selections, setSelections] = useState(defaultSelections())
|
||||
const [error, setError] = useState('')
|
||||
const { data: existedFilters = [] } = useFilters()
|
||||
|
||||
const filterNameExists = (name, excludeId = null) => {
|
||||
return existedFilters.some(
|
||||
filter =>
|
||||
filter.name.toLowerCase() === name.toLowerCase() &&
|
||||
filter.id !== excludeId,
|
||||
const filterNameExists = (name, excludeId = null) =>
|
||||
existedFilters.some(
|
||||
f => f.name.toLowerCase() === name.toLowerCase() && f.id !== excludeId,
|
||||
)
|
||||
}
|
||||
|
||||
// Initialize refs array when conditions change
|
||||
useEffect(() => {
|
||||
conditionRefs.current = conditionRefs.current.slice(0, conditions.length)
|
||||
}, [conditions.length])
|
||||
|
||||
// Initialize state when editing a filter
|
||||
useEffect(() => {
|
||||
if (!isOpen) return
|
||||
if (editingFilter) {
|
||||
setFilterName(editingFilter.name)
|
||||
setFilterDescription(editingFilter.description || '')
|
||||
setFilterColor(editingFilter.color || FILTER_COLORS[0].value)
|
||||
setConditions(editingFilter.conditions || [])
|
||||
setError('')
|
||||
setSelections(conditionsToSelections(editingFilter.conditions))
|
||||
} else {
|
||||
setFilterName('')
|
||||
setFilterDescription('')
|
||||
// find color no filter has it :
|
||||
const potentialColor = FILTER_COLORS.find(
|
||||
color => !existedFilters.some(filter => filter.color === color.value),
|
||||
c => !existedFilters.some(f => f.color === c.value),
|
||||
)
|
||||
|
||||
setFilterColor(
|
||||
potentialColor ? potentialColor.value : FILTER_COLORS[0].value,
|
||||
)
|
||||
setConditions([{ type: 'assignee', operator: 'is', value: [] }])
|
||||
setError('')
|
||||
setFilterColor(potentialColor?.value ?? FILTER_COLORS[0].value)
|
||||
setSelections(defaultSelections())
|
||||
}
|
||||
setError('')
|
||||
}, [editingFilter, isOpen])
|
||||
|
||||
const conditions = useMemo(() => selectionsToConditions(selections), [selections])
|
||||
|
||||
const previewChores = useMemo(() => {
|
||||
const validConditions = conditions.filter(c => {
|
||||
if (c.type === 'dueDate' || c.type === 'points') return true
|
||||
return c.value && (Array.isArray(c.value) ? c.value.length > 0 : true)
|
||||
})
|
||||
|
||||
if (validConditions.length === 0) return []
|
||||
|
||||
const result = applyFilter(
|
||||
if (conditions.length === 0) return []
|
||||
return applyFilter(
|
||||
allChores,
|
||||
{ conditions: validConditions, operator: 'AND' },
|
||||
{
|
||||
userId: userProfile?.id,
|
||||
members,
|
||||
labels,
|
||||
projects,
|
||||
},
|
||||
{ conditions, operator: 'AND' },
|
||||
{ userId: userProfile?.id, members, labels, projects },
|
||||
)
|
||||
|
||||
return result
|
||||
}, [conditions, allChores, userProfile, members, labels, projects])
|
||||
|
||||
const previewCount = previewChores.length
|
||||
const previewOverdueCount = previewChores.filter(
|
||||
chore => chore.nextDueDate && new Date(chore.nextDueDate) < new Date(),
|
||||
c => c.nextDueDate && new Date(c.nextDueDate) < new Date(),
|
||||
).length
|
||||
|
||||
const addCondition = () => {
|
||||
setConditions([
|
||||
...conditions,
|
||||
{ type: 'assignee', operator: 'is', value: [] },
|
||||
])
|
||||
|
||||
// Scroll to the new condition after it's rendered
|
||||
setTimeout(() => {
|
||||
const newIndex = conditions.length
|
||||
const newConditionElement = conditionRefs.current[newIndex]
|
||||
if (newConditionElement && listContainerRef.current) {
|
||||
newConditionElement.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'nearest',
|
||||
})
|
||||
}
|
||||
}, 100)
|
||||
}
|
||||
|
||||
const removeCondition = index => {
|
||||
setConditions(conditions.filter((_, i) => i !== index))
|
||||
}
|
||||
|
||||
const updateCondition = (index, field, value) => {
|
||||
const updated = [...conditions]
|
||||
updated[index] = { ...updated[index], [field]: value }
|
||||
|
||||
if (field === 'type') {
|
||||
updated[index].value = []
|
||||
if (value === 'dueDate') {
|
||||
updated[index].operator = 'isOverdue'
|
||||
updated[index].value = null
|
||||
} else if (value === 'status') {
|
||||
updated[index].value = []
|
||||
} else if (value === 'points') {
|
||||
updated[index].operator = 'greaterThan'
|
||||
updated[index].value = 0
|
||||
}
|
||||
}
|
||||
|
||||
setConditions(updated)
|
||||
}
|
||||
const activeConditionCount = conditions.length
|
||||
|
||||
const handleSave = () => {
|
||||
if (!filterName.trim()) {
|
||||
setError('Please enter a filter name')
|
||||
return
|
||||
}
|
||||
|
||||
// Check for duplicate name, excluding current filter if editing
|
||||
if (filterNameExists(filterName.trim(), editingFilter?.id)) {
|
||||
setError('A filter with this name already exists')
|
||||
return
|
||||
}
|
||||
|
||||
const validConditions = conditions.filter(c => {
|
||||
if (c.type === 'dueDate' || c.type === 'points') return true
|
||||
return c.value && (Array.isArray(c.value) ? c.value.length > 0 : true)
|
||||
})
|
||||
|
||||
if (conditions.length === 0 || validConditions.length === 0) {
|
||||
setError('Please add at least one filter condition')
|
||||
if (conditions.length === 0) {
|
||||
setError('Please configure at least one filter condition')
|
||||
return
|
||||
}
|
||||
|
||||
const filterData = {
|
||||
onSave({
|
||||
name: filterName.trim(),
|
||||
description: filterDescription.trim(),
|
||||
description: editingFilter?.description ?? '',
|
||||
color: filterColor,
|
||||
conditions: validConditions,
|
||||
conditions,
|
||||
operator: 'AND',
|
||||
}
|
||||
|
||||
// Include ID if editing
|
||||
if (editingFilter) {
|
||||
filterData.id = editingFilter.id
|
||||
}
|
||||
|
||||
onSave(filterData)
|
||||
...(editingFilter ? { id: editingFilter.id } : {}),
|
||||
})
|
||||
onClose()
|
||||
}
|
||||
|
||||
const renderValueSelector = (condition, index) => {
|
||||
switch (condition.type) {
|
||||
case 'assignee':
|
||||
return (
|
||||
<Select
|
||||
multiple
|
||||
value={condition.value || []}
|
||||
onChange={(_, newValue) =>
|
||||
updateCondition(index, 'value', newValue)
|
||||
}
|
||||
placeholder='Select assignees'
|
||||
sx={{ width: '100%' }}
|
||||
slotProps={{
|
||||
listbox: {
|
||||
placement: 'bottom-start',
|
||||
disablePortal: false,
|
||||
},
|
||||
}}
|
||||
renderValue={selected => (
|
||||
<Box sx={{ display: 'flex', gap: 0.5, flexWrap: 'wrap' }}>
|
||||
{selected.map((selectedElement, idx) => {
|
||||
const value = selectedElement.value
|
||||
const member = members.find(
|
||||
m => String(m.userId) === String(value),
|
||||
)
|
||||
return (
|
||||
<Chip key={`${value}-${idx}`} size='sm'>
|
||||
{member?.displayName || member?.username || 'Unknown'}
|
||||
</Chip>
|
||||
)
|
||||
})}
|
||||
</Box>
|
||||
)}
|
||||
>
|
||||
{members.map((member, idx) => (
|
||||
<Option
|
||||
key={`member-${member.userId}-${idx}`}
|
||||
value={member.userId}
|
||||
>
|
||||
{member.displayName || member.username} ({member.userId})
|
||||
</Option>
|
||||
))}
|
||||
</Select>
|
||||
)
|
||||
|
||||
case 'createdBy':
|
||||
return (
|
||||
<Select
|
||||
multiple
|
||||
value={condition.value || []}
|
||||
onChange={(_, newValue) =>
|
||||
updateCondition(index, 'value', newValue)
|
||||
}
|
||||
placeholder='Select creators'
|
||||
sx={{ width: '100%' }}
|
||||
slotProps={{
|
||||
listbox: {
|
||||
placement: 'bottom-start',
|
||||
disablePortal: false,
|
||||
},
|
||||
}}
|
||||
renderValue={selected => (
|
||||
<Box sx={{ display: 'flex', gap: 0.5, flexWrap: 'wrap' }}>
|
||||
{selected.map((selectedElement, idx) => {
|
||||
const value = selectedElement.value
|
||||
|
||||
const member = members.find(
|
||||
m => String(m.userId) === String(value),
|
||||
)
|
||||
return (
|
||||
<Chip key={`${value}-${idx}`} size='sm'>
|
||||
{member?.displayName || member?.username || 'Unknown'}
|
||||
</Chip>
|
||||
)
|
||||
})}
|
||||
</Box>
|
||||
)}
|
||||
>
|
||||
{members.map((member, idx) => (
|
||||
<Option
|
||||
key={`creator-${member.userId}-${idx}`}
|
||||
value={member.userId}
|
||||
>
|
||||
{member.displayName || member.username}
|
||||
</Option>
|
||||
))}
|
||||
</Select>
|
||||
)
|
||||
|
||||
case 'priority':
|
||||
return (
|
||||
<Select
|
||||
multiple
|
||||
value={condition.value || []}
|
||||
onChange={(_, newValue) =>
|
||||
updateCondition(index, 'value', newValue)
|
||||
}
|
||||
placeholder='Select priorities'
|
||||
sx={{ width: '100%' }}
|
||||
slotProps={{
|
||||
listbox: {
|
||||
placement: 'bottom-start',
|
||||
disablePortal: false,
|
||||
},
|
||||
}}
|
||||
renderValue={selected => (
|
||||
<Box sx={{ display: 'flex', gap: 0.5, flexWrap: 'wrap' }}>
|
||||
{selected.map((selectedElement, idx) => {
|
||||
const value = selectedElement.value
|
||||
const priority = Priorities.find(p => p.value === value)
|
||||
return (
|
||||
<Chip key={`priority-${value}-${idx}`} size='sm'>
|
||||
{priority?.name || `Priority ${value}`}
|
||||
</Chip>
|
||||
)
|
||||
})}
|
||||
</Box>
|
||||
)}
|
||||
>
|
||||
{Priorities.map((priority, idx) => (
|
||||
<Option
|
||||
key={`priority-opt-${priority.value}-${idx}`}
|
||||
value={priority.value}
|
||||
>
|
||||
{priority.name}
|
||||
</Option>
|
||||
))}
|
||||
</Select>
|
||||
)
|
||||
|
||||
case 'label':
|
||||
return (
|
||||
<Select
|
||||
multiple
|
||||
value={condition.value || []}
|
||||
onChange={(_, newValue) =>
|
||||
updateCondition(index, 'value', newValue)
|
||||
}
|
||||
placeholder='Select labels'
|
||||
sx={{ width: '100%' }}
|
||||
slotProps={{
|
||||
listbox: {
|
||||
placement: 'bottom-start',
|
||||
disablePortal: false,
|
||||
},
|
||||
}}
|
||||
renderValue={selected => (
|
||||
<Box sx={{ display: 'flex', gap: 0.5, flexWrap: 'wrap' }}>
|
||||
{selected.map((selectedElement, idx) => {
|
||||
const value = selectedElement.value
|
||||
|
||||
const label = labels.find(l => String(l.id) === String(value))
|
||||
return (
|
||||
<Chip key={`label-chip-${value}-${idx}`} size='sm'>
|
||||
{label?.name || 'Unknown'}
|
||||
</Chip>
|
||||
)
|
||||
})}
|
||||
</Box>
|
||||
)}
|
||||
>
|
||||
{labels.map((label, idx) => (
|
||||
<Option key={`label-opt-${label.id}-${idx}`} value={label.id}>
|
||||
{label.name}
|
||||
</Option>
|
||||
))}
|
||||
</Select>
|
||||
)
|
||||
|
||||
case 'project':
|
||||
return (
|
||||
<Select
|
||||
multiple
|
||||
value={condition.value || []}
|
||||
onChange={(_, newValue) =>
|
||||
updateCondition(index, 'value', newValue)
|
||||
}
|
||||
placeholder='Select projects'
|
||||
sx={{ width: '100%' }}
|
||||
slotProps={{
|
||||
listbox: {
|
||||
placement: 'bottom-start',
|
||||
disablePortal: false,
|
||||
},
|
||||
}}
|
||||
renderValue={selected => (
|
||||
<Box sx={{ display: 'flex', gap: 0.5, flexWrap: 'wrap' }}>
|
||||
{selected.map((event, idx) => {
|
||||
const value = event.value
|
||||
if (value === 'default')
|
||||
return (
|
||||
<Chip key={`default-${idx}`} size='sm'>
|
||||
Default
|
||||
</Chip>
|
||||
)
|
||||
const project = projects.find(
|
||||
p => String(p.id) === String(value),
|
||||
)
|
||||
return (
|
||||
<Chip key={`project-chip-${value}-${idx}`} size='sm'>
|
||||
{project?.name || 'Unknown'}
|
||||
</Chip>
|
||||
)
|
||||
})}
|
||||
</Box>
|
||||
)}
|
||||
>
|
||||
<Option value='default'>Default Project</Option>
|
||||
{projects
|
||||
.filter(p => p.id !== 'default')
|
||||
.map((project, idx) => (
|
||||
<Option
|
||||
key={`project-opt-${project.id}-${idx}`}
|
||||
value={project.id}
|
||||
>
|
||||
{project.name}
|
||||
</Option>
|
||||
))}
|
||||
</Select>
|
||||
)
|
||||
|
||||
case 'status':
|
||||
return (
|
||||
<Select
|
||||
multiple
|
||||
value={condition.value || []}
|
||||
onChange={(_, newValue) =>
|
||||
updateCondition(index, 'value', newValue)
|
||||
}
|
||||
placeholder='Select statuses'
|
||||
sx={{ width: '100%' }}
|
||||
slotProps={{
|
||||
listbox: {
|
||||
placement: 'bottom-start',
|
||||
disablePortal: false,
|
||||
},
|
||||
}}
|
||||
renderValue={selected => (
|
||||
<Box sx={{ display: 'flex', gap: 0.5, flexWrap: 'wrap' }}>
|
||||
{selected.map((selectedElement, idx) => {
|
||||
const value = selectedElement.value
|
||||
const statusLabels = {
|
||||
0: 'Active',
|
||||
1: 'Started',
|
||||
2: 'In Progress',
|
||||
3: 'Pending Approval',
|
||||
}
|
||||
return (
|
||||
<Chip key={`status-chip-${value}-${idx}`} size='sm'>
|
||||
{statusLabels[value] || 'Unknown'}
|
||||
</Chip>
|
||||
)
|
||||
})}
|
||||
</Box>
|
||||
)}
|
||||
>
|
||||
<Option value={0}>Active</Option>
|
||||
<Option value={1}>Started</Option>
|
||||
<Option value={2}>In Progress</Option>
|
||||
<Option value={3}>Pending Approval</Option>
|
||||
</Select>
|
||||
)
|
||||
|
||||
case 'dueDate':
|
||||
return (
|
||||
<Select
|
||||
value={condition.operator}
|
||||
onChange={(_, newValue) =>
|
||||
updateCondition(index, 'operator', newValue)
|
||||
}
|
||||
sx={{ width: '100%' }}
|
||||
slotProps={{
|
||||
listbox: {
|
||||
placement: 'bottom-start',
|
||||
disablePortal: false,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Option value='isOverdue'>Is Overdue</Option>
|
||||
<Option value='isDueToday'>Is Due Today</Option>
|
||||
<Option value='isDueTomorrow'>Is Due Tomorrow</Option>
|
||||
<Option value='isDueThisWeek'>Is Due This Week</Option>
|
||||
<Option value='isDueThisMonth'>Is Due This Month</Option>
|
||||
<Option value='hasNoDueDate'>Has No Due Date</Option>
|
||||
<Option value='hasDueDate'>Has Due Date</Option>
|
||||
</Select>
|
||||
)
|
||||
|
||||
case 'points':
|
||||
return (
|
||||
<Box sx={{ display: 'flex', gap: 1, width: '100%' }}>
|
||||
<Select
|
||||
value={condition.operator}
|
||||
onChange={(_, newValue) =>
|
||||
updateCondition(index, 'operator', newValue)
|
||||
}
|
||||
sx={{ flex: 1 }}
|
||||
slotProps={{
|
||||
listbox: {
|
||||
placement: 'bottom-start',
|
||||
disablePortal: false,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Option value='equals'>Equals</Option>
|
||||
<Option value='greaterThan'>Greater Than</Option>
|
||||
<Option value='lessThan'>Less Than</Option>
|
||||
<Option value='greaterThanOrEqual'>Greater Than or Equal</Option>
|
||||
<Option value='lessThanOrEqual'>Less Than or Equal</Option>
|
||||
</Select>
|
||||
<Input
|
||||
type='number'
|
||||
value={condition.value ?? 0}
|
||||
onChange={e =>
|
||||
updateCondition(index, 'value', parseInt(e.target.value) || 0)
|
||||
}
|
||||
sx={{ flex: 1 }}
|
||||
slotProps={{
|
||||
input: {
|
||||
min: 0,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
)
|
||||
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<ResponsiveModal
|
||||
<BottomSheetModal
|
||||
open={isOpen}
|
||||
onClose={onClose}
|
||||
size='lg'
|
||||
fullWidth={true}
|
||||
title={editingFilter ? 'Edit Filter' : 'Create Advanced Filter'}
|
||||
maxHeight='92vh'
|
||||
title={
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
{editingFilter ? 'Edit Filter' : 'New Filter'}
|
||||
{activeConditionCount > 0 && (
|
||||
<Chip size='sm' variant='solid' color='primary'>
|
||||
{activeConditionCount} condition{activeConditionCount !== 1 ? 's' : ''}
|
||||
</Chip>
|
||||
)}
|
||||
</Box>
|
||||
}
|
||||
footer={
|
||||
<Box sx={{ display: 'flex', gap: 1, justifyContent: 'flex-end' }}>
|
||||
<Button variant='outlined' color='neutral' onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant='solid' color='primary' onClick={handleSave}>
|
||||
Save
|
||||
</Button>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: 1,
|
||||
}}
|
||||
>
|
||||
{/* Preview */}
|
||||
<Box sx={{ display: 'flex', gap: 1, flexShrink: 0 }}>
|
||||
{conditions.length > 0 ? (
|
||||
<>
|
||||
<Chip size='sm' variant='soft' color='neutral'>
|
||||
{previewCount} task{previewCount !== 1 ? 's' : ''}
|
||||
</Chip>
|
||||
{previewOverdueCount > 0 && (
|
||||
<Chip size='sm' variant='solid' color='danger'>
|
||||
{previewOverdueCount} overdue
|
||||
</Chip>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<Typography level='body-xs' sx={{ color: 'text.tertiary' }}>
|
||||
Add conditions to preview
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Actions */}
|
||||
<Box sx={{ display: 'flex', gap: 1 }}>
|
||||
<Button variant='plain' color='neutral' size='sm' onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant='solid'
|
||||
color='primary'
|
||||
size='sm'
|
||||
startDecorator={<Save sx={{ fontSize: 16 }} />}
|
||||
onClick={handleSave}
|
||||
>
|
||||
Save Filter
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 2,
|
||||
height: '100%',
|
||||
}}
|
||||
>
|
||||
<Box>
|
||||
<Typography level='body-sm' sx={{ mb: 1 }}>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0 }}>
|
||||
{/* Name */}
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Typography
|
||||
level='body-xs'
|
||||
sx={{ mb: 0.75, color: 'text.secondary', fontWeight: 600 }}
|
||||
>
|
||||
Filter Name
|
||||
</Typography>
|
||||
<Input
|
||||
placeholder='e.g. Important Tasks due soon, Tasks for John, etc.'
|
||||
placeholder='e.g. Overdue tasks for Alice'
|
||||
value={filterName}
|
||||
onChange={e => {
|
||||
setFilterName(e.target.value)
|
||||
@@ -560,253 +181,57 @@ const AdvancedFilterBuilder = ({
|
||||
autoFocus
|
||||
/>
|
||||
{error && (
|
||||
<Typography level='body-sm' color='danger' sx={{ mt: 0.5 }}>
|
||||
<Typography level='body-xs' color='danger' sx={{ mt: 0.5 }}>
|
||||
{error}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<Typography level='body-sm' sx={{ mb: 1 }}>
|
||||
Description (Optional)
|
||||
</Typography>
|
||||
<Textarea
|
||||
placeholder='Optional description for this filter...'
|
||||
value={filterDescription}
|
||||
onChange={e => setFilterDescription(e.target.value)}
|
||||
minRows={2}
|
||||
maxRows={3}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<Typography level='body-sm' sx={{ mb: 1 }}>
|
||||
{/* Color */}
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Typography
|
||||
level='body-xs'
|
||||
sx={{ mb: 0.75, color: 'text.secondary', fontWeight: 600 }}
|
||||
>
|
||||
Color
|
||||
</Typography>
|
||||
<Select
|
||||
value={filterColor}
|
||||
onChange={(_, value) => value && setFilterColor(value)}
|
||||
slotProps={{
|
||||
listbox: {
|
||||
placement: 'bottom-start',
|
||||
disablePortal: false,
|
||||
},
|
||||
}}
|
||||
renderValue={selected => (
|
||||
<Typography
|
||||
startDecorator={
|
||||
<Box
|
||||
sx={{
|
||||
width: 16,
|
||||
height: 16,
|
||||
borderRadius: '50%',
|
||||
background: selected.value,
|
||||
}}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{selected.label}
|
||||
</Typography>
|
||||
)}
|
||||
>
|
||||
{FILTER_COLORS.map(color => (
|
||||
<Option key={color.value} value={color.value}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Box
|
||||
sx={{
|
||||
width: 20,
|
||||
height: 20,
|
||||
borderRadius: '50%',
|
||||
background: color.value,
|
||||
}}
|
||||
/>
|
||||
<Typography>{color.name}</Typography>
|
||||
</Box>
|
||||
</Option>
|
||||
))}
|
||||
</Select>
|
||||
</Box>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
}}
|
||||
>
|
||||
<Typography level='body-sm' sx={{ mb: 1 }}>
|
||||
Filter Conditions (All must match)
|
||||
</Typography>
|
||||
|
||||
<List
|
||||
ref={listContainerRef}
|
||||
sx={{
|
||||
gap: 1,
|
||||
overflowY: 'auto',
|
||||
overflowX: 'hidden',
|
||||
maxHeight: { xs: '40vh', sm: '50vh' },
|
||||
pr: 0.5,
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
{conditions.map((condition, index) => (
|
||||
<ListItem
|
||||
key={index}
|
||||
ref={el => (conditionRefs.current[index] = el)}
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
|
||||
{FILTER_COLORS.map(c => (
|
||||
<Box
|
||||
key={c.value}
|
||||
title={c.name}
|
||||
onClick={() => setFilterColor(c.value)}
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 1,
|
||||
p: 1.5,
|
||||
bgcolor: 'background.level1',
|
||||
borderRadius: 'sm',
|
||||
position: 'relative',
|
||||
width: 26,
|
||||
height: 26,
|
||||
borderRadius: '50%',
|
||||
background: c.value,
|
||||
cursor: 'pointer',
|
||||
outline:
|
||||
filterColor === c.value
|
||||
? '3px solid var(--joy-palette-primary-500)'
|
||||
: '2px solid transparent',
|
||||
outlineOffset: '2px',
|
||||
transition: 'all 0.15s ease',
|
||||
flexShrink: 0,
|
||||
'&:hover': { transform: 'scale(1.2)' },
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
width: '100%',
|
||||
}}
|
||||
>
|
||||
<Typography level='body-xs' color='neutral'>
|
||||
Condition {index + 1}
|
||||
</Typography>
|
||||
<IconButton
|
||||
size='sm'
|
||||
color='danger'
|
||||
variant='plain'
|
||||
onClick={() => removeCondition(index)}
|
||||
disabled={conditions.length === 1}
|
||||
>
|
||||
<Delete />
|
||||
</IconButton>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ width: '100%' }}>
|
||||
<Typography level='body-xs' sx={{ mb: 0.5 }}>
|
||||
Field
|
||||
</Typography>
|
||||
<Select
|
||||
value={condition.type}
|
||||
onChange={(_, newValue) =>
|
||||
updateCondition(index, 'type', newValue)
|
||||
}
|
||||
sx={{ width: '100%' }}
|
||||
slotProps={{
|
||||
listbox: {
|
||||
placement: 'bottom-start',
|
||||
disablePortal: false,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Option value='assignee'>Assignee</Option>
|
||||
<Option value='createdBy'>Created By</Option>
|
||||
<Option value='priority'>Priority</Option>
|
||||
<Option value='label'>Label</Option>
|
||||
<Option value='project'>Project</Option>
|
||||
<Option value='status'>Status</Option>
|
||||
<Option value='dueDate'>Due Date</Option>
|
||||
<Option value='points'>Points</Option>
|
||||
</Select>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ width: '100%' }}>
|
||||
<Typography level='body-xs' sx={{ mb: 0.5 }}>
|
||||
{condition.type === 'dueDate' || condition.type === 'points'
|
||||
? 'Condition'
|
||||
: 'Value'}
|
||||
</Typography>
|
||||
{renderValueSelector(condition, index)}
|
||||
</Box>
|
||||
</ListItem>
|
||||
/>
|
||||
))}
|
||||
</List>
|
||||
|
||||
<Button
|
||||
size='sm'
|
||||
variant='outlined'
|
||||
startDecorator={<Add />}
|
||||
onClick={addCondition}
|
||||
sx={{ mt: 1 }}
|
||||
>
|
||||
Add Condition
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
mb: 1,
|
||||
}}
|
||||
>
|
||||
<Typography level='body-sm'>Preview</Typography>
|
||||
<Box sx={{ display: 'flex', gap: 1 }}>
|
||||
<Chip size='sm' variant='soft' color='neutral'>
|
||||
{previewCount} tasks
|
||||
</Chip>
|
||||
{previewOverdueCount > 0 && (
|
||||
<Chip size='sm' variant='solid' color='danger'>
|
||||
{previewOverdueCount} overdue
|
||||
</Chip>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
maxHeight: 150,
|
||||
overflowY: 'auto',
|
||||
overflowX: 'hidden',
|
||||
bgcolor: 'background.level1',
|
||||
p: 1,
|
||||
borderRadius: 'sm',
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
{previewCount === 0 ? (
|
||||
<Typography
|
||||
level='body-sm'
|
||||
color='neutral'
|
||||
sx={{ textAlign: 'center', py: 2 }}
|
||||
>
|
||||
No tasks match these filters
|
||||
</Typography>
|
||||
) : (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||
{previewChores.slice(0, 3).map(chore => (
|
||||
<Box
|
||||
key={chore.id}
|
||||
sx={{
|
||||
bgcolor: 'background.surface',
|
||||
p: 1,
|
||||
borderRadius: 'sm',
|
||||
}}
|
||||
>
|
||||
<Typography level='body-sm'>{chore.name}</Typography>
|
||||
</Box>
|
||||
))}
|
||||
{previewCount > 3 && (
|
||||
<Typography
|
||||
level='body-xs'
|
||||
color='neutral'
|
||||
sx={{ textAlign: 'center', mt: 0.5 }}
|
||||
>
|
||||
...and {previewCount - 3} more
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Divider sx={{ mb: 2.5 }} />
|
||||
|
||||
<FilterBuilderContent
|
||||
selections={selections}
|
||||
onSelectionsChange={setSelections}
|
||||
members={members}
|
||||
labels={labels}
|
||||
projects={projects}
|
||||
/>
|
||||
</Box>
|
||||
</ResponsiveModal>
|
||||
</BottomSheetModal>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
138
src/views/Modals/Inputs/AttachmentBrowserModal.jsx
Normal file
138
src/views/Modals/Inputs/AttachmentBrowserModal.jsx
Normal file
@@ -0,0 +1,138 @@
|
||||
import { AttachFile, Close, Image } from '@mui/icons-material'
|
||||
import { Box, Button, CircularProgress, List, ListItem, ListItemButton, Typography } from '@mui/joy'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useResponsiveModal } from '../../../hooks/useResponsiveModal'
|
||||
import { GetChoreAttachments } from '../../../utils/Fetcher'
|
||||
import { resolvePhotoURL } from '../../../utils/Helpers'
|
||||
import AttachmentViewerModal from './AttachmentViewerModal'
|
||||
|
||||
const IMAGE_EXTENSIONS = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp', 'svg']
|
||||
|
||||
const isImageFile = fileName => {
|
||||
if (!fileName) return false
|
||||
const ext = fileName.split('.').pop().toLowerCase()
|
||||
return IMAGE_EXTENSIONS.includes(ext)
|
||||
}
|
||||
|
||||
const downloadFile = (url, fileName) => {
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = fileName || 'attachment'
|
||||
a.rel = 'noopener'
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
}
|
||||
|
||||
function AttachmentBrowserModal({ choreId, isOpen, onClose }) {
|
||||
const { ResponsiveModal } = useResponsiveModal()
|
||||
const [attachments, setAttachments] = useState([])
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [viewerConfig, setViewerConfig] = useState({ isOpen: false })
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen || !choreId) return
|
||||
setIsLoading(true)
|
||||
GetChoreAttachments(choreId)
|
||||
.then(async res => {
|
||||
if (!res.ok) throw new Error('Failed to fetch attachments')
|
||||
return res.json()
|
||||
})
|
||||
.then(data => setAttachments(Array.isArray(data) ? data : []))
|
||||
.catch(() => setAttachments([]))
|
||||
.finally(() => setIsLoading(false))
|
||||
}, [isOpen, choreId])
|
||||
|
||||
const handleClose = () => {
|
||||
setAttachments([])
|
||||
onClose?.()
|
||||
}
|
||||
|
||||
const handleAttachmentClick = attachment => {
|
||||
const url = resolvePhotoURL(attachment.sign)
|
||||
if (isImageFile(attachment.file_name)) {
|
||||
setViewerConfig({
|
||||
isOpen: true,
|
||||
url,
|
||||
fileName: attachment.file_name,
|
||||
onClose: () => setViewerConfig({ isOpen: false }),
|
||||
})
|
||||
} else {
|
||||
downloadFile(url, attachment.file_name)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<ResponsiveModal
|
||||
open={!!isOpen}
|
||||
onClose={handleClose}
|
||||
title='Attachments'
|
||||
footer={
|
||||
<Box sx={{ display: 'flex', justifyContent: 'flex-end' }}>
|
||||
<Button
|
||||
variant='plain'
|
||||
color='neutral'
|
||||
startDecorator={<Close />}
|
||||
onClick={handleClose}
|
||||
>
|
||||
Close
|
||||
</Button>
|
||||
</Box>
|
||||
}
|
||||
>
|
||||
{isLoading ? (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}>
|
||||
<CircularProgress size='md' />
|
||||
</Box>
|
||||
) : attachments.length === 0 ? (
|
||||
<Typography
|
||||
level='body-sm'
|
||||
sx={{ color: 'text.secondary', py: 2, textAlign: 'center' }}
|
||||
>
|
||||
No attachments found.
|
||||
</Typography>
|
||||
) : (
|
||||
<List sx={{ '--ListItem-paddingX': '0px' }}>
|
||||
{attachments.map((attachment, index) => (
|
||||
<ListItem
|
||||
key={
|
||||
attachment.id ||
|
||||
attachment.file_path ||
|
||||
attachment.sign ||
|
||||
attachment.file_name ||
|
||||
index
|
||||
}
|
||||
sx={{ p: 0 }}
|
||||
>
|
||||
<ListItemButton
|
||||
onClick={() => handleAttachmentClick(attachment)}
|
||||
sx={{ borderRadius: 'sm', gap: 1.5, py: 1 }}
|
||||
>
|
||||
{isImageFile(attachment.file_name) ? (
|
||||
<Image fontSize='small' />
|
||||
) : (
|
||||
<AttachFile fontSize='small' />
|
||||
)}
|
||||
<Box sx={{ flex: 1, minWidth: 0 }}>
|
||||
<Typography level='body-sm' noWrap>
|
||||
{attachment.file_name || `File ${index + 1}`}
|
||||
</Typography>
|
||||
{attachment.size_bytes > 0 && (
|
||||
<Typography level='body-xs' sx={{ color: 'text.tertiary' }}>
|
||||
{(attachment.size_bytes / 1024).toFixed(1)} KB
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
</ListItemButton>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
)}
|
||||
</ResponsiveModal>
|
||||
<AttachmentViewerModal config={viewerConfig} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default AttachmentBrowserModal
|
||||
116
src/views/Modals/Inputs/AttachmentViewerModal.jsx
Normal file
116
src/views/Modals/Inputs/AttachmentViewerModal.jsx
Normal file
@@ -0,0 +1,116 @@
|
||||
import { Browser } from '@capacitor/browser'
|
||||
import { Capacitor } from '@capacitor/core'
|
||||
import { Close, Download } from '@mui/icons-material'
|
||||
import { Box, Button, CircularProgress, Typography } from '@mui/joy'
|
||||
import { useState } from 'react'
|
||||
import { useResponsiveModal } from '../../../hooks/useResponsiveModal'
|
||||
|
||||
const openUrl = async url => {
|
||||
if (Capacitor.isNativePlatform()) {
|
||||
await Browser.open({ url })
|
||||
} else {
|
||||
window.open(url, '_blank', 'noopener,noreferrer')
|
||||
}
|
||||
}
|
||||
|
||||
const downloadUrl = (url, fileName) => {
|
||||
if (Capacitor.isNativePlatform()) {
|
||||
Browser.open({ url })
|
||||
} else {
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = fileName || 'attachment'
|
||||
a.rel = 'noopener'
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
}
|
||||
}
|
||||
|
||||
function AttachmentViewerModal({ config }) {
|
||||
const { ResponsiveModal } = useResponsiveModal()
|
||||
const [imgLoaded, setImgLoaded] = useState(false)
|
||||
const [imgError, setImgError] = useState(false)
|
||||
|
||||
const { isOpen, url, fileName, onClose } = config || {}
|
||||
|
||||
const handleClose = () => {
|
||||
setImgLoaded(false)
|
||||
setImgError(false)
|
||||
onClose?.()
|
||||
}
|
||||
|
||||
return (
|
||||
<ResponsiveModal
|
||||
open={!!isOpen}
|
||||
onClose={handleClose}
|
||||
title={fileName || 'Attachment'}
|
||||
maxHeight='92vh'
|
||||
footer={
|
||||
<Box sx={{ display: 'flex', gap: 1, justifyContent: 'flex-end' }}>
|
||||
<Button
|
||||
variant='plain'
|
||||
color='neutral'
|
||||
startDecorator={<Close />}
|
||||
onClick={handleClose}
|
||||
>
|
||||
Close
|
||||
</Button>
|
||||
<Button
|
||||
variant='soft'
|
||||
color='neutral'
|
||||
startDecorator={<Download />}
|
||||
onClick={() => downloadUrl(url, fileName)}
|
||||
disabled={!url}
|
||||
>
|
||||
Download
|
||||
</Button>
|
||||
</Box>
|
||||
}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
minHeight: 200,
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
{!imgLoaded && !imgError && (
|
||||
<CircularProgress
|
||||
sx={{ position: 'absolute' }}
|
||||
size='md'
|
||||
/>
|
||||
)}
|
||||
{imgError ? (
|
||||
<Typography level='body-sm' sx={{ color: 'text.secondary' }}>
|
||||
Failed to load image.
|
||||
</Typography>
|
||||
) : (
|
||||
<Box
|
||||
component='img'
|
||||
src={url}
|
||||
alt={fileName}
|
||||
onClick={() => url && openUrl(url)}
|
||||
onLoad={() => setImgLoaded(true)}
|
||||
onError={() => {
|
||||
setImgLoaded(true)
|
||||
setImgError(true)
|
||||
}}
|
||||
sx={{
|
||||
cursor: url ? 'zoom-in' : 'default',
|
||||
maxWidth: '100%',
|
||||
maxHeight: '65vh',
|
||||
borderRadius: 'md',
|
||||
objectFit: 'contain',
|
||||
display: imgLoaded && !imgError ? 'block' : 'none',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
</ResponsiveModal>
|
||||
)
|
||||
}
|
||||
|
||||
export default AttachmentViewerModal
|
||||
@@ -1,114 +1,175 @@
|
||||
import { CopyAll } from '@mui/icons-material'
|
||||
import { Box, Button, Checkbox, Input, ListItem, Typography } from '@mui/joy'
|
||||
import { useState } from 'react'
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Checkbox,
|
||||
CircularProgress,
|
||||
Input,
|
||||
ListItem,
|
||||
Typography,
|
||||
} from '@mui/joy'
|
||||
import { useRef, useState } from 'react'
|
||||
import { Capacitor } from '@capacitor/core'
|
||||
import { useResponsiveModal } from '../../../hooks/useResponsiveModal'
|
||||
import { startNativeNFCWrite } from '../../../service/NFCWriter'
|
||||
|
||||
function WriteNFCModal({ config }) {
|
||||
const { ResponsiveModal } = useResponsiveModal()
|
||||
|
||||
const [nfcStatus, setNfcStatus] = useState('idle') // 'idle', 'writing', 'success', 'error'
|
||||
const [nfcStatus, setNfcStatus] = useState('idle') // 'idle' | 'writing' | 'waiting_for_tag' | 'success' | 'error'
|
||||
const [errorMessage, setErrorMessage] = useState('')
|
||||
const [isAutoCompleteWhenScan, setIsAutoCompleteWhenScan] = useState(false)
|
||||
const cancelScanRef = useRef(null)
|
||||
const isNative = Capacitor.isNativePlatform()
|
||||
|
||||
const requestNFCAccess = async () => {
|
||||
if ('NDEFReader' in window) {
|
||||
// Assuming permission request is implicit in 'write' or 'scan' methods
|
||||
setNfcStatus('idle')
|
||||
} else {
|
||||
alert('NFC is not supported by this browser.')
|
||||
}
|
||||
const getURL = () => {
|
||||
let url = config.url
|
||||
if (isAutoCompleteWhenScan) url += '?auto_complete=true'
|
||||
return url
|
||||
}
|
||||
|
||||
const writeToNFC = async url => {
|
||||
if ('NDEFReader' in window) {
|
||||
try {
|
||||
const ndef = new window.NDEFReader()
|
||||
await ndef.write({
|
||||
records: [{ recordType: 'url', data: url }],
|
||||
})
|
||||
setNfcStatus('success')
|
||||
} catch (error) {
|
||||
console.error('Error writing to NFC tag:', error)
|
||||
setNfcStatus('error')
|
||||
setErrorMessage('Error writing to NFC tag. Please try again.')
|
||||
}
|
||||
} else {
|
||||
setNfcStatus('error')
|
||||
setErrorMessage(
|
||||
'NFC is not supported by this browser. You can still copy the URL and write it to an NFC tag using a compatible device.',
|
||||
)
|
||||
const handleClose = async () => {
|
||||
if (cancelScanRef.current) {
|
||||
await cancelScanRef.current()
|
||||
cancelScanRef.current = null
|
||||
}
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
config.onClose()
|
||||
setNfcStatus('idle')
|
||||
setErrorMessage('')
|
||||
}
|
||||
const getURL = () => {
|
||||
let url = config.url
|
||||
if (isAutoCompleteWhenScan) {
|
||||
url = url + '?auto_complete=true'
|
||||
|
||||
const handleCancel = async () => {
|
||||
if (cancelScanRef.current) {
|
||||
await cancelScanRef.current()
|
||||
cancelScanRef.current = null
|
||||
}
|
||||
setNfcStatus('idle')
|
||||
}
|
||||
|
||||
const writeToNFC = async () => {
|
||||
const url = getURL()
|
||||
|
||||
if (isNative) {
|
||||
setNfcStatus('writing')
|
||||
const cancel = await startNativeNFCWrite(url, {
|
||||
onWaiting: () => setNfcStatus('waiting_for_tag'),
|
||||
onSuccess: () => {
|
||||
cancelScanRef.current = null
|
||||
setNfcStatus('success')
|
||||
},
|
||||
onError: msg => {
|
||||
cancelScanRef.current = null
|
||||
setNfcStatus('error')
|
||||
setErrorMessage(msg)
|
||||
},
|
||||
})
|
||||
cancelScanRef.current = cancel
|
||||
} else {
|
||||
if ('NDEFReader' in window) {
|
||||
try {
|
||||
setNfcStatus('writing')
|
||||
const ndef = new window.NDEFReader()
|
||||
await ndef.write({ records: [{ recordType: 'url', data: url }] })
|
||||
setNfcStatus('success')
|
||||
} catch (error) {
|
||||
console.error('Error writing to NFC tag:', error)
|
||||
setNfcStatus('error')
|
||||
setErrorMessage('Error writing to NFC tag. Please try again.')
|
||||
}
|
||||
} else {
|
||||
setNfcStatus('error')
|
||||
setErrorMessage(
|
||||
'NFC is not supported by this browser. You can still copy the URL and write it to an NFC tag using a compatible device.',
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const renderBody = () => {
|
||||
if (nfcStatus === 'success') {
|
||||
return (
|
||||
<Typography level='body-md' gutterBottom>
|
||||
URL written to NFC tag successfully!
|
||||
</Typography>
|
||||
)
|
||||
}
|
||||
|
||||
return url
|
||||
if (nfcStatus === 'waiting_for_tag') {
|
||||
return (
|
||||
<>
|
||||
<Box
|
||||
display='flex'
|
||||
flexDirection='column'
|
||||
alignItems='center'
|
||||
gap={2}
|
||||
py={3}
|
||||
>
|
||||
<CircularProgress size='lg' />
|
||||
<Typography level='body-md' textAlign='center'>
|
||||
Hold your device near the NFC tag
|
||||
</Typography>
|
||||
</Box>
|
||||
<Button
|
||||
variant='outlined'
|
||||
color='neutral'
|
||||
fullWidth
|
||||
onClick={handleCancel}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Typography level='body-md' gutterBottom>
|
||||
{nfcStatus === 'error'
|
||||
? errorMessage
|
||||
: 'Press the button below to write to NFC.'}
|
||||
</Typography>
|
||||
<Input
|
||||
value={getURL()}
|
||||
fullWidth
|
||||
readOnly
|
||||
label='URL'
|
||||
sx={{ mt: 1 }}
|
||||
endDecorator={
|
||||
<CopyAll
|
||||
sx={{ cursor: 'pointer' }}
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(getURL())
|
||||
alert('URL copied to clipboard!')
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<ListItem>
|
||||
<Checkbox
|
||||
checked={isAutoCompleteWhenScan}
|
||||
onChange={e => setIsAutoCompleteWhenScan(e.target.checked)}
|
||||
label='Auto-complete when scanned'
|
||||
/>
|
||||
</ListItem>
|
||||
<Box display='flex' justifyContent='space-around' mt={1}>
|
||||
<Button
|
||||
size='lg'
|
||||
onClick={writeToNFC}
|
||||
fullWidth
|
||||
disabled={nfcStatus === 'writing'}
|
||||
>
|
||||
Write NFC
|
||||
</Button>
|
||||
</Box>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<ResponsiveModal open={config?.isOpen} onClose={handleClose}>
|
||||
<Typography level='h4' mb={1}>
|
||||
{nfcStatus === 'success' ? 'Success!' : 'Write to NFC'}
|
||||
</Typography>
|
||||
|
||||
{nfcStatus === 'success' ? (
|
||||
<Typography level='body-md' gutterBottom>
|
||||
URL written to NFC tag successfully!
|
||||
</Typography>
|
||||
) : (
|
||||
<>
|
||||
<Typography level='body-md' gutterBottom>
|
||||
{nfcStatus === 'error'
|
||||
? errorMessage
|
||||
: 'Press the button below to write to NFC.'}
|
||||
</Typography>
|
||||
<Input
|
||||
value={getURL()}
|
||||
fullWidth
|
||||
readOnly
|
||||
label='URL'
|
||||
sx={{ mt: 1 }}
|
||||
endDecorator={
|
||||
<CopyAll
|
||||
sx={{ cursor: 'pointer' }}
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(getURL())
|
||||
alert('URL copied to clipboard!')
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<ListItem>
|
||||
<Checkbox
|
||||
checked={isAutoCompleteWhenScan}
|
||||
onChange={e => setIsAutoCompleteWhenScan(e.target.checked)}
|
||||
label='Auto-complete when scanned'
|
||||
/>
|
||||
</ListItem>
|
||||
<Box display={'flex'} justifyContent={'space-around'} mt={1}>
|
||||
<Button
|
||||
size='lg'
|
||||
onClick={() => writeToNFC(getURL())}
|
||||
fullWidth
|
||||
sx={{ mr: 1 }}
|
||||
disabled={nfcStatus === 'writing'}
|
||||
>
|
||||
Write NFC
|
||||
</Button>
|
||||
<Button size='lg' onClick={requestNFCAccess} variant='outlined'>
|
||||
Request Access
|
||||
</Button>
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
{renderBody()}
|
||||
</ResponsiveModal>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import React from 'react'
|
||||
|
||||
const PrivacyPolicyView = () => {
|
||||
return (
|
||||
<div>
|
||||
|
||||
@@ -29,7 +29,10 @@ const AccountSettings = () => {
|
||||
async function configurePurchases() {
|
||||
if (Capacitor.isNativePlatform() && userProfile) {
|
||||
await Purchases.configure({
|
||||
apiKey: import.meta.env.VITE_REACT_APP_REVENUECAT_API_KEY,
|
||||
apiKey:
|
||||
Capacitor.getPlatform() === 'ios'
|
||||
? import.meta.env.VITE_REACT_APP_REVENUECAT_API_KEY_IOS
|
||||
: import.meta.env.VITE_REACT_APP_REVENUECAT_API_KEY_ANDROID,
|
||||
appUserID: String(userProfile?.id),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -25,13 +25,12 @@ import SettingsLayout from './SettingsLayout'
|
||||
const ProfileSettings = () => {
|
||||
const { t } = useTranslation('settings')
|
||||
const queryClient = useQueryClient()
|
||||
const { data: userProfile } = useUserProfile()
|
||||
const { data: userProfile, refetch: refetchUserProfile } = useUserProfile()
|
||||
const { showSuccess, showError } = useNotification()
|
||||
const [displayName, setDisplayName] = useState(userProfile?.displayName || '')
|
||||
const [timezone, setTimezone] = useState(
|
||||
userProfile?.timezone || Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||
)
|
||||
const [photoURL, setPhotoURL] = useState(userProfile?.image || '')
|
||||
const [isUploading, setIsUploading] = useState(false)
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
const fileInputRef = useRef()
|
||||
@@ -89,10 +88,9 @@ const ProfileSettings = () => {
|
||||
formData.append('file', compressedFile, 'profile.jpg')
|
||||
const response = await apiClient.upload('/users/profile_photo', formData)
|
||||
if (!response.ok) throw new Error('Upload failed')
|
||||
const data = await response.json()
|
||||
const url = resolvePhotoURL(data.url || data.sign)
|
||||
await response.json()
|
||||
|
||||
setPhotoURL(url)
|
||||
refetchUserProfile() // Refresh user profile to get the new photoURL
|
||||
showSuccess({
|
||||
title: t('profile.photoUpdated'),
|
||||
message: t('profile.photoUpdatedMessage'),
|
||||
@@ -155,7 +153,7 @@ const ProfileSettings = () => {
|
||||
maxWidth: 400,
|
||||
}}
|
||||
>
|
||||
<Avatar src={photoURL} sx={{ width: 64, height: 64 }} />
|
||||
<Avatar src={resolvePhotoURL(userProfile?.image)} sx={{ width: 64, height: 64 }} />
|
||||
<Box sx={{ flex: 1 }}>
|
||||
<Button
|
||||
variant='soft'
|
||||
|
||||
@@ -139,7 +139,10 @@ const Settings = () => {
|
||||
async function configurePurchases() {
|
||||
if (Capacitor.isNativePlatform() && userProfile) {
|
||||
await Purchases.configure({
|
||||
apiKey: import.meta.env.VITE_REACT_APP_REVENUECAT_API_KEY,
|
||||
apiKey:
|
||||
Capacitor.getPlatform() === 'ios'
|
||||
? import.meta.env.VITE_REACT_APP_REVENUECAT_API_KEY_IOS
|
||||
: import.meta.env.VITE_REACT_APP_REVENUECAT_API_KEY_ANDROID,
|
||||
appUserID: String(userProfile?.id),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -2,14 +2,17 @@ import { Cell, Pie, PieChart, Tooltip } from 'recharts'
|
||||
|
||||
import {
|
||||
AccessTime,
|
||||
CalendarMonth,
|
||||
Check,
|
||||
Checklist,
|
||||
EventBusy,
|
||||
EventNote,
|
||||
Group,
|
||||
HourglassEmpty,
|
||||
Person,
|
||||
Redo,
|
||||
RunningWithErrors,
|
||||
Schedule,
|
||||
Style,
|
||||
ThumbDown,
|
||||
Timeline,
|
||||
Toll,
|
||||
@@ -24,23 +27,27 @@ import {
|
||||
Divider,
|
||||
Grid,
|
||||
Link,
|
||||
Option,
|
||||
Select,
|
||||
Stack,
|
||||
Tab,
|
||||
TabList,
|
||||
Tabs,
|
||||
Typography,
|
||||
} from '@mui/joy'
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import React, { useEffect, useMemo, useState } from 'react'
|
||||
import FilterBar from '../../components/common/FilterBar'
|
||||
import { useFilter } from '../../hooks/useFilter'
|
||||
|
||||
import { useLocalization } from '../../contexts/LocalizationContext'
|
||||
import { useChores, useChoresHistory } from '../../queries/ChoreQueries'
|
||||
import {
|
||||
useChores,
|
||||
useChoresHistory,
|
||||
useDeleteChoreHistory,
|
||||
useUpdateChoreHistory,
|
||||
} from '../../queries/ChoreQueries'
|
||||
import EditHistoryModal from '../Modals/EditHistoryModal'
|
||||
import HistoryDetailModal from '../Modals/HistoryDetailModal'
|
||||
import NoteViewerModal from '../Modals/Inputs/NoteViewerModal'
|
||||
import { useCircleMembers, useUserProfile } from '../../queries/UserQueries.jsx'
|
||||
import { useLabels } from '../Labels/LabelQueries'
|
||||
import { ChoresGrouper } from '../../utils/Chores'
|
||||
import { COLORS, TASK_COLOR } from '../../utils/Colors.jsx'
|
||||
import { resolvePhotoURL } from '../../utils/Helpers.jsx'
|
||||
import LoadingComponent from '../components/Loading'
|
||||
|
||||
const groupByDate = history => {
|
||||
@@ -58,47 +65,57 @@ const groupByDate = history => {
|
||||
return aggregated
|
||||
}
|
||||
|
||||
const ChoreHistoryItem = ({ time, name, points, status, performer, notes, onViewNote }) => {
|
||||
const getStatusIcon = status => {
|
||||
switch (status) {
|
||||
case 0:
|
||||
return <AccessTime color='primary' />
|
||||
case 1:
|
||||
return <Check color='success' />
|
||||
case 2:
|
||||
return <Redo color='warning' />
|
||||
case 3:
|
||||
return <HourglassEmpty color='neutral' />
|
||||
case 4:
|
||||
return <ThumbDown color='error' />
|
||||
case 5:
|
||||
return <RunningWithErrors color='error' />
|
||||
case 6:
|
||||
return <Schedule color='warning' />
|
||||
default:
|
||||
return <Check color='success' />
|
||||
}
|
||||
}
|
||||
const statusConfig = {
|
||||
0: { color: 'primary', icon: <AccessTime /> },
|
||||
1: { color: 'success', icon: <Check /> },
|
||||
2: { color: 'warning', icon: <Redo /> },
|
||||
3: { color: 'neutral', icon: <HourglassEmpty /> },
|
||||
4: { color: 'danger', icon: <ThumbDown /> },
|
||||
5: { color: 'danger', icon: <RunningWithErrors /> },
|
||||
6: { color: 'warning', icon: <Schedule /> },
|
||||
}
|
||||
|
||||
const ChoreHistoryItem = ({
|
||||
time,
|
||||
name,
|
||||
points,
|
||||
status,
|
||||
notes,
|
||||
onViewNote,
|
||||
onViewDetails,
|
||||
}) => {
|
||||
const cfg = statusConfig[status] ?? statusConfig[1]
|
||||
|
||||
return (
|
||||
<Stack direction='row' alignItems='center' spacing={2}>
|
||||
<Stack
|
||||
direction='row'
|
||||
alignItems='center'
|
||||
spacing={1}
|
||||
onClick={onViewDetails}
|
||||
sx={{
|
||||
cursor: onViewDetails ? 'pointer' : 'default',
|
||||
borderRadius: 'sm',
|
||||
'&:hover': onViewDetails
|
||||
? { backgroundColor: 'background.level1' }
|
||||
: {},
|
||||
}}
|
||||
>
|
||||
<Typography level='body-md' sx={{ minWidth: 80 }}>
|
||||
{time}
|
||||
</Typography>
|
||||
<Box
|
||||
<Avatar
|
||||
size='sm'
|
||||
color={cfg.color}
|
||||
variant='soft'
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
minWidth: 32,
|
||||
minHeight: 32,
|
||||
borderRadius: '50%',
|
||||
backgroundColor: 'background.level2',
|
||||
boxShadow: 'sm',
|
||||
width: 32,
|
||||
height: 32,
|
||||
flexShrink: 0,
|
||||
'& svg': { fontSize: '16px' },
|
||||
}}
|
||||
>
|
||||
{getStatusIcon(status)}
|
||||
</Box>
|
||||
{cfg.icon}
|
||||
</Avatar>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
@@ -143,25 +160,18 @@ const ChoreHistoryItem = ({ time, name, points, status, performer, notes, onView
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
const ChoreHistoryTimeline = ({ history, onViewNote }) => {
|
||||
const ChoreHistoryTimeline = ({
|
||||
history,
|
||||
performers,
|
||||
onViewNote,
|
||||
onViewDetails,
|
||||
}) => {
|
||||
const { fmt } = useLocalization()
|
||||
|
||||
const groupedHistory = groupByDate(history)
|
||||
|
||||
const sortedEntries = Object.entries(groupedHistory).sort(
|
||||
([a], [b]) => new Date(b) - new Date(a),
|
||||
)
|
||||
|
||||
return (
|
||||
<Container sx={{ p: 2 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 2 }}>
|
||||
<Timeline sx={{ fontSize: '1.5rem', color: 'primary.500' }} />
|
||||
<Typography level='h4' sx={{ fontWeight: 'lg', color: 'text.primary' }}>
|
||||
Activities Timeline
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ py: 2, width: '100%' }}>
|
||||
{Object.entries(groupedHistory).map(([date, items]) => (
|
||||
<Box key={date} sx={{ mb: 4 }}>
|
||||
<Typography level='title-sm' sx={{ mb: 0.5 }}>
|
||||
@@ -170,25 +180,21 @@ const ChoreHistoryTimeline = ({ history, onViewNote }) => {
|
||||
<Divider />
|
||||
<Stack spacing={1}>
|
||||
{items.map(record => (
|
||||
<>
|
||||
<ChoreHistoryItem
|
||||
key={record.id}
|
||||
|
||||
time={fmt.time(
|
||||
record.performedAt || record.updatedAt,
|
||||
)}
|
||||
name={record.choreName}
|
||||
points={record.points}
|
||||
status={record.status}
|
||||
notes={record.notes}
|
||||
onViewNote={onViewNote}
|
||||
/>
|
||||
</>
|
||||
<ChoreHistoryItem
|
||||
key={record.id}
|
||||
time={fmt.time(record.performedAt || record.updatedAt)}
|
||||
name={record.choreName}
|
||||
points={record.points}
|
||||
status={record.status}
|
||||
notes={record.notes}
|
||||
onViewNote={onViewNote}
|
||||
onViewDetails={() => onViewDetails?.(record, performers)}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
</Box>
|
||||
))}
|
||||
</Container>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -402,6 +408,11 @@ const UserActivites = () => {
|
||||
const [enrichedHistory, setEnrichedHistory] = React.useState([])
|
||||
const [selectedChart, setSelectedChart] = React.useState('history')
|
||||
const [noteViewerConfig, setNoteViewerConfig] = useState({ isOpen: false })
|
||||
const [detailModalConfig, setDetailModalConfig] = useState({ isOpen: false })
|
||||
const [editModalConfig, setEditModalConfig] = useState({ isOpen: false })
|
||||
const [editHistoryRecord, setEditHistoryRecord] = useState(null)
|
||||
const updateChoreHistory = useUpdateChoreHistory()
|
||||
const deleteChoreHistory = useDeleteChoreHistory()
|
||||
|
||||
const [historyPieChartData, setHistoryPieChartData] = React.useState([])
|
||||
const [choreDuePieChartData, setChoreDuePieChartData] = React.useState([])
|
||||
@@ -416,6 +427,7 @@ const UserActivites = () => {
|
||||
choresAssigneeBreakdownChartData,
|
||||
setChoresAssigneeBreakdownChartData,
|
||||
] = React.useState([])
|
||||
const { data: userLabels } = useLabels()
|
||||
const { data: choresData, isLoading: isChoresLoading } = useChores(true)
|
||||
const {
|
||||
data: choresHistory,
|
||||
@@ -432,6 +444,142 @@ const UserActivites = () => {
|
||||
}
|
||||
}, [circleMembersData])
|
||||
|
||||
// Client-side filters applied on top of the user+time-window slice
|
||||
const clientFilterDefs = useMemo(
|
||||
() => [
|
||||
{
|
||||
id: 'status',
|
||||
label: 'Status',
|
||||
type: 'multi-select',
|
||||
icon: <Checklist />,
|
||||
options: [
|
||||
{ value: 1, label: 'Completed', color: 'success', icon: <Check sx={{ fontSize: 14 }} /> },
|
||||
{ value: 2, label: 'Skipped', color: 'warning', icon: <Redo sx={{ fontSize: 14 }} /> },
|
||||
{ value: 3, label: 'Pending', color: 'neutral', icon: <HourglassEmpty sx={{ fontSize: 14 }} /> },
|
||||
{ value: 4, label: 'Rejected', color: 'danger', icon: <ThumbDown sx={{ fontSize: 14 }} /> },
|
||||
{ value: 5, label: 'Missed', color: 'danger', icon: <RunningWithErrors sx={{ fontSize: 14 }} /> },
|
||||
{ value: 6, label: 'Rescheduled', color: 'warning', icon: <Schedule sx={{ fontSize: 14 }} /> },
|
||||
],
|
||||
filterFn: (item, values) => values.includes(item.status),
|
||||
},
|
||||
...(userLabels?.length > 0
|
||||
? [
|
||||
{
|
||||
id: 'label',
|
||||
label: 'Labels',
|
||||
type: 'multi-select',
|
||||
icon: <Style />,
|
||||
options: userLabels.map(l => ({
|
||||
value: l.id,
|
||||
label: l.name,
|
||||
icon: (
|
||||
<Box
|
||||
component='span'
|
||||
sx={{
|
||||
display: 'inline-block',
|
||||
width: 10,
|
||||
height: 10,
|
||||
borderRadius: '50%',
|
||||
bgcolor: l.color || '#90a4ae',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
),
|
||||
})),
|
||||
filterFn: (item, values) =>
|
||||
item.labelsV2?.some(l => values.includes(l.id)) ?? false,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
id: 'hasNotes',
|
||||
label: 'Has Notes',
|
||||
type: 'boolean',
|
||||
icon: <EventNote />,
|
||||
filterFn: item => !!item.notes,
|
||||
},
|
||||
{
|
||||
id: 'hasPoints',
|
||||
label: 'Has Points',
|
||||
type: 'boolean',
|
||||
icon: <Toll />,
|
||||
filterFn: item => (item.points ?? 0) > 0,
|
||||
},
|
||||
],
|
||||
[userLabels],
|
||||
)
|
||||
|
||||
const {
|
||||
filteredData: filteredTimeline,
|
||||
activeFilters: clientActiveFilters,
|
||||
setFilter: setClientFilter,
|
||||
clearAll: clearClientFilters,
|
||||
} = useFilter(selectedHistory, clientFilterDefs)
|
||||
|
||||
// All filter defs merged for FilterBar display
|
||||
const filterDefs = useMemo(
|
||||
() => [
|
||||
{
|
||||
id: 'timePeriod',
|
||||
label: 'Time Period',
|
||||
type: 'single-select',
|
||||
icon: <CalendarMonth />,
|
||||
defaultValue: 7,
|
||||
options: [
|
||||
{ value: 7, label: '7 Days' },
|
||||
{ value: 30, label: '30 Days' },
|
||||
{ value: 90, label: '90 Days' },
|
||||
{ value: 365, label: 'All Time' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'completedBy',
|
||||
label: 'User',
|
||||
type: 'single-select',
|
||||
icon: <Person />,
|
||||
options: circleUsers.map(u => ({
|
||||
value: u.userId,
|
||||
label: u.displayName,
|
||||
avatar: u.image,
|
||||
})),
|
||||
},
|
||||
...clientFilterDefs,
|
||||
],
|
||||
[circleUsers, clientFilterDefs],
|
||||
)
|
||||
|
||||
// Merge server-driven and client-driven active filter states for the bar
|
||||
const activeFilters = useMemo(
|
||||
() => ({
|
||||
timePeriod: tabValue,
|
||||
...(selectedUser !== 'all' ? { completedBy: selectedUser } : {}),
|
||||
...clientActiveFilters,
|
||||
}),
|
||||
[tabValue, selectedUser, clientActiveFilters],
|
||||
)
|
||||
|
||||
const handleSetFilter = (id, value) => {
|
||||
if (id === 'completedBy') {
|
||||
const userId = value ?? 'all'
|
||||
setSelectedUser(userId)
|
||||
setSelectedHistory(enrichedHistory.filter(h => USER_FILTER(h, userId)))
|
||||
} else if (id === 'timePeriod') {
|
||||
const days = value ?? 7
|
||||
setTabValue(days)
|
||||
refetchHistory(days)
|
||||
} else {
|
||||
setClientFilter(id, value)
|
||||
}
|
||||
}
|
||||
|
||||
const handleClearAll = () => {
|
||||
setSelectedUser('all')
|
||||
setSelectedHistory(enrichedHistory)
|
||||
setTabValue(7)
|
||||
refetchHistory(7)
|
||||
clearClientFilters()
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
!isChoresHistoryLoading &&
|
||||
@@ -444,6 +592,7 @@ const UserActivites = () => {
|
||||
return {
|
||||
...item,
|
||||
choreName: chore?.name,
|
||||
labelsV2: chore?.labelsV2,
|
||||
}
|
||||
})
|
||||
setEnrichedHistory(enrichedHistory)
|
||||
@@ -823,222 +972,24 @@ const UserActivites = () => {
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 2 }}>
|
||||
{/* <EmojiEvents sx={{ fontSize: '2rem', color: '#FFD700' }} /> */}
|
||||
<Stack sx={{ flex: 1 }}>
|
||||
<Typography
|
||||
level='h3'
|
||||
sx={{ fontWeight: 'lg', color: 'text.primary' }}
|
||||
>
|
||||
User Activities
|
||||
</Typography>
|
||||
<Typography level='body-sm' sx={{ color: 'text.secondary' }}>
|
||||
Overview of user activities and task statistics
|
||||
</Typography>
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
{/* Filter Controls - Always visible */}
|
||||
<Card
|
||||
variant='outlined'
|
||||
sx={{
|
||||
width: '100%',
|
||||
p: 2,
|
||||
mb: 3,
|
||||
borderRadius: 12,
|
||||
background:
|
||||
'linear-gradient(135deg, rgba(255,255,255,0.1) 0%, rgba(255,255,255,0.05) 100%)',
|
||||
backdropFilter: 'blur(10px)',
|
||||
}}
|
||||
>
|
||||
<Stack spacing={2}>
|
||||
<Typography level='title-sm' sx={{ color: 'text.secondary' }}>
|
||||
Filter Activities
|
||||
</Typography>
|
||||
|
||||
<Stack
|
||||
direction={{ xs: 'column', sm: 'row' }}
|
||||
spacing={2}
|
||||
alignItems={{ xs: 'stretch', sm: 'center' }}
|
||||
>
|
||||
{/* User Filter */}
|
||||
<Box sx={{ flex: 1, minWidth: 200 }}>
|
||||
<Typography level='body-sm' sx={{ mb: 1, fontWeight: 500 }}>
|
||||
Show activities for:
|
||||
</Typography>
|
||||
<Select
|
||||
sx={{
|
||||
width: '100%',
|
||||
}}
|
||||
variant='outlined'
|
||||
value={selectedUser}
|
||||
onChange={(e, selected) => {
|
||||
setSelectedUser(selected)
|
||||
setSelectedHistory(
|
||||
enrichedHistory.filter(h => USER_FILTER(h, selected)),
|
||||
)
|
||||
}}
|
||||
renderValue={() => {
|
||||
if (selectedUser === undefined || selectedUser === 'all') {
|
||||
return (
|
||||
<Typography
|
||||
startDecorator={
|
||||
<Avatar color='primary' size='sm'>
|
||||
<Group />
|
||||
</Avatar>
|
||||
}
|
||||
>
|
||||
All Users
|
||||
</Typography>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<Typography
|
||||
startDecorator={
|
||||
<Avatar
|
||||
color='primary'
|
||||
size='sm'
|
||||
src={resolvePhotoURL(
|
||||
circleUsers.find(
|
||||
user => user.userId === selectedUser,
|
||||
)?.image,
|
||||
)}
|
||||
>
|
||||
{circleUsers
|
||||
.find(user => user.userId === selectedUser)
|
||||
?.displayName?.charAt(0)}
|
||||
</Avatar>
|
||||
}
|
||||
>
|
||||
{
|
||||
circleUsers.find(user => user.userId === selectedUser)
|
||||
?.displayName
|
||||
}
|
||||
</Typography>
|
||||
)
|
||||
}}
|
||||
>
|
||||
<Option value='all'>
|
||||
<Typography
|
||||
startDecorator={
|
||||
<Avatar color='primary' size='sm'>
|
||||
<Group />
|
||||
</Avatar>
|
||||
}
|
||||
>
|
||||
All Users
|
||||
</Typography>
|
||||
</Option>
|
||||
{circleUsers.map(user => (
|
||||
<Option key={user.userId} value={user.userId}>
|
||||
<Avatar
|
||||
color='primary'
|
||||
size='sm'
|
||||
src={resolvePhotoURL(user.image)}
|
||||
>
|
||||
{user.displayName?.charAt(0)}
|
||||
</Avatar>
|
||||
<Typography>{user.displayName}</Typography>
|
||||
<Chip
|
||||
color='success'
|
||||
size='sm'
|
||||
variant='soft'
|
||||
startDecorator={<Toll />}
|
||||
>
|
||||
{user.points - user.pointsRedeemed}
|
||||
</Chip>
|
||||
</Option>
|
||||
))}
|
||||
</Select>
|
||||
</Box>
|
||||
|
||||
{/* Time Period Filter */}
|
||||
<Box sx={{ flex: 1, minWidth: 200 }}>
|
||||
<Typography level='body-sm' sx={{ mb: 1, fontWeight: 500 }}>
|
||||
Time period:
|
||||
</Typography>
|
||||
<Tabs
|
||||
onChange={(e, tabValue) => {
|
||||
setTabValue(tabValue)
|
||||
refetchHistory(tabValue)
|
||||
}}
|
||||
value={tabValue}
|
||||
sx={{
|
||||
borderRadius: 8,
|
||||
backgroundColor: 'background.surface',
|
||||
border: '1px solid',
|
||||
borderColor: 'divider',
|
||||
}}
|
||||
>
|
||||
<TabList
|
||||
disableUnderline
|
||||
sx={{
|
||||
borderRadius: 8,
|
||||
backgroundColor: 'transparent',
|
||||
p: 0.5,
|
||||
gap: 0.5,
|
||||
}}
|
||||
>
|
||||
{[
|
||||
{ label: '7 Days', value: 7 },
|
||||
{ label: '30 Days', value: 30 },
|
||||
{ label: '90 Days', value: 90 },
|
||||
{ label: 'All Time', value: 365 },
|
||||
].map((tab, index) => (
|
||||
<Tab
|
||||
key={index}
|
||||
sx={{
|
||||
borderRadius: 6,
|
||||
minWidth: 'auto',
|
||||
px: 2,
|
||||
py: 1,
|
||||
fontSize: 'sm',
|
||||
fontWeight: 500,
|
||||
color: 'text.secondary',
|
||||
'&.Mui-selected': {
|
||||
color: 'primary.plainColor',
|
||||
backgroundColor: 'primary.softBg',
|
||||
fontWeight: 600,
|
||||
},
|
||||
'&:hover': {
|
||||
backgroundColor: 'neutral.softHoverBg',
|
||||
},
|
||||
}}
|
||||
disableIndicator
|
||||
value={tab.value}
|
||||
>
|
||||
{tab.label}
|
||||
</Tab>
|
||||
))}
|
||||
</TabList>
|
||||
</Tabs>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
{/* Current Filter Summary */}
|
||||
<Box sx={{ mb: 3, textAlign: 'center' }}>
|
||||
<Typography level='body-sm' sx={{ color: 'text.secondary' }}>
|
||||
Showing activities for{' '}
|
||||
<Typography
|
||||
component='span'
|
||||
sx={{ fontWeight: 600, color: 'primary.500' }}
|
||||
>
|
||||
{selectedUser === undefined || selectedUser === 'all'
|
||||
? 'All Users'
|
||||
: circleUsers.find(user => user.userId === selectedUser)
|
||||
?.displayName || 'Unknown User'}
|
||||
</Typography>{' '}
|
||||
over the{' '}
|
||||
<Typography
|
||||
component='span'
|
||||
sx={{ fontWeight: 600, color: 'primary.500' }}
|
||||
>
|
||||
{tabValue === 365 ? 'All Time' : `Last ${tabValue} Days`}
|
||||
</Typography>
|
||||
<Timeline sx={{ fontSize: '1.5rem' }} />
|
||||
<Typography
|
||||
level='title-md'
|
||||
sx={{ fontWeight: 'lg', color: 'text.primary' }}
|
||||
>
|
||||
Activities
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<FilterBar
|
||||
filterDefs={filterDefs}
|
||||
activeFilters={activeFilters}
|
||||
onSetFilter={handleSetFilter}
|
||||
onClearAll={handleClearAll}
|
||||
resultCount={filteredTimeline.length}
|
||||
totalCount={selectedHistory.length}
|
||||
/>
|
||||
|
||||
{/* Conditional Content Based on Data Availability */}
|
||||
{!choresData.res?.length > 0 || !choresHistory?.length > 0 ? (
|
||||
<Container
|
||||
@@ -1103,7 +1054,8 @@ const UserActivites = () => {
|
||||
{/* Left Side - Timeline (Mobile: Full width, Desktop: Flexible) */}
|
||||
<Box sx={{ flex: 1, minWidth: 0, width: '100%' }}>
|
||||
<ChoreHistoryTimeline
|
||||
history={selectedHistory}
|
||||
history={filteredTimeline}
|
||||
performers={circleUsers}
|
||||
onViewNote={notes => {
|
||||
setNoteViewerConfig({
|
||||
isOpen: true,
|
||||
@@ -1112,19 +1064,68 @@ const UserActivites = () => {
|
||||
onClose: () => setNoteViewerConfig({ isOpen: false }),
|
||||
})
|
||||
}}
|
||||
onViewDetails={(entry, performers) => {
|
||||
setDetailModalConfig({
|
||||
isOpen: true,
|
||||
entry,
|
||||
performers,
|
||||
onClose: () => setDetailModalConfig({ isOpen: false }),
|
||||
onEdit: record => {
|
||||
setDetailModalConfig(prev => ({ ...prev, isOpen: false }))
|
||||
setEditHistoryRecord(record)
|
||||
setEditModalConfig({
|
||||
isOpen: true,
|
||||
onClose: () => {
|
||||
setEditModalConfig({ isOpen: false })
|
||||
setEditHistoryRecord(null)
|
||||
},
|
||||
onSave: updated => {
|
||||
updateChoreHistory.mutate(
|
||||
{
|
||||
choreId: record.choreId,
|
||||
historyId: record.id,
|
||||
historyData: {
|
||||
performedAt: updated.performedAt,
|
||||
dueDate: updated.dueDate,
|
||||
notes: updated.notes,
|
||||
},
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
setEditModalConfig({ isOpen: false })
|
||||
setEditHistoryRecord(null)
|
||||
},
|
||||
},
|
||||
)
|
||||
},
|
||||
onDelete: () => {
|
||||
deleteChoreHistory.mutate(
|
||||
{ choreId: record.choreId, historyId: record.id },
|
||||
{
|
||||
onSuccess: () => {
|
||||
setEditModalConfig({ isOpen: false })
|
||||
setEditHistoryRecord(null)
|
||||
},
|
||||
},
|
||||
)
|
||||
},
|
||||
})
|
||||
},
|
||||
})
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* Right Sidebar - Charts (Mobile: Full width, Desktop: Fixed width + sticky) */}
|
||||
{/* Right Sidebar - Charts (Desktop only, hidden on mobile) */}
|
||||
<Box
|
||||
sx={{
|
||||
width: { xs: '100%', lg: '350px' },
|
||||
position: { xs: 'static', lg: 'sticky' },
|
||||
top: { lg: '60px' },
|
||||
alignSelf: { lg: 'flex-start' },
|
||||
maxHeight: { lg: 'calc(100vh - 40px)' },
|
||||
overflowY: { lg: 'auto' },
|
||||
order: { xs: -1, lg: 1 }, // Show charts first on mobile, last on desktop
|
||||
display: { xs: 'none', lg: 'block' },
|
||||
width: '350px',
|
||||
position: 'sticky',
|
||||
top: '60px',
|
||||
alignSelf: 'flex-start',
|
||||
maxHeight: 'calc(100vh - 40px)',
|
||||
overflowY: 'auto',
|
||||
}}
|
||||
>
|
||||
{/* Charts Container */}
|
||||
@@ -1263,6 +1264,11 @@ const UserActivites = () => {
|
||||
</>
|
||||
)}
|
||||
<NoteViewerModal config={noteViewerConfig} />
|
||||
<HistoryDetailModal config={detailModalConfig} />
|
||||
<EditHistoryModal
|
||||
config={editModalConfig}
|
||||
historyRecord={editHistoryRecord}
|
||||
/>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
43
src/views/components/AssigneePickerField.jsx
Normal file
43
src/views/components/AssigneePickerField.jsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import { Person } from '@mui/icons-material'
|
||||
import BaseOptionPicker from './BaseOptionPicker'
|
||||
|
||||
const AssigneePickerField = ({
|
||||
value = null,
|
||||
onChange,
|
||||
onClear,
|
||||
members = [],
|
||||
includeAnyone = true,
|
||||
emptyDisplay,
|
||||
currentUserId = null,
|
||||
}) => {
|
||||
const options = [
|
||||
...(includeAnyone ? [{ userId: 'anyone', displayName: 'Anyone' }] : []),
|
||||
...members.map(member => ({
|
||||
userId: member.userId,
|
||||
displayName: member.displayName || member.username || 'Unknown',
|
||||
})),
|
||||
]
|
||||
|
||||
const displayValue = currentUserId && value === currentUserId ? null : value
|
||||
|
||||
return (
|
||||
<BaseOptionPicker
|
||||
items={options}
|
||||
value={displayValue}
|
||||
onChange={onChange}
|
||||
onClear={onClear}
|
||||
emptyDisplay={emptyDisplay}
|
||||
emptyLabel='Assignee'
|
||||
getItemValue={item => item.userId}
|
||||
getItemLabel={item => item.displayName}
|
||||
renderTriggerIcon={() => <Person sx={{ fontSize: '20px' }} />}
|
||||
renderItemStart={() => <Person sx={{ fontSize: '18px' }} />}
|
||||
getTriggerText={({ selectedItems, isEmpty }) =>
|
||||
isEmpty ? 'Assignee' : selectedItems[0].displayName
|
||||
}
|
||||
menuMinWidth={220}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default AssigneePickerField
|
||||
259
src/views/components/AttachmentPickerField.jsx
Normal file
259
src/views/components/AttachmentPickerField.jsx
Normal file
@@ -0,0 +1,259 @@
|
||||
import { AttachFile, Close, DeleteOutline, Image } from '@mui/icons-material'
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
CircularProgress,
|
||||
IconButton,
|
||||
Sheet,
|
||||
Typography,
|
||||
} from '@mui/joy'
|
||||
import { ClickAwayListener, Popper } from '@mui/material'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { Z_INDEX } from '../../constants/zIndex'
|
||||
import { useFileUpload } from '../../hooks/useFileUpload'
|
||||
|
||||
const AttachmentPickerField = ({
|
||||
attachments = [],
|
||||
onChange,
|
||||
onClear,
|
||||
emptyDisplay = 'icon-text',
|
||||
entityType = 'chore_attachment',
|
||||
entityId,
|
||||
draftId,
|
||||
}) => {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const [isUploading, setIsUploading] = useState(false)
|
||||
const buttonRef = useRef(null)
|
||||
const { uploadFile } = useFileUpload({ entityType, entityId, draftId })
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return
|
||||
const handleEscape = e => {
|
||||
if (e.key === 'Escape') setIsOpen(false)
|
||||
}
|
||||
document.addEventListener('keydown', handleEscape)
|
||||
return () => document.removeEventListener('keydown', handleEscape)
|
||||
}, [isOpen])
|
||||
|
||||
const handleAddFile = () => {
|
||||
const input = document.createElement('input')
|
||||
input.setAttribute('type', 'file')
|
||||
input.setAttribute('accept', 'image/*')
|
||||
input.click()
|
||||
input.onchange = async () => {
|
||||
const file = input.files?.[0]
|
||||
if (!file) return
|
||||
setIsUploading(true)
|
||||
try {
|
||||
const url = await uploadFile(file)
|
||||
if (url) {
|
||||
onChange([...attachments, { url, name: file.name }])
|
||||
}
|
||||
} finally {
|
||||
setIsUploading(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleRemove = index => {
|
||||
const updated = attachments.filter((_, i) => i !== index)
|
||||
onChange(updated)
|
||||
if (updated.length === 0) setIsOpen(false)
|
||||
}
|
||||
|
||||
const handleClear = e => {
|
||||
e.stopPropagation()
|
||||
onClear?.()
|
||||
setIsOpen(false)
|
||||
}
|
||||
|
||||
const isEmpty = attachments.length === 0
|
||||
const shouldShowLabel = !isEmpty || emptyDisplay === 'icon-text'
|
||||
|
||||
return (
|
||||
<>
|
||||
<Box sx={{ position: 'relative', display: 'flex', alignItems: 'center' }}>
|
||||
<Button
|
||||
ref={buttonRef}
|
||||
size='sm'
|
||||
variant={isEmpty ? 'outlined' : 'soft'}
|
||||
color='neutral'
|
||||
onClick={() => setIsOpen(prev => !prev)}
|
||||
sx={{
|
||||
borderRadius: '128px',
|
||||
minHeight: 40,
|
||||
minWidth: 'min-content',
|
||||
px: shouldShowLabel ? 1.25 : 0.75,
|
||||
gap: shouldShowLabel ? 1 : 0,
|
||||
justifyContent: 'flex-start',
|
||||
whiteSpace: 'nowrap',
|
||||
transition: 'all 0.25s ease-in-out',
|
||||
}}
|
||||
>
|
||||
{isUploading ? (
|
||||
<CircularProgress size='sm' sx={{ '--CircularProgress-size': '16px' }} />
|
||||
) : (
|
||||
<AttachFile sx={{ fontSize: '20px' }} />
|
||||
)}
|
||||
<Typography
|
||||
level='body-sm'
|
||||
sx={{
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
maxWidth: shouldShowLabel ? 180 : 0,
|
||||
opacity: shouldShowLabel ? 1 : 0,
|
||||
transform: shouldShowLabel ? 'translateX(0)' : 'translateX(-4px)',
|
||||
transition:
|
||||
'max-width 0.25s ease-in-out, opacity 0.2s ease-in-out, transform 0.25s ease-in-out',
|
||||
}}
|
||||
>
|
||||
{isEmpty
|
||||
? 'Attachments'
|
||||
: `${attachments.length} file${attachments.length !== 1 ? 's' : ''}`}
|
||||
</Typography>
|
||||
</Button>
|
||||
{!isEmpty && onClear && (
|
||||
<IconButton
|
||||
size='sm'
|
||||
variant='soft'
|
||||
color='danger'
|
||||
onClick={handleClear}
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: -12,
|
||||
right: -16,
|
||||
zIndex: 10,
|
||||
maxHeight: 18,
|
||||
maxWidth: 18,
|
||||
borderRadius: '50%',
|
||||
'&:hover': { bgcolor: 'danger.softBg' },
|
||||
}}
|
||||
>
|
||||
<Close sx={{ fontSize: '18px' }} />
|
||||
</IconButton>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{isOpen && (
|
||||
<Popper
|
||||
open={isOpen}
|
||||
anchorEl={buttonRef.current}
|
||||
placement='top-start'
|
||||
modifiers={[
|
||||
{ name: 'offset', options: { offset: [0, 8] } },
|
||||
{
|
||||
name: 'flip',
|
||||
options: { fallbackPlacements: ['bottom-start', 'top-start'] },
|
||||
},
|
||||
]}
|
||||
sx={{ zIndex: Z_INDEX.MODAL_CLOSE_BUTTON + 1 }}
|
||||
>
|
||||
<ClickAwayListener onClickAway={() => setIsOpen(false)}>
|
||||
<Sheet
|
||||
variant='outlined'
|
||||
sx={{
|
||||
minWidth: 240,
|
||||
maxWidth: 320,
|
||||
p: 1,
|
||||
borderRadius: 'md',
|
||||
boxShadow: 'lg',
|
||||
bgcolor: 'background.popup',
|
||||
}}
|
||||
>
|
||||
{attachments.length > 0 && (
|
||||
<Box sx={{ mb: 1, display: 'flex', flexDirection: 'column', gap: 0.5 }}>
|
||||
{attachments.map((attachment, index) => (
|
||||
<Box
|
||||
key={index}
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 1,
|
||||
p: 0.5,
|
||||
borderRadius: 'sm',
|
||||
'&:hover': { bgcolor: 'background.level1' },
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
component='img'
|
||||
src={attachment.url}
|
||||
alt={attachment.name}
|
||||
sx={{
|
||||
width: 36,
|
||||
height: 36,
|
||||
objectFit: 'cover',
|
||||
borderRadius: 'sm',
|
||||
flexShrink: 0,
|
||||
bgcolor: 'background.level2',
|
||||
}}
|
||||
onError={e => {
|
||||
e.target.style.display = 'none'
|
||||
e.target.nextSibling.style.display = 'flex'
|
||||
}}
|
||||
/>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'none',
|
||||
width: 36,
|
||||
height: 36,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderRadius: 'sm',
|
||||
bgcolor: 'background.level2',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<Image sx={{ fontSize: 20, color: 'text.tertiary' }} />
|
||||
</Box>
|
||||
<Typography
|
||||
level='body-xs'
|
||||
sx={{
|
||||
flex: 1,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{attachment.name}
|
||||
</Typography>
|
||||
<IconButton
|
||||
size='sm'
|
||||
variant='plain'
|
||||
color='danger'
|
||||
onClick={() => handleRemove(index)}
|
||||
sx={{ flexShrink: 0 }}
|
||||
>
|
||||
<DeleteOutline sx={{ fontSize: 16 }} />
|
||||
</IconButton>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Button
|
||||
fullWidth
|
||||
size='sm'
|
||||
variant='outlined'
|
||||
color='neutral'
|
||||
startDecorator={
|
||||
isUploading ? (
|
||||
<CircularProgress size='sm' sx={{ '--CircularProgress-size': '14px' }} />
|
||||
) : (
|
||||
<AttachFile sx={{ fontSize: 16 }} />
|
||||
)
|
||||
}
|
||||
onClick={handleAddFile}
|
||||
disabled={isUploading}
|
||||
>
|
||||
{isUploading ? 'Uploading…' : 'Add image'}
|
||||
</Button>
|
||||
</Sheet>
|
||||
</ClickAwayListener>
|
||||
</Popper>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default AttachmentPickerField
|
||||
253
src/views/components/BaseOptionPicker.jsx
Normal file
253
src/views/components/BaseOptionPicker.jsx
Normal file
@@ -0,0 +1,253 @@
|
||||
import { Close } from '@mui/icons-material'
|
||||
import { Box, Button, IconButton, Sheet, Typography } from '@mui/joy'
|
||||
import { ClickAwayListener, Popper } from '@mui/material'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Z_INDEX } from '../../constants/zIndex'
|
||||
|
||||
const BaseOptionPicker = ({
|
||||
items = [],
|
||||
value = null,
|
||||
values = [],
|
||||
multiple = false,
|
||||
onChange,
|
||||
onValuesChange,
|
||||
emptyDisplay = 'icon',
|
||||
emptyLabel = 'Select',
|
||||
placement = 'top-start',
|
||||
menuMinWidth = 180,
|
||||
menuMaxHeight = 280,
|
||||
getItemValue = item => item.id,
|
||||
getItemLabel = item => item.label,
|
||||
renderItemStart,
|
||||
renderTriggerIcon,
|
||||
getItemColor,
|
||||
getTriggerText,
|
||||
onClear,
|
||||
}) => {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const buttonRef = useRef(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return
|
||||
|
||||
const handleEscape = event => {
|
||||
if (event.key === 'Escape') {
|
||||
setIsOpen(false)
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', handleEscape)
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleEscape)
|
||||
}
|
||||
}, [isOpen])
|
||||
|
||||
const selectedItems = useMemo(() => {
|
||||
if (multiple) {
|
||||
const selectedSet = new Set(values)
|
||||
return items.filter(item => selectedSet.has(getItemValue(item)))
|
||||
}
|
||||
|
||||
if (value === null || value === undefined) return []
|
||||
return items.filter(item => getItemValue(item) === value)
|
||||
}, [items, multiple, value, values, getItemValue])
|
||||
|
||||
const isEmpty = selectedItems.length === 0
|
||||
const shouldShowLabel = !isEmpty || emptyDisplay === 'icon-text'
|
||||
|
||||
const triggerText = getTriggerText
|
||||
? getTriggerText({ selectedItems, isEmpty })
|
||||
: isEmpty
|
||||
? emptyLabel
|
||||
: getItemLabel(selectedItems[0])
|
||||
|
||||
const triggerColor = isEmpty
|
||||
? undefined
|
||||
: getItemColor
|
||||
? getItemColor(selectedItems[0])
|
||||
: undefined
|
||||
|
||||
const handleSelect = selectedValue => {
|
||||
if (multiple) {
|
||||
const selectedSet = new Set(values)
|
||||
if (selectedSet.has(selectedValue)) {
|
||||
selectedSet.delete(selectedValue)
|
||||
} else {
|
||||
selectedSet.add(selectedValue)
|
||||
}
|
||||
onValuesChange?.(Array.from(selectedSet))
|
||||
return
|
||||
}
|
||||
|
||||
onChange?.(selectedValue)
|
||||
setIsOpen(false)
|
||||
}
|
||||
|
||||
const isSelected = item => {
|
||||
const optionValue = getItemValue(item)
|
||||
if (multiple) {
|
||||
return values.includes(optionValue)
|
||||
}
|
||||
return value === optionValue
|
||||
}
|
||||
|
||||
const handleClear = e => {
|
||||
e.stopPropagation()
|
||||
onClear?.()
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Box
|
||||
sx={{
|
||||
position: 'relative',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
ref={buttonRef}
|
||||
size={'sm'}
|
||||
variant={isEmpty ? 'outlined' : 'soft'}
|
||||
color='neutral'
|
||||
onClick={() => setIsOpen(prev => !prev)}
|
||||
sx={{
|
||||
borderRadius: '128px',
|
||||
minHeight: 40,
|
||||
minWidth: 'min-content',
|
||||
px: shouldShowLabel ? 1.25 : 0.75,
|
||||
gap: shouldShowLabel ? 1 : 0,
|
||||
justifyContent: 'flex-start',
|
||||
whiteSpace: 'nowrap',
|
||||
transition: 'all 0.25s ease-in-out',
|
||||
backgroundColor: triggerColor ? `${triggerColor}20` : undefined,
|
||||
borderColor: triggerColor || undefined,
|
||||
color: triggerColor || undefined,
|
||||
'&:hover': {
|
||||
backgroundColor: triggerColor ? `${triggerColor}28` : undefined,
|
||||
borderColor: triggerColor || undefined,
|
||||
},
|
||||
}}
|
||||
>
|
||||
{renderTriggerIcon?.({ selectedItems, isEmpty })}
|
||||
<Typography
|
||||
level='body-sm'
|
||||
sx={{
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
maxWidth: shouldShowLabel ? 180 : 0,
|
||||
opacity: shouldShowLabel ? 1 : 0,
|
||||
transform: shouldShowLabel ? 'translateX(0)' : 'translateX(-4px)',
|
||||
transition:
|
||||
'max-width 0.25s ease-in-out, opacity 0.2s ease-in-out, transform 0.25s ease-in-out',
|
||||
}}
|
||||
>
|
||||
{triggerText}
|
||||
</Typography>
|
||||
</Button>
|
||||
{!isEmpty && onClear && (
|
||||
<IconButton
|
||||
size='sm'
|
||||
variant='soft'
|
||||
color='danger'
|
||||
onClick={handleClear}
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: -12,
|
||||
right: -16,
|
||||
zIndex: 10,
|
||||
maxHeight: 18,
|
||||
maxWidth: 18,
|
||||
borderRadius: '50%',
|
||||
'&:hover': {
|
||||
bgcolor: 'danger.softBg',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Close sx={{ fontSize: '18px' }} />
|
||||
</IconButton>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{isOpen && (
|
||||
<Popper
|
||||
open={isOpen}
|
||||
anchorEl={buttonRef.current}
|
||||
placement={placement}
|
||||
modifiers={[
|
||||
{
|
||||
name: 'offset',
|
||||
options: {
|
||||
offset: [0, 8],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'flip',
|
||||
options: {
|
||||
fallbackPlacements: ['bottom-start', 'top-start'],
|
||||
},
|
||||
},
|
||||
]}
|
||||
sx={{ zIndex: Z_INDEX.MODAL_CLOSE_BUTTON + 1 }}
|
||||
>
|
||||
<ClickAwayListener onClickAway={() => setIsOpen(false)}>
|
||||
<Sheet
|
||||
variant='outlined'
|
||||
sx={{
|
||||
minWidth: menuMinWidth,
|
||||
maxHeight: menuMaxHeight,
|
||||
overflowY: 'auto',
|
||||
overflowX: 'hidden',
|
||||
p: 0.75,
|
||||
borderRadius: 'md',
|
||||
boxShadow: 'lg',
|
||||
bgcolor: 'background.popup',
|
||||
}}
|
||||
>
|
||||
{items.map((item, index) => {
|
||||
const optionValue = getItemValue(item)
|
||||
const selected = isSelected(item)
|
||||
const itemColor = getItemColor ? getItemColor(item) : undefined
|
||||
|
||||
return (
|
||||
<Button
|
||||
key={optionValue ?? index}
|
||||
variant={selected ? 'soft' : 'plain'}
|
||||
color='neutral'
|
||||
onClick={() => handleSelect(optionValue)}
|
||||
sx={{
|
||||
width: '100%',
|
||||
display: 'flex',
|
||||
justifyContent: 'flex-start',
|
||||
gap: 1,
|
||||
whiteSpace: 'nowrap',
|
||||
mb: index === items.length - 1 ? 0 : 0.5,
|
||||
color: selected
|
||||
? itemColor || 'text.primary'
|
||||
: 'text.primary',
|
||||
}}
|
||||
>
|
||||
{renderItemStart?.({ item, selected })}
|
||||
<Typography
|
||||
level='body-sm'
|
||||
sx={{
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
}}
|
||||
>
|
||||
{getItemLabel(item)}
|
||||
</Typography>
|
||||
</Button>
|
||||
)
|
||||
})}
|
||||
</Sheet>
|
||||
</ClickAwayListener>
|
||||
</Popper>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default BaseOptionPicker
|
||||
@@ -1,8 +1,10 @@
|
||||
import {
|
||||
Archive,
|
||||
ArrowBack,
|
||||
Cancel,
|
||||
CopyAll,
|
||||
Delete,
|
||||
DriveFileMove,
|
||||
Edit,
|
||||
ManageSearch,
|
||||
MoreTime,
|
||||
@@ -20,10 +22,25 @@ import {
|
||||
WbSunny,
|
||||
Weekend,
|
||||
} from '@mui/icons-material'
|
||||
import { Divider, IconButton, Menu, MenuItem, Tooltip } from '@mui/joy'
|
||||
import {
|
||||
Avatar,
|
||||
Divider,
|
||||
IconButton,
|
||||
ListItemContent,
|
||||
ListItemDecorator,
|
||||
Menu,
|
||||
MenuItem,
|
||||
Tooltip,
|
||||
Typography,
|
||||
} from '@mui/joy'
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import LABEL_COLORS, {
|
||||
getTextColorFromBackgroundColor,
|
||||
} from '../../utils/Colors'
|
||||
import { isOfficialDonetickInstanceSync } from '../../utils/FeatureToggle'
|
||||
import { getIconComponent } from '../../utils/ProjectIcons'
|
||||
import { useProjects } from '../Projects/ProjectQueries'
|
||||
|
||||
const ChoreActionMenu = ({
|
||||
chore,
|
||||
@@ -43,10 +60,11 @@ const ChoreActionMenu = ({
|
||||
}) => {
|
||||
const [anchorEl, setAnchorEl] = React.useState(null)
|
||||
const [isOfficialInstance, setIsOfficialInstance] = useState(false)
|
||||
const [showProjectPicker, setShowProjectPicker] = useState(false)
|
||||
const menuRef = React.useRef(null)
|
||||
const navigate = useNavigate()
|
||||
const { data: projects = [] } = useProjects()
|
||||
|
||||
// Check if this is the official donetick.com instance
|
||||
useEffect(() => {
|
||||
try {
|
||||
setIsOfficialInstance(isOfficialDonetickInstanceSync())
|
||||
@@ -83,6 +101,12 @@ const ChoreActionMenu = ({
|
||||
|
||||
const handleMenuClose = () => {
|
||||
setAnchorEl(null)
|
||||
setShowProjectPicker(false)
|
||||
}
|
||||
|
||||
const handleMoveToProject = project => {
|
||||
onAction?.('moveToProject', chore, { project })
|
||||
handleMenuClose()
|
||||
}
|
||||
|
||||
const handleEdit = () => {
|
||||
@@ -134,7 +158,6 @@ const ChoreActionMenu = ({
|
||||
|
||||
switch (option) {
|
||||
case 'today': {
|
||||
// Schedule for today at the next available slot: 9am, 12pm, 5pm, or now if after 5pm
|
||||
const nowHour = now.getHours()
|
||||
const scheduled = new Date(today)
|
||||
if (nowHour < 9) {
|
||||
@@ -144,7 +167,6 @@ const ChoreActionMenu = ({
|
||||
} else if (nowHour < 17) {
|
||||
scheduled.setHours(17, 0, 0, 0)
|
||||
} else {
|
||||
// After 5pm, use current time
|
||||
scheduled.setHours(
|
||||
now.getHours(),
|
||||
now.getMinutes(),
|
||||
@@ -163,7 +185,7 @@ const ChoreActionMenu = ({
|
||||
case 'tomorrow': {
|
||||
const tomorrow = new Date(today)
|
||||
tomorrow.setDate(today.getDate() + 1)
|
||||
tomorrow.setHours(12, 0, 0, 0) // Set to noon
|
||||
tomorrow.setHours(12, 0, 0, 0)
|
||||
return tomorrow
|
||||
}
|
||||
case 'tomorrow-afternoon': {
|
||||
@@ -195,6 +217,18 @@ const ChoreActionMenu = ({
|
||||
handleMenuClose()
|
||||
}
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<IconButton
|
||||
@@ -227,218 +261,267 @@ const ChoreActionMenu = ({
|
||||
left: '50%',
|
||||
}}
|
||||
>
|
||||
<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'
|
||||
{showProjectPicker ? (
|
||||
<>
|
||||
<MenuItem
|
||||
onClick={e => {
|
||||
e.stopPropagation()
|
||||
handleQuickSchedule('today')
|
||||
setShowProjectPicker(false)
|
||||
}}
|
||||
sx={{ gap: 1 }}
|
||||
>
|
||||
<Today />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title='Tomorrow' placement='top'>
|
||||
<IconButton
|
||||
size='sm'
|
||||
<ArrowBack fontSize='small' />
|
||||
<Typography level='body-sm' fontWeight={600}>
|
||||
Move to project
|
||||
</Typography>
|
||||
</MenuItem>
|
||||
<Divider />
|
||||
<MenuItem
|
||||
onClick={e => {
|
||||
e.stopPropagation()
|
||||
handleQuickSchedule('tomorrow')
|
||||
handleMoveToProject({ id: null, name: 'Default Project' })
|
||||
}}
|
||||
>
|
||||
<WbSunny />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
{/* <Tooltip title='Tomorrow afternoon' placement='top'>
|
||||
<IconButton
|
||||
size='sm'
|
||||
<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={e => {
|
||||
e.stopPropagation()
|
||||
handleMoveToProject(project)
|
||||
}}
|
||||
>
|
||||
<ListItemDecorator>
|
||||
{renderProjectAvatar(project.color, project.icon)}
|
||||
</ListItemDecorator>
|
||||
<ListItemContent>
|
||||
<Typography level='body-sm'>{project.name}</Typography>
|
||||
</ListItemContent>
|
||||
</MenuItem>
|
||||
))}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<MenuItem
|
||||
onClick={e => {
|
||||
e.stopPropagation()
|
||||
handleQuickSchedule('tomorrow-afternoon')
|
||||
onCompleteWithNote?.()
|
||||
handleMenuClose()
|
||||
}}
|
||||
>
|
||||
<WbTwilight />
|
||||
</IconButton>
|
||||
</Tooltip> */}
|
||||
<Tooltip title='Weekend' placement='top'>
|
||||
<IconButton
|
||||
size='sm'
|
||||
<NoteAdd />
|
||||
Complete with note
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
onClick={e => {
|
||||
e.stopPropagation()
|
||||
handleQuickSchedule('weekend')
|
||||
onCompleteWithPastDate?.()
|
||||
handleMenuClose()
|
||||
}}
|
||||
>
|
||||
<Weekend />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title='Next week' placement='top'>
|
||||
<IconButton
|
||||
size='sm'
|
||||
<Update />
|
||||
Complete in past
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
onClick={e => {
|
||||
e.stopPropagation()
|
||||
handleQuickSchedule('next-week')
|
||||
handleSkip()
|
||||
}}
|
||||
>
|
||||
<NextWeek />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title='Remove due date' placement='top'>
|
||||
<IconButton
|
||||
size='sm'
|
||||
<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 => {
|
||||
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>
|
||||
</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()
|
||||
handleQuickSchedule('remove')
|
||||
handleDelete()
|
||||
}}
|
||||
color='danger'
|
||||
>
|
||||
<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>
|
||||
<Divider />
|
||||
<MenuItem
|
||||
onClick={e => {
|
||||
e.stopPropagation()
|
||||
handleDelete()
|
||||
}}
|
||||
color='danger'
|
||||
>
|
||||
<Delete />
|
||||
Delete
|
||||
</MenuItem>
|
||||
<Delete />
|
||||
Delete
|
||||
</MenuItem>
|
||||
</>
|
||||
)}
|
||||
</Menu>
|
||||
</>
|
||||
)
|
||||
|
||||
628
src/views/components/DueDatePickerField.jsx
Normal file
628
src/views/components/DueDatePickerField.jsx
Normal file
@@ -0,0 +1,628 @@
|
||||
import {
|
||||
Bedtime,
|
||||
CalendarMonth,
|
||||
Close,
|
||||
EventNote,
|
||||
LightMode,
|
||||
NextWeek,
|
||||
NightsStay,
|
||||
Today,
|
||||
WbSunny,
|
||||
WbTwilight,
|
||||
Weekend,
|
||||
} from '@mui/icons-material'
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Checkbox,
|
||||
IconButton,
|
||||
Input,
|
||||
List,
|
||||
ListItem,
|
||||
Typography,
|
||||
} from '@mui/joy'
|
||||
import moment from 'moment'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import Calendar from 'react-calendar'
|
||||
import { useLocalization } from '../../contexts/LocalizationContext'
|
||||
import { useResponsiveModal } from '../../hooks/useResponsiveModal'
|
||||
|
||||
const DueDatePickerField = ({
|
||||
dueDateOnly,
|
||||
dueTime,
|
||||
useCustomTime,
|
||||
onDueDateChange,
|
||||
onDueTimeChange,
|
||||
onUseCustomTimeChange,
|
||||
onClear,
|
||||
emptyDisplay = 'icon-text',
|
||||
size = 'sm',
|
||||
}) => {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const { ResponsiveModal } = useResponsiveModal()
|
||||
const { firstDayOfWeek } = useLocalization()
|
||||
|
||||
// Local buffered state — only committed on Apply
|
||||
const [localDueDateOnly, setLocalDueDateOnly] = useState(dueDateOnly)
|
||||
const [localDueTime, setLocalDueTime] = useState(dueTime)
|
||||
const [localUseCustomTime, setLocalUseCustomTime] = useState(useCustomTime)
|
||||
|
||||
// Sync local state from props whenever the modal opens
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setLocalDueDateOnly(dueDateOnly)
|
||||
setLocalDueTime(dueTime)
|
||||
setLocalUseCustomTime(useCustomTime)
|
||||
}
|
||||
}, [isOpen, dueDateOnly, dueTime, useCustomTime])
|
||||
|
||||
const calendarType =
|
||||
firstDayOfWeek === 1
|
||||
? 'iso8601'
|
||||
: firstDayOfWeek === 6
|
||||
? 'islamic'
|
||||
: 'gregory'
|
||||
|
||||
const pillListSx = {
|
||||
'--List-gap': '8px',
|
||||
'--ListItem-radius': '20px',
|
||||
}
|
||||
|
||||
const getQuickScheduleDate = option => {
|
||||
const now = new Date()
|
||||
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate())
|
||||
|
||||
switch (option) {
|
||||
case 'today':
|
||||
return today
|
||||
case 'tomorrow': {
|
||||
const tomorrow = new Date(today)
|
||||
tomorrow.setDate(today.getDate() + 1)
|
||||
return tomorrow
|
||||
}
|
||||
case 'weekend': {
|
||||
const weekend = new Date(today)
|
||||
const daysUntilSaturday = (6 - today.getDay() + 7) % 7 || 7
|
||||
weekend.setDate(today.getDate() + daysUntilSaturday)
|
||||
return weekend
|
||||
}
|
||||
case 'next-week': {
|
||||
const nextWeek = new Date(today)
|
||||
const daysUntilMonday = (1 - today.getDay() + 7) % 7 || 7
|
||||
nextWeek.setDate(today.getDate() + daysUntilMonday)
|
||||
return nextWeek
|
||||
}
|
||||
case 'next-month': {
|
||||
const nextMonth = new Date(today)
|
||||
nextMonth.setMonth(today.getMonth() + 1)
|
||||
return nextMonth
|
||||
}
|
||||
default:
|
||||
return today
|
||||
}
|
||||
}
|
||||
|
||||
const handleQuickSchedule = option => {
|
||||
const date = getQuickScheduleDate(option)
|
||||
setLocalDueDateOnly(date.toISOString().split('T')[0])
|
||||
}
|
||||
|
||||
const handleQuickTime = timeStr => {
|
||||
// Tap the active chip again to deselect it
|
||||
if (localUseCustomTime && localDueTime === timeStr) {
|
||||
setLocalUseCustomTime(false)
|
||||
setLocalDueTime(null)
|
||||
return
|
||||
}
|
||||
if (!localDueDateOnly) {
|
||||
setLocalDueDateOnly(new Date().toISOString().split('T')[0])
|
||||
}
|
||||
setLocalUseCustomTime(true)
|
||||
setLocalDueTime(timeStr)
|
||||
}
|
||||
|
||||
const handleCalendarChange = selected => {
|
||||
if (!selected || Array.isArray(selected)) return
|
||||
setLocalDueDateOnly(moment(selected).format('YYYY-MM-DD'))
|
||||
}
|
||||
|
||||
const handleLocalTimeInputChange = e => {
|
||||
setLocalUseCustomTime(true)
|
||||
setLocalDueTime(e.target.value)
|
||||
}
|
||||
|
||||
const handleSave = () => {
|
||||
onDueDateChange?.({ target: { value: localDueDateOnly || '' } })
|
||||
onUseCustomTimeChange?.(localUseCustomTime)
|
||||
if (localUseCustomTime && localDueTime) {
|
||||
onDueTimeChange?.({ target: { value: localDueTime } })
|
||||
} else {
|
||||
onDueTimeChange?.({ target: { value: '' } })
|
||||
}
|
||||
setIsOpen(false)
|
||||
}
|
||||
|
||||
const hasDueDate = Boolean(dueDateOnly)
|
||||
const shouldShowLabel = hasDueDate || emptyDisplay === 'icon-text'
|
||||
|
||||
const dueDateLabel = useMemo(() => {
|
||||
if (!dueDateOnly) {
|
||||
return 'Due'
|
||||
}
|
||||
|
||||
const formattedDate = moment(dueDateOnly).format('MMM D')
|
||||
if (useCustomTime && dueTime) {
|
||||
return `${formattedDate}, ${dueTime}`
|
||||
}
|
||||
|
||||
return formattedDate
|
||||
}, [dueDateOnly, dueTime, useCustomTime])
|
||||
|
||||
return (
|
||||
<>
|
||||
<Box
|
||||
sx={{
|
||||
position: 'relative',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
size={size}
|
||||
variant={hasDueDate ? 'soft' : 'outlined'}
|
||||
color='neutral'
|
||||
onClick={() => setIsOpen(true)}
|
||||
sx={{
|
||||
minHeight: 40,
|
||||
borderRadius: '128px',
|
||||
minWidth: 'min-content',
|
||||
px: shouldShowLabel ? 1.25 : 0.75,
|
||||
gap: shouldShowLabel ? 1 : 0,
|
||||
justifyContent: 'flex-start',
|
||||
whiteSpace: 'nowrap',
|
||||
transition: 'all 0.25s ease-in-out',
|
||||
}}
|
||||
>
|
||||
<CalendarMonth sx={{ fontSize: '20px' }} />
|
||||
<Typography
|
||||
level='body-sm'
|
||||
sx={{
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
maxWidth: shouldShowLabel ? 220 : 0,
|
||||
opacity: shouldShowLabel ? 1 : 0,
|
||||
transform: shouldShowLabel ? 'translateX(0)' : 'translateX(-4px)',
|
||||
transition:
|
||||
'max-width 0.25s ease-in-out, opacity 0.2s ease-in-out, transform 0.25s ease-in-out',
|
||||
}}
|
||||
>
|
||||
{dueDateLabel}
|
||||
</Typography>
|
||||
</Button>
|
||||
{hasDueDate && onClear && (
|
||||
<IconButton
|
||||
size='sm'
|
||||
variant='soft'
|
||||
color='danger'
|
||||
onClick={e => {
|
||||
e.stopPropagation()
|
||||
onClear?.()
|
||||
}}
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: -12,
|
||||
right: -16,
|
||||
zIndex: 10,
|
||||
maxHeight: 18,
|
||||
maxWidth: 18,
|
||||
borderRadius: '50%',
|
||||
'&:hover': {
|
||||
bgcolor: 'danger.softBg',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Close sx={{ fontSize: '18px' }} />
|
||||
</IconButton>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<ResponsiveModal
|
||||
open={isOpen}
|
||||
onClose={() => setIsOpen(false)}
|
||||
title='Due Date'
|
||||
footer={
|
||||
<Box sx={{ display: 'flex', justifyContent: 'flex-end', gap: 1 }}>
|
||||
{hasDueDate && (
|
||||
<Button
|
||||
variant='plain'
|
||||
color='danger'
|
||||
size='lg'
|
||||
onClick={() => {
|
||||
onClear?.()
|
||||
setIsOpen(false)
|
||||
}}
|
||||
sx={{ mr: 'auto' }}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant='outlined'
|
||||
color='neutral'
|
||||
size='lg'
|
||||
onClick={() => setIsOpen(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant='solid'
|
||||
color='primary'
|
||||
size='lg'
|
||||
onClick={handleSave}
|
||||
>
|
||||
Apply
|
||||
</Button>
|
||||
</Box>
|
||||
}
|
||||
>
|
||||
<Box sx={{ fontFamily: 'var(--joy-fontFamily-body)' }}>
|
||||
{/* 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default DueDatePickerField
|
||||
48
src/views/components/LabelsPickerField.jsx
Normal file
48
src/views/components/LabelsPickerField.jsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import { Label } from '@mui/icons-material'
|
||||
import BaseOptionPicker from './BaseOptionPicker'
|
||||
|
||||
const LabelsPickerField = ({
|
||||
values = [],
|
||||
onChange,
|
||||
onClear,
|
||||
labels = [],
|
||||
emptyDisplay = 'icon-text',
|
||||
}) => {
|
||||
const options = labels.map(label => ({
|
||||
id: label.id,
|
||||
name: label.name,
|
||||
color: label.color,
|
||||
}))
|
||||
|
||||
return (
|
||||
<BaseOptionPicker
|
||||
items={options}
|
||||
multiple
|
||||
values={values}
|
||||
onValuesChange={onChange}
|
||||
onClear={onClear}
|
||||
emptyDisplay={emptyDisplay}
|
||||
emptyLabel='Labels'
|
||||
getItemValue={item => item.id}
|
||||
getItemLabel={item => item.name}
|
||||
getItemColor={item => item.color}
|
||||
renderTriggerIcon={() => <Label sx={{ fontSize: '20px' }} />}
|
||||
renderItemStart={({ item }) => (
|
||||
<Label
|
||||
sx={{
|
||||
fontSize: '18px',
|
||||
color: item.color || 'text.secondary',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
getTriggerText={({ selectedItems, isEmpty }) => {
|
||||
if (isEmpty) return 'Labels'
|
||||
if (selectedItems.length === 1) return selectedItems[0].name
|
||||
return `${selectedItems.length} labels`
|
||||
}}
|
||||
menuMinWidth={220}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default LabelsPickerField
|
||||
@@ -223,7 +223,7 @@ const NavBar = () => {
|
||||
},
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<div className='drawer-content'>
|
||||
{/* <div className='align-center flex px-5 pt-4'>
|
||||
<ModalClose size='sm' sx={{ top: 'unset', right: 20 }} />
|
||||
</div> */}
|
||||
|
||||
@@ -6,17 +6,47 @@ import { networkManager } from '../../hooks/NetworkManager'
|
||||
|
||||
const NetworkBanner = () => {
|
||||
const [isOnline, setIsOnline] = useState(networkManager.isOnline)
|
||||
const [offlineReason, setOfflineReason] = useState(
|
||||
networkManager.offlineReason,
|
||||
)
|
||||
const [isBannerVisible, setIsBannerVisible] = useState(
|
||||
!networkManager.isOnline,
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
const handleNetworkChange = isOnline => {
|
||||
setIsOnline(isOnline)
|
||||
setOfflineReason(networkManager.offlineReason)
|
||||
|
||||
if (!isOnline) {
|
||||
setIsBannerVisible(true)
|
||||
}
|
||||
}
|
||||
|
||||
networkManager.registerNetworkListener(handleNetworkChange)
|
||||
return () => networkManager.unregisterNetworkListener(handleNetworkChange)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (isOnline || !isBannerVisible) {
|
||||
return
|
||||
}
|
||||
|
||||
const timerId = setTimeout(() => {
|
||||
setIsBannerVisible(false)
|
||||
}, 5000)
|
||||
|
||||
return () => clearTimeout(timerId)
|
||||
}, [isOnline, isBannerVisible])
|
||||
|
||||
const message =
|
||||
offlineReason === 'server'
|
||||
? 'Server unreachable. Changes will sync when connection is restored.'
|
||||
: 'No internet connection. Some features may not be available.'
|
||||
|
||||
return (
|
||||
<Box sx={{}}>
|
||||
{!isOnline && (
|
||||
{!isOnline && isBannerVisible && (
|
||||
<Alert
|
||||
variant='soft'
|
||||
color='warning'
|
||||
@@ -36,7 +66,7 @@ const NetworkBanner = () => {
|
||||
}}
|
||||
startDecorator={<WifiOff />}
|
||||
>
|
||||
You are currently offline. Some features may not be available.
|
||||
{message}
|
||||
</Alert>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
160
src/views/components/NotificationPickerField.jsx
Normal file
160
src/views/components/NotificationPickerField.jsx
Normal file
@@ -0,0 +1,160 @@
|
||||
import { Close, NotificationsNone } from '@mui/icons-material'
|
||||
import { Box, Button, IconButton, Typography } from '@mui/joy'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import NotificationTemplate from '../../components/NotificationTemplate'
|
||||
import { useResponsiveModal } from '../../hooks/useResponsiveModal'
|
||||
|
||||
const getDisplayLabel = templates => {
|
||||
if (!templates || templates.length === 0) return 'Remind'
|
||||
const count = templates.length
|
||||
if (count === 1) {
|
||||
const n = templates[0]
|
||||
const numericValue = Number(n.value)
|
||||
if (numericValue === 0) return 'On due date'
|
||||
const unitName =
|
||||
n.unit === 'm' ? 'min' : n.unit === 'h' ? 'hr' : 'day'
|
||||
const absValue = Math.abs(numericValue)
|
||||
const plural = absValue !== 1 ? 's' : ''
|
||||
return `${absValue} ${unitName}${plural} ${numericValue < 0 ? 'before' : 'after'}`
|
||||
}
|
||||
return `${count} reminders`
|
||||
}
|
||||
|
||||
const NotificationPickerField = ({
|
||||
value,
|
||||
onChange,
|
||||
onClear,
|
||||
emptyDisplay = 'icon-text',
|
||||
size = 'sm',
|
||||
}) => {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const latestTemplatesRef = useRef(value?.templates || [])
|
||||
const { ResponsiveModal } = useResponsiveModal()
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
latestTemplatesRef.current = value?.templates || []
|
||||
}
|
||||
}, [isOpen, value])
|
||||
|
||||
const templates = value?.templates || []
|
||||
const hasNotifications = templates.length > 0
|
||||
const shouldShowLabel = hasNotifications || emptyDisplay === 'icon-text'
|
||||
const displayLabel = getDisplayLabel(templates)
|
||||
|
||||
const handleSave = () => {
|
||||
onChange({ ...value, templates: latestTemplatesRef.current })
|
||||
setIsOpen(false)
|
||||
}
|
||||
|
||||
const footer = (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'flex-end', gap: 1 }}>
|
||||
{hasNotifications && (
|
||||
<Button
|
||||
variant='plain'
|
||||
color='danger'
|
||||
size='lg'
|
||||
onClick={() => {
|
||||
onClear?.()
|
||||
setIsOpen(false)
|
||||
}}
|
||||
sx={{ mr: 'auto' }}
|
||||
>
|
||||
Remove all
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant='outlined'
|
||||
color='neutral'
|
||||
size='lg'
|
||||
onClick={() => setIsOpen(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant='solid' color='primary' size='lg' onClick={handleSave}>
|
||||
Apply
|
||||
</Button>
|
||||
</Box>
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<Box sx={{ position: 'relative', display: 'flex', alignItems: 'center' }}>
|
||||
<Button
|
||||
size={size}
|
||||
variant={hasNotifications ? 'soft' : 'outlined'}
|
||||
color='neutral'
|
||||
onClick={() => setIsOpen(true)}
|
||||
sx={{
|
||||
minHeight: 40,
|
||||
borderRadius: '128px',
|
||||
minWidth: 'min-content',
|
||||
px: shouldShowLabel ? 1.25 : 0.75,
|
||||
gap: shouldShowLabel ? 1 : 0,
|
||||
justifyContent: 'flex-start',
|
||||
whiteSpace: 'nowrap',
|
||||
transition: 'all 0.25s ease-in-out',
|
||||
}}
|
||||
>
|
||||
<NotificationsNone sx={{ fontSize: '20px' }} />
|
||||
<Typography
|
||||
level='body-sm'
|
||||
sx={{
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
maxWidth: shouldShowLabel ? 220 : 0,
|
||||
opacity: shouldShowLabel ? 1 : 0,
|
||||
transform: shouldShowLabel ? 'translateX(0)' : 'translateX(-4px)',
|
||||
transition:
|
||||
'max-width 0.25s ease-in-out, opacity 0.2s ease-in-out, transform 0.25s ease-in-out',
|
||||
}}
|
||||
>
|
||||
{displayLabel}
|
||||
</Typography>
|
||||
</Button>
|
||||
|
||||
{hasNotifications && onClear && (
|
||||
<IconButton
|
||||
size='sm'
|
||||
variant='soft'
|
||||
color='danger'
|
||||
onClick={e => {
|
||||
e.stopPropagation()
|
||||
onClear?.()
|
||||
}}
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: -12,
|
||||
right: -16,
|
||||
zIndex: 10,
|
||||
maxHeight: 18,
|
||||
maxWidth: 18,
|
||||
borderRadius: '50%',
|
||||
'&:hover': { bgcolor: 'danger.softBg' },
|
||||
}}
|
||||
>
|
||||
<Close sx={{ fontSize: '18px' }} />
|
||||
</IconButton>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<ResponsiveModal
|
||||
open={isOpen}
|
||||
onClose={() => setIsOpen(false)}
|
||||
title='Reminders'
|
||||
footer={footer}
|
||||
>
|
||||
<NotificationTemplate
|
||||
value={value}
|
||||
onChange={({ notifications }) => {
|
||||
latestTemplatesRef.current = notifications
|
||||
}}
|
||||
showTimeline
|
||||
/>
|
||||
</ResponsiveModal>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default NotificationPickerField
|
||||
595
src/views/components/PhotoTaskModal.jsx
Normal file
595
src/views/components/PhotoTaskModal.jsx
Normal file
@@ -0,0 +1,595 @@
|
||||
import {
|
||||
ArrowBack,
|
||||
CameraAlt,
|
||||
CheckCircle,
|
||||
Close,
|
||||
DocumentScanner,
|
||||
PhotoCamera,
|
||||
Replay,
|
||||
TextSnippet,
|
||||
} from '@mui/icons-material'
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
CircularProgress,
|
||||
IconButton,
|
||||
LinearProgress,
|
||||
Typography,
|
||||
} from '@mui/joy'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useDocumentScanner } from '../../hooks/useDocumentScanner'
|
||||
import { useResponsiveModal } from '../../hooks/useResponsiveModal'
|
||||
import { localAIService } from '../../service/LocalAIService'
|
||||
|
||||
const SYSTEM_PROMPT = `You are helping create tasks for a household task management app.
|
||||
|
||||
Given OCR text extracted from a photo, identify the most useful task a person should add to their task list.
|
||||
|
||||
The task title should always start with an action verb when possible.
|
||||
|
||||
Examples:
|
||||
|
||||
Bill -> "Pay water bill"
|
||||
Appointment -> "Attend eye doctor appointment"
|
||||
Invitation -> "RSVP for wedding"
|
||||
Renewal Notice -> "Renew vehicle registration"
|
||||
Package Notice -> "Pick up package"
|
||||
School Form -> "Complete school permission form"
|
||||
|
||||
Action Priority Rules:
|
||||
|
||||
1. Payments and bills
|
||||
2. Deadlines and renewals
|
||||
3. Appointments
|
||||
4. Required forms
|
||||
5. Informational actions (view, read, review)
|
||||
|
||||
|
||||
Rules:
|
||||
|
||||
Generate at most one task.
|
||||
Focus on the most important action.
|
||||
Extract due dates and deadlines.
|
||||
Use appointment dates as due dates when appropriate.
|
||||
Do not invent information.
|
||||
If the content contains no actionable item, return null values.
|
||||
Include any important ID or URL or instructions in the description
|
||||
Titles must be specific and useful at a glance.
|
||||
Include the organization, provider, event, or subject when available.
|
||||
Avoid generic document names.
|
||||
Return valid JSON only.
|
||||
Output:
|
||||
|
||||
{
|
||||
"taskName": string | null,
|
||||
"description": string | null,
|
||||
"dueDate": string | null,
|
||||
"confidence": number
|
||||
}`
|
||||
|
||||
async function runNativeOCR(imageSource) {
|
||||
const { Ocr } = await import('@jcesarmobile/capacitor-ocr')
|
||||
|
||||
// Convert Capacitor WebView file URL → native file:// URL the plugin can read
|
||||
const image = imageSource.includes('/_capacitor_file_/')
|
||||
? 'file://' + imageSource.replace(/^https?:\/\/localhost\/_capacitor_file_/, '')
|
||||
: imageSource
|
||||
|
||||
const result = await Ocr.process({ image })
|
||||
return result.results.map(r => r.text).join('\n').trim()
|
||||
}
|
||||
|
||||
async function runOCR(imageSource, onProgress) {
|
||||
const { createWorker } = await import('tesseract.js')
|
||||
const worker = await createWorker('eng', 1, {
|
||||
logger: m => {
|
||||
if (m.status === 'recognizing text' && onProgress) {
|
||||
onProgress(Math.round(m.progress * 100))
|
||||
}
|
||||
},
|
||||
})
|
||||
const { data } = await worker.recognize(imageSource)
|
||||
await worker.terminate()
|
||||
return data.text?.trim() || ''
|
||||
}
|
||||
|
||||
async function extractTaskFromOCR(ocrText) {
|
||||
const messages = [
|
||||
{ role: 'system', content: SYSTEM_PROMPT },
|
||||
{ role: 'user', content: `OCR Text:\n${ocrText}` },
|
||||
]
|
||||
|
||||
const result = await localAIService.plainChat(messages)
|
||||
if (!result) return null
|
||||
|
||||
const jsonMatch = result.match(/\{[\s\S]*\}/)
|
||||
if (!jsonMatch) return null
|
||||
|
||||
try {
|
||||
return JSON.parse(jsonMatch[0])
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const PhotoTaskModal = ({ open, onClose, onTaskExtracted }) => {
|
||||
const { ResponsiveModal } = useResponsiveModal()
|
||||
const { isNativeScanner, scanDocument } = useDocumentScanner()
|
||||
const videoRef = useRef(null)
|
||||
const canvasRef = useRef(null)
|
||||
const streamRef = useRef(null)
|
||||
const fileInputRef = useRef(null)
|
||||
|
||||
const [phase, setPhase] = useState('capture') // capture | preview | ocr | llm | done | error
|
||||
const [capturedImage, setCapturedImage] = useState(null)
|
||||
const [ocrProgress, setOcrProgress] = useState(0)
|
||||
const [ocrText, setOcrText] = useState('')
|
||||
const [ocrMethod, setOcrMethod] = useState('tesseract')
|
||||
const [showRawText, setShowRawText] = useState(false)
|
||||
const [taskResult, setTaskResult] = useState(null)
|
||||
const [errorMsg, setErrorMsg] = useState('')
|
||||
const [cameraAvailable, setCameraAvailable] = useState(true)
|
||||
|
||||
const startCamera = useCallback(async () => {
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({
|
||||
video: { facingMode: 'environment' },
|
||||
})
|
||||
streamRef.current = stream
|
||||
if (videoRef.current) {
|
||||
videoRef.current.srcObject = stream
|
||||
}
|
||||
setCameraAvailable(true)
|
||||
} catch {
|
||||
setCameraAvailable(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const stopCamera = useCallback(() => {
|
||||
if (streamRef.current) {
|
||||
streamRef.current.getTracks().forEach(t => t.stop())
|
||||
streamRef.current = null
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (open && phase === 'capture' && !isNativeScanner) {
|
||||
startCamera()
|
||||
}
|
||||
return () => {
|
||||
stopCamera()
|
||||
}
|
||||
}, [open, phase, isNativeScanner, startCamera, stopCamera])
|
||||
|
||||
const handleCapture = () => {
|
||||
if (!videoRef.current || !canvasRef.current) return
|
||||
const video = videoRef.current
|
||||
const canvas = canvasRef.current
|
||||
canvas.width = video.videoWidth
|
||||
canvas.height = video.videoHeight
|
||||
canvas.getContext('2d').drawImage(video, 0, 0)
|
||||
const dataUrl = canvas.toDataURL('image/jpeg', 0.9)
|
||||
setCapturedImage(dataUrl)
|
||||
stopCamera()
|
||||
setPhase('preview')
|
||||
}
|
||||
|
||||
const handleFileSelect = e => {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
const reader = new FileReader()
|
||||
reader.onload = ev => {
|
||||
setCapturedImage(ev.target.result)
|
||||
stopCamera()
|
||||
setPhase('preview')
|
||||
}
|
||||
reader.readAsDataURL(file)
|
||||
}
|
||||
|
||||
const handleBackToPreview = () => {
|
||||
setOcrText('')
|
||||
setTaskResult(null)
|
||||
setErrorMsg('')
|
||||
setOcrProgress(0)
|
||||
setShowRawText(false)
|
||||
setPhase('preview')
|
||||
}
|
||||
|
||||
const handleProcess = async (method = 'tesseract') => {
|
||||
setOcrMethod(method)
|
||||
setPhase('ocr')
|
||||
setOcrProgress(0)
|
||||
setErrorMsg('')
|
||||
setShowRawText(false)
|
||||
|
||||
try {
|
||||
let text
|
||||
if (method === 'native') {
|
||||
try {
|
||||
text = await runNativeOCR(capturedImage)
|
||||
} catch {
|
||||
throw new Error('Native OCR is only available on iOS and Android devices.')
|
||||
}
|
||||
} else {
|
||||
text = await runOCR(capturedImage, pct => setOcrProgress(pct))
|
||||
}
|
||||
setOcrText(text)
|
||||
|
||||
if (!text) {
|
||||
setErrorMsg('No text found in the image. Please try a clearer photo.')
|
||||
setPhase('error')
|
||||
return
|
||||
}
|
||||
|
||||
setPhase('llm')
|
||||
const task = await extractTaskFromOCR(text)
|
||||
|
||||
if (!task || !task.taskName) {
|
||||
setErrorMsg('Could not identify a task from this image. Please try a different photo.')
|
||||
setPhase('error')
|
||||
return
|
||||
}
|
||||
|
||||
setTaskResult(task)
|
||||
setPhase('done')
|
||||
} catch (e) {
|
||||
setErrorMsg(`Processing failed: ${e.message || 'Unknown error'}`)
|
||||
setPhase('error')
|
||||
}
|
||||
}
|
||||
|
||||
const handleRetake = () => {
|
||||
setCapturedImage(null)
|
||||
setOcrText('')
|
||||
setTaskResult(null)
|
||||
setErrorMsg('')
|
||||
setOcrProgress(0)
|
||||
setShowRawText(false)
|
||||
setPhase('capture')
|
||||
}
|
||||
|
||||
const handleNativeScan = async () => {
|
||||
const { image, cancelled, error } = await scanDocument()
|
||||
if (cancelled) return
|
||||
if (error || !image) {
|
||||
setErrorMsg(error ? `Scanner error: ${error}` : 'Scan cancelled or failed.')
|
||||
setPhase('error')
|
||||
return
|
||||
}
|
||||
setCapturedImage(image)
|
||||
stopCamera()
|
||||
setPhase('preview')
|
||||
}
|
||||
|
||||
const handleConfirm = () => {
|
||||
if (taskResult) {
|
||||
onTaskExtracted(taskResult)
|
||||
}
|
||||
handleClose()
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
stopCamera()
|
||||
setCapturedImage(null)
|
||||
setOcrText('')
|
||||
setTaskResult(null)
|
||||
setErrorMsg('')
|
||||
setOcrProgress(0)
|
||||
setShowRawText(false)
|
||||
setPhase('capture')
|
||||
onClose()
|
||||
}
|
||||
|
||||
const isProcessing = phase === 'ocr' || phase === 'llm'
|
||||
|
||||
return (
|
||||
<ResponsiveModal
|
||||
open={open}
|
||||
onClose={handleClose}
|
||||
size='md'
|
||||
fullWidth
|
||||
title='Scan photo to create task'
|
||||
>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
{(phase === 'capture' || phase === 'preview') && (
|
||||
<Box
|
||||
sx={{
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
borderRadius: 'md',
|
||||
overflow: 'hidden',
|
||||
bgcolor: 'background.level1',
|
||||
minHeight: 240,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
{phase === 'capture' && !isNativeScanner && cameraAvailable && (
|
||||
<video
|
||||
ref={videoRef}
|
||||
autoPlay
|
||||
playsInline
|
||||
muted
|
||||
style={{ width: '100%', display: 'block' }}
|
||||
/>
|
||||
)}
|
||||
{phase === 'capture' && isNativeScanner && (
|
||||
<Box sx={{ textAlign: 'center', p: 4 }}>
|
||||
<DocumentScanner sx={{ fontSize: 64, opacity: 0.4, mb: 1 }} />
|
||||
<Typography level='body-sm' sx={{ opacity: 0.6 }}>
|
||||
Tap "Scan Document" to open the scanner
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
{phase === 'capture' && !isNativeScanner && !cameraAvailable && (
|
||||
<Box sx={{ textAlign: 'center', p: 3 }}>
|
||||
<CameraAlt sx={{ fontSize: 48, opacity: 0.5, mb: 1 }} />
|
||||
<Typography level='body-sm' sx={{ opacity: 0.7 }}>
|
||||
Camera not available
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
{phase === 'preview' && capturedImage && (
|
||||
<img
|
||||
src={capturedImage}
|
||||
alt='Captured document'
|
||||
style={{ width: '100%', display: 'block' }}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<canvas ref={canvasRef} style={{ display: 'none' }} />
|
||||
|
||||
{isProcessing && (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
gap: 2,
|
||||
py: 4,
|
||||
}}
|
||||
>
|
||||
{capturedImage && (
|
||||
<img
|
||||
src={capturedImage}
|
||||
alt='Processing'
|
||||
style={{
|
||||
width: '100%',
|
||||
borderRadius: 8,
|
||||
opacity: 0.6,
|
||||
maxHeight: 200,
|
||||
objectFit: 'contain',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<CircularProgress size='md' />
|
||||
{phase === 'ocr' && ocrMethod === 'tesseract' && (
|
||||
<>
|
||||
<Typography level='body-sm'>
|
||||
Reading text from image… {ocrProgress}%
|
||||
</Typography>
|
||||
<LinearProgress determinate value={ocrProgress} sx={{ width: '100%' }} />
|
||||
</>
|
||||
)}
|
||||
{phase === 'ocr' && ocrMethod === 'native' && (
|
||||
<Typography level='body-sm'>Running native OCR…</Typography>
|
||||
)}
|
||||
{phase === 'llm' && (
|
||||
<Typography level='body-sm'>Identifying task with AI…</Typography>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{phase === 'done' && taskResult && (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<CheckCircle color='success' />
|
||||
<Typography level='title-sm'>Task identified</Typography>
|
||||
</Box>
|
||||
<Box
|
||||
sx={{
|
||||
p: 1.5,
|
||||
borderRadius: 'md',
|
||||
bgcolor: 'background.level1',
|
||||
border: '1px solid',
|
||||
borderColor: 'divider',
|
||||
}}
|
||||
>
|
||||
<Typography level='title-sm'>{taskResult.taskName}</Typography>
|
||||
{taskResult.description && (
|
||||
<Typography level='body-xs' sx={{ mt: 0.5, opacity: 0.8 }}>
|
||||
{taskResult.description}
|
||||
</Typography>
|
||||
)}
|
||||
{taskResult.dueDate && (
|
||||
<Typography level='body-xs' sx={{ mt: 0.5, opacity: 0.7 }}>
|
||||
Due: {taskResult.dueDate}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
{ocrText && (
|
||||
<>
|
||||
<Button
|
||||
size='sm'
|
||||
variant='plain'
|
||||
color='neutral'
|
||||
startDecorator={<TextSnippet />}
|
||||
onClick={() => setShowRawText(v => !v)}
|
||||
sx={{ alignSelf: 'flex-start' }}
|
||||
>
|
||||
{showRawText ? 'Hide Raw Text' : 'Show Raw Text'}
|
||||
</Button>
|
||||
{showRawText && (
|
||||
<Box
|
||||
sx={{
|
||||
p: 1.5,
|
||||
borderRadius: 'md',
|
||||
bgcolor: 'background.level2',
|
||||
border: '1px solid',
|
||||
borderColor: 'divider',
|
||||
maxHeight: 180,
|
||||
overflowY: 'auto',
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
level='body-xs'
|
||||
sx={{ whiteSpace: 'pre-wrap', fontFamily: 'monospace' }}
|
||||
>
|
||||
{ocrText}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{phase === 'error' && (
|
||||
<Box
|
||||
sx={{
|
||||
p: 2,
|
||||
borderRadius: 'md',
|
||||
bgcolor: 'danger.softBg',
|
||||
color: 'danger.softColor',
|
||||
}}
|
||||
>
|
||||
<Typography level='body-sm'>{errorMsg}</Typography>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Box sx={{ display: 'flex', gap: 1, justifyContent: 'flex-end' }}>
|
||||
{phase === 'capture' && (
|
||||
<>
|
||||
<Button
|
||||
variant='outlined'
|
||||
color='neutral'
|
||||
startDecorator={<PhotoCamera />}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
>
|
||||
Upload Photo
|
||||
</Button>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type='file'
|
||||
accept='image/*'
|
||||
style={{ display: 'none' }}
|
||||
onChange={handleFileSelect}
|
||||
/>
|
||||
{isNativeScanner ? (
|
||||
<Button
|
||||
variant='solid'
|
||||
color='primary'
|
||||
startDecorator={<DocumentScanner />}
|
||||
onClick={handleNativeScan}
|
||||
>
|
||||
Scan Document
|
||||
</Button>
|
||||
) : (
|
||||
cameraAvailable && (
|
||||
<Button
|
||||
variant='solid'
|
||||
color='primary'
|
||||
startDecorator={<CameraAlt />}
|
||||
onClick={handleCapture}
|
||||
>
|
||||
Capture
|
||||
</Button>
|
||||
)
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{phase === 'preview' && (
|
||||
<>
|
||||
<Button
|
||||
variant='outlined'
|
||||
color='neutral'
|
||||
startDecorator={<Replay />}
|
||||
onClick={handleRetake}
|
||||
>
|
||||
Retake
|
||||
</Button>
|
||||
{isNativeScanner && (
|
||||
<Button
|
||||
variant='outlined'
|
||||
color='primary'
|
||||
onClick={() => handleProcess('native')}
|
||||
>
|
||||
Process Natively
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant='solid'
|
||||
color='primary'
|
||||
onClick={() => handleProcess('tesseract')}
|
||||
>
|
||||
Process Image
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{phase === 'error' && (
|
||||
<>
|
||||
<Button
|
||||
variant='outlined'
|
||||
color='neutral'
|
||||
startDecorator={<ArrowBack />}
|
||||
onClick={handleBackToPreview}
|
||||
>
|
||||
Back
|
||||
</Button>
|
||||
<Button
|
||||
variant='outlined'
|
||||
color='neutral'
|
||||
startDecorator={<Replay />}
|
||||
onClick={handleRetake}
|
||||
>
|
||||
Retake
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{phase === 'done' && (
|
||||
<>
|
||||
<Button
|
||||
variant='outlined'
|
||||
color='neutral'
|
||||
startDecorator={<ArrowBack />}
|
||||
onClick={handleBackToPreview}
|
||||
>
|
||||
Back
|
||||
</Button>
|
||||
<Button
|
||||
variant='outlined'
|
||||
color='neutral'
|
||||
startDecorator={<Replay />}
|
||||
onClick={handleRetake}
|
||||
>
|
||||
Retake
|
||||
</Button>
|
||||
<Button variant='solid' color='primary' onClick={handleConfirm}>
|
||||
Create Task
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{!isProcessing && (
|
||||
<IconButton
|
||||
variant='plain'
|
||||
color='neutral'
|
||||
onClick={handleClose}
|
||||
sx={{ ml: 'auto' }}
|
||||
>
|
||||
<Close />
|
||||
</IconButton>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
</ResponsiveModal>
|
||||
)
|
||||
}
|
||||
|
||||
export default PhotoTaskModal
|
||||
74
src/views/components/PriorityPickerField.jsx
Normal file
74
src/views/components/PriorityPickerField.jsx
Normal file
@@ -0,0 +1,74 @@
|
||||
import { Flag } from '@mui/icons-material'
|
||||
import BaseOptionPicker from './BaseOptionPicker'
|
||||
|
||||
const defaultPriorityColors = {
|
||||
0: '#9CA3AF',
|
||||
1: '#EF4444',
|
||||
2: '#F97316',
|
||||
3: '#FBBF24',
|
||||
4: '#3B82F6',
|
||||
}
|
||||
|
||||
const defaultPriorityLabels = {
|
||||
0: 'No Priority',
|
||||
1: 'P1',
|
||||
2: 'P2',
|
||||
3: 'P3',
|
||||
4: 'P4',
|
||||
}
|
||||
|
||||
const PriorityPickerField = ({
|
||||
value = 0,
|
||||
onChange,
|
||||
onClear,
|
||||
emptyDisplay = 'icon-text',
|
||||
priorityColors = defaultPriorityColors,
|
||||
priorityLabels = defaultPriorityLabels,
|
||||
size = 'sm',
|
||||
}) => {
|
||||
const options = [1, 2, 3, 4].map(priorityOption => ({
|
||||
id: priorityOption,
|
||||
label: priorityLabels[priorityOption],
|
||||
color: priorityColors[priorityOption],
|
||||
}))
|
||||
|
||||
// Don't add the 0 option to the menu - priority 0 is the "empty" state (icon only)
|
||||
|
||||
return (
|
||||
<BaseOptionPicker
|
||||
items={options}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
onClear={onClear}
|
||||
emptyDisplay={emptyDisplay}
|
||||
size={size}
|
||||
getItemValue={item => item.id}
|
||||
getItemLabel={item => item.label}
|
||||
getItemColor={item => item.color}
|
||||
getTriggerText={({ selectedItems, isEmpty }) => {
|
||||
// For priority 0 (no priority), show empty string (icon only)
|
||||
if (value === 0 || isEmpty) return 'Priority'
|
||||
return selectedItems[0]?.label || ''
|
||||
}}
|
||||
renderTriggerIcon={({ selectedItems, isEmpty }) => (
|
||||
<Flag
|
||||
sx={{
|
||||
color: isEmpty || value === 0 ? '' : selectedItems[0]?.color,
|
||||
fontSize: '20px',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
renderItemStart={({ item }) => (
|
||||
<Flag
|
||||
sx={{
|
||||
color: item.color,
|
||||
fontSize: '18px',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
menuMinWidth={180}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default PriorityPickerField
|
||||
48
src/views/components/ProjectPickerField.jsx
Normal file
48
src/views/components/ProjectPickerField.jsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import { FolderOpen } from '@mui/icons-material'
|
||||
import BaseOptionPicker from './BaseOptionPicker'
|
||||
|
||||
const ProjectPickerField = ({
|
||||
value = 'default',
|
||||
onChange,
|
||||
onClear,
|
||||
projects = [],
|
||||
emptyDisplay = 'icon-text',
|
||||
}) => {
|
||||
const options = [
|
||||
{ id: 'default', name: 'Default Project', color: '#9CA3AF' },
|
||||
...projects.map(project => ({
|
||||
id: project.id,
|
||||
name: project.name,
|
||||
color: project.color,
|
||||
})),
|
||||
]
|
||||
|
||||
return (
|
||||
<BaseOptionPicker
|
||||
items={options}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
onClear={onClear}
|
||||
emptyDisplay={emptyDisplay}
|
||||
emptyLabel='Project'
|
||||
getItemValue={item => item.id}
|
||||
getItemLabel={item => item.name}
|
||||
getItemColor={item => item.color}
|
||||
renderTriggerIcon={() => <FolderOpen sx={{ fontSize: '20px' }} />}
|
||||
renderItemStart={({ item }) => (
|
||||
<FolderOpen
|
||||
sx={{
|
||||
fontSize: '18px',
|
||||
color: item.color || 'text.secondary',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
getTriggerText={({ selectedItems, isEmpty }) =>
|
||||
isEmpty ? 'Project' : selectedItems[0].name
|
||||
}
|
||||
menuMinWidth={240}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default ProjectPickerField
|
||||
639
src/views/components/RepeatPickerField.jsx
Normal file
639
src/views/components/RepeatPickerField.jsx
Normal file
@@ -0,0 +1,639 @@
|
||||
import { Close, Repeat } from '@mui/icons-material'
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Checkbox,
|
||||
Divider,
|
||||
IconButton,
|
||||
Input,
|
||||
List,
|
||||
ListItem,
|
||||
Radio,
|
||||
RadioGroup,
|
||||
Typography,
|
||||
} from '@mui/joy'
|
||||
import moment from 'moment'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { getRecurrentChipText } from '../../utils/ChoreCardHelpers'
|
||||
import { useResponsiveModal } from '../../hooks/useResponsiveModal'
|
||||
|
||||
const FREQUENCY_TYPES = [
|
||||
'daily',
|
||||
'weekly',
|
||||
'monthly',
|
||||
'yearly',
|
||||
'adaptive',
|
||||
'custom',
|
||||
]
|
||||
const REPEAT_ON_TYPE = ['interval', 'days_of_the_week', 'day_of_the_month']
|
||||
|
||||
const DAYS = [
|
||||
'monday',
|
||||
'tuesday',
|
||||
'wednesday',
|
||||
'thursday',
|
||||
'friday',
|
||||
'saturday',
|
||||
'sunday',
|
||||
]
|
||||
|
||||
const MONTHS = [
|
||||
'january',
|
||||
'february',
|
||||
'march',
|
||||
'april',
|
||||
'may',
|
||||
'june',
|
||||
'july',
|
||||
'august',
|
||||
'september',
|
||||
'october',
|
||||
'november',
|
||||
'december',
|
||||
]
|
||||
|
||||
const OCCURRENCE_OPTIONS = [
|
||||
{ value: 1, label: '1st' },
|
||||
{ value: 2, label: '2nd' },
|
||||
{ value: 3, label: '3rd' },
|
||||
{ value: 4, label: '4th' },
|
||||
{ value: -1, label: 'Last' },
|
||||
]
|
||||
|
||||
const defaultMetadata = () => ({
|
||||
unit: 'days',
|
||||
time: moment(moment(new Date()).format('YYYY-MM-DD') + 'T18:00').format(),
|
||||
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||
})
|
||||
|
||||
const initLocalState = value => {
|
||||
if (!value) {
|
||||
return {
|
||||
frequencyType: 'daily',
|
||||
frequency: 1,
|
||||
frequencyMetadata: defaultMetadata(),
|
||||
}
|
||||
}
|
||||
|
||||
let { frequencyType, frequency, frequencyMetadata } = value
|
||||
|
||||
// Normalize parser output: interval/1/days → daily, etc.
|
||||
if (frequencyType === 'interval' && frequency === 1) {
|
||||
const unitTypeMap = {
|
||||
days: 'daily',
|
||||
weeks: 'weekly',
|
||||
months: 'monthly',
|
||||
years: 'yearly',
|
||||
}
|
||||
frequencyType = unitTypeMap[frequencyMetadata?.unit] || frequencyType
|
||||
}
|
||||
|
||||
return {
|
||||
frequencyType,
|
||||
frequency: frequency ?? 1,
|
||||
frequencyMetadata: {
|
||||
...defaultMetadata(),
|
||||
...frequencyMetadata,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const getDisplayType = frequencyType =>
|
||||
REPEAT_ON_TYPE.includes(frequencyType) ? 'custom' : frequencyType
|
||||
|
||||
// Shared section label
|
||||
const SectionLabel = ({ children }) => (
|
||||
<Typography
|
||||
level='body-xs'
|
||||
sx={{
|
||||
color: 'text.tertiary',
|
||||
mb: 0.75,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.05em',
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</Typography>
|
||||
)
|
||||
|
||||
// Shared time-of-day picker
|
||||
const TimeRow = ({ metadata, onUpdate }) => (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, mt: 2 }}>
|
||||
<SectionLabel>Time of day</SectionLabel>
|
||||
<Input
|
||||
type='time'
|
||||
size='sm'
|
||||
value={moment(metadata?.time).format('HH:mm')}
|
||||
onChange={e =>
|
||||
onUpdate({
|
||||
...metadata,
|
||||
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||
time: moment(
|
||||
moment(new Date()).format('YYYY-MM-DD') + 'T' + e.target.value,
|
||||
).format(),
|
||||
})
|
||||
}
|
||||
sx={{ width: 120 }}
|
||||
/>
|
||||
</Box>
|
||||
)
|
||||
|
||||
const pillListSx = {
|
||||
'--List-gap': '8px',
|
||||
'--ListItem-radius': '20px',
|
||||
}
|
||||
|
||||
// Interval section
|
||||
const IntervalSection = ({
|
||||
frequency,
|
||||
frequencyMetadata,
|
||||
onFrequencyUpdate,
|
||||
onFrequencyMetadataUpdate,
|
||||
}) => (
|
||||
<Box>
|
||||
<SectionLabel>Repeat every</SectionLabel>
|
||||
<Box
|
||||
sx={{ display: 'flex', alignItems: 'center', gap: 1, flexWrap: 'wrap' }}
|
||||
>
|
||||
<Input
|
||||
type='number'
|
||||
size='sm'
|
||||
value={frequency}
|
||||
onChange={e =>
|
||||
onFrequencyUpdate(Math.max(1, parseInt(e.target.value, 10) || 1))
|
||||
}
|
||||
sx={{ width: 72 }}
|
||||
slotProps={{ input: { min: 1, max: 999 } }}
|
||||
/>
|
||||
<List orientation='horizontal' wrap sx={pillListSx}>
|
||||
{['days', 'weeks', 'months', 'years'].map(unit => (
|
||||
<ListItem key={unit}>
|
||||
<Checkbox
|
||||
checked={frequencyMetadata?.unit === unit}
|
||||
onClick={() =>
|
||||
onFrequencyMetadataUpdate({ ...frequencyMetadata, unit })
|
||||
}
|
||||
overlay
|
||||
disableIcon
|
||||
variant='soft'
|
||||
label={unit.charAt(0).toUpperCase() + unit.slice(1)}
|
||||
/>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
</Box>
|
||||
<TimeRow
|
||||
metadata={frequencyMetadata}
|
||||
onUpdate={onFrequencyMetadataUpdate}
|
||||
/>
|
||||
</Box>
|
||||
)
|
||||
|
||||
// Days of week section
|
||||
const DaysOfWeekSection = ({
|
||||
frequencyMetadata,
|
||||
onFrequencyMetadataUpdate,
|
||||
}) => {
|
||||
const selectedDays = frequencyMetadata?.days || []
|
||||
const weekPattern = frequencyMetadata?.weekPattern || 'every_week'
|
||||
const selectedOccurrences = frequencyMetadata?.occurrences || []
|
||||
|
||||
const toggleDay = day => {
|
||||
const next = selectedDays.includes(day)
|
||||
? selectedDays.filter(d => d !== day)
|
||||
: [...selectedDays, day]
|
||||
onFrequencyMetadataUpdate({ ...frequencyMetadata, days: next })
|
||||
}
|
||||
|
||||
const toggleOccurrence = val => {
|
||||
const next = selectedOccurrences.includes(val)
|
||||
? selectedOccurrences.filter(v => v !== val)
|
||||
: [...selectedOccurrences, val]
|
||||
onFrequencyMetadataUpdate({ ...frequencyMetadata, occurrences: next })
|
||||
}
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<SectionLabel>Days</SectionLabel>
|
||||
<List orientation='horizontal' wrap sx={pillListSx}>
|
||||
{DAYS.map(day => (
|
||||
<ListItem key={day}>
|
||||
<Checkbox
|
||||
checked={selectedDays.includes(day)}
|
||||
onClick={() => toggleDay(day)}
|
||||
overlay
|
||||
disableIcon
|
||||
variant='soft'
|
||||
label={day.charAt(0).toUpperCase() + day.slice(1, 3)}
|
||||
/>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<SectionLabel>Pattern</SectionLabel>
|
||||
<RadioGroup
|
||||
orientation='horizontal'
|
||||
value={weekPattern}
|
||||
onChange={e =>
|
||||
onFrequencyMetadataUpdate({
|
||||
...frequencyMetadata,
|
||||
weekPattern: e.target.value,
|
||||
occurrences:
|
||||
e.target.value === 'every_week' ? [] : selectedOccurrences,
|
||||
})
|
||||
}
|
||||
sx={{
|
||||
padding: '3px',
|
||||
borderRadius: '10px',
|
||||
bgcolor: 'neutral.softBg',
|
||||
'--RadioGroup-gap': '3px',
|
||||
'--Radio-actionRadius': '7px',
|
||||
display: 'inline-flex',
|
||||
}}
|
||||
>
|
||||
{[
|
||||
{ value: 'every_week', label: 'Every week' },
|
||||
{ value: 'week_of_month', label: 'Specific weeks' },
|
||||
].map(opt => (
|
||||
<Radio
|
||||
key={opt.value}
|
||||
value={opt.value}
|
||||
color='neutral'
|
||||
disableIcon
|
||||
label={opt.label}
|
||||
variant='plain'
|
||||
sx={{ px: 1.5, py: 0.5 }}
|
||||
slotProps={{
|
||||
action: ({ checked }) => ({
|
||||
sx: checked
|
||||
? {
|
||||
bgcolor: 'background.surface',
|
||||
boxShadow: 'sm',
|
||||
'&:hover': { bgcolor: 'background.surface' },
|
||||
}
|
||||
: {},
|
||||
}),
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</RadioGroup>
|
||||
</Box>
|
||||
|
||||
{weekPattern === 'week_of_month' && (
|
||||
<Box sx={{ mt: 1.5 }}>
|
||||
<SectionLabel>Occurrences</SectionLabel>
|
||||
<List orientation='horizontal' wrap sx={pillListSx}>
|
||||
{OCCURRENCE_OPTIONS.map(opt => (
|
||||
<ListItem key={opt.value}>
|
||||
<Checkbox
|
||||
checked={selectedOccurrences.includes(opt.value)}
|
||||
onClick={() => toggleOccurrence(opt.value)}
|
||||
overlay
|
||||
disableIcon
|
||||
variant='soft'
|
||||
label={opt.label}
|
||||
/>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<TimeRow
|
||||
metadata={frequencyMetadata}
|
||||
onUpdate={onFrequencyMetadataUpdate}
|
||||
/>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
// Day of month section
|
||||
const DayOfMonthSection = ({
|
||||
frequency,
|
||||
frequencyMetadata,
|
||||
onFrequencyUpdate,
|
||||
onFrequencyMetadataUpdate,
|
||||
}) => {
|
||||
const selectedMonths = frequencyMetadata?.months || []
|
||||
|
||||
const toggleMonth = month => {
|
||||
const next = selectedMonths.includes(month)
|
||||
? selectedMonths.filter(m => m !== month)
|
||||
: [...selectedMonths, month]
|
||||
onFrequencyMetadataUpdate({ ...frequencyMetadata, months: next })
|
||||
}
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<SectionLabel>Months</SectionLabel>
|
||||
<List orientation='horizontal' wrap sx={pillListSx}>
|
||||
{MONTHS.map(month => (
|
||||
<ListItem key={month}>
|
||||
<Checkbox
|
||||
checked={selectedMonths.includes(month)}
|
||||
onClick={() => toggleMonth(month)}
|
||||
overlay
|
||||
disableIcon
|
||||
variant='soft'
|
||||
label={month.charAt(0).toUpperCase() + month.slice(1, 3)}
|
||||
/>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, mt: 2 }}>
|
||||
<SectionLabel>Day of month</SectionLabel>
|
||||
<Input
|
||||
type='number'
|
||||
size='sm'
|
||||
value={frequency}
|
||||
onChange={e => {
|
||||
const v = Math.min(
|
||||
31,
|
||||
Math.max(1, parseInt(e.target.value, 10) || 1),
|
||||
)
|
||||
onFrequencyUpdate(v)
|
||||
}}
|
||||
sx={{ width: 72 }}
|
||||
slotProps={{ input: { min: 1, max: 31 } }}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<TimeRow
|
||||
metadata={frequencyMetadata}
|
||||
onUpdate={onFrequencyMetadataUpdate}
|
||||
/>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
const RepeatPickerField = ({
|
||||
value,
|
||||
onChange,
|
||||
onClear,
|
||||
emptyDisplay = 'icon-text',
|
||||
size = 'sm',
|
||||
}) => {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const [localFrequencyType, setLocalFrequencyType] = useState('daily')
|
||||
const [localFrequency, setLocalFrequency] = useState(1)
|
||||
const [localFrequencyMetadata, setLocalFrequencyMetadata] =
|
||||
useState(defaultMetadata)
|
||||
const { ResponsiveModal } = useResponsiveModal()
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return
|
||||
const init = initLocalState(value)
|
||||
setLocalFrequencyType(init.frequencyType)
|
||||
setLocalFrequency(init.frequency)
|
||||
setLocalFrequencyMetadata(init.frequencyMetadata)
|
||||
}, [isOpen, value])
|
||||
|
||||
const hasRepeat = Boolean(value)
|
||||
const shouldShowLabel = hasRepeat || emptyDisplay === 'icon-text'
|
||||
const displayLabel = hasRepeat ? getRecurrentChipText(value) : 'Repeat'
|
||||
const displayType = getDisplayType(localFrequencyType)
|
||||
|
||||
const handleTypeSelect = type => {
|
||||
if (type === 'custom') {
|
||||
setLocalFrequencyType('interval')
|
||||
setLocalFrequency(1)
|
||||
setLocalFrequencyMetadata({ ...defaultMetadata(), unit: 'days' })
|
||||
} else {
|
||||
setLocalFrequencyType(type)
|
||||
setLocalFrequency(1)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubTypeSelect = newType => {
|
||||
setLocalFrequencyType(newType)
|
||||
if (newType === 'interval') {
|
||||
setLocalFrequency(1)
|
||||
setLocalFrequencyMetadata(prev => ({ ...prev, unit: 'days' }))
|
||||
} else if (newType === 'days_of_the_week') {
|
||||
setLocalFrequencyMetadata(prev => ({
|
||||
...prev,
|
||||
days: [],
|
||||
weekPattern: 'every_week',
|
||||
occurrences: [],
|
||||
}))
|
||||
} else if (newType === 'day_of_the_month') {
|
||||
setLocalFrequency(1)
|
||||
setLocalFrequencyMetadata(prev => ({ ...prev, months: [] }))
|
||||
}
|
||||
}
|
||||
|
||||
const handleSave = () => {
|
||||
onChange({
|
||||
frequencyType: localFrequencyType,
|
||||
frequency: localFrequency,
|
||||
frequencyMetadata: localFrequencyMetadata,
|
||||
})
|
||||
setIsOpen(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Box sx={{ position: 'relative', display: 'flex', alignItems: 'center' }}>
|
||||
<Button
|
||||
size={size}
|
||||
variant={hasRepeat ? 'soft' : 'outlined'}
|
||||
color='neutral'
|
||||
onClick={() => setIsOpen(true)}
|
||||
sx={{
|
||||
minHeight: 40,
|
||||
borderRadius: '128px',
|
||||
minWidth: 'min-content',
|
||||
px: shouldShowLabel ? 1.25 : 0.75,
|
||||
gap: shouldShowLabel ? 1 : 0,
|
||||
justifyContent: 'flex-start',
|
||||
whiteSpace: 'nowrap',
|
||||
transition: 'all 0.25s ease-in-out',
|
||||
}}
|
||||
>
|
||||
<Repeat sx={{ fontSize: '20px' }} />
|
||||
<Typography
|
||||
level='body-sm'
|
||||
sx={{
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
maxWidth: shouldShowLabel ? 220 : 0,
|
||||
opacity: shouldShowLabel ? 1 : 0,
|
||||
transform: shouldShowLabel ? 'translateX(0)' : 'translateX(-4px)',
|
||||
transition:
|
||||
'max-width 0.25s ease-in-out, opacity 0.2s ease-in-out, transform 0.25s ease-in-out',
|
||||
}}
|
||||
>
|
||||
{displayLabel}
|
||||
</Typography>
|
||||
</Button>
|
||||
|
||||
{hasRepeat && onClear && (
|
||||
<IconButton
|
||||
size='sm'
|
||||
variant='soft'
|
||||
color='danger'
|
||||
onClick={e => {
|
||||
e.stopPropagation()
|
||||
onClear?.()
|
||||
}}
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: -12,
|
||||
right: -16,
|
||||
zIndex: 10,
|
||||
maxHeight: 18,
|
||||
maxWidth: 18,
|
||||
borderRadius: '50%',
|
||||
'&:hover': { bgcolor: 'danger.softBg' },
|
||||
}}
|
||||
>
|
||||
<Close sx={{ fontSize: '18px' }} />
|
||||
</IconButton>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<ResponsiveModal
|
||||
open={isOpen}
|
||||
onClose={() => setIsOpen(false)}
|
||||
title='Repeat Schedule'
|
||||
footer={
|
||||
<Box sx={{ display: 'flex', justifyContent: 'flex-end', gap: 1 }}>
|
||||
{hasRepeat && (
|
||||
<Button
|
||||
variant='plain'
|
||||
color='danger'
|
||||
size='lg'
|
||||
onClick={() => {
|
||||
onClear?.()
|
||||
setIsOpen(false)
|
||||
}}
|
||||
sx={{ mr: 'auto' }}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant='outlined'
|
||||
color='neutral'
|
||||
size='lg'
|
||||
onClick={() => setIsOpen(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant='solid'
|
||||
color='primary'
|
||||
size='lg'
|
||||
onClick={handleSave}
|
||||
>
|
||||
Apply
|
||||
</Button>
|
||||
</Box>
|
||||
}
|
||||
>
|
||||
{/* Frequency type selector */}
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2.5 }}>
|
||||
<Box>
|
||||
<SectionLabel>Frequency</SectionLabel>
|
||||
<List orientation='horizontal' wrap sx={pillListSx}>
|
||||
{FREQUENCY_TYPES.map(type => (
|
||||
<ListItem key={type}>
|
||||
<Checkbox
|
||||
checked={displayType === type}
|
||||
onClick={() => handleTypeSelect(type)}
|
||||
overlay
|
||||
disableIcon
|
||||
variant='soft'
|
||||
label={type.charAt(0).toUpperCase() + type.slice(1)}
|
||||
/>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
</Box>
|
||||
|
||||
{/* Custom sub-type + detail panel */}
|
||||
{displayType === 'custom' && (
|
||||
<>
|
||||
<Box>
|
||||
<SectionLabel>Schedule type</SectionLabel>
|
||||
<RadioGroup
|
||||
orientation='horizontal'
|
||||
value={localFrequencyType}
|
||||
onChange={e => handleSubTypeSelect(e.target.value)}
|
||||
sx={{
|
||||
padding: '3px',
|
||||
borderRadius: '10px',
|
||||
bgcolor: 'neutral.softBg',
|
||||
'--RadioGroup-gap': '3px',
|
||||
'--Radio-actionRadius': '7px',
|
||||
display: 'inline-flex',
|
||||
}}
|
||||
>
|
||||
{REPEAT_ON_TYPE.map(type => (
|
||||
<Radio
|
||||
key={type}
|
||||
value={type}
|
||||
color='neutral'
|
||||
disableIcon
|
||||
label={type
|
||||
.split('_')
|
||||
.map((w, i, arr) =>
|
||||
i === 0 || i === arr.length - 1
|
||||
? w.charAt(0).toUpperCase() + w.slice(1)
|
||||
: w,
|
||||
)
|
||||
.join(' ')}
|
||||
variant='plain'
|
||||
sx={{ px: 1.5, py: 0.5 }}
|
||||
slotProps={{
|
||||
action: ({ checked }) => ({
|
||||
sx: checked
|
||||
? {
|
||||
bgcolor: 'background.surface',
|
||||
boxShadow: 'sm',
|
||||
'&:hover': { bgcolor: 'background.surface' },
|
||||
}
|
||||
: {},
|
||||
}),
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</RadioGroup>
|
||||
</Box>
|
||||
|
||||
<Divider />
|
||||
|
||||
{localFrequencyType === 'interval' && (
|
||||
<IntervalSection
|
||||
frequency={localFrequency}
|
||||
frequencyMetadata={localFrequencyMetadata}
|
||||
onFrequencyUpdate={setLocalFrequency}
|
||||
onFrequencyMetadataUpdate={setLocalFrequencyMetadata}
|
||||
/>
|
||||
)}
|
||||
{localFrequencyType === 'days_of_the_week' && (
|
||||
<DaysOfWeekSection
|
||||
frequencyMetadata={localFrequencyMetadata}
|
||||
onFrequencyMetadataUpdate={setLocalFrequencyMetadata}
|
||||
/>
|
||||
)}
|
||||
{localFrequencyType === 'day_of_the_month' && (
|
||||
<DayOfMonthSection
|
||||
frequency={localFrequency}
|
||||
frequencyMetadata={localFrequencyMetadata}
|
||||
onFrequencyUpdate={setLocalFrequency}
|
||||
onFrequencyMetadataUpdate={setLocalFrequencyMetadata}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
</ResponsiveModal>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default RepeatPickerField
|
||||
@@ -53,20 +53,33 @@
|
||||
}
|
||||
|
||||
/* Material-UI style customizations for Quill toolbar */
|
||||
.quill-root {
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
background-color: var(--joy-palette-neutral-softBg, #f0f4f8);
|
||||
transition: box-shadow 0.15s ease, background-color 0.15s ease;
|
||||
}
|
||||
|
||||
.quill-root:focus-within {
|
||||
box-shadow: 0 0 0 1px var(--joy-palette-primary-outlinedBorder, rgba(11, 107, 203, 0.15));
|
||||
}
|
||||
|
||||
.quill-root:hover:not(:focus-within) {
|
||||
background-color: var(--joy-palette-neutral-softHoverBg, #dde7ee);
|
||||
}
|
||||
|
||||
.quill-root .ql-toolbar.ql-snow {
|
||||
border: 1px solid var(--joy-palette-neutral-outlinedBorder, #dde7ee);
|
||||
border-bottom: none;
|
||||
border-radius: 8px 8px 0 0;
|
||||
background: var(--joy-palette-background-surface, #fff);
|
||||
box-shadow: var(--joy-shadow-xs, 0px 1px 2px 0px rgba(16, 24, 40, 0.05));
|
||||
padding: 12px 16px;
|
||||
border: none;
|
||||
border-bottom: 1px solid var(--joy-palette-neutral-softActiveBg, rgba(99, 107, 116, 0.16));
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
padding: 8px 12px;
|
||||
}
|
||||
|
||||
.quill-root .ql-container.ql-snow {
|
||||
border: 1px solid var(--joy-palette-neutral-outlinedBorder, #dde7ee);
|
||||
border-top: none;
|
||||
border-radius: 0 0 8px 8px;
|
||||
background: var(--joy-palette-background-surface, #fff);
|
||||
border: none;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
/* Style toolbar buttons with Material-UI look */
|
||||
|
||||
@@ -2,6 +2,33 @@ import imageCompression from 'browser-image-compression'
|
||||
import Quill from 'quill'
|
||||
import 'quill/dist/quill.snow.css'
|
||||
import QuillMarkdown from 'quilljs-markdown'
|
||||
|
||||
// Extend the built-in Image blot to preserve dt-data-path
|
||||
const ImageBlot = Quill.import('formats/image')
|
||||
class DtImageBlot extends ImageBlot {
|
||||
static create(value) {
|
||||
const node = super.create(typeof value === 'string' ? value : value.src)
|
||||
if (value?.path) node.setAttribute('dt-data-path', value.path)
|
||||
return node
|
||||
}
|
||||
static value(node) {
|
||||
return { src: node.getAttribute('src'), path: node.getAttribute('dt-data-path') }
|
||||
}
|
||||
static formats(node) {
|
||||
return { 'dt-data-path': node.getAttribute('dt-data-path') }
|
||||
}
|
||||
format(name, value) {
|
||||
if (name === 'dt-data-path') {
|
||||
if (value) this.domNode.setAttribute('dt-data-path', value)
|
||||
else this.domNode.removeAttribute('dt-data-path')
|
||||
} else {
|
||||
super.format(name, value)
|
||||
}
|
||||
}
|
||||
}
|
||||
DtImageBlot.blotName = 'image'
|
||||
DtImageBlot.tagName = 'img'
|
||||
Quill.register(DtImageBlot, true)
|
||||
import {
|
||||
forwardRef,
|
||||
useCallback,
|
||||
@@ -12,7 +39,11 @@ import {
|
||||
import { useUserProfile } from '../../queries/UserQueries'
|
||||
import { useNotification } from '../../service/NotificationProvider'
|
||||
import { apiClient } from '../../utils/ApiClient'
|
||||
import { isPlusAccount, resolvePhotoURL } from '../../utils/Helpers'
|
||||
import {
|
||||
isPlusAccount,
|
||||
refreshSignedUrlsInHtml,
|
||||
resolvePhotoURL,
|
||||
} from '../../utils/Helpers'
|
||||
import './RichTextEditor.css'
|
||||
|
||||
const RichTextEditor = forwardRef(
|
||||
@@ -32,6 +63,7 @@ const RichTextEditor = forwardRef(
|
||||
const { data: userProfile } = useUserProfile()
|
||||
const quillRef = useRef(null)
|
||||
const editorRef = useRef(null)
|
||||
const initialContentSet = useRef(false)
|
||||
|
||||
// Expose focus method to parent components
|
||||
useImperativeHandle(
|
||||
@@ -141,11 +173,16 @@ const RichTextEditor = forwardRef(
|
||||
return
|
||||
}
|
||||
const data = await response.json()
|
||||
const url = resolvePhotoURL(data.url || data.sign)
|
||||
// Insert image into Quill
|
||||
// Prefer the backend-proxied path (data.sign) over the direct cloud
|
||||
// signed URL (data.url) — the proxy re-signs on every request so the
|
||||
// embedded src never expires.
|
||||
const path = data.path
|
||||
const url = resolvePhotoURL(data.sign || data.url)
|
||||
// Insert image into Quill with dt-data-path tracked by the custom blot
|
||||
const quill = editorRef.current
|
||||
const range = quill.getSelection()
|
||||
quill.insertEmbed(range ? range.index : 0, 'image', url)
|
||||
const insertIndex = range ? range.index : 0
|
||||
quill.insertEmbed(insertIndex, 'image', { src: url, path })
|
||||
} catch (error) {
|
||||
console.error('Error during image processing or upload:', error)
|
||||
showError({
|
||||
@@ -202,7 +239,11 @@ const RichTextEditor = forwardRef(
|
||||
useEffect(() => {
|
||||
if (editorRef.current && isEditable) {
|
||||
if (editorRef.current.root.innerHTML !== value) {
|
||||
editorRef.current.root.innerHTML = value || ''
|
||||
const html = !initialContentSet.current
|
||||
? refreshSignedUrlsInHtml(value || '')
|
||||
: value || ''
|
||||
initialContentSet.current = true
|
||||
editorRef.current.root.innerHTML = html
|
||||
}
|
||||
}
|
||||
}, [value, isEditable])
|
||||
@@ -227,7 +268,7 @@ const RichTextEditor = forwardRef(
|
||||
boxShadow:
|
||||
'var(--joy-shadow-xs, 0px 1px 2px 0px rgba(16, 24, 40, 0.05))',
|
||||
}}
|
||||
dangerouslySetInnerHTML={{ __html: value }}
|
||||
dangerouslySetInnerHTML={{ __html: refreshSignedUrlsInHtml(value) }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
309
src/views/components/ScanToTask/ScanPanel.jsx
Normal file
309
src/views/components/ScanToTask/ScanPanel.jsx
Normal file
@@ -0,0 +1,309 @@
|
||||
import {
|
||||
CameraAlt,
|
||||
DocumentScanner,
|
||||
PhotoCamera,
|
||||
Replay,
|
||||
WarningAmber,
|
||||
} from '@mui/icons-material'
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
CircularProgress,
|
||||
LinearProgress,
|
||||
Typography,
|
||||
} from '@mui/joy'
|
||||
import { useEffect } from 'react'
|
||||
import { useScanToTask } from './useScanToTask'
|
||||
|
||||
/**
|
||||
* Inline scan-to-task panel. Mounts inside AddTaskModal — no second modal.
|
||||
*
|
||||
* Flow: capture → (auto) processing → done [calls onTaskExtracted + onClose]
|
||||
* → error [retake or cancel]
|
||||
*/
|
||||
const ScanPanel = ({ open, onTaskExtracted, onClose, initialImageUrl }) => {
|
||||
const {
|
||||
isNativeScanner,
|
||||
phase,
|
||||
capturedImage,
|
||||
ocrProgress,
|
||||
taskResult,
|
||||
errorMsg,
|
||||
cameraAvailable,
|
||||
videoRef,
|
||||
canvasRef,
|
||||
fileInputRef,
|
||||
startCamera,
|
||||
stopCamera,
|
||||
capture,
|
||||
handleFileSelect,
|
||||
handleNativeScan,
|
||||
retake,
|
||||
activate,
|
||||
reset,
|
||||
} = useScanToTask()
|
||||
|
||||
// Start/stop based on open state
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
activate(initialImageUrl)
|
||||
} else {
|
||||
reset()
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open, initialImageUrl])
|
||||
|
||||
// Start camera when entering capture phase on web
|
||||
useEffect(() => {
|
||||
if (phase === 'capture' && !isNativeScanner) {
|
||||
startCamera()
|
||||
}
|
||||
if (phase !== 'capture') {
|
||||
stopCamera()
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [phase, isNativeScanner])
|
||||
|
||||
// Auto-close and populate when done
|
||||
useEffect(() => {
|
||||
if (phase === 'done' && taskResult) {
|
||||
onTaskExtracted(taskResult)
|
||||
onClose()
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [phase, taskResult])
|
||||
|
||||
if (!open) return null
|
||||
|
||||
const isProcessing = phase === 'processing'
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
borderRadius: 'md',
|
||||
border: '1px solid',
|
||||
borderColor: 'primary.outlinedBorder',
|
||||
overflow: 'hidden',
|
||||
bgcolor: 'background.level1',
|
||||
}}
|
||||
>
|
||||
{/* ── Capture phase ── */}
|
||||
{phase === 'capture' && (
|
||||
<>
|
||||
<Box
|
||||
sx={{
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
minHeight: 200,
|
||||
maxHeight: 300,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
bgcolor: 'neutral.900',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{isNativeScanner && (
|
||||
<Box sx={{ textAlign: 'center', p: 4 }}>
|
||||
<DocumentScanner
|
||||
sx={{ fontSize: 56, color: 'white', opacity: 0.5, mb: 1 }}
|
||||
/>
|
||||
<Typography level='body-sm' sx={{ color: 'white', opacity: 0.6 }}>
|
||||
Tap "Scan Document" to open the scanner
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{!isNativeScanner && cameraAvailable && (
|
||||
<video
|
||||
ref={videoRef}
|
||||
autoPlay
|
||||
playsInline
|
||||
muted
|
||||
style={{
|
||||
width: '100%',
|
||||
display: 'block',
|
||||
maxHeight: 300,
|
||||
objectFit: 'cover',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!isNativeScanner && !cameraAvailable && (
|
||||
<Box sx={{ textAlign: 'center', p: 4 }}>
|
||||
<CameraAlt
|
||||
sx={{ fontSize: 48, color: 'white', opacity: 0.4, mb: 1 }}
|
||||
/>
|
||||
<Typography level='body-sm' sx={{ color: 'white', opacity: 0.6 }}>
|
||||
Camera not available — use Upload instead
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
px: 1.5,
|
||||
py: 1,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 1,
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
size='sm'
|
||||
variant='plain'
|
||||
color='neutral'
|
||||
startDecorator={<PhotoCamera fontSize='small' />}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
>
|
||||
Upload
|
||||
</Button>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type='file'
|
||||
accept='image/*'
|
||||
style={{ display: 'none' }}
|
||||
onChange={handleFileSelect}
|
||||
/>
|
||||
|
||||
<Box sx={{ ml: 'auto', display: 'flex', gap: 1 }}>
|
||||
<Button size='sm' variant='plain' color='neutral' onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
{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>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ── Processing phase ── */}
|
||||
{isProcessing && (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
gap: 1.5,
|
||||
p: 2.5,
|
||||
}}
|
||||
>
|
||||
{capturedImage && (
|
||||
<Box
|
||||
sx={{
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
borderRadius: 'sm',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={capturedImage}
|
||||
alt='Processing'
|
||||
style={{
|
||||
width: '100%',
|
||||
display: 'block',
|
||||
maxHeight: 180,
|
||||
objectFit: 'contain',
|
||||
opacity: 0.45,
|
||||
}}
|
||||
/>
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<CircularProgress size='md' />
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Typography level='body-sm' sx={{ opacity: 0.7 }}>
|
||||
{ocrProgress > 0 && ocrProgress < 100
|
||||
? `Reading text… ${ocrProgress}%`
|
||||
: ocrProgress >= 100
|
||||
? 'Identifying task with AI…'
|
||||
: 'Starting…'}
|
||||
</Typography>
|
||||
|
||||
{ocrProgress > 0 && ocrProgress < 100 && (
|
||||
<LinearProgress
|
||||
determinate
|
||||
value={ocrProgress}
|
||||
sx={{ width: '100%' }}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* ── Error phase ── */}
|
||||
{phase === 'error' && (
|
||||
<Box sx={{ p: 2 }}>
|
||||
{capturedImage && (
|
||||
<img
|
||||
src={capturedImage}
|
||||
alt='Failed scan'
|
||||
style={{
|
||||
width: '100%',
|
||||
display: 'block',
|
||||
borderRadius: 8,
|
||||
maxHeight: 160,
|
||||
objectFit: 'contain',
|
||||
opacity: 0.4,
|
||||
marginBottom: 12,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 1, mb: 2 }}>
|
||||
<WarningAmber color='warning' sx={{ mt: 0.25, flexShrink: 0 }} />
|
||||
<Typography level='body-sm'>{errorMsg}</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', gap: 1 }}>
|
||||
<Button
|
||||
size='sm'
|
||||
variant='outlined'
|
||||
color='neutral'
|
||||
startDecorator={<Replay fontSize='small' />}
|
||||
onClick={retake}
|
||||
>
|
||||
Retake
|
||||
</Button>
|
||||
<Button size='sm' variant='plain' color='neutral' onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<canvas ref={canvasRef} style={{ display: 'none' }} />
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
export default ScanPanel
|
||||
256
src/views/components/ScanToTask/useScanToTask.js
Normal file
256
src/views/components/ScanToTask/useScanToTask.js
Normal file
@@ -0,0 +1,256 @@
|
||||
import { useCallback, useRef, useState } from 'react'
|
||||
import { useDocumentScanner } from '../../../hooks/useDocumentScanner'
|
||||
import { localAIService } from '../../../service/LocalAIService'
|
||||
|
||||
const SYSTEM_PROMPT = `You are helping create tasks for a household task management app.
|
||||
|
||||
Given OCR text extracted from a photo, identify the most useful task a person should add to their task list.
|
||||
|
||||
The task title should always start with an action verb when possible.
|
||||
|
||||
Examples:
|
||||
|
||||
Bill -> "Pay water bill"
|
||||
Appointment -> "Attend eye doctor appointment"
|
||||
Invitation -> "RSVP for wedding"
|
||||
Renewal Notice -> "Renew vehicle registration"
|
||||
Package Notice -> "Pick up package"
|
||||
School Form -> "Complete school permission form"
|
||||
|
||||
Action Priority Rules:
|
||||
|
||||
1. Payments and bills
|
||||
2. Deadlines and renewals
|
||||
3. Appointments
|
||||
4. Required forms
|
||||
5. Informational actions (view, read, review)
|
||||
|
||||
Rules:
|
||||
|
||||
Generate at most one task.
|
||||
Focus on the most important action.
|
||||
Extract due dates and deadlines.
|
||||
Use appointment dates as due dates when appropriate.
|
||||
Do not invent information.
|
||||
If the content contains no actionable item, return null values.
|
||||
Include any important ID or URL or instructions in the description.
|
||||
Titles must be specific and useful at a glance.
|
||||
Include the organization, provider, event, or subject when available.
|
||||
Avoid generic document names.
|
||||
Return valid JSON only.
|
||||
|
||||
Output:
|
||||
|
||||
{
|
||||
"taskName": string | null,
|
||||
"description": string | null,
|
||||
"dueDate": string | null,
|
||||
"confidence": number
|
||||
}`
|
||||
|
||||
async function runNativeOCR(imageSource) {
|
||||
const { Ocr } = await import('@jcesarmobile/capacitor-ocr')
|
||||
const image = imageSource.includes('/_capacitor_file_/')
|
||||
? 'file://' + imageSource.replace(/^https?:\/\/localhost\/_capacitor_file_/, '')
|
||||
: imageSource
|
||||
const result = await Ocr.process({ image })
|
||||
return result.results.map(r => r.text).join('\n').trim()
|
||||
}
|
||||
|
||||
async function runBrowserOCR(imageSource, onProgress) {
|
||||
const { createWorker } = await import('tesseract.js')
|
||||
const worker = await createWorker('eng', 1, {
|
||||
logger: m => {
|
||||
if (m.status === 'recognizing text' && onProgress) {
|
||||
onProgress(Math.round(m.progress * 100))
|
||||
}
|
||||
},
|
||||
})
|
||||
const { data } = await worker.recognize(imageSource)
|
||||
await worker.terminate()
|
||||
return data.text?.trim() || ''
|
||||
}
|
||||
|
||||
async function extractTaskFromOCR(ocrText) {
|
||||
const messages = [
|
||||
{ role: 'system', content: SYSTEM_PROMPT },
|
||||
{ role: 'user', content: `OCR Text:\n${ocrText}` },
|
||||
]
|
||||
const result = await localAIService.plainChat(messages)
|
||||
if (!result) return null
|
||||
const jsonMatch = result.match(/\{[\s\S]*\}/)
|
||||
if (!jsonMatch) return null
|
||||
try {
|
||||
return JSON.parse(jsonMatch[0])
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// phases: idle | capture | processing | done | error
|
||||
export function useScanToTask() {
|
||||
const { isNativeScanner, scanDocument } = useDocumentScanner()
|
||||
const videoRef = useRef(null)
|
||||
const canvasRef = useRef(null)
|
||||
const streamRef = useRef(null)
|
||||
const fileInputRef = useRef(null)
|
||||
|
||||
const [phase, setPhase] = useState('idle')
|
||||
const [capturedImage, setCapturedImage] = useState(null)
|
||||
const [ocrProgress, setOcrProgress] = useState(0)
|
||||
const [taskResult, setTaskResult] = useState(null)
|
||||
const [errorMsg, setErrorMsg] = useState('')
|
||||
const [cameraAvailable, setCameraAvailable] = useState(true)
|
||||
|
||||
const startCamera = useCallback(async () => {
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({
|
||||
video: { facingMode: 'environment' },
|
||||
})
|
||||
streamRef.current = stream
|
||||
if (videoRef.current) {
|
||||
videoRef.current.srcObject = stream
|
||||
}
|
||||
setCameraAvailable(true)
|
||||
} catch {
|
||||
setCameraAvailable(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const stopCamera = useCallback(() => {
|
||||
if (streamRef.current) {
|
||||
streamRef.current.getTracks().forEach(t => t.stop())
|
||||
streamRef.current = null
|
||||
}
|
||||
}, [])
|
||||
|
||||
const processImage = useCallback(async (imageData, method = 'browser') => {
|
||||
setPhase('processing')
|
||||
setOcrProgress(0)
|
||||
setErrorMsg('')
|
||||
|
||||
try {
|
||||
let text
|
||||
if (method === 'native') {
|
||||
text = await runNativeOCR(imageData)
|
||||
} else {
|
||||
text = await runBrowserOCR(imageData, pct => setOcrProgress(pct))
|
||||
}
|
||||
|
||||
if (!text) {
|
||||
setErrorMsg('No text found in image. Try a clearer photo.')
|
||||
setPhase('error')
|
||||
return
|
||||
}
|
||||
|
||||
const task = await extractTaskFromOCR(text)
|
||||
if (!task || !task.taskName) {
|
||||
setErrorMsg('Could not identify a task. Try a different photo.')
|
||||
setPhase('error')
|
||||
return
|
||||
}
|
||||
|
||||
setTaskResult(task)
|
||||
setPhase('done')
|
||||
} catch (e) {
|
||||
setErrorMsg(e.message || 'Processing failed.')
|
||||
setPhase('error')
|
||||
}
|
||||
}, [])
|
||||
|
||||
const capture = useCallback(() => {
|
||||
if (!videoRef.current || !canvasRef.current) return
|
||||
const video = videoRef.current
|
||||
const canvas = canvasRef.current
|
||||
canvas.width = video.videoWidth
|
||||
canvas.height = video.videoHeight
|
||||
canvas.getContext('2d').drawImage(video, 0, 0)
|
||||
const dataUrl = canvas.toDataURL('image/jpeg', 0.9)
|
||||
setCapturedImage(dataUrl)
|
||||
stopCamera()
|
||||
processImage(dataUrl, 'browser')
|
||||
}, [stopCamera, processImage])
|
||||
|
||||
const handleFileSelect = useCallback(
|
||||
e => {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
const reader = new FileReader()
|
||||
reader.onload = ev => {
|
||||
const dataUrl = ev.target.result
|
||||
setCapturedImage(dataUrl)
|
||||
stopCamera()
|
||||
processImage(dataUrl, 'browser')
|
||||
}
|
||||
reader.readAsDataURL(file)
|
||||
},
|
||||
[stopCamera, processImage],
|
||||
)
|
||||
|
||||
const handleNativeScan = useCallback(async () => {
|
||||
const { image, cancelled, error } = await scanDocument()
|
||||
if (cancelled) return
|
||||
if (error || !image) {
|
||||
setErrorMsg(error ? `Scanner error: ${error}` : 'Scan failed.')
|
||||
setPhase('error')
|
||||
return
|
||||
}
|
||||
setCapturedImage(image)
|
||||
processImage(image, 'native')
|
||||
}, [scanDocument, processImage])
|
||||
|
||||
const retake = useCallback(() => {
|
||||
setCapturedImage(null)
|
||||
setTaskResult(null)
|
||||
setErrorMsg('')
|
||||
setOcrProgress(0)
|
||||
setPhase('capture')
|
||||
}, [])
|
||||
|
||||
const activate = useCallback(
|
||||
(initialImageUrl = null) => {
|
||||
setCapturedImage(initialImageUrl)
|
||||
setTaskResult(null)
|
||||
setErrorMsg('')
|
||||
setOcrProgress(0)
|
||||
|
||||
if (initialImageUrl) {
|
||||
processImage(initialImageUrl, 'browser')
|
||||
return
|
||||
}
|
||||
|
||||
setPhase('capture')
|
||||
},
|
||||
[processImage],
|
||||
)
|
||||
|
||||
const reset = useCallback(() => {
|
||||
stopCamera()
|
||||
setCapturedImage(null)
|
||||
setTaskResult(null)
|
||||
setErrorMsg('')
|
||||
setOcrProgress(0)
|
||||
setPhase('idle')
|
||||
}, [stopCamera])
|
||||
|
||||
return {
|
||||
isNativeScanner,
|
||||
phase,
|
||||
capturedImage,
|
||||
ocrProgress,
|
||||
taskResult,
|
||||
errorMsg,
|
||||
cameraAvailable,
|
||||
videoRef,
|
||||
canvasRef,
|
||||
fileInputRef,
|
||||
startCamera,
|
||||
stopCamera,
|
||||
capture,
|
||||
handleFileSelect,
|
||||
handleNativeScan,
|
||||
retake,
|
||||
activate,
|
||||
reset,
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,18 @@
|
||||
:root,
|
||||
[data-joy-color-scheme='light'] {
|
||||
--highlight-date-color: #b45309;
|
||||
--highlight-repeat-color: #15803d;
|
||||
--highlight-label-color: #1d4ed8;
|
||||
--highlight-priority-color: #be123c;
|
||||
}
|
||||
|
||||
[data-joy-color-scheme='dark'] {
|
||||
--highlight-date-color: #fca5a5;
|
||||
--highlight-repeat-color: #86efac;
|
||||
--highlight-label-color: #93c5fd;
|
||||
--highlight-priority-color: #f9a8d4;
|
||||
}
|
||||
|
||||
.smart-task-display {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
@@ -11,32 +26,52 @@
|
||||
white-space: pre-wrap;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.smart-task-common {
|
||||
font-size: 1.2em;
|
||||
line-height: 1.2em;
|
||||
font-family: inherit;
|
||||
caret-color: #f08080;
|
||||
}
|
||||
|
||||
.highlight-date {
|
||||
color: #f08080;
|
||||
color: var(--highlight-date-color);
|
||||
}
|
||||
|
||||
.highlight-repeat {
|
||||
color: #90ee90;
|
||||
color: var(--highlight-repeat-color);
|
||||
}
|
||||
|
||||
.highlight-label {
|
||||
color: #add8e6;
|
||||
color: var(--highlight-label-color);
|
||||
}
|
||||
|
||||
.highlight-priority {
|
||||
color: #ffb6c1;
|
||||
color: var(--highlight-priority-color);
|
||||
}
|
||||
|
||||
.highlight-assignee {
|
||||
color: var(--highlight-repeat-color);
|
||||
}
|
||||
|
||||
.highlight-points {
|
||||
color: var(--highlight-label-color);
|
||||
}
|
||||
|
||||
.task-input {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
border-radius: 8px;
|
||||
border-radius: 10px;
|
||||
box-sizing: border-box;
|
||||
border: none;
|
||||
background-color: var(--joy-palette-neutral-softBg, #f0f4f8);
|
||||
overflow: auto;
|
||||
transition: box-shadow 0.15s ease, background-color 0.15s ease;
|
||||
}
|
||||
|
||||
.task-input:focus-within {
|
||||
box-shadow: 0 0 0 2px var(--joy-palette-primary-outlinedBorder, rgba(11, 107, 203, 0.15));
|
||||
}
|
||||
|
||||
.task-input:hover:not(:focus-within) {
|
||||
background-color: var(--joy-palette-neutral-softHoverBg, #dde7ee);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useColorScheme } from '@mui/joy'
|
||||
import { CameraEnhance, PhotoFilter } from '@mui/icons-material'
|
||||
import { IconButton, Tooltip, useColorScheme } from '@mui/joy'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import AutocompleteDropdown from '../TestView/AutocompleteDropdown'
|
||||
import './SmartTaskTitleInput.css'
|
||||
@@ -52,9 +53,13 @@ const SmartTaskTitleInput = ({
|
||||
suggestions,
|
||||
onEnterPressed,
|
||||
customRenderer,
|
||||
isNativeScanner,
|
||||
onScanClick,
|
||||
onPhotoSelected,
|
||||
}) => {
|
||||
const { mode, setMode } = useColorScheme()
|
||||
const titleInputRef = useRef(null)
|
||||
const photoInputRef = useRef(null)
|
||||
const [cursorPosition, setCursorPosition] = useState(value?.length)
|
||||
const dropdownRef = useRef(null)
|
||||
const [lastWord, setLastWord] = useState('')
|
||||
@@ -181,12 +186,26 @@ const SmartTaskTitleInput = ({
|
||||
}
|
||||
}
|
||||
|
||||
const handlePhotoInputChange = e => {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file || !onPhotoSelected) return
|
||||
const reader = new FileReader()
|
||||
reader.onload = ev => onPhotoSelected(ev.target.result)
|
||||
reader.readAsDataURL(file)
|
||||
e.target.value = ''
|
||||
}
|
||||
|
||||
const showNativeButtons = isNativeScanner && !value
|
||||
const MIC_BUTTON_WIDTH =
|
||||
showNativeButtons && onPhotoSelected && onScanClick
|
||||
? '5rem'
|
||||
: showNativeButtons
|
||||
? '2.5rem'
|
||||
: '0rem'
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
className='task-input overflow-auto rounded border'
|
||||
style={{ minHeight: '2.4em' }}
|
||||
>
|
||||
<div className='task-input' style={{ minHeight: '2.8em' }}>
|
||||
<textarea
|
||||
ref={titleInputRef}
|
||||
autoFocus={autoFocus}
|
||||
@@ -199,13 +218,12 @@ const SmartTaskTitleInput = ({
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
width: `calc(100% - ${MIC_BUTTON_WIDTH})`,
|
||||
height: '100%',
|
||||
// opacity: 100,
|
||||
zIndex: 1,
|
||||
resize: 'none',
|
||||
overflow: 'hidden',
|
||||
padding: '0.5rem',
|
||||
padding: '0.6rem 0.75rem',
|
||||
boxSizing: 'border-box',
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-word',
|
||||
@@ -215,6 +233,8 @@ const SmartTaskTitleInput = ({
|
||||
backgroundColor: 'transparent',
|
||||
color: 'transparent',
|
||||
caretColor: mode === 'dark' ? '#fff' : '#000',
|
||||
border: 'none',
|
||||
outline: 'none',
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
@@ -224,7 +244,7 @@ const SmartTaskTitleInput = ({
|
||||
position: 'relative',
|
||||
zIndex: 1,
|
||||
minHeight: '1.2em',
|
||||
padding: '0.5rem',
|
||||
padding: '0.6rem 0.75rem',
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-word',
|
||||
fontFamily: 'inherit',
|
||||
@@ -234,7 +254,12 @@ const SmartTaskTitleInput = ({
|
||||
onClick={handleDisplayClick}
|
||||
>
|
||||
{placeholder && !value && (
|
||||
<span className='pointer-events-none text-gray-400'>
|
||||
<span
|
||||
style={{
|
||||
pointerEvents: 'none',
|
||||
color: 'var(--joy-palette-text-tertiary, #9fa6ad)',
|
||||
}}
|
||||
>
|
||||
{placeholder}
|
||||
</span>
|
||||
)}
|
||||
@@ -244,6 +269,55 @@ const SmartTaskTitleInput = ({
|
||||
{/* Zero-width space to maintain consistent height */}
|
||||
​
|
||||
</div>
|
||||
{showNativeButtons && (
|
||||
<span
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '50%',
|
||||
right: '0.3rem',
|
||||
transform: 'translateY(-50%)',
|
||||
zIndex: 2,
|
||||
display: 'flex',
|
||||
gap: '0.1rem',
|
||||
}}
|
||||
>
|
||||
{onPhotoSelected && (
|
||||
<>
|
||||
<Tooltip title='Select photo' placement='top' size='sm'>
|
||||
<IconButton
|
||||
size='sm'
|
||||
variant='plain'
|
||||
color='neutral'
|
||||
onClick={() => photoInputRef.current?.click()}
|
||||
sx={{ borderRadius: 'xl' }}
|
||||
>
|
||||
<PhotoFilter fontSize='small' />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<input
|
||||
ref={photoInputRef}
|
||||
type='file'
|
||||
accept='image/*'
|
||||
style={{ display: 'none' }}
|
||||
onChange={handlePhotoInputChange}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{onScanClick && (
|
||||
<Tooltip title='Scan to create task' placement='top' size='sm'>
|
||||
<IconButton
|
||||
size='sm'
|
||||
variant='plain'
|
||||
color='neutral'
|
||||
onClick={onScanClick}
|
||||
sx={{ borderRadius: 'xl' }}
|
||||
>
|
||||
<CameraEnhance fontSize='small' />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{showSuggestions && (
|
||||
<AutocompleteDropdown
|
||||
|
||||
@@ -23,8 +23,12 @@ import {
|
||||
Typography,
|
||||
} from '@mui/joy'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { networkManager } from '../../hooks/NetworkManager'
|
||||
import {
|
||||
PENDING_POLL_MS,
|
||||
SERVER_PROBE_MS,
|
||||
} from '../../hooks/useSyncOnReconnect'
|
||||
import { commandQueue } from '../../utils/CommandQueue'
|
||||
import {
|
||||
isOfflineFeatureEnabled,
|
||||
@@ -57,8 +61,6 @@ const formatCommandLabel = commandType => {
|
||||
)
|
||||
}
|
||||
|
||||
const RETRY_INTERVAL = 30
|
||||
|
||||
function SyncStatusIndicator() {
|
||||
const queryClient = useQueryClient()
|
||||
const [pendingCommands, setPendingCommands] = useState([])
|
||||
@@ -70,7 +72,18 @@ function SyncStatusIndicator() {
|
||||
})
|
||||
const [isOnline, setIsOnline] = useState(networkManager.isOnline)
|
||||
const [offlineSince, setOfflineSince] = useState(networkManager.offlineSince)
|
||||
const [retryIn, setRetryIn] = useState(RETRY_INTERVAL)
|
||||
const [offlineReason, setOfflineReason] = useState(networkManager.offlineReason)
|
||||
|
||||
// Mirror the actual intervals used by useSyncOnReconnect so the countdown is accurate
|
||||
const retryInterval = useMemo(
|
||||
() =>
|
||||
!isOnline && offlineReason === 'server'
|
||||
? SERVER_PROBE_MS / 1000
|
||||
: PENDING_POLL_MS / 1000,
|
||||
[isOnline, offlineReason],
|
||||
)
|
||||
|
||||
const [retryIn, setRetryIn] = useState(retryInterval)
|
||||
const [offlineFeatureEnabled, setOfflineFeatureEnabled] = useState(
|
||||
isOfflineFeatureEnabled(),
|
||||
)
|
||||
@@ -89,24 +102,49 @@ function SyncStatusIndicator() {
|
||||
|
||||
useEffect(() => {
|
||||
if (!syncState.syncing) {
|
||||
setRetryIn(RETRY_INTERVAL)
|
||||
setRetryIn(retryInterval)
|
||||
}
|
||||
}, [syncState.syncing, syncState.lastSync])
|
||||
}, [syncState.syncing, syncState.lastSync, retryInterval])
|
||||
|
||||
useEffect(() => {
|
||||
networkManager.registerNetworkListener(online => {
|
||||
setIsOnline(online)
|
||||
setOfflineReason(networkManager.offlineReason)
|
||||
if (!online) setOfflineSince(networkManager.offlineSince)
|
||||
})
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOnline || syncState.syncing) return
|
||||
// Run countdown both when online (pending commands) and when server-unreachable (probe interval)
|
||||
if (syncState.syncing) return
|
||||
if (isOnline && pendingCommands.length === 0) return
|
||||
if (!isOnline && offlineReason === 'device') return
|
||||
const interval = setInterval(() => {
|
||||
setRetryIn(prev => (prev <= 1 ? RETRY_INTERVAL : prev - 1))
|
||||
setRetryIn(prev => {
|
||||
if (prev <= 1) {
|
||||
console.debug('[SyncStatusIndicator] Retry timer fired', {
|
||||
isOnline,
|
||||
offlineReason,
|
||||
syncing: syncState.syncing,
|
||||
lastSync: syncState.lastSync,
|
||||
error: syncState.error,
|
||||
pendingCommands: pendingCommands.length,
|
||||
})
|
||||
return retryInterval
|
||||
}
|
||||
return prev - 1
|
||||
})
|
||||
}, 1000)
|
||||
return () => clearInterval(interval)
|
||||
}, [isOnline, syncState.syncing, syncState.lastSync])
|
||||
}, [
|
||||
isOnline,
|
||||
offlineReason,
|
||||
syncState.syncing,
|
||||
syncState.lastSync,
|
||||
syncState.error,
|
||||
pendingCommands.length,
|
||||
retryInterval,
|
||||
])
|
||||
|
||||
useEffect(() => {
|
||||
const update = async () => {
|
||||
@@ -461,7 +499,19 @@ function SyncStatusIndicator() {
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
{!isOnline && (
|
||||
{!isOnline && offlineReason === 'server' && (
|
||||
<Box sx={{ px: 1, pb: 0.5 }}>
|
||||
<Typography
|
||||
level='body-xs'
|
||||
sx={{ color: 'var(--joy-palette-text-tertiary)' }}
|
||||
>
|
||||
{syncState.syncing
|
||||
? 'Checking server...'
|
||||
: `Retrying in ${retryIn}s`}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
{!isOnline && offlineReason !== 'server' && (
|
||||
<Box sx={{ px: 1, pb: 0.5 }}>
|
||||
<Typography
|
||||
level='body-xs'
|
||||
|
||||
Reference in New Issue
Block a user