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 { useImpersonateUser } from '../contexts/ImpersonateUserContext'
|
||||||
import useStickyState from '../hooks/useStickyState'
|
import useStickyState from '../hooks/useStickyState'
|
||||||
import { useCircleMembers, useUserProfile } from '../queries/UserQueries'
|
import { useCircleMembers, useUserProfile } from '../queries/UserQueries'
|
||||||
|
import { apiClient } from '../utils/apiClient'
|
||||||
import { isPlusAccount } from '../utils/Helpers'
|
import { isPlusAccount } from '../utils/Helpers'
|
||||||
import UserModal from '../views/Modals/Inputs/UserModal'
|
import UserModal from '../views/Modals/Inputs/UserModal'
|
||||||
import SubscriptionModal from './SubscriptionModal'
|
import SubscriptionModal from './SubscriptionModal'
|
||||||
@@ -76,9 +77,7 @@ const UserProfileAvatar = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleLogout = () => {
|
const handleLogout = () => {
|
||||||
localStorage.removeItem('access_token')
|
apiClient.handleLogout()
|
||||||
localStorage.removeItem('ca_expiration')
|
|
||||||
window.location.href = '/login'
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSupportEmail = () => {
|
const handleSupportEmail = () => {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Close } from '@mui/icons-material'
|
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 { forwardRef, useEffect, useState } from 'react'
|
||||||
import { Z_INDEX } from '../../constants/zIndex'
|
import { Z_INDEX } from '../../constants/zIndex'
|
||||||
|
|
||||||
@@ -10,6 +10,7 @@ const BottomSheetModal = forwardRef(
|
|||||||
onClose,
|
onClose,
|
||||||
children,
|
children,
|
||||||
title,
|
title,
|
||||||
|
footer,
|
||||||
height = 'auto',
|
height = 'auto',
|
||||||
maxHeight = '90vh',
|
maxHeight = '90vh',
|
||||||
expandedHeight = '95vh',
|
expandedHeight = '95vh',
|
||||||
@@ -79,7 +80,11 @@ const BottomSheetModal = forwardRef(
|
|||||||
const currentHeight = isExpanded ? expandedHeight : height
|
const currentHeight = isExpanded ? expandedHeight : height
|
||||||
|
|
||||||
// Filter out DOM props that shouldn't be passed to Modal
|
// 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 (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
@@ -223,6 +228,21 @@ const BottomSheetModal = forwardRef(
|
|||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{footer && (
|
||||||
|
<>
|
||||||
|
<Divider />
|
||||||
|
<footer
|
||||||
|
style={{
|
||||||
|
flexShrink: 0,
|
||||||
|
// borderTop: '1px solid var(--joy-palette-divider)',
|
||||||
|
padding: '16px 20px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{footer}
|
||||||
|
</footer>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</Sheet>
|
</Sheet>
|
||||||
</Modal>
|
</Modal>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { createContext, useContext, useEffect, useState } from 'react'
|
|||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
import { API_URL } from '../Config'
|
import { API_URL } from '../Config'
|
||||||
import { apiClient } from '../utils/ApiClient'
|
import { apiClient } from '../utils/ApiClient'
|
||||||
|
import { saveTokens, clearAllTokens } from '../utils/TokenStorage'
|
||||||
|
|
||||||
const AuthContext = createContext(null)
|
const AuthContext = createContext(null)
|
||||||
|
|
||||||
@@ -28,14 +29,10 @@ export const AuthProvider = ({ children }) => {
|
|||||||
return new Date() >= new Date(expiry)
|
return new Date() >= new Date(expiry)
|
||||||
}
|
}
|
||||||
|
|
||||||
const clearAuth = () => {
|
const clearAuth = async () => {
|
||||||
setToken(null)
|
setToken(null)
|
||||||
setUser(null)
|
setUser(null)
|
||||||
localStorage.removeItem('token')
|
await clearAllTokens()
|
||||||
localStorage.removeItem('token_expiry')
|
|
||||||
localStorage.removeItem('ca_token')
|
|
||||||
localStorage.removeItem('ca_expiration')
|
|
||||||
localStorage.removeItem('access_token')
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const login = async credentials => {
|
const login = async credentials => {
|
||||||
@@ -58,14 +55,14 @@ export const AuthProvider = ({ children }) => {
|
|||||||
|
|
||||||
if (userToken) {
|
if (userToken) {
|
||||||
setToken(userToken)
|
setToken(userToken)
|
||||||
localStorage.setItem('token', userToken)
|
|
||||||
|
|
||||||
if (data.expire || data.access_token_expiry) {
|
// Use centralized token storage
|
||||||
localStorage.setItem(
|
await saveTokens({
|
||||||
'token_expiry',
|
accessToken: userToken,
|
||||||
data.expire || data.access_token_expiry,
|
accessTokenExpiry: data.expire || data.access_token_expiry,
|
||||||
)
|
refreshToken: data.refresh_token,
|
||||||
}
|
refreshTokenExpiry: data.refresh_token_expiry,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
setIsLoading(false)
|
setIsLoading(false)
|
||||||
@@ -86,7 +83,7 @@ export const AuthProvider = ({ children }) => {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('Logout API call failed:', error)
|
console.warn('Logout API call failed:', error)
|
||||||
} finally {
|
} finally {
|
||||||
clearAuth()
|
await clearAuth()
|
||||||
setIsLoading(false)
|
setIsLoading(false)
|
||||||
navigate('/login')
|
navigate('/login')
|
||||||
}
|
}
|
||||||
@@ -112,22 +109,21 @@ export const AuthProvider = ({ children }) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const initAuth = async () => {
|
// const initAuth = async () => {
|
||||||
if (token && !isTokenExpired()) {
|
// if (token && !isTokenExpired()) {
|
||||||
await fetchUser()
|
// await fetchUser()
|
||||||
} else if (token && isTokenExpired()) {
|
// } else if (token && isTokenExpired()) {
|
||||||
// Token is expired, but don't refresh here
|
// // Token is expired, but don't refresh here
|
||||||
// Let the first API call handle refresh via ApiClient
|
// // Let the first API call handle refresh via ApiClient
|
||||||
// Just try to fetch user - if it fails, ApiClient will handle refresh
|
// // Just try to fetch user - if it fails, ApiClient will handle refresh
|
||||||
await fetchUser()
|
// await fetchUser()
|
||||||
} else {
|
// } else {
|
||||||
clearAuth()
|
// clearAuth()
|
||||||
navigate('/login')
|
// navigate('/login')
|
||||||
}
|
// }
|
||||||
setIsLoading(false)
|
// setIsLoading(false)
|
||||||
}
|
// }
|
||||||
|
// initAuth()
|
||||||
initAuth()
|
|
||||||
}, [token, navigate])
|
}, [token, navigate])
|
||||||
|
|
||||||
const value = {
|
const value = {
|
||||||
|
|||||||
@@ -443,6 +443,13 @@ export const useSSE = () => {
|
|||||||
|
|
||||||
return // Exit early, don't use exponential backoff for 401 errors
|
return // Exit early, don't use exponential backoff for 401 errors
|
||||||
} else {
|
} 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)
|
console.error('Token refresh failed:', refreshResult.error)
|
||||||
setError('Authentication failed - please log in again')
|
setError('Authentication failed - please log in again')
|
||||||
// Don't attempt reconnection if token refresh failed
|
// Don't attempt reconnection if token refresh failed
|
||||||
|
|||||||
@@ -46,24 +46,11 @@ export const useUserProfile = () => {
|
|||||||
const { data, error, isLoading } = useQuery({
|
const { data, error, isLoading } = useQuery({
|
||||||
queryKey: ['userProfile'],
|
queryKey: ['userProfile'],
|
||||||
queryFn: async () => {
|
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 resp = await GetUserProfile()
|
||||||
const result = await resp.json()
|
const result = await resp.json()
|
||||||
// if we got 403 then user probably deleted their account and token is still valid. navigate to login
|
// 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
|
staleTime: 30 * 60 * 1000, // 30 minutes in milliseconds
|
||||||
gcTime: 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 { Box, Button, Snackbar, Typography } from '@mui/joy'
|
||||||
import React, { createContext, useContext, useState } from 'react'
|
import React, { createContext, useContext, useState } from 'react'
|
||||||
|
|
||||||
@@ -24,10 +24,17 @@ const NOTIFICATION_TYPES = {
|
|||||||
success: {
|
success: {
|
||||||
color: 'success',
|
color: 'success',
|
||||||
icon: <CheckCircle color='success' />,
|
icon: <CheckCircle color='success' />,
|
||||||
autoHideDuration: 3000,
|
autoHideDuration: 5000,
|
||||||
showDismissButton: false,
|
showDismissButton: false,
|
||||||
defaultTitle: 'Success',
|
defaultTitle: 'Success',
|
||||||
},
|
},
|
||||||
|
undo: {
|
||||||
|
color: 'success',
|
||||||
|
icon: <Undo color='success' />,
|
||||||
|
autoHideDuration: null,
|
||||||
|
showDismissButton: false,
|
||||||
|
defaultTitle: 'Undone Successfully',
|
||||||
|
},
|
||||||
warning: {
|
warning: {
|
||||||
color: 'warning',
|
color: 'warning',
|
||||||
icon: <Warning color='warning' />,
|
icon: <Warning color='warning' />,
|
||||||
@@ -134,6 +141,10 @@ export const NotificationProvider = ({ children }) => {
|
|||||||
return addNotification(normalizeNotification(error, 'error'))
|
return addNotification(normalizeNotification(error, 'error'))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const showUndo = message => {
|
||||||
|
return addNotification(normalizeNotification(message, 'undo'))
|
||||||
|
}
|
||||||
|
|
||||||
const showSuccess = message => {
|
const showSuccess = message => {
|
||||||
return addNotification(normalizeNotification(message, 'success'))
|
return addNotification(normalizeNotification(message, 'success'))
|
||||||
}
|
}
|
||||||
@@ -189,7 +200,18 @@ export const NotificationProvider = ({ children }) => {
|
|||||||
onClose={() => removeNotification(notification.id)}
|
onClose={() => removeNotification(notification.id)}
|
||||||
startDecorator={notificationIcon}
|
startDecorator={notificationIcon}
|
||||||
endDecorator={
|
endDecorator={
|
||||||
config.showDismissButton ? (
|
notification.undoAction ? (
|
||||||
|
<Button
|
||||||
|
variant='outlined'
|
||||||
|
color={config.color}
|
||||||
|
onClick={() => {
|
||||||
|
notification.undoAction()
|
||||||
|
removeNotification(notification.id)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Undo
|
||||||
|
</Button>
|
||||||
|
) : config.showDismissButton ? (
|
||||||
<Button
|
<Button
|
||||||
variant='outlined'
|
variant='outlined'
|
||||||
color={config.color}
|
color={config.color}
|
||||||
@@ -232,6 +254,7 @@ export const NotificationProvider = ({ children }) => {
|
|||||||
showNotification,
|
showNotification,
|
||||||
showError,
|
showError,
|
||||||
showSuccess,
|
showSuccess,
|
||||||
|
showUndo,
|
||||||
showWarning,
|
showWarning,
|
||||||
showInfo,
|
showInfo,
|
||||||
removeNotification,
|
removeNotification,
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
import { API_URL } from '../Config'
|
import { API_URL } from '../Config'
|
||||||
import { RefreshToken } from './Fetcher'
|
import { logout, RefreshToken } from './Fetcher'
|
||||||
|
import {
|
||||||
|
clearAllTokens,
|
||||||
|
isRefreshTokenExpired,
|
||||||
|
saveTokens,
|
||||||
|
} from './TokenStorage'
|
||||||
|
|
||||||
class ApiClient {
|
class ApiClient {
|
||||||
constructor() {
|
constructor() {
|
||||||
@@ -11,6 +16,17 @@ class ApiClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async refreshToken() {
|
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) {
|
if (this.isRefreshing) {
|
||||||
return { success: false, error: 'Already refreshing' }
|
return { success: false, error: 'Already refreshing' }
|
||||||
}
|
}
|
||||||
@@ -30,14 +46,13 @@ class ApiClient {
|
|||||||
const data = await refreshReq.json()
|
const data = await refreshReq.json()
|
||||||
const newToken = data.token || data.access_token
|
const newToken = data.token || data.access_token
|
||||||
|
|
||||||
// Update Local Storage
|
// Save all tokens including rotated refresh token
|
||||||
localStorage.setItem('token', newToken)
|
await saveTokens({
|
||||||
if (data.expire || data.access_token_expiry) {
|
accessToken: newToken,
|
||||||
localStorage.setItem(
|
accessTokenExpiry: data.expire || data.access_token_expiry,
|
||||||
'token_expiry',
|
refreshToken: data.refresh_token,
|
||||||
data.expire || data.access_token_expiry,
|
refreshTokenExpiry: data.refresh_token_expiry,
|
||||||
)
|
})
|
||||||
}
|
|
||||||
|
|
||||||
// Update last refresh time
|
// Update last refresh time
|
||||||
this.lastRefreshTime = Date.now()
|
this.lastRefreshTime = Date.now()
|
||||||
@@ -90,10 +105,11 @@ class ApiClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Helper to avoid repeating cleanup code
|
// Helper to avoid repeating cleanup code
|
||||||
handleLogout() {
|
async handleLogout() {
|
||||||
localStorage.removeItem('token')
|
logout().then(async () => {
|
||||||
localStorage.removeItem('token_expiry')
|
await clearAllTokens()
|
||||||
window.location.href = '/login'
|
if (window.location.pathname !== '/login') window.location.href = '/login'
|
||||||
|
}) // fire and forget
|
||||||
}
|
}
|
||||||
async request(endpoint, options = {}) {
|
async request(endpoint, options = {}) {
|
||||||
const url = `${this.baseURL}${endpoint}`
|
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 = () => {
|
const GetAllUsers = () => {
|
||||||
return Fetch(`/users/`, {
|
return Fetch(`/users/`, {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
@@ -888,6 +896,7 @@ export {
|
|||||||
JoinCircle,
|
JoinCircle,
|
||||||
LeaveCircle,
|
LeaveCircle,
|
||||||
login,
|
login,
|
||||||
|
logout,
|
||||||
MarkChoreComplete,
|
MarkChoreComplete,
|
||||||
NudgeChore,
|
NudgeChore,
|
||||||
PauseChore,
|
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 { useUserProfile } from '../../queries/UserQueries.jsx'
|
||||||
import { useNotification } from '../../service/NotificationProvider'
|
import { useNotification } from '../../service/NotificationProvider'
|
||||||
import { apiClient } from '../../utils/apiClient'
|
import { apiClient } from '../../utils/apiClient'
|
||||||
|
import { saveTokens } from '../../utils/TokenStorage'
|
||||||
import { buildChildUsername, getUserDisplayInfo } from '../../utils/UserHelpers'
|
import { buildChildUsername, getUserDisplayInfo } from '../../utils/UserHelpers'
|
||||||
import MFAVerificationModal from './MFAVerificationModal'
|
import MFAVerificationModal from './MFAVerificationModal'
|
||||||
|
|
||||||
@@ -202,10 +203,13 @@ const LoginView = () => {
|
|||||||
const token = responseData.token || responseData.access_token
|
const token = responseData.token || responseData.access_token
|
||||||
const expiry = responseData.expire || responseData.access_token_expiry
|
const expiry = responseData.expire || responseData.access_token_expiry
|
||||||
|
|
||||||
localStorage.setItem('token', token)
|
// Save all tokens including refresh tokens
|
||||||
if (expiry) {
|
await saveTokens({
|
||||||
localStorage.setItem('token_expiry', expiry)
|
accessToken: token,
|
||||||
}
|
accessTokenExpiry: expiry,
|
||||||
|
refreshToken: responseData.refresh_token,
|
||||||
|
refreshTokenExpiry: responseData.refresh_token_expiry,
|
||||||
|
})
|
||||||
|
|
||||||
// Refetch user profile after successful OAuth login
|
// Refetch user profile after successful OAuth login
|
||||||
queryClient.invalidateQueries(['userProfile'])
|
queryClient.invalidateQueries(['userProfile'])
|
||||||
@@ -247,9 +251,15 @@ const LoginView = () => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleMFASuccess = data => {
|
const handleMFASuccess = async data => {
|
||||||
localStorage.setItem('token', data.token)
|
// Save all tokens including refresh tokens
|
||||||
localStorage.setItem('token_expiry', data.expire)
|
await saveTokens({
|
||||||
|
accessToken: data.token,
|
||||||
|
accessTokenExpiry: data.expire,
|
||||||
|
refreshToken: data.refresh_token,
|
||||||
|
refreshTokenExpiry: data.refresh_token_expiry,
|
||||||
|
})
|
||||||
|
|
||||||
setMfaModalOpen(false)
|
setMfaModalOpen(false)
|
||||||
setMfaSessionToken('')
|
setMfaSessionToken('')
|
||||||
|
|
||||||
@@ -427,10 +437,7 @@ const LoginView = () => {
|
|||||||
borderRadius: '8px',
|
borderRadius: '8px',
|
||||||
}}
|
}}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
localStorage.removeItem('ca_token')
|
apiClient.handleLogout()
|
||||||
localStorage.removeItem('ca_expiration')
|
|
||||||
// go to login page:
|
|
||||||
window.location.href = '/login'
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Logout
|
Logout
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Add, HorizontalRule, Save } from '@mui/icons-material'
|
import { Add, HorizontalRule, Save } from '@mui/icons-material'
|
||||||
import {
|
import {
|
||||||
|
Avatar,
|
||||||
Box,
|
Box,
|
||||||
Button,
|
Button,
|
||||||
Card,
|
Card,
|
||||||
@@ -24,6 +25,7 @@ import {
|
|||||||
import moment from 'moment'
|
import moment from 'moment'
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { useNavigate, useParams, useSearchParams } from 'react-router-dom'
|
import { useNavigate, useParams, useSearchParams } from 'react-router-dom'
|
||||||
|
import KeyboardShortcutHint from '../../components/common/KeyboardShortcutHint'
|
||||||
import NotificationTemplate from '../../components/NotificationTemplate.jsx'
|
import NotificationTemplate from '../../components/NotificationTemplate.jsx'
|
||||||
import {
|
import {
|
||||||
useArchiveChore,
|
useArchiveChore,
|
||||||
@@ -39,6 +41,7 @@ import { getTextColorFromBackgroundColor } from '../../utils/Colors.jsx'
|
|||||||
import { GetAllCircleMembers, GetThings } from '../../utils/Fetcher'
|
import { GetAllCircleMembers, GetThings } from '../../utils/Fetcher'
|
||||||
import { isPlusAccount } from '../../utils/Helpers'
|
import { isPlusAccount } from '../../utils/Helpers'
|
||||||
import Priorities from '../../utils/Priorities.jsx'
|
import Priorities from '../../utils/Priorities.jsx'
|
||||||
|
import { getIconComponent } from '../../utils/ProjectIcons'
|
||||||
import { getSafeBottomPadding } from '../../utils/SafeAreaUtils.js'
|
import { getSafeBottomPadding } from '../../utils/SafeAreaUtils.js'
|
||||||
import LoadingComponent from '../components/Loading.jsx'
|
import LoadingComponent from '../components/Loading.jsx'
|
||||||
import RichTextEditor from '../components/RichTextEditor.jsx'
|
import RichTextEditor from '../components/RichTextEditor.jsx'
|
||||||
@@ -46,9 +49,8 @@ import SubTasks from '../components/SubTask.jsx'
|
|||||||
import { useLabels } from '../Labels/LabelQueries'
|
import { useLabels } from '../Labels/LabelQueries'
|
||||||
import ConfirmationModal from '../Modals/Inputs/ConfirmationModal'
|
import ConfirmationModal from '../Modals/Inputs/ConfirmationModal'
|
||||||
import LabelModal from '../Modals/Inputs/LabelModal'
|
import LabelModal from '../Modals/Inputs/LabelModal'
|
||||||
import RepeatSection from './RepeatSection'
|
|
||||||
import { useProjects } from '../Projects/ProjectQueries'
|
import { useProjects } from '../Projects/ProjectQueries'
|
||||||
import { getIconComponent } from '../../utils/ProjectIcons'
|
import RepeatSection from './RepeatSection'
|
||||||
|
|
||||||
const ASSIGN_STRATEGIES = [
|
const ASSIGN_STRATEGIES = [
|
||||||
'random',
|
'random',
|
||||||
@@ -79,6 +81,9 @@ const ChoreEdit = () => {
|
|||||||
const [performers, setPerformers] = useState([])
|
const [performers, setPerformers] = useState([])
|
||||||
const [assignStrategy, setAssignStrategy] = useState(ASSIGN_STRATEGIES[2])
|
const [assignStrategy, setAssignStrategy] = useState(ASSIGN_STRATEGIES[2])
|
||||||
const [dueDate, setDueDate] = useState(null)
|
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 [assignedTo, setAssignedTo] = useState(-1)
|
||||||
const [frequencyType, setFrequencyType] = useState('once')
|
const [frequencyType, setFrequencyType] = useState('once')
|
||||||
const [frequency, setFrequency] = useState(1)
|
const [frequency, setFrequency] = useState(1)
|
||||||
@@ -114,6 +119,7 @@ const ChoreEdit = () => {
|
|||||||
const [showSaveNotificationDefault, setShowSaveNotificationDefault] =
|
const [showSaveNotificationDefault, setShowSaveNotificationDefault] =
|
||||||
useState(false)
|
useState(false)
|
||||||
const [showSaveAssigneeDefault, setShowSaveAssigneeDefault] = useState(false)
|
const [showSaveAssigneeDefault, setShowSaveAssigneeDefault] = useState(false)
|
||||||
|
const [showKeyboardShortcuts, setShowKeyboardShortcuts] = useState(false)
|
||||||
|
|
||||||
const { data: userLabelsRaw, isLoading: isUserLabelsLoading } = useLabels()
|
const { data: userLabelsRaw, isLoading: isUserLabelsLoading } = useLabels()
|
||||||
const { data: projects = [], isLoading: isProjectsLoading } = useProjects()
|
const { data: projects = [], isLoading: isProjectsLoading } = useProjects()
|
||||||
@@ -219,7 +225,88 @@ const ChoreEdit = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleDueDateChange = e => {
|
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 = () => {
|
const HandleSaveChore = () => {
|
||||||
setAttemptToSave(true)
|
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(() => {
|
useEffect(() => {
|
||||||
if (isChoreLoading === false && choreData && choreId) {
|
if (isChoreLoading === false && choreData && choreId) {
|
||||||
const data = choreData
|
const data = choreData
|
||||||
@@ -373,11 +501,32 @@ const ChoreEdit = () => {
|
|||||||
|
|
||||||
setIsNotificable(data.res.notification)
|
setIsNotificable(data.res.notification)
|
||||||
setThingTrigger(data.res.thingChore)
|
setThingTrigger(data.res.thingChore)
|
||||||
setDueDate(
|
|
||||||
data.res.nextDueDate
|
// Parse existing due date into date and time components
|
||||||
? moment(data.res.nextDueDate).format('YYYY-MM-DDTHH:mm:00')
|
if (data.res.nextDueDate) {
|
||||||
: null,
|
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)
|
setCreatedBy(data.res.createdBy)
|
||||||
setUpdatedBy(data.res.updatedBy)
|
setUpdatedBy(data.res.updatedBy)
|
||||||
}
|
}
|
||||||
@@ -392,12 +541,20 @@ const ChoreEdit = () => {
|
|||||||
// }, [userLabels, labelsV2])
|
// }, [userLabels, labelsV2])
|
||||||
|
|
||||||
useEffect(() => {
|
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) {
|
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)) {
|
if (NO_DUE_DATE_ALLOWED_TYPE.includes(frequencyType)) {
|
||||||
setDueDate(null)
|
setDueDate(null)
|
||||||
|
setDueDateOnly(null)
|
||||||
|
setUseCustomTime(false)
|
||||||
|
setDueTime(null)
|
||||||
}
|
}
|
||||||
}, [frequencyType])
|
}, [frequencyType])
|
||||||
|
|
||||||
@@ -555,7 +712,7 @@ const ChoreEdit = () => {
|
|||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* Project Selection - Show only if there are multiple projects */}
|
{/* Project Selection - Show only if there are multiple projects */}
|
||||||
{projects.length > 1 && (
|
{projects.length >= 1 && (
|
||||||
<Box mb={3}>
|
<Box mb={3}>
|
||||||
<Typography level='h4'>Project</Typography>
|
<Typography level='h4'>Project</Typography>
|
||||||
<Typography level='body-md'>
|
<Typography level='body-md'>
|
||||||
@@ -564,34 +721,68 @@ const ChoreEdit = () => {
|
|||||||
<Select
|
<Select
|
||||||
value={projectId}
|
value={projectId}
|
||||||
onChange={(event, newValue) => setProjectId(newValue)}
|
onChange={(event, newValue) => setProjectId(newValue)}
|
||||||
|
defaultValue='default'
|
||||||
sx={{ minWidth: '15rem' }}
|
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 => (
|
{projects.map(project => (
|
||||||
<Option key={project.id} value={project.id}>
|
<Option key={project.id} value={project.id}>
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||||
{project.icon &&
|
<Avatar
|
||||||
(() => {
|
size='sm'
|
||||||
const IconComponent = getIconComponent(project.icon)
|
|
||||||
return (
|
|
||||||
<IconComponent
|
|
||||||
sx={{
|
|
||||||
fontSize: 16,
|
|
||||||
color: getTextColorFromBackgroundColor(
|
|
||||||
project.color || '#1976d2',
|
|
||||||
),
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
})()}
|
|
||||||
<Box
|
|
||||||
sx={{
|
sx={{
|
||||||
width: 12,
|
width: 24,
|
||||||
height: 12,
|
height: 24,
|
||||||
borderRadius: '50%',
|
bgcolor: project.color || '#1976d2',
|
||||||
backgroundColor: project.color || '#1976d2',
|
|
||||||
mr: 1,
|
|
||||||
}}
|
}}
|
||||||
/>
|
>
|
||||||
|
{project.icon ? (
|
||||||
|
(() => {
|
||||||
|
const IconComponent = getIconComponent(project.icon)
|
||||||
|
return (
|
||||||
|
<IconComponent
|
||||||
|
sx={{
|
||||||
|
fontSize: 14,
|
||||||
|
color: getTextColorFromBackgroundColor(
|
||||||
|
project.color || '#1976d2',
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})()
|
||||||
|
) : (
|
||||||
|
<></>
|
||||||
|
)}
|
||||||
|
</Avatar>
|
||||||
{project.name}
|
{project.name}
|
||||||
</Box>
|
</Box>
|
||||||
</Option>
|
</Option>
|
||||||
@@ -901,9 +1092,18 @@ const ChoreEdit = () => {
|
|||||||
<Checkbox
|
<Checkbox
|
||||||
onChange={e => {
|
onChange={e => {
|
||||||
if (e.target.checked) {
|
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 {
|
} else {
|
||||||
setDueDate(null)
|
setDueDate(null)
|
||||||
|
setDueDateOnly(null)
|
||||||
|
setUseCustomTime(false)
|
||||||
|
setDueTime(null)
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
defaultChecked={dueDate !== null}
|
defaultChecked={dueDate !== null}
|
||||||
@@ -917,19 +1117,50 @@ const ChoreEdit = () => {
|
|||||||
</FormControl>
|
</FormControl>
|
||||||
)}
|
)}
|
||||||
{dueDate && (
|
{dueDate && (
|
||||||
<FormControl error={Boolean(errors.dueDate)}>
|
<>
|
||||||
<Typography level='body-md'>
|
<FormControl error={Boolean(errors.dueDate)} sx={{ mt: 2 }}>
|
||||||
{REPEAT_ON_TYPE.includes(frequencyType)
|
<Typography level='body-md'>
|
||||||
? 'When does this task start?'
|
{REPEAT_ON_TYPE.includes(frequencyType)
|
||||||
: 'When is the next first time this task is due?'}
|
? 'When does this task start?'
|
||||||
</Typography>
|
: 'When is the next first time this task is due?'}
|
||||||
<Input
|
</Typography>
|
||||||
type='datetime-local'
|
<Input
|
||||||
value={dueDate}
|
type='date'
|
||||||
onChange={handleDueDateChange}
|
value={dueDateOnly || ''}
|
||||||
/>
|
onChange={handleDueDateChange}
|
||||||
<FormHelperText>{errors.dueDate}</FormHelperText>
|
/>
|
||||||
</FormControl>
|
<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>
|
</Box>
|
||||||
|
|
||||||
@@ -1006,7 +1237,9 @@ const ChoreEdit = () => {
|
|||||||
onChange={e => {
|
onChange={e => {
|
||||||
if (e.target.checked) {
|
if (e.target.checked) {
|
||||||
// Set deadline to 24 hours after due date by default
|
// 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)
|
setDeadline(deadlineDate)
|
||||||
} else {
|
} else {
|
||||||
setDeadline(null)
|
setDeadline(null)
|
||||||
@@ -1036,7 +1269,8 @@ const ChoreEdit = () => {
|
|||||||
label='Set a deadline for this task'
|
label='Set a deadline for this task'
|
||||||
/>
|
/>
|
||||||
<FormHelperText>
|
<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>
|
</FormHelperText>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
)}
|
)}
|
||||||
@@ -1045,7 +1279,9 @@ const ChoreEdit = () => {
|
|||||||
{deadline && ['once', 'no_repeat'].includes(frequencyType) && (
|
{deadline && ['once', 'no_repeat'].includes(frequencyType) && (
|
||||||
<Card variant='outlined' sx={{ mt: 2 }}>
|
<Card variant='outlined' sx={{ mt: 2 }}>
|
||||||
<Box sx={{ p: 2 }}>
|
<Box sx={{ p: 2 }}>
|
||||||
<Typography level='body-sm' mb={1}>Deadline Date:</Typography>
|
<Typography level='body-sm' mb={1}>
|
||||||
|
Deadline Date:
|
||||||
|
</Typography>
|
||||||
<Input
|
<Input
|
||||||
type='datetime-local'
|
type='datetime-local'
|
||||||
value={deadline}
|
value={deadline}
|
||||||
@@ -1061,41 +1297,50 @@ const ChoreEdit = () => {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Offset input for recurring tasks */}
|
{/* Offset input for recurring tasks */}
|
||||||
{deadlineOffset !== -1 && !['once', 'no_repeat'].includes(frequencyType) && (
|
{deadlineOffset !== -1 &&
|
||||||
<Card variant='outlined' sx={{ mt: 2 }}>
|
!['once', 'no_repeat'].includes(frequencyType) && (
|
||||||
<Box sx={{ p: 2, display: 'flex', gap: 2, alignItems: 'end' }}>
|
<Card variant='outlined' sx={{ mt: 2 }}>
|
||||||
<Box>
|
<Box
|
||||||
<Typography level='body-sm' mb={1}>Time after due date:</Typography>
|
sx={{ p: 2, display: 'flex', gap: 2, alignItems: 'end' }}
|
||||||
<Input
|
>
|
||||||
type='number'
|
<Box>
|
||||||
value={deadlineOffset}
|
<Typography level='body-sm' mb={1}>
|
||||||
sx={{ maxWidth: 100 }}
|
Time after due date:
|
||||||
slotProps={{
|
</Typography>
|
||||||
input: {
|
<Input
|
||||||
min: 1,
|
type='number'
|
||||||
max: 720, // Max 30 days in hours
|
value={deadlineOffset}
|
||||||
},
|
sx={{ maxWidth: 100 }}
|
||||||
}}
|
slotProps={{
|
||||||
placeholder='Time'
|
input: {
|
||||||
onChange={e => {
|
min: 1,
|
||||||
setDeadlineOffset(parseInt(e.target.value) || 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>
|
||||||
<Box>
|
</Card>
|
||||||
<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>
|
|
||||||
)}
|
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -1502,9 +1747,15 @@ const ChoreEdit = () => {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
|
{showKeyboardShortcuts && (
|
||||||
|
<KeyboardShortcutHint shortcut='Esc' sx={{ ml: 1 }} />
|
||||||
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
<Button color='primary' variant='solid' onClick={HandleSaveChore}>
|
<Button color='primary' variant='solid' onClick={HandleSaveChore}>
|
||||||
{choreId > 0 ? 'Save' : 'Create'}
|
{choreId > 0 ? 'Save' : 'Create'}
|
||||||
|
{showKeyboardShortcuts && (
|
||||||
|
<KeyboardShortcutHint shortcut='Enter' sx={{ ml: 1 }} />
|
||||||
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</Sheet>
|
</Sheet>
|
||||||
<ConfirmationModal config={confirmModelConfig} />
|
<ConfirmationModal config={confirmModelConfig} />
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import {
|
import {
|
||||||
|
Archive,
|
||||||
CalendarMonth,
|
CalendarMonth,
|
||||||
CancelScheduleSend,
|
CancelScheduleSend,
|
||||||
Check,
|
Check,
|
||||||
@@ -15,6 +16,7 @@ import {
|
|||||||
SwitchAccessShortcut,
|
SwitchAccessShortcut,
|
||||||
ThumbDown,
|
ThumbDown,
|
||||||
ThumbUp,
|
ThumbUp,
|
||||||
|
Unarchive,
|
||||||
} from '@mui/icons-material'
|
} from '@mui/icons-material'
|
||||||
import {
|
import {
|
||||||
Box,
|
Box,
|
||||||
@@ -60,6 +62,7 @@ import {
|
|||||||
MarkChoreComplete,
|
MarkChoreComplete,
|
||||||
RejectChore,
|
RejectChore,
|
||||||
SkipChore,
|
SkipChore,
|
||||||
|
UnArchiveChore,
|
||||||
UpdateChorePriority,
|
UpdateChorePriority,
|
||||||
} from '../../utils/Fetcher'
|
} from '../../utils/Fetcher'
|
||||||
import Priorities from '../../utils/Priorities'
|
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)
|
// Check if the current user can approve/reject (admin, manager, or task owner)
|
||||||
const canApproveReject = () => {
|
const canApproveReject = () => {
|
||||||
if (!circleMembersData?.res || !chore) return false
|
if (!circleMembersData?.res || !chore) return false
|
||||||
@@ -408,6 +423,16 @@ const ChoreView = () => {
|
|||||||
>
|
>
|
||||||
{chore.name}
|
{chore.name}
|
||||||
</Typography>
|
</Typography>
|
||||||
|
{chore.isActive === false && (
|
||||||
|
<Chip
|
||||||
|
startDecorator={<Archive />}
|
||||||
|
size='md'
|
||||||
|
color='warning'
|
||||||
|
sx={{ mb: 1 }}
|
||||||
|
>
|
||||||
|
Archived
|
||||||
|
</Chip>
|
||||||
|
)}
|
||||||
<Chip startDecorator={<CalendarMonth />} size='md' sx={{ mb: 1 }}>
|
<Chip startDecorator={<CalendarMonth />} size='md' sx={{ mb: 1 }}>
|
||||||
{chore.nextDueDate
|
{chore.nextDueDate
|
||||||
? `Due at ${moment(chore.nextDueDate).format('MM/DD/YYYY hh:mm A')}`
|
? `Due at ${moment(chore.nextDueDate).format('MM/DD/YYYY hh:mm A')}`
|
||||||
@@ -531,6 +556,7 @@ const ChoreView = () => {
|
|||||||
>
|
>
|
||||||
<Dropdown>
|
<Dropdown>
|
||||||
<MenuButton
|
<MenuButton
|
||||||
|
disabled={chore.isActive === false}
|
||||||
color={
|
color={
|
||||||
chorePriority?.name === 'P1'
|
chorePriority?.name === 'P1'
|
||||||
? 'danger'
|
? 'danger'
|
||||||
@@ -591,6 +617,7 @@ const ChoreView = () => {
|
|||||||
color='neutral'
|
color='neutral'
|
||||||
variant='plain'
|
variant='plain'
|
||||||
fullWidth
|
fullWidth
|
||||||
|
disabled={chore.isActive === false}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
navigate(`/chores/${choreId}/history`)
|
navigate(`/chores/${choreId}/history`)
|
||||||
}}
|
}}
|
||||||
@@ -609,6 +636,7 @@ const ChoreView = () => {
|
|||||||
color='neutral'
|
color='neutral'
|
||||||
variant='plain'
|
variant='plain'
|
||||||
fullWidth
|
fullWidth
|
||||||
|
disabled={chore.isActive === false}
|
||||||
sx={{
|
sx={{
|
||||||
// top right of the card:
|
// top right of the card:
|
||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
@@ -725,6 +753,7 @@ const ChoreView = () => {
|
|||||||
<Checkbox
|
<Checkbox
|
||||||
checked={note !== null}
|
checked={note !== null}
|
||||||
size='lg'
|
size='lg'
|
||||||
|
disabled={chore.isActive === false}
|
||||||
onChange={e => {
|
onChange={e => {
|
||||||
if (e.target.checked) {
|
if (e.target.checked) {
|
||||||
setNote('')
|
setNote('')
|
||||||
@@ -764,6 +793,7 @@ const ChoreView = () => {
|
|||||||
<Checkbox
|
<Checkbox
|
||||||
checked={completedDate !== null}
|
checked={completedDate !== null}
|
||||||
size='lg'
|
size='lg'
|
||||||
|
disabled={chore.isActive === false}
|
||||||
onChange={e => {
|
onChange={e => {
|
||||||
if (e.target.checked) {
|
if (e.target.checked) {
|
||||||
setCompletedDate(
|
setCompletedDate(
|
||||||
@@ -804,164 +834,188 @@ const ChoreView = () => {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Box
|
{chore.isActive === false ? (
|
||||||
sx={{
|
// Archived chore - only show unarchive button
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
gap: 1,
|
|
||||||
alignContent: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'row',
|
flexDirection: 'column',
|
||||||
gap: 1,
|
gap: 1,
|
||||||
alignContent: 'center',
|
alignContent: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
mb: 1,
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{chore.status === 3 ? (
|
<Button
|
||||||
// Pending approval: Show approve/reject for admins/managers/owners, grayed out button for others
|
fullWidth
|
||||||
canApproveReject() ? (
|
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
|
<Button
|
||||||
fullWidth
|
fullWidth
|
||||||
size='lg'
|
size='lg'
|
||||||
onClick={handleApproveChore}
|
onClick={handleTaskCompletion}
|
||||||
color='success'
|
disabled={
|
||||||
startDecorator={<ThumbUp />}
|
isPendingCompletion ||
|
||||||
|
notInCompletionWindow(chore) ||
|
||||||
|
(chore.lastCompletedDate !== null &&
|
||||||
|
chore.frequencyType === 'once')
|
||||||
|
}
|
||||||
|
color={isPendingCompletion ? 'danger' : 'success'}
|
||||||
|
startDecorator={<Check />}
|
||||||
sx={{
|
sx={{
|
||||||
flex: 1,
|
flex: 4,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Approve
|
<Box>Mark as done</Box>
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
fullWidth
|
fullWidth
|
||||||
size='lg'
|
size='lg'
|
||||||
onClick={handleRejectChore}
|
onClick={() => {
|
||||||
color='danger'
|
setConfirmModelConfig({
|
||||||
startDecorator={<ThumbDown />}
|
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={{
|
sx={{
|
||||||
flex: 1,
|
flex: 1,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Box>Reject</Box>
|
<Box>Skip</Box>
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
) : (
|
)}
|
||||||
<Button
|
</Box>
|
||||||
fullWidth
|
{/* Timer Button - Show split button when timer is active, regular button otherwise */}
|
||||||
size='lg'
|
{[ChoreStatus.ACTIVE, ChoreStatus.PAUSED].includes(chore.status) ? (
|
||||||
disabled={true}
|
<TimerSplitButton
|
||||||
color='neutral'
|
disabled={
|
||||||
startDecorator={<HourglassEmpty />}
|
chore.lastCompletedDate !== null &&
|
||||||
>
|
chore.frequencyType === 'once'
|
||||||
<Box>Pending Approval</Box>
|
}
|
||||||
</Button>
|
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
|
||||||
<>
|
size='lg'
|
||||||
<Button
|
onClick={() => {
|
||||||
fullWidth
|
handleChoreStart()
|
||||||
size='lg'
|
}}
|
||||||
onClick={handleTaskCompletion}
|
variant='soft'
|
||||||
disabled={
|
color='success'
|
||||||
isPendingCompletion ||
|
disabled={
|
||||||
notInCompletionWindow(chore) ||
|
chore.lastCompletedDate !== null &&
|
||||||
(chore.lastCompletedDate !== null &&
|
chore.frequencyType === 'once'
|
||||||
chore.frequencyType === 'once')
|
}
|
||||||
}
|
startDecorator={<PlayArrow />}
|
||||||
color={isPendingCompletion ? 'danger' : 'success'}
|
sx={{
|
||||||
startDecorator={<Check />}
|
flex: 1,
|
||||||
sx={{
|
}}
|
||||||
flex: 4,
|
>
|
||||||
}}
|
Start
|
||||||
>
|
</Button>
|
||||||
<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>
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
</Box>
|
</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
|
<Snackbar
|
||||||
open={isPendingCompletion}
|
open={isPendingCompletion}
|
||||||
|
|||||||
@@ -444,7 +444,6 @@ const ArchivedTasks = () => {
|
|||||||
performers={performers}
|
performers={performers}
|
||||||
viewOnly={false}
|
viewOnly={false}
|
||||||
showActions={false}
|
showActions={false}
|
||||||
// onAction={handleChoreAction}
|
|
||||||
// Multi-select props
|
// Multi-select props
|
||||||
isMultiSelectMode={isMultiSelectMode}
|
isMultiSelectMode={isMultiSelectMode}
|
||||||
isSelected={selectedChores.has(chore.id)}
|
isSelected={selectedChores.has(chore.id)}
|
||||||
|
|||||||
@@ -209,9 +209,16 @@ const MyChores = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Use project-filtered chores for section grouping
|
// Use project-filtered chores for section grouping
|
||||||
const choresToGroup = selectedProject
|
let choresToGroup = chores
|
||||||
? filterByProject(chores, selectedProject.id)
|
if (selectedProject) {
|
||||||
: chores
|
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(
|
const sections = ChoresGrouper(
|
||||||
selectedChoreSection,
|
selectedChoreSection,
|
||||||
@@ -1084,8 +1091,8 @@ const MyChores = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const setSelectedProjectWithCache = project => {
|
const setSelectedProjectWithCache = project => {
|
||||||
// Handle the case where project might be null (clearing selection)
|
// Keep the project as-is, including the default project object
|
||||||
const finalProject = project?.id === 'default' || !project ? null : project
|
const finalProject = project || null
|
||||||
|
|
||||||
setSelectedProject(finalProject)
|
setSelectedProject(finalProject)
|
||||||
console.log('final project', finalProject)
|
console.log('final project', finalProject)
|
||||||
@@ -1157,6 +1164,13 @@ const MyChores = () => {
|
|||||||
if (!selectedProject) {
|
if (!selectedProject) {
|
||||||
return chores
|
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)
|
return filterByProject(chores, selectedProject.id)
|
||||||
}, [chores, selectedProject])
|
}, [chores, selectedProject])
|
||||||
|
|
||||||
@@ -1183,11 +1197,17 @@ const MyChores = () => {
|
|||||||
.search(searchTerm.toLowerCase())
|
.search(searchTerm.toLowerCase())
|
||||||
.map(result => result.item)
|
.map(result => result.item)
|
||||||
} else {
|
} else {
|
||||||
result = filteredChores.filter(
|
result = filteredChores.filter(chore => {
|
||||||
chore =>
|
if (!selectedProject) return true
|
||||||
!selectedProject ||
|
|
||||||
filterByProject([chore], selectedProject).length > 0,
|
// 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 {
|
} else {
|
||||||
let choresToFilter = baseChores
|
let choresToFilter = baseChores
|
||||||
|
|||||||
@@ -125,12 +125,32 @@ const ProjectModal = ({ isOpen, onClose, onSave, project }) => {
|
|||||||
size='md'
|
size='md'
|
||||||
unmountDelay={250}
|
unmountDelay={250}
|
||||||
fullWidth={true}
|
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}>
|
<form onSubmit={handleSubmit} id='project-form'>
|
||||||
{project ? 'Edit Project' : 'Create New Project'}
|
|
||||||
</Typography>
|
|
||||||
|
|
||||||
<form onSubmit={handleSubmit}>
|
|
||||||
<Stack spacing={3}>
|
<Stack spacing={3}>
|
||||||
{/* Project Name */}
|
{/* Project Name */}
|
||||||
<FormControl required>
|
<FormControl required>
|
||||||
@@ -238,62 +258,6 @@ const ProjectModal = ({ isOpen, onClose, onSave, project }) => {
|
|||||||
</Select>
|
</Select>
|
||||||
</FormControl>
|
</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 Message */}
|
||||||
{error && (
|
{error && (
|
||||||
<Typography color='danger' level='body-sm'>
|
<Typography color='danger' level='body-sm'>
|
||||||
@@ -301,29 +265,7 @@ const ProjectModal = ({ isOpen, onClose, onSave, project }) => {
|
|||||||
</Typography>
|
</Typography>
|
||||||
)}
|
)}
|
||||||
</Stack>
|
</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>
|
</form>
|
||||||
|
|
||||||
<IconPickerModal
|
<IconPickerModal
|
||||||
isOpen={isIconPickerOpen}
|
isOpen={isIconPickerOpen}
|
||||||
onClose={() => setIsIconPickerOpen(false)}
|
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>
|
</Avatar>
|
||||||
</Box>
|
</Box>
|
||||||
@@ -545,7 +534,7 @@ const ProjectCard = ({
|
|||||||
const ProjectView = () => {
|
const ProjectView = () => {
|
||||||
const { data: projects, isProjectsLoading, isError } = useProjects()
|
const { data: projects, isProjectsLoading, isError } = useProjects()
|
||||||
const { data: userProfile } = useUserProfile()
|
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 [userProjects, setUserProjects] = useState([])
|
||||||
const [modalOpen, setModalOpen] = useState(false)
|
const [modalOpen, setModalOpen] = useState(false)
|
||||||
@@ -602,23 +591,27 @@ const ProjectView = () => {
|
|||||||
|
|
||||||
// Calculate real task counts from chores data
|
// Calculate real task counts from chores data
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (chores && chores.res && userProjects.length > 0) {
|
if (chores && chores.res) {
|
||||||
const choresList = chores.res
|
const choresList = chores.res
|
||||||
const realCounts = {}
|
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 => {
|
userProjects.forEach(project => {
|
||||||
// Count chores for this project
|
|
||||||
const choreCount = choresList.filter(chore => {
|
const choreCount = choresList.filter(chore => {
|
||||||
// Handle default project (projectId is null, undefined, empty string, or 'default')
|
const choreProjectId = chore.projectId || chore.project_id
|
||||||
if (project.id === 'default') {
|
return choreProjectId === project.id
|
||||||
return (
|
|
||||||
!chore.projectId ||
|
|
||||||
chore.projectId === '' ||
|
|
||||||
chore.projectId === 'default'
|
|
||||||
)
|
|
||||||
}
|
|
||||||
// Handle custom projects - exact match with project ID
|
|
||||||
return chore.projectId === project.id
|
|
||||||
}).length
|
}).length
|
||||||
|
|
||||||
realCounts[project.id] = choreCount
|
realCounts[project.id] = choreCount
|
||||||
|
|||||||
@@ -1,5 +1,13 @@
|
|||||||
import { Add, EditNotifications } from '@mui/icons-material'
|
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 { FormControl } from '@mui/material'
|
||||||
import * as chrono from 'chrono-node'
|
import * as chrono from 'chrono-node'
|
||||||
import moment from 'moment'
|
import moment from 'moment'
|
||||||
@@ -7,8 +15,11 @@ import { useCallback, useEffect, useRef, useState } from 'react'
|
|||||||
import { useResponsiveModal } from '../../hooks/useResponsiveModal'
|
import { useResponsiveModal } from '../../hooks/useResponsiveModal'
|
||||||
import { useCreateChore } from '../../queries/ChoreQueries'
|
import { useCreateChore } from '../../queries/ChoreQueries'
|
||||||
import { useCircleMembers, useUserProfile } from '../../queries/UserQueries'
|
import { useCircleMembers, useUserProfile } from '../../queries/UserQueries'
|
||||||
|
import { getTextColorFromBackgroundColor } from '../../utils/Colors.jsx'
|
||||||
import { isPlusAccount } from '../../utils/Helpers'
|
import { isPlusAccount } from '../../utils/Helpers'
|
||||||
|
import { getIconComponent } from '../../utils/ProjectIcons'
|
||||||
import { useLabels } from '../Labels/LabelQueries'
|
import { useLabels } from '../Labels/LabelQueries'
|
||||||
|
import { useProjects } from '../Projects/ProjectQueries'
|
||||||
import {
|
import {
|
||||||
parseAssignees,
|
parseAssignees,
|
||||||
parseDueDate,
|
parseDueDate,
|
||||||
@@ -47,10 +58,25 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
|
|||||||
const { data: userLabels, isLoading: userLabelsLoading } = useLabels()
|
const { data: userLabels, isLoading: userLabelsLoading } = useLabels()
|
||||||
const { data: circleMembers, isLoading: isCircleMembersLoading } =
|
const { data: circleMembers, isLoading: isCircleMembersLoading } =
|
||||||
useCircleMembers()
|
useCircleMembers()
|
||||||
|
const { data: projects = [], isLoading: isProjectsLoading } = useProjects()
|
||||||
const createChoreMutation = useCreateChore()
|
const createChoreMutation = useCreateChore()
|
||||||
|
|
||||||
const { data: userProfile } = useUserProfile()
|
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 [taskText, setTaskText] = useState('')
|
||||||
const [taskTitle, setTaskTitle] = useState('')
|
const [taskTitle, setTaskTitle] = useState('')
|
||||||
const [renderedParts, setRenderedParts] = useState([])
|
const [renderedParts, setRenderedParts] = useState([])
|
||||||
@@ -75,6 +101,7 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
|
|||||||
const [hasSubTasks, setHasSubTasks] = useState(false)
|
const [hasSubTasks, setHasSubTasks] = useState(false)
|
||||||
const [hasNotifications, setHasNotifications] = useState(false)
|
const [hasNotifications, setHasNotifications] = useState(false)
|
||||||
const [showKeyboardShortcuts, setShowKeyboardShortcuts] = 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:
|
// set showKeyboardShortcuts true as soon as the user hold ctrl or cmd key:
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -473,6 +500,7 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
|
|||||||
setHasSubTasks(false)
|
setHasSubTasks(false)
|
||||||
setLabelsV2([])
|
setLabelsV2([])
|
||||||
setAssignees([])
|
setAssignees([])
|
||||||
|
setProjectId(getInitialProject())
|
||||||
}
|
}
|
||||||
|
|
||||||
const createChore = () => {
|
const createChore = () => {
|
||||||
@@ -516,6 +544,7 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
|
|||||||
frequencyMetadata: {},
|
frequencyMetadata: {},
|
||||||
notificationMetadata: {},
|
notificationMetadata: {},
|
||||||
subTasks: subTasks?.length > 0 ? subTasks : null,
|
subTasks: subTasks?.length > 0 ? subTasks : null,
|
||||||
|
projectId: projectId === 'default' ? null : projectId,
|
||||||
}
|
}
|
||||||
|
|
||||||
if (frequency) {
|
if (frequency) {
|
||||||
@@ -560,7 +589,7 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
|
|||||||
})
|
})
|
||||||
handleCloseModal(false)
|
handleCloseModal(false)
|
||||||
}
|
}
|
||||||
if (userLabelsLoading || isCircleMembersLoading) {
|
if (userLabelsLoading || isCircleMembersLoading || isProjectsLoading) {
|
||||||
return <></>
|
return <></>
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -571,6 +600,44 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
|
|||||||
size='lg'
|
size='lg'
|
||||||
fullWidth={true}
|
fullWidth={true}
|
||||||
title='Create new task'
|
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>
|
||||||
<Box
|
<Box
|
||||||
@@ -803,6 +870,75 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
|
|||||||
</FormControl>
|
</FormControl>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</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
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
marginTop: 2,
|
marginTop: 2,
|
||||||
@@ -861,37 +997,6 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
|
|||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
</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>
|
</ResponsiveModal>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -94,13 +94,12 @@ const links = [
|
|||||||
|
|
||||||
import { SafeArea } from 'capacitor-plugin-safe-area'
|
import { SafeArea } from 'capacitor-plugin-safe-area'
|
||||||
import Z_INDEX from '../../constants/zIndex'
|
import Z_INDEX from '../../constants/zIndex'
|
||||||
import { useAuth } from '../../hooks/useAuth.jsx'
|
|
||||||
import { useResource } from '../../queries/ResourceQueries'
|
import { useResource } from '../../queries/ResourceQueries'
|
||||||
|
import { apiClient } from '../../utils/apiClient'
|
||||||
|
|
||||||
const publicPages = ['/landing', '/privacy', '/terms']
|
const publicPages = ['/landing', '/privacy', '/terms']
|
||||||
const NavBar = () => {
|
const NavBar = () => {
|
||||||
const { data: resource } = useResource()
|
const { data: resource } = useResource()
|
||||||
const { logout } = useAuth()
|
|
||||||
|
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const [drawerOpen, setDrawerOpen] = useState(false)
|
const [drawerOpen, setDrawerOpen] = useState(false)
|
||||||
@@ -272,7 +271,9 @@ const NavBar = () => {
|
|||||||
<ListItemContent>Upgrade to Plus</ListItemContent>
|
<ListItemContent>Upgrade to Plus</ListItemContent>
|
||||||
</ListItemButton> */}
|
</ListItemButton> */}
|
||||||
<ListItemButton
|
<ListItemButton
|
||||||
onClick={logout}
|
onClick={() => {
|
||||||
|
apiClient.handleLogout()
|
||||||
|
}}
|
||||||
sx={{
|
sx={{
|
||||||
py: 1.2,
|
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 {
|
import {
|
||||||
Avatar,
|
Avatar,
|
||||||
Box,
|
Box,
|
||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
Typography,
|
Typography,
|
||||||
} from '@mui/joy'
|
} from '@mui/joy'
|
||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
import KeyboardShortcutHint from '../../components/common/KeyboardShortcutHint'
|
import KeyboardShortcutHint from '../../components/common/KeyboardShortcutHint'
|
||||||
import LABEL_COLORS, {
|
import LABEL_COLORS, {
|
||||||
getTextColorFromBackgroundColor,
|
getTextColorFromBackgroundColor,
|
||||||
@@ -25,9 +26,11 @@ const ProjectSelector = ({
|
|||||||
showKeyboardShortcuts = false,
|
showKeyboardShortcuts = false,
|
||||||
}) => {
|
}) => {
|
||||||
const { data: projects = [], isLoading } = useProjects()
|
const { data: projects = [], isLoading } = useProjects()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
|
||||||
const [anchorEl, setAnchorEl] = useState(null)
|
const [anchorEl, setAnchorEl] = useState(null)
|
||||||
const [selectedIndex, setSelectedIndex] = useState(0)
|
const [selectedIndex, setSelectedIndex] = useState(0)
|
||||||
|
const [isKeyboardNavigating, setIsKeyboardNavigating] = useState(false)
|
||||||
const [isProjectModalOpen, setIsProjectModalOpen] = useState(false)
|
const [isProjectModalOpen, setIsProjectModalOpen] = useState(false)
|
||||||
const menuRef = useRef(null)
|
const menuRef = useRef(null)
|
||||||
const buttonRef = useRef(null)
|
const buttonRef = useRef(null)
|
||||||
@@ -56,6 +59,7 @@ const ProjectSelector = ({
|
|||||||
|
|
||||||
const handleMenuOpen = event => {
|
const handleMenuOpen = event => {
|
||||||
setAnchorEl(event.currentTarget)
|
setAnchorEl(event.currentTarget)
|
||||||
|
setIsKeyboardNavigating(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleMenuClose = () => {
|
const handleMenuClose = () => {
|
||||||
@@ -76,6 +80,11 @@ const ProjectSelector = ({
|
|||||||
handleProjectSelect(project)
|
handleProjectSelect(project)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleManageProjects = () => {
|
||||||
|
navigate('/projects')
|
||||||
|
handleMenuClose()
|
||||||
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleMenuOutsideClick = event => {
|
const handleMenuOutsideClick = event => {
|
||||||
if (menuRef.current && !menuRef.current.contains(event.target)) {
|
if (menuRef.current && !menuRef.current.contains(event.target)) {
|
||||||
@@ -100,6 +109,7 @@ const ProjectSelector = ({
|
|||||||
if (!anchorEl) {
|
if (!anchorEl) {
|
||||||
setAnchorEl(buttonRef.current)
|
setAnchorEl(buttonRef.current)
|
||||||
setSelectedIndex(0)
|
setSelectedIndex(0)
|
||||||
|
setIsKeyboardNavigating(true)
|
||||||
} else {
|
} else {
|
||||||
handleMenuClose()
|
handleMenuClose()
|
||||||
}
|
}
|
||||||
@@ -112,20 +122,33 @@ const ProjectSelector = ({
|
|||||||
switch (event.key) {
|
switch (event.key) {
|
||||||
case 'ArrowDown':
|
case 'ArrowDown':
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
|
setIsKeyboardNavigating(true)
|
||||||
setSelectedIndex(prev =>
|
setSelectedIndex(prev =>
|
||||||
prev < defaultProjects.length ? prev + 1 : prev,
|
prev < defaultProjects.length + 2 ? prev + 1 : prev,
|
||||||
)
|
)
|
||||||
break
|
break
|
||||||
case 'ArrowUp':
|
case 'ArrowUp':
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
|
setIsKeyboardNavigating(true)
|
||||||
setSelectedIndex(prev => (prev > 0 ? prev - 1 : prev))
|
setSelectedIndex(prev => (prev > 0 ? prev - 1 : prev))
|
||||||
break
|
break
|
||||||
case 'Enter':
|
case 'Enter':
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
if (selectedIndex < defaultProjects.length) {
|
if (selectedIndex === 0) {
|
||||||
handleProjectSelect(defaultProjects[selectedIndex])
|
// Hardcoded Default Project
|
||||||
} else {
|
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()
|
handleAddProjectClick()
|
||||||
|
} else {
|
||||||
|
handleManageProjects()
|
||||||
}
|
}
|
||||||
break
|
break
|
||||||
case 'Escape':
|
case 'Escape':
|
||||||
@@ -139,7 +162,7 @@ const ProjectSelector = ({
|
|||||||
return () => {
|
return () => {
|
||||||
document.removeEventListener('keydown', handleKeyDown)
|
document.removeEventListener('keydown', handleKeyDown)
|
||||||
}
|
}
|
||||||
}, [anchorEl, selectedIndex, defaultProjects])
|
}, [anchorEl, selectedIndex, defaultProjects, isKeyboardNavigating])
|
||||||
|
|
||||||
// Reset selected index when menu opens
|
// Reset selected index when menu opens
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -233,7 +256,6 @@ const ProjectSelector = ({
|
|||||||
disabled
|
disabled
|
||||||
sx={{
|
sx={{
|
||||||
borderRadius: 'var(--joy-radius-sm)',
|
borderRadius: 'var(--joy-radius-sm)',
|
||||||
mb: 1,
|
|
||||||
cursor: 'default',
|
cursor: 'default',
|
||||||
opacity: 1,
|
opacity: 1,
|
||||||
}}
|
}}
|
||||||
@@ -243,13 +265,7 @@ const ProjectSelector = ({
|
|||||||
</ListItemDecorator>
|
</ListItemDecorator>
|
||||||
<ListItemContent>
|
<ListItemContent>
|
||||||
<Typography level='title-sm' sx={{ fontWeight: 600 }}>
|
<Typography level='title-sm' sx={{ fontWeight: 600 }}>
|
||||||
Select Project
|
Projects
|
||||||
</Typography>
|
|
||||||
<Typography
|
|
||||||
level='body-xs'
|
|
||||||
sx={{ color: 'var(--joy-palette-text-tertiary)' }}
|
|
||||||
>
|
|
||||||
Choose or create a project workspace
|
|
||||||
</Typography>
|
</Typography>
|
||||||
</ListItemContent>
|
</ListItemContent>
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
@@ -266,12 +282,13 @@ const ProjectSelector = ({
|
|||||||
icon: 'FolderOpen',
|
icon: 'FolderOpen',
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
onMouseEnter={() => setIsKeyboardNavigating(false)}
|
||||||
sx={{
|
sx={{
|
||||||
borderRadius: 'var(--joy-radius-sm)',
|
borderRadius: 'var(--joy-radius-sm)',
|
||||||
backgroundColor:
|
backgroundColor:
|
||||||
effectiveSelectedProject === 'Default Project'
|
effectiveSelectedProject === 'Default Project'
|
||||||
? 'var(--joy-palette-primary-softBg)'
|
? 'var(--joy-palette-primary-softBg)'
|
||||||
: selectedIndex === 0 && anchorEl
|
: selectedIndex === 0 && anchorEl && isKeyboardNavigating
|
||||||
? 'var(--joy-palette-neutral-softHoverBg)'
|
? 'var(--joy-palette-neutral-softHoverBg)'
|
||||||
: 'transparent',
|
: 'transparent',
|
||||||
'&:hover': {
|
'&:hover': {
|
||||||
@@ -345,12 +362,13 @@ const ProjectSelector = ({
|
|||||||
<MenuItem
|
<MenuItem
|
||||||
key={project.id}
|
key={project.id}
|
||||||
onClick={() => handleProjectSelect(project)}
|
onClick={() => handleProjectSelect(project)}
|
||||||
|
onMouseEnter={() => setIsKeyboardNavigating(false)}
|
||||||
sx={{
|
sx={{
|
||||||
borderRadius: 'var(--joy-radius-sm)',
|
borderRadius: 'var(--joy-radius-sm)',
|
||||||
backgroundColor:
|
backgroundColor:
|
||||||
effectiveSelectedProject === project.name
|
effectiveSelectedProject === project.name
|
||||||
? 'var(--joy-palette-primary-softBg)'
|
? 'var(--joy-palette-primary-softBg)'
|
||||||
: selectedIndex === index && anchorEl
|
: selectedIndex === index + 1 && anchorEl && isKeyboardNavigating
|
||||||
? 'var(--joy-palette-neutral-softHoverBg)'
|
? 'var(--joy-palette-neutral-softHoverBg)'
|
||||||
: 'transparent',
|
: 'transparent',
|
||||||
'&:hover': {
|
'&:hover': {
|
||||||
@@ -437,10 +455,11 @@ const ProjectSelector = ({
|
|||||||
|
|
||||||
<MenuItem
|
<MenuItem
|
||||||
onClick={handleAddProjectClick}
|
onClick={handleAddProjectClick}
|
||||||
|
onMouseEnter={() => setIsKeyboardNavigating(false)}
|
||||||
sx={{
|
sx={{
|
||||||
borderRadius: 'var(--joy-radius-sm)',
|
borderRadius: 'var(--joy-radius-sm)',
|
||||||
backgroundColor:
|
backgroundColor:
|
||||||
selectedIndex === defaultProjects.length && anchorEl
|
selectedIndex === defaultProjects.length + 1 && anchorEl && isKeyboardNavigating
|
||||||
? 'var(--joy-palette-success-softHoverBg)'
|
? 'var(--joy-palette-success-softHoverBg)'
|
||||||
: 'transparent',
|
: 'transparent',
|
||||||
'&:hover': {
|
'&:hover': {
|
||||||
@@ -456,7 +475,6 @@ const ProjectSelector = ({
|
|||||||
level='body-sm'
|
level='body-sm'
|
||||||
sx={{
|
sx={{
|
||||||
fontWeight: 500,
|
fontWeight: 500,
|
||||||
color: 'var(--joy-palette-success-600)',
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Create New Project
|
Create New Project
|
||||||
@@ -469,6 +487,41 @@ const ProjectSelector = ({
|
|||||||
</Typography>
|
</Typography>
|
||||||
</ListItemContent>
|
</ListItemContent>
|
||||||
</MenuItem>
|
</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>
|
</Menu>
|
||||||
|
|
||||||
<ProjectModal
|
<ProjectModal
|
||||||
|
|||||||
Reference in New Issue
Block a user