refactor: update App component to use QueryClient and improve theme handling; enhance ChoreView and SubTask components with performers data
This commit is contained in:
23
src/App.jsx
23
src/App.jsx
@@ -1,13 +1,11 @@
|
||||
import NavBar from '@/views/components/NavBar'
|
||||
import { Button, Typography, useColorScheme } from '@mui/joy'
|
||||
import Tracker from '@openreplay/tracker'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { useEffect } from 'react'
|
||||
import { useCallback, useEffect } from 'react'
|
||||
import { Outlet, useNavigate } from 'react-router-dom'
|
||||
import { useRegisterSW } from 'virtual:pwa-register/react'
|
||||
import { registerCapacitorListeners } from './CapacitorListener'
|
||||
import { ImpersonateUserProvider } from './contexts/ImpersonateUserContext'
|
||||
import { useResource } from './queries/ResourceQueries'
|
||||
import { AuthenticationProvider } from './service/AuthenticationService'
|
||||
import {
|
||||
NotificationProvider,
|
||||
@@ -15,6 +13,7 @@ import {
|
||||
} from './service/NotificationProvider'
|
||||
import { apiManager } from './utils/TokenManager'
|
||||
import NetworkBanner from './views/components/NetworkBanner'
|
||||
|
||||
const add = className => {
|
||||
document.getElementById('root').classList.add(className)
|
||||
}
|
||||
@@ -22,9 +21,9 @@ const add = className => {
|
||||
const remove = className => {
|
||||
document.getElementById('root').classList.remove(className)
|
||||
}
|
||||
|
||||
// TODO: Update the interval to at 60 minutes
|
||||
const intervalMS = 5 * 60 * 1000 // 5 minutes
|
||||
const queryClient = new QueryClient({})
|
||||
|
||||
const AppContent = () => {
|
||||
const { showNotification } = useNotification()
|
||||
@@ -85,14 +84,13 @@ const AppContent = () => {
|
||||
}
|
||||
|
||||
function App() {
|
||||
const resource = useResource()
|
||||
const navigate = useNavigate()
|
||||
startApiManager(navigate)
|
||||
startOpenReplay()
|
||||
|
||||
const { mode, systemMode } = useColorScheme()
|
||||
|
||||
const setThemeClass = () => {
|
||||
const setThemeClass = useCallback(() => {
|
||||
const value = JSON.parse(localStorage.getItem('themeMode')) || mode
|
||||
|
||||
if (value === 'system') {
|
||||
@@ -107,11 +105,11 @@ function App() {
|
||||
}
|
||||
|
||||
return remove('dark')
|
||||
}
|
||||
}, [mode, systemMode])
|
||||
|
||||
useEffect(() => {
|
||||
setThemeClass()
|
||||
}, [mode, systemMode])
|
||||
}, [setThemeClass])
|
||||
|
||||
useEffect(() => {
|
||||
registerCapacitorListeners()
|
||||
@@ -120,13 +118,11 @@ function App() {
|
||||
return (
|
||||
<div className='min-h-screen'>
|
||||
<NetworkBanner />
|
||||
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<AuthenticationProvider />
|
||||
<AuthenticationProvider>
|
||||
<NotificationProvider>
|
||||
<AppContent />
|
||||
</NotificationProvider>
|
||||
</QueryClientProvider>
|
||||
</AuthenticationProvider>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -139,7 +135,6 @@ const startOpenReplay = () => {
|
||||
|
||||
tracker.start()
|
||||
}
|
||||
export default App
|
||||
|
||||
const startApiManager = navigate => {
|
||||
apiManager.init()
|
||||
@@ -147,3 +142,5 @@ const startApiManager = navigate => {
|
||||
navigate('/login')
|
||||
})
|
||||
}
|
||||
|
||||
export default App
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import { QueryClient } from '@tanstack/react-query'
|
||||
import App from './App.jsx'
|
||||
import Contexts from './contexts/Contexts.jsx'
|
||||
import './index.css'
|
||||
|
||||
const queryClient = new QueryClient({})
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||
<React.StrictMode>
|
||||
<Contexts />
|
||||
<Contexts queryClient={queryClient}>
|
||||
<App />
|
||||
</Contexts>
|
||||
</React.StrictMode>,
|
||||
)
|
||||
|
||||
@@ -178,7 +178,7 @@ export const useChoresHistory = (initialLimit, includeMembers) => {
|
||||
|
||||
export const useChoreDetails = choreId => {
|
||||
return useQuery({
|
||||
queryKey: ['chore', choreId],
|
||||
queryKey: ['choreDetails', choreId],
|
||||
queryFn: async () => {
|
||||
var onlineChore = null
|
||||
|
||||
|
||||
@@ -527,6 +527,7 @@ const ChoreView = () => {
|
||||
>
|
||||
<SubTasks
|
||||
editMode={false}
|
||||
performers={performers}
|
||||
tasks={chore.subTasks}
|
||||
setTasks={tasks => {
|
||||
setChore({
|
||||
|
||||
207
src/views/ChoreEdit/TimePassedCard.jsx
Normal file
207
src/views/ChoreEdit/TimePassedCard.jsx
Normal file
@@ -0,0 +1,207 @@
|
||||
import { Flag, Schedule } from '@mui/icons-material'
|
||||
import { Box, Card, Chip, Typography } from '@mui/joy'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
|
||||
const TimePassedCard = ({ chore }) => {
|
||||
const [time, setTime] = useState(0)
|
||||
const [shouldAnimate, setShouldAnimate] = useState(false)
|
||||
const [prevStatus, setPrevStatus] = useState(null) // Initialize as null
|
||||
const intervalRef = useRef(null)
|
||||
|
||||
// Track status changes to trigger animation
|
||||
useEffect(() => {
|
||||
// Only trigger animation if we have a previous status and it changed from 0 to 1
|
||||
if (prevStatus !== null && prevStatus === 0 && chore.status === 1) {
|
||||
setShouldAnimate(true)
|
||||
// Reset animation after it completes
|
||||
const timer = setTimeout(() => setShouldAnimate(false), 300)
|
||||
return () => clearTimeout(timer)
|
||||
}
|
||||
setPrevStatus(chore.status)
|
||||
}, [chore.status, prevStatus])
|
||||
|
||||
// Single effect to handle both time calculation and timer
|
||||
useEffect(() => {
|
||||
// Calculate current time based on chore data
|
||||
const calculateCurrentTime = () => {
|
||||
if (chore.timerUpdatedAt && chore.status === 1) {
|
||||
// Active session: base duration + time since start
|
||||
return (
|
||||
Math.floor(
|
||||
(Date.now() - new Date(chore.timerUpdatedAt).getTime()) / 1000,
|
||||
) + (chore.duration || 0)
|
||||
)
|
||||
}
|
||||
// Not active: just return accumulated duration
|
||||
return chore.duration || 0
|
||||
}
|
||||
|
||||
// Set initial time
|
||||
const currentTime = calculateCurrentTime()
|
||||
setTime(currentTime)
|
||||
|
||||
// Handle timer based on status
|
||||
if (chore.status === 1) {
|
||||
// Active: start interval timer
|
||||
intervalRef.current = setInterval(() => {
|
||||
setTime(calculateCurrentTime())
|
||||
}, 1000)
|
||||
} else {
|
||||
// Not active: clear any existing timer
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current)
|
||||
intervalRef.current = null
|
||||
}
|
||||
}
|
||||
|
||||
// Cleanup function
|
||||
return () => {
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current)
|
||||
intervalRef.current = null
|
||||
}
|
||||
}
|
||||
}, [chore.status, chore.timerUpdatedAt, chore.duration])
|
||||
|
||||
const formatTime = seconds => {
|
||||
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')}`
|
||||
}
|
||||
|
||||
return (
|
||||
<Card
|
||||
variant='soft'
|
||||
sx={{
|
||||
borderRadius: 'md',
|
||||
boxShadow: 1,
|
||||
px: 2,
|
||||
py: 1,
|
||||
alignItems: 'center',
|
||||
...(shouldAnimate && {
|
||||
animation: 'slideInUp 0.3s ease-out',
|
||||
}),
|
||||
'@keyframes slideInUp': {
|
||||
'0%': {
|
||||
opacity: 0,
|
||||
transform: 'translateY(20px) scale(0.95)',
|
||||
},
|
||||
'100%': {
|
||||
opacity: 1,
|
||||
transform: 'translateY(0) scale(1)',
|
||||
},
|
||||
},
|
||||
transition: 'all 0.3s ease',
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
level='h4'
|
||||
sx={{
|
||||
fontWeight: 600,
|
||||
pt: 1,
|
||||
color: chore.status === 1 ? 'success.main' : 'text.primary',
|
||||
mb: 0.5,
|
||||
transition: 'all 0.3s ease',
|
||||
transform: chore.status === 1 ? 'scale(1.40)' : 'scale(1)',
|
||||
}}
|
||||
>
|
||||
{formatTime(time)}
|
||||
</Typography>
|
||||
|
||||
{/* Status and info section */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mt: 0.5 }}>
|
||||
{/* <Chip
|
||||
variant='solid'
|
||||
color={
|
||||
chore.status === 1
|
||||
? 'success'
|
||||
: chore.status === 2
|
||||
? 'warning'
|
||||
: 'neutral'
|
||||
}
|
||||
size='sm'
|
||||
startDecorator={
|
||||
chore.status === 1 ? (
|
||||
<PlayArrow sx={{ fontSize: 14 }} />
|
||||
) : chore.status === 2 ? (
|
||||
<Pause sx={{ fontSize: 14 }} />
|
||||
) : (
|
||||
<AccessTime sx={{ fontSize: 14 }} />
|
||||
)
|
||||
}
|
||||
>
|
||||
{chore.status === 1
|
||||
? 'Active'
|
||||
: chore.status === 2
|
||||
? 'Paused'
|
||||
: 'Idle'}
|
||||
</Chip> */}
|
||||
|
||||
{/* Show start time and user if active */}
|
||||
{chore.status === 1 && chore.timerUpdatedAt && (
|
||||
<>
|
||||
{/* Original start time */}
|
||||
{chore.startTime && (
|
||||
<Chip
|
||||
variant='plain'
|
||||
color='primary'
|
||||
size='sm'
|
||||
startDecorator={<Flag sx={{ fontSize: 14 }} />}
|
||||
>
|
||||
{'Started '}
|
||||
{new Date(chore.startTime).toLocaleTimeString([], {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})}
|
||||
</Chip>
|
||||
)}
|
||||
|
||||
{/* Current session start time */}
|
||||
{chore.timerUpdatedAt !== chore.startTime && (
|
||||
<Chip
|
||||
variant='plain'
|
||||
color='neutral'
|
||||
size='sm'
|
||||
startDecorator={<Schedule sx={{ fontSize: 14 }} />}
|
||||
>
|
||||
{'Session '}
|
||||
{new Date(chore.timerUpdatedAt).toLocaleTimeString([], {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})}
|
||||
</Chip>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Chips FOr paused : */}
|
||||
{chore.status === 2 && (
|
||||
<>
|
||||
<Chip
|
||||
variant='solid'
|
||||
color='warning'
|
||||
size='sm'
|
||||
startDecorator={<Schedule sx={{ fontSize: 14 }} />}
|
||||
>
|
||||
Paused
|
||||
</Chip>
|
||||
<Chip
|
||||
variant='plain'
|
||||
color='neutral'
|
||||
size='sm'
|
||||
startDecorator={<Flag sx={{ fontSize: 14 }} />}
|
||||
>
|
||||
{new Date(chore.timerUpdatedAt).toLocaleTimeString([], {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})}
|
||||
</Chip>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
export default TimePassedCard
|
||||
@@ -51,15 +51,13 @@ import CompactChoreCard from './CompactChoreCard'
|
||||
import IconButtonWithMenu from './IconButtonWithMenu'
|
||||
import MultiSelectHelp from './MultiSelectHelp'
|
||||
|
||||
import KeyboardShortcutHint from '../../components/common/KeyboardShortcutHint'
|
||||
import { useImpersonateUser } from '../../contexts/ImpersonateUserContext.jsx'
|
||||
import { useCircleMembers, useUserProfile } from '../../queries/UserQueries'
|
||||
import { ChoreFilters, ChoresGrouper, ChoreSorter } from '../../utils/Chores'
|
||||
import { DeleteChore, MarkChoreComplete, SkipChore } from '../../utils/Fetcher'
|
||||
import TaskInput from '../components/AddTaskModal'
|
||||
import {
|
||||
canScheduleNotification,
|
||||
scheduleChoreNotification,
|
||||
} from './LocalNotificationScheduler'
|
||||
import { canScheduleNotification } from './LocalNotificationScheduler'
|
||||
import NotificationAccessSnackbar from './NotificationAccessSnackbar'
|
||||
import Sidepanel from './Sidepanel'
|
||||
import SortAndGrouping from './SortAndGrouping'
|
||||
@@ -102,40 +100,50 @@ const MyChores = () => {
|
||||
data: choresData,
|
||||
isLoading: choresLoading,
|
||||
refetch: refetchChores,
|
||||
} = useChores()
|
||||
} = useChores(false)
|
||||
const { data: membersData, isLoading: membersLoading } = useCircleMembers()
|
||||
|
||||
// Multi-select state
|
||||
const [isMultiSelectMode, setIsMultiSelectMode] = useState(false)
|
||||
const [selectedChores, setSelectedChores] = useState(new Set())
|
||||
const [confirmModelConfig, setConfirmModelConfig] = useState({})
|
||||
|
||||
const [showKeyboardShortcuts, setShowKeyboardShortcuts] = useState(false)
|
||||
useEffect(() => {
|
||||
if (!choresLoading && !membersLoading && userProfile) {
|
||||
setPerformers(membersData.res)
|
||||
const sortedChores = choresData.res.sort(ChoreSorter)
|
||||
setChores(sortedChores)
|
||||
setFilteredChores(sortedChores)
|
||||
const sections = ChoresGrouper(
|
||||
selectedChoreSection,
|
||||
sortedChores,
|
||||
ChoreFilters(userProfile)[selectedChoreFilter],
|
||||
)
|
||||
setChoreSections(sections)
|
||||
if (localStorage.getItem('openChoreSections') === null) {
|
||||
setSelectedChoreSectionWithCache(selectedChoreSection)
|
||||
setOpenChoreSections(
|
||||
Object.keys(sections).reduce((acc, key) => {
|
||||
acc[key] = true
|
||||
return acc
|
||||
}, {}),
|
||||
;(async () => {
|
||||
if (!choresLoading && !membersLoading && userProfile) {
|
||||
setPerformers(membersData.res)
|
||||
const sortedChores = choresData.res.sort(ChoreSorter)
|
||||
setChores(sortedChores)
|
||||
setFilteredChores(sortedChores)
|
||||
const sections = ChoresGrouper(
|
||||
selectedChoreSection,
|
||||
sortedChores,
|
||||
ChoreFilters(userProfile)[selectedChoreFilter],
|
||||
)
|
||||
setChoreSections(sections)
|
||||
if (localStorage.getItem('openChoreSections') === null) {
|
||||
setSelectedChoreSectionWithCache(selectedChoreSection)
|
||||
setOpenChoreSections(
|
||||
Object.keys(sections).reduce((acc, key) => {
|
||||
acc[key] = true
|
||||
return acc
|
||||
}, {}),
|
||||
)
|
||||
}
|
||||
console.log(
|
||||
'Checking if can schedule notification',
|
||||
canScheduleNotification(),
|
||||
)
|
||||
}
|
||||
|
||||
if (canScheduleNotification()) {
|
||||
scheduleChoreNotification(choresData.res, userProfile, membersData.res)
|
||||
if (await canScheduleNotification()) {
|
||||
// scheduleChoreNotification(
|
||||
// choresData.res,
|
||||
// userProfile,
|
||||
// membersData.res,
|
||||
// )
|
||||
}
|
||||
}
|
||||
}
|
||||
})()
|
||||
}, [
|
||||
membersLoading,
|
||||
choresLoading,
|
||||
@@ -164,6 +172,11 @@ const MyChores = () => {
|
||||
// Keyboard shortcuts for multi-select and other actions
|
||||
useEffect(() => {
|
||||
const handleKeyDown = event => {
|
||||
// if Ctrl/Cmd + / then show keyboard shortcuts modal
|
||||
if (event.ctrlKey || event.metaKey) {
|
||||
setShowKeyboardShortcuts(true)
|
||||
}
|
||||
|
||||
// Ctrl/Cmd + K to open task modal
|
||||
if ((event.ctrlKey || event.metaKey) && event.key === 'k') {
|
||||
event.preventDefault()
|
||||
@@ -176,8 +189,13 @@ const MyChores = () => {
|
||||
event.preventDefault()
|
||||
searchInputRef.current?.focus()
|
||||
return
|
||||
// Ctrl/Cmd + X to close search input
|
||||
} else if ((event.ctrlKey || event.metaKey) && event.key === 'x') {
|
||||
event.preventDefault()
|
||||
if (searchTerm?.length > 0) {
|
||||
handleSearchClose()
|
||||
}
|
||||
}
|
||||
|
||||
// Ctrl/Cmd + S Toggle Multi-select mode
|
||||
else if ((event.ctrlKey || event.metaKey) && event.key === 's') {
|
||||
event.preventDefault()
|
||||
@@ -299,10 +317,17 @@ const MyChores = () => {
|
||||
}
|
||||
}
|
||||
}
|
||||
const handleKeyUp = event => {
|
||||
if (!event.ctrlKey && !event.metaKey) {
|
||||
setShowKeyboardShortcuts(false)
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown)
|
||||
document.addEventListener('keyup', handleKeyUp)
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeyDown)
|
||||
document.removeEventListener('keyup', handleKeyUp)
|
||||
}
|
||||
}, [isMultiSelectMode, selectedChores.size])
|
||||
const setSelectedChoreSectionWithCache = value => {
|
||||
@@ -506,7 +531,7 @@ const MyChores = () => {
|
||||
const fuse = new Fuse(
|
||||
chores.map(c => ({
|
||||
...c,
|
||||
raw_label: c.labelsV2.map(c => c.name).join(' '),
|
||||
raw_label: c.labelsV2?.map(c => c.name).join(' '),
|
||||
})),
|
||||
searchOptions,
|
||||
)
|
||||
@@ -526,6 +551,12 @@ const MyChores = () => {
|
||||
setSearchTerm(term)
|
||||
setFilteredChores(fuse.search(term).map(result => result.item))
|
||||
}
|
||||
const handleSearchClose = () => {
|
||||
setSearchTerm('')
|
||||
setFilteredChores(chores)
|
||||
// remove the focus from the search input:
|
||||
setSearchInputFocus(0)
|
||||
}
|
||||
|
||||
// Multi-select helper functions
|
||||
const toggleMultiSelectMode = () => {
|
||||
@@ -870,15 +901,21 @@ const MyChores = () => {
|
||||
padding: 1,
|
||||
}}
|
||||
onChange={handleSearchChange}
|
||||
startDecorator={
|
||||
<KeyboardShortcutHint shortcut='F' show={showKeyboardShortcuts} />
|
||||
}
|
||||
endDecorator={
|
||||
searchTerm && (
|
||||
<CancelRounded
|
||||
onClick={() => {
|
||||
setSearchTerm('')
|
||||
setFilteredChores(chores)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
||||
{searchTerm && (
|
||||
<>
|
||||
<KeyboardShortcutHint
|
||||
shortcut='X'
|
||||
show={showKeyboardShortcuts}
|
||||
/>
|
||||
<CancelRounded onClick={handleSearchClose} />
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -981,6 +1018,7 @@ const MyChores = () => {
|
||||
>
|
||||
{isMultiSelectMode ? <CheckBox /> : <CheckBoxOutlineBlank />}
|
||||
</IconButton>
|
||||
<KeyboardShortcutHint shortcut='S' show={showKeyboardShortcuts} />
|
||||
</Box>
|
||||
|
||||
{/* Search Filter with animation */}
|
||||
@@ -1202,6 +1240,12 @@ const MyChores = () => {
|
||||
minWidth: 'auto',
|
||||
'--Button-paddingInline': '0.75rem',
|
||||
}}
|
||||
endDecorator={
|
||||
<KeyboardShortcutHint
|
||||
shortcut='A'
|
||||
show={showKeyboardShortcuts && selectedChores.size > 0}
|
||||
/>
|
||||
}
|
||||
>
|
||||
All
|
||||
</Button>
|
||||
@@ -1220,6 +1264,13 @@ const MyChores = () => {
|
||||
minWidth: 'auto',
|
||||
'--Button-paddingInline': '0.75rem',
|
||||
}}
|
||||
endDecorator={
|
||||
<KeyboardShortcutHint
|
||||
withCtrl={false}
|
||||
shortcut='Esc'
|
||||
show={showKeyboardShortcuts && selectedChores.size > 0}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{selectedChores.size === 0 ? 'Close' : 'Clear'}
|
||||
</Button>
|
||||
@@ -1252,6 +1303,12 @@ const MyChores = () => {
|
||||
sx={{
|
||||
'--Button-paddingInline': { xs: '0.75rem', sm: '1rem' },
|
||||
}}
|
||||
endDecorator={
|
||||
<KeyboardShortcutHint
|
||||
shortcut='Enter'
|
||||
show={showKeyboardShortcuts && selectedChores.size > 0}
|
||||
/>
|
||||
}
|
||||
>
|
||||
Complete
|
||||
</Button>
|
||||
@@ -1265,6 +1322,12 @@ const MyChores = () => {
|
||||
sx={{
|
||||
'--Button-paddingInline': { xs: '0.75rem', sm: '1rem' },
|
||||
}}
|
||||
endDecorator={
|
||||
<KeyboardShortcutHint
|
||||
shortcut='/'
|
||||
show={showKeyboardShortcuts && selectedChores.size > 0}
|
||||
/>
|
||||
}
|
||||
>
|
||||
Skip
|
||||
</Button>
|
||||
@@ -1278,6 +1341,12 @@ const MyChores = () => {
|
||||
sx={{
|
||||
'--Button-paddingInline': { xs: '0.75rem', sm: '1rem' },
|
||||
}}
|
||||
endDecorator={
|
||||
<KeyboardShortcutHint
|
||||
shortcut='X'
|
||||
show={showKeyboardShortcuts && selectedChores.size > 0}
|
||||
/>
|
||||
}
|
||||
>
|
||||
Archive
|
||||
</Button>
|
||||
@@ -1292,6 +1361,13 @@ const MyChores = () => {
|
||||
sx={{
|
||||
'--Button-paddingInline': { xs: '0.75rem', sm: '1rem' },
|
||||
}}
|
||||
endDecorator={
|
||||
<KeyboardShortcutHint
|
||||
withShift={true}
|
||||
shortcut='X'
|
||||
show={showKeyboardShortcuts && selectedChores.size > 0}
|
||||
/>
|
||||
}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
@@ -1473,6 +1549,12 @@ const MyChores = () => {
|
||||
variant='outlined'
|
||||
color='neutral'
|
||||
startDecorator={<Unarchive />}
|
||||
endDecorator={
|
||||
<KeyboardShortcutHint
|
||||
shortcut='A'
|
||||
show={showKeyboardShortcuts}
|
||||
/>
|
||||
}
|
||||
>
|
||||
Show Archived
|
||||
</Button>
|
||||
@@ -1550,6 +1632,12 @@ const MyChores = () => {
|
||||
}}
|
||||
/>
|
||||
</IconButton>
|
||||
|
||||
<KeyboardShortcutHint
|
||||
sx={{ position: 'relative', left: -40, top: 30 }}
|
||||
show={showKeyboardShortcuts}
|
||||
shortcut='K'
|
||||
/>
|
||||
</Box>
|
||||
<NotificationAccessSnackbar />
|
||||
{addTaskModalOpen && (
|
||||
|
||||
@@ -1,59 +1,82 @@
|
||||
import { CalendarViewDay, Check, Timelapse } from '@mui/icons-material'
|
||||
import {
|
||||
AccessTime,
|
||||
Assignment,
|
||||
CalendarViewDay,
|
||||
Check,
|
||||
EventNote,
|
||||
Person,
|
||||
Timelapse,
|
||||
} from '@mui/icons-material'
|
||||
import {
|
||||
Avatar,
|
||||
Box,
|
||||
Chip,
|
||||
Grid,
|
||||
ListDivider,
|
||||
ListItem,
|
||||
ListItemContent,
|
||||
ListItemDecorator,
|
||||
Typography,
|
||||
} from '@mui/joy'
|
||||
import moment from 'moment'
|
||||
|
||||
export const getCompletedChip = historyEntry => {
|
||||
var text = 'No Due Date'
|
||||
var color = 'info'
|
||||
var icon = <CalendarViewDay />
|
||||
// if completed few hours +-6 hours
|
||||
if (
|
||||
historyEntry.dueDate &&
|
||||
historyEntry.performedAt > historyEntry.dueDate - 1000 * 60 * 60 * 6 &&
|
||||
historyEntry.performedAt < historyEntry.dueDate + 1000 * 60 * 60 * 6
|
||||
) {
|
||||
text = 'On Time'
|
||||
color = 'success'
|
||||
icon = <Check />
|
||||
} else if (
|
||||
historyEntry.dueDate &&
|
||||
historyEntry.performedAt < historyEntry.dueDate
|
||||
) {
|
||||
text = 'On Time'
|
||||
color = 'success'
|
||||
icon = <Check />
|
||||
/**
|
||||
* Enhanced completion status chip with better logic and visual design
|
||||
*/
|
||||
const getCompletedChip = historyEntry => {
|
||||
if (historyEntry.status === 0) {
|
||||
return null
|
||||
}
|
||||
if (!historyEntry.dueDate) {
|
||||
return (
|
||||
<Chip
|
||||
size='sm'
|
||||
variant='soft'
|
||||
color='neutral'
|
||||
startDecorator={<CalendarViewDay />}
|
||||
>
|
||||
No Due Date
|
||||
</Chip>
|
||||
)
|
||||
}
|
||||
|
||||
// if completed after due date then it's late
|
||||
else if (
|
||||
historyEntry.dueDate &&
|
||||
historyEntry.performedAt > historyEntry.dueDate
|
||||
) {
|
||||
text = 'Late'
|
||||
color = 'warning'
|
||||
icon = <Timelapse />
|
||||
const performedAt = moment(historyEntry.performedAt)
|
||||
const dueDate = moment(historyEntry.dueDate)
|
||||
const gracePeriod = 6 * 60 * 60 * 1000 // 6 hours in milliseconds
|
||||
|
||||
if (Math.abs(performedAt - dueDate) <= gracePeriod) {
|
||||
return (
|
||||
<Chip
|
||||
size='sm'
|
||||
variant='solid'
|
||||
color='success'
|
||||
startDecorator={<Check />}
|
||||
>
|
||||
On Time
|
||||
</Chip>
|
||||
)
|
||||
} else if (performedAt.isBefore(dueDate)) {
|
||||
return (
|
||||
<Chip size='sm' variant='soft' color='primary' startDecorator={<Check />}>
|
||||
Early
|
||||
</Chip>
|
||||
)
|
||||
} else {
|
||||
text = 'No Due Date'
|
||||
color = 'neutral'
|
||||
icon = <CalendarViewDay />
|
||||
return (
|
||||
<Chip
|
||||
size='sm'
|
||||
variant='solid'
|
||||
color='warning'
|
||||
startDecorator={<Timelapse />}
|
||||
>
|
||||
Late
|
||||
</Chip>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Chip startDecorator={icon} color={color}>
|
||||
{text}
|
||||
</Chip>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact HistoryCard component with improved UX and 2-row height design
|
||||
*/
|
||||
const HistoryCard = ({
|
||||
allHistory,
|
||||
performers,
|
||||
@@ -61,7 +84,10 @@ const HistoryCard = ({
|
||||
index,
|
||||
onClick,
|
||||
}) => {
|
||||
function formatTimeDifference(startDate, endDate) {
|
||||
const performer = performers.find(p => p.userId === historyEntry.completedBy)
|
||||
const assignedTo = performers.find(p => p.userId === historyEntry.assignedTo)
|
||||
|
||||
const formatTimeDifference = (startDate, endDate) => {
|
||||
const diffInMinutes = moment(startDate).diff(endDate, 'minutes')
|
||||
let timeValue = diffInMinutes
|
||||
let unit = 'minute'
|
||||
@@ -81,86 +107,187 @@ const HistoryCard = ({
|
||||
return `${timeValue} ${unit}${timeValue !== 1 ? 's' : ''}`
|
||||
}
|
||||
|
||||
const getStatusAvatar = () => {
|
||||
const statusMap = {
|
||||
0: { icon: <AccessTime />, color: 'primary' }, // Started
|
||||
1: { icon: <Check />, color: 'success' }, // Completed
|
||||
2: { icon: <Timelapse />, color: 'danger' }, // Skipped
|
||||
}
|
||||
|
||||
const config = statusMap[historyEntry.status] || statusMap[1]
|
||||
return (
|
||||
<Avatar
|
||||
size='sm'
|
||||
color={config.color}
|
||||
variant='solid'
|
||||
sx={{
|
||||
width: 24,
|
||||
height: 24,
|
||||
'& svg': { fontSize: '14px' },
|
||||
}}
|
||||
>
|
||||
{config.icon}
|
||||
</Avatar>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<ListItem sx={{ gap: 1.5, alignItems: 'flex-start' }} onClick={onClick}>
|
||||
{' '}
|
||||
{/* Adjusted spacing and alignment */}
|
||||
<ListItemDecorator>
|
||||
<Avatar sx={{ mr: 1 }}>
|
||||
{performers
|
||||
.find(p => p.userId === historyEntry.completedBy)
|
||||
?.displayName?.charAt(0) || '?'}
|
||||
</Avatar>
|
||||
</ListItemDecorator>
|
||||
<ListItemContent sx={{ my: 0 }}>
|
||||
{' '}
|
||||
{/* Removed vertical margin */}
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<Typography level='body1' sx={{ fontWeight: 'md' }}>
|
||||
{historyEntry.performedAt
|
||||
? moment(historyEntry.performedAt).format(
|
||||
'ddd MM/DD/yyyy HH:mm',
|
||||
)
|
||||
: 'Skipped'}
|
||||
</Typography>
|
||||
{getCompletedChip(historyEntry)}
|
||||
</Box>
|
||||
<Typography level='body2' color='text.tertiary'>
|
||||
<Chip>
|
||||
{
|
||||
performers.find(p => p.userId === historyEntry.completedBy)
|
||||
?.displayName
|
||||
<ListItem
|
||||
onClick={onClick}
|
||||
sx={{
|
||||
cursor: onClick ? 'pointer' : 'default',
|
||||
py: 1.5,
|
||||
px: 2,
|
||||
'&:hover': onClick
|
||||
? {
|
||||
backgroundColor: 'background.level1',
|
||||
}
|
||||
</Chip>{' '}
|
||||
completed
|
||||
{historyEntry.completedBy !== historyEntry.assignedTo && (
|
||||
<>
|
||||
{', '}
|
||||
assigned to{' '}
|
||||
<Chip>
|
||||
{
|
||||
performers.find(p => p.userId === historyEntry.assignedTo)
|
||||
?.displayName
|
||||
}
|
||||
: {},
|
||||
borderRadius: 'sm',
|
||||
transition: 'background-color 0.2s',
|
||||
}}
|
||||
>
|
||||
<ListItemContent>
|
||||
<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',
|
||||
}}
|
||||
>
|
||||
{getStatusAvatar()}
|
||||
|
||||
<Typography
|
||||
level='body-sm'
|
||||
sx={{
|
||||
color: 'text.secondary',
|
||||
fontWeight: 'md',
|
||||
}}
|
||||
>
|
||||
{historyEntry.status === 0
|
||||
? 'In Progress'
|
||||
: historyEntry.status === 1
|
||||
? 'Completed'
|
||||
: 'Skipped'}
|
||||
</Typography>
|
||||
|
||||
<Typography
|
||||
level='body-xs'
|
||||
sx={{ fontWeight: 'sm', color: 'text.primary' }}
|
||||
>
|
||||
{moment(
|
||||
historyEntry.performedAt || historyEntry.updatedAt,
|
||||
).format('MMM DD, h:mm A')}
|
||||
</Typography>
|
||||
|
||||
<Box sx={{ display: 'flex', gap: 0.5 }}>
|
||||
{getCompletedChip(historyEntry)}
|
||||
</Box>
|
||||
</Box>
|
||||
</Grid>
|
||||
|
||||
{/* 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,
|
||||
}}
|
||||
>
|
||||
{historyEntry.dueDate && (
|
||||
<Typography
|
||||
level='body-xs'
|
||||
sx={{ color: 'text.tertiary', whiteSpace: 'nowrap' }}
|
||||
>
|
||||
Due: {moment(historyEntry.dueDate).format('MMM DD')}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
</Grid>
|
||||
|
||||
{/* Third Row: Performer and Assignment Info */}
|
||||
<Grid xs={12}>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 1,
|
||||
flexWrap: 'wrap',
|
||||
mt: 0.5,
|
||||
}}
|
||||
>
|
||||
<Chip size='sm' variant='outlined' startDecorator={<Person />}>
|
||||
{performer?.displayName || 'Unknown'}
|
||||
</Chip>
|
||||
</>
|
||||
)}
|
||||
</Typography>
|
||||
{historyEntry.dueDate && (
|
||||
<Typography level='body2' color='text.tertiary'>
|
||||
Due: {moment(historyEntry.dueDate).format('ddd MM/DD/yyyy')}
|
||||
</Typography>
|
||||
)}
|
||||
{historyEntry.notes && (
|
||||
<Typography level='body2' color='text.tertiary'>
|
||||
Note: {historyEntry.notes}
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
{historyEntry.completedBy !== historyEntry.assignedTo &&
|
||||
assignedTo && (
|
||||
<>
|
||||
<Typography
|
||||
level='body-xs'
|
||||
sx={{ color: 'text.tertiary' }}
|
||||
>
|
||||
→
|
||||
</Typography>
|
||||
<Chip
|
||||
size='sm'
|
||||
variant='soft'
|
||||
color='neutral'
|
||||
startDecorator={<Assignment />}
|
||||
>
|
||||
{assignedTo.displayName}
|
||||
</Chip>
|
||||
</>
|
||||
)}
|
||||
|
||||
{historyEntry.notes && (
|
||||
<Chip
|
||||
size='sm'
|
||||
variant='plain'
|
||||
color='neutral'
|
||||
startDecorator={<EventNote />}
|
||||
sx={{ maxWidth: '120px', overflow: 'hidden' }}
|
||||
>
|
||||
Note
|
||||
</Chip>
|
||||
)}
|
||||
</Box>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</ListItemContent>
|
||||
</ListItem>
|
||||
{index < allHistory.length - 1 && (
|
||||
<>
|
||||
<ListDivider component='li'>
|
||||
{/* time between two completion: */}
|
||||
{index < allHistory.length - 1 &&
|
||||
allHistory[index + 1].performedAt && (
|
||||
<Typography level='body3' color='text.tertiary'>
|
||||
{formatTimeDifference(
|
||||
historyEntry.performedAt,
|
||||
allHistory[index + 1].performedAt,
|
||||
)}{' '}
|
||||
before
|
||||
</Typography>
|
||||
)}
|
||||
</ListDivider>
|
||||
</>
|
||||
|
||||
{/* Compact Divider with Time Difference */}
|
||||
{index < allHistory.length - 1 && allHistory[index + 1].performedAt && (
|
||||
<ListDivider
|
||||
component='li'
|
||||
sx={{
|
||||
my: 0.5,
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
level='body-xs'
|
||||
sx={{
|
||||
color: 'text.tertiary',
|
||||
backgroundColor: 'background.surface',
|
||||
px: 1,
|
||||
fontSize: '0.75rem',
|
||||
}}
|
||||
>
|
||||
{formatTimeDifference(
|
||||
historyEntry.performedAt || historyEntry.updatedAt,
|
||||
allHistory[index + 1].performedAt,
|
||||
)}{' '}
|
||||
before
|
||||
</Typography>
|
||||
</ListDivider>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -1,25 +1,375 @@
|
||||
import DeleteIcon from '@mui/icons-material/Delete'
|
||||
import EditIcon from '@mui/icons-material/Edit'
|
||||
import {
|
||||
Avatar,
|
||||
Box,
|
||||
Button,
|
||||
Chip,
|
||||
CircularProgress,
|
||||
Container,
|
||||
IconButton,
|
||||
Typography,
|
||||
} from '@mui/joy'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import LabelModal from '../Modals/Inputs/LabelModal'
|
||||
|
||||
// import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { Add } from '@mui/icons-material'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { getTextColorFromBackgroundColor } from '../../utils/Colors'
|
||||
import LABEL_COLORS from '../../utils/Colors'
|
||||
import { DeleteLabel } from '../../utils/Fetcher'
|
||||
import ConfirmationModal from '../Modals/Inputs/ConfirmationModal'
|
||||
import { useLabels } from './LabelQueries'
|
||||
|
||||
const LabelCard = ({ label, onEditClick, onDeleteClick }) => {
|
||||
// Helper function to get color name from hex value
|
||||
const getColorName = hexValue => {
|
||||
const colorObj = LABEL_COLORS.find(
|
||||
color => color.value.toLowerCase() === hexValue.toLowerCase(),
|
||||
)
|
||||
return colorObj ? colorObj.name : hexValue
|
||||
}
|
||||
|
||||
// Swipe functionality state
|
||||
const [swipeTranslateX, setSwipeTranslateX] = useState(0)
|
||||
const [isDragging, setIsDragging] = useState(false)
|
||||
const [isSwipeRevealed, setIsSwipeRevealed] = useState(false)
|
||||
const [hoverTimer, setHoverTimer] = useState(null)
|
||||
const swipeThreshold = 80
|
||||
const maxSwipeDistance = 160
|
||||
const dragStartX = useRef(0)
|
||||
const cardRef = useRef(null)
|
||||
|
||||
// Swipe gesture handlers
|
||||
const handleTouchStart = e => {
|
||||
dragStartX.current = e.touches[0].clientX
|
||||
setIsDragging(true)
|
||||
}
|
||||
|
||||
const handleTouchMove = e => {
|
||||
if (!isDragging) return
|
||||
|
||||
const currentX = e.touches[0].clientX
|
||||
const deltaX = currentX - dragStartX.current
|
||||
|
||||
if (isSwipeRevealed) {
|
||||
if (deltaX > 0) {
|
||||
const clampedDelta = Math.min(deltaX - maxSwipeDistance, 0)
|
||||
setSwipeTranslateX(clampedDelta)
|
||||
}
|
||||
} else {
|
||||
if (deltaX < 0) {
|
||||
const clampedDelta = Math.max(deltaX, -maxSwipeDistance)
|
||||
setSwipeTranslateX(clampedDelta)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleTouchEnd = () => {
|
||||
if (!isDragging) return
|
||||
setIsDragging(false)
|
||||
|
||||
if (isSwipeRevealed) {
|
||||
if (swipeTranslateX > -swipeThreshold) {
|
||||
setSwipeTranslateX(0)
|
||||
setIsSwipeRevealed(false)
|
||||
} else {
|
||||
setSwipeTranslateX(-maxSwipeDistance)
|
||||
}
|
||||
} else {
|
||||
if (Math.abs(swipeTranslateX) > swipeThreshold) {
|
||||
setSwipeTranslateX(-maxSwipeDistance)
|
||||
setIsSwipeRevealed(true)
|
||||
} else {
|
||||
setSwipeTranslateX(0)
|
||||
setIsSwipeRevealed(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleMouseDown = e => {
|
||||
dragStartX.current = e.clientX
|
||||
setIsDragging(true)
|
||||
}
|
||||
|
||||
const handleMouseMove = e => {
|
||||
if (!isDragging) return
|
||||
|
||||
const currentX = e.clientX
|
||||
const deltaX = currentX - dragStartX.current
|
||||
|
||||
if (isSwipeRevealed) {
|
||||
if (deltaX > 0) {
|
||||
const clampedDelta = Math.min(deltaX - maxSwipeDistance, 0)
|
||||
setSwipeTranslateX(clampedDelta)
|
||||
}
|
||||
} else {
|
||||
if (deltaX < 0) {
|
||||
const clampedDelta = Math.max(deltaX, -maxSwipeDistance)
|
||||
setSwipeTranslateX(clampedDelta)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleMouseUp = () => {
|
||||
if (!isDragging) return
|
||||
setIsDragging(false)
|
||||
|
||||
if (isSwipeRevealed) {
|
||||
if (swipeTranslateX > -swipeThreshold) {
|
||||
setSwipeTranslateX(0)
|
||||
setIsSwipeRevealed(false)
|
||||
} else {
|
||||
setSwipeTranslateX(-maxSwipeDistance)
|
||||
}
|
||||
} else {
|
||||
if (Math.abs(swipeTranslateX) > swipeThreshold) {
|
||||
setSwipeTranslateX(-maxSwipeDistance)
|
||||
setIsSwipeRevealed(true)
|
||||
} else {
|
||||
setSwipeTranslateX(0)
|
||||
setIsSwipeRevealed(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const resetSwipe = () => {
|
||||
setSwipeTranslateX(0)
|
||||
setIsSwipeRevealed(false)
|
||||
}
|
||||
|
||||
// Hover functionality for desktop
|
||||
const handleMouseEnter = () => {
|
||||
if (isSwipeRevealed) return
|
||||
const timer = setTimeout(() => {
|
||||
setSwipeTranslateX(-maxSwipeDistance)
|
||||
setIsSwipeRevealed(true)
|
||||
setHoverTimer(null)
|
||||
}, 1500)
|
||||
setHoverTimer(timer)
|
||||
}
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
if (hoverTimer) {
|
||||
clearTimeout(hoverTimer)
|
||||
setHoverTimer(null)
|
||||
}
|
||||
if (isSwipeRevealed) {
|
||||
resetSwipe()
|
||||
}
|
||||
}
|
||||
|
||||
const handleActionAreaMouseEnter = () => {
|
||||
if (hoverTimer) {
|
||||
clearTimeout(hoverTimer)
|
||||
setHoverTimer(null)
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up timer on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (hoverTimer) {
|
||||
clearTimeout(hoverTimer)
|
||||
}
|
||||
}
|
||||
}, [hoverTimer])
|
||||
|
||||
return (
|
||||
<Box key={label.id + '-compact-box'}>
|
||||
<Box
|
||||
sx={{
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
borderBottom: '1px solid',
|
||||
borderColor: 'divider',
|
||||
'&:last-child': {
|
||||
borderBottom: 'none',
|
||||
},
|
||||
}}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
>
|
||||
{/* Action buttons underneath (revealed on swipe) */}
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
right: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
width: maxSwipeDistance,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
boxShadow: 'inset 2px 0 4px rgba(0,0,0,0.06)',
|
||||
zIndex: 0,
|
||||
}}
|
||||
onMouseEnter={handleActionAreaMouseEnter}
|
||||
>
|
||||
<IconButton
|
||||
variant='plain'
|
||||
color='neutral'
|
||||
size='sm'
|
||||
onClick={e => {
|
||||
e.stopPropagation()
|
||||
resetSwipe()
|
||||
onEditClick(label)
|
||||
}}
|
||||
sx={{
|
||||
width: 40,
|
||||
height: 40,
|
||||
mx: 1,
|
||||
bgcolor: 'primary.100',
|
||||
color: 'primary.600',
|
||||
'&:hover': {
|
||||
bgcolor: 'primary.200',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<EditIcon sx={{ fontSize: 16 }} />
|
||||
</IconButton>
|
||||
|
||||
<IconButton
|
||||
variant='plain'
|
||||
color='danger'
|
||||
size='sm'
|
||||
onClick={e => {
|
||||
e.stopPropagation()
|
||||
resetSwipe()
|
||||
onDeleteClick(label.id)
|
||||
}}
|
||||
sx={{
|
||||
width: 40,
|
||||
height: 40,
|
||||
mx: 1,
|
||||
bgcolor: 'danger.100',
|
||||
color: 'danger.600',
|
||||
'&:hover': {
|
||||
bgcolor: 'danger.200',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<DeleteIcon sx={{ fontSize: 16 }} />
|
||||
</IconButton>
|
||||
</Box>
|
||||
|
||||
{/* Main card content */}
|
||||
<Box
|
||||
ref={cardRef}
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
minHeight: 64,
|
||||
cursor: 'pointer',
|
||||
position: 'relative',
|
||||
px: 2,
|
||||
py: 1.5,
|
||||
bgcolor: 'background.body',
|
||||
transform: `translateX(${swipeTranslateX}px)`,
|
||||
transition: isDragging ? 'none' : 'transform 0.3s ease-out',
|
||||
zIndex: 1,
|
||||
'&:hover': {
|
||||
bgcolor: isSwipeRevealed
|
||||
? 'background.surface'
|
||||
: 'background.level1',
|
||||
boxShadow: isSwipeRevealed ? 'none' : 'sm',
|
||||
},
|
||||
}}
|
||||
onClick={() => {
|
||||
if (isSwipeRevealed) {
|
||||
resetSwipe()
|
||||
return
|
||||
}
|
||||
// Optional: Navigate to label details or edit directly
|
||||
onEditClick(label)
|
||||
}}
|
||||
onTouchStart={handleTouchStart}
|
||||
onTouchMove={handleTouchMove}
|
||||
onTouchEnd={handleTouchEnd}
|
||||
onMouseDown={handleMouseDown}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseUp={handleMouseUp}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
>
|
||||
{/* Color Avatar */}
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
mr: 2,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<Avatar
|
||||
size='sm'
|
||||
sx={{
|
||||
width: 32,
|
||||
height: 32,
|
||||
bgcolor: label.color,
|
||||
border: '2px solid',
|
||||
borderColor: 'background.surface',
|
||||
boxShadow: 'sm',
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
level='body-xs'
|
||||
sx={{
|
||||
color: getTextColorFromBackgroundColor(label.color),
|
||||
fontWeight: 'bold',
|
||||
fontSize: 10,
|
||||
}}
|
||||
>
|
||||
{label.name.charAt(0).toUpperCase()}
|
||||
</Typography>
|
||||
</Avatar>
|
||||
</Box>
|
||||
|
||||
{/* Content - Center */}
|
||||
<Box
|
||||
sx={{
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
}}
|
||||
>
|
||||
{/* Label Name */}
|
||||
<Typography
|
||||
level='title-sm'
|
||||
sx={{
|
||||
fontWeight: 600,
|
||||
fontSize: 14,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
mb: 0.25,
|
||||
}}
|
||||
>
|
||||
{label.name}
|
||||
</Typography>
|
||||
|
||||
{/* Color Info */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
||||
<Chip
|
||||
size='sm'
|
||||
variant='soft'
|
||||
sx={{
|
||||
fontSize: 10,
|
||||
height: 18,
|
||||
px: 0.75,
|
||||
bgcolor: `${label.color}20`,
|
||||
color: label.color,
|
||||
border: `1px solid ${label.color}30`,
|
||||
}}
|
||||
>
|
||||
{getColorName(label.color)}
|
||||
</Chip>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
const LabelView = () => {
|
||||
const { data: labels, isLabelsLoading, isError } = useLabels()
|
||||
|
||||
@@ -61,7 +411,7 @@ const LabelView = () => {
|
||||
}
|
||||
|
||||
const handleDeleteLabel = id => {
|
||||
DeleteLabel(id).then(res => {
|
||||
DeleteLabel(id).then(() => {
|
||||
const updatedLabels = userLabels.filter(label => label.id !== id)
|
||||
setUserLabels(updatedLabels)
|
||||
|
||||
@@ -106,54 +456,40 @@ const LabelView = () => {
|
||||
}
|
||||
|
||||
return (
|
||||
<Container maxWidth='md'>
|
||||
<div className='flex flex-col gap-2'>
|
||||
{userLabels.map(label => (
|
||||
<div
|
||||
key={label}
|
||||
className='grid w-full grid-cols-[1fr,auto,auto] rounded-lg border border-zinc-200/80 p-4 shadow-sm dark:bg-zinc-900'
|
||||
<Container maxWidth='md' sx={{ px: 0 }}>
|
||||
<Box
|
||||
sx={{
|
||||
bgcolor: 'background.body',
|
||||
border: '1px solid',
|
||||
borderColor: 'divider',
|
||||
borderRadius: 'md',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{userLabels.length === 0 && (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
flexDirection: 'column',
|
||||
height: '50vh',
|
||||
}}
|
||||
>
|
||||
<Chip
|
||||
variant='outlined'
|
||||
color='primary'
|
||||
size='lg'
|
||||
sx={{
|
||||
background: label.color,
|
||||
borderColor: label.color,
|
||||
color: getTextColorFromBackgroundColor(label.color),
|
||||
}}
|
||||
>
|
||||
{label.name}
|
||||
</Chip>
|
||||
|
||||
<div className='flex gap-2'>
|
||||
<Button
|
||||
size='sm'
|
||||
variant='soft'
|
||||
color='neutral'
|
||||
onClick={() => handleEditLabel(label)}
|
||||
startDecorator={<EditIcon />}
|
||||
>
|
||||
Edit
|
||||
</Button>
|
||||
<IconButton
|
||||
size='sm'
|
||||
variant='soft'
|
||||
onClick={() => handleDeleteClicked(label.id)}
|
||||
color='danger'
|
||||
>
|
||||
<DeleteIcon />
|
||||
</IconButton>
|
||||
</div>
|
||||
</div>
|
||||
<Typography level='title-md' gutterBottom>
|
||||
No labels available. Add a new label to get started.
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
{userLabels.map(label => (
|
||||
<LabelCard
|
||||
key={label.id}
|
||||
label={label}
|
||||
onEditClick={handleEditLabel}
|
||||
onDeleteClick={handleDeleteClicked}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{userLabels.length === 0 && (
|
||||
<Typography textAlign='center' mt={2}>
|
||||
No labels available. Add a new label to get started.
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{modalOpen && (
|
||||
<LabelModal
|
||||
|
||||
@@ -6,11 +6,14 @@ import {
|
||||
PlusOne,
|
||||
ToggleOff,
|
||||
ToggleOn,
|
||||
TrendingUp,
|
||||
Widgets,
|
||||
} from '@mui/icons-material'
|
||||
import {
|
||||
Avatar,
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
Chip,
|
||||
Container,
|
||||
Grid,
|
||||
@@ -38,6 +41,7 @@ const ThingCard = ({
|
||||
}) => {
|
||||
const [isDisabled, setIsDisabled] = useState(false)
|
||||
const Navigate = useNavigate()
|
||||
|
||||
const getThingIcon = type => {
|
||||
if (type === 'text') {
|
||||
return <Flip />
|
||||
@@ -54,6 +58,43 @@ const ThingCard = ({
|
||||
}
|
||||
}
|
||||
|
||||
const getThingAvatar = () => {
|
||||
const typeConfig = {
|
||||
text: { color: 'primary', icon: <Flip /> },
|
||||
number: { color: 'success', icon: <PlusOne /> },
|
||||
boolean: {
|
||||
color: thing.state === 'true' ? 'success' : 'neutral',
|
||||
icon: thing.state === 'true' ? <ToggleOn /> : <ToggleOff />
|
||||
},
|
||||
}
|
||||
|
||||
const config = typeConfig[thing?.type] || typeConfig.boolean
|
||||
return (
|
||||
<Avatar
|
||||
size='sm'
|
||||
color={config.color}
|
||||
variant='solid'
|
||||
sx={{
|
||||
width: 28,
|
||||
height: 28,
|
||||
'& svg': { fontSize: '16px' },
|
||||
}}
|
||||
>
|
||||
{config.icon}
|
||||
</Avatar>
|
||||
)
|
||||
}
|
||||
|
||||
const getActionButtonProps = () => {
|
||||
const buttonConfig = {
|
||||
text: { text: 'Change', color: 'primary' },
|
||||
number: { text: 'Increment', color: 'success' },
|
||||
boolean: { text: 'Toggle', color: 'warning' },
|
||||
}
|
||||
|
||||
return buttonConfig[thing?.type] || buttonConfig.boolean
|
||||
}
|
||||
|
||||
const handleRequestChange = thing => {
|
||||
setIsDisabled(true)
|
||||
onStateChangeRequest(thing)
|
||||
@@ -62,103 +103,158 @@ const ThingCard = ({
|
||||
}, 2000)
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
className='rounded-lg border border-zinc-200/80 p-4 shadow-sm'
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'space-between',
|
||||
p: 2,
|
||||
const actionProps = getActionButtonProps()
|
||||
|
||||
return (
|
||||
<Card
|
||||
variant='outlined'
|
||||
sx={{
|
||||
mb: 2,
|
||||
p: 2,
|
||||
transition: 'all 0.2s ease-in-out',
|
||||
cursor: 'pointer',
|
||||
'&:hover': {
|
||||
borderColor: 'primary.300',
|
||||
boxShadow: 'sm',
|
||||
transform: 'translateY(-1px)',
|
||||
},
|
||||
}}
|
||||
onClick={() => Navigate(`/things/${thing?.id}`)}
|
||||
>
|
||||
<Grid container alignItems='center'>
|
||||
<Grid
|
||||
item
|
||||
xs={12}
|
||||
sm={8}
|
||||
onClick={() => Navigate(`/things/${thing?.id}`)}
|
||||
>
|
||||
<Grid container spacing={2} alignItems='center'>
|
||||
{/* First Row: Thing Info */}
|
||||
<Grid xs={12} sm={8}>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 1.5,
|
||||
mb: 1,
|
||||
}}
|
||||
>
|
||||
{getThingAvatar()}
|
||||
|
||||
<Box sx={{ flex: 1, minWidth: 0 }}>
|
||||
<Typography
|
||||
level='title-md'
|
||||
sx={{
|
||||
fontWeight: 'lg',
|
||||
color: 'text.primary',
|
||||
mb: 0.5,
|
||||
}}
|
||||
>
|
||||
{thing?.name}
|
||||
</Typography>
|
||||
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flexWrap: 'wrap' }}>
|
||||
<Chip
|
||||
size='sm'
|
||||
variant='soft'
|
||||
color='neutral'
|
||||
>
|
||||
{thing?.type}
|
||||
</Chip>
|
||||
|
||||
<Typography level='body-xs' sx={{ color: 'text.tertiary' }}>
|
||||
•
|
||||
</Typography>
|
||||
|
||||
<Typography level='body-xs' sx={{ color: 'text.secondary' }}>
|
||||
Current state:
|
||||
</Typography>
|
||||
|
||||
<Chip
|
||||
size='sm'
|
||||
variant='solid'
|
||||
color={thing?.type === 'boolean' && thing?.state === 'true' ? 'success' : 'primary'}
|
||||
sx={{ fontWeight: 'md' }}
|
||||
>
|
||||
{thing?.state}
|
||||
</Chip>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</Grid>
|
||||
|
||||
{/* Second Row: Action Buttons */}
|
||||
<Grid xs={12} sm={4}>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
justifyContent: { xs: 'flex-start', sm: 'flex-end' },
|
||||
alignItems: 'center',
|
||||
gap: 1,
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
onClick={() => Navigate(`/things/${thing?.id}`)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Typography level='title-lg'>{thing?.name}</Typography>
|
||||
<Chip
|
||||
<Button
|
||||
variant='solid'
|
||||
color={actionProps.color}
|
||||
size='sm'
|
||||
sx={{
|
||||
ml: 1,
|
||||
onClick={() => {
|
||||
if (thing?.type === 'text') {
|
||||
onEditClick(thing)
|
||||
} else {
|
||||
handleRequestChange(thing)
|
||||
}
|
||||
}}
|
||||
disabled={isDisabled}
|
||||
startDecorator={getThingIcon(thing?.type)}
|
||||
sx={{
|
||||
minWidth: '80px',
|
||||
fontWeight: 'md',
|
||||
}}
|
||||
>
|
||||
{thing?.type}
|
||||
</Chip>
|
||||
</Box>
|
||||
State: <Chip size='md'>{thing?.state}</Chip>
|
||||
</Grid>
|
||||
<Grid
|
||||
item
|
||||
xs={12}
|
||||
sm={4}
|
||||
container
|
||||
justifyContent='flex-end'
|
||||
alignItems='center'
|
||||
>
|
||||
<Button
|
||||
variant='soft'
|
||||
color='success'
|
||||
onClick={() => {
|
||||
if (thing?.type === 'text') {
|
||||
{actionProps.text}
|
||||
</Button>
|
||||
|
||||
<IconButton
|
||||
variant='outlined'
|
||||
color='neutral'
|
||||
size='sm'
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onEditClick(thing)
|
||||
} else {
|
||||
handleRequestChange(thing)
|
||||
}
|
||||
}}
|
||||
disabled={isDisabled}
|
||||
startDecorator={getThingIcon(thing?.type)}
|
||||
>
|
||||
{thing?.type === 'text'
|
||||
? 'Change'
|
||||
: thing?.type === 'number'
|
||||
? 'Increment'
|
||||
: 'Toggle'}
|
||||
</Button>
|
||||
<IconButton
|
||||
color='primary'
|
||||
onClick={() => onEditClick(thing)}
|
||||
sx={{
|
||||
borderRadius: '50%',
|
||||
width: 30,
|
||||
height: 30,
|
||||
ml: 1,
|
||||
transition: 'background-color 0.2s',
|
||||
'&:hover': { backgroundColor: 'action.hover' },
|
||||
}}
|
||||
>
|
||||
<Edit />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
color='danger'
|
||||
onClick={() => onDeleteClick(thing)}
|
||||
sx={{
|
||||
borderRadius: '50%',
|
||||
width: 30,
|
||||
height: 30,
|
||||
ml: 1,
|
||||
}}
|
||||
>
|
||||
<Delete fontSize='small' />
|
||||
</IconButton>
|
||||
}}
|
||||
sx={{
|
||||
borderRadius: '50%',
|
||||
width: 32,
|
||||
height: 32,
|
||||
transition: 'all 0.2s',
|
||||
'&:hover': {
|
||||
backgroundColor: 'primary.softBg',
|
||||
borderColor: 'primary.300',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Edit fontSize='small' />
|
||||
</IconButton>
|
||||
|
||||
<IconButton
|
||||
variant='outlined'
|
||||
color='danger'
|
||||
size='sm'
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onDeleteClick(thing)
|
||||
}}
|
||||
sx={{
|
||||
borderRadius: '50%',
|
||||
width: 32,
|
||||
height: 32,
|
||||
transition: 'all 0.2s',
|
||||
'&:hover': {
|
||||
backgroundColor: 'danger.softBg',
|
||||
borderColor: 'danger.300',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Delete fontSize='small' />
|
||||
</IconButton>
|
||||
</Box>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Box>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
import {
|
||||
Box,
|
||||
Checkbox,
|
||||
Chip,
|
||||
IconButton,
|
||||
Input,
|
||||
List,
|
||||
@@ -31,6 +32,7 @@ import {
|
||||
Typography,
|
||||
} from '@mui/joy'
|
||||
import { useState } from 'react'
|
||||
import { useUserProfile } from '../../queries/UserQueries'
|
||||
import { CompleteSubTask } from '../../utils/Fetcher'
|
||||
|
||||
function SortableItem({
|
||||
@@ -43,10 +45,12 @@ function SortableItem({
|
||||
setTasks,
|
||||
level = 0,
|
||||
editMode,
|
||||
performers = [],
|
||||
}) {
|
||||
const { attributes, listeners, setNodeRef, transform, transition } =
|
||||
useSortable({
|
||||
id: task.id,
|
||||
data: { completedAt: task.completedAt, completedBy: task.completedBy },
|
||||
// Add touch sensor options for better mobile scrolling
|
||||
options: {
|
||||
activationConstraint: {
|
||||
@@ -206,6 +210,14 @@ function SortableItem({
|
||||
}}
|
||||
>
|
||||
{new Date(task.completedAt).toLocaleString()}
|
||||
{performers.find(p => p.userId === task.completedBy) ? (
|
||||
<Chip>
|
||||
{
|
||||
performers.find(p => p.userId === task.completedBy)
|
||||
.displayName
|
||||
}
|
||||
</Chip>
|
||||
) : null}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
@@ -281,6 +293,7 @@ function SortableItem({
|
||||
setTasks={setTasks}
|
||||
level={level + 1}
|
||||
editMode={editMode}
|
||||
performers={performers}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
@@ -289,8 +302,15 @@ function SortableItem({
|
||||
)
|
||||
}
|
||||
|
||||
const SubTasks = ({ editMode = true, choreId = 0, tasks = [], setTasks }) => {
|
||||
const SubTasks = ({
|
||||
editMode = true,
|
||||
choreId = 0,
|
||||
tasks = [],
|
||||
setTasks,
|
||||
performers,
|
||||
}) => {
|
||||
const [newTask, setNewTask] = useState('')
|
||||
const { data: userProfile } = useUserProfile()
|
||||
|
||||
const topLevelTasks = tasks.filter(task => task.parentId === null)
|
||||
|
||||
@@ -313,7 +333,13 @@ const SubTasks = ({ editMode = true, choreId = 0, tasks = [], setTasks }) => {
|
||||
|
||||
// Update the task
|
||||
const updatedTasks = tasks.map(task =>
|
||||
task.id === taskId ? { ...task, completedAt: newCompletedAt } : task,
|
||||
task.id === taskId
|
||||
? {
|
||||
...task,
|
||||
completedAt: newCompletedAt,
|
||||
completedBy: userProfile?.id,
|
||||
}
|
||||
: task,
|
||||
)
|
||||
|
||||
// If completing a task, also complete all child tasks
|
||||
@@ -469,6 +495,7 @@ const SubTasks = ({ editMode = true, choreId = 0, tasks = [], setTasks }) => {
|
||||
allTasks={tasks}
|
||||
setTasks={setTasks}
|
||||
editMode={editMode}
|
||||
performers={performers}
|
||||
/>
|
||||
))}
|
||||
{editMode && (
|
||||
|
||||
Reference in New Issue
Block a user