Initial Activity View
Add New Colors.jsx Allow Dark model Toggle from the Navbar
This commit is contained in:
commit
97f1c1b6f8
140 changed files with 11476 additions and 3426 deletions
|
@ -14,6 +14,7 @@ import {
|
|||
import { useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { API_URL } from './../../Config'
|
||||
import { ResetPassword } from '../../utils/Fetcher'
|
||||
|
||||
const ForgotPasswordView = () => {
|
||||
const navigate = useNavigate()
|
||||
|
@ -43,13 +44,7 @@ const ForgotPasswordView = () => {
|
|||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/auth/reset`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ email: email }),
|
||||
})
|
||||
const response = await ResetPassword(email)
|
||||
|
||||
if (response.ok) {
|
||||
setResetStatusOk(true)
|
||||
|
|
181
src/views/Authorization/LoginSettings.jsx
Normal file
181
src/views/Authorization/LoginSettings.jsx
Normal file
|
@ -0,0 +1,181 @@
|
|||
import GoogleIcon from '@mui/icons-material/Google'
|
||||
import {
|
||||
Avatar,
|
||||
Box,
|
||||
Button,
|
||||
Container,
|
||||
Divider,
|
||||
IconButton,
|
||||
Input,
|
||||
Sheet,
|
||||
Snackbar,
|
||||
Typography,
|
||||
} from '@mui/joy'
|
||||
import React from 'react'
|
||||
import { LoginSocialGoogle } from 'reactjs-social-login'
|
||||
import { API_URL, GOOGLE_CLIENT_ID, REDIRECT_URL } from '../../Config'
|
||||
import Logo from '../../Logo'
|
||||
import { GoogleAuth } from '@codetrix-studio/capacitor-google-auth';
|
||||
import { Capacitor } from '@capacitor/core'
|
||||
import { Settings } from '@mui/icons-material'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { Preferences } from '@capacitor/preferences'
|
||||
|
||||
|
||||
|
||||
const LoginSettings = () => {
|
||||
const [error, setError] = React.useState(null)
|
||||
const Navigate = useNavigate()
|
||||
|
||||
const [serverURL, setServerURL] = React.useState('')
|
||||
|
||||
React.useEffect(() => {
|
||||
Preferences.get({ key: 'customServerUrl' }).then((result) => {
|
||||
setServerURL(result.value || API_URL)
|
||||
})
|
||||
}, [])
|
||||
|
||||
const isValidServerURL = () => {
|
||||
return serverURL.match(/^(http|https):\/\/[^ "]+$/)
|
||||
}
|
||||
|
||||
return (
|
||||
<Container
|
||||
component='main'
|
||||
maxWidth='xs'
|
||||
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
marginTop: 4,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<Sheet
|
||||
component='form'
|
||||
sx={{
|
||||
mt: 1,
|
||||
width: '100%',
|
||||
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
padding: 2,
|
||||
borderRadius: '8px',
|
||||
boxShadow: 'md',
|
||||
}}
|
||||
>
|
||||
<Logo />
|
||||
|
||||
<Typography level='h2'>
|
||||
Done
|
||||
<span
|
||||
style={{
|
||||
color: '#06b6d4',
|
||||
}}
|
||||
>
|
||||
tick
|
||||
</span>
|
||||
</Typography>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
<Typography level='body2' alignSelf={'start'} mt={4} >
|
||||
Server URL
|
||||
</Typography>
|
||||
<Input
|
||||
margin='normal'
|
||||
required
|
||||
fullWidth
|
||||
id='serverURL'
|
||||
name='serverURL'
|
||||
autoFocus
|
||||
value={serverURL}
|
||||
onChange={e => {
|
||||
setServerURL(e.target.value)
|
||||
}}
|
||||
/>
|
||||
|
||||
<Typography mt={1} level='body-xs'>
|
||||
Change the server URL to connect to a different server, such as your own self-hosted Donetick server.
|
||||
</Typography>
|
||||
<Typography mt={1} level='body-xs'>
|
||||
Please ensure to include the protocol (http:// or https://) and the port number if necessary (default Donetick port is 2021).
|
||||
</Typography>
|
||||
<Button
|
||||
fullWidth
|
||||
size='lg'
|
||||
variant='solid'
|
||||
sx={{
|
||||
width: '100%',
|
||||
mt: 3,
|
||||
mb: 2,
|
||||
border: 'moccasin',
|
||||
borderRadius: '8px',
|
||||
}}
|
||||
|
||||
onClick={() => {
|
||||
if (serverURL === '') {
|
||||
Preferences.set({ key: 'customServerUrl', value: API_URL }).then(() => {
|
||||
Navigate('/login')
|
||||
})
|
||||
return
|
||||
}
|
||||
if (!isValidServerURL()){
|
||||
setError('Invalid server URL')
|
||||
return
|
||||
}
|
||||
Preferences.set({ key: 'customServerUrl', value: serverURL }).then(() => {
|
||||
Navigate('/login')
|
||||
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
<Button
|
||||
fullWidth
|
||||
size='lg'
|
||||
variant='soft'
|
||||
color='danger'
|
||||
|
||||
sx={{
|
||||
width: '100%',
|
||||
|
||||
mb: 2,
|
||||
border: 'moccasin',
|
||||
borderRadius: '8px',
|
||||
}}
|
||||
|
||||
onClick={() => {
|
||||
Preferences.set({ key: 'customServerUrl', value: API_URL }).then(() => {
|
||||
Navigate('/login')
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
>
|
||||
Cancel and Reset
|
||||
</Button>
|
||||
</Sheet>
|
||||
</Box>
|
||||
<Snackbar
|
||||
open={error !== null}
|
||||
onClose={() => setError(null)}
|
||||
autoHideDuration={3000}
|
||||
message={error}
|
||||
>
|
||||
{error}
|
||||
</Snackbar>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
||||
export default LoginSettings
|
|
@ -5,6 +5,7 @@ import {
|
|||
Button,
|
||||
Container,
|
||||
Divider,
|
||||
IconButton,
|
||||
Input,
|
||||
Sheet,
|
||||
Snackbar,
|
||||
|
@ -17,7 +18,13 @@ import { LoginSocialGoogle } from 'reactjs-social-login'
|
|||
import { API_URL, GOOGLE_CLIENT_ID, REDIRECT_URL } from '../../Config'
|
||||
import { UserContext } from '../../contexts/UserContext'
|
||||
import Logo from '../../Logo'
|
||||
import { GetUserProfile } from '../../utils/Fetcher'
|
||||
import { GetUserProfile, login } from '../../utils/Fetcher'
|
||||
import { GoogleAuth } from '@codetrix-studio/capacitor-google-auth';
|
||||
import { Capacitor } from '@capacitor/core'
|
||||
import { Settings } from '@mui/icons-material'
|
||||
|
||||
|
||||
|
||||
const LoginView = () => {
|
||||
const { userProfile, setUserProfile } = React.useContext(UserContext)
|
||||
const [username, setUsername] = React.useState('')
|
||||
|
@ -26,14 +33,8 @@ const LoginView = () => {
|
|||
const Navigate = useNavigate()
|
||||
const handleSubmit = async e => {
|
||||
e.preventDefault()
|
||||
|
||||
fetch(`${API_URL}/auth/login`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ username, password }),
|
||||
})
|
||||
login(username, password)
|
||||
|
||||
.then(response => {
|
||||
if (response.status === 200) {
|
||||
return response.json().then(data => {
|
||||
|
@ -71,7 +72,7 @@ const LoginView = () => {
|
|||
provider: provider,
|
||||
token:
|
||||
data['access_token'] || // data["access_token"] is for Google
|
||||
data['accessToken'], // data["accessToken"] is for Facebook
|
||||
data['accessToken'], // data["accessToken"] is for Google Capacitor
|
||||
data: data,
|
||||
}),
|
||||
}).then(response => {
|
||||
|
@ -140,6 +141,20 @@ const LoginView = () => {
|
|||
boxShadow: 'md',
|
||||
}}
|
||||
>
|
||||
<IconButton
|
||||
// on top right of the screen:
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: 2,
|
||||
right: 2,
|
||||
color: 'black',
|
||||
}}
|
||||
onClick={() => {
|
||||
Navigate('/login/settings')
|
||||
|
||||
}
|
||||
}
|
||||
> <Settings /></IconButton>
|
||||
{/* <img
|
||||
src='/src/assets/logo.svg'
|
||||
alt='logo'
|
||||
|
@ -285,7 +300,7 @@ const LoginView = () => {
|
|||
</>
|
||||
)}
|
||||
<Divider> or </Divider>
|
||||
|
||||
{!Capacitor.isNativePlatform() && (
|
||||
<Box sx={{ width: '100%' }}>
|
||||
<LoginSocialGoogle
|
||||
client_id={GOOGLE_CLIENT_ID}
|
||||
|
@ -320,7 +335,32 @@ const LoginView = () => {
|
|||
</div>
|
||||
</Button>
|
||||
</LoginSocialGoogle>
|
||||
</Box> )}
|
||||
|
||||
{Capacitor.isNativePlatform() && (
|
||||
<Box sx={{ width: '100%' }}>
|
||||
<Button fullWidth variant='soft' size='lg' sx={{ mt: 3, mb: 2 }}
|
||||
onClick={()=>{
|
||||
GoogleAuth.initialize({
|
||||
clientId: import.meta.env.VITE_APP_GOOGLE_CLIENT_ID,
|
||||
scopes: ['profile', 'email','openid'],
|
||||
grantOfflineAccess: true,
|
||||
});
|
||||
GoogleAuth.signIn().then((user) => {
|
||||
console.log("Google user", user);
|
||||
|
||||
loggedWithProvider("google", user.authentication)
|
||||
});
|
||||
|
||||
}}>
|
||||
<div className='flex gap-2'>
|
||||
<GoogleIcon />
|
||||
Continue with Google
|
||||
</div>
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
|
||||
<Button
|
||||
onClick={() => {
|
||||
|
|
|
@ -15,6 +15,7 @@ import { useNavigate, useSearchParams } from 'react-router-dom'
|
|||
|
||||
import { API_URL } from '../../Config'
|
||||
import Logo from '../../Logo'
|
||||
import { ChangePassword } from '../../utils/Fetcher'
|
||||
|
||||
const UpdatePasswordView = () => {
|
||||
const navigate = useNavigate()
|
||||
|
@ -52,17 +53,8 @@ const UpdatePasswordView = () => {
|
|||
return
|
||||
}
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${API_URL}/auth/password?c=${verifiticationCode}`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ password: password }),
|
||||
},
|
||||
)
|
||||
|
||||
const response = await ChangePassword(verifiticationCode, password)
|
||||
|
||||
if (response.ok) {
|
||||
setUpdateStatusOk(true)
|
||||
// wait 3 seconds and then redirect to login:
|
||||
|
|
|
@ -9,6 +9,7 @@ import {
|
|||
Divider,
|
||||
FormControl,
|
||||
FormHelperText,
|
||||
FormLabel,
|
||||
Input,
|
||||
List,
|
||||
ListItem,
|
||||
|
@ -20,12 +21,14 @@ import {
|
|||
Sheet,
|
||||
Snackbar,
|
||||
Stack,
|
||||
Switch,
|
||||
Typography,
|
||||
} from '@mui/joy'
|
||||
import moment from 'moment'
|
||||
import { useContext, useEffect, useState } from 'react'
|
||||
import { useNavigate, useParams } from 'react-router-dom'
|
||||
import { UserContext } from '../../contexts/UserContext'
|
||||
import { getTextColorFromBackgroundColor } from '../../utils/Colors.jsx'
|
||||
import {
|
||||
CreateChore,
|
||||
DeleteChore,
|
||||
|
@ -36,7 +39,6 @@ import {
|
|||
SaveChore,
|
||||
} from '../../utils/Fetcher'
|
||||
import { isPlusAccount } from '../../utils/Helpers'
|
||||
import { getTextColorFromBackgroundColor } from '../../utils/LabelColors'
|
||||
import { useLabels } from '../Labels/LabelQueries'
|
||||
import ConfirmationModal from '../Modals/Inputs/ConfirmationModal'
|
||||
import LabelModal from '../Modals/Inputs/LabelModal'
|
||||
|
@ -72,6 +74,7 @@ const ChoreEdit = () => {
|
|||
const [frequencyMetadata, setFrequencyMetadata] = useState({})
|
||||
const [labels, setLabels] = useState([])
|
||||
const [labelsV2, setLabelsV2] = useState([])
|
||||
const [points, setPoints] = useState(-1)
|
||||
const [allUserThings, setAllUserThings] = useState([])
|
||||
const [thingTrigger, setThingTrigger] = useState(null)
|
||||
const [isThingValid, setIsThingValid] = useState(false)
|
||||
|
@ -196,6 +199,7 @@ const ChoreEdit = () => {
|
|||
labelsV2: labelsV2,
|
||||
notificationMetadata: notificationMetadata,
|
||||
thingTrigger: thingTrigger,
|
||||
points: points < 0 ? null : points,
|
||||
}
|
||||
let SaveFunction = CreateChore
|
||||
if (choreId > 0) {
|
||||
|
@ -247,6 +251,9 @@ const ChoreEdit = () => {
|
|||
setFrequency(data.res.frequency)
|
||||
|
||||
setNotificationMetadata(JSON.parse(data.res.notificationMetadata))
|
||||
setPoints(
|
||||
data.res.points && data.res.points > -1 ? data.res.points : -1,
|
||||
)
|
||||
// setLabels(data.res.labels ? data.res.labels.split(',') : [])
|
||||
|
||||
setLabelsV2(data.res.labelsV2)
|
||||
|
@ -758,23 +765,6 @@ const ChoreEdit = () => {
|
|||
<Typography level='h5'>
|
||||
Things to remember about this chore or to tag it
|
||||
</Typography>
|
||||
{/* <FreeSoloCreateOption
|
||||
options={[...labels, 'test']}
|
||||
selected={labels}
|
||||
onSelectChange={changes => {
|
||||
const newLabels = []
|
||||
changes.map(change => {
|
||||
// if type is string :
|
||||
if (typeof change === 'string') {
|
||||
// add the change to the labels array:
|
||||
newLabels.push(change)
|
||||
} else {
|
||||
newLabels.push(change.inputValue)
|
||||
}
|
||||
})
|
||||
setLabels(newLabels)
|
||||
}}
|
||||
/> */}
|
||||
<Select
|
||||
multiple
|
||||
onChange={(event, newValue) => {
|
||||
|
@ -839,37 +829,80 @@ const ChoreEdit = () => {
|
|||
Add New Label
|
||||
</MenuItem>
|
||||
</Select>
|
||||
{/* <Card>
|
||||
<List
|
||||
orientation='horizontal'
|
||||
wrap
|
||||
sx={{
|
||||
'--List-gap': '8px',
|
||||
'--ListItem-radius': '20px',
|
||||
}}
|
||||
>
|
||||
{labels?.map((label, index) => (
|
||||
<ListItem key={label}>
|
||||
<Chip
|
||||
onClick={() => {
|
||||
setLabels(labels.filter(l => l !== label))
|
||||
}}
|
||||
checked={true}
|
||||
overlay
|
||||
variant='soft'
|
||||
color='primary'
|
||||
size='lg'
|
||||
endDecorator={<Cancel />}
|
||||
>
|
||||
{label}
|
||||
</Chip>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
</Card> */}
|
||||
</Box>
|
||||
|
||||
<Box mt={2}>
|
||||
<Typography level='h4' gutterBottom>
|
||||
Others :
|
||||
</Typography>
|
||||
|
||||
<FormControl
|
||||
orientation='horizontal'
|
||||
sx={{ width: 400, justifyContent: 'space-between' }}
|
||||
>
|
||||
<div>
|
||||
<FormLabel>Assign Points</FormLabel>
|
||||
<FormHelperText sx={{ mt: 0 }}>
|
||||
Assign points to this task and user will earn points when they
|
||||
completed it
|
||||
</FormHelperText>
|
||||
</div>
|
||||
<Switch
|
||||
checked={points > -1}
|
||||
onClick={event => {
|
||||
event.preventDefault()
|
||||
if (points > -1) {
|
||||
setPoints(-1)
|
||||
} else {
|
||||
setPoints(1)
|
||||
}
|
||||
}}
|
||||
color={points !== -1 ? 'success' : 'neutral'}
|
||||
variant={points !== -1 ? 'solid' : 'outlined'}
|
||||
// endDecorator={points !== -1 ? 'On' : 'Off'}
|
||||
slotProps={{
|
||||
endDecorator: {
|
||||
sx: {
|
||||
minWidth: 24,
|
||||
},
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
{points != -1 && (
|
||||
<Card variant='outlined'>
|
||||
<Box
|
||||
sx={{
|
||||
mt: 0,
|
||||
ml: 4,
|
||||
}}
|
||||
>
|
||||
<Typography level='body-sm'>Points:</Typography>
|
||||
|
||||
<Input
|
||||
type='number'
|
||||
value={points}
|
||||
sx={{ maxWidth: 100 }}
|
||||
// add min points is 0 and max is 1000
|
||||
slotProps={{
|
||||
input: {
|
||||
min: 0,
|
||||
max: 1000,
|
||||
},
|
||||
}}
|
||||
placeholder='Points'
|
||||
onChange={e => {
|
||||
setPoints(parseInt(e.target.value))
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</Card>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{choreId > 0 && (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5, mt: 3 }}>
|
||||
<Sheet
|
||||
sx={{
|
||||
p: 2,
|
||||
|
@ -900,6 +933,7 @@ const ChoreEdit = () => {
|
|||
</Sheet>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Divider sx={{ mb: 9 }} />
|
||||
|
||||
{/* <Box mt={2} alignSelf={'flex-start'} display='flex' gap={2}>
|
||||
|
|
|
@ -36,6 +36,7 @@ import { Divider } from '@mui/material'
|
|||
import moment from 'moment'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useNavigate, useParams, useSearchParams } from 'react-router-dom'
|
||||
import { getTextColorFromBackgroundColor } from '../../utils/Colors.jsx'
|
||||
import {
|
||||
GetAllUsers,
|
||||
GetChoreDetailById,
|
||||
|
@ -43,7 +44,6 @@ import {
|
|||
SkipChore,
|
||||
UpdateChorePriority,
|
||||
} from '../../utils/Fetcher'
|
||||
import { getTextColorFromBackgroundColor } from '../../utils/LabelColors'
|
||||
import Priorities from '../../utils/Priorities'
|
||||
import ConfirmationModal from '../Modals/Inputs/ConfirmationModal'
|
||||
const IconCard = styled('div')({
|
||||
|
@ -190,7 +190,7 @@ const ChoreView = () => {
|
|||
}, 1000)
|
||||
|
||||
const id = setTimeout(() => {
|
||||
MarkChoreComplete(choreId, note, completedDate)
|
||||
MarkChoreComplete(choreId, note, completedDate, null)
|
||||
.then(resp => {
|
||||
if (resp.ok) {
|
||||
return resp.json().then(data => {
|
||||
|
|
|
@ -42,18 +42,18 @@ import {
|
|||
import moment from 'moment'
|
||||
import React, { useEffect } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { API_URL } from '../../Config'
|
||||
import { UserContext } from '../../contexts/UserContext'
|
||||
import { getTextColorFromBackgroundColor } from '../../utils/Colors.jsx'
|
||||
import {
|
||||
ArchiveChore,
|
||||
DeleteChore,
|
||||
MarkChoreComplete,
|
||||
SkipChore,
|
||||
UnArchiveChore,
|
||||
UpdateChoreAssignee,
|
||||
UpdateDueDate,
|
||||
} from '../../utils/Fetcher'
|
||||
import { getTextColorFromBackgroundColor } from '../../utils/LabelColors'
|
||||
import Priorities from '../../utils/Priorities'
|
||||
import { Fetch } from '../../utils/TokenManager'
|
||||
import ConfirmationModal from '../Modals/Inputs/ConfirmationModal'
|
||||
import DateModal from '../Modals/Inputs/DateModal'
|
||||
import SelectModal from '../Modals/Inputs/SelectModal'
|
||||
|
@ -127,11 +127,8 @@ const ChoreCard = ({
|
|||
cancelText: 'Cancel',
|
||||
message: 'Are you sure you want to delete this chore?',
|
||||
onClose: isConfirmed => {
|
||||
console.log('isConfirmed', isConfirmed)
|
||||
if (isConfirmed === true) {
|
||||
Fetch(`${API_URL}/chores/${chore.id}`, {
|
||||
method: 'DELETE',
|
||||
}).then(response => {
|
||||
DeleteChore(chore.id).then(response => {
|
||||
if (response.ok) {
|
||||
onChoreRemove(chore)
|
||||
}
|
||||
|
@ -181,7 +178,7 @@ const ChoreCard = ({
|
|||
}, 1000)
|
||||
|
||||
const id = setTimeout(() => {
|
||||
MarkChoreComplete(chore.id)
|
||||
MarkChoreComplete(chore.id, null, null, null)
|
||||
.then(resp => {
|
||||
if (resp.ok) {
|
||||
return resp.json().then(data => {
|
||||
|
@ -206,16 +203,7 @@ const ChoreCard = ({
|
|||
alert('Please select a performer')
|
||||
return
|
||||
}
|
||||
Fetch(`${API_URL}/chores/${chore.id}/dueDate`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
dueDate: newDate ? new Date(newDate).toISOString() : null,
|
||||
UpdatedBy: activeUserId,
|
||||
}),
|
||||
}).then(response => {
|
||||
UpdateDueDate.then(response => {
|
||||
if (response.ok) {
|
||||
response.json().then(data => {
|
||||
const newChore = data.res
|
||||
|
@ -230,17 +218,12 @@ const ChoreCard = ({
|
|||
alert('Please select a performer')
|
||||
return
|
||||
}
|
||||
Fetch(
|
||||
`${API_URL}/chores/${chore.id}/do?completedDate=${new Date(
|
||||
newDate,
|
||||
).toISOString()}`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({}),
|
||||
},
|
||||
|
||||
MarkChoreComplete(
|
||||
chore.id,
|
||||
null,
|
||||
new Date(newDate).toISOString(),
|
||||
null,
|
||||
).then(response => {
|
||||
if (response.ok) {
|
||||
response.json().then(data => {
|
||||
|
@ -261,15 +244,7 @@ const ChoreCard = ({
|
|||
})
|
||||
}
|
||||
const handleCompleteWithNote = note => {
|
||||
Fetch(`${API_URL}/chores/${chore.id}/do`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
note: note,
|
||||
}),
|
||||
}).then(response => {
|
||||
MarkChoreComplete(chore.id, note, null, null).then(response => {
|
||||
if (response.ok) {
|
||||
response.json().then(data => {
|
||||
const newChore = data.res
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import { Chip, Menu, MenuItem, Typography } from '@mui/joy'
|
||||
import IconButton from '@mui/joy/IconButton'
|
||||
import React, { useEffect, useRef, useState } from 'react'
|
||||
import { getTextColorFromBackgroundColor } from '../../utils/LabelColors'
|
||||
import { getTextColorFromBackgroundColor } from '../../utils/Colors.jsx'
|
||||
|
||||
const IconButtonWithMenu = ({
|
||||
key,
|
||||
|
|
128
src/views/Chores/LocalNotificationScheduler.js
Normal file
128
src/views/Chores/LocalNotificationScheduler.js
Normal file
|
@ -0,0 +1,128 @@
|
|||
import { Capacitor } from '@capacitor/core';
|
||||
import { LocalNotifications } from '@capacitor/local-notifications';
|
||||
import { Preferences } from '@capacitor/preferences';
|
||||
|
||||
const getNotificationPreferences = async () => {
|
||||
const ret = await Preferences.get({ key: 'notificationPreferences' });
|
||||
return JSON.parse(ret.value);
|
||||
};
|
||||
|
||||
const canScheduleNotification = () => {
|
||||
if (Capacitor.isNativePlatform() === false) {
|
||||
return false;
|
||||
}
|
||||
const notificationPreferences = getNotificationPreferences();
|
||||
if (notificationPreferences["granted"] === false) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
const scheduleChoreNotification = async (chores, userProfile,allPerformers) => {
|
||||
// for each chore will create local notification:
|
||||
const notifications = [];
|
||||
const now = new Date()
|
||||
|
||||
const devicePreferences = await getNotificationPreferences();
|
||||
|
||||
for (let i = 0; i < chores.length; i++) {
|
||||
|
||||
const chore = chores[i];
|
||||
const chorePreferences = JSON.parse(chore.notificationMetadata)
|
||||
if ( chore.notification ===false || chore.nextDueDate === null) {
|
||||
continue;
|
||||
}
|
||||
scheduleDueNotification(chore, userProfile, allPerformers,chorePreferences,devicePreferences, notifications)
|
||||
schedulePreDueNotification(chore, userProfile, allPerformers,chorePreferences, devicePreferences,notifications)
|
||||
scheduleNaggingNotification(chore, userProfile, allPerformers,chorePreferences,devicePreferences, notifications)
|
||||
|
||||
|
||||
}
|
||||
LocalNotifications.schedule({
|
||||
notifications,
|
||||
});
|
||||
}
|
||||
|
||||
const scheduleDueNotification = (chore, userProfile, allPerformers,chorePreferences,devicePreferences, notifications) => {
|
||||
|
||||
if (devicePreferences['dueNotification'] !== true || chorePreferences['dueDate'] !== true){
|
||||
return
|
||||
}
|
||||
|
||||
const nextDueDate = new Date(chore.nextDueDate)
|
||||
const diff = nextDueDate - now
|
||||
|
||||
if (diff < 0) {
|
||||
return
|
||||
}
|
||||
|
||||
const notification = {
|
||||
title: `${chore.name} is due! 🕒`,
|
||||
body: userProfile.id === chore.assignedTo ? `It's assigned to you!` : `It is ${allPerformers[chore.assignedTo].name}'s turn`,
|
||||
id: chore.id,
|
||||
allowWhileIdle: true,
|
||||
schedule: {
|
||||
at: new Date(chore.nextDueDate),
|
||||
},
|
||||
extra: {
|
||||
choreId: chore.id,
|
||||
},
|
||||
};
|
||||
notifications.push(notification);
|
||||
}
|
||||
|
||||
const schedulePreDueNotification = (chore, userProfile, allPerformers,chorePreferences,devicePreferences, notifications) => {
|
||||
if (devicePreferences['preDueNotification'] !== true || chorePreferences['preDue'] !== true){
|
||||
return
|
||||
}
|
||||
|
||||
const nextDueDate = new Date(chore.nextDueDate)
|
||||
const diff = nextDueDate - now
|
||||
|
||||
if (diff < 0 || userProfile.id !== chore.assignedTo) {
|
||||
return
|
||||
}
|
||||
|
||||
const notification = {
|
||||
title: `${chore.name} is due soon! 🕒`,
|
||||
body: `is due at ${nextDueDate.toLocaleTimeString()}`,
|
||||
id: chore.id,
|
||||
allowWhileIdle: true,
|
||||
schedule: {
|
||||
// 1 hour before
|
||||
at: new Date(nextDueDate - 60 * 60 * 1000),
|
||||
},
|
||||
extra: {
|
||||
choreId: chore.id,
|
||||
},
|
||||
};
|
||||
notifications.push(notification);
|
||||
}
|
||||
const scheduleNaggingNotification = (chore, userProfile, allPerformers,chorePreferences,devicePreferences, notifications) => {
|
||||
if (devicePreferences['naggingNotification'] === false || chorePreferences.nagging !== true){
|
||||
return
|
||||
}
|
||||
const nextDueDate = new Date(chore.nextDueDate)
|
||||
const diff = nextDueDate - now
|
||||
|
||||
if (diff > 0 || userProfile.id !== chore.assignedTo) {
|
||||
return
|
||||
}
|
||||
|
||||
const notification = {
|
||||
title: `${chore.name} is overdue! 🕒`,
|
||||
body: `❗ It was due at ${nextDueDate.toLocaleTimeString()}`,
|
||||
id: chore.id,
|
||||
allowWhileIdle: true,
|
||||
schedule: {
|
||||
at: new Date(chore.nextDueDate),
|
||||
},
|
||||
extra: {
|
||||
choreId: chore.id,
|
||||
},
|
||||
};
|
||||
notifications.push(notification);
|
||||
}
|
||||
|
||||
export{ scheduleChoreNotification, canScheduleNotification }
|
|
@ -34,6 +34,7 @@ import { useChores } from '../../queries/ChoreQueries'
|
|||
import {
|
||||
GetAllUsers,
|
||||
GetArchivedChores,
|
||||
GetChores,
|
||||
GetUserProfile,
|
||||
} from '../../utils/Fetcher'
|
||||
import Priorities from '../../utils/Priorities'
|
||||
|
@ -42,6 +43,13 @@ import { useLabels } from '../Labels/LabelQueries'
|
|||
import ChoreCard from './ChoreCard'
|
||||
import IconButtonWithMenu from './IconButtonWithMenu'
|
||||
|
||||
import { ChoresGrouper } from '../../utils/Chores'
|
||||
import {
|
||||
canScheduleNotification,
|
||||
scheduleChoreNotification,
|
||||
} from './LocalNotificationScheduler'
|
||||
import NotificationAccessSnackbar from './NotificationAccessSnackbar'
|
||||
|
||||
const MyChores = () => {
|
||||
const { userProfile, setUserProfile } = useContext(UserContext)
|
||||
const [isSnackbarOpen, setIsSnackbarOpen] = useState(false)
|
||||
|
@ -54,7 +62,6 @@ const MyChores = () => {
|
|||
const [selectedChoreSection, setSelectedChoreSection] = useState('due_date')
|
||||
const [openChoreSections, setOpenChoreSections] = useState({})
|
||||
const [searchTerm, setSearchTerm] = useState('')
|
||||
const [activeUserId, setActiveUserId] = useState(0)
|
||||
const [performers, setPerformers] = useState([])
|
||||
const [anchorEl, setAnchorEl] = useState(null)
|
||||
const menuRef = useRef(null)
|
||||
|
@ -89,148 +96,54 @@ const MyChores = () => {
|
|||
return aDueDate - bDueDate // Sort ascending by due date
|
||||
}
|
||||
|
||||
const sectionSorter = (t, chores) => {
|
||||
// sort by priority then due date:
|
||||
chores.sort((a, b) => {
|
||||
// no priority is lowest priority:
|
||||
if (a.priority === 0) {
|
||||
return 1
|
||||
}
|
||||
if (a.priority !== b.priority) {
|
||||
return a.priority - b.priority
|
||||
}
|
||||
if (a.nextDueDate === null) {
|
||||
return 1
|
||||
}
|
||||
if (b.nextDueDate === null) {
|
||||
return -1
|
||||
}
|
||||
return new Date(a.nextDueDate) - new Date(b.nextDueDate)
|
||||
})
|
||||
|
||||
var groups = []
|
||||
switch (t) {
|
||||
case 'due_date':
|
||||
var groupRaw = {
|
||||
Today: [],
|
||||
'In a week': [],
|
||||
'This month': [],
|
||||
Later: [],
|
||||
Overdue: [],
|
||||
Anytime: [],
|
||||
}
|
||||
chores.forEach(chore => {
|
||||
if (chore.nextDueDate === null) {
|
||||
groupRaw['Anytime'].push(chore)
|
||||
} else if (new Date(chore.nextDueDate) < new Date()) {
|
||||
groupRaw['Overdue'].push(chore)
|
||||
} else if (
|
||||
new Date(chore.nextDueDate).toDateString() ===
|
||||
new Date().toDateString()
|
||||
) {
|
||||
groupRaw['Today'].push(chore)
|
||||
} else if (
|
||||
new Date(chore.nextDueDate) <
|
||||
new Date(Date.now() + 7 * 24 * 60 * 60 * 1000) &&
|
||||
new Date(chore.nextDueDate) > new Date()
|
||||
) {
|
||||
groupRaw['In a week'].push(chore)
|
||||
} else if (
|
||||
new Date(chore.nextDueDate).getMonth() === new Date().getMonth()
|
||||
) {
|
||||
groupRaw['This month'].push(chore)
|
||||
} else {
|
||||
groupRaw['Later'].push(chore)
|
||||
}
|
||||
})
|
||||
groups = [
|
||||
{ name: 'Overdue', content: groupRaw['Overdue'] },
|
||||
{ name: 'Today', content: groupRaw['Today'] },
|
||||
{ name: 'In a week', content: groupRaw['In a week'] },
|
||||
{ name: 'This month', content: groupRaw['This month'] },
|
||||
{ name: 'Later', content: groupRaw['Later'] },
|
||||
{ name: 'Anytime', content: groupRaw['Anytime'] },
|
||||
]
|
||||
break
|
||||
case 'priority':
|
||||
groupRaw = {
|
||||
p1: [],
|
||||
p2: [],
|
||||
p3: [],
|
||||
p4: [],
|
||||
no_priority: [],
|
||||
}
|
||||
chores.forEach(chore => {
|
||||
switch (chore.priority) {
|
||||
case 1:
|
||||
groupRaw['p1'].push(chore)
|
||||
break
|
||||
case 2:
|
||||
groupRaw['p2'].push(chore)
|
||||
break
|
||||
case 3:
|
||||
groupRaw['p3'].push(chore)
|
||||
break
|
||||
case 4:
|
||||
groupRaw['p4'].push(chore)
|
||||
break
|
||||
default:
|
||||
groupRaw['no_priority'].push(chore)
|
||||
break
|
||||
}
|
||||
})
|
||||
groups = [
|
||||
{ name: 'Priority 1', content: groupRaw['p1'] },
|
||||
{ name: 'Priority 2', content: groupRaw['p2'] },
|
||||
{ name: 'Priority 3', content: groupRaw['p3'] },
|
||||
{ name: 'Priority 4', content: groupRaw['p4'] },
|
||||
{ name: 'No Priority', content: groupRaw['no_priority'] },
|
||||
]
|
||||
break
|
||||
case 'labels':
|
||||
groupRaw = {}
|
||||
var labels = {}
|
||||
chores.forEach(chore => {
|
||||
chore.labelsV2.forEach(label => {
|
||||
labels[label.id] = label
|
||||
if (groupRaw[label.id] === undefined) {
|
||||
groupRaw[label.id] = []
|
||||
}
|
||||
groupRaw[label.id].push(chore)
|
||||
})
|
||||
})
|
||||
groups = Object.keys(groupRaw).map(key => {
|
||||
return {
|
||||
name: labels[key].name,
|
||||
content: groupRaw[key],
|
||||
}
|
||||
})
|
||||
groups.sort((a, b) => {
|
||||
a.name < b.name ? 1 : -1
|
||||
})
|
||||
}
|
||||
return groups
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (userProfile === null) {
|
||||
GetUserProfile()
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
setUserProfile(data.res)
|
||||
Promise.all([GetChores(), GetAllUsers(), GetUserProfile()]).then(
|
||||
responses => {
|
||||
const [choresResponse, usersResponse, userProfileResponse] = responses
|
||||
if (!choresResponse.ok) {
|
||||
throw new Error(choresResponse.statusText)
|
||||
}
|
||||
if (!usersResponse.ok) {
|
||||
throw new Error(usersResponse.statusText)
|
||||
}
|
||||
if (!userProfileResponse.ok) {
|
||||
throw new Error(userProfileResponse.statusText)
|
||||
}
|
||||
Promise.all([
|
||||
choresResponse.json(),
|
||||
usersResponse.json(),
|
||||
userProfileResponse.json(),
|
||||
]).then(data => {
|
||||
const [choresData, usersData, userProfileData] = data
|
||||
setUserProfile(userProfileData.res)
|
||||
choresData.res.sort(choreSorter)
|
||||
setChores(choresData.res)
|
||||
setFilteredChores(choresData.res)
|
||||
setPerformers(usersData.res)
|
||||
if (canScheduleNotification()) {
|
||||
scheduleChoreNotification(
|
||||
choresData.res,
|
||||
userProfileData.res,
|
||||
usersData.res,
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
GetAllUsers()
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
setPerformers(data.res)
|
||||
})
|
||||
// GetAllUsers()
|
||||
// .then(response => response.json())
|
||||
// .then(data => {
|
||||
// setPerformers(data.res)
|
||||
// })
|
||||
// GetUserProfile().then(response => response.json()).then(data => {
|
||||
// setUserProfile(data.res)
|
||||
// })
|
||||
|
||||
const currentUser = JSON.parse(localStorage.getItem('user'))
|
||||
if (currentUser !== null) {
|
||||
setActiveUserId(currentUser.id)
|
||||
}
|
||||
// const currentUser = JSON.parse(localStorage.getItem('user'))
|
||||
// if (currentUser !== null) {
|
||||
// setActiveUserId(currentUser.id)
|
||||
// }
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
|
@ -238,7 +151,7 @@ const MyChores = () => {
|
|||
const sortedChores = choresData.res.sort(choreSorter)
|
||||
setChores(sortedChores)
|
||||
setFilteredChores(sortedChores)
|
||||
const sections = sectionSorter('due_date', sortedChores)
|
||||
const sections = ChoresGrouper('due_date', sortedChores)
|
||||
setChoreSections(sections)
|
||||
setOpenChoreSections(
|
||||
Object.keys(sections).reduce((acc, key) => {
|
||||
|
@ -326,7 +239,7 @@ const MyChores = () => {
|
|||
}
|
||||
setChores(newChores)
|
||||
setFilteredChores(newFilteredChores)
|
||||
setChoreSections(sectionSorter('due_date', newChores))
|
||||
setChoreSections(ChoresGrouper('due_date', newChores))
|
||||
|
||||
switch (event) {
|
||||
case 'completed':
|
||||
|
@ -357,7 +270,7 @@ const MyChores = () => {
|
|||
)
|
||||
setChores(newChores)
|
||||
setFilteredChores(newFilteredChores)
|
||||
setChoreSections(sectionSorter('due_date', newChores))
|
||||
setChoreSections(ChoresGrouper('due_date', newChores))
|
||||
}
|
||||
|
||||
const searchOptions = {
|
||||
|
@ -552,7 +465,7 @@ const MyChores = () => {
|
|||
]}
|
||||
selectedItem={selectedChoreSection}
|
||||
onItemSelect={selected => {
|
||||
const section = sectionSorter(selected.value, chores)
|
||||
const section = ChoresGrouper(selected.value, chores)
|
||||
setChoreSections(section)
|
||||
setSelectedChoreSection(selected.value)
|
||||
setFilteredChores(chores)
|
||||
|
@ -802,6 +715,7 @@ const MyChores = () => {
|
|||
>
|
||||
<Typography level='title-md'>{snackBarMessage}</Typography>
|
||||
</Snackbar>
|
||||
<NotificationAccessSnackbar />
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
|
87
src/views/Chores/NotificationAccessSnackbar.jsx
Normal file
87
src/views/Chores/NotificationAccessSnackbar.jsx
Normal file
|
@ -0,0 +1,87 @@
|
|||
import { Capacitor } from '@capacitor/core';
|
||||
import { Button, Snackbar, Stack, Typography } from '@mui/joy'
|
||||
import { Preferences } from '@capacitor/preferences';
|
||||
import { LocalNotifications } from '@capacitor/local-notifications';
|
||||
|
||||
import {React, useEffect, useState} from 'react';
|
||||
|
||||
const NotificationAccessSnackbar = () => {
|
||||
|
||||
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
if (!Capacitor.isNativePlatform()) {
|
||||
return null;
|
||||
}
|
||||
const getNotificationPreferences = async () => {
|
||||
const ret = await Preferences.get({ key: 'notificationPreferences' });
|
||||
return JSON.parse(ret.value);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
getNotificationPreferences().then((data) => {
|
||||
// if optOut is true then don't show the snackbar
|
||||
if(data?.optOut === true || data?.granted === true) {
|
||||
return;
|
||||
}
|
||||
setOpen(true);
|
||||
});
|
||||
}
|
||||
, []);
|
||||
|
||||
|
||||
|
||||
return (
|
||||
|
||||
<Snackbar
|
||||
// autoHideDuration={5000}
|
||||
variant="solid"
|
||||
color="primary"
|
||||
size="lg"
|
||||
invertedColors
|
||||
open={open}
|
||||
onClose={() => setOpen(false)}
|
||||
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
|
||||
sx={(theme) => ({
|
||||
background: `linear-gradient(45deg, ${theme.palette.primary[600]} 30%, ${theme.palette.primary[500]} 90%})`,
|
||||
maxWidth: 360,
|
||||
})}
|
||||
>
|
||||
<div>
|
||||
<Typography level="title-lg">Need Notification?</Typography>
|
||||
<Typography sx={{ mt: 1, mb: 2 }}>
|
||||
You need to enable permission to receive notifications, do you want to enable it?
|
||||
</Typography>
|
||||
<Stack direction="row" spacing={1}>
|
||||
<Button variant="solid" color="primary" onClick={() => {
|
||||
const notificationPreferences = { optOut: false };
|
||||
LocalNotifications.requestPermissions().then((resp) => {
|
||||
if (resp.display === 'granted') {
|
||||
notificationPreferences['granted'] = true;
|
||||
}
|
||||
})
|
||||
Preferences.set({ key: 'notificationPreferences', value: JSON.stringify(notificationPreferences) });
|
||||
setOpen(false);
|
||||
}}>
|
||||
Yes
|
||||
</Button>
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="primary"
|
||||
onClick={() => {
|
||||
const notificationPreferences = { optOut: true };
|
||||
Preferences.set({ key: 'notificationPreferences', value: JSON.stringify(notificationPreferences) });
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
No, Keep it Disabled
|
||||
</Button>
|
||||
</Stack>
|
||||
</div>
|
||||
</Snackbar>
|
||||
|
||||
)
|
||||
}
|
||||
|
||||
export default NotificationAccessSnackbar;
|
|
@ -27,7 +27,7 @@ import moment from 'moment'
|
|||
import { useEffect, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { API_URL } from '../Config'
|
||||
import { GetAllUsers } from '../utils/Fetcher'
|
||||
import { GetAllUsers, GetChores, MarkChoreComplete } from '../utils/Fetcher'
|
||||
import { Fetch } from '../utils/TokenManager'
|
||||
import DateModal from './Modals/Inputs/DateModal'
|
||||
// import moment from 'moment'
|
||||
|
@ -98,7 +98,7 @@ const ChoresOverview = () => {
|
|||
}
|
||||
useEffect(() => {
|
||||
// fetch chores:
|
||||
Fetch(`${API_URL}/chores/`)
|
||||
GetChores()
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
const filteredData = data.res.filter(
|
||||
|
@ -263,9 +263,8 @@ const ChoresOverview = () => {
|
|||
size='sm'
|
||||
// sx={{ borderRadius: '50%' }}
|
||||
onClick={() => {
|
||||
Fetch(`${API_URL}/chores/${chore.id}/do`, {
|
||||
method: 'POST',
|
||||
}).then(response => {
|
||||
MarkChoreComplete(chore.id,null,null,null)
|
||||
.then(response => {
|
||||
if (response.ok) {
|
||||
response.json().then(data => {
|
||||
const newChore = data.res
|
||||
|
@ -326,14 +325,8 @@ const ChoresOverview = () => {
|
|||
alert('Please select a performer')
|
||||
return
|
||||
}
|
||||
fetch(
|
||||
`${API_URL}/chores/${choreId}/do?performer=${activeUserId}&completedDate=${new Date(
|
||||
date,
|
||||
).toISOString()}`,
|
||||
{
|
||||
method: 'POST',
|
||||
},
|
||||
).then(response => {
|
||||
MarkChoreComplete(choreId, null, date, activeUserId)
|
||||
.then(response => {
|
||||
if (response.ok) {
|
||||
response.json().then(data => {
|
||||
const newChore = data.res
|
||||
|
|
|
@ -18,6 +18,7 @@ import { API_URL } from '../../Config'
|
|||
import {
|
||||
DeleteChoreHistory,
|
||||
GetAllCircleMembers,
|
||||
GetChoreHistory,
|
||||
UpdateChoreHistory,
|
||||
} from '../../utils/Fetcher'
|
||||
import { Fetch } from '../../utils/TokenManager'
|
||||
|
@ -40,7 +41,7 @@ const ChoreHistory = () => {
|
|||
setIsLoading(true) // Start loading
|
||||
|
||||
Promise.all([
|
||||
Fetch(`${API_URL}/chores/${choreId}/history`).then(res => res.json()),
|
||||
GetChoreHistory(choreId).then(res => res.json()),
|
||||
GetAllCircleMembers().then(res => res.json()),
|
||||
])
|
||||
.then(([historyData, usersData]) => {
|
||||
|
|
|
@ -11,6 +11,49 @@ import {
|
|||
} from '@mui/joy'
|
||||
import moment from 'moment'
|
||||
|
||||
export const getCompletedChip = historyEntry => {
|
||||
var text = 'No Due Date'
|
||||
var color = 'info'
|
||||
var icon = <CalendarViewDay />
|
||||
// if completed few hours +-6 hours
|
||||
if (
|
||||
historyEntry.dueDate &&
|
||||
historyEntry.completedAt > historyEntry.dueDate - 1000 * 60 * 60 * 6 &&
|
||||
historyEntry.completedAt < historyEntry.dueDate + 1000 * 60 * 60 * 6
|
||||
) {
|
||||
text = 'On Time'
|
||||
color = 'success'
|
||||
icon = <Check />
|
||||
} else if (
|
||||
historyEntry.dueDate &&
|
||||
historyEntry.completedAt < historyEntry.dueDate
|
||||
) {
|
||||
text = 'On Time'
|
||||
color = 'success'
|
||||
icon = <Check />
|
||||
}
|
||||
|
||||
// if completed after due date then it's late
|
||||
else if (
|
||||
historyEntry.dueDate &&
|
||||
historyEntry.completedAt > historyEntry.dueDate
|
||||
) {
|
||||
text = 'Late'
|
||||
color = 'warning'
|
||||
icon = <Timelapse />
|
||||
} else {
|
||||
text = 'No Due Date'
|
||||
color = 'neutral'
|
||||
icon = <CalendarViewDay />
|
||||
}
|
||||
|
||||
return (
|
||||
<Chip startDecorator={icon} color={color}>
|
||||
{text}
|
||||
</Chip>
|
||||
)
|
||||
}
|
||||
|
||||
const HistoryCard = ({
|
||||
allHistory,
|
||||
performers,
|
||||
|
@ -38,49 +81,6 @@ const HistoryCard = ({
|
|||
return `${timeValue} ${unit}${timeValue !== 1 ? 's' : ''}`
|
||||
}
|
||||
|
||||
const getCompletedChip = historyEntry => {
|
||||
var text = 'No Due Date'
|
||||
var color = 'info'
|
||||
var icon = <CalendarViewDay />
|
||||
// if completed few hours +-6 hours
|
||||
if (
|
||||
historyEntry.dueDate &&
|
||||
historyEntry.completedAt > historyEntry.dueDate - 1000 * 60 * 60 * 6 &&
|
||||
historyEntry.completedAt < historyEntry.dueDate + 1000 * 60 * 60 * 6
|
||||
) {
|
||||
text = 'On Time'
|
||||
color = 'success'
|
||||
icon = <Check />
|
||||
} else if (
|
||||
historyEntry.dueDate &&
|
||||
historyEntry.completedAt < historyEntry.dueDate
|
||||
) {
|
||||
text = 'On Time'
|
||||
color = 'success'
|
||||
icon = <Check />
|
||||
}
|
||||
|
||||
// if completed after due date then it's late
|
||||
else if (
|
||||
historyEntry.dueDate &&
|
||||
historyEntry.completedAt > historyEntry.dueDate
|
||||
) {
|
||||
text = 'Late'
|
||||
color = 'warning'
|
||||
icon = <Timelapse />
|
||||
} else {
|
||||
text = 'No Due Date'
|
||||
color = 'neutral'
|
||||
icon = <CalendarViewDay />
|
||||
}
|
||||
|
||||
return (
|
||||
<Chip startDecorator={icon} color={color}>
|
||||
{text}
|
||||
</Chip>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<ListItem sx={{ gap: 1.5, alignItems: 'flex-start' }} onClick={onClick}>
|
||||
|
|
|
@ -1,4 +1,3 @@
|
|||
import React, { useEffect, useState } from 'react'
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
|
@ -10,9 +9,11 @@ import {
|
|||
Select,
|
||||
Typography,
|
||||
} from '@mui/joy'
|
||||
import { useQueryClient, useMutation } from 'react-query'
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
import { useMutation, useQueryClient } from 'react-query'
|
||||
import LABEL_COLORS from '../../../utils/Colors.jsx'
|
||||
import { CreateLabel, UpdateLabel } from '../../../utils/Fetcher'
|
||||
import LABEL_COLORS from '../../../utils/LabelColors'
|
||||
import { useLabels } from '../../Labels/LabelQueries'
|
||||
|
||||
function LabelModal({ isOpen, onClose, label }) {
|
||||
|
|
|
@ -1,10 +1,94 @@
|
|||
import { Button, Divider, Input, Option, Select, Typography } from '@mui/joy'
|
||||
import { useContext, useState } from 'react'
|
||||
import { Capacitor } from '@capacitor/core'
|
||||
import { LocalNotifications } from '@capacitor/local-notifications'
|
||||
import { Preferences } from '@capacitor/preferences'
|
||||
import { Close } from '@mui/icons-material'
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
Divider,
|
||||
FormControl,
|
||||
FormHelperText,
|
||||
FormLabel,
|
||||
IconButton,
|
||||
Input,
|
||||
Option,
|
||||
Select,
|
||||
Snackbar,
|
||||
Switch,
|
||||
Typography,
|
||||
} from '@mui/joy'
|
||||
import React, { useContext, useEffect, useState } from 'react'
|
||||
import { UserContext } from '../../contexts/UserContext'
|
||||
import { UpdateNotificationTarget } from '../../utils/Fetcher'
|
||||
import {
|
||||
GetUserProfile,
|
||||
UpdateNotificationTarget,
|
||||
UpdateUserDetails,
|
||||
} from '../../utils/Fetcher'
|
||||
|
||||
const NotificationSetting = () => {
|
||||
const [isSnackbarOpen, setIsSnackbarOpen] = useState(false)
|
||||
const { userProfile, setUserProfile } = useContext(UserContext)
|
||||
useEffect(() => {
|
||||
if (!userProfile) {
|
||||
GetUserProfile().then(resp => {
|
||||
resp.json().then(data => {
|
||||
setUserProfile(data.res)
|
||||
setChatID(data.res.chatID)
|
||||
})
|
||||
})
|
||||
}
|
||||
}, [])
|
||||
const getNotificationPreferences = async () => {
|
||||
const ret = await Preferences.get({ key: 'notificationPreferences' })
|
||||
return JSON.parse(ret.value)
|
||||
}
|
||||
const setNotificationPreferences = async value => {
|
||||
if (value.granted === false) {
|
||||
await Preferences.set({
|
||||
key: 'notificationPreferences',
|
||||
value: JSON.stringify({ granted: false }),
|
||||
})
|
||||
return
|
||||
}
|
||||
const currentSettings = await getNotificationPreferences()
|
||||
await Preferences.set({
|
||||
key: 'notificationPreferences',
|
||||
value: JSON.stringify({ ...currentSettings, ...value }),
|
||||
})
|
||||
}
|
||||
|
||||
const getPushNotificationPreferences = async () => {
|
||||
const ret = await Preferences.get({ key: 'pushNotificationPreferences' })
|
||||
return JSON.parse(ret.value)
|
||||
}
|
||||
|
||||
const setPushNotificationPreferences = async value => {
|
||||
await Preferences.set({
|
||||
key: 'pushNotificationPreferences',
|
||||
value: JSON.stringify(value),
|
||||
})
|
||||
}
|
||||
|
||||
const [deviceNotification, setDeviceNotification] = useState(false)
|
||||
|
||||
const [dueNotification, setDueNotification] = useState(true)
|
||||
const [preDueNotification, setPreDueNotification] = useState(false)
|
||||
const [naggingNotification, setNaggingNotification] = useState(false)
|
||||
const [pushNotification, setPushNotification] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
getNotificationPreferences().then(resp => {
|
||||
setDeviceNotification(resp.granted)
|
||||
setDueNotification(resp.dueNotification)
|
||||
setPreDueNotification(resp.preDueNotification)
|
||||
setNaggingNotification(resp.naggingNotification)
|
||||
})
|
||||
getPushNotificationPreferences().then(resp => {
|
||||
setPushNotification(resp.granted)
|
||||
})
|
||||
}, [])
|
||||
|
||||
const [notificationTarget, setNotificationTarget] = useState(
|
||||
userProfile?.notification_target
|
||||
? String(userProfile.notification_target.type)
|
||||
|
@ -57,90 +141,344 @@ const NotificationSetting = () => {
|
|||
}
|
||||
return (
|
||||
<div className='grid gap-4 py-4' id='notifications'>
|
||||
<Typography level='h3'>Notification Settings</Typography>
|
||||
<Typography level='h3'>Device Notification</Typography>
|
||||
<Divider />
|
||||
<Typography level='body-md'>Manage your notification settings</Typography>
|
||||
<Typography level='body-md'>Manage your Device Notificaiton</Typography>
|
||||
|
||||
<Select
|
||||
value={notificationTarget}
|
||||
sx={{ maxWidth: '200px' }}
|
||||
onChange={(e, selected) => setNotificationTarget(selected)}
|
||||
<FormControl
|
||||
orientation='horizontal'
|
||||
sx={{ width: 400, justifyContent: 'space-between' }}
|
||||
>
|
||||
<Option value='0'>None</Option>
|
||||
<Option value='1'>Telegram</Option>
|
||||
<Option value='2'>Pushover</Option>
|
||||
</Select>
|
||||
{notificationTarget === '1' && (
|
||||
<>
|
||||
<Typography level='body-xs'>
|
||||
You need to initiate a message to the bot in order for the Telegram
|
||||
notification to work{' '}
|
||||
<a
|
||||
style={{
|
||||
textDecoration: 'underline',
|
||||
color: '#0891b2',
|
||||
}}
|
||||
href='https://t.me/DonetickBot'
|
||||
<div>
|
||||
<FormLabel>Device Notification</FormLabel>
|
||||
<FormHelperText sx={{ mt: 0 }}>
|
||||
{Capacitor.isNativePlatform()
|
||||
? 'Receive notification on your device when a task is due'
|
||||
: 'This feature is only available on mobile devices'}{' '}
|
||||
</FormHelperText>
|
||||
</div>
|
||||
<Switch
|
||||
disabled={!Capacitor.isNativePlatform()}
|
||||
checked={deviceNotification}
|
||||
onClick={event => {
|
||||
event.preventDefault()
|
||||
if (deviceNotification === false) {
|
||||
LocalNotifications.requestPermissions().then(resp => {
|
||||
if (resp.display === 'granted') {
|
||||
setDeviceNotification(true)
|
||||
setNotificationPreferences({ granted: true })
|
||||
} else if (resp.display === 'denied') {
|
||||
setIsSnackbarOpen(true)
|
||||
setDeviceNotification(false)
|
||||
setNotificationPreferences({ granted: false })
|
||||
}
|
||||
})
|
||||
} else {
|
||||
setDeviceNotification(false)
|
||||
}
|
||||
}}
|
||||
color={deviceNotification ? 'success' : 'neutral'}
|
||||
variant={deviceNotification ? 'solid' : 'outlined'}
|
||||
endDecorator={deviceNotification ? 'On' : 'Off'}
|
||||
slotProps={{
|
||||
endDecorator: {
|
||||
sx: {
|
||||
minWidth: 24,
|
||||
},
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
{deviceNotification && (
|
||||
<Card>
|
||||
{[
|
||||
{
|
||||
title: 'Due Date Notification',
|
||||
checked: dueNotification,
|
||||
set: setDueNotification,
|
||||
label: 'Notification when the task is due',
|
||||
property: 'dueNotification',
|
||||
disabled: false,
|
||||
},
|
||||
{
|
||||
title: 'Pre-Due Date Notification',
|
||||
checked: preDueNotification,
|
||||
set: setPreDueNotification,
|
||||
label: 'Notification a few hours before the task is due',
|
||||
property: 'preDueNotification',
|
||||
disabled: true,
|
||||
},
|
||||
{
|
||||
title: 'Overdue Notification',
|
||||
checked: naggingNotification,
|
||||
set: setNaggingNotification,
|
||||
label: 'Notification when the task is overdue',
|
||||
property: 'naggingNotification',
|
||||
disabled: true,
|
||||
},
|
||||
].map(item => (
|
||||
<FormControl
|
||||
key={item.property}
|
||||
orientation='horizontal'
|
||||
sx={{ width: 385, justifyContent: 'space-between' }}
|
||||
>
|
||||
Click here
|
||||
</a>{' '}
|
||||
to start a chat
|
||||
</Typography>
|
||||
<div>
|
||||
<FormLabel>{item.title}</FormLabel>
|
||||
<FormHelperText sx={{ mt: 0 }}>{item.label} </FormHelperText>
|
||||
</div>
|
||||
|
||||
<Typography level='body-sm'>Chat ID</Typography>
|
||||
<Switch
|
||||
checked={item.checked}
|
||||
disabled={item.disabled}
|
||||
onClick={() => {
|
||||
setNotificationPreferences({ [item.property]: !item.checked })
|
||||
item.set(!item.checked)
|
||||
}}
|
||||
color={item.checked ? 'success' : ''}
|
||||
variant='solid'
|
||||
endDecorator={item.checked ? 'On' : 'Off'}
|
||||
slotProps={{ endDecorator: { sx: { minWidth: 24 } } }}
|
||||
/>
|
||||
</FormControl>
|
||||
))}
|
||||
</Card>
|
||||
)}
|
||||
{/* <FormControl
|
||||
orientation="horizontal"
|
||||
sx={{ width: 400, justifyContent: 'space-between' }}
|
||||
>
|
||||
<div>
|
||||
<FormLabel>Push Notifications</FormLabel>
|
||||
<FormHelperText sx={{ mt: 0 }}>{Capacitor.isNativePlatform()? 'Receive push notification when someone complete task' : 'This feature is only available on mobile devices'} </FormHelperText>
|
||||
</div>
|
||||
<Switch
|
||||
disabled={!Capacitor.isNativePlatform()}
|
||||
checked={pushNotification}
|
||||
onClick={(event) =>{
|
||||
event.preventDefault()
|
||||
if (pushNotification === false){
|
||||
PushNotifications.requestPermissions().then((resp) => {
|
||||
console.log("user PushNotifications permission",resp);
|
||||
if (resp.receive === 'granted') {
|
||||
|
||||
<Input
|
||||
value={chatID}
|
||||
onChange={e => setChatID(e.target.value)}
|
||||
placeholder='User ID / Chat ID'
|
||||
sx={{
|
||||
width: '200px',
|
||||
}}
|
||||
/>
|
||||
<Typography mt={0} level='body-xs'>
|
||||
If you don't know your Chat ID, start chat with userinfobot and it
|
||||
will send you your Chat ID.{' '}
|
||||
<a
|
||||
style={{
|
||||
textDecoration: 'underline',
|
||||
color: '#0891b2',
|
||||
}}
|
||||
href='https://t.me/userinfobot'
|
||||
>
|
||||
Click here
|
||||
</a>{' '}
|
||||
to start chat with userinfobot{' '}
|
||||
</Typography>
|
||||
</>
|
||||
)}
|
||||
{notificationTarget === '2' && (
|
||||
<>
|
||||
<Typography level='body-sm'>User key</Typography>
|
||||
<Input
|
||||
value={chatID}
|
||||
onChange={e => setChatID(e.target.value)}
|
||||
placeholder='User ID'
|
||||
sx={{
|
||||
width: '200px',
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{error && (
|
||||
<Typography color='warning' level='body-sm'>
|
||||
{error}
|
||||
</Typography>
|
||||
)}
|
||||
setPushNotification(true)
|
||||
setPushNotificationPreferences({granted: true})
|
||||
}
|
||||
if (resp.receive!== 'granted') {
|
||||
setIsSnackbarOpen(true)
|
||||
setPushNotification(false)
|
||||
setPushNotificationPreferences({granted: false})
|
||||
console.log("User denied permission", resp)
|
||||
|
||||
}
|
||||
})
|
||||
}
|
||||
else{
|
||||
setPushNotification(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
color={pushNotification ? 'success' : 'neutral'}
|
||||
variant={pushNotification ? 'solid' : 'outlined'}
|
||||
endDecorator={pushNotification ? 'On' : 'Off'}
|
||||
slotProps={{
|
||||
endDecorator: {
|
||||
sx: {
|
||||
minWidth: 24,
|
||||
},
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</FormControl> */}
|
||||
|
||||
<Button
|
||||
variant='soft'
|
||||
color='primary'
|
||||
sx={{
|
||||
width: '110px',
|
||||
width: '210px',
|
||||
mb: 1,
|
||||
}}
|
||||
onClick={handleSave}
|
||||
onClick={() => {
|
||||
// schedule a local notification in 5 seconds
|
||||
LocalNotifications.schedule({
|
||||
notifications: [
|
||||
{
|
||||
title: 'Task Reminder',
|
||||
body: 'You have a task due soon',
|
||||
id: 1,
|
||||
schedule: { at: new Date(Date.now() + 3000) },
|
||||
sound: null,
|
||||
attachments: null,
|
||||
actionTypeId: '',
|
||||
extra: null,
|
||||
},
|
||||
],
|
||||
})
|
||||
}}
|
||||
>
|
||||
Save
|
||||
Test Notification{' '}
|
||||
</Button>
|
||||
<Typography level='h3'>Custom Notification</Typography>
|
||||
<Divider />
|
||||
<Typography level='body-md'>
|
||||
Notificaiton through other platform like Telegram or Pushover
|
||||
</Typography>
|
||||
|
||||
<FormControl
|
||||
orientation='horizontal'
|
||||
sx={{ width: 400, justifyContent: 'space-between' }}
|
||||
>
|
||||
<div>
|
||||
<FormLabel>Custom Notification</FormLabel>
|
||||
<FormHelperText sx={{ mt: 0 }}>
|
||||
Receive notification on other platform
|
||||
</FormHelperText>
|
||||
</div>
|
||||
<Switch
|
||||
checked={chatID !== 0}
|
||||
onClick={event => {
|
||||
event.preventDefault()
|
||||
if (chatID !== 0) {
|
||||
setChatID(0)
|
||||
} else {
|
||||
setChatID('')
|
||||
UpdateUserDetails({
|
||||
chatID: Number(0),
|
||||
}).then(resp => {
|
||||
resp.json().then(data => {
|
||||
setUserProfile(data)
|
||||
})
|
||||
})
|
||||
}
|
||||
setNotificationTarget('0')
|
||||
handleSave()
|
||||
}}
|
||||
color={chatID !== 0 ? 'success' : 'neutral'}
|
||||
variant={chatID !== 0 ? 'solid' : 'outlined'}
|
||||
endDecorator={chatID !== 0 ? 'On' : 'Off'}
|
||||
slotProps={{
|
||||
endDecorator: {
|
||||
sx: {
|
||||
minWidth: 24,
|
||||
},
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
{chatID !== 0 && (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 2,
|
||||
}}
|
||||
>
|
||||
<Select
|
||||
value={notificationTarget}
|
||||
sx={{ maxWidth: '200px' }}
|
||||
onChange={(e, selected) => setNotificationTarget(selected)}
|
||||
>
|
||||
<Option value='0'>None</Option>
|
||||
<Option value='1'>Telegram</Option>
|
||||
<Option value='2'>Pushover</Option>
|
||||
</Select>
|
||||
{notificationTarget === '1' && (
|
||||
<>
|
||||
<Typography level='body-xs'>
|
||||
You need to initiate a message to the bot in order for the
|
||||
Telegram notification to work{' '}
|
||||
<a
|
||||
style={{
|
||||
textDecoration: 'underline',
|
||||
color: '#0891b2',
|
||||
}}
|
||||
href='https://t.me/DonetickBot'
|
||||
>
|
||||
Click here
|
||||
</a>{' '}
|
||||
to start a chat
|
||||
</Typography>
|
||||
|
||||
<Typography level='body-sm'>Chat ID</Typography>
|
||||
|
||||
<Input
|
||||
value={chatID}
|
||||
onChange={e => setChatID(e.target.value)}
|
||||
placeholder='User ID / Chat ID'
|
||||
sx={{
|
||||
width: '200px',
|
||||
}}
|
||||
/>
|
||||
<Typography mt={0} level='body-xs'>
|
||||
If you don't know your Chat ID, start chat with userinfobot and
|
||||
it will send you your Chat ID.{' '}
|
||||
<a
|
||||
style={{
|
||||
textDecoration: 'underline',
|
||||
color: '#0891b2',
|
||||
}}
|
||||
href='https://t.me/userinfobot'
|
||||
>
|
||||
Click here
|
||||
</a>{' '}
|
||||
to start chat with userinfobot{' '}
|
||||
</Typography>
|
||||
</>
|
||||
)}
|
||||
{notificationTarget === '2' && (
|
||||
<>
|
||||
<Typography level='body-sm'>User key</Typography>
|
||||
<Input
|
||||
value={chatID}
|
||||
onChange={e => setChatID(e.target.value)}
|
||||
placeholder='User ID'
|
||||
sx={{
|
||||
width: '200px',
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{error && (
|
||||
<Typography color='warning' level='body-sm'>
|
||||
{error}
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
<Button
|
||||
sx={{
|
||||
width: '110px',
|
||||
mb: 1,
|
||||
}}
|
||||
onClick={handleSave}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
<Snackbar
|
||||
open={isSnackbarOpen}
|
||||
autoHideDuration={8000}
|
||||
onClose={() => setIsSnackbarOpen(false)}
|
||||
endDecorator={
|
||||
<IconButton size='md' onClick={() => setIsSnackbarOpen(false)}>
|
||||
<Close />
|
||||
</IconButton>
|
||||
}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<Typography level='title-md'>Permission Denied</Typography>
|
||||
<Typography level='body-md'>
|
||||
You have denied the permission to receive notification on this
|
||||
device. Please enable it in your device settings
|
||||
</Typography>
|
||||
</div>
|
||||
</Snackbar>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
51
src/views/Settings/ThemeToggleButton.jsx
Normal file
51
src/views/Settings/ThemeToggleButton.jsx
Normal file
|
@ -0,0 +1,51 @@
|
|||
import useStickyState from '@/hooks/useStickyState'
|
||||
import {
|
||||
BrightnessAuto,
|
||||
DarkModeOutlined,
|
||||
LightModeOutlined,
|
||||
} from '@mui/icons-material'
|
||||
import { FormControl, IconButton, useColorScheme } from '@mui/joy'
|
||||
|
||||
const ELEMENTID = 'select-theme-mode'
|
||||
|
||||
const ThemeToggleButton = ({ sx }) => {
|
||||
const { mode, setMode } = useColorScheme()
|
||||
const [themeMode, setThemeMode] = useStickyState(mode, 'themeMode')
|
||||
|
||||
const handleThemeModeChange = e => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
|
||||
let newThemeMode
|
||||
switch (themeMode) {
|
||||
case 'light':
|
||||
newThemeMode = 'dark'
|
||||
break
|
||||
case 'dark':
|
||||
newThemeMode = 'system'
|
||||
break
|
||||
case 'system':
|
||||
default:
|
||||
newThemeMode = 'light'
|
||||
break
|
||||
}
|
||||
setThemeMode(newThemeMode)
|
||||
setMode(newThemeMode)
|
||||
}
|
||||
|
||||
return (
|
||||
<FormControl sx={sx}>
|
||||
<IconButton onClick={handleThemeModeChange}>
|
||||
{themeMode === 'light' ? (
|
||||
<DarkModeOutlined />
|
||||
) : themeMode === 'dark' ? (
|
||||
<BrightnessAuto />
|
||||
) : (
|
||||
<LightModeOutlined />
|
||||
)}
|
||||
</IconButton>
|
||||
</FormControl>
|
||||
)
|
||||
}
|
||||
|
||||
export default ThemeToggleButton
|
395
src/views/User/UserActivities.jsx
Normal file
395
src/views/User/UserActivities.jsx
Normal file
|
@ -0,0 +1,395 @@
|
|||
import CancelIcon from '@mui/icons-material/Cancel'
|
||||
import CheckCircleIcon from '@mui/icons-material/CheckCircle'
|
||||
import CircleIcon from '@mui/icons-material/Circle'
|
||||
import { Cell, Legend, Pie, PieChart, Tooltip } from 'recharts'
|
||||
|
||||
import { Toll } from '@mui/icons-material'
|
||||
import {
|
||||
Box,
|
||||
Card,
|
||||
Chip,
|
||||
Container,
|
||||
Divider,
|
||||
Grid,
|
||||
Stack,
|
||||
Tab,
|
||||
TabList,
|
||||
Tabs,
|
||||
Typography,
|
||||
} from '@mui/joy'
|
||||
import React, { useEffect } from 'react'
|
||||
import { UserContext } from '../../contexts/UserContext'
|
||||
import { useChores, useChoresHistory } from '../../queries/ChoreQueries'
|
||||
import { ChoresGrouper } from '../../utils/Chores'
|
||||
import { TASK_COLOR } from '../../utils/Colors.jsx'
|
||||
import LoadingComponent from '../components/Loading'
|
||||
|
||||
const groupByDate = history => {
|
||||
const aggregated = {}
|
||||
for (let i = 0; i < history.length; i++) {
|
||||
const item = history[i]
|
||||
const date = new Date(item.completedAt).toLocaleDateString()
|
||||
if (!aggregated[date]) {
|
||||
aggregated[date] = []
|
||||
}
|
||||
aggregated[date].push(item)
|
||||
}
|
||||
return aggregated
|
||||
}
|
||||
|
||||
const ChoreHistoryItem = ({ time, name, points, status }) => {
|
||||
const statusIcon = {
|
||||
completed: <CheckCircleIcon color='success' />,
|
||||
missed: <CancelIcon color='error' />,
|
||||
pending: <CircleIcon color='neutral' />,
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack direction='row' alignItems='center' spacing={2}>
|
||||
<Typography level='body-md' sx={{ minWidth: 80 }}>
|
||||
{time}
|
||||
</Typography>
|
||||
<Box>
|
||||
{statusIcon[status] ? statusIcon[status] : statusIcon['completed']}
|
||||
</Box>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
minHeight: 40,
|
||||
// center vertically:
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
sx={{
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
maxWidth: '50vw',
|
||||
}}
|
||||
level='body-md'
|
||||
>
|
||||
{name}
|
||||
</Typography>
|
||||
{points && (
|
||||
<Chip size='sm' color='success' startDecorator={<Toll />}>
|
||||
{`${points} points`}
|
||||
</Chip>
|
||||
)}
|
||||
</Box>
|
||||
</Stack>
|
||||
)
|
||||
}
|
||||
|
||||
const ChoreHistoryTimeline = ({ history }) => {
|
||||
const groupedHistory = groupByDate(history)
|
||||
|
||||
return (
|
||||
<Container sx={{ p: 2 }}>
|
||||
<Typography level='h4' sx={{ mb: 2 }}>
|
||||
Activities Timeline
|
||||
</Typography>
|
||||
|
||||
{Object.entries(groupedHistory).map(([date, items]) => (
|
||||
<Box key={date} sx={{ mb: 4 }}>
|
||||
<Typography level='title-sm' sx={{ mb: 0.5 }}>
|
||||
{new Date(date).toLocaleDateString([], {
|
||||
weekday: 'long',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
})}
|
||||
</Typography>
|
||||
<Divider />
|
||||
<Stack spacing={1}>
|
||||
{items.map(record => (
|
||||
<>
|
||||
<ChoreHistoryItem
|
||||
key={record.id}
|
||||
time={new Date(record.completedAt).toLocaleTimeString([], {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})}
|
||||
name={record.choreName}
|
||||
points={record.points}
|
||||
status={record.status}
|
||||
/>
|
||||
</>
|
||||
))}
|
||||
</Stack>
|
||||
</Box>
|
||||
))}
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
||||
const renderPieChart = (data, size, isPrimary) => (
|
||||
<PieChart width={size} height={size}>
|
||||
<Pie
|
||||
data={data}
|
||||
dataKey='value'
|
||||
nameKey='label'
|
||||
cx='50%'
|
||||
cy='50%'
|
||||
innerRadius={isPrimary ? size / 4 : size / 6}
|
||||
paddingAngle={5}
|
||||
cornerRadius={5}
|
||||
>
|
||||
{data.map((entry, index) => (
|
||||
<Cell key={`cell-${index}`} fill={entry.color} />
|
||||
))}
|
||||
</Pie>
|
||||
{isPrimary && <Tooltip />}
|
||||
{isPrimary && (
|
||||
<Legend
|
||||
layout='horizontal'
|
||||
verticalAlign='bottom'
|
||||
align='center'
|
||||
// format as : {entry.payload.label}: {value}
|
||||
iconType='circle'
|
||||
formatter={(label, value) => `${label}: ${value.payload.value}`}
|
||||
/>
|
||||
)}
|
||||
</PieChart>
|
||||
)
|
||||
|
||||
const UserActivites = () => {
|
||||
const { userProfile } = React.useContext(UserContext)
|
||||
const [tabValue, setTabValue] = React.useState(30)
|
||||
const [selectedHistory, setSelectedHistory] = React.useState([])
|
||||
const [selectedChart, setSelectedChart] = React.useState('history')
|
||||
|
||||
const [historyPieChartData, setHistoryPieChartData] = React.useState([])
|
||||
const [choreDuePieChartData, setChoreDuePieChartData] = React.useState([])
|
||||
const [choresAssignedChartData, setChoresAssignedChartData] = React.useState(
|
||||
[],
|
||||
)
|
||||
const [choresPriorityChartData, setChoresPriorityChartData] = React.useState(
|
||||
[],
|
||||
)
|
||||
const { data: choresData, isLoading: isChoresLoading } = useChores(true)
|
||||
const {
|
||||
data: choresHistory,
|
||||
isChoresHistoryLoading,
|
||||
handleLimitChange: refetchHistory,
|
||||
} = useChoresHistory(tabValue ? tabValue : 30)
|
||||
useEffect(() => {
|
||||
if (!isChoresHistoryLoading && !isChoresLoading && choresHistory) {
|
||||
const enrichedHistory = choresHistory.res.map(item => {
|
||||
const chore = choresData.res.find(chore => chore.id === item.choreId)
|
||||
return {
|
||||
...item,
|
||||
choreName: chore?.name,
|
||||
}
|
||||
})
|
||||
|
||||
setSelectedHistory(enrichedHistory)
|
||||
setHistoryPieChartData(generateHistoryPieChartData(enrichedHistory))
|
||||
}
|
||||
}, [isChoresHistoryLoading, isChoresLoading, choresHistory])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isChoresLoading && choresData) {
|
||||
const choreDuePieChartData = generateChoreDuePieChartData(choresData.res)
|
||||
setChoreDuePieChartData(choreDuePieChartData)
|
||||
setChoresAssignedChartData(generateChoreAssignedChartData(choresData.res))
|
||||
setChoresPriorityChartData(
|
||||
generateChorePriorityPieChartData(choresData.res),
|
||||
)
|
||||
}
|
||||
}, [isChoresLoading, choresData])
|
||||
|
||||
const generateChoreAssignedChartData = chores => {
|
||||
var assignedToMe = 0
|
||||
var assignedToOthers = 0
|
||||
chores.forEach(chore => {
|
||||
if (chore.assignedTo === userProfile.id) {
|
||||
assignedToMe++
|
||||
} else assignedToOthers++
|
||||
})
|
||||
|
||||
const group = []
|
||||
if (assignedToMe > 0) {
|
||||
group.push({
|
||||
label: `Assigned to me`,
|
||||
value: assignedToMe,
|
||||
color: TASK_COLOR.ASSIGNED_TO_ME,
|
||||
id: 1,
|
||||
})
|
||||
}
|
||||
if (assignedToOthers > 0) {
|
||||
group.push({
|
||||
label: `Assigned to others`,
|
||||
value: assignedToOthers,
|
||||
color: TASK_COLOR.ASSIGNED_TO_OTHERS,
|
||||
id: 2,
|
||||
})
|
||||
}
|
||||
return group
|
||||
}
|
||||
|
||||
const generateChoreDuePieChartData = chores => {
|
||||
const groups = ChoresGrouper('due_date', chores)
|
||||
return groups
|
||||
.map(group => {
|
||||
return {
|
||||
label: group.name,
|
||||
value: group.content.length,
|
||||
color: group.color,
|
||||
id: group.name,
|
||||
}
|
||||
})
|
||||
.filter(item => item.value > 0)
|
||||
}
|
||||
const generateChorePriorityPieChartData = chores => {
|
||||
const groups = ChoresGrouper('priority', chores)
|
||||
return groups
|
||||
.map(group => {
|
||||
return {
|
||||
label: group.name,
|
||||
value: group.content.length,
|
||||
color: group.color,
|
||||
id: group.name,
|
||||
}
|
||||
})
|
||||
.filter(item => item.value > 0)
|
||||
}
|
||||
|
||||
const generateHistoryPieChartData = history => {
|
||||
const totalCompleted = history.filter(
|
||||
item => item.dueDate > item.completedAt,
|
||||
).length
|
||||
const totalLate = history.filter(
|
||||
item => item.dueDate < item.completedAt,
|
||||
).length
|
||||
|
||||
return [
|
||||
{
|
||||
label: `On time`,
|
||||
value: totalCompleted,
|
||||
color: '#4ec1a2',
|
||||
id: 1,
|
||||
},
|
||||
{
|
||||
label: `Late`,
|
||||
value: totalLate,
|
||||
color: '#f6ad55',
|
||||
id: 2,
|
||||
},
|
||||
]
|
||||
}
|
||||
if (isChoresHistoryLoading || isChoresLoading) {
|
||||
return <LoadingComponent />
|
||||
}
|
||||
const COLORS = historyPieChartData.map(item => item.color)
|
||||
const chartData = {
|
||||
history: {
|
||||
data: historyPieChartData,
|
||||
title: 'Status',
|
||||
description: 'Completed tasks status',
|
||||
},
|
||||
due: {
|
||||
data: choreDuePieChartData,
|
||||
title: 'Due Date',
|
||||
description: 'Current tasks due date',
|
||||
},
|
||||
assigned: {
|
||||
data: choresAssignedChartData,
|
||||
title: 'Assignee',
|
||||
description: 'Tasks assigned to you vs others',
|
||||
},
|
||||
priority: {
|
||||
data: choresPriorityChartData,
|
||||
title: 'Priority',
|
||||
description: 'Tasks by priority',
|
||||
},
|
||||
}
|
||||
|
||||
return (
|
||||
<Container
|
||||
maxWidth='md'
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<Tabs
|
||||
onChange={(e, tabValue) => {
|
||||
setTabValue(tabValue)
|
||||
refetchHistory(tabValue)
|
||||
}}
|
||||
defaultValue={7}
|
||||
sx={{
|
||||
py: 0.5,
|
||||
borderRadius: 16,
|
||||
maxWidth: 400,
|
||||
mb: 1,
|
||||
}}
|
||||
>
|
||||
<TabList
|
||||
disableUnderline
|
||||
sx={{
|
||||
borderRadius: 16,
|
||||
backgroundColor: 'background.paper',
|
||||
boxShadow: 1,
|
||||
justifyContent: 'space-evenly',
|
||||
}}
|
||||
>
|
||||
{[
|
||||
{ label: '7 Days', value: 7 },
|
||||
{ label: '30 Days', value: 30 },
|
||||
{ label: '90 Days', value: 90 },
|
||||
].map((tab, index) => (
|
||||
<Tab
|
||||
key={index}
|
||||
sx={{
|
||||
borderRadius: 16,
|
||||
color: 'text.secondary',
|
||||
'&.Mui-selected': {
|
||||
color: 'text.primary',
|
||||
backgroundColor: 'primary.light',
|
||||
},
|
||||
}}
|
||||
disableIndicator
|
||||
value={tab.value}
|
||||
>
|
||||
{tab.label}
|
||||
</Tab>
|
||||
))}
|
||||
</TabList>
|
||||
</Tabs>
|
||||
<Box sx={{ mb: 4 }}>
|
||||
<Typography level='h4' textAlign='center'>
|
||||
{chartData[selectedChart].title}
|
||||
</Typography>
|
||||
<Typography level='body-xs' textAlign='center'>
|
||||
{chartData[selectedChart].description}
|
||||
</Typography>
|
||||
{renderPieChart(chartData[selectedChart].data, 250, true)}
|
||||
</Box>
|
||||
<Grid container spacing={1}>
|
||||
{Object.entries(chartData)
|
||||
.filter(([key]) => key !== selectedChart)
|
||||
.map(([key, { data, title }]) => (
|
||||
<Grid item key={key} xs={4}>
|
||||
<Card
|
||||
onClick={() => setSelectedChart(key)}
|
||||
sx={{ cursor: 'pointer', p: 1 }}
|
||||
>
|
||||
<Typography textAlign='center' level='body-xs' mb={-2}>
|
||||
{title}
|
||||
</Typography>
|
||||
{renderPieChart(data, 75, false)}
|
||||
</Card>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
<ChoreHistoryTimeline history={selectedHistory} />
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
||||
export default UserActivites
|
|
@ -1,6 +1,7 @@
|
|||
import Logo from '@/assets/logo.svg'
|
||||
import {
|
||||
AccountBox,
|
||||
History,
|
||||
HomeOutlined,
|
||||
ListAlt,
|
||||
Logout,
|
||||
|
@ -23,6 +24,7 @@ import {
|
|||
import { useState } from 'react'
|
||||
import { useLocation, useNavigate } from 'react-router-dom'
|
||||
import { version } from '../../../package.json'
|
||||
import ThemeToggleButton from '../Settings/ThemeToggleButton'
|
||||
import NavBarLink from './NavBarLink'
|
||||
const links = [
|
||||
{
|
||||
|
@ -41,6 +43,11 @@ const links = [
|
|||
label: 'Things',
|
||||
icon: <Widgets />,
|
||||
},
|
||||
{
|
||||
to: 'activities',
|
||||
label: 'Activities',
|
||||
icon: <History />,
|
||||
},
|
||||
{
|
||||
to: 'labels',
|
||||
label: 'Labels',
|
||||
|
@ -120,6 +127,12 @@ const NavBar = () => {
|
|||
tick✓
|
||||
</span>
|
||||
</Typography>
|
||||
<ThemeToggleButton
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
right: 10,
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
<Drawer
|
||||
open={drawerOpen}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue