diff --git a/src/components/UserProfileAvatar.jsx b/src/components/UserProfileAvatar.jsx index 0801636..7e1566d 100644 --- a/src/components/UserProfileAvatar.jsx +++ b/src/components/UserProfileAvatar.jsx @@ -1,7 +1,7 @@ import { AdminPanelSettings, DarkModeOutlined, - Email, + GroupAdd, LightModeOutlined, Logout, Person, @@ -24,8 +24,9 @@ import { Typography, useColorScheme, } from '@mui/joy' +import { useMediaQuery } from '@mui/material' import moment from 'moment' -import { useEffect, useState } from 'react' +import { useState } from 'react' import { useNavigate } from 'react-router-dom' import { useImpersonateUser } from '../contexts/ImpersonateUserContext' import useStickyState from '../hooks/useStickyState' @@ -38,29 +39,23 @@ const UserProfileAvatar = () => { const navigate = useNavigate() const { mode, setMode } = useColorScheme() const { data: userProfile } = useUserProfile() - const { impersonatedUser, setImpersonatedUser } = useImpersonateUser() + const { + isImpersonating, + startImpersonation, + stopImpersonation, + canImpersonate, + getEffectiveUser, + } = useImpersonateUser() const { data: circleMembersData } = useCircleMembers() const [isModalOpen, setIsModalOpen] = useState(false) const [isSubscriptionModalOpen, setIsSubscriptionModalOpen] = useState(false) - const [isAdmin, setIsAdmin] = useState(false) const [themeMode, setThemeMode] = useStickyState(mode, 'themeMode') - - useEffect(() => { - if (userProfile && userProfile?.id) { - const members = circleMembersData?.res || [] - const isUserAdmin = members.some( - member => - member.userId === userProfile?.id && - (member.role === 'admin' || member.role === 'manager'), - ) - setIsAdmin(isUserAdmin) - } - }, [userProfile, circleMembersData]) + const isLargeScreen = useMediaQuery(theme => theme.breakpoints.up('lg')) if (!userProfile) return null - const currentUser = impersonatedUser || userProfile - const isImpersonating = !!impersonatedUser + const currentUser = getEffectiveUser(userProfile) + const isAdmin = canImpersonate(userProfile, circleMembersData?.res) const isPlusUser = isPlusAccount(userProfile) const getSubscriptionStatus = () => { @@ -310,7 +305,7 @@ const UserProfileAvatar = () => { {isImpersonating && ( setImpersonatedUser(null)} + onClick={() => stopImpersonation()} sx={{ borderRadius: 'var(--joy-radius-sm)', '&:hover': { @@ -367,7 +362,7 @@ const UserProfileAvatar = () => { navigate('/settings/detailed#sidepanel')} + onClick={() => navigate('/settings/circle')} sx={{ borderRadius: 'var(--joy-radius-sm)', '&:hover': { @@ -375,21 +370,49 @@ const UserProfileAvatar = () => { }, }} > - - + + - Sidepanel Settings + Invite People - Customize layout & cards + Add members to your circle + {isLargeScreen && ( + navigate('/settings/detailed#sidepanel')} + sx={{ + borderRadius: 'var(--joy-radius-sm)', + '&:hover': { + backgroundColor: 'var(--joy-palette-neutral-softHoverBg)', + }, + }} + > + + + + + + Side Panel Settings + + + Customize layout & cards + + + + )} { )} - { support@donetick.com - + */} @@ -500,7 +523,7 @@ const UserProfileAvatar = () => { isOpen={isModalOpen} performers={circleMembersData?.res} onSelect={user => { - setImpersonatedUser(user) + startImpersonation(user, userProfile) setIsModalOpen(false) }} onClose={() => setIsModalOpen(false)} diff --git a/src/contexts/ImpersonateUserContext.jsx b/src/contexts/ImpersonateUserContext.jsx index 23f5503..2964a37 100644 --- a/src/contexts/ImpersonateUserContext.jsx +++ b/src/contexts/ImpersonateUserContext.jsx @@ -1,15 +1,150 @@ -import { createContext, useContext, useState } from 'react' +import { + createContext, + useCallback, + useContext, + useEffect, + useState, +} from 'react' const ImpersonateUserContext = createContext() export const useImpersonateUser = () => useContext(ImpersonateUserContext) export const ImpersonateUserProvider = ({ children }) => { - const [impersonatedUser, setImpersonatedUser] = useState(null) + const [impersonationState, setImpersonationState] = useState({ + isImpersonating: false, + impersonatedUser: null, + originalUser: null, + }) + + // Start impersonation + const startImpersonation = useCallback((userToImpersonate, currentUser) => { + console.log('Starting impersonation:', { userToImpersonate, currentUser }) + const newState = { + isImpersonating: true, + impersonatedUser: userToImpersonate, + originalUser: currentUser, + } + + setImpersonationState(newState) + + // Store in localStorage for persistence across page refreshes + localStorage.setItem('impersonation', JSON.stringify(newState)) + localStorage.setItem('impersonatedUserId', userToImpersonate.userId) + }, []) + + // Stop impersonation + const stopImpersonation = useCallback(() => { + console.log('Stopping impersonation') + setImpersonationState({ + isImpersonating: false, + impersonatedUser: null, + originalUser: null, + }) + + // Remove from localStorage + localStorage.removeItem('impersonation') + localStorage.removeItem('impersonatedUserId') + }, []) + + // Get effective user (impersonated user if impersonating, otherwise current user) + const getEffectiveUser = useCallback( + currentUser => { + if ( + impersonationState.isImpersonating && + impersonationState.impersonatedUser + ) { + return impersonationState.impersonatedUser + } + return currentUser + }, + [impersonationState], + ) + + // Get impersonation headers for API calls + const getImpersonationHeaders = useCallback(() => { + if ( + impersonationState.isImpersonating && + impersonationState.impersonatedUser + ) { + return { + 'X-Impersonate-User-ID': + impersonationState.impersonatedUser.id.toString(), + } + } + return {} + }, [impersonationState]) + + // Check if user can impersonate (admin or manager) + // Note: This is a basic check. The component using this should also check circle membership + const canImpersonate = useCallback((user, circleMembers = []) => { + if (!user?.id) return false + + // If circleMembers is provided, check role from there + if (circleMembers.length > 0) { + const member = circleMembers.find(m => m.userId === user.id) + return member?.role === 'admin' || member?.role === 'manager' + } + + // Fallback to user.role property (if available) + return user?.role === 'admin' || user?.role === 'manager' + }, []) + + // Restore impersonation state from localStorage on mount + useEffect(() => { + const storedImpersonation = localStorage.getItem('impersonation') + if (storedImpersonation) { + try { + const parsed = JSON.parse(storedImpersonation) + if ( + parsed.isImpersonating && + parsed.impersonatedUser && + parsed.originalUser + ) { + setImpersonationState(parsed) + } + } catch (error) { + console.error('Failed to restore impersonation state:', error) + localStorage.removeItem('impersonation') + } + } + }, []) + + const value = { + // State + ...impersonationState, + + // Actions + startImpersonation, + stopImpersonation, + + getEffectiveUser, + getImpersonationHeaders, + canImpersonate, + + // Computed properties + isImpersonating: impersonationState.isImpersonating, + impersonatedUser: impersonationState.impersonatedUser, + originalUser: impersonationState.originalUser, + + // Legacy support + setImpersonatedUser: user => { + if (user) { + // If setting a user, assume we're starting impersonation + // Note: This won't have originalUser, so it's for backward compatibility only + setImpersonationState(prev => ({ + isImpersonating: true, + impersonatedUser: user, + originalUser: prev.originalUser, + })) + } else { + stopImpersonation() + } + }, + } + return ( - + {children} ) diff --git a/src/utils/TokenManager.jsx b/src/utils/TokenManager.jsx index 01d2ecb..98d0065 100644 --- a/src/utils/TokenManager.jsx +++ b/src/utils/TokenManager.jsx @@ -137,9 +137,13 @@ export async function Fetch(url, options) { } export const HEADERS = () => { + // Import here to avoid circular dependency issues + const impersonateUserId = localStorage.getItem('impersonatedUserId') + return { 'Content-Type': 'application/json', Authorization: 'Bearer ' + localStorage.getItem('ca_token'), + ...(impersonateUserId && { 'X-Impersonate-User-ID': impersonateUserId }), } } diff --git a/src/views/Authorization/LoginView.jsx b/src/views/Authorization/LoginView.jsx index b2ee9f8..b205649 100644 --- a/src/views/Authorization/LoginView.jsx +++ b/src/views/Authorization/LoginView.jsx @@ -648,7 +648,7 @@ const LoginView = () => { variant='plain' size='sm' onClick={() => { - window.open('https://donetick.com/privacy-policy', '_blank') + window.open('https://donetick.com/privacy', '_blank') }} > Privacy Policy diff --git a/src/views/Chores/UserSwitcher.jsx b/src/views/Chores/UserSwitcher.jsx index c48956e..6483435 100644 --- a/src/views/Chores/UserSwitcher.jsx +++ b/src/views/Chores/UserSwitcher.jsx @@ -6,30 +6,24 @@ import { useImpersonateUser } from '../../contexts/ImpersonateUserContext' import { useCircleMembers, useUserProfile } from '../../queries/UserQueries' import UserModal from '../Modals/Inputs/UserModal' const UserSwitcher = () => { - const { impersonatedUser, setImpersonatedUser } = useImpersonateUser() - const [isAdmin, setIsAdmin] = useState(false) + const { + impersonatedUser, + isImpersonating, + startImpersonation, + stopImpersonation, + canImpersonate + } = useImpersonateUser() const { data: userProfile } = useUserProfile() - const [isModalOpen, setIsModalOpen] = useState(false) const { data: circleMembersData, isLoading: isCircleMembersLoading } = 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'), - ) - - setIsAdmin(isUserAdmin) - } - }, [userProfile, circleMembersData]) + // Check if current user can impersonate + const isAdmin = canImpersonate(userProfile, circleMembersData?.res) if (!isAdmin) { return null - } else if (isCircleMembersLoading || impersonatedUser === null) { + } else if (isCircleMembersLoading || !isImpersonating) { return ( { isOpen={isModalOpen} performers={circleMembersData?.res} onSelect={user => { - setImpersonatedUser(user) + startImpersonation(user, userProfile) setIsModalOpen(false) }} onClose={() => setIsModalOpen(false)} @@ -167,7 +161,7 @@ const UserSwitcher = () => { size='sm' sx={{ ml: 0.5 }} onClick={() => { - setImpersonatedUser(null) + stopImpersonation() }} > Cancel @@ -180,7 +174,7 @@ const UserSwitcher = () => { isOpen={isModalOpen} performers={circleMembersData?.res} onSelect={user => { - setImpersonatedUser(user) + startImpersonation(user, userProfile) setIsModalOpen(false) }} onClose={() => { diff --git a/src/views/History/ChoreHistory.jsx b/src/views/History/ChoreHistory.jsx index d40b61a..0005566 100644 --- a/src/views/History/ChoreHistory.jsx +++ b/src/views/History/ChoreHistory.jsx @@ -22,6 +22,7 @@ import moment from 'moment' import { useEffect, useState } from 'react' import { Link, useParams } from 'react-router-dom' import { LoadingScreen } from '../../components/animations' +import useConfirmationModal from '../../hooks/useConfirmationModal' import { ChoreHistoryStatus } from '../../utils/Chores' import { DeleteChoreHistory, @@ -29,6 +30,7 @@ import { GetChoreHistory, UpdateChoreHistory, } from '../../utils/Fetcher' +import ConfirmationModal from '../Modals/Inputs/ConfirmationModal' import EditHistoryModal from '../Modals/EditHistoryModal' import HistoryCard from './HistoryCard' @@ -42,6 +44,31 @@ const ChoreHistory = () => { const { choreId } = useParams() const [isEditModalOpen, setIsEditModalOpen] = useState(false) const [editHistory, setEditHistory] = useState({}) + const { confirmModalConfig, showConfirmation } = useConfirmationModal() + + const handleDelete = historyEntry => { + showConfirmation( + `Are you sure you want to delete this history record?`, + 'Delete History Record', + () => { + DeleteChoreHistory(choreId, historyEntry.id).then(() => { + const newHistory = choreHistory.filter( + record => record.id !== historyEntry.id, + ) + setChoresHistory(newHistory) + updateHistoryInfo(newHistory, userHistory, performers) + }) + }, + 'Delete', + 'Cancel', + 'danger', + ) + } + + const handleEdit = historyEntry => { + setIsEditModalOpen(true) + setEditHistory(historyEntry) + } useEffect(() => { setIsLoading(true) // Start loading @@ -281,10 +308,9 @@ const ChoreHistory = () => { {choreHistory.map((historyEntry, index) => ( { - setIsEditModalOpen(true) - setEditHistory(historyEntry) - }} + onClick={() => handleEdit(historyEntry)} + onEditClick={handleEdit} + onDeleteClick={handleDelete} historyEntry={historyEntry} performers={performers} allHistory={choreHistory} @@ -334,6 +360,7 @@ const ChoreHistory = () => { }} historyRecord={editHistory} /> + ) } diff --git a/src/views/History/HistoryCard.jsx b/src/views/History/HistoryCard.jsx index 68b524e..c105772 100644 --- a/src/views/History/HistoryCard.jsx +++ b/src/views/History/HistoryCard.jsx @@ -3,6 +3,8 @@ import { CalendarMonth, Check, CheckCircle, + Delete, + Edit, EventNote, HourglassEmpty, Person, @@ -16,12 +18,14 @@ import { Box, Chip, Grid, + IconButton, ListDivider, ListItem, ListItemContent, Typography, } from '@mui/joy' import moment from 'moment' +import React, { useEffect, useRef, useState } from 'react' import { TASK_COLOR } from '../../utils/Colors.jsx' const getCompletedChip = historyEntry => { @@ -100,10 +104,22 @@ const HistoryCard = ({ historyEntry, index, onClick, + onEditClick, + onDeleteClick, }) => { const performer = performers.find(p => p.userId === historyEntry.completedBy) const assignedTo = performers.find(p => p.userId === historyEntry.assignedTo) + // Swipe functionality state + const [swipeTranslateX, setSwipeTranslateX] = useState(0) + const [isDragging, setIsDragging] = useState(false) + const [isSwipeRevealed, setIsSwipeRevealed] = useState(false) + const [hoverTimer, setHoverTimer] = useState(null) + const swipeThreshold = 80 + const maxSwipeDistance = 200 + const dragStartX = useRef(0) + const cardRef = useRef(null) + const formatTimeDifference = (startDate, endDate) => { const diffInMinutes = moment(startDate).diff(endDate, 'minutes') let timeValue = diffInMinutes @@ -150,23 +166,265 @@ const HistoryCard = ({ ) } + // Swipe gesture handlers + const handleTouchStart = e => { + dragStartX.current = e.touches[0].clientX + setIsDragging(true) + } + + const handleTouchMove = e => { + if (!isDragging) return + + const currentX = e.touches[0].clientX + const deltaX = currentX - dragStartX.current + + if (isSwipeRevealed) { + if (deltaX > 0) { + const clampedDelta = Math.min(deltaX - maxSwipeDistance, 0) + setSwipeTranslateX(clampedDelta) + } + } else { + if (deltaX < 0) { + const clampedDelta = Math.max(deltaX, -maxSwipeDistance) + setSwipeTranslateX(clampedDelta) + } + } + } + + const handleTouchEnd = () => { + if (!isDragging) return + setIsDragging(false) + + if (isSwipeRevealed) { + if (swipeTranslateX > -swipeThreshold) { + setSwipeTranslateX(0) + setIsSwipeRevealed(false) + } else { + setSwipeTranslateX(-maxSwipeDistance) + } + } else { + if (Math.abs(swipeTranslateX) > swipeThreshold) { + setSwipeTranslateX(-maxSwipeDistance) + setIsSwipeRevealed(true) + } else { + setSwipeTranslateX(0) + setIsSwipeRevealed(false) + } + } + } + + const handleMouseDown = e => { + dragStartX.current = e.clientX + setIsDragging(true) + } + + const handleMouseMove = e => { + if (!isDragging) return + + const currentX = e.clientX + const deltaX = currentX - dragStartX.current + + if (isSwipeRevealed) { + if (deltaX > 0) { + const clampedDelta = Math.min(deltaX - maxSwipeDistance, 0) + setSwipeTranslateX(clampedDelta) + } + } else { + if (deltaX < 0) { + const clampedDelta = Math.max(deltaX, -maxSwipeDistance) + setSwipeTranslateX(clampedDelta) + } + } + } + + const handleMouseUp = () => { + if (!isDragging) return + setIsDragging(false) + + if (isSwipeRevealed) { + if (swipeTranslateX > -swipeThreshold) { + setSwipeTranslateX(0) + setIsSwipeRevealed(false) + } else { + setSwipeTranslateX(-maxSwipeDistance) + } + } else { + if (Math.abs(swipeTranslateX) > swipeThreshold) { + setSwipeTranslateX(-maxSwipeDistance) + setIsSwipeRevealed(true) + } else { + setSwipeTranslateX(0) + setIsSwipeRevealed(false) + } + } + } + + const resetSwipe = () => { + setSwipeTranslateX(0) + setIsSwipeRevealed(false) + } + + // Hover functionality for desktop - only trigger from drag area + const handleMouseEnter = () => { + if (isSwipeRevealed) return + const timer = setTimeout(() => { + setSwipeTranslateX(-maxSwipeDistance) + setIsSwipeRevealed(true) + setHoverTimer(null) + }, 800) // Shorter delay for drag area + setHoverTimer(timer) + } + + const handleMouseLeave = () => { + if (hoverTimer) { + clearTimeout(hoverTimer) + setHoverTimer(null) + } + // Only add hide timer if we're leaving the drag area and actions are NOT revealed + // If actions are revealed, let the action area handle the hiding + if (!isSwipeRevealed) { + // Actions are not revealed, so we can safely hide after delay + const hideTimer = setTimeout(() => { + resetSwipe() + }, 300) + setHoverTimer(hideTimer) + } + } + + const handleActionAreaMouseEnter = () => { + // Clear any pending timer when entering action area + if (hoverTimer) { + clearTimeout(hoverTimer) + setHoverTimer(null) + } + } + + const handleActionAreaMouseLeave = () => { + // Hide immediately when leaving action area + if (isSwipeRevealed) { + resetSwipe() + } + } + + // Clean up timer on unmount + useEffect(() => { + return () => { + if (hoverTimer) { + clearTimeout(hoverTimer) + } + } + }, [hoverTimer]) + return ( <> - { + // Only clear timers, don't auto-hide + if (hoverTimer) { + clearTimeout(hoverTimer) + setHoverTimer(null) + } }} > + {/* Action buttons underneath (revealed on swipe) */} + {(onEditClick || onDeleteClick) && ( + + {onEditClick && ( + { + e.stopPropagation() + resetSwipe() + onEditClick(historyEntry) + }} + sx={{ + width: 40, + height: 40, + mx: 1, + }} + > + + + )} + + {onDeleteClick && ( + { + e.stopPropagation() + resetSwipe() + onDeleteClick(historyEntry) + }} + sx={{ + width: 40, + height: 40, + mx: 1, + }} + > + + + )} + + )} + + {/* Main card content */} + { + if (isSwipeRevealed) { + resetSwipe() + return + } + if (onClick) onClick() + }} + sx={{ + cursor: onClick ? 'pointer' : 'default', + py: 1.5, + px: 2, + position: 'relative', + bgcolor: 'background.surface', + transform: `translateX(${swipeTranslateX}px)`, + transition: isDragging ? 'none' : 'transform 0.3s ease-out', + zIndex: 1, + width: '100%', + '&:hover': onClick + ? { + bgcolor: isSwipeRevealed + ? 'background.surface' + : 'background.level1', + } + : {}, + borderRadius: 'sm', + }} + onTouchStart={handleTouchStart} + onTouchMove={handleTouchMove} + onTouchEnd={handleTouchEnd} + onMouseDown={handleMouseDown} + onMouseMove={handleMouseMove} + onMouseUp={handleMouseUp} + > {/* First Row/Column: Status and Time Info */} @@ -303,6 +561,56 @@ const HistoryCard = ({ + + {/* Right drag area - only triggers reveal on hover */} + {(onEditClick || onDeleteClick) && ( + + {/* Drag indicator dots */} + + {[...Array(3)].map((_, i) => ( + + ))} + + + )} {/* Compact Divider with Time Difference */} @@ -330,6 +638,7 @@ const HistoryCard = ({ )} + ) } diff --git a/src/views/Labels/LabelView.jsx b/src/views/Labels/LabelView.jsx index b60f7ab..125962b 100644 --- a/src/views/Labels/LabelView.jsx +++ b/src/views/Labels/LabelView.jsx @@ -232,7 +232,7 @@ const LabelCard = ({ label, onEditClick, onDeleteClick, currentUserId }) => { onMouseLeave={handleActionAreaMouseLeave} > { @@ -244,18 +244,13 @@ const LabelCard = ({ label, onEditClick, onDeleteClick, currentUserId }) => { width: 40, height: 40, mx: 1, - bgcolor: 'primary.100', - color: 'primary.600', - '&:hover': { - bgcolor: 'primary.200', - }, }} > { @@ -267,11 +262,6 @@ const LabelCard = ({ label, onEditClick, onDeleteClick, currentUserId }) => { width: 40, height: 40, mx: 1, - bgcolor: 'danger.100', - color: 'danger.600', - '&:hover': { - bgcolor: 'danger.200', - }, }} > diff --git a/src/views/Modals/EditHistoryModal.jsx b/src/views/Modals/EditHistoryModal.jsx index 4995753..0047321 100644 --- a/src/views/Modals/EditHistoryModal.jsx +++ b/src/views/Modals/EditHistoryModal.jsx @@ -25,7 +25,12 @@ function EditHistoryModal({ config, historyRecord }) { const [notes, setNotes] = useState(historyRecord.notes) const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false) return ( - + Edit History @@ -82,19 +87,9 @@ function EditHistoryModal({ config, historyRecord }) { > Save - - { const { choreId } = useParams() @@ -50,6 +54,17 @@ const TimerDetails = () => { const [isDragging, setIsDragging] = useState(false) const [isTouchDevice, setIsTouchDevice] = useState(false) + // Fetch circle members data + const { data: circleMembersData, isLoading: isCircleMembersLoading } = + useCircleMembers() + + const members = circleMembersData?.res || [] + + // Helper function to find member by user ID + const getMemberById = userId => { + return members?.find(member => member.userId === userId) + } + // Detect if device supports touch useEffect(() => { const checkTouchDevice = () => { @@ -1230,16 +1245,56 @@ const TimerDetails = () => { > {formatDuration(realTimeDuration)} - {isOngoing && ( - - Live - - )} + + {isOngoing && ( + + Live + + )} + {/* User chip showing who started the session */} + {pause.updatedBy && + pause.updatedBy !== 0 && + (() => { + const sessionUser = getMemberById( + pause.updatedBy, + ) + return sessionUser ? ( + + {sessionUser?.displayName?.charAt( + 0, + ) || + sessionUser?.name?.charAt( + 0, + ) || } + + } + sx={{ fontSize: '0.7rem' }} + > + {sessionUser?.displayName || + sessionUser?.name || + 'Unknown'} + + ) : null + })()} + {/* Session details */}