Part of #145. Fourteen files across seven feature areas that had no namespace yet: the things create/edit modals and their history, the chore history detail and edit modals, the activity feed, the points view and its redemption modal, the project view with its selector and icon picker, the label view, the advanced filter builder and the timer edit modal. Seven new namespaces registered in `src/i18n/config.js` in one change rather than one per PR, so the `ns:` array is touched once and my other extraction PRs cannot conflict with this one. Namespaces stay feature-scoped as described in #145; if you'd rather fold any of these into `common` or `chores`, say which and I'll rework it. Dictionaries: `history` 57 keys, `points` 50, `timer` 25, `projects` 16, `things` 14, `filters` 13, `labels` 5. English only — no translations, no behaviour change. Every t() value is checked against this branch's base: the string must appear character-for-character in the code it replaces (226 call sites). Three values in `UserPoints` are matched loosely and worth naming. The base builds the leaderboard heading and subtitle around a ternary — `{mode === 'points' ? 'Points' : 'Tasks'} Leaderboard` and `Rankings based on {…} during the selected time period` — so neither full sentence exists contiguously in the source. Each key holds exactly what one branch renders. The sentences are kept whole rather than split around the ternary, since a sentence assembled from fragments cannot be reordered by a translator.
974 lines
32 KiB
JavaScript
974 lines
32 KiB
JavaScript
import {
|
|
Bar,
|
|
BarChart,
|
|
CartesianGrid,
|
|
ResponsiveContainer,
|
|
XAxis,
|
|
YAxis,
|
|
} from 'recharts'
|
|
|
|
import {
|
|
AccountBalanceWallet,
|
|
Analytics,
|
|
AssignmentTurnedIn,
|
|
CreditCard,
|
|
EmojiEvents,
|
|
MilitaryTech,
|
|
Redeem,
|
|
Star,
|
|
SwapHoriz,
|
|
Timeline,
|
|
Toll,
|
|
TrendingUp,
|
|
WorkspacePremium,
|
|
} from '@mui/icons-material'
|
|
import {
|
|
Avatar,
|
|
Box,
|
|
Button,
|
|
Card,
|
|
CardContent,
|
|
Chip,
|
|
Container,
|
|
Grid,
|
|
Option,
|
|
Select,
|
|
Stack,
|
|
Tab,
|
|
TabList,
|
|
Tabs,
|
|
Typography,
|
|
} from '@mui/joy'
|
|
import { useEffect, useState } from 'react'
|
|
import moment from 'moment'
|
|
import { useTranslation } from 'react-i18next'
|
|
import LoadingComponent from '../components/Loading.jsx'
|
|
|
|
import { useChoresHistory } from '../../queries/ChoreQueries.jsx'
|
|
import { useCircleMembers, useUserProfile } from '../../queries/UserQueries.jsx'
|
|
import { RedeemPoints } from '../../utils/Fetcher.jsx'
|
|
import { resolvePhotoURL } from '../../utils/Helpers.jsx'
|
|
import RedeemPointsModal from '../Modals/RedeemPointsModal'
|
|
const UserPoints = () => {
|
|
const { t } = useTranslation('points')
|
|
const [tabValue, setTabValue] = useState(7)
|
|
const [isRedeemModalOpen, setIsRedeemModalOpen] = useState(false)
|
|
const [leaderboardMode, setLeaderboardMode] = useState('points') // 'points' or 'tasks'
|
|
|
|
const {
|
|
data: circleMembersData,
|
|
isLoading: isCircleMembersLoading,
|
|
handleRefetch: handleCircleMembersRefetch,
|
|
} = useCircleMembers()
|
|
|
|
const {
|
|
data: choresHistoryData,
|
|
isLoading: isChoresHistoryLoading,
|
|
handleLimitChange: handleChoresHistoryLimitChange,
|
|
} = useChoresHistory(7, true)
|
|
|
|
const { data: userProfile } = useUserProfile()
|
|
const [selectedUser, setSelectedUser] = useState(userProfile?.id)
|
|
const [circleUsers, setCircleUsers] = useState([])
|
|
const [selectedHistory, setSelectedHistory] = useState([])
|
|
|
|
useEffect(() => {
|
|
if (circleMembersData && choresHistoryData && userProfile) {
|
|
setCircleUsers(circleMembersData.res)
|
|
setSelectedHistory(
|
|
generateWeeklySummary(choresHistoryData, userProfile?.id),
|
|
)
|
|
}
|
|
}, [circleMembersData, choresHistoryData, userProfile])
|
|
|
|
useEffect(() => {
|
|
if (choresHistoryData) {
|
|
var history
|
|
if (tabValue === 7) {
|
|
history = generateWeeklySummary(choresHistoryData, selectedUser)
|
|
} else if (tabValue === 30) {
|
|
history = generateMonthSummary(choresHistoryData, selectedUser)
|
|
} else if (tabValue === 6 * 30) {
|
|
history = generateMonthlySummary(choresHistoryData, selectedUser)
|
|
} else {
|
|
history = generateYearlySummary(choresHistoryData, selectedUser)
|
|
}
|
|
setSelectedHistory(history)
|
|
}
|
|
}, [selectedUser, choresHistoryData, tabValue])
|
|
|
|
useEffect(() => {
|
|
setSelectedUser(userProfile?.id)
|
|
}, [userProfile])
|
|
|
|
const generateWeeklySummary = (history, userId) => {
|
|
const daysAggregated = []
|
|
for (let i = 6; i > -1; i--) {
|
|
const currentDate = new Date()
|
|
currentDate.setDate(currentDate.getDate() - i)
|
|
daysAggregated.push({
|
|
label: moment(currentDate).format('ddd'),
|
|
points: 0,
|
|
tasks: 0,
|
|
})
|
|
}
|
|
history.forEach(chore => {
|
|
const dayName = moment(chore.performedAt).format('ddd')
|
|
|
|
const dayIndex = daysAggregated.findIndex(dayData => {
|
|
if (userId)
|
|
return dayData.label === dayName && chore.completedBy === userId
|
|
return dayData.label === dayName
|
|
})
|
|
if (dayIndex !== -1) {
|
|
if (chore.points) daysAggregated[dayIndex].points += chore.points
|
|
daysAggregated[dayIndex].tasks += 1
|
|
}
|
|
})
|
|
return daysAggregated
|
|
}
|
|
|
|
const generateMonthSummary = (history, userId) => {
|
|
const daysAggregated = []
|
|
for (let i = 29; i > -1; i--) {
|
|
const currentDate = new Date()
|
|
currentDate.setDate(currentDate.getDate() - i)
|
|
daysAggregated.push({
|
|
label: moment(currentDate).format('D'),
|
|
points: 0,
|
|
tasks: 0,
|
|
})
|
|
}
|
|
history.forEach(chore => {
|
|
const dayName = moment(chore.performedAt).format('D')
|
|
|
|
const dayIndex = daysAggregated.findIndex(dayData => {
|
|
if (userId)
|
|
return dayData.label === dayName && chore.completedBy === userId
|
|
return dayData.label === dayName
|
|
})
|
|
|
|
if (dayIndex !== -1) {
|
|
if (chore.points) daysAggregated[dayIndex].points += chore.points
|
|
daysAggregated[dayIndex].tasks += 1
|
|
}
|
|
})
|
|
|
|
return daysAggregated
|
|
}
|
|
|
|
const generateMonthlySummary = (history, userId) => {
|
|
const monthlyAggregated = []
|
|
for (let i = 5; i > -1; i--) {
|
|
const currentMonth = new Date()
|
|
currentMonth.setMonth(currentMonth.getMonth() - i)
|
|
monthlyAggregated.push({
|
|
label: moment(currentMonth).format('MMM'),
|
|
points: 0,
|
|
tasks: 0,
|
|
})
|
|
}
|
|
history.forEach(chore => {
|
|
const monthName = moment(chore.performedAt).format('MMM')
|
|
|
|
const monthIndex = monthlyAggregated.findIndex(monthData => {
|
|
if (userId)
|
|
return monthData.label === monthName && chore.completedBy === userId
|
|
return monthData.label === monthName
|
|
})
|
|
|
|
if (monthIndex !== -1) {
|
|
if (chore.points) monthlyAggregated[monthIndex].points += chore.points
|
|
monthlyAggregated[monthIndex].tasks += 1
|
|
}
|
|
})
|
|
return monthlyAggregated
|
|
}
|
|
|
|
const generateYearlySummary = (history, userId) => {
|
|
const yearlyAggregated = []
|
|
|
|
for (let i = 11; i > -1; i--) {
|
|
const currentYear = new Date()
|
|
currentYear.setFullYear(currentYear.getFullYear() - i)
|
|
yearlyAggregated.push({
|
|
label: moment(currentYear).format('YYYY'),
|
|
points: 0,
|
|
tasks: 0,
|
|
})
|
|
}
|
|
history.forEach(chore => {
|
|
const yearName = moment(chore.performedAt).format('YYYY')
|
|
|
|
const yearIndex = yearlyAggregated.findIndex(yearData => {
|
|
if (userId)
|
|
return yearData.label === yearName && chore.completedBy === userId
|
|
return yearData.label === yearName
|
|
})
|
|
|
|
if (yearIndex !== -1) {
|
|
if (chore.points) yearlyAggregated[yearIndex].points += chore.points
|
|
yearlyAggregated[yearIndex].tasks += 1
|
|
}
|
|
})
|
|
return yearlyAggregated
|
|
}
|
|
|
|
if (isChoresHistoryLoading || isCircleMembersLoading || !userProfile) {
|
|
return <LoadingComponent />
|
|
}
|
|
|
|
// Calculate leaderboard data for the current time period
|
|
const calculateLeaderboard = () => {
|
|
if (!choresHistoryData || !circleUsers.length) return []
|
|
|
|
// Calculate points for each user in the current time period
|
|
const userPeriodStats = {}
|
|
|
|
// Initialize stats for all users
|
|
circleUsers.forEach(user => {
|
|
userPeriodStats[user.userId] = {
|
|
userId: user.userId,
|
|
displayName: user.displayName,
|
|
image: user.image,
|
|
totalPoints: user.points || 0,
|
|
availablePoints: (user.points || 0) - (user.pointsRedeemed || 0),
|
|
periodPoints: 0,
|
|
periodTasks: 0,
|
|
}
|
|
})
|
|
|
|
// Calculate period-specific stats from history
|
|
choresHistoryData.forEach(historyEntry => {
|
|
const userId = historyEntry.completedBy
|
|
if (userPeriodStats[userId]) {
|
|
userPeriodStats[userId].periodPoints += historyEntry.points || 0
|
|
userPeriodStats[userId].periodTasks += 1
|
|
}
|
|
})
|
|
|
|
// Convert to array and sort by selected mode
|
|
const sortField =
|
|
leaderboardMode === 'points' ? 'periodPoints' : 'periodTasks'
|
|
return Object.values(userPeriodStats)
|
|
.sort((a, b) => b[sortField] - a[sortField])
|
|
.map((user, index) => ({
|
|
...user,
|
|
rank: index + 1,
|
|
avgPointsPerTask:
|
|
user.periodTasks > 0
|
|
? (user.periodPoints / user.periodTasks).toFixed(1)
|
|
: 0,
|
|
}))
|
|
}
|
|
|
|
const leaderboardData = calculateLeaderboard()
|
|
|
|
// Get trophy icons for top 3
|
|
const getTrophyIcon = rank => {
|
|
switch (rank) {
|
|
case 1:
|
|
return <EmojiEvents sx={{ color: '#FFD700', fontSize: '1.2rem' }} /> // Gold
|
|
case 2:
|
|
return (
|
|
<WorkspacePremium sx={{ color: '#C0C0C0', fontSize: '1.2rem' }} />
|
|
) // Silver
|
|
case 3:
|
|
return <MilitaryTech sx={{ color: '#CD7F32', fontSize: '1.2rem' }} /> // Bronze
|
|
default:
|
|
return <Star sx={{ color: 'text.secondary', fontSize: '1rem' }} />
|
|
}
|
|
}
|
|
|
|
return (
|
|
<Container
|
|
maxWidth='md'
|
|
sx={{
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
px: { xs: 2, sm: 3 },
|
|
}}
|
|
>
|
|
{/* Enhanced Leaderboard Header Section */}
|
|
<Box sx={{ mb: 4 }}>
|
|
<Stack spacing={2}>
|
|
{/* Title Row */}
|
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
|
<EmojiEvents sx={{ fontSize: '2rem', color: '#FFD700' }} />
|
|
<Stack sx={{ flex: 1 }}>
|
|
<Typography
|
|
level='h3'
|
|
sx={{ fontWeight: 'lg', color: 'text.primary' }}
|
|
>
|
|
{leaderboardMode === 'points'
|
|
? t('leaderboard.titlePoints')
|
|
: t('leaderboard.titleTasks')}
|
|
</Typography>
|
|
<Typography level='body-sm' sx={{ color: 'text.secondary' }}>
|
|
{leaderboardMode === 'points'
|
|
? t('leaderboard.subtitlePoints')
|
|
: t('leaderboard.subtitleTasks')}
|
|
</Typography>
|
|
</Stack>
|
|
</Box>
|
|
|
|
{/* Filters Row - Responsive */}
|
|
<Box
|
|
sx={{
|
|
display: 'flex',
|
|
flexDirection: { xs: 'column', sm: 'row' },
|
|
gap: 2,
|
|
alignItems: { xs: 'stretch', sm: 'center' },
|
|
justifyContent: { xs: 'flex-start', sm: 'space-between' },
|
|
}}
|
|
>
|
|
{/* Time Period Filter */}
|
|
<Box
|
|
sx={{
|
|
display: 'flex',
|
|
justifyContent: { xs: 'center', sm: 'flex-start' },
|
|
}}
|
|
>
|
|
<Tabs
|
|
onChange={(e, tabValue) => {
|
|
setTabValue(tabValue)
|
|
handleChoresHistoryLimitChange(tabValue)
|
|
}}
|
|
value={tabValue}
|
|
size='sm'
|
|
sx={{
|
|
borderRadius: 6,
|
|
backgroundColor: 'background.surface',
|
|
border: '1px solid',
|
|
borderColor: 'divider',
|
|
}}
|
|
>
|
|
<TabList
|
|
disableUnderline
|
|
sx={{
|
|
borderRadius: 6,
|
|
backgroundColor: 'transparent',
|
|
p: 0.3,
|
|
gap: 0.3,
|
|
}}
|
|
>
|
|
{[
|
|
{ label: t('tabs.short7d'), value: 7 },
|
|
{ label: t('tabs.short6m'), value: 6 * 30 },
|
|
{ label: t('tabs.shortAll'), value: 24 * 30 },
|
|
].map((tab, index) => (
|
|
<Tab
|
|
key={index}
|
|
sx={{
|
|
borderRadius: 4,
|
|
minWidth: 'auto',
|
|
px: 1.5,
|
|
py: 0.5,
|
|
fontSize: 'xs',
|
|
fontWeight: 500,
|
|
color: 'text.secondary',
|
|
'&.Mui-selected': {
|
|
color: 'primary.plainColor',
|
|
backgroundColor: 'primary.softBg',
|
|
fontWeight: 600,
|
|
},
|
|
'&:hover': {
|
|
backgroundColor: 'neutral.softHoverBg',
|
|
},
|
|
}}
|
|
disableIndicator
|
|
value={tab.value}
|
|
>
|
|
{tab.label}
|
|
</Tab>
|
|
))}
|
|
</TabList>
|
|
</Tabs>
|
|
</Box>
|
|
|
|
{/* Toggle between points and tasks */}
|
|
<Box
|
|
sx={{
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
justifyContent: { xs: 'center', sm: 'flex-end' },
|
|
gap: 1,
|
|
mb: 1,
|
|
}}
|
|
>
|
|
<Chip
|
|
variant={leaderboardMode === 'points' ? 'solid' : 'outlined'}
|
|
color='primary'
|
|
size='sm'
|
|
sx={{ cursor: 'pointer' }}
|
|
onClick={() => setLeaderboardMode('points')}
|
|
>
|
|
{t('leaderboard.modePoints')}
|
|
</Chip>
|
|
<SwapHoriz
|
|
sx={{ fontSize: '0.875rem', color: 'text.tertiary' }}
|
|
/>
|
|
<Chip
|
|
variant={leaderboardMode === 'tasks' ? 'solid' : 'outlined'}
|
|
color='primary'
|
|
size='sm'
|
|
sx={{ cursor: 'pointer' }}
|
|
onClick={() => setLeaderboardMode('tasks')}
|
|
>
|
|
{t('leaderboard.modeTasks')}
|
|
</Chip>
|
|
</Box>
|
|
</Box>
|
|
</Stack>
|
|
|
|
{/* Leaderboard Cards */}
|
|
<Card
|
|
variant='outlined'
|
|
sx={{ borderRadius: 'lg', overflow: 'hidden' }}
|
|
>
|
|
<Stack spacing={0}>
|
|
{leaderboardData.map((user, index) => (
|
|
<Box key={user.userId}>
|
|
<Box
|
|
sx={{
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
p: 2.5,
|
|
backgroundColor:
|
|
user.userId === userProfile.id
|
|
? 'primary.softBg'
|
|
: 'transparent',
|
|
position: 'relative',
|
|
'&:hover': {
|
|
backgroundColor:
|
|
user.userId === userProfile.id
|
|
? 'primary.softHoverBg'
|
|
: 'neutral.softHoverBg',
|
|
},
|
|
cursor:
|
|
user.userId === selectedUser ? 'default' : 'pointer',
|
|
transition: 'background-color 0.2s ease',
|
|
}}
|
|
onClick={() => {
|
|
if (user.userId !== selectedUser) {
|
|
setSelectedUser(user.userId)
|
|
setSelectedHistory(
|
|
generateWeeklySummary(choresHistoryData, user.userId),
|
|
)
|
|
}
|
|
}}
|
|
>
|
|
{/* Rank Badge */}
|
|
<Box
|
|
sx={{
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
minWidth: 40,
|
|
mr: 2,
|
|
}}
|
|
>
|
|
{getTrophyIcon(user.rank)}
|
|
<Typography
|
|
level='body-sm'
|
|
sx={{
|
|
ml: 0.5,
|
|
fontWeight: user.rank <= 3 ? 'bold' : 'normal',
|
|
color:
|
|
user.rank <= 3 ? 'text.primary' : 'text.secondary',
|
|
}}
|
|
>
|
|
#{user.rank}
|
|
</Typography>
|
|
</Box>
|
|
|
|
{/* User Avatar and Info */}
|
|
<Box sx={{ display: 'flex', alignItems: 'center', flex: 1 }}>
|
|
<Avatar
|
|
src={user.image ? resolvePhotoURL(user.image) : undefined}
|
|
sx={{ width: 40, height: 40, mr: 2 }}
|
|
>
|
|
{user.displayName?.charAt(0)}
|
|
</Avatar>
|
|
<Stack sx={{ flex: 1 }}>
|
|
<Typography
|
|
level='body-md'
|
|
sx={{
|
|
fontWeight:
|
|
user.userId === userProfile.id ? 'bold' : 'normal',
|
|
color: 'text.primary',
|
|
}}
|
|
>
|
|
{user.displayName}
|
|
{user.userId === userProfile.id && (
|
|
<Chip
|
|
size='sm'
|
|
variant='soft'
|
|
color='primary'
|
|
sx={{ ml: 1 }}
|
|
>
|
|
{t('leaderboard.you')}
|
|
</Chip>
|
|
)}
|
|
</Typography>
|
|
<Typography
|
|
level='body-xs'
|
|
sx={{ color: 'text.secondary' }}
|
|
>
|
|
{t('leaderboard.tasksAndAvg', {
|
|
tasks: user.periodTasks,
|
|
avg: user.avgPointsPerTask,
|
|
})}
|
|
</Typography>
|
|
</Stack>
|
|
</Box>
|
|
|
|
{/* Metric Display */}
|
|
<Stack alignItems='flex-end' spacing={0.5}>
|
|
<Box
|
|
sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}
|
|
>
|
|
{leaderboardMode === 'points' ? (
|
|
<Toll sx={{ fontSize: '1rem', color: 'success.500' }} />
|
|
) : (
|
|
<AssignmentTurnedIn
|
|
sx={{ fontSize: '1rem', color: 'success.500' }}
|
|
/>
|
|
)}
|
|
<Typography
|
|
level='title-md'
|
|
sx={{
|
|
fontWeight: 'bold',
|
|
color: 'success.600',
|
|
}}
|
|
>
|
|
{leaderboardMode === 'points'
|
|
? user.periodPoints
|
|
: user.periodTasks}
|
|
</Typography>
|
|
</Box>
|
|
<Typography level='body-xs' sx={{ color: 'text.tertiary' }}>
|
|
{leaderboardMode === 'points'
|
|
? t('leaderboard.available', {
|
|
count: user.availablePoints,
|
|
})
|
|
: t('leaderboard.points', {
|
|
count: user.periodPoints,
|
|
})}
|
|
</Typography>
|
|
</Stack>
|
|
|
|
{/* Selection Indicator */}
|
|
{user.userId === selectedUser && (
|
|
<Box
|
|
sx={{
|
|
position: 'absolute',
|
|
left: 0,
|
|
top: 0,
|
|
bottom: 0,
|
|
width: 4,
|
|
backgroundColor: 'primary.500',
|
|
borderRadius: '0 4px 4px 0',
|
|
}}
|
|
/>
|
|
)}
|
|
</Box>
|
|
{index < leaderboardData.length - 1 && (
|
|
<Box
|
|
sx={{
|
|
height: 1,
|
|
backgroundColor: 'divider',
|
|
mx: 2.5,
|
|
}}
|
|
/>
|
|
)}
|
|
</Box>
|
|
))}
|
|
</Stack>
|
|
</Card>
|
|
</Box>
|
|
|
|
{/* Filters Section Header */}
|
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 2 }}>
|
|
<Analytics sx={{ fontSize: '1.5rem', color: 'primary.500' }} />
|
|
<Typography level='h4' sx={{ fontWeight: 'lg', color: 'text.primary' }}>
|
|
{t('filter.analysisTitle')}
|
|
</Typography>
|
|
</Box>
|
|
|
|
{/* Improved Filter Bar */}
|
|
<Card
|
|
variant='outlined'
|
|
sx={{
|
|
width: '100%',
|
|
p: 2,
|
|
mb: 3,
|
|
borderRadius: 12,
|
|
background:
|
|
'linear-gradient(135deg, rgba(255,255,255,0.1) 0%, rgba(255,255,255,0.05) 100%)',
|
|
backdropFilter: 'blur(10px)',
|
|
}}
|
|
>
|
|
<Stack spacing={2}>
|
|
<Typography level='title-sm' sx={{ color: 'text.secondary' }}>
|
|
{t('filter.title')}
|
|
</Typography>
|
|
|
|
<Stack
|
|
direction={{ xs: 'column', sm: 'row' }}
|
|
spacing={2}
|
|
alignItems={{ xs: 'stretch', sm: 'center' }}
|
|
>
|
|
{/* User Filter */}
|
|
<Box sx={{ flex: 1, minWidth: 200 }}>
|
|
<Typography level='body-sm' sx={{ mb: 1, fontWeight: 500 }}>
|
|
{t('filter.showFor')}
|
|
</Typography>
|
|
<Select
|
|
sx={{
|
|
width: '100%',
|
|
}}
|
|
variant='outlined'
|
|
value={selectedUser}
|
|
onChange={(e, selected) => {
|
|
setSelectedUser(selected)
|
|
setSelectedHistory(
|
|
generateWeeklySummary(choresHistoryData, selected),
|
|
)
|
|
}}
|
|
renderValue={() => {
|
|
return (
|
|
<Typography
|
|
startDecorator={
|
|
<Avatar
|
|
color='primary'
|
|
size='sm'
|
|
src={resolvePhotoURL(
|
|
circleUsers.find(
|
|
user => user.userId === selectedUser,
|
|
)?.image,
|
|
)}
|
|
>
|
|
{circleUsers
|
|
.find(user => user.userId === selectedUser)
|
|
?.displayName?.charAt(0)}
|
|
</Avatar>
|
|
}
|
|
>
|
|
{
|
|
circleUsers.find(user => user.userId === selectedUser)
|
|
?.displayName
|
|
}
|
|
</Typography>
|
|
)
|
|
}}
|
|
>
|
|
{circleUsers.map(user => (
|
|
<Option key={user.userId} value={user.userId}>
|
|
<Avatar
|
|
color='primary'
|
|
size='sm'
|
|
src={resolvePhotoURL(user.image)}
|
|
>
|
|
{user.displayName?.charAt(0)}
|
|
</Avatar>
|
|
<Typography>{user.displayName}</Typography>
|
|
<Chip
|
|
color='success'
|
|
size='sm'
|
|
variant='soft'
|
|
startDecorator={<Toll />}
|
|
>
|
|
{user.points - user.pointsRedeemed}
|
|
</Chip>
|
|
</Option>
|
|
))}
|
|
</Select>
|
|
</Box>
|
|
|
|
{/* Time Period Filter */}
|
|
<Box sx={{ flex: 1, minWidth: 200 }}>
|
|
<Typography level='body-sm' sx={{ mb: 1, fontWeight: 500 }}>
|
|
{t('filter.timePeriod')}
|
|
</Typography>
|
|
<Tabs
|
|
onChange={(e, tabValue) => {
|
|
setTabValue(tabValue)
|
|
handleChoresHistoryLimitChange(tabValue)
|
|
}}
|
|
value={tabValue}
|
|
sx={{
|
|
borderRadius: 8,
|
|
backgroundColor: 'background.surface',
|
|
border: '1px solid',
|
|
borderColor: 'divider',
|
|
}}
|
|
>
|
|
<TabList
|
|
disableUnderline
|
|
sx={{
|
|
borderRadius: 8,
|
|
backgroundColor: 'transparent',
|
|
p: 0.5,
|
|
gap: 0.5,
|
|
}}
|
|
>
|
|
{[
|
|
{ label: t('tabs.days7'), value: 7 },
|
|
{ label: t('tabs.months6'), value: 6 * 30 },
|
|
{ label: t('tabs.allTime'), value: 24 * 30 },
|
|
].map((tab, index) => (
|
|
<Tab
|
|
key={index}
|
|
sx={{
|
|
borderRadius: 6,
|
|
minWidth: 'auto',
|
|
px: 2,
|
|
py: 1,
|
|
fontSize: 'sm',
|
|
fontWeight: 500,
|
|
color: 'text.secondary',
|
|
'&.Mui-selected': {
|
|
color: 'primary.plainColor',
|
|
backgroundColor: 'primary.softBg',
|
|
fontWeight: 600,
|
|
},
|
|
'&:hover': {
|
|
backgroundColor: 'neutral.softHoverBg',
|
|
},
|
|
}}
|
|
disableIndicator
|
|
value={tab.value}
|
|
>
|
|
{tab.label}
|
|
</Tab>
|
|
))}
|
|
</TabList>
|
|
</Tabs>
|
|
</Box>
|
|
|
|
{/* Redeem Points Button */}
|
|
{circleUsers.find(user => user.userId === userProfile.id)?.role ===
|
|
'admin' && (
|
|
<Box sx={{ display: 'flex', alignItems: 'flex-end' }}>
|
|
<Button
|
|
variant='soft'
|
|
size='md'
|
|
startDecorator={<CreditCard />}
|
|
onClick={() => {
|
|
setIsRedeemModalOpen(true)
|
|
}}
|
|
sx={{ mt: 'auto' }}
|
|
>
|
|
{t('redeem')}
|
|
</Button>
|
|
</Box>
|
|
)}
|
|
</Stack>
|
|
</Stack>
|
|
</Card>
|
|
|
|
{/* Points Status Cards */}
|
|
<Grid container spacing={1} sx={{ mb: 3 }}>
|
|
{(() => {
|
|
const selectedUserData = circleUsers.find(
|
|
user => user.userId === selectedUser,
|
|
)
|
|
const totalPoints = selectedUserData?.points || 0
|
|
const redeemedPoints = selectedUserData?.pointsRedeemed || 0
|
|
const availablePoints = totalPoints - redeemedPoints
|
|
|
|
const periodStats = leaderboardData.find(
|
|
user => user.userId === selectedUser,
|
|
)
|
|
const periodPoints = selectedHistory.reduce(
|
|
(sum, item) => sum + (item.points || 0),
|
|
0,
|
|
)
|
|
|
|
const pointsCards = [
|
|
{
|
|
icon: <Toll />,
|
|
title: t('cards.available.title'),
|
|
text: t('leaderboard.points', { count: availablePoints }),
|
|
subtext: t('cards.available.subtext'),
|
|
},
|
|
{
|
|
icon: <Redeem />,
|
|
title: t('cards.redeemed.title'),
|
|
text: t('leaderboard.points', { count: redeemedPoints }),
|
|
subtext: t('cards.redeemed.subtext'),
|
|
},
|
|
{
|
|
icon: <AccountBalanceWallet />,
|
|
title: t('cards.total.title'),
|
|
text: t('leaderboard.points', { count: totalPoints }),
|
|
subtext: t('cards.total.subtext'),
|
|
},
|
|
{
|
|
icon: <TrendingUp />,
|
|
title: t('cards.period.title'),
|
|
text: t('leaderboard.points', { count: periodPoints }),
|
|
subtext:
|
|
tabValue === 24 * 30
|
|
? t('cards.period.allTime')
|
|
: tabValue === 6 * 30
|
|
? t('cards.period.last6Months')
|
|
: t('cards.period.lastDays', { count: tabValue }),
|
|
},
|
|
]
|
|
|
|
return pointsCards.map((card, index) => (
|
|
<Grid item xs={6} sm={6} key={index}>
|
|
<Card
|
|
variant='soft'
|
|
sx={{
|
|
borderRadius: 'md',
|
|
boxShadow: 1,
|
|
px: 2,
|
|
py: 1,
|
|
minHeight: 90,
|
|
height: '100%',
|
|
justifyContent: 'start',
|
|
}}
|
|
>
|
|
<CardContent>
|
|
<Box
|
|
sx={{
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
justifyContent: 'start',
|
|
mb: 0.5,
|
|
}}
|
|
>
|
|
{card.icon}
|
|
<Typography
|
|
level='body-md'
|
|
sx={{
|
|
ml: 1,
|
|
fontWeight: '500',
|
|
color: 'text.primary',
|
|
}}
|
|
>
|
|
{card.title}
|
|
</Typography>
|
|
</Box>
|
|
<Box>
|
|
<Typography
|
|
level='body-sm'
|
|
sx={{ color: 'text.secondary', lineHeight: 1.5 }}
|
|
>
|
|
{card.text}
|
|
</Typography>
|
|
<Typography
|
|
level='body-sm'
|
|
sx={{ color: 'text.secondary', lineHeight: 1.5 }}
|
|
>
|
|
{card.subtext}
|
|
</Typography>
|
|
</Box>
|
|
</CardContent>
|
|
</Card>
|
|
</Grid>
|
|
))
|
|
})()}
|
|
</Grid>
|
|
|
|
{/* Current Filter Summary */}
|
|
<Box sx={{ mb: 3, textAlign: 'center' }}>
|
|
<Typography level='body-sm' sx={{ color: 'text.secondary' }}>
|
|
{t('summary.showingFor')}{' '}
|
|
<Typography
|
|
component='span'
|
|
sx={{ fontWeight: 600, color: 'primary.500' }}
|
|
>
|
|
{circleUsers.find(user => user.userId === selectedUser)
|
|
?.displayName || t('summary.unknownUser')}
|
|
</Typography>{' '}
|
|
{t('summary.overThe')}{' '}
|
|
<Typography
|
|
component='span'
|
|
sx={{ fontWeight: 600, color: 'primary.500' }}
|
|
>
|
|
{tabValue === 24 * 30
|
|
? t('summary.allTime')
|
|
: tabValue === 6 * 30
|
|
? t('summary.last6Months')
|
|
: t('summary.lastDays', { count: tabValue })}
|
|
</Typography>
|
|
</Typography>
|
|
</Box>
|
|
|
|
<Box
|
|
sx={{
|
|
mb: 4,
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
gap: 3,
|
|
}}
|
|
>
|
|
{/* Chart Section Header */}
|
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 2 }}>
|
|
<Timeline sx={{ fontSize: '1.5rem', color: 'primary.500' }} />
|
|
<Typography
|
|
level='h4'
|
|
sx={{ fontWeight: 'lg', color: 'text.primary' }}
|
|
>
|
|
{t('trend.title')}
|
|
</Typography>
|
|
</Box>
|
|
|
|
{/* Bar Chart for points overtime */}
|
|
<Box sx={{ display: 'flex', justifyContent: 'center', gap: 1 }}>
|
|
<ResponsiveContainer height={300}>
|
|
<BarChart
|
|
data={selectedHistory}
|
|
margin={{ top: 5, left: -20, bottom: 5 }}
|
|
>
|
|
<CartesianGrid strokeDasharray={'3 3'} />
|
|
<XAxis dataKey='label' axisLine={false} tickLine={false} />
|
|
<YAxis axisLine={false} tickLine={false} />
|
|
<Bar
|
|
fill='#4183F2'
|
|
dataKey='points'
|
|
barSize={30}
|
|
radius={[5, 5, 0, 0]}
|
|
/>
|
|
</BarChart>
|
|
</ResponsiveContainer>
|
|
</Box>
|
|
</Box>
|
|
|
|
<RedeemPointsModal
|
|
config={(() => {
|
|
const user = circleUsers.find(u => u.userId === selectedUser)
|
|
const availablePoints = user
|
|
? (user.points || 0) - (user.pointsRedeemed || 0)
|
|
: 0
|
|
|
|
return {
|
|
onClose: () => {
|
|
setIsRedeemModalOpen(false)
|
|
},
|
|
isOpen: isRedeemModalOpen,
|
|
available: availablePoints,
|
|
user: user,
|
|
onSave: ({ userId, points }) => {
|
|
RedeemPoints(userId, points, userProfile.circleID)
|
|
.then(() => {
|
|
setIsRedeemModalOpen(false)
|
|
handleCircleMembersRefetch()
|
|
})
|
|
.catch(err => {
|
|
console.log(err)
|
|
})
|
|
},
|
|
}
|
|
})()}
|
|
/>
|
|
</Container>
|
|
)
|
|
}
|
|
|
|
export default UserPoints
|