diff options
Diffstat (limited to '')
-rw-r--r-- | src/utils/Fetcher.jsx | 8 | ||||
-rw-r--r-- | src/views/ChoreEdit/ChoreEdit.jsx | 2 | ||||
-rw-r--r-- | src/views/ChoreEdit/ThingTriggerSection.jsx | 3 | ||||
-rw-r--r-- | src/views/Chores/ChoreCard.jsx | 2 | ||||
-rw-r--r-- | src/views/Modals/Inputs/CreateThingModal.jsx | 151 | ||||
-rw-r--r-- | src/views/Things/ThingsHistory.jsx | 164 | ||||
-rw-r--r-- | src/views/Things/ThingsView.jsx | 87 |
7 files changed, 341 insertions, 76 deletions
diff --git a/src/utils/Fetcher.jsx b/src/utils/Fetcher.jsx index 6444b34..be3971c 100644 --- a/src/utils/Fetcher.jsx +++ b/src/utils/Fetcher.jsx @@ -197,6 +197,13 @@ const DeleteThing = id => { }) } +const GetThingHistory = (id, offset) => { + return Fetch(`${API_URL}/things/${id}/history?offset=${offset}`, { + method: 'GET', + headers: HEADERS(), + }) +} + const CreateLongLiveToken = name => { return Fetch(`${API_URL}/users/tokens`, { method: 'POST', @@ -236,6 +243,7 @@ export { GetCircleMemberRequests, GetLongLiveTokens, GetSubscriptionSession, + GetThingHistory, GetThings, GetUserCircle, GetUserProfile, diff --git a/src/views/ChoreEdit/ChoreEdit.jsx b/src/views/ChoreEdit/ChoreEdit.jsx index e8eb17d..568b20a 100644 --- a/src/views/ChoreEdit/ChoreEdit.jsx +++ b/src/views/ChoreEdit/ChoreEdit.jsx @@ -506,7 +506,7 @@ const ChoreEdit = () => { </FormControl> )} </Box> - {!['once', 'no_repeat'].includes(frequencyType) && ( + {!['once', 'no_repeat', 'trigger'].includes(frequencyType) && ( <Box mt={2}> <Typography level='h4'>Scheduling Preferences: </Typography> <Typography level='h5'> diff --git a/src/views/ChoreEdit/ThingTriggerSection.jsx b/src/views/ChoreEdit/ThingTriggerSection.jsx index 7a040ad..981f84b 100644 --- a/src/views/ChoreEdit/ThingTriggerSection.jsx +++ b/src/views/ChoreEdit/ThingTriggerSection.jsx @@ -6,7 +6,6 @@ import { Card, Chip, FormControl, - FormLabel, Input, ListItem, ListItemContent, @@ -91,7 +90,7 @@ const ThingTriggerSection = ({ <Typography level='h5'> Trigger a task when a thing state changes to a desired state </Typography> - {things.length !== 0 && ( + {things?.length === 0 && ( <Typography level='body-sm'> it's look like you don't have any things yet, create a thing to trigger a task when the state changes. diff --git a/src/views/Chores/ChoreCard.jsx b/src/views/Chores/ChoreCard.jsx index 0b2a408..8efaf04 100644 --- a/src/views/Chores/ChoreCard.jsx +++ b/src/views/Chores/ChoreCard.jsx @@ -125,7 +125,7 @@ const ChoreCard = ({ chore, performers, onChoreUpdate, onChoreRemove, sx }) => { } }) setIsDisabled(true) - setTimeout(() => setIsDisabled(false), 5000) // Re-enable the button after 5 seconds + setTimeout(() => setIsDisabled(false), 3000) // Re-enable the button after 5 seconds } const handleChangeDueDate = newDate => { if (activeUserId === null) { diff --git a/src/views/Modals/Inputs/CreateThingModal.jsx b/src/views/Modals/Inputs/CreateThingModal.jsx index 59263ff..ffce51c 100644 --- a/src/views/Modals/Inputs/CreateThingModal.jsx +++ b/src/views/Modals/Inputs/CreateThingModal.jsx @@ -1,6 +1,8 @@ import { Box, Button, + FormControl, + FormHelperText, FormLabel, Input, Modal, @@ -14,8 +16,9 @@ import { useEffect, useState } from 'react' function CreateThingModal({ isOpen, onClose, onSave, currentThing }) { const [name, setName] = useState(currentThing?.name || '') - const [type, setType] = useState(currentThing?.type || 'numeric') + const [type, setType] = useState(currentThing?.type || 'number') const [state, setState] = useState(currentThing?.state || '') + const [errors, setErrors] = useState({}) useEffect(() => { if (type === 'boolean') { if (state !== 'true' && state !== 'false') { @@ -27,7 +30,31 @@ function CreateThingModal({ isOpen, onClose, onSave, currentThing }) { } } }, [type]) + + const isValid = () => { + const newErrors = {} + if (!name || name.trim() === '') { + newErrors.name = 'Name is required' + } + + if (type === 'number' && isNaN(state)) { + newErrors.state = 'State must be a number' + } + if (type === 'boolean' && !['true', 'false'].includes(state)) { + newErrors.state = 'State must be true or false' + } + if ((type === 'text' && !state) || state.trim() === '') { + newErrors.state = 'State is required' + } + + setErrors(newErrors) + return Object.keys(newErrors).length === 0 + } + const handleSave = () => { + if (!isValid()) { + return + } onSave({ name, type, id: currentThing?.id, state: state || null }) onClose() } @@ -36,64 +63,81 @@ function CreateThingModal({ isOpen, onClose, onSave, currentThing }) { <Modal open={isOpen} onClose={onClose}> <ModalDialog> {/* <ModalClose /> */} - <Typography variant='h4'>P;lease add info</Typography> - <FormLabel>Name</FormLabel> - - <Textarea - placeholder='Thing name' - value={name} - onChange={e => setName(e.target.value)} - sx={{ minWidth: 300 }} - /> - <FormLabel>Type</FormLabel> - <Select value={type} sx={{ minWidth: 300 }}> - {['text', 'number', 'boolean'].map(type => ( - <Option value={type} key={type} onClick={() => setType(type)}> - {type.charAt(0).toUpperCase() + type.slice(1)} - </Option> - ))} - </Select> - - {type === 'text' && ( - <> - <FormLabel>Value</FormLabel> - <Input - placeholder='Thing value' - value={state || ''} - onChange={e => setState(e.target.value)} + <Typography level='h4'> + {currentThing?.id ? 'Edit' : 'Create'} Thing + </Typography> + <FormControl> + <FormLabel> + Name + <Textarea + placeholder='Thing name' + value={name} + onChange={e => setName(e.target.value)} sx={{ minWidth: 300 }} /> - </> + </FormLabel> + <FormHelperText color='danger'>{errors.name}</FormHelperText> + </FormControl> + <FormControl> + <FormLabel> + Type + <Select value={type} sx={{ minWidth: 300 }}> + {['text', 'number', 'boolean'].map(type => ( + <Option value={type} key={type} onClick={() => setType(type)}> + {type.charAt(0).toUpperCase() + type.slice(1)} + </Option> + ))} + </Select> + </FormLabel> + <FormHelperText color='danger'>{errors.type}</FormHelperText> + </FormControl> + {type === 'text' && ( + <FormControl> + <FormLabel> + Value + <Input + placeholder='Thing value' + value={state || ''} + onChange={e => setState(e.target.value)} + sx={{ minWidth: 300 }} + /> + </FormLabel> + <FormHelperText color='danger'>{errors.state}</FormHelperText> + </FormControl> )} {type === 'number' && ( - <> - <FormLabel>Value</FormLabel> - <Input - placeholder='Thing value' - type='number' - value={state || ''} - onChange={e => { - setState(e.target.value) - }} - sx={{ minWidth: 300 }} - /> - </> + <FormControl> + <FormLabel> + Value + <Input + placeholder='Thing value' + type='number' + value={state || ''} + onChange={e => { + setState(e.target.value) + }} + sx={{ minWidth: 300 }} + /> + </FormLabel> + </FormControl> )} {type === 'boolean' && ( - <> - <FormLabel>Value</FormLabel> - <Select sx={{ minWidth: 300 }} value={state}> - {['true', 'false'].map(value => ( - <Option - value={value} - key={value} - onClick={() => setState(value)} - > - {value.charAt(0).toUpperCase() + value.slice(1)} - </Option> - ))} - </Select> - </> + <FormControl> + <FormLabel> + Value + <Select sx={{ minWidth: 300 }} value={state}> + {['true', 'false'].map(value => ( + <Option + value={value} + key={value} + onClick={() => setState(value)} + > + {value.charAt(0).toUpperCase() + value.slice(1)} + </Option> + ))} + </Select> + </FormLabel> + </FormControl> )} <Box display={'flex'} justifyContent={'space-around'} mt={1}> @@ -108,5 +152,4 @@ function CreateThingModal({ isOpen, onClose, onSave, currentThing }) { </Modal> ) } - export default CreateThingModal diff --git a/src/views/Things/ThingsHistory.jsx b/src/views/Things/ThingsHistory.jsx index 39f0e30..4b32b0e 100644 --- a/src/views/Things/ThingsHistory.jsx +++ b/src/views/Things/ThingsHistory.jsx @@ -1,11 +1,171 @@ -import { Container, Typography } from '@mui/joy' +import { EventBusy } from '@mui/icons-material' +import { + Box, + Button, + Chip, + Container, + List, + ListDivider, + ListItem, + ListItemContent, + Typography, +} from '@mui/joy' +import moment from 'moment' +import { useEffect, useState } from 'react' +import { Link, useParams } from 'react-router-dom' +import { GetThingHistory } from '../../utils/Fetcher' const ThingsHistory = () => { + const { id } = useParams() + const [thingsHistory, setThingsHistory] = useState([]) + const [noMoreHistory, setNoMoreHistory] = useState(false) + const [errLoading, setErrLoading] = useState(false) + useEffect(() => { + GetThingHistory(id, 0, 10).then(resp => { + if (resp.ok) { + resp.json().then(data => { + setThingsHistory(data.res) + if (data.res.length < 10) { + setNoMoreHistory(true) + } + }) + } else { + setErrLoading(true) + } + }) + }, []) + + const handleLoadMore = () => { + GetThingHistory(id, thingsHistory.length).then(resp => { + if (resp.ok) { + resp.json().then(data => { + setThingsHistory([...thingsHistory, ...data.res]) + if (data.res.length < 10) { + setNoMoreHistory(true) + } + }) + } + }) + } + + const formatTimeDifference = (startDate, endDate) => { + const diffInMinutes = moment(startDate).diff(endDate, 'minutes') + let timeValue = diffInMinutes + let unit = 'minute' + + if (diffInMinutes >= 60) { + const diffInHours = moment(startDate).diff(endDate, 'hours') + timeValue = diffInHours + unit = 'hour' + + if (diffInHours >= 24) { + const diffInDays = moment(startDate).diff(endDate, 'days') + timeValue = diffInDays + unit = 'day' + } + } + + return `${timeValue} ${unit}${timeValue !== 1 ? 's' : ''}` + } + if (errLoading || !thingsHistory) { + return ( + <Container + maxWidth='md' + sx={{ + textAlign: 'center', + display: 'flex', + // make sure the content is centered vertically: + alignItems: 'center', + justifyContent: 'center', + flexDirection: 'column', + height: '50vh', + }} + > + <EventBusy + sx={{ + fontSize: '6rem', + // color: 'text.disabled', + mb: 1, + }} + /> + + <Typography level='h3' gutterBottom> + No history found + </Typography> + <Typography level='body1'> + It's look like there is no history for this thing yet. + </Typography> + <Button variant='soft' sx={{ mt: 2 }}> + <Link to='/things'>Go back to things</Link> + </Button> + </Container> + ) + } + return ( <Container maxWidth='md'> <Typography level='h3' mb={1.5}> - Summary: + History: </Typography> + <List sx={{ p: 0 }}> + {thingsHistory.map((history, index) => ( + <> + <ListItem sx={{ gap: 1.5, alignItems: 'flex-start' }}> + <ListItemContent sx={{ my: 0 }}> + <Box + sx={{ + display: 'flex', + justifyContent: 'space-between', + alignItems: 'center', + }} + > + <Typography level='body1' sx={{ fontWeight: 'md' }}> + {moment(history.updatedAt).format( + 'ddd MM/DD/yyyy HH:mm:ss', + )} + </Typography> + <Chip>{history.state === '1' ? 'Active' : 'Inactive'}</Chip> + </Box> + </ListItemContent> + </ListItem> + {index < thingsHistory.length - 1 && ( + <> + <ListDivider component='li'> + {/* time between two completion: */} + {index < thingsHistory.length - 1 && + thingsHistory[index + 1].createdAt && ( + <Typography level='body3' color='text.tertiary'> + {formatTimeDifference( + history.createdAt, + thingsHistory[index + 1].createdAt, + )}{' '} + before + </Typography> + )} + </ListDivider> + </> + )} + </> + ))} + </List> + {/* Load more Button */} + <Box + sx={{ + display: 'flex', + justifyContent: 'center', + mt: 2, + }} + > + <Button + variant='plain' + fullWidth + color='primary' + onClick={handleLoadMore} + disabled={noMoreHistory} + > + {noMoreHistory ? 'No more history' : 'Load more'} + </Button> + </Box> </Container> ) } diff --git a/src/views/Things/ThingsView.jsx b/src/views/Things/ThingsView.jsx index deb2df5..8b2beb6 100644 --- a/src/views/Things/ThingsView.jsx +++ b/src/views/Things/ThingsView.jsx @@ -12,12 +12,15 @@ import { Box, Card, Chip, + CircularProgress, Container, Grid, IconButton, + Snackbar, Typography, } from '@mui/joy' import { useEffect, useState } from 'react' +import { useNavigate } from 'react-router-dom' import { CreateThing, DeleteThing, @@ -27,13 +30,14 @@ import { } from '../../utils/Fetcher' import ConfirmationModal from '../Modals/Inputs/ConfirmationModal' import CreateThingModal from '../Modals/Inputs/CreateThingModal' - const ThingCard = ({ thing, onEditClick, onStateChangeRequest, onDeleteClick, }) => { + const [isDisabled, setIsDisabled] = useState(false) + const Navigate = useNavigate() const getThingIcon = type => { if (type === 'text') { return <Flip /> @@ -49,6 +53,15 @@ const ThingCard = ({ return <ToggleOff /> } } + + const handleRequestChange = thing => { + setIsDisabled(true) + onStateChangeRequest(thing) + setTimeout(() => { + setIsDisabled(false) + }, 2000) + } + return ( <Card variant='outlined' @@ -71,6 +84,9 @@ const ThingCard = ({ flexDirection: 'row', gap: 1, }} + onClick={() => { + Navigate(`/things/${thing?.id}`) + }} > <Typography level='title-lg' component='h2'> {thing?.name} @@ -91,21 +107,39 @@ const ThingCard = ({ <Grid item xs={3}> <Box display='flex' justifyContent='flex-end' alignItems='flex-end'> {/* <ButtonGroup> */} - <IconButton - variant='solid' - color='success' - onClick={() => { - onStateChangeRequest(thing) - }} - sx={{ - borderRadius: '50%', - width: 50, - height: 50, - zIndex: 1, - }} - > - {getThingIcon(thing?.type)} - </IconButton> + <div className='relative grid place-items-center'> + <IconButton + variant='solid' + color='success' + onClick={() => { + handleRequestChange(thing) + }} + sx={{ + borderRadius: '50%', + width: 50, + minWidth: 50, + height: 50, + zIndex: 1, + }} + disabled={isDisabled} + > + {getThingIcon(thing?.type)} + </IconButton> + {isDisabled && ( + <CircularProgress + variant='solid' + color='success' + size='md' + sx={{ + color: 'success.main', + position: 'absolute', + '--CircularProgress-size': '55px', + + zIndex: 0, + }} + /> + )} + </div> <IconButton // sx={{ width: 15 }} variant='soft' @@ -154,6 +188,10 @@ const ThingsView = () => { const [isShowCreateThingModal, setIsShowCreateThingModal] = useState(false) const [createModalThing, setCreateModalThing] = useState(null) const [confirmModelConfig, setConfirmModelConfig] = useState({}) + + const [isSnackbarOpen, setIsSnackbarOpen] = useState(false) + const [snackBarMessage, setSnackBarMessage] = useState('') + useEffect(() => { // fetch things GetThings().then(result => { @@ -184,6 +222,8 @@ const ThingsView = () => { } }) }) + setSnackBarMessage('Thing saved successfully') + setIsSnackbarOpen(true) } const handleEditClick = thing => { setCreateModalThing(thing) @@ -240,6 +280,8 @@ const ThingsView = () => { }) }) } + setSnackBarMessage('Thing state updated successfully') + setIsSnackbarOpen(true) } return ( @@ -317,6 +359,19 @@ const ThingsView = () => { )} <ConfirmationModal config={confirmModelConfig} /> </Box> + <Snackbar + open={isSnackbarOpen} + onClose={() => { + setIsSnackbarOpen(false) + }} + autoHideDuration={3000} + variant='soft' + color='success' + size='lg' + invertedColors + > + <Typography level='title-md'>{snackBarMessage}</Typography> + </Snackbar> </Container> ) } |