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 NavBar from '@/views/components/NavBar'
|
||||||
import { Button, Typography, useColorScheme } from '@mui/joy'
|
import { Button, Typography, useColorScheme } from '@mui/joy'
|
||||||
import Tracker from '@openreplay/tracker'
|
import Tracker from '@openreplay/tracker'
|
||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
import { useCallback, useEffect } from 'react'
|
||||||
import { useEffect } from 'react'
|
|
||||||
import { Outlet, useNavigate } from 'react-router-dom'
|
import { Outlet, useNavigate } from 'react-router-dom'
|
||||||
import { useRegisterSW } from 'virtual:pwa-register/react'
|
import { useRegisterSW } from 'virtual:pwa-register/react'
|
||||||
import { registerCapacitorListeners } from './CapacitorListener'
|
import { registerCapacitorListeners } from './CapacitorListener'
|
||||||
import { ImpersonateUserProvider } from './contexts/ImpersonateUserContext'
|
import { ImpersonateUserProvider } from './contexts/ImpersonateUserContext'
|
||||||
import { useResource } from './queries/ResourceQueries'
|
|
||||||
import { AuthenticationProvider } from './service/AuthenticationService'
|
import { AuthenticationProvider } from './service/AuthenticationService'
|
||||||
import {
|
import {
|
||||||
NotificationProvider,
|
NotificationProvider,
|
||||||
@@ -15,6 +13,7 @@ import {
|
|||||||
} from './service/NotificationProvider'
|
} from './service/NotificationProvider'
|
||||||
import { apiManager } from './utils/TokenManager'
|
import { apiManager } from './utils/TokenManager'
|
||||||
import NetworkBanner from './views/components/NetworkBanner'
|
import NetworkBanner from './views/components/NetworkBanner'
|
||||||
|
|
||||||
const add = className => {
|
const add = className => {
|
||||||
document.getElementById('root').classList.add(className)
|
document.getElementById('root').classList.add(className)
|
||||||
}
|
}
|
||||||
@@ -22,9 +21,9 @@ const add = className => {
|
|||||||
const remove = className => {
|
const remove = className => {
|
||||||
document.getElementById('root').classList.remove(className)
|
document.getElementById('root').classList.remove(className)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: Update the interval to at 60 minutes
|
// TODO: Update the interval to at 60 minutes
|
||||||
const intervalMS = 5 * 60 * 1000 // 5 minutes
|
const intervalMS = 5 * 60 * 1000 // 5 minutes
|
||||||
const queryClient = new QueryClient({})
|
|
||||||
|
|
||||||
const AppContent = () => {
|
const AppContent = () => {
|
||||||
const { showNotification } = useNotification()
|
const { showNotification } = useNotification()
|
||||||
@@ -85,14 +84,13 @@ const AppContent = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const resource = useResource()
|
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
startApiManager(navigate)
|
startApiManager(navigate)
|
||||||
startOpenReplay()
|
startOpenReplay()
|
||||||
|
|
||||||
const { mode, systemMode } = useColorScheme()
|
const { mode, systemMode } = useColorScheme()
|
||||||
|
|
||||||
const setThemeClass = () => {
|
const setThemeClass = useCallback(() => {
|
||||||
const value = JSON.parse(localStorage.getItem('themeMode')) || mode
|
const value = JSON.parse(localStorage.getItem('themeMode')) || mode
|
||||||
|
|
||||||
if (value === 'system') {
|
if (value === 'system') {
|
||||||
@@ -107,11 +105,11 @@ function App() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return remove('dark')
|
return remove('dark')
|
||||||
}
|
}, [mode, systemMode])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setThemeClass()
|
setThemeClass()
|
||||||
}, [mode, systemMode])
|
}, [setThemeClass])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
registerCapacitorListeners()
|
registerCapacitorListeners()
|
||||||
@@ -120,13 +118,11 @@ function App() {
|
|||||||
return (
|
return (
|
||||||
<div className='min-h-screen'>
|
<div className='min-h-screen'>
|
||||||
<NetworkBanner />
|
<NetworkBanner />
|
||||||
|
<AuthenticationProvider>
|
||||||
<QueryClientProvider client={queryClient}>
|
|
||||||
<AuthenticationProvider />
|
|
||||||
<NotificationProvider>
|
<NotificationProvider>
|
||||||
<AppContent />
|
<AppContent />
|
||||||
</NotificationProvider>
|
</NotificationProvider>
|
||||||
</QueryClientProvider>
|
</AuthenticationProvider>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -139,7 +135,6 @@ const startOpenReplay = () => {
|
|||||||
|
|
||||||
tracker.start()
|
tracker.start()
|
||||||
}
|
}
|
||||||
export default App
|
|
||||||
|
|
||||||
const startApiManager = navigate => {
|
const startApiManager = navigate => {
|
||||||
apiManager.init()
|
apiManager.init()
|
||||||
@@ -147,3 +142,5 @@ const startApiManager = navigate => {
|
|||||||
navigate('/login')
|
navigate('/login')
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default App
|
||||||
|
|||||||
@@ -1,10 +1,16 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
import ReactDOM from 'react-dom/client'
|
import ReactDOM from 'react-dom/client'
|
||||||
|
import { QueryClient } from '@tanstack/react-query'
|
||||||
|
import App from './App.jsx'
|
||||||
import Contexts from './contexts/Contexts.jsx'
|
import Contexts from './contexts/Contexts.jsx'
|
||||||
import './index.css'
|
import './index.css'
|
||||||
|
|
||||||
|
const queryClient = new QueryClient({})
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById('root')).render(
|
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
<Contexts />
|
<Contexts queryClient={queryClient}>
|
||||||
|
<App />
|
||||||
|
</Contexts>
|
||||||
</React.StrictMode>,
|
</React.StrictMode>,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -178,7 +178,7 @@ export const useChoresHistory = (initialLimit, includeMembers) => {
|
|||||||
|
|
||||||
export const useChoreDetails = choreId => {
|
export const useChoreDetails = choreId => {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ['chore', choreId],
|
queryKey: ['choreDetails', choreId],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
var onlineChore = null
|
var onlineChore = null
|
||||||
|
|
||||||
|
|||||||
@@ -527,6 +527,7 @@ const ChoreView = () => {
|
|||||||
>
|
>
|
||||||
<SubTasks
|
<SubTasks
|
||||||
editMode={false}
|
editMode={false}
|
||||||
|
performers={performers}
|
||||||
tasks={chore.subTasks}
|
tasks={chore.subTasks}
|
||||||
setTasks={tasks => {
|
setTasks={tasks => {
|
||||||
setChore({
|
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 IconButtonWithMenu from './IconButtonWithMenu'
|
||||||
import MultiSelectHelp from './MultiSelectHelp'
|
import MultiSelectHelp from './MultiSelectHelp'
|
||||||
|
|
||||||
|
import KeyboardShortcutHint from '../../components/common/KeyboardShortcutHint'
|
||||||
import { useImpersonateUser } from '../../contexts/ImpersonateUserContext.jsx'
|
import { useImpersonateUser } from '../../contexts/ImpersonateUserContext.jsx'
|
||||||
import { useCircleMembers, useUserProfile } from '../../queries/UserQueries'
|
import { useCircleMembers, useUserProfile } from '../../queries/UserQueries'
|
||||||
import { ChoreFilters, ChoresGrouper, ChoreSorter } from '../../utils/Chores'
|
import { ChoreFilters, ChoresGrouper, ChoreSorter } from '../../utils/Chores'
|
||||||
import { DeleteChore, MarkChoreComplete, SkipChore } from '../../utils/Fetcher'
|
import { DeleteChore, MarkChoreComplete, SkipChore } from '../../utils/Fetcher'
|
||||||
import TaskInput from '../components/AddTaskModal'
|
import TaskInput from '../components/AddTaskModal'
|
||||||
import {
|
import { canScheduleNotification } from './LocalNotificationScheduler'
|
||||||
canScheduleNotification,
|
|
||||||
scheduleChoreNotification,
|
|
||||||
} from './LocalNotificationScheduler'
|
|
||||||
import NotificationAccessSnackbar from './NotificationAccessSnackbar'
|
import NotificationAccessSnackbar from './NotificationAccessSnackbar'
|
||||||
import Sidepanel from './Sidepanel'
|
import Sidepanel from './Sidepanel'
|
||||||
import SortAndGrouping from './SortAndGrouping'
|
import SortAndGrouping from './SortAndGrouping'
|
||||||
@@ -102,40 +100,50 @@ const MyChores = () => {
|
|||||||
data: choresData,
|
data: choresData,
|
||||||
isLoading: choresLoading,
|
isLoading: choresLoading,
|
||||||
refetch: refetchChores,
|
refetch: refetchChores,
|
||||||
} = useChores()
|
} = useChores(false)
|
||||||
const { data: membersData, isLoading: membersLoading } = useCircleMembers()
|
const { data: membersData, isLoading: membersLoading } = useCircleMembers()
|
||||||
|
|
||||||
// Multi-select state
|
// Multi-select state
|
||||||
const [isMultiSelectMode, setIsMultiSelectMode] = useState(false)
|
const [isMultiSelectMode, setIsMultiSelectMode] = useState(false)
|
||||||
const [selectedChores, setSelectedChores] = useState(new Set())
|
const [selectedChores, setSelectedChores] = useState(new Set())
|
||||||
const [confirmModelConfig, setConfirmModelConfig] = useState({})
|
const [confirmModelConfig, setConfirmModelConfig] = useState({})
|
||||||
|
const [showKeyboardShortcuts, setShowKeyboardShortcuts] = useState(false)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!choresLoading && !membersLoading && userProfile) {
|
;(async () => {
|
||||||
setPerformers(membersData.res)
|
if (!choresLoading && !membersLoading && userProfile) {
|
||||||
const sortedChores = choresData.res.sort(ChoreSorter)
|
setPerformers(membersData.res)
|
||||||
setChores(sortedChores)
|
const sortedChores = choresData.res.sort(ChoreSorter)
|
||||||
setFilteredChores(sortedChores)
|
setChores(sortedChores)
|
||||||
const sections = ChoresGrouper(
|
setFilteredChores(sortedChores)
|
||||||
selectedChoreSection,
|
const sections = ChoresGrouper(
|
||||||
sortedChores,
|
selectedChoreSection,
|
||||||
ChoreFilters(userProfile)[selectedChoreFilter],
|
sortedChores,
|
||||||
)
|
ChoreFilters(userProfile)[selectedChoreFilter],
|
||||||
setChoreSections(sections)
|
)
|
||||||
if (localStorage.getItem('openChoreSections') === null) {
|
setChoreSections(sections)
|
||||||
setSelectedChoreSectionWithCache(selectedChoreSection)
|
if (localStorage.getItem('openChoreSections') === null) {
|
||||||
setOpenChoreSections(
|
setSelectedChoreSectionWithCache(selectedChoreSection)
|
||||||
Object.keys(sections).reduce((acc, key) => {
|
setOpenChoreSections(
|
||||||
acc[key] = true
|
Object.keys(sections).reduce((acc, key) => {
|
||||||
return acc
|
acc[key] = true
|
||||||
}, {}),
|
return acc
|
||||||
|
}, {}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
console.log(
|
||||||
|
'Checking if can schedule notification',
|
||||||
|
canScheduleNotification(),
|
||||||
)
|
)
|
||||||
}
|
|
||||||
|
|
||||||
if (canScheduleNotification()) {
|
if (await canScheduleNotification()) {
|
||||||
scheduleChoreNotification(choresData.res, userProfile, membersData.res)
|
// scheduleChoreNotification(
|
||||||
|
// choresData.res,
|
||||||
|
// userProfile,
|
||||||
|
// membersData.res,
|
||||||
|
// )
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
})()
|
||||||
}, [
|
}, [
|
||||||
membersLoading,
|
membersLoading,
|
||||||
choresLoading,
|
choresLoading,
|
||||||
@@ -164,6 +172,11 @@ const MyChores = () => {
|
|||||||
// Keyboard shortcuts for multi-select and other actions
|
// Keyboard shortcuts for multi-select and other actions
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleKeyDown = event => {
|
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
|
// Ctrl/Cmd + K to open task modal
|
||||||
if ((event.ctrlKey || event.metaKey) && event.key === 'k') {
|
if ((event.ctrlKey || event.metaKey) && event.key === 'k') {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
@@ -176,8 +189,13 @@ const MyChores = () => {
|
|||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
searchInputRef.current?.focus()
|
searchInputRef.current?.focus()
|
||||||
return
|
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
|
// Ctrl/Cmd + S Toggle Multi-select mode
|
||||||
else if ((event.ctrlKey || event.metaKey) && event.key === 's') {
|
else if ((event.ctrlKey || event.metaKey) && event.key === 's') {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
@@ -299,10 +317,17 @@ const MyChores = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
const handleKeyUp = event => {
|
||||||
|
if (!event.ctrlKey && !event.metaKey) {
|
||||||
|
setShowKeyboardShortcuts(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
document.addEventListener('keydown', handleKeyDown)
|
document.addEventListener('keydown', handleKeyDown)
|
||||||
|
document.addEventListener('keyup', handleKeyUp)
|
||||||
return () => {
|
return () => {
|
||||||
document.removeEventListener('keydown', handleKeyDown)
|
document.removeEventListener('keydown', handleKeyDown)
|
||||||
|
document.removeEventListener('keyup', handleKeyUp)
|
||||||
}
|
}
|
||||||
}, [isMultiSelectMode, selectedChores.size])
|
}, [isMultiSelectMode, selectedChores.size])
|
||||||
const setSelectedChoreSectionWithCache = value => {
|
const setSelectedChoreSectionWithCache = value => {
|
||||||
@@ -506,7 +531,7 @@ const MyChores = () => {
|
|||||||
const fuse = new Fuse(
|
const fuse = new Fuse(
|
||||||
chores.map(c => ({
|
chores.map(c => ({
|
||||||
...c,
|
...c,
|
||||||
raw_label: c.labelsV2.map(c => c.name).join(' '),
|
raw_label: c.labelsV2?.map(c => c.name).join(' '),
|
||||||
})),
|
})),
|
||||||
searchOptions,
|
searchOptions,
|
||||||
)
|
)
|
||||||
@@ -526,6 +551,12 @@ const MyChores = () => {
|
|||||||
setSearchTerm(term)
|
setSearchTerm(term)
|
||||||
setFilteredChores(fuse.search(term).map(result => result.item))
|
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
|
// Multi-select helper functions
|
||||||
const toggleMultiSelectMode = () => {
|
const toggleMultiSelectMode = () => {
|
||||||
@@ -870,15 +901,21 @@ const MyChores = () => {
|
|||||||
padding: 1,
|
padding: 1,
|
||||||
}}
|
}}
|
||||||
onChange={handleSearchChange}
|
onChange={handleSearchChange}
|
||||||
|
startDecorator={
|
||||||
|
<KeyboardShortcutHint shortcut='F' show={showKeyboardShortcuts} />
|
||||||
|
}
|
||||||
endDecorator={
|
endDecorator={
|
||||||
searchTerm && (
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
||||||
<CancelRounded
|
{searchTerm && (
|
||||||
onClick={() => {
|
<>
|
||||||
setSearchTerm('')
|
<KeyboardShortcutHint
|
||||||
setFilteredChores(chores)
|
shortcut='X'
|
||||||
}}
|
show={showKeyboardShortcuts}
|
||||||
/>
|
/>
|
||||||
)
|
<CancelRounded onClick={handleSearchClose} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -981,6 +1018,7 @@ const MyChores = () => {
|
|||||||
>
|
>
|
||||||
{isMultiSelectMode ? <CheckBox /> : <CheckBoxOutlineBlank />}
|
{isMultiSelectMode ? <CheckBox /> : <CheckBoxOutlineBlank />}
|
||||||
</IconButton>
|
</IconButton>
|
||||||
|
<KeyboardShortcutHint shortcut='S' show={showKeyboardShortcuts} />
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* Search Filter with animation */}
|
{/* Search Filter with animation */}
|
||||||
@@ -1202,6 +1240,12 @@ const MyChores = () => {
|
|||||||
minWidth: 'auto',
|
minWidth: 'auto',
|
||||||
'--Button-paddingInline': '0.75rem',
|
'--Button-paddingInline': '0.75rem',
|
||||||
}}
|
}}
|
||||||
|
endDecorator={
|
||||||
|
<KeyboardShortcutHint
|
||||||
|
shortcut='A'
|
||||||
|
show={showKeyboardShortcuts && selectedChores.size > 0}
|
||||||
|
/>
|
||||||
|
}
|
||||||
>
|
>
|
||||||
All
|
All
|
||||||
</Button>
|
</Button>
|
||||||
@@ -1220,6 +1264,13 @@ const MyChores = () => {
|
|||||||
minWidth: 'auto',
|
minWidth: 'auto',
|
||||||
'--Button-paddingInline': '0.75rem',
|
'--Button-paddingInline': '0.75rem',
|
||||||
}}
|
}}
|
||||||
|
endDecorator={
|
||||||
|
<KeyboardShortcutHint
|
||||||
|
withCtrl={false}
|
||||||
|
shortcut='Esc'
|
||||||
|
show={showKeyboardShortcuts && selectedChores.size > 0}
|
||||||
|
/>
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{selectedChores.size === 0 ? 'Close' : 'Clear'}
|
{selectedChores.size === 0 ? 'Close' : 'Clear'}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -1252,6 +1303,12 @@ const MyChores = () => {
|
|||||||
sx={{
|
sx={{
|
||||||
'--Button-paddingInline': { xs: '0.75rem', sm: '1rem' },
|
'--Button-paddingInline': { xs: '0.75rem', sm: '1rem' },
|
||||||
}}
|
}}
|
||||||
|
endDecorator={
|
||||||
|
<KeyboardShortcutHint
|
||||||
|
shortcut='Enter'
|
||||||
|
show={showKeyboardShortcuts && selectedChores.size > 0}
|
||||||
|
/>
|
||||||
|
}
|
||||||
>
|
>
|
||||||
Complete
|
Complete
|
||||||
</Button>
|
</Button>
|
||||||
@@ -1265,6 +1322,12 @@ const MyChores = () => {
|
|||||||
sx={{
|
sx={{
|
||||||
'--Button-paddingInline': { xs: '0.75rem', sm: '1rem' },
|
'--Button-paddingInline': { xs: '0.75rem', sm: '1rem' },
|
||||||
}}
|
}}
|
||||||
|
endDecorator={
|
||||||
|
<KeyboardShortcutHint
|
||||||
|
shortcut='/'
|
||||||
|
show={showKeyboardShortcuts && selectedChores.size > 0}
|
||||||
|
/>
|
||||||
|
}
|
||||||
>
|
>
|
||||||
Skip
|
Skip
|
||||||
</Button>
|
</Button>
|
||||||
@@ -1278,6 +1341,12 @@ const MyChores = () => {
|
|||||||
sx={{
|
sx={{
|
||||||
'--Button-paddingInline': { xs: '0.75rem', sm: '1rem' },
|
'--Button-paddingInline': { xs: '0.75rem', sm: '1rem' },
|
||||||
}}
|
}}
|
||||||
|
endDecorator={
|
||||||
|
<KeyboardShortcutHint
|
||||||
|
shortcut='X'
|
||||||
|
show={showKeyboardShortcuts && selectedChores.size > 0}
|
||||||
|
/>
|
||||||
|
}
|
||||||
>
|
>
|
||||||
Archive
|
Archive
|
||||||
</Button>
|
</Button>
|
||||||
@@ -1292,6 +1361,13 @@ const MyChores = () => {
|
|||||||
sx={{
|
sx={{
|
||||||
'--Button-paddingInline': { xs: '0.75rem', sm: '1rem' },
|
'--Button-paddingInline': { xs: '0.75rem', sm: '1rem' },
|
||||||
}}
|
}}
|
||||||
|
endDecorator={
|
||||||
|
<KeyboardShortcutHint
|
||||||
|
withShift={true}
|
||||||
|
shortcut='X'
|
||||||
|
show={showKeyboardShortcuts && selectedChores.size > 0}
|
||||||
|
/>
|
||||||
|
}
|
||||||
>
|
>
|
||||||
Delete
|
Delete
|
||||||
</Button>
|
</Button>
|
||||||
@@ -1473,6 +1549,12 @@ const MyChores = () => {
|
|||||||
variant='outlined'
|
variant='outlined'
|
||||||
color='neutral'
|
color='neutral'
|
||||||
startDecorator={<Unarchive />}
|
startDecorator={<Unarchive />}
|
||||||
|
endDecorator={
|
||||||
|
<KeyboardShortcutHint
|
||||||
|
shortcut='A'
|
||||||
|
show={showKeyboardShortcuts}
|
||||||
|
/>
|
||||||
|
}
|
||||||
>
|
>
|
||||||
Show Archived
|
Show Archived
|
||||||
</Button>
|
</Button>
|
||||||
@@ -1550,6 +1632,12 @@ const MyChores = () => {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</IconButton>
|
</IconButton>
|
||||||
|
|
||||||
|
<KeyboardShortcutHint
|
||||||
|
sx={{ position: 'relative', left: -40, top: 30 }}
|
||||||
|
show={showKeyboardShortcuts}
|
||||||
|
shortcut='K'
|
||||||
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
<NotificationAccessSnackbar />
|
<NotificationAccessSnackbar />
|
||||||
{addTaskModalOpen && (
|
{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 {
|
import {
|
||||||
Avatar,
|
Avatar,
|
||||||
Box,
|
Box,
|
||||||
Chip,
|
Chip,
|
||||||
|
Grid,
|
||||||
ListDivider,
|
ListDivider,
|
||||||
ListItem,
|
ListItem,
|
||||||
ListItemContent,
|
ListItemContent,
|
||||||
ListItemDecorator,
|
|
||||||
Typography,
|
Typography,
|
||||||
} from '@mui/joy'
|
} from '@mui/joy'
|
||||||
import moment from 'moment'
|
import moment from 'moment'
|
||||||
|
|
||||||
export const getCompletedChip = historyEntry => {
|
/**
|
||||||
var text = 'No Due Date'
|
* Enhanced completion status chip with better logic and visual design
|
||||||
var color = 'info'
|
*/
|
||||||
var icon = <CalendarViewDay />
|
const getCompletedChip = historyEntry => {
|
||||||
// if completed few hours +-6 hours
|
if (historyEntry.status === 0) {
|
||||||
if (
|
return null
|
||||||
historyEntry.dueDate &&
|
}
|
||||||
historyEntry.performedAt > historyEntry.dueDate - 1000 * 60 * 60 * 6 &&
|
if (!historyEntry.dueDate) {
|
||||||
historyEntry.performedAt < historyEntry.dueDate + 1000 * 60 * 60 * 6
|
return (
|
||||||
) {
|
<Chip
|
||||||
text = 'On Time'
|
size='sm'
|
||||||
color = 'success'
|
variant='soft'
|
||||||
icon = <Check />
|
color='neutral'
|
||||||
} else if (
|
startDecorator={<CalendarViewDay />}
|
||||||
historyEntry.dueDate &&
|
>
|
||||||
historyEntry.performedAt < historyEntry.dueDate
|
No Due Date
|
||||||
) {
|
</Chip>
|
||||||
text = 'On Time'
|
)
|
||||||
color = 'success'
|
|
||||||
icon = <Check />
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// if completed after due date then it's late
|
const performedAt = moment(historyEntry.performedAt)
|
||||||
else if (
|
const dueDate = moment(historyEntry.dueDate)
|
||||||
historyEntry.dueDate &&
|
const gracePeriod = 6 * 60 * 60 * 1000 // 6 hours in milliseconds
|
||||||
historyEntry.performedAt > historyEntry.dueDate
|
|
||||||
) {
|
if (Math.abs(performedAt - dueDate) <= gracePeriod) {
|
||||||
text = 'Late'
|
return (
|
||||||
color = 'warning'
|
<Chip
|
||||||
icon = <Timelapse />
|
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 {
|
} else {
|
||||||
text = 'No Due Date'
|
return (
|
||||||
color = 'neutral'
|
<Chip
|
||||||
icon = <CalendarViewDay />
|
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 = ({
|
const HistoryCard = ({
|
||||||
allHistory,
|
allHistory,
|
||||||
performers,
|
performers,
|
||||||
@@ -61,7 +84,10 @@ const HistoryCard = ({
|
|||||||
index,
|
index,
|
||||||
onClick,
|
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')
|
const diffInMinutes = moment(startDate).diff(endDate, 'minutes')
|
||||||
let timeValue = diffInMinutes
|
let timeValue = diffInMinutes
|
||||||
let unit = 'minute'
|
let unit = 'minute'
|
||||||
@@ -81,86 +107,187 @@ const HistoryCard = ({
|
|||||||
return `${timeValue} ${unit}${timeValue !== 1 ? 's' : ''}`
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<ListItem sx={{ gap: 1.5, alignItems: 'flex-start' }} onClick={onClick}>
|
<ListItem
|
||||||
{' '}
|
onClick={onClick}
|
||||||
{/* Adjusted spacing and alignment */}
|
sx={{
|
||||||
<ListItemDecorator>
|
cursor: onClick ? 'pointer' : 'default',
|
||||||
<Avatar sx={{ mr: 1 }}>
|
py: 1.5,
|
||||||
{performers
|
px: 2,
|
||||||
.find(p => p.userId === historyEntry.completedBy)
|
'&:hover': onClick
|
||||||
?.displayName?.charAt(0) || '?'}
|
? {
|
||||||
</Avatar>
|
backgroundColor: 'background.level1',
|
||||||
</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
|
|
||||||
}
|
}
|
||||||
</Chip>{' '}
|
: {},
|
||||||
completed
|
borderRadius: 'sm',
|
||||||
{historyEntry.completedBy !== historyEntry.assignedTo && (
|
transition: 'background-color 0.2s',
|
||||||
<>
|
}}
|
||||||
{', '}
|
>
|
||||||
assigned to{' '}
|
<ListItemContent>
|
||||||
<Chip>
|
<Grid container spacing={1} alignItems='center'>
|
||||||
{
|
{/* First Row/Column: Status and Time Info */}
|
||||||
performers.find(p => p.userId === historyEntry.assignedTo)
|
<Grid xs={12} sm={8}>
|
||||||
?.displayName
|
<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>
|
</Chip>
|
||||||
</>
|
|
||||||
)}
|
{historyEntry.completedBy !== historyEntry.assignedTo &&
|
||||||
</Typography>
|
assignedTo && (
|
||||||
{historyEntry.dueDate && (
|
<>
|
||||||
<Typography level='body2' color='text.tertiary'>
|
<Typography
|
||||||
Due: {moment(historyEntry.dueDate).format('ddd MM/DD/yyyy')}
|
level='body-xs'
|
||||||
</Typography>
|
sx={{ color: 'text.tertiary' }}
|
||||||
)}
|
>
|
||||||
{historyEntry.notes && (
|
→
|
||||||
<Typography level='body2' color='text.tertiary'>
|
</Typography>
|
||||||
Note: {historyEntry.notes}
|
<Chip
|
||||||
</Typography>
|
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>
|
</ListItemContent>
|
||||||
</ListItem>
|
</ListItem>
|
||||||
{index < allHistory.length - 1 && (
|
|
||||||
<>
|
{/* Compact Divider with Time Difference */}
|
||||||
<ListDivider component='li'>
|
{index < allHistory.length - 1 && allHistory[index + 1].performedAt && (
|
||||||
{/* time between two completion: */}
|
<ListDivider
|
||||||
{index < allHistory.length - 1 &&
|
component='li'
|
||||||
allHistory[index + 1].performedAt && (
|
sx={{
|
||||||
<Typography level='body3' color='text.tertiary'>
|
my: 0.5,
|
||||||
{formatTimeDifference(
|
}}
|
||||||
historyEntry.performedAt,
|
>
|
||||||
allHistory[index + 1].performedAt,
|
<Typography
|
||||||
)}{' '}
|
level='body-xs'
|
||||||
before
|
sx={{
|
||||||
</Typography>
|
color: 'text.tertiary',
|
||||||
)}
|
backgroundColor: 'background.surface',
|
||||||
</ListDivider>
|
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 DeleteIcon from '@mui/icons-material/Delete'
|
||||||
import EditIcon from '@mui/icons-material/Edit'
|
import EditIcon from '@mui/icons-material/Edit'
|
||||||
import {
|
import {
|
||||||
|
Avatar,
|
||||||
Box,
|
Box,
|
||||||
Button,
|
|
||||||
Chip,
|
Chip,
|
||||||
CircularProgress,
|
CircularProgress,
|
||||||
Container,
|
Container,
|
||||||
IconButton,
|
IconButton,
|
||||||
Typography,
|
Typography,
|
||||||
} from '@mui/joy'
|
} from '@mui/joy'
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import LabelModal from '../Modals/Inputs/LabelModal'
|
import LabelModal from '../Modals/Inputs/LabelModal'
|
||||||
|
|
||||||
// import { useMutation, useQueryClient } from '@tanstack/react-query'
|
// import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
import { Add } from '@mui/icons-material'
|
import { Add } from '@mui/icons-material'
|
||||||
import { useQueryClient } from '@tanstack/react-query'
|
import { useQueryClient } from '@tanstack/react-query'
|
||||||
import { getTextColorFromBackgroundColor } from '../../utils/Colors'
|
import { getTextColorFromBackgroundColor } from '../../utils/Colors'
|
||||||
|
import LABEL_COLORS from '../../utils/Colors'
|
||||||
import { DeleteLabel } from '../../utils/Fetcher'
|
import { DeleteLabel } from '../../utils/Fetcher'
|
||||||
import ConfirmationModal from '../Modals/Inputs/ConfirmationModal'
|
import ConfirmationModal from '../Modals/Inputs/ConfirmationModal'
|
||||||
import { useLabels } from './LabelQueries'
|
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 LabelView = () => {
|
||||||
const { data: labels, isLabelsLoading, isError } = useLabels()
|
const { data: labels, isLabelsLoading, isError } = useLabels()
|
||||||
|
|
||||||
@@ -61,7 +411,7 @@ const LabelView = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleDeleteLabel = id => {
|
const handleDeleteLabel = id => {
|
||||||
DeleteLabel(id).then(res => {
|
DeleteLabel(id).then(() => {
|
||||||
const updatedLabels = userLabels.filter(label => label.id !== id)
|
const updatedLabels = userLabels.filter(label => label.id !== id)
|
||||||
setUserLabels(updatedLabels)
|
setUserLabels(updatedLabels)
|
||||||
|
|
||||||
@@ -106,54 +456,40 @@ const LabelView = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Container maxWidth='md'>
|
<Container maxWidth='md' sx={{ px: 0 }}>
|
||||||
<div className='flex flex-col gap-2'>
|
<Box
|
||||||
{userLabels.map(label => (
|
sx={{
|
||||||
<div
|
bgcolor: 'background.body',
|
||||||
key={label}
|
border: '1px solid',
|
||||||
className='grid w-full grid-cols-[1fr,auto,auto] rounded-lg border border-zinc-200/80 p-4 shadow-sm dark:bg-zinc-900'
|
borderColor: 'divider',
|
||||||
|
borderRadius: 'md',
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{userLabels.length === 0 && (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
flexDirection: 'column',
|
||||||
|
height: '50vh',
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<Chip
|
<Typography level='title-md' gutterBottom>
|
||||||
variant='outlined'
|
No labels available. Add a new label to get started.
|
||||||
color='primary'
|
</Typography>
|
||||||
size='lg'
|
</Box>
|
||||||
sx={{
|
)}
|
||||||
background: label.color,
|
{userLabels.map(label => (
|
||||||
borderColor: label.color,
|
<LabelCard
|
||||||
color: getTextColorFromBackgroundColor(label.color),
|
key={label.id}
|
||||||
}}
|
label={label}
|
||||||
>
|
onEditClick={handleEditLabel}
|
||||||
{label.name}
|
onDeleteClick={handleDeleteClicked}
|
||||||
</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>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</Box>
|
||||||
|
|
||||||
{userLabels.length === 0 && (
|
|
||||||
<Typography textAlign='center' mt={2}>
|
|
||||||
No labels available. Add a new label to get started.
|
|
||||||
</Typography>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{modalOpen && (
|
{modalOpen && (
|
||||||
<LabelModal
|
<LabelModal
|
||||||
|
|||||||
@@ -6,11 +6,14 @@ import {
|
|||||||
PlusOne,
|
PlusOne,
|
||||||
ToggleOff,
|
ToggleOff,
|
||||||
ToggleOn,
|
ToggleOn,
|
||||||
|
TrendingUp,
|
||||||
Widgets,
|
Widgets,
|
||||||
} from '@mui/icons-material'
|
} from '@mui/icons-material'
|
||||||
import {
|
import {
|
||||||
|
Avatar,
|
||||||
Box,
|
Box,
|
||||||
Button,
|
Button,
|
||||||
|
Card,
|
||||||
Chip,
|
Chip,
|
||||||
Container,
|
Container,
|
||||||
Grid,
|
Grid,
|
||||||
@@ -38,6 +41,7 @@ const ThingCard = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const [isDisabled, setIsDisabled] = useState(false)
|
const [isDisabled, setIsDisabled] = useState(false)
|
||||||
const Navigate = useNavigate()
|
const Navigate = useNavigate()
|
||||||
|
|
||||||
const getThingIcon = type => {
|
const getThingIcon = type => {
|
||||||
if (type === 'text') {
|
if (type === 'text') {
|
||||||
return <Flip />
|
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 => {
|
const handleRequestChange = thing => {
|
||||||
setIsDisabled(true)
|
setIsDisabled(true)
|
||||||
onStateChangeRequest(thing)
|
onStateChangeRequest(thing)
|
||||||
@@ -62,103 +103,158 @@ const ThingCard = ({
|
|||||||
}, 2000)
|
}, 2000)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
const actionProps = getActionButtonProps()
|
||||||
<Box
|
|
||||||
className='rounded-lg border border-zinc-200/80 p-4 shadow-sm'
|
|
||||||
sx={{
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
p: 2,
|
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
variant='outlined'
|
||||||
|
sx={{
|
||||||
mb: 2,
|
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 container spacing={2} alignItems='center'>
|
||||||
<Grid
|
{/* First Row: Thing Info */}
|
||||||
item
|
<Grid xs={12} sm={8}>
|
||||||
xs={12}
|
|
||||||
sm={8}
|
|
||||||
onClick={() => Navigate(`/things/${thing?.id}`)}
|
|
||||||
>
|
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
display: 'flex',
|
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',
|
alignItems: 'center',
|
||||||
gap: 1,
|
gap: 1,
|
||||||
cursor: 'pointer',
|
|
||||||
}}
|
}}
|
||||||
onClick={() => Navigate(`/things/${thing?.id}`)}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
<Typography level='title-lg'>{thing?.name}</Typography>
|
<Button
|
||||||
<Chip
|
variant='solid'
|
||||||
|
color={actionProps.color}
|
||||||
size='sm'
|
size='sm'
|
||||||
sx={{
|
onClick={() => {
|
||||||
ml: 1,
|
if (thing?.type === 'text') {
|
||||||
|
onEditClick(thing)
|
||||||
|
} else {
|
||||||
|
handleRequestChange(thing)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={isDisabled}
|
||||||
|
startDecorator={getThingIcon(thing?.type)}
|
||||||
|
sx={{
|
||||||
|
minWidth: '80px',
|
||||||
|
fontWeight: 'md',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{thing?.type}
|
{actionProps.text}
|
||||||
</Chip>
|
</Button>
|
||||||
</Box>
|
|
||||||
State: <Chip size='md'>{thing?.state}</Chip>
|
<IconButton
|
||||||
</Grid>
|
variant='outlined'
|
||||||
<Grid
|
color='neutral'
|
||||||
item
|
size='sm'
|
||||||
xs={12}
|
onClick={(e) => {
|
||||||
sm={4}
|
e.stopPropagation()
|
||||||
container
|
|
||||||
justifyContent='flex-end'
|
|
||||||
alignItems='center'
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
variant='soft'
|
|
||||||
color='success'
|
|
||||||
onClick={() => {
|
|
||||||
if (thing?.type === 'text') {
|
|
||||||
onEditClick(thing)
|
onEditClick(thing)
|
||||||
} else {
|
}}
|
||||||
handleRequestChange(thing)
|
sx={{
|
||||||
}
|
borderRadius: '50%',
|
||||||
}}
|
width: 32,
|
||||||
disabled={isDisabled}
|
height: 32,
|
||||||
startDecorator={getThingIcon(thing?.type)}
|
transition: 'all 0.2s',
|
||||||
>
|
'&:hover': {
|
||||||
{thing?.type === 'text'
|
backgroundColor: 'primary.softBg',
|
||||||
? 'Change'
|
borderColor: 'primary.300',
|
||||||
: thing?.type === 'number'
|
},
|
||||||
? 'Increment'
|
}}
|
||||||
: 'Toggle'}
|
>
|
||||||
</Button>
|
<Edit fontSize='small' />
|
||||||
<IconButton
|
</IconButton>
|
||||||
color='primary'
|
|
||||||
onClick={() => onEditClick(thing)}
|
<IconButton
|
||||||
sx={{
|
variant='outlined'
|
||||||
borderRadius: '50%',
|
color='danger'
|
||||||
width: 30,
|
size='sm'
|
||||||
height: 30,
|
onClick={(e) => {
|
||||||
ml: 1,
|
e.stopPropagation()
|
||||||
transition: 'background-color 0.2s',
|
onDeleteClick(thing)
|
||||||
'&:hover': { backgroundColor: 'action.hover' },
|
}}
|
||||||
}}
|
sx={{
|
||||||
>
|
borderRadius: '50%',
|
||||||
<Edit />
|
width: 32,
|
||||||
</IconButton>
|
height: 32,
|
||||||
<IconButton
|
transition: 'all 0.2s',
|
||||||
color='danger'
|
'&:hover': {
|
||||||
onClick={() => onDeleteClick(thing)}
|
backgroundColor: 'danger.softBg',
|
||||||
sx={{
|
borderColor: 'danger.300',
|
||||||
borderRadius: '50%',
|
},
|
||||||
width: 30,
|
}}
|
||||||
height: 30,
|
>
|
||||||
ml: 1,
|
<Delete fontSize='small' />
|
||||||
}}
|
</IconButton>
|
||||||
>
|
</Box>
|
||||||
<Delete fontSize='small' />
|
|
||||||
</IconButton>
|
|
||||||
</Grid>
|
</Grid>
|
||||||
</Grid>
|
</Grid>
|
||||||
</Box>
|
</Card>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
Box,
|
Box,
|
||||||
Checkbox,
|
Checkbox,
|
||||||
|
Chip,
|
||||||
IconButton,
|
IconButton,
|
||||||
Input,
|
Input,
|
||||||
List,
|
List,
|
||||||
@@ -31,6 +32,7 @@ import {
|
|||||||
Typography,
|
Typography,
|
||||||
} from '@mui/joy'
|
} from '@mui/joy'
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
|
import { useUserProfile } from '../../queries/UserQueries'
|
||||||
import { CompleteSubTask } from '../../utils/Fetcher'
|
import { CompleteSubTask } from '../../utils/Fetcher'
|
||||||
|
|
||||||
function SortableItem({
|
function SortableItem({
|
||||||
@@ -43,10 +45,12 @@ function SortableItem({
|
|||||||
setTasks,
|
setTasks,
|
||||||
level = 0,
|
level = 0,
|
||||||
editMode,
|
editMode,
|
||||||
|
performers = [],
|
||||||
}) {
|
}) {
|
||||||
const { attributes, listeners, setNodeRef, transform, transition } =
|
const { attributes, listeners, setNodeRef, transform, transition } =
|
||||||
useSortable({
|
useSortable({
|
||||||
id: task.id,
|
id: task.id,
|
||||||
|
data: { completedAt: task.completedAt, completedBy: task.completedBy },
|
||||||
// Add touch sensor options for better mobile scrolling
|
// Add touch sensor options for better mobile scrolling
|
||||||
options: {
|
options: {
|
||||||
activationConstraint: {
|
activationConstraint: {
|
||||||
@@ -206,6 +210,14 @@ function SortableItem({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{new Date(task.completedAt).toLocaleString()}
|
{new Date(task.completedAt).toLocaleString()}
|
||||||
|
{performers.find(p => p.userId === task.completedBy) ? (
|
||||||
|
<Chip>
|
||||||
|
{
|
||||||
|
performers.find(p => p.userId === task.completedBy)
|
||||||
|
.displayName
|
||||||
|
}
|
||||||
|
</Chip>
|
||||||
|
) : null}
|
||||||
</Typography>
|
</Typography>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
@@ -281,6 +293,7 @@ function SortableItem({
|
|||||||
setTasks={setTasks}
|
setTasks={setTasks}
|
||||||
level={level + 1}
|
level={level + 1}
|
||||||
editMode={editMode}
|
editMode={editMode}
|
||||||
|
performers={performers}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</Box>
|
</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 [newTask, setNewTask] = useState('')
|
||||||
|
const { data: userProfile } = useUserProfile()
|
||||||
|
|
||||||
const topLevelTasks = tasks.filter(task => task.parentId === null)
|
const topLevelTasks = tasks.filter(task => task.parentId === null)
|
||||||
|
|
||||||
@@ -313,7 +333,13 @@ const SubTasks = ({ editMode = true, choreId = 0, tasks = [], setTasks }) => {
|
|||||||
|
|
||||||
// Update the task
|
// Update the task
|
||||||
const updatedTasks = tasks.map(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
|
// If completing a task, also complete all child tasks
|
||||||
@@ -469,6 +495,7 @@ const SubTasks = ({ editMode = true, choreId = 0, tasks = [], setTasks }) => {
|
|||||||
allTasks={tasks}
|
allTasks={tasks}
|
||||||
setTasks={setTasks}
|
setTasks={setTasks}
|
||||||
editMode={editMode}
|
editMode={editMode}
|
||||||
|
performers={performers}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
{editMode && (
|
{editMode && (
|
||||||
|
|||||||
Reference in New Issue
Block a user