feat: Add unarchive functionality to ChoreView and enhance project management features
- Implemented unarchive button for archived chores in ChoreView. - Updated chore status handling to disable actions for archived chores. - Enhanced MyChores component to filter tasks based on project selection, including a default project. - Improved ProjectModal to streamline project creation and editing with a new footer layout. - Refactored ProjectView to accurately count tasks for default and user projects. - Added project selection dropdown in AddTaskModal with default project handling. - Updated NavBar to handle logout using apiClient. - Enhanced ProjectSelector to manage projects with a new menu item for project management.
This commit is contained in:
@@ -31,6 +31,7 @@ import { useNavigate } from 'react-router-dom'
|
||||
import { useImpersonateUser } from '../contexts/ImpersonateUserContext'
|
||||
import useStickyState from '../hooks/useStickyState'
|
||||
import { useCircleMembers, useUserProfile } from '../queries/UserQueries'
|
||||
import { apiClient } from '../utils/apiClient'
|
||||
import { isPlusAccount } from '../utils/Helpers'
|
||||
import UserModal from '../views/Modals/Inputs/UserModal'
|
||||
import SubscriptionModal from './SubscriptionModal'
|
||||
@@ -76,9 +77,7 @@ const UserProfileAvatar = () => {
|
||||
}
|
||||
|
||||
const handleLogout = () => {
|
||||
localStorage.removeItem('access_token')
|
||||
localStorage.removeItem('ca_expiration')
|
||||
window.location.href = '/login'
|
||||
apiClient.handleLogout()
|
||||
}
|
||||
|
||||
const handleSupportEmail = () => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Close } from '@mui/icons-material'
|
||||
import { IconButton, Modal, Sheet, Typography } from '@mui/joy'
|
||||
import { Divider, IconButton, Modal, Sheet, Typography } from '@mui/joy'
|
||||
import { forwardRef, useEffect, useState } from 'react'
|
||||
import { Z_INDEX } from '../../constants/zIndex'
|
||||
|
||||
@@ -10,6 +10,7 @@ const BottomSheetModal = forwardRef(
|
||||
onClose,
|
||||
children,
|
||||
title,
|
||||
footer,
|
||||
height = 'auto',
|
||||
maxHeight = '90vh',
|
||||
expandedHeight = '95vh',
|
||||
@@ -79,7 +80,11 @@ const BottomSheetModal = forwardRef(
|
||||
const currentHeight = isExpanded ? expandedHeight : height
|
||||
|
||||
// Filter out DOM props that shouldn't be passed to Modal
|
||||
const { fullWidth: _fullWidth, unmountDelay: _unmountDelay, ...modalProps } = props
|
||||
const {
|
||||
fullWidth: _fullWidth,
|
||||
unmountDelay: _unmountDelay,
|
||||
...modalProps
|
||||
} = props
|
||||
|
||||
return (
|
||||
<Modal
|
||||
@@ -223,6 +228,21 @@ const BottomSheetModal = forwardRef(
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
|
||||
{footer && (
|
||||
<>
|
||||
<Divider />
|
||||
<footer
|
||||
style={{
|
||||
flexShrink: 0,
|
||||
// borderTop: '1px solid var(--joy-palette-divider)',
|
||||
padding: '16px 20px',
|
||||
}}
|
||||
>
|
||||
{footer}
|
||||
</footer>
|
||||
</>
|
||||
)}
|
||||
</Sheet>
|
||||
</Modal>
|
||||
)
|
||||
|
||||
@@ -2,6 +2,7 @@ import { createContext, useContext, useEffect, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { API_URL } from '../Config'
|
||||
import { apiClient } from '../utils/ApiClient'
|
||||
import { saveTokens, clearAllTokens } from '../utils/TokenStorage'
|
||||
|
||||
const AuthContext = createContext(null)
|
||||
|
||||
@@ -28,14 +29,10 @@ export const AuthProvider = ({ children }) => {
|
||||
return new Date() >= new Date(expiry)
|
||||
}
|
||||
|
||||
const clearAuth = () => {
|
||||
const clearAuth = async () => {
|
||||
setToken(null)
|
||||
setUser(null)
|
||||
localStorage.removeItem('token')
|
||||
localStorage.removeItem('token_expiry')
|
||||
localStorage.removeItem('ca_token')
|
||||
localStorage.removeItem('ca_expiration')
|
||||
localStorage.removeItem('access_token')
|
||||
await clearAllTokens()
|
||||
}
|
||||
|
||||
const login = async credentials => {
|
||||
@@ -58,14 +55,14 @@ export const AuthProvider = ({ children }) => {
|
||||
|
||||
if (userToken) {
|
||||
setToken(userToken)
|
||||
localStorage.setItem('token', userToken)
|
||||
|
||||
if (data.expire || data.access_token_expiry) {
|
||||
localStorage.setItem(
|
||||
'token_expiry',
|
||||
data.expire || data.access_token_expiry,
|
||||
)
|
||||
}
|
||||
// Use centralized token storage
|
||||
await saveTokens({
|
||||
accessToken: userToken,
|
||||
accessTokenExpiry: data.expire || data.access_token_expiry,
|
||||
refreshToken: data.refresh_token,
|
||||
refreshTokenExpiry: data.refresh_token_expiry,
|
||||
})
|
||||
}
|
||||
|
||||
setIsLoading(false)
|
||||
@@ -86,7 +83,7 @@ export const AuthProvider = ({ children }) => {
|
||||
} catch (error) {
|
||||
console.warn('Logout API call failed:', error)
|
||||
} finally {
|
||||
clearAuth()
|
||||
await clearAuth()
|
||||
setIsLoading(false)
|
||||
navigate('/login')
|
||||
}
|
||||
@@ -112,22 +109,21 @@ export const AuthProvider = ({ children }) => {
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const initAuth = async () => {
|
||||
if (token && !isTokenExpired()) {
|
||||
await fetchUser()
|
||||
} else if (token && isTokenExpired()) {
|
||||
// Token is expired, but don't refresh here
|
||||
// Let the first API call handle refresh via ApiClient
|
||||
// Just try to fetch user - if it fails, ApiClient will handle refresh
|
||||
await fetchUser()
|
||||
} else {
|
||||
clearAuth()
|
||||
navigate('/login')
|
||||
}
|
||||
setIsLoading(false)
|
||||
}
|
||||
|
||||
initAuth()
|
||||
// const initAuth = async () => {
|
||||
// if (token && !isTokenExpired()) {
|
||||
// await fetchUser()
|
||||
// } else if (token && isTokenExpired()) {
|
||||
// // Token is expired, but don't refresh here
|
||||
// // Let the first API call handle refresh via ApiClient
|
||||
// // Just try to fetch user - if it fails, ApiClient will handle refresh
|
||||
// await fetchUser()
|
||||
// } else {
|
||||
// clearAuth()
|
||||
// navigate('/login')
|
||||
// }
|
||||
// setIsLoading(false)
|
||||
// }
|
||||
// initAuth()
|
||||
}, [token, navigate])
|
||||
|
||||
const value = {
|
||||
|
||||
@@ -443,6 +443,13 @@ export const useSSE = () => {
|
||||
|
||||
return // Exit early, don't use exponential backoff for 401 errors
|
||||
} else {
|
||||
// Check if refresh token expired
|
||||
if (refreshResult.error === 'Refresh token expired') {
|
||||
console.error('Refresh token expired, user must login again')
|
||||
setError('Session expired - please log in again')
|
||||
return // Don't attempt reconnection
|
||||
}
|
||||
|
||||
console.error('Token refresh failed:', refreshResult.error)
|
||||
setError('Authentication failed - please log in again')
|
||||
// Don't attempt reconnection if token refresh failed
|
||||
|
||||
@@ -46,24 +46,11 @@ export const useUserProfile = () => {
|
||||
const { data, error, isLoading } = useQuery({
|
||||
queryKey: ['userProfile'],
|
||||
queryFn: async () => {
|
||||
// the below code deleted because it cause issue when token expire and screen is off
|
||||
// and then user comes back to the app after long time. the user profile fetch would fail
|
||||
// and there is no retry for some reason. remove this seem to fix the issue.
|
||||
|
||||
if (!isTokenValid()) {
|
||||
throw new Error('Invalid or expired token, cannot fetch user profile')
|
||||
}
|
||||
const resp = await GetUserProfile()
|
||||
const result = await resp.json()
|
||||
// if we got 403 then user probably deleted their account and token is still valid. navigate to login
|
||||
if (resp.status === 403) {
|
||||
localStorage.removeItem('access_token')
|
||||
localStorage.removeItem('ca_expiration')
|
||||
window.location.href = '/login'
|
||||
throw new Error('User account deleted or access forbidden')
|
||||
}
|
||||
|
||||
return result.res // Return the actual user profile data
|
||||
return result.res || null
|
||||
},
|
||||
staleTime: 30 * 60 * 1000, // 30 minutes in milliseconds
|
||||
gcTime: 30 * 60 * 1000, // 30 minutes in milliseconds
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { CheckCircle, Error, Info, Warning } from '@mui/icons-material'
|
||||
import { CheckCircle, Error, Info, Undo, Warning } from '@mui/icons-material'
|
||||
import { Box, Button, Snackbar, Typography } from '@mui/joy'
|
||||
import React, { createContext, useContext, useState } from 'react'
|
||||
|
||||
@@ -24,10 +24,17 @@ const NOTIFICATION_TYPES = {
|
||||
success: {
|
||||
color: 'success',
|
||||
icon: <CheckCircle color='success' />,
|
||||
autoHideDuration: 3000,
|
||||
autoHideDuration: 5000,
|
||||
showDismissButton: false,
|
||||
defaultTitle: 'Success',
|
||||
},
|
||||
undo: {
|
||||
color: 'success',
|
||||
icon: <Undo color='success' />,
|
||||
autoHideDuration: null,
|
||||
showDismissButton: false,
|
||||
defaultTitle: 'Undone Successfully',
|
||||
},
|
||||
warning: {
|
||||
color: 'warning',
|
||||
icon: <Warning color='warning' />,
|
||||
@@ -134,6 +141,10 @@ export const NotificationProvider = ({ children }) => {
|
||||
return addNotification(normalizeNotification(error, 'error'))
|
||||
}
|
||||
|
||||
const showUndo = message => {
|
||||
return addNotification(normalizeNotification(message, 'undo'))
|
||||
}
|
||||
|
||||
const showSuccess = message => {
|
||||
return addNotification(normalizeNotification(message, 'success'))
|
||||
}
|
||||
@@ -189,7 +200,18 @@ export const NotificationProvider = ({ children }) => {
|
||||
onClose={() => removeNotification(notification.id)}
|
||||
startDecorator={notificationIcon}
|
||||
endDecorator={
|
||||
config.showDismissButton ? (
|
||||
notification.undoAction ? (
|
||||
<Button
|
||||
variant='outlined'
|
||||
color={config.color}
|
||||
onClick={() => {
|
||||
notification.undoAction()
|
||||
removeNotification(notification.id)
|
||||
}}
|
||||
>
|
||||
Undo
|
||||
</Button>
|
||||
) : config.showDismissButton ? (
|
||||
<Button
|
||||
variant='outlined'
|
||||
color={config.color}
|
||||
@@ -232,6 +254,7 @@ export const NotificationProvider = ({ children }) => {
|
||||
showNotification,
|
||||
showError,
|
||||
showSuccess,
|
||||
showUndo,
|
||||
showWarning,
|
||||
showInfo,
|
||||
removeNotification,
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import { API_URL } from '../Config'
|
||||
import { RefreshToken } from './Fetcher'
|
||||
import { logout, RefreshToken } from './Fetcher'
|
||||
import {
|
||||
clearAllTokens,
|
||||
isRefreshTokenExpired,
|
||||
saveTokens,
|
||||
} from './TokenStorage'
|
||||
|
||||
class ApiClient {
|
||||
constructor() {
|
||||
@@ -11,6 +16,17 @@ class ApiClient {
|
||||
}
|
||||
|
||||
async refreshToken() {
|
||||
// Check if refresh token is expired BEFORE attempting refresh
|
||||
const refreshExpired = await isRefreshTokenExpired()
|
||||
if (refreshExpired) {
|
||||
console.log('Refresh token expired, forcing logout')
|
||||
await clearAllTokens()
|
||||
if (window.location.pathname !== '/login') {
|
||||
window.location.href = '/login'
|
||||
}
|
||||
return { success: false, error: 'Refresh token expired' }
|
||||
}
|
||||
|
||||
if (this.isRefreshing) {
|
||||
return { success: false, error: 'Already refreshing' }
|
||||
}
|
||||
@@ -30,14 +46,13 @@ class ApiClient {
|
||||
const data = await refreshReq.json()
|
||||
const newToken = data.token || data.access_token
|
||||
|
||||
// Update Local Storage
|
||||
localStorage.setItem('token', newToken)
|
||||
if (data.expire || data.access_token_expiry) {
|
||||
localStorage.setItem(
|
||||
'token_expiry',
|
||||
data.expire || data.access_token_expiry,
|
||||
)
|
||||
}
|
||||
// Save all tokens including rotated refresh token
|
||||
await saveTokens({
|
||||
accessToken: newToken,
|
||||
accessTokenExpiry: data.expire || data.access_token_expiry,
|
||||
refreshToken: data.refresh_token,
|
||||
refreshTokenExpiry: data.refresh_token_expiry,
|
||||
})
|
||||
|
||||
// Update last refresh time
|
||||
this.lastRefreshTime = Date.now()
|
||||
@@ -90,10 +105,11 @@ class ApiClient {
|
||||
}
|
||||
|
||||
// Helper to avoid repeating cleanup code
|
||||
handleLogout() {
|
||||
localStorage.removeItem('token')
|
||||
localStorage.removeItem('token_expiry')
|
||||
window.location.href = '/login'
|
||||
async handleLogout() {
|
||||
logout().then(async () => {
|
||||
await clearAllTokens()
|
||||
if (window.location.pathname !== '/login') window.location.href = '/login'
|
||||
}) // fire and forget
|
||||
}
|
||||
async request(endpoint, options = {}) {
|
||||
const url = `${this.baseURL}${endpoint}`
|
||||
|
||||
@@ -55,6 +55,14 @@ const login = (username, password) => {
|
||||
})
|
||||
}
|
||||
|
||||
const logout = () => {
|
||||
const baseURL = apiManager.getApiURL()
|
||||
return fetch(`${baseURL}/auth/logout`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
})
|
||||
}
|
||||
|
||||
const GetAllUsers = () => {
|
||||
return Fetch(`/users/`, {
|
||||
method: 'GET',
|
||||
@@ -888,6 +896,7 @@ export {
|
||||
JoinCircle,
|
||||
LeaveCircle,
|
||||
login,
|
||||
logout,
|
||||
MarkChoreComplete,
|
||||
NudgeChore,
|
||||
PauseChore,
|
||||
|
||||
188
src/utils/TokenStorage.js
Normal file
188
src/utils/TokenStorage.js
Normal file
@@ -0,0 +1,188 @@
|
||||
import { Preferences } from '@capacitor/preferences'
|
||||
|
||||
// Token storage keys
|
||||
const TOKEN_KEYS = {
|
||||
ACCESS_TOKEN: 'token',
|
||||
ACCESS_TOKEN_EXPIRY: 'token_expiry',
|
||||
REFRESH_TOKEN: 'refresh_token',
|
||||
REFRESH_TOKEN_EXPIRY: 'refresh_token_expiry',
|
||||
}
|
||||
|
||||
// Cache platform detection to avoid repeated checks
|
||||
let _isNativePlatform = null
|
||||
|
||||
// Platform detection
|
||||
const isNativePlatform = () => {
|
||||
if (_isNativePlatform === null) {
|
||||
try {
|
||||
_isNativePlatform =
|
||||
typeof window !== 'undefined' &&
|
||||
window.Capacitor?.isNativePlatform?.()
|
||||
} catch (error) {
|
||||
console.warn('Platform detection failed, defaulting to web:', error)
|
||||
_isNativePlatform = false
|
||||
}
|
||||
}
|
||||
return _isNativePlatform
|
||||
}
|
||||
|
||||
// Track if we're currently clearing tokens to prevent race conditions
|
||||
let clearingTokens = false
|
||||
|
||||
/**
|
||||
* Save tokens based on platform
|
||||
* Web: Only access tokens to localStorage
|
||||
* Native: Access tokens to localStorage + refresh tokens to Capacitor Preferences
|
||||
*/
|
||||
export const saveTokens = async ({
|
||||
accessToken,
|
||||
accessTokenExpiry,
|
||||
refreshToken,
|
||||
refreshTokenExpiry,
|
||||
}) => {
|
||||
try {
|
||||
// Always save access tokens to localStorage
|
||||
if (accessToken) {
|
||||
localStorage.setItem(TOKEN_KEYS.ACCESS_TOKEN, accessToken)
|
||||
}
|
||||
if (accessTokenExpiry) {
|
||||
localStorage.setItem(TOKEN_KEYS.ACCESS_TOKEN_EXPIRY, accessTokenExpiry)
|
||||
}
|
||||
|
||||
// On native platforms, also save refresh tokens to Capacitor Preferences
|
||||
if (isNativePlatform()) {
|
||||
try {
|
||||
if (refreshToken) {
|
||||
await Preferences.set({
|
||||
key: TOKEN_KEYS.REFRESH_TOKEN,
|
||||
value: refreshToken,
|
||||
})
|
||||
}
|
||||
if (refreshTokenExpiry) {
|
||||
await Preferences.set({
|
||||
key: TOKEN_KEYS.REFRESH_TOKEN_EXPIRY,
|
||||
value: refreshTokenExpiry,
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(
|
||||
'Failed to save refresh tokens to Capacitor Preferences:',
|
||||
error,
|
||||
)
|
||||
// Don't throw - access token is still saved to localStorage
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error saving tokens:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get refresh token from storage
|
||||
* Web: Returns null (uses HTTP-only cookies)
|
||||
* Native: Returns from Capacitor Preferences
|
||||
*/
|
||||
export const getRefreshToken = async () => {
|
||||
if (!isNativePlatform()) {
|
||||
return null // Web uses HTTP-only cookies
|
||||
}
|
||||
|
||||
try {
|
||||
const { value } = await Preferences.get({ key: TOKEN_KEYS.REFRESH_TOKEN })
|
||||
return value
|
||||
} catch (error) {
|
||||
console.error('Error reading refresh token from Preferences:', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get refresh token expiry from storage
|
||||
* Web: Returns null (uses HTTP-only cookies)
|
||||
* Native: Returns from Capacitor Preferences
|
||||
*/
|
||||
export const getRefreshTokenExpiry = async () => {
|
||||
if (!isNativePlatform()) {
|
||||
return null // Web uses HTTP-only cookies
|
||||
}
|
||||
|
||||
try {
|
||||
const { value } = await Preferences.get({
|
||||
key: TOKEN_KEYS.REFRESH_TOKEN_EXPIRY,
|
||||
})
|
||||
return value
|
||||
} catch (error) {
|
||||
console.error('Error reading refresh token expiry from Preferences:', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if refresh token is expired
|
||||
* Web: Returns false (backend handles cookie expiration)
|
||||
* Native: Checks expiry from Capacitor Preferences
|
||||
*/
|
||||
export const isRefreshTokenExpired = async () => {
|
||||
if (!isNativePlatform()) {
|
||||
return false // Web uses cookies, backend handles expiration
|
||||
}
|
||||
|
||||
const expiry = await getRefreshTokenExpiry()
|
||||
if (!expiry) {
|
||||
return true // No expiry means no token
|
||||
}
|
||||
|
||||
try {
|
||||
const expiryDate = new Date(expiry)
|
||||
if (isNaN(expiryDate.getTime())) {
|
||||
console.error('Invalid refresh token expiry date:', expiry)
|
||||
return true // Treat invalid date as expired
|
||||
}
|
||||
return new Date() >= expiryDate
|
||||
} catch (error) {
|
||||
console.error('Error parsing refresh token expiry:', error)
|
||||
return true // Treat parse error as expired
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all tokens from all storage locations
|
||||
* Idempotent - safe to call multiple times
|
||||
*/
|
||||
export const clearAllTokens = async () => {
|
||||
if (clearingTokens) {
|
||||
return // Already clearing, don't run concurrently
|
||||
}
|
||||
|
||||
clearingTokens = true
|
||||
|
||||
try {
|
||||
// Clear localStorage
|
||||
localStorage.removeItem(TOKEN_KEYS.ACCESS_TOKEN)
|
||||
localStorage.removeItem(TOKEN_KEYS.ACCESS_TOKEN_EXPIRY)
|
||||
// Clean up legacy keys
|
||||
localStorage.removeItem('ca_token')
|
||||
localStorage.removeItem('ca_expiration')
|
||||
localStorage.removeItem('access_token')
|
||||
|
||||
// Clear Capacitor Preferences on native
|
||||
if (isNativePlatform()) {
|
||||
try {
|
||||
await Preferences.remove({ key: TOKEN_KEYS.REFRESH_TOKEN })
|
||||
await Preferences.remove({ key: TOKEN_KEYS.REFRESH_TOKEN_EXPIRY })
|
||||
} catch (error) {
|
||||
console.error('Error clearing tokens from Preferences:', error)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error clearing tokens:', error)
|
||||
} finally {
|
||||
clearingTokens = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Export platform detection helper
|
||||
*/
|
||||
export const isNative = isNativePlatform
|
||||
@@ -33,6 +33,7 @@ import { useResource } from '../../queries/ResourceQueries'
|
||||
import { useUserProfile } from '../../queries/UserQueries.jsx'
|
||||
import { useNotification } from '../../service/NotificationProvider'
|
||||
import { apiClient } from '../../utils/apiClient'
|
||||
import { saveTokens } from '../../utils/TokenStorage'
|
||||
import { buildChildUsername, getUserDisplayInfo } from '../../utils/UserHelpers'
|
||||
import MFAVerificationModal from './MFAVerificationModal'
|
||||
|
||||
@@ -202,10 +203,13 @@ const LoginView = () => {
|
||||
const token = responseData.token || responseData.access_token
|
||||
const expiry = responseData.expire || responseData.access_token_expiry
|
||||
|
||||
localStorage.setItem('token', token)
|
||||
if (expiry) {
|
||||
localStorage.setItem('token_expiry', expiry)
|
||||
}
|
||||
// Save all tokens including refresh tokens
|
||||
await saveTokens({
|
||||
accessToken: token,
|
||||
accessTokenExpiry: expiry,
|
||||
refreshToken: responseData.refresh_token,
|
||||
refreshTokenExpiry: responseData.refresh_token_expiry,
|
||||
})
|
||||
|
||||
// Refetch user profile after successful OAuth login
|
||||
queryClient.invalidateQueries(['userProfile'])
|
||||
@@ -247,9 +251,15 @@ const LoginView = () => {
|
||||
})
|
||||
}
|
||||
|
||||
const handleMFASuccess = data => {
|
||||
localStorage.setItem('token', data.token)
|
||||
localStorage.setItem('token_expiry', data.expire)
|
||||
const handleMFASuccess = async data => {
|
||||
// Save all tokens including refresh tokens
|
||||
await saveTokens({
|
||||
accessToken: data.token,
|
||||
accessTokenExpiry: data.expire,
|
||||
refreshToken: data.refresh_token,
|
||||
refreshTokenExpiry: data.refresh_token_expiry,
|
||||
})
|
||||
|
||||
setMfaModalOpen(false)
|
||||
setMfaSessionToken('')
|
||||
|
||||
@@ -427,10 +437,7 @@ const LoginView = () => {
|
||||
borderRadius: '8px',
|
||||
}}
|
||||
onClick={() => {
|
||||
localStorage.removeItem('ca_token')
|
||||
localStorage.removeItem('ca_expiration')
|
||||
// go to login page:
|
||||
window.location.href = '/login'
|
||||
apiClient.handleLogout()
|
||||
}}
|
||||
>
|
||||
Logout
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Add, HorizontalRule, Save } from '@mui/icons-material'
|
||||
import {
|
||||
Avatar,
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
@@ -24,6 +25,7 @@ import {
|
||||
import moment from 'moment'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useNavigate, useParams, useSearchParams } from 'react-router-dom'
|
||||
import KeyboardShortcutHint from '../../components/common/KeyboardShortcutHint'
|
||||
import NotificationTemplate from '../../components/NotificationTemplate.jsx'
|
||||
import {
|
||||
useArchiveChore,
|
||||
@@ -39,6 +41,7 @@ import { getTextColorFromBackgroundColor } from '../../utils/Colors.jsx'
|
||||
import { GetAllCircleMembers, GetThings } from '../../utils/Fetcher'
|
||||
import { isPlusAccount } from '../../utils/Helpers'
|
||||
import Priorities from '../../utils/Priorities.jsx'
|
||||
import { getIconComponent } from '../../utils/ProjectIcons'
|
||||
import { getSafeBottomPadding } from '../../utils/SafeAreaUtils.js'
|
||||
import LoadingComponent from '../components/Loading.jsx'
|
||||
import RichTextEditor from '../components/RichTextEditor.jsx'
|
||||
@@ -46,9 +49,8 @@ import SubTasks from '../components/SubTask.jsx'
|
||||
import { useLabels } from '../Labels/LabelQueries'
|
||||
import ConfirmationModal from '../Modals/Inputs/ConfirmationModal'
|
||||
import LabelModal from '../Modals/Inputs/LabelModal'
|
||||
import RepeatSection from './RepeatSection'
|
||||
import { useProjects } from '../Projects/ProjectQueries'
|
||||
import { getIconComponent } from '../../utils/ProjectIcons'
|
||||
import RepeatSection from './RepeatSection'
|
||||
|
||||
const ASSIGN_STRATEGIES = [
|
||||
'random',
|
||||
@@ -79,6 +81,9 @@ const ChoreEdit = () => {
|
||||
const [performers, setPerformers] = useState([])
|
||||
const [assignStrategy, setAssignStrategy] = useState(ASSIGN_STRATEGIES[2])
|
||||
const [dueDate, setDueDate] = useState(null)
|
||||
const [dueDateOnly, setDueDateOnly] = useState(null)
|
||||
const [dueTime, setDueTime] = useState(null)
|
||||
const [useCustomTime, setUseCustomTime] = useState(false)
|
||||
const [assignedTo, setAssignedTo] = useState(-1)
|
||||
const [frequencyType, setFrequencyType] = useState('once')
|
||||
const [frequency, setFrequency] = useState(1)
|
||||
@@ -114,6 +119,7 @@ const ChoreEdit = () => {
|
||||
const [showSaveNotificationDefault, setShowSaveNotificationDefault] =
|
||||
useState(false)
|
||||
const [showSaveAssigneeDefault, setShowSaveAssigneeDefault] = useState(false)
|
||||
const [showKeyboardShortcuts, setShowKeyboardShortcuts] = useState(false)
|
||||
|
||||
const { data: userLabelsRaw, isLoading: isUserLabelsLoading } = useLabels()
|
||||
const { data: projects = [], isLoading: isProjectsLoading } = useProjects()
|
||||
@@ -219,7 +225,88 @@ const ChoreEdit = () => {
|
||||
}
|
||||
|
||||
const handleDueDateChange = e => {
|
||||
setDueDate(e.target.value)
|
||||
const dateValue = e.target.value // YYYY-MM-DD format
|
||||
setDueDateOnly(dateValue)
|
||||
|
||||
// Combine date with time or end of day
|
||||
if (useCustomTime && dueTime) {
|
||||
// Use the custom time
|
||||
const combinedDateTime = moment(`${dateValue}T${dueTime}`).format(
|
||||
'YYYY-MM-DDTHH:mm:00',
|
||||
)
|
||||
setDueDate(combinedDateTime)
|
||||
|
||||
// Update frequencyMetadata.time for REPEAT_ON_TYPE frequencies
|
||||
if (REPEAT_ON_TYPE.includes(frequencyType)) {
|
||||
setFrequencyMetadata({
|
||||
...frequencyMetadata,
|
||||
time: moment(`${dateValue}T${dueTime}`).format(),
|
||||
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||
})
|
||||
}
|
||||
} else {
|
||||
// Default to end of day (23:59:59) in user's timezone
|
||||
const endOfDay = moment(dateValue)
|
||||
.endOf('day')
|
||||
.format('YYYY-MM-DDTHH:mm:00')
|
||||
setDueDate(endOfDay)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDueTimeChange = e => {
|
||||
const timeValue = e.target.value // HH:mm format
|
||||
setDueTime(timeValue)
|
||||
|
||||
if (dueDateOnly) {
|
||||
// Combine date with the selected time
|
||||
const combinedDateTime = moment(`${dueDateOnly}T${timeValue}`).format(
|
||||
'YYYY-MM-DDTHH:mm:00',
|
||||
)
|
||||
setDueDate(combinedDateTime)
|
||||
|
||||
// Update frequencyMetadata.time for REPEAT_ON_TYPE frequencies
|
||||
if (REPEAT_ON_TYPE.includes(frequencyType)) {
|
||||
setFrequencyMetadata({
|
||||
...frequencyMetadata,
|
||||
time: moment(`${dueDateOnly}T${timeValue}`).format(),
|
||||
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleUseCustomTimeChange = checked => {
|
||||
setUseCustomTime(checked)
|
||||
|
||||
if (checked) {
|
||||
// Initialize with current time or default to 18:00
|
||||
const defaultTime = dueTime || '18:00'
|
||||
setDueTime(defaultTime)
|
||||
|
||||
if (dueDateOnly) {
|
||||
const combinedDateTime = moment(`${dueDateOnly}T${defaultTime}`).format(
|
||||
'YYYY-MM-DDTHH:mm:00',
|
||||
)
|
||||
setDueDate(combinedDateTime)
|
||||
|
||||
// Update frequencyMetadata.time for REPEAT_ON_TYPE frequencies
|
||||
if (REPEAT_ON_TYPE.includes(frequencyType)) {
|
||||
setFrequencyMetadata({
|
||||
...frequencyMetadata,
|
||||
time: moment(`${dueDateOnly}T${defaultTime}`).format(),
|
||||
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||
})
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Revert to end of day
|
||||
if (dueDateOnly) {
|
||||
const endOfDay = moment(dueDateOnly)
|
||||
.endOf('day')
|
||||
.format('YYYY-MM-DDTHH:mm:00')
|
||||
setDueDate(endOfDay)
|
||||
}
|
||||
}
|
||||
}
|
||||
const HandleSaveChore = () => {
|
||||
setAttemptToSave(true)
|
||||
@@ -317,6 +404,47 @@ const ChoreEdit = () => {
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Keyboard shortcuts
|
||||
useEffect(() => {
|
||||
const handleKeyDown = event => {
|
||||
const isHoldingCmd = event.ctrlKey || event.metaKey
|
||||
|
||||
// Show keyboard shortcuts when holding Cmd/Ctrl
|
||||
if (isHoldingCmd) {
|
||||
setShowKeyboardShortcuts(true)
|
||||
}
|
||||
|
||||
// Cmd/Ctrl + Enter to save
|
||||
if (event.key === 'Enter' && (event.ctrlKey || event.metaKey)) {
|
||||
event.preventDefault()
|
||||
HandleSaveChore()
|
||||
return
|
||||
}
|
||||
|
||||
// Cmd/Ctrl + Escape key to cancel
|
||||
if (event.key === 'Escape' && (event.ctrlKey || event.metaKey)) {
|
||||
event.preventDefault()
|
||||
window.history.back()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const handleKeyUp = event => {
|
||||
if (event.key === 'Control' || event.key === 'Meta') {
|
||||
setShowKeyboardShortcuts(false)
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown)
|
||||
window.addEventListener('keyup', handleKeyUp)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('keydown', handleKeyDown)
|
||||
window.removeEventListener('keyup', handleKeyUp)
|
||||
}
|
||||
}, [HandleSaveChore])
|
||||
|
||||
useEffect(() => {
|
||||
if (isChoreLoading === false && choreData && choreId) {
|
||||
const data = choreData
|
||||
@@ -373,11 +501,32 @@ const ChoreEdit = () => {
|
||||
|
||||
setIsNotificable(data.res.notification)
|
||||
setThingTrigger(data.res.thingChore)
|
||||
setDueDate(
|
||||
data.res.nextDueDate
|
||||
? moment(data.res.nextDueDate).format('YYYY-MM-DDTHH:mm:00')
|
||||
: null,
|
||||
)
|
||||
|
||||
// Parse existing due date into date and time components
|
||||
if (data.res.nextDueDate) {
|
||||
const dueDateMoment = moment(data.res.nextDueDate)
|
||||
const dateOnly = dueDateMoment.format('YYYY-MM-DD')
|
||||
const timeOnly = dueDateMoment.format('HH:mm')
|
||||
const endOfDayTime = '23:59'
|
||||
|
||||
setDueDateOnly(dateOnly)
|
||||
setDueDate(dueDateMoment.format('YYYY-MM-DDTHH:mm:00'))
|
||||
|
||||
// Check if it's a custom time (not end of day)
|
||||
if (timeOnly !== endOfDayTime) {
|
||||
setUseCustomTime(true)
|
||||
setDueTime(timeOnly)
|
||||
} else {
|
||||
setUseCustomTime(false)
|
||||
setDueTime(null)
|
||||
}
|
||||
} else {
|
||||
setDueDateOnly(null)
|
||||
setDueDate(null)
|
||||
setUseCustomTime(false)
|
||||
setDueTime(null)
|
||||
}
|
||||
|
||||
setCreatedBy(data.res.createdBy)
|
||||
setUpdatedBy(data.res.updatedBy)
|
||||
}
|
||||
@@ -392,12 +541,20 @@ const ChoreEdit = () => {
|
||||
// }, [userLabels, labelsV2])
|
||||
|
||||
useEffect(() => {
|
||||
// if frequency type change to somthing need a due date then set it to the current date:
|
||||
// if frequency type change to something need a due date then set it to the current date:
|
||||
if (!NO_DUE_DATE_REQUIRED_TYPE.includes(frequencyType) && !dueDate) {
|
||||
setDueDate(moment(new Date()).format('YYYY-MM-DDTHH:mm:00'))
|
||||
const today = moment(new Date()).format('YYYY-MM-DD')
|
||||
setDueDateOnly(today)
|
||||
// Default to end of day
|
||||
setDueDate(moment(today).endOf('day').format('YYYY-MM-DDTHH:mm:00'))
|
||||
setUseCustomTime(false)
|
||||
setDueTime(null)
|
||||
}
|
||||
if (NO_DUE_DATE_ALLOWED_TYPE.includes(frequencyType)) {
|
||||
setDueDate(null)
|
||||
setDueDateOnly(null)
|
||||
setUseCustomTime(false)
|
||||
setDueTime(null)
|
||||
}
|
||||
}, [frequencyType])
|
||||
|
||||
@@ -555,7 +712,7 @@ const ChoreEdit = () => {
|
||||
</Box>
|
||||
|
||||
{/* Project Selection - Show only if there are multiple projects */}
|
||||
{projects.length > 1 && (
|
||||
{projects.length >= 1 && (
|
||||
<Box mb={3}>
|
||||
<Typography level='h4'>Project</Typography>
|
||||
<Typography level='body-md'>
|
||||
@@ -564,34 +721,68 @@ const ChoreEdit = () => {
|
||||
<Select
|
||||
value={projectId}
|
||||
onChange={(event, newValue) => setProjectId(newValue)}
|
||||
defaultValue='default'
|
||||
sx={{ minWidth: '15rem' }}
|
||||
>
|
||||
{/* id: 'default',
|
||||
name: 'Default Project',
|
||||
color: LABEL_COLORS[0].value,
|
||||
icon: 'FolderOpen',
|
||||
*/}
|
||||
<Option key='default' value='default'>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Avatar
|
||||
size='sm'
|
||||
sx={{
|
||||
width: 24,
|
||||
height: 24,
|
||||
bgcolor: '#1976d2',
|
||||
}}
|
||||
>
|
||||
{(() => {
|
||||
const IconComponent = getIconComponent('FolderOpen')
|
||||
return (
|
||||
<IconComponent
|
||||
sx={{
|
||||
fontSize: 14,
|
||||
color: getTextColorFromBackgroundColor('#1976d2'),
|
||||
}}
|
||||
/>
|
||||
)
|
||||
})()}
|
||||
</Avatar>
|
||||
Default Project
|
||||
</Box>
|
||||
</Option>
|
||||
{projects.map(project => (
|
||||
<Option key={project.id} value={project.id}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
{project.icon &&
|
||||
(() => {
|
||||
const IconComponent = getIconComponent(project.icon)
|
||||
return (
|
||||
<IconComponent
|
||||
sx={{
|
||||
fontSize: 16,
|
||||
color: getTextColorFromBackgroundColor(
|
||||
project.color || '#1976d2',
|
||||
),
|
||||
}}
|
||||
/>
|
||||
)
|
||||
})()}
|
||||
<Box
|
||||
<Avatar
|
||||
size='sm'
|
||||
sx={{
|
||||
width: 12,
|
||||
height: 12,
|
||||
borderRadius: '50%',
|
||||
backgroundColor: project.color || '#1976d2',
|
||||
mr: 1,
|
||||
width: 24,
|
||||
height: 24,
|
||||
bgcolor: project.color || '#1976d2',
|
||||
}}
|
||||
/>
|
||||
>
|
||||
{project.icon ? (
|
||||
(() => {
|
||||
const IconComponent = getIconComponent(project.icon)
|
||||
return (
|
||||
<IconComponent
|
||||
sx={{
|
||||
fontSize: 14,
|
||||
color: getTextColorFromBackgroundColor(
|
||||
project.color || '#1976d2',
|
||||
),
|
||||
}}
|
||||
/>
|
||||
)
|
||||
})()
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
</Avatar>
|
||||
{project.name}
|
||||
</Box>
|
||||
</Option>
|
||||
@@ -901,9 +1092,18 @@ const ChoreEdit = () => {
|
||||
<Checkbox
|
||||
onChange={e => {
|
||||
if (e.target.checked) {
|
||||
setDueDate(moment(new Date()).format('YYYY-MM-DDTHH:mm:00'))
|
||||
const today = moment(new Date()).format('YYYY-MM-DD')
|
||||
setDueDateOnly(today)
|
||||
setDueDate(
|
||||
moment(today).endOf('day').format('YYYY-MM-DDTHH:mm:00'),
|
||||
)
|
||||
setUseCustomTime(false)
|
||||
setDueTime(null)
|
||||
} else {
|
||||
setDueDate(null)
|
||||
setDueDateOnly(null)
|
||||
setUseCustomTime(false)
|
||||
setDueTime(null)
|
||||
}
|
||||
}}
|
||||
defaultChecked={dueDate !== null}
|
||||
@@ -917,19 +1117,50 @@ const ChoreEdit = () => {
|
||||
</FormControl>
|
||||
)}
|
||||
{dueDate && (
|
||||
<FormControl error={Boolean(errors.dueDate)}>
|
||||
<Typography level='body-md'>
|
||||
{REPEAT_ON_TYPE.includes(frequencyType)
|
||||
? 'When does this task start?'
|
||||
: 'When is the next first time this task is due?'}
|
||||
</Typography>
|
||||
<Input
|
||||
type='datetime-local'
|
||||
value={dueDate}
|
||||
onChange={handleDueDateChange}
|
||||
/>
|
||||
<FormHelperText>{errors.dueDate}</FormHelperText>
|
||||
</FormControl>
|
||||
<>
|
||||
<FormControl error={Boolean(errors.dueDate)} sx={{ mt: 2 }}>
|
||||
<Typography level='body-md'>
|
||||
{REPEAT_ON_TYPE.includes(frequencyType)
|
||||
? 'When does this task start?'
|
||||
: 'When is the next first time this task is due?'}
|
||||
</Typography>
|
||||
<Input
|
||||
type='date'
|
||||
value={dueDateOnly || ''}
|
||||
onChange={handleDueDateChange}
|
||||
/>
|
||||
<FormHelperText>{errors.dueDate}</FormHelperText>
|
||||
</FormControl>
|
||||
|
||||
{/* Optional time picker */}
|
||||
<FormControl sx={{ mt: 2 }}>
|
||||
<Checkbox
|
||||
checked={useCustomTime}
|
||||
onChange={e => handleUseCustomTimeChange(e.target.checked)}
|
||||
overlay
|
||||
label='Set a specific time'
|
||||
/>
|
||||
<FormHelperText>
|
||||
{useCustomTime
|
||||
? 'Task will be due at the specified time'
|
||||
: 'Task will be due at the end of the day (11:59 PM)'}
|
||||
</FormHelperText>
|
||||
</FormControl>
|
||||
|
||||
{useCustomTime && (
|
||||
<Box sx={{ mt: 2, ml: 4 }}>
|
||||
<Typography level='body-sm' mb={1}>
|
||||
Time:
|
||||
</Typography>
|
||||
<Input
|
||||
type='time'
|
||||
value={dueTime || '18:00'}
|
||||
onChange={handleDueTimeChange}
|
||||
sx={{ maxWidth: 200 }}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
@@ -1006,7 +1237,9 @@ const ChoreEdit = () => {
|
||||
onChange={e => {
|
||||
if (e.target.checked) {
|
||||
// Set deadline to 24 hours after due date by default
|
||||
const deadlineDate = moment(dueDate).add(1, 'day').format('YYYY-MM-DDTHH:mm:00')
|
||||
const deadlineDate = moment(dueDate)
|
||||
.add(1, 'day')
|
||||
.format('YYYY-MM-DDTHH:mm:00')
|
||||
setDeadline(deadlineDate)
|
||||
} else {
|
||||
setDeadline(null)
|
||||
@@ -1036,7 +1269,8 @@ const ChoreEdit = () => {
|
||||
label='Set a deadline for this task'
|
||||
/>
|
||||
<FormHelperText>
|
||||
Task will be considered expired after the specified time from due date
|
||||
Task will be considered expired after the specified time from
|
||||
due date
|
||||
</FormHelperText>
|
||||
</FormControl>
|
||||
)}
|
||||
@@ -1045,7 +1279,9 @@ const ChoreEdit = () => {
|
||||
{deadline && ['once', 'no_repeat'].includes(frequencyType) && (
|
||||
<Card variant='outlined' sx={{ mt: 2 }}>
|
||||
<Box sx={{ p: 2 }}>
|
||||
<Typography level='body-sm' mb={1}>Deadline Date:</Typography>
|
||||
<Typography level='body-sm' mb={1}>
|
||||
Deadline Date:
|
||||
</Typography>
|
||||
<Input
|
||||
type='datetime-local'
|
||||
value={deadline}
|
||||
@@ -1061,41 +1297,50 @@ const ChoreEdit = () => {
|
||||
)}
|
||||
|
||||
{/* Offset input for recurring tasks */}
|
||||
{deadlineOffset !== -1 && !['once', 'no_repeat'].includes(frequencyType) && (
|
||||
<Card variant='outlined' sx={{ mt: 2 }}>
|
||||
<Box sx={{ p: 2, display: 'flex', gap: 2, alignItems: 'end' }}>
|
||||
<Box>
|
||||
<Typography level='body-sm' mb={1}>Time after due date:</Typography>
|
||||
<Input
|
||||
type='number'
|
||||
value={deadlineOffset}
|
||||
sx={{ maxWidth: 100 }}
|
||||
slotProps={{
|
||||
input: {
|
||||
min: 1,
|
||||
max: 720, // Max 30 days in hours
|
||||
},
|
||||
}}
|
||||
placeholder='Time'
|
||||
onChange={e => {
|
||||
setDeadlineOffset(parseInt(e.target.value) || 1)
|
||||
}}
|
||||
/>
|
||||
{deadlineOffset !== -1 &&
|
||||
!['once', 'no_repeat'].includes(frequencyType) && (
|
||||
<Card variant='outlined' sx={{ mt: 2 }}>
|
||||
<Box
|
||||
sx={{ p: 2, display: 'flex', gap: 2, alignItems: 'end' }}
|
||||
>
|
||||
<Box>
|
||||
<Typography level='body-sm' mb={1}>
|
||||
Time after due date:
|
||||
</Typography>
|
||||
<Input
|
||||
type='number'
|
||||
value={deadlineOffset}
|
||||
sx={{ maxWidth: 100 }}
|
||||
slotProps={{
|
||||
input: {
|
||||
min: 1,
|
||||
max: 720, // Max 30 days in hours
|
||||
},
|
||||
}}
|
||||
placeholder='Time'
|
||||
onChange={e => {
|
||||
setDeadlineOffset(parseInt(e.target.value) || 1)
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography level='body-sm' mb={1}>
|
||||
Unit:
|
||||
</Typography>
|
||||
<Select
|
||||
value={deadlineUnit}
|
||||
onChange={(event, newValue) =>
|
||||
setDeadlineUnit(newValue)
|
||||
}
|
||||
sx={{ minWidth: 100 }}
|
||||
>
|
||||
<Option value='hours'>Hours</Option>
|
||||
<Option value='days'>Days</Option>
|
||||
</Select>
|
||||
</Box>
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography level='body-sm' mb={1}>Unit:</Typography>
|
||||
<Select
|
||||
value={deadlineUnit}
|
||||
onChange={(event, newValue) => setDeadlineUnit(newValue)}
|
||||
sx={{ minWidth: 100 }}
|
||||
>
|
||||
<Option value='hours'>Hours</Option>
|
||||
<Option value='days'>Days</Option>
|
||||
</Select>
|
||||
</Box>
|
||||
</Box>
|
||||
</Card>
|
||||
)}
|
||||
</Card>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
@@ -1502,9 +1747,15 @@ const ChoreEdit = () => {
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
{showKeyboardShortcuts && (
|
||||
<KeyboardShortcutHint shortcut='Esc' sx={{ ml: 1 }} />
|
||||
)}
|
||||
</Button>
|
||||
<Button color='primary' variant='solid' onClick={HandleSaveChore}>
|
||||
{choreId > 0 ? 'Save' : 'Create'}
|
||||
{showKeyboardShortcuts && (
|
||||
<KeyboardShortcutHint shortcut='Enter' sx={{ ml: 1 }} />
|
||||
)}
|
||||
</Button>
|
||||
</Sheet>
|
||||
<ConfirmationModal config={confirmModelConfig} />
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
Archive,
|
||||
CalendarMonth,
|
||||
CancelScheduleSend,
|
||||
Check,
|
||||
@@ -15,6 +16,7 @@ import {
|
||||
SwitchAccessShortcut,
|
||||
ThumbDown,
|
||||
ThumbUp,
|
||||
Unarchive,
|
||||
} from '@mui/icons-material'
|
||||
import {
|
||||
Box,
|
||||
@@ -60,6 +62,7 @@ import {
|
||||
MarkChoreComplete,
|
||||
RejectChore,
|
||||
SkipChore,
|
||||
UnArchiveChore,
|
||||
UpdateChorePriority,
|
||||
} from '../../utils/Fetcher'
|
||||
import Priorities from '../../utils/Priorities'
|
||||
@@ -354,6 +357,18 @@ const ChoreView = () => {
|
||||
})
|
||||
}
|
||||
|
||||
const handleUnarchiveChore = () => {
|
||||
UnArchiveChore(choreId).then(response => {
|
||||
if (response.ok) {
|
||||
response.json().then(data => {
|
||||
setChore({ ...chore, isActive: true })
|
||||
// Invalidate chores cache to refetch data
|
||||
queryClient.invalidateQueries(['chores'])
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Check if the current user can approve/reject (admin, manager, or task owner)
|
||||
const canApproveReject = () => {
|
||||
if (!circleMembersData?.res || !chore) return false
|
||||
@@ -408,6 +423,16 @@ const ChoreView = () => {
|
||||
>
|
||||
{chore.name}
|
||||
</Typography>
|
||||
{chore.isActive === false && (
|
||||
<Chip
|
||||
startDecorator={<Archive />}
|
||||
size='md'
|
||||
color='warning'
|
||||
sx={{ mb: 1 }}
|
||||
>
|
||||
Archived
|
||||
</Chip>
|
||||
)}
|
||||
<Chip startDecorator={<CalendarMonth />} size='md' sx={{ mb: 1 }}>
|
||||
{chore.nextDueDate
|
||||
? `Due at ${moment(chore.nextDueDate).format('MM/DD/YYYY hh:mm A')}`
|
||||
@@ -531,6 +556,7 @@ const ChoreView = () => {
|
||||
>
|
||||
<Dropdown>
|
||||
<MenuButton
|
||||
disabled={chore.isActive === false}
|
||||
color={
|
||||
chorePriority?.name === 'P1'
|
||||
? 'danger'
|
||||
@@ -591,6 +617,7 @@ const ChoreView = () => {
|
||||
color='neutral'
|
||||
variant='plain'
|
||||
fullWidth
|
||||
disabled={chore.isActive === false}
|
||||
onClick={() => {
|
||||
navigate(`/chores/${choreId}/history`)
|
||||
}}
|
||||
@@ -609,6 +636,7 @@ const ChoreView = () => {
|
||||
color='neutral'
|
||||
variant='plain'
|
||||
fullWidth
|
||||
disabled={chore.isActive === false}
|
||||
sx={{
|
||||
// top right of the card:
|
||||
flexDirection: 'column',
|
||||
@@ -725,6 +753,7 @@ const ChoreView = () => {
|
||||
<Checkbox
|
||||
checked={note !== null}
|
||||
size='lg'
|
||||
disabled={chore.isActive === false}
|
||||
onChange={e => {
|
||||
if (e.target.checked) {
|
||||
setNote('')
|
||||
@@ -764,6 +793,7 @@ const ChoreView = () => {
|
||||
<Checkbox
|
||||
checked={completedDate !== null}
|
||||
size='lg'
|
||||
disabled={chore.isActive === false}
|
||||
onChange={e => {
|
||||
if (e.target.checked) {
|
||||
setCompletedDate(
|
||||
@@ -804,164 +834,188 @@ const ChoreView = () => {
|
||||
/>
|
||||
)}
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 1,
|
||||
alignContent: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
{chore.isActive === false ? (
|
||||
// Archived chore - only show unarchive button
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
flexDirection: 'column',
|
||||
gap: 1,
|
||||
alignContent: 'center',
|
||||
justifyContent: 'center',
|
||||
mb: 1,
|
||||
}}
|
||||
>
|
||||
{chore.status === 3 ? (
|
||||
// Pending approval: Show approve/reject for admins/managers/owners, grayed out button for others
|
||||
canApproveReject() ? (
|
||||
<Button
|
||||
fullWidth
|
||||
size='lg'
|
||||
onClick={handleUnarchiveChore}
|
||||
color='primary'
|
||||
startDecorator={<Unarchive />}
|
||||
>
|
||||
Unarchive
|
||||
</Button>
|
||||
</Box>
|
||||
) : (
|
||||
// Active chore - show all normal actions
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 1,
|
||||
alignContent: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
gap: 1,
|
||||
alignContent: 'center',
|
||||
justifyContent: 'center',
|
||||
mb: 1,
|
||||
}}
|
||||
>
|
||||
{chore.status === 3 ? (
|
||||
// Pending approval: Show approve/reject for admins/managers/owners, grayed out button for others
|
||||
canApproveReject() ? (
|
||||
<>
|
||||
<Button
|
||||
fullWidth
|
||||
size='lg'
|
||||
onClick={handleApproveChore}
|
||||
color='success'
|
||||
startDecorator={<ThumbUp />}
|
||||
sx={{
|
||||
flex: 1,
|
||||
}}
|
||||
>
|
||||
Approve
|
||||
</Button>
|
||||
<Button
|
||||
fullWidth
|
||||
size='lg'
|
||||
onClick={handleRejectChore}
|
||||
color='danger'
|
||||
startDecorator={<ThumbDown />}
|
||||
sx={{
|
||||
flex: 1,
|
||||
}}
|
||||
>
|
||||
<Box>Reject</Box>
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button
|
||||
fullWidth
|
||||
size='lg'
|
||||
disabled={true}
|
||||
color='neutral'
|
||||
startDecorator={<HourglassEmpty />}
|
||||
>
|
||||
<Box>Pending Approval</Box>
|
||||
</Button>
|
||||
)
|
||||
) : (
|
||||
// Normal completion flow
|
||||
<>
|
||||
<Button
|
||||
fullWidth
|
||||
size='lg'
|
||||
onClick={handleApproveChore}
|
||||
color='success'
|
||||
startDecorator={<ThumbUp />}
|
||||
onClick={handleTaskCompletion}
|
||||
disabled={
|
||||
isPendingCompletion ||
|
||||
notInCompletionWindow(chore) ||
|
||||
(chore.lastCompletedDate !== null &&
|
||||
chore.frequencyType === 'once')
|
||||
}
|
||||
color={isPendingCompletion ? 'danger' : 'success'}
|
||||
startDecorator={<Check />}
|
||||
sx={{
|
||||
flex: 1,
|
||||
flex: 4,
|
||||
}}
|
||||
>
|
||||
Approve
|
||||
<Box>Mark as done</Box>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
fullWidth
|
||||
size='lg'
|
||||
onClick={handleRejectChore}
|
||||
color='danger'
|
||||
startDecorator={<ThumbDown />}
|
||||
onClick={() => {
|
||||
setConfirmModelConfig({
|
||||
isOpen: true,
|
||||
title: 'Skip Task',
|
||||
|
||||
message: 'Are you sure you want to skip this task?',
|
||||
|
||||
confirmText: 'Skip',
|
||||
cancelText: 'Cancel',
|
||||
onClose: confirmed => {
|
||||
if (confirmed) {
|
||||
handleSkippingTask()
|
||||
}
|
||||
setConfirmModelConfig({})
|
||||
},
|
||||
})
|
||||
}}
|
||||
disabled={
|
||||
chore.lastCompletedDate !== null &&
|
||||
chore.frequencyType === 'once'
|
||||
}
|
||||
startDecorator={<SwitchAccessShortcut />}
|
||||
sx={{
|
||||
flex: 1,
|
||||
}}
|
||||
>
|
||||
<Box>Reject</Box>
|
||||
<Box>Skip</Box>
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button
|
||||
fullWidth
|
||||
size='lg'
|
||||
disabled={true}
|
||||
color='neutral'
|
||||
startDecorator={<HourglassEmpty />}
|
||||
>
|
||||
<Box>Pending Approval</Box>
|
||||
</Button>
|
||||
)
|
||||
)}
|
||||
</Box>
|
||||
{/* Timer Button - Show split button when timer is active, regular button otherwise */}
|
||||
{[ChoreStatus.ACTIVE, ChoreStatus.PAUSED].includes(chore.status) ? (
|
||||
<TimerSplitButton
|
||||
disabled={
|
||||
chore.lastCompletedDate !== null &&
|
||||
chore.frequencyType === 'once'
|
||||
}
|
||||
chore={chore}
|
||||
onAction={action => {
|
||||
if (action === 'pause') {
|
||||
handleChorePause()
|
||||
} else if (action === 'resume') {
|
||||
handleChoreStart()
|
||||
}
|
||||
}}
|
||||
onShowDetails={() => navigate(`/chores/${choreId}/timer`)}
|
||||
onResetTimer={handleResetTimer}
|
||||
onClearAllTime={handleClearAllTime}
|
||||
fullWidth
|
||||
/>
|
||||
) : chore.status === ChoreStatus.PENDING_APPROVAL ? (
|
||||
<></>
|
||||
) : (
|
||||
// Normal completion flow
|
||||
<>
|
||||
<Button
|
||||
fullWidth
|
||||
size='lg'
|
||||
onClick={handleTaskCompletion}
|
||||
disabled={
|
||||
isPendingCompletion ||
|
||||
notInCompletionWindow(chore) ||
|
||||
(chore.lastCompletedDate !== null &&
|
||||
chore.frequencyType === 'once')
|
||||
}
|
||||
color={isPendingCompletion ? 'danger' : 'success'}
|
||||
startDecorator={<Check />}
|
||||
sx={{
|
||||
flex: 4,
|
||||
}}
|
||||
>
|
||||
<Box>Mark as done</Box>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
fullWidth
|
||||
size='lg'
|
||||
onClick={() => {
|
||||
setConfirmModelConfig({
|
||||
isOpen: true,
|
||||
title: 'Skip Task',
|
||||
|
||||
message: 'Are you sure you want to skip this task?',
|
||||
|
||||
confirmText: 'Skip',
|
||||
cancelText: 'Cancel',
|
||||
onClose: confirmed => {
|
||||
if (confirmed) {
|
||||
handleSkippingTask()
|
||||
}
|
||||
setConfirmModelConfig({})
|
||||
},
|
||||
})
|
||||
}}
|
||||
disabled={
|
||||
chore.lastCompletedDate !== null &&
|
||||
chore.frequencyType === 'once'
|
||||
}
|
||||
startDecorator={<SwitchAccessShortcut />}
|
||||
sx={{
|
||||
flex: 1,
|
||||
}}
|
||||
>
|
||||
<Box>Skip</Box>
|
||||
</Button>
|
||||
</>
|
||||
<Button
|
||||
size='lg'
|
||||
onClick={() => {
|
||||
handleChoreStart()
|
||||
}}
|
||||
variant='soft'
|
||||
color='success'
|
||||
disabled={
|
||||
chore.lastCompletedDate !== null &&
|
||||
chore.frequencyType === 'once'
|
||||
}
|
||||
startDecorator={<PlayArrow />}
|
||||
sx={{
|
||||
flex: 1,
|
||||
}}
|
||||
>
|
||||
Start
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
{/* Timer Button - Show split button when timer is active, regular button otherwise */}
|
||||
{[ChoreStatus.ACTIVE, ChoreStatus.PAUSED].includes(chore.status) ? (
|
||||
<TimerSplitButton
|
||||
disabled={
|
||||
chore.lastCompletedDate !== null &&
|
||||
chore.frequencyType === 'once'
|
||||
}
|
||||
chore={chore}
|
||||
onAction={action => {
|
||||
if (action === 'pause') {
|
||||
handleChorePause()
|
||||
} else if (action === 'resume') {
|
||||
handleChoreStart()
|
||||
}
|
||||
}}
|
||||
onShowDetails={() => navigate(`/chores/${choreId}/timer`)}
|
||||
onResetTimer={handleResetTimer}
|
||||
onClearAllTime={handleClearAllTime}
|
||||
fullWidth
|
||||
/>
|
||||
) : chore.status === ChoreStatus.PENDING_APPROVAL ? (
|
||||
<></>
|
||||
) : (
|
||||
<Button
|
||||
size='lg'
|
||||
onClick={() => {
|
||||
handleChoreStart()
|
||||
}}
|
||||
variant='soft'
|
||||
color='success'
|
||||
disabled={
|
||||
chore.lastCompletedDate !== null &&
|
||||
chore.frequencyType === 'once'
|
||||
}
|
||||
startDecorator={<PlayArrow />}
|
||||
sx={{
|
||||
flex: 1,
|
||||
}}
|
||||
>
|
||||
Start
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Snackbar
|
||||
open={isPendingCompletion}
|
||||
|
||||
@@ -444,7 +444,6 @@ const ArchivedTasks = () => {
|
||||
performers={performers}
|
||||
viewOnly={false}
|
||||
showActions={false}
|
||||
// onAction={handleChoreAction}
|
||||
// Multi-select props
|
||||
isMultiSelectMode={isMultiSelectMode}
|
||||
isSelected={selectedChores.has(chore.id)}
|
||||
|
||||
@@ -209,9 +209,16 @@ const MyChores = () => {
|
||||
}
|
||||
|
||||
// Use project-filtered chores for section grouping
|
||||
const choresToGroup = selectedProject
|
||||
? filterByProject(chores, selectedProject.id)
|
||||
: chores
|
||||
let choresToGroup = chores
|
||||
if (selectedProject) {
|
||||
if (selectedProject.id === 'default') {
|
||||
// Default project: only show tasks without a projectId
|
||||
choresToGroup = chores.filter(chore => !chore.projectId)
|
||||
} else {
|
||||
// Other projects: use the existing filter function
|
||||
choresToGroup = filterByProject(chores, selectedProject.id)
|
||||
}
|
||||
}
|
||||
|
||||
const sections = ChoresGrouper(
|
||||
selectedChoreSection,
|
||||
@@ -1084,8 +1091,8 @@ const MyChores = () => {
|
||||
}
|
||||
|
||||
const setSelectedProjectWithCache = project => {
|
||||
// Handle the case where project might be null (clearing selection)
|
||||
const finalProject = project?.id === 'default' || !project ? null : project
|
||||
// Keep the project as-is, including the default project object
|
||||
const finalProject = project || null
|
||||
|
||||
setSelectedProject(finalProject)
|
||||
console.log('final project', finalProject)
|
||||
@@ -1157,6 +1164,13 @@ const MyChores = () => {
|
||||
if (!selectedProject) {
|
||||
return chores
|
||||
}
|
||||
|
||||
// Special case: Default project shows only tasks without a projectId
|
||||
if (selectedProject.id === 'default') {
|
||||
return chores.filter(chore => !chore.projectId)
|
||||
}
|
||||
|
||||
// Other projects: use the existing filter function
|
||||
return filterByProject(chores, selectedProject.id)
|
||||
}, [chores, selectedProject])
|
||||
|
||||
@@ -1183,11 +1197,17 @@ const MyChores = () => {
|
||||
.search(searchTerm.toLowerCase())
|
||||
.map(result => result.item)
|
||||
} else {
|
||||
result = filteredChores.filter(
|
||||
chore =>
|
||||
!selectedProject ||
|
||||
filterByProject([chore], selectedProject).length > 0,
|
||||
)
|
||||
result = filteredChores.filter(chore => {
|
||||
if (!selectedProject) return true
|
||||
|
||||
// Default project: only show tasks without projectId
|
||||
if (selectedProject.id === 'default') {
|
||||
return !chore.projectId
|
||||
}
|
||||
|
||||
// Other projects: use existing filter
|
||||
return filterByProject([chore], selectedProject.id).length > 0
|
||||
})
|
||||
}
|
||||
} else {
|
||||
let choresToFilter = baseChores
|
||||
|
||||
@@ -125,12 +125,32 @@ const ProjectModal = ({ isOpen, onClose, onSave, project }) => {
|
||||
size='md'
|
||||
unmountDelay={250}
|
||||
fullWidth={true}
|
||||
title={project ? 'Edit Project' : 'Create New Project'}
|
||||
footer={
|
||||
<Box display='flex' justifyContent='space-around' gap={1}>
|
||||
<Button
|
||||
type='submit'
|
||||
form='project-form'
|
||||
loading={isSubmitting}
|
||||
disabled={!projectName.trim() || isSubmitting}
|
||||
fullWidth
|
||||
size='lg'
|
||||
>
|
||||
{project ? 'Update' : 'Create'}
|
||||
</Button>
|
||||
<Button
|
||||
variant='outlined'
|
||||
onClick={handleClose}
|
||||
disabled={isSubmitting}
|
||||
fullWidth
|
||||
size='lg'
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</Box>
|
||||
}
|
||||
>
|
||||
<Typography level='h4' mb={2}>
|
||||
{project ? 'Edit Project' : 'Create New Project'}
|
||||
</Typography>
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<form onSubmit={handleSubmit} id='project-form'>
|
||||
<Stack spacing={3}>
|
||||
{/* Project Name */}
|
||||
<FormControl required>
|
||||
@@ -238,62 +258,6 @@ const ProjectModal = ({ isOpen, onClose, onSave, project }) => {
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
{/* Project Preview */}
|
||||
<FormControl>
|
||||
<FormLabel>Preview</FormLabel>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 1.5,
|
||||
p: 2,
|
||||
borderRadius: 'sm',
|
||||
border: '1px solid',
|
||||
borderColor: 'divider',
|
||||
bgcolor: 'background.level1',
|
||||
}}
|
||||
>
|
||||
<Avatar
|
||||
size='sm'
|
||||
sx={{
|
||||
width: 32,
|
||||
height: 32,
|
||||
bgcolor: projectColor,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
'& svg': {
|
||||
display: 'block',
|
||||
margin: '0 auto',
|
||||
},
|
||||
}}
|
||||
>
|
||||
{(() => {
|
||||
const IconComponent = getIconComponent(projectIcon)
|
||||
return (
|
||||
<IconComponent
|
||||
sx={{
|
||||
fontSize: 16,
|
||||
color: getTextColorFromBackgroundColor(projectColor),
|
||||
display: 'block',
|
||||
}}
|
||||
/>
|
||||
)
|
||||
})()}
|
||||
</Avatar>
|
||||
<Box>
|
||||
<Typography level='title-sm' sx={{ fontWeight: 600 }}>
|
||||
{projectName || 'Project Name'}
|
||||
</Typography>
|
||||
{projectDescription && (
|
||||
<Typography level='body-xs' sx={{ color: 'text.tertiary' }}>
|
||||
{projectDescription}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
</FormControl>
|
||||
|
||||
{/* Error Message */}
|
||||
{error && (
|
||||
<Typography color='danger' level='body-sm'>
|
||||
@@ -301,29 +265,7 @@ const ProjectModal = ({ isOpen, onClose, onSave, project }) => {
|
||||
</Typography>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
<Box display='flex' justifyContent='space-around' gap={1} mt={3}>
|
||||
<Button
|
||||
type='submit'
|
||||
loading={isSubmitting}
|
||||
disabled={!projectName.trim() || isSubmitting}
|
||||
fullWidth
|
||||
size='lg'
|
||||
>
|
||||
{project ? 'Update' : 'Create'}
|
||||
</Button>
|
||||
<Button
|
||||
variant='outlined'
|
||||
onClick={handleClose}
|
||||
disabled={isSubmitting}
|
||||
fullWidth
|
||||
size='lg'
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</Box>
|
||||
</form>
|
||||
|
||||
<IconPickerModal
|
||||
isOpen={isIconPickerOpen}
|
||||
onClose={() => setIsIconPickerOpen(false)}
|
||||
|
||||
@@ -413,18 +413,7 @@ const ProjectCard = ({
|
||||
)
|
||||
})()
|
||||
) : (
|
||||
<Typography
|
||||
level='body-xs'
|
||||
sx={{
|
||||
color: getTextColorFromBackgroundColor(
|
||||
project.color || '#1976d2',
|
||||
),
|
||||
fontWeight: 'bold',
|
||||
fontSize: 10,
|
||||
}}
|
||||
>
|
||||
{project.name.charAt(0).toUpperCase()}
|
||||
</Typography>
|
||||
<></>
|
||||
)}
|
||||
</Avatar>
|
||||
</Box>
|
||||
@@ -545,7 +534,7 @@ const ProjectCard = ({
|
||||
const ProjectView = () => {
|
||||
const { data: projects, isProjectsLoading, isError } = useProjects()
|
||||
const { data: userProfile } = useUserProfile()
|
||||
const { data: chores = [] } = useChores(false) // false to exclude archived
|
||||
const { data: chores = { res: [] } } = useChores(false) // false to exclude archived
|
||||
|
||||
const [userProjects, setUserProjects] = useState([])
|
||||
const [modalOpen, setModalOpen] = useState(false)
|
||||
@@ -602,23 +591,27 @@ const ProjectView = () => {
|
||||
|
||||
// Calculate real task counts from chores data
|
||||
useEffect(() => {
|
||||
if (chores && chores.res && userProjects.length > 0) {
|
||||
if (chores && chores.res) {
|
||||
const choresList = chores.res
|
||||
const realCounts = {}
|
||||
|
||||
// First, count tasks for the default project (tasks without a projectId)
|
||||
const defaultProjectCount = choresList.filter(chore => {
|
||||
const choreProjectId = chore.projectId || chore.project_id
|
||||
return (
|
||||
!choreProjectId ||
|
||||
choreProjectId === '' ||
|
||||
choreProjectId === 'default' ||
|
||||
choreProjectId === null
|
||||
)
|
||||
}).length
|
||||
realCounts['default'] = defaultProjectCount
|
||||
|
||||
// Then count tasks for each user project
|
||||
userProjects.forEach(project => {
|
||||
// Count chores for this project
|
||||
const choreCount = choresList.filter(chore => {
|
||||
// Handle default project (projectId is null, undefined, empty string, or 'default')
|
||||
if (project.id === 'default') {
|
||||
return (
|
||||
!chore.projectId ||
|
||||
chore.projectId === '' ||
|
||||
chore.projectId === 'default'
|
||||
)
|
||||
}
|
||||
// Handle custom projects - exact match with project ID
|
||||
return chore.projectId === project.id
|
||||
const choreProjectId = chore.projectId || chore.project_id
|
||||
return choreProjectId === project.id
|
||||
}).length
|
||||
|
||||
realCounts[project.id] = choreCount
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
import { Add, EditNotifications } from '@mui/icons-material'
|
||||
import { Box, Button, Input, Option, Select, Typography } from '@mui/joy'
|
||||
import {
|
||||
Avatar,
|
||||
Box,
|
||||
Button,
|
||||
Input,
|
||||
Option,
|
||||
Select,
|
||||
Typography,
|
||||
} from '@mui/joy'
|
||||
import { FormControl } from '@mui/material'
|
||||
import * as chrono from 'chrono-node'
|
||||
import moment from 'moment'
|
||||
@@ -7,8 +15,11 @@ import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useResponsiveModal } from '../../hooks/useResponsiveModal'
|
||||
import { useCreateChore } from '../../queries/ChoreQueries'
|
||||
import { useCircleMembers, useUserProfile } from '../../queries/UserQueries'
|
||||
import { getTextColorFromBackgroundColor } from '../../utils/Colors.jsx'
|
||||
import { isPlusAccount } from '../../utils/Helpers'
|
||||
import { getIconComponent } from '../../utils/ProjectIcons'
|
||||
import { useLabels } from '../Labels/LabelQueries'
|
||||
import { useProjects } from '../Projects/ProjectQueries'
|
||||
import {
|
||||
parseAssignees,
|
||||
parseDueDate,
|
||||
@@ -47,10 +58,25 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
|
||||
const { data: userLabels, isLoading: userLabelsLoading } = useLabels()
|
||||
const { data: circleMembers, isLoading: isCircleMembersLoading } =
|
||||
useCircleMembers()
|
||||
const { data: projects = [], isLoading: isProjectsLoading } = useProjects()
|
||||
const createChoreMutation = useCreateChore()
|
||||
|
||||
const { data: userProfile } = useUserProfile()
|
||||
|
||||
// Get initial project from localStorage (current active project)
|
||||
const getInitialProject = () => {
|
||||
const saved = localStorage.getItem('selectedProject')
|
||||
if (saved) {
|
||||
try {
|
||||
const project = JSON.parse(saved)
|
||||
return project?.id || 'default'
|
||||
} catch {
|
||||
return 'default'
|
||||
}
|
||||
}
|
||||
return 'default'
|
||||
}
|
||||
|
||||
const [taskText, setTaskText] = useState('')
|
||||
const [taskTitle, setTaskTitle] = useState('')
|
||||
const [renderedParts, setRenderedParts] = useState([])
|
||||
@@ -75,6 +101,7 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
|
||||
const [hasSubTasks, setHasSubTasks] = useState(false)
|
||||
const [hasNotifications, setHasNotifications] = useState(false)
|
||||
const [showKeyboardShortcuts, setShowKeyboardShortcuts] = useState(false)
|
||||
const [projectId, setProjectId] = useState(getInitialProject())
|
||||
|
||||
// set showKeyboardShortcuts true as soon as the user hold ctrl or cmd key:
|
||||
useEffect(() => {
|
||||
@@ -473,6 +500,7 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
|
||||
setHasSubTasks(false)
|
||||
setLabelsV2([])
|
||||
setAssignees([])
|
||||
setProjectId(getInitialProject())
|
||||
}
|
||||
|
||||
const createChore = () => {
|
||||
@@ -516,6 +544,7 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
|
||||
frequencyMetadata: {},
|
||||
notificationMetadata: {},
|
||||
subTasks: subTasks?.length > 0 ? subTasks : null,
|
||||
projectId: projectId === 'default' ? null : projectId,
|
||||
}
|
||||
|
||||
if (frequency) {
|
||||
@@ -560,7 +589,7 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
|
||||
})
|
||||
handleCloseModal(false)
|
||||
}
|
||||
if (userLabelsLoading || isCircleMembersLoading) {
|
||||
if (userLabelsLoading || isCircleMembersLoading || isProjectsLoading) {
|
||||
return <></>
|
||||
}
|
||||
|
||||
@@ -571,6 +600,44 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
|
||||
size='lg'
|
||||
fullWidth={true}
|
||||
title='Create new task'
|
||||
footer={
|
||||
<Box
|
||||
sx={{
|
||||
marginTop: 2,
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'end',
|
||||
gap: 1,
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
size='lg'
|
||||
variant='outlined'
|
||||
color='neutral'
|
||||
onClick={handleCloseModal}
|
||||
>
|
||||
Cancel
|
||||
{showKeyboardShortcuts && (
|
||||
<KeyboardShortcutHint
|
||||
shortcut='Esc'
|
||||
sx={{ ml: 1 }}
|
||||
withCtrl={false}
|
||||
/>
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
size='lg'
|
||||
variant='solid'
|
||||
color='primary'
|
||||
onClick={createChore}
|
||||
>
|
||||
Create
|
||||
{showKeyboardShortcuts && (
|
||||
<KeyboardShortcutHint shortcut='Enter' sx={{ ml: 1 }} />
|
||||
)}
|
||||
</Button>
|
||||
</Box>
|
||||
}
|
||||
>
|
||||
<Box>
|
||||
<Box
|
||||
@@ -803,6 +870,75 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
|
||||
</FormControl>
|
||||
)}
|
||||
</Box>
|
||||
{projects.length >= 1 && (
|
||||
<FormControl>
|
||||
<Typography level='body-sm'>Project</Typography>
|
||||
<Select
|
||||
value={projectId}
|
||||
onChange={(event, newValue) => setProjectId(newValue)}
|
||||
sx={{ minWidth: '15rem' }}
|
||||
>
|
||||
<Option key='default' value='default'>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Avatar
|
||||
size='sm'
|
||||
sx={{
|
||||
width: 24,
|
||||
height: 24,
|
||||
bgcolor: '#1976d2',
|
||||
}}
|
||||
>
|
||||
{(() => {
|
||||
const IconComponent = getIconComponent('FolderOpen')
|
||||
return (
|
||||
<IconComponent
|
||||
sx={{
|
||||
fontSize: 14,
|
||||
color: getTextColorFromBackgroundColor('#1976d2'),
|
||||
}}
|
||||
/>
|
||||
)
|
||||
})()}
|
||||
</Avatar>
|
||||
Default Project
|
||||
</Box>
|
||||
</Option>
|
||||
{projects.map(project => (
|
||||
<Option key={project.id} value={project.id}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Avatar
|
||||
size='sm'
|
||||
sx={{
|
||||
width: 24,
|
||||
height: 24,
|
||||
bgcolor: project.color || '#1976d2',
|
||||
}}
|
||||
>
|
||||
{project.icon ? (
|
||||
(() => {
|
||||
const IconComponent = getIconComponent(project.icon)
|
||||
return (
|
||||
<IconComponent
|
||||
sx={{
|
||||
fontSize: 14,
|
||||
color: getTextColorFromBackgroundColor(
|
||||
project.color || '#1976d2',
|
||||
),
|
||||
}}
|
||||
/>
|
||||
)
|
||||
})()
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
</Avatar>
|
||||
{project.name}
|
||||
</Box>
|
||||
</Option>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
)}
|
||||
<Box
|
||||
sx={{
|
||||
marginTop: 2,
|
||||
@@ -861,37 +997,6 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
<Box
|
||||
sx={{
|
||||
marginTop: 2,
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'end',
|
||||
gap: 1,
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
size='lg'
|
||||
variant='outlined'
|
||||
color='neutral'
|
||||
onClick={handleCloseModal}
|
||||
>
|
||||
Cancel
|
||||
{showKeyboardShortcuts && (
|
||||
<KeyboardShortcutHint
|
||||
shortcut='Esc'
|
||||
sx={{ ml: 1 }}
|
||||
withCtrl={false}
|
||||
/>
|
||||
)}
|
||||
</Button>
|
||||
<Button size='lg' variant='solid' color='primary' onClick={createChore}>
|
||||
Create
|
||||
{showKeyboardShortcuts && (
|
||||
<KeyboardShortcutHint shortcut='Enter' sx={{ ml: 1 }} />
|
||||
)}
|
||||
</Button>
|
||||
</Box>
|
||||
</ResponsiveModal>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -94,13 +94,12 @@ const links = [
|
||||
|
||||
import { SafeArea } from 'capacitor-plugin-safe-area'
|
||||
import Z_INDEX from '../../constants/zIndex'
|
||||
import { useAuth } from '../../hooks/useAuth.jsx'
|
||||
import { useResource } from '../../queries/ResourceQueries'
|
||||
import { apiClient } from '../../utils/apiClient'
|
||||
|
||||
const publicPages = ['/landing', '/privacy', '/terms']
|
||||
const NavBar = () => {
|
||||
const { data: resource } = useResource()
|
||||
const { logout } = useAuth()
|
||||
|
||||
const navigate = useNavigate()
|
||||
const [drawerOpen, setDrawerOpen] = useState(false)
|
||||
@@ -272,7 +271,9 @@ const NavBar = () => {
|
||||
<ListItemContent>Upgrade to Plus</ListItemContent>
|
||||
</ListItemButton> */}
|
||||
<ListItemButton
|
||||
onClick={logout}
|
||||
onClick={() => {
|
||||
apiClient.handleLogout()
|
||||
}}
|
||||
sx={{
|
||||
py: 1.2,
|
||||
}}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Add, Check, FolderOpen } from '@mui/icons-material'
|
||||
import { Add, Check, FolderOpen, Settings } from '@mui/icons-material'
|
||||
import {
|
||||
Avatar,
|
||||
Box,
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
Typography,
|
||||
} from '@mui/joy'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import KeyboardShortcutHint from '../../components/common/KeyboardShortcutHint'
|
||||
import LABEL_COLORS, {
|
||||
getTextColorFromBackgroundColor,
|
||||
@@ -25,9 +26,11 @@ const ProjectSelector = ({
|
||||
showKeyboardShortcuts = false,
|
||||
}) => {
|
||||
const { data: projects = [], isLoading } = useProjects()
|
||||
const navigate = useNavigate()
|
||||
|
||||
const [anchorEl, setAnchorEl] = useState(null)
|
||||
const [selectedIndex, setSelectedIndex] = useState(0)
|
||||
const [isKeyboardNavigating, setIsKeyboardNavigating] = useState(false)
|
||||
const [isProjectModalOpen, setIsProjectModalOpen] = useState(false)
|
||||
const menuRef = useRef(null)
|
||||
const buttonRef = useRef(null)
|
||||
@@ -56,6 +59,7 @@ const ProjectSelector = ({
|
||||
|
||||
const handleMenuOpen = event => {
|
||||
setAnchorEl(event.currentTarget)
|
||||
setIsKeyboardNavigating(false)
|
||||
}
|
||||
|
||||
const handleMenuClose = () => {
|
||||
@@ -76,6 +80,11 @@ const ProjectSelector = ({
|
||||
handleProjectSelect(project)
|
||||
}
|
||||
|
||||
const handleManageProjects = () => {
|
||||
navigate('/projects')
|
||||
handleMenuClose()
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const handleMenuOutsideClick = event => {
|
||||
if (menuRef.current && !menuRef.current.contains(event.target)) {
|
||||
@@ -100,6 +109,7 @@ const ProjectSelector = ({
|
||||
if (!anchorEl) {
|
||||
setAnchorEl(buttonRef.current)
|
||||
setSelectedIndex(0)
|
||||
setIsKeyboardNavigating(true)
|
||||
} else {
|
||||
handleMenuClose()
|
||||
}
|
||||
@@ -112,20 +122,33 @@ const ProjectSelector = ({
|
||||
switch (event.key) {
|
||||
case 'ArrowDown':
|
||||
event.preventDefault()
|
||||
setIsKeyboardNavigating(true)
|
||||
setSelectedIndex(prev =>
|
||||
prev < defaultProjects.length ? prev + 1 : prev,
|
||||
prev < defaultProjects.length + 2 ? prev + 1 : prev,
|
||||
)
|
||||
break
|
||||
case 'ArrowUp':
|
||||
event.preventDefault()
|
||||
setIsKeyboardNavigating(true)
|
||||
setSelectedIndex(prev => (prev > 0 ? prev - 1 : prev))
|
||||
break
|
||||
case 'Enter':
|
||||
event.preventDefault()
|
||||
if (selectedIndex < defaultProjects.length) {
|
||||
handleProjectSelect(defaultProjects[selectedIndex])
|
||||
} else {
|
||||
if (selectedIndex === 0) {
|
||||
// Hardcoded Default Project
|
||||
handleProjectSelect({
|
||||
id: 'default',
|
||||
name: 'Default Project',
|
||||
color: LABEL_COLORS[0].value,
|
||||
icon: 'FolderOpen',
|
||||
})
|
||||
} else if (selectedIndex <= defaultProjects.length) {
|
||||
// Projects from the array (offset by 1)
|
||||
handleProjectSelect(defaultProjects[selectedIndex - 1])
|
||||
} else if (selectedIndex === defaultProjects.length + 1) {
|
||||
handleAddProjectClick()
|
||||
} else {
|
||||
handleManageProjects()
|
||||
}
|
||||
break
|
||||
case 'Escape':
|
||||
@@ -139,7 +162,7 @@ const ProjectSelector = ({
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeyDown)
|
||||
}
|
||||
}, [anchorEl, selectedIndex, defaultProjects])
|
||||
}, [anchorEl, selectedIndex, defaultProjects, isKeyboardNavigating])
|
||||
|
||||
// Reset selected index when menu opens
|
||||
useEffect(() => {
|
||||
@@ -233,7 +256,6 @@ const ProjectSelector = ({
|
||||
disabled
|
||||
sx={{
|
||||
borderRadius: 'var(--joy-radius-sm)',
|
||||
mb: 1,
|
||||
cursor: 'default',
|
||||
opacity: 1,
|
||||
}}
|
||||
@@ -243,13 +265,7 @@ const ProjectSelector = ({
|
||||
</ListItemDecorator>
|
||||
<ListItemContent>
|
||||
<Typography level='title-sm' sx={{ fontWeight: 600 }}>
|
||||
Select Project
|
||||
</Typography>
|
||||
<Typography
|
||||
level='body-xs'
|
||||
sx={{ color: 'var(--joy-palette-text-tertiary)' }}
|
||||
>
|
||||
Choose or create a project workspace
|
||||
Projects
|
||||
</Typography>
|
||||
</ListItemContent>
|
||||
</MenuItem>
|
||||
@@ -266,12 +282,13 @@ const ProjectSelector = ({
|
||||
icon: 'FolderOpen',
|
||||
})
|
||||
}
|
||||
onMouseEnter={() => setIsKeyboardNavigating(false)}
|
||||
sx={{
|
||||
borderRadius: 'var(--joy-radius-sm)',
|
||||
backgroundColor:
|
||||
effectiveSelectedProject === 'Default Project'
|
||||
? 'var(--joy-palette-primary-softBg)'
|
||||
: selectedIndex === 0 && anchorEl
|
||||
: selectedIndex === 0 && anchorEl && isKeyboardNavigating
|
||||
? 'var(--joy-palette-neutral-softHoverBg)'
|
||||
: 'transparent',
|
||||
'&:hover': {
|
||||
@@ -345,12 +362,13 @@ const ProjectSelector = ({
|
||||
<MenuItem
|
||||
key={project.id}
|
||||
onClick={() => handleProjectSelect(project)}
|
||||
onMouseEnter={() => setIsKeyboardNavigating(false)}
|
||||
sx={{
|
||||
borderRadius: 'var(--joy-radius-sm)',
|
||||
backgroundColor:
|
||||
effectiveSelectedProject === project.name
|
||||
? 'var(--joy-palette-primary-softBg)'
|
||||
: selectedIndex === index && anchorEl
|
||||
: selectedIndex === index + 1 && anchorEl && isKeyboardNavigating
|
||||
? 'var(--joy-palette-neutral-softHoverBg)'
|
||||
: 'transparent',
|
||||
'&:hover': {
|
||||
@@ -437,10 +455,11 @@ const ProjectSelector = ({
|
||||
|
||||
<MenuItem
|
||||
onClick={handleAddProjectClick}
|
||||
onMouseEnter={() => setIsKeyboardNavigating(false)}
|
||||
sx={{
|
||||
borderRadius: 'var(--joy-radius-sm)',
|
||||
backgroundColor:
|
||||
selectedIndex === defaultProjects.length && anchorEl
|
||||
selectedIndex === defaultProjects.length + 1 && anchorEl && isKeyboardNavigating
|
||||
? 'var(--joy-palette-success-softHoverBg)'
|
||||
: 'transparent',
|
||||
'&:hover': {
|
||||
@@ -456,7 +475,6 @@ const ProjectSelector = ({
|
||||
level='body-sm'
|
||||
sx={{
|
||||
fontWeight: 500,
|
||||
color: 'var(--joy-palette-success-600)',
|
||||
}}
|
||||
>
|
||||
Create New Project
|
||||
@@ -469,6 +487,41 @@ const ProjectSelector = ({
|
||||
</Typography>
|
||||
</ListItemContent>
|
||||
</MenuItem>
|
||||
|
||||
<MenuItem
|
||||
onClick={handleManageProjects}
|
||||
onMouseEnter={() => setIsKeyboardNavigating(false)}
|
||||
sx={{
|
||||
borderRadius: 'var(--joy-radius-sm)',
|
||||
backgroundColor:
|
||||
selectedIndex === defaultProjects.length + 2 && anchorEl && isKeyboardNavigating
|
||||
? 'var(--joy-palette-neutral-softHoverBg)'
|
||||
: 'transparent',
|
||||
'&:hover': {
|
||||
backgroundColor: 'var(--joy-palette-neutral-softHoverBg)',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<ListItemDecorator sx={{ color: 'var(--joy-palette-neutral-500)' }}>
|
||||
<Settings />
|
||||
</ListItemDecorator>
|
||||
<ListItemContent>
|
||||
<Typography
|
||||
level='body-sm'
|
||||
sx={{
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
Manage Projects
|
||||
</Typography>
|
||||
<Typography
|
||||
level='body-xs'
|
||||
sx={{ color: 'var(--joy-palette-text-tertiary)' }}
|
||||
>
|
||||
View, edit, and organize all projects
|
||||
</Typography>
|
||||
</ListItemContent>
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
|
||||
<ProjectModal
|
||||
|
||||
Reference in New Issue
Block a user