Your commit message

This commit is contained in:
Mo Tarbin
2025-05-27 02:58:52 -04:00
parent 6ddb8d01ec
commit 709a91b1db
11 changed files with 452 additions and 91 deletions

View File

@@ -6,6 +6,7 @@ import { useEffect, useState } from 'react'
import { Outlet, useNavigate } from 'react-router-dom' import { Outlet, useNavigate } from 'react-router-dom'
import { useRegisterSW } from 'virtual:pwa-register/react' import { useRegisterSW } from 'virtual:pwa-register/react'
import { registerCapacitorListeners } from './CapacitorListener' import { registerCapacitorListeners } from './CapacitorListener'
import { ImpersonateUserProvider } from './contexts/ImpersonateUserContext'
import { UserContext } from './contexts/UserContext' import { UserContext } from './contexts/UserContext'
import { useResource } from './queries/ResourceQueries' import { useResource } from './queries/ResourceQueries'
import { AuthenticationProvider } from './service/AuthenticationService' import { AuthenticationProvider } from './service/AuthenticationService'
@@ -93,14 +94,18 @@ function App() {
return ( return (
<div className='min-h-screen'> <div className='min-h-screen'>
<NetworkBanner /> <NetworkBanner />
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<AuthenticationProvider /> <AuthenticationProvider />
<ErrorProvider> <ErrorProvider>
<UserContext.Provider value={{ userProfile, setUserProfile }}> <ImpersonateUserProvider>
<NavBar /> <UserContext.Provider value={{ userProfile, setUserProfile }}>
<Outlet /> <NavBar />
</UserContext.Provider> <Outlet />
</UserContext.Provider>
</ImpersonateUserProvider>
</ErrorProvider> </ErrorProvider>
{needRefresh && ( {needRefresh && (
<Snackbar open={showUpdateSnackbar}> <Snackbar open={showUpdateSnackbar}>
<Typography level='body-md'> <Typography level='body-md'>

View File

@@ -0,0 +1,16 @@
import { createContext, useContext, useState } from 'react'
const ImpersonateUserContext = createContext()
export const useImpersonateUser = () => useContext(ImpersonateUserContext)
export const ImpersonateUserProvider = ({ children }) => {
const [impersonatedUser, setImpersonatedUser] = useState(null)
return (
<ImpersonateUserContext.Provider
value={{ impersonatedUser, setImpersonatedUser }}
>
{children}
</ImpersonateUserContext.Provider>
)
}

View File

@@ -97,12 +97,9 @@ const GetChoreDetailById = id => {
headers: HEADERS(), headers: HEADERS(),
}) })
} }
const MarkChoreComplete = (id, note, completedDate, performer) => { const MarkChoreComplete = (id, body, completedDate, performer) => {
var markChoreURL = `/chores/${id}/do` var markChoreURL = `/chores/${id}/do`
const body = {
note,
}
let completedDateFormated = '' let completedDateFormated = ''
if (completedDate) { if (completedDate) {
completedDateFormated = `?completedDate=${new Date( completedDateFormated = `?completedDate=${new Date(
@@ -220,6 +217,14 @@ const GetAllCircleMembers = async () => {
return resp.json() return resp.json()
} }
const UpdateMemberRole = async (memberId, role) => {
return Fetch(`/circles/members/role`, {
method: 'PUT',
headers: HEADERS(),
body: JSON.stringify({ role, memberId }),
})
}
const GetUserProfile = () => { const GetUserProfile = () => {
return Fetch(`/users/profile`, { return Fetch(`/users/profile`, {
method: 'GET', method: 'GET',
@@ -540,6 +545,7 @@ export {
UpdateChoreStatus, UpdateChoreStatus,
UpdateDueDate, UpdateDueDate,
UpdateLabel, UpdateLabel,
UpdateMemberRole,
UpdateNotificationTarget, UpdateNotificationTarget,
UpdatePassword, UpdatePassword,
UpdateThingState, UpdateThingState,

View File

@@ -43,6 +43,28 @@ class ApiManager {
export const apiManager = new ApiManager() export const apiManager = new ApiManager()
export const getAssetURL = path => {
const baseURL = apiManager.getApiURL()
return `${baseURL}/assets/${path}`
}
export async function UploadFile(url, options) {
if (!isTokenValid()) {
Cookies.set('ca_redirect', window.location.pathname)
window.location.href = '/login'
}
if (!options) {
options = {}
}
const headers = HEADERS()
options.headers = { Authorization: headers['Authorization'] }
const baseURL = apiManager.getApiURL()
const fullURL = `${baseURL}${url}`
return fetch(fullURL, options)
}
export async function Fetch(url, options) { export async function Fetch(url, options) {
if (!isTokenValid()) { if (!isTokenValid()) {
Cookies.set('ca_redirect', window.location.pathname) Cookies.set('ca_redirect', window.location.pathname)

View File

@@ -37,6 +37,8 @@ import { Divider } from '@mui/material'
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 { useImpersonateUser } from '../../contexts/ImpersonateUserContext.jsx'
import { useChoreDetails } from '../../queries/ChoreQueries.jsx' import { useChoreDetails } from '../../queries/ChoreQueries.jsx'
import { useCircleMembers } from '../../queries/UserQueries.jsx' import { useCircleMembers } from '../../queries/UserQueries.jsx'
import { notInCompletionWindow } from '../../utils/Chores.jsx' import { notInCompletionWindow } from '../../utils/Chores.jsx'
@@ -85,6 +87,7 @@ const ChoreView = () => {
isLoading: isCircleMembersLoading, isLoading: isCircleMembersLoading,
handleRefetch: handleCircleMembersRefetch, handleRefetch: handleCircleMembersRefetch,
} = useCircleMembers() } = useCircleMembers()
const { impersonatedUser } = useImpersonateUser()
const { const {
data: choreData, data: choreData,
@@ -181,7 +184,14 @@ const ChoreView = () => {
}, 1000) }, 1000)
const id = setTimeout(() => { const id = setTimeout(() => {
MarkChoreComplete(choreId, note, completedDate, null) MarkChoreComplete(
choreId,
impersonatedUser
? { completedBy: impersonatedUser.userId, note }
: { note },
completedDate,
null,
)
.then(resp => { .then(resp => {
if (resp.ok) { if (resp.ok) {
return resp.json().then(data => { return resp.json().then(data => {

View File

@@ -44,6 +44,7 @@ import {
import moment from 'moment' import moment from 'moment'
import React, { useEffect } from 'react' import React, { useEffect } from 'react'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { useImpersonateUser } from '../../contexts/ImpersonateUserContext.jsx'
import { UserContext } from '../../contexts/UserContext' import { UserContext } from '../../contexts/UserContext'
import { useError } from '../../service/ErrorProvider' import { useError } from '../../service/ErrorProvider'
import { notInCompletionWindow } from '../../utils/Chores.jsx' import { notInCompletionWindow } from '../../utils/Chores.jsx'
@@ -93,6 +94,8 @@ const ChoreCard = ({
const [secondsLeftToCancel, setSecondsLeftToCancel] = React.useState(null) const [secondsLeftToCancel, setSecondsLeftToCancel] = React.useState(null)
const [timeoutId, setTimeoutId] = React.useState(null) const [timeoutId, setTimeoutId] = React.useState(null)
const { userProfile } = React.useContext(UserContext) const { userProfile } = React.useContext(UserContext)
const { impersonatedUser } = useImpersonateUser()
const { showError } = useError() const { showError } = useError()
useEffect(() => { useEffect(() => {
document.addEventListener('mousedown', handleMenuOutsideClick) document.addEventListener('mousedown', handleMenuOutsideClick)
@@ -187,7 +190,12 @@ const ChoreCard = ({
}, 1000) }, 1000)
const id = setTimeout(() => { const id = setTimeout(() => {
MarkChoreComplete(chore.id, null, null, null) MarkChoreComplete(
chore.id,
impersonatedUser ? { completedBy: impersonatedUser.userId } : null,
null,
null,
)
.then(resp => { .then(resp => {
if (resp.ok) { if (resp.ok) {
return resp.json().then(data => { return resp.json().then(data => {
@@ -249,7 +257,7 @@ const ChoreCard = ({
MarkChoreComplete( MarkChoreComplete(
chore.id, chore.id,
null, impersonatedUser ? { completedBy: impersonatedUser.userId } : null,
new Date(newDate).toISOString(), new Date(newDate).toISOString(),
null, null,
).then(response => { ).then(response => {
@@ -272,7 +280,14 @@ const ChoreCard = ({
}) })
} }
const handleCompleteWithNote = note => { const handleCompleteWithNote = note => {
MarkChoreComplete(chore.id, note, null, null).then(response => { MarkChoreComplete(
chore.id,
impersonatedUser
? { note, completedBy: impersonatedUser.userId }
: { note },
null,
null,
).then(response => {
if (response.ok) { if (response.ok) {
response.json().then(data => { response.json().then(data => {
const newChore = data.res const newChore = data.res

View File

@@ -3,6 +3,7 @@ import { useMediaQuery } from '@mui/material'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { ChoresGrouper } from '../../utils/Chores' import { ChoresGrouper } from '../../utils/Chores'
import CalendarView from '../components/CalendarView' import CalendarView from '../components/CalendarView'
import WelcomeCard from './WelcomeCard'
const Sidepanel = ({ chores }) => { const Sidepanel = ({ chores }) => {
const isLargeScreen = useMediaQuery(theme => theme.breakpoints.up('md')) const isLargeScreen = useMediaQuery(theme => theme.breakpoints.up('md'))
@@ -30,61 +31,28 @@ const Sidepanel = ({ chores }) => {
return null return null
} }
return ( return (
<Sheet <Box>
variant='plain' <WelcomeCard chores={chores} />
sx={{ <Sheet
p: 2, variant='plain'
// borderRadius: 'sm',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
mr: 10,
justifyContent: 'space-between',
boxShadow: 'sm',
borderRadius: 20,
// minimum height to fit the content:
height: '80vh',
width: '290px',
}}
>
{/* <Box
sx={{ sx={{
p: 2,
display: 'flex', display: 'flex',
flexDirection: 'column', flexDirection: 'column',
alignItems: 'center', alignItems: 'center',
mr: 10,
justifyContent: 'space-between',
boxShadow: 'sm',
borderRadius: 20,
height: '80vh',
width: '290px',
}} }}
> >
<PieChart width={200} height={200}> <Box sx={{ width: '100%' }}>
<Pie <CalendarView chores={chores} />
data={dueDatePieChartData} </Box>
dataKey='value' </Sheet>
nameKey='label' </Box>
innerRadius={30}
paddingAngle={5}
cornerRadius={5}
>
{dueDatePieChartData.map((entry, index) => (
<Cell key={`cell-${index}`} fill={entry.color} />
))}
</Pie>
<Legend
layout='horizontal'
align='center'
iconType='circle'
iconSize={8}
fontSize={12}
formatter={(label, value) => `${label}: ${value.payload.value}`}
wrapperStyle={{ paddingTop: 0, marginTop: 0 }} // Adjust padding and margin
/>
<Tooltip />
</PieChart>
</Box> */}
<Box sx={{ width: '100%' }}>
<CalendarView chores={chores} />
</Box>
</Sheet>
) )
} }

View File

@@ -0,0 +1,169 @@
import { Avatar, Box, Button, Sheet, Typography } from '@mui/joy'
import { useContext, useEffect, useState } from 'react'
import { useImpersonateUser } from '../../contexts/ImpersonateUserContext'
import { UserContext } from '../../contexts/UserContext'
import { useCircleMembers } from '../../queries/UserQueries'
import UserModal from '../Modals/Inputs/UserModal'
const WelcomeCard = chores => {
const { impersonatedUser, setImpersonatedUser } = useImpersonateUser()
const [isAdmin, setIsAdmin] = useState(false)
const { userProfile } = useContext(UserContext)
const [isModalOpen, setIsModalOpen] = useState(false)
const [summarytext, setSummaryText] = useState('')
const {
data: circleMembersData,
isLoading: isCircleMembersLoading,
handleRefetch: handleCircleMembersRefetch,
} = useCircleMembers()
useEffect(() => {
if (userProfile && userProfile?.id) {
const members = circleMembersData?.res || []
const isUserAdmin = members.some(
member =>
member.userId === userProfile?.id &&
(member.role === 'admin' || member.role === 'manager'),
)
members.forEach(m =>
console.log('Member:', userProfile?.id, m.id, m.role),
)
console.log('Circle Members:', members)
console.log(isUserAdmin)
setIsAdmin(isUserAdmin)
}
}, [userProfile, circleMembersData])
if (!isAdmin) {
return null
} else if (isCircleMembersLoading || impersonatedUser === null) {
return (
<Sheet
variant='plain'
sx={{
p: 2,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
mr: 10,
justifyContent: 'space-between',
boxShadow: 'sm',
borderRadius: 20,
width: '290px',
mb: 1,
}}
>
<Box sx={{ textAlign: 'center', width: '100%' }}>
<Box sx={{ mb: 2 }}>
<Typography level='title-md' sx={{ mb: 0.5 }}>
Who's checking in?
</Typography>
</Box>
<Button
variant='plain'
color='primary'
onClick={() => setIsModalOpen(true)}
size='sm'
>
Select User
</Button>
<UserModal
isOpen={isModalOpen}
performers={circleMembersData?.res}
onSelect={user => {
setImpersonatedUser(user)
setIsModalOpen(false)
}}
onClose={() => setIsModalOpen(false)}
/>
</Box>
</Sheet>
)
}
return (
<Sheet
variant='plain'
sx={{
p: 2,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
mr: 10,
justifyContent: 'space-between',
boxShadow: 'sm',
borderRadius: 20,
width: '290px',
mb: 1,
}}
>
<Box
sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
mb: 2,
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', width: '100%' }}>
<Box sx={{ mr: 2 }}>
<Avatar
sx={{
width: 48,
height: 48,
borderRadius: '50%',
display: 'flex',
}}
src={impersonatedUser?.image || impersonatedUser?.avatar}
alt={impersonatedUser?.displayName || impersonatedUser?.name}
/>
</Box>
<Box sx={{ flex: 1 }}>
<Typography level='title-md' sx={{ mb: 1, ml: 0.5 }}>
{impersonatedUser?.displayName || impersonatedUser?.name}
</Typography>
{/* <Box sx={{ fontSize: 14, color: 'text.secondary', mb: 0.5 }}>
5 chores assigned, 2 due soon
</Box> */}
<Box>
<Button
variant='plain'
color='neutral'
size='sm'
onClick={() => {
setIsModalOpen(true)
}}
>
Change User
</Button>
<Button
variant='plain'
color='neutral'
size='sm'
sx={{ ml: 0.5 }}
onClick={() => {
setImpersonatedUser(null)
}}
>
Cancel
</Button>
</Box>
</Box>
</Box>
</Box>
<UserModal
isOpen={isModalOpen}
performers={circleMembersData?.res}
onSelect={user => {
setImpersonatedUser(user)
setIsModalOpen(false)
}}
onClose={() => {
setIsModalOpen(false)
}}
/>
</Sheet>
)
}
export default WelcomeCard

View File

@@ -0,0 +1,58 @@
import {
Avatar,
Box,
Button,
List,
ListItem,
Modal,
ModalDialog,
ModalOverflow,
Typography,
} from '@mui/joy'
const UserModal = ({ isOpen, performers = [], onSelect, onClose }) => {
return (
<Modal open={isOpen} onClose={onClose}>
<ModalOverflow>
<ModalDialog size='md' sx={{ minWidth: 360 }}>
<Typography level='h4' sx={{ mb: 2 }}>
Select User
</Typography>
<List sx={{ mb: 2 }}>
{performers.map(user => (
<ListItem
key={user.id}
sx={{
cursor: 'pointer',
'&:hover': {
backgroundColor: 'rgba(0, 0, 0, 0.04)',
},
}}
onClick={() => {
onSelect(user)
onClose()
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
<Avatar
size='lg'
src={user.image || user.avatar}
alt={user.displayName || user.name}
/>
<Typography>{user.displayName || user.name}</Typography>
</Box>
</ListItem>
))}
</List>
<Box sx={{ display: 'flex', justifyContent: 'flex-end', gap: 1 }}>
<Button variant='outlined' color='neutral' onClick={onClose}>
Cancel
</Button>
</Box>
</ModalDialog>
</ModalOverflow>
</Modal>
)
}
export default UserModal

View File

@@ -10,6 +10,9 @@ import {
FormControl, FormControl,
FormHelperText, FormHelperText,
Input, Input,
ListItem,
Option,
Select,
Typography, Typography,
} from '@mui/joy' } from '@mui/joy'
import moment from 'moment' import moment from 'moment'
@@ -28,6 +31,7 @@ import {
JoinCircle, JoinCircle,
LeaveCircle, LeaveCircle,
PutWebhookURL, PutWebhookURL,
UpdateMemberRole,
UpdatePassword, UpdatePassword,
} from '../../utils/Fetcher' } from '../../utils/Fetcher'
import { isPlusAccount } from '../../utils/Helpers' import { isPlusAccount } from '../../utils/Helpers'
@@ -46,6 +50,7 @@ const Settings = () => {
const [circleMembers, setCircleMembers] = useState([]) const [circleMembers, setCircleMembers] = useState([])
const [webhookURL, setWebhookURL] = useState(null) const [webhookURL, setWebhookURL] = useState(null)
const [webhookError, setWebhookError] = useState(null) const [webhookError, setWebhookError] = useState(null)
const [isAdmin, setIsAdmin] = useState(false)
const [changePasswordModal, setChangePasswordModal] = useState(false) const [changePasswordModal, setChangePasswordModal] = useState(false)
useEffect(() => { useEffect(() => {
@@ -70,6 +75,16 @@ const Settings = () => {
}) })
}, []) }, [])
// useEffect when circleMembers and userprofile:
useEffect(() => {
if (userProfile && userProfile.id) {
const isUserAdmin = circleMembers.some(
member => member.userId === userProfile.id && member.role === 'admin',
)
setIsAdmin(isUserAdmin)
}
}, [circleMembers, userProfile])
useEffect(() => { useEffect(() => {
const hash = window.location.hash const hash = window.location.hash
if (hash) { if (hash) {
@@ -222,33 +237,110 @@ const Settings = () => {
</Typography> </Typography>
)} )}
</Box> </Box>
{member.userId !== userProfile.id && member.isActive && (
<Button <Box sx={{ display: 'flex', alignItems: 'center' }}>
disabled={ {member.userId !== userProfile.id && isAdmin && (
circleMembers.find(m => userProfile.id == m.userId).role !== <Select
'admin' size='sm'
} sx={{ mr: 1 }}
variant='outlined' value={member.role}
color='danger' renderValue={() => (
size='sm' <Typography>
onClick={() => { {member.role.charAt(0).toUpperCase() +
const confirmed = confirm( member.role.slice(1)}
`Are you sure you want to remove ${member.displayName} from your circle?`, </Typography>
) )}
if (confirmed) { onChange={(e, value) => {
DeleteCircleMember(member.circleId, member.userId).then( UpdateMemberRole(member.userId, value).then(resp => {
resp => { if (resp.ok) {
if (resp.ok) { const newCircleMembers = circleMembers.map(m => {
alert('Removed member successfully.') if (m.userId === member.userId) {
} m.role = value
}, }
) return m
} })
}} setCircleMembers(newCircleMembers)
> } else {
Remove alert('Failed to update role')
</Button> }
)} })
}}
>
{[
{
value: 'member',
description: 'Just a regular member of the circle',
},
{
value: 'manager',
description:
'Can impersonate users and perform actions on their behalf',
},
{
value: 'admin',
description: 'Full access to the circle',
},
].map((option, index) => (
<Option value={option.value} key={index}>
<ListItem
sx={{
display: 'flex',
flexDirection: 'column',
justifyContent: 'start',
alignItems: 'start',
width: '100%',
gap: 0.5,
}}
>
<Typography
level='title-sm'
sx={{ mb: 0, mt: 0, lineHeight: 1.1 }}
>
{option.value.charAt(0).toUpperCase() +
option.value.slice(1)}
</Typography>
<Typography
level='body-sm'
sx={{ mt: 0, mb: 0, lineHeight: 1.1 }}
>
{option.description}
</Typography>
</ListItem>
</Option>
))}
</Select>
)}
{userProfile.role === 'admin' &&
member.userId !== userProfile.id &&
member.isActive && (
<Button
disabled={
circleMembers.find(m => userProfile.id == m.userId)
.role !== 'admin'
}
variant='outlined'
color='danger'
size='sm'
onClick={() => {
const confirmed = confirm(
`Are you sure you want to remove ${member.displayName} from your circle?`,
)
if (confirmed) {
DeleteCircleMember(
member.circleId,
member.userId,
).then(resp => {
if (resp.ok) {
alert('Removed member successfully.')
}
})
}
}}
>
Remove
</Button>
)}
</Box>
</Box> </Box>
</Card> </Card>
))} ))}

View File

@@ -31,12 +31,12 @@ const RichTextEditor = ({
if (!file) return if (!file) return
try { try {
// Define compression options based on entity type // Define compression options based on entity type ( this need a revist later)
const compressionOptions = { const compressionOptions = {
maxSizeMB: entityType === 'profile' ? 0.5 : 1, // Smaller size for profile images maxSizeMB: entityType === 'profile' ? 0.5 : 1, // Smaller size for profile images
maxWidthOrHeight: entityType === 'profile' ? 320 : 1200, // Smaller dimensions for profile images maxWidthOrHeight: entityType === 'profile' ? 320 : 1200, // Smaller dimensions for profile images
useWebWorker: true, useWebWorker: true,
fileType: 'image/jpeg', // Always convert to JPEG fileType: 'image/jpeg',
} }
// Compress the image // Compress the image
@@ -59,6 +59,7 @@ const RichTextEditor = ({
formData.append('file', compressedJpegFile) formData.append('file', compressedJpegFile)
formData.append('entityId', entityId) formData.append('entityId', entityId)
formData.append('entityType', entityType) formData.append('entityType', entityType)
const response = await UploadFile('/assets/chore', { const response = await UploadFile('/assets/chore', {
method: 'POST', method: 'POST',
body: formData, body: formData,
@@ -83,7 +84,6 @@ const RichTextEditor = ({
}) })
return return
} }
const data = await response.json() const data = await response.json()
const url = resolvePhotoURL(data.url || data.sign) const url = resolvePhotoURL(data.url || data.sign)
// Insert image into Quill // Insert image into Quill