Part of #145. Twenty-three files across the task list zone: the list and card views, sorting and grouping, multi-select and its toolbar and help sheet, archived tasks, the assignee card, the chore action menu, the nudge/NFC/photo modals, the rich text editor, the scan panel, the notification templates and the keyboard-shortcut toasts. Extends the existing `chores` namespace, so `src/i18n/config.js` is untouched. 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 (247 call sites). Two values are matched loosely and worth naming: archived.closeMultiSelect in both the archived view and the toolbar. The base builds that tooltip as `${size === 0 ? 'Close' : 'Clear'} multi-select (Esc)`, so only one branch of the ternary exists contiguously in the source. Both keys hold exactly what each branch renders; the tooltip is kept whole so a translator can reorder it. Rebased on current `develop` again after #215 and #216 landed — the dictionary conflict was theirs, not the code's. No code file in this PR was touched upstream in the meantime.
405 lines
11 KiB
JavaScript
405 lines
11 KiB
JavaScript
import { BarChart, Person } from '@mui/icons-material'
|
|
import { Avatar, Box, Sheet, Typography } from '@mui/joy'
|
|
import { useEffect, useState } from 'react'
|
|
import EmptyState from '../../components/common/EmptyState'
|
|
import { useCircleMembers } from '../../queries/UserQueries'
|
|
import { TASK_COLOR } from '../../utils/Colors'
|
|
import { resolvePhotoURL } from '../../utils/Helpers'
|
|
import { useTranslation } from 'react-i18next'
|
|
|
|
const TasksByAssigneeCard = ({ chores = [] }) => {
|
|
const { t } = useTranslation('chores')
|
|
const [assigneeData, setAssigneeData] = useState([])
|
|
const { data: circleMembersData, isLoading: isCircleMembersLoading } =
|
|
useCircleMembers()
|
|
|
|
useEffect(() => {
|
|
if (
|
|
!isCircleMembersLoading &&
|
|
circleMembersData?.res &&
|
|
chores.length > 0
|
|
) {
|
|
const members = circleMembersData.res
|
|
const data = processTasksByAssignee(chores, members)
|
|
setAssigneeData(data)
|
|
}
|
|
}, [chores, circleMembersData, isCircleMembersLoading])
|
|
|
|
const processTasksByAssignee = (chores, members) => {
|
|
const assigneeStats = {}
|
|
|
|
// Initialize stats for all members
|
|
members.forEach(member => {
|
|
assigneeStats[member.userId] = {
|
|
id: member.userId,
|
|
name: member.displayName || member.name,
|
|
image: member.image,
|
|
inProgress: 0,
|
|
overdue: 0,
|
|
scheduled: 0,
|
|
pendingReview: 0,
|
|
total: 0,
|
|
}
|
|
})
|
|
|
|
// Count tasks by status for each assignee
|
|
chores.forEach(chore => {
|
|
if (chore.assignedTo && assigneeStats[chore.assignedTo]) {
|
|
const assignee = assigneeStats[chore.assignedTo]
|
|
assignee.total++
|
|
|
|
// Map chore status to our categories based on your system
|
|
if (chore.status === 3) {
|
|
// Pending approval/review
|
|
assignee.pendingReview++
|
|
} else if (chore.status === 1 || chore.status === 2) {
|
|
// In progress (started or paused)
|
|
assignee.inProgress++
|
|
} else if (
|
|
chore.nextDueDate &&
|
|
new Date(chore.nextDueDate) < new Date()
|
|
) {
|
|
// Overdue - past due date
|
|
assignee.overdue++
|
|
} else {
|
|
// Scheduled/planned - future due date or no due date
|
|
assignee.scheduled++
|
|
}
|
|
}
|
|
})
|
|
|
|
// Filter out members with no tasks and sort by total tasks
|
|
return Object.values(assigneeStats)
|
|
.filter(assignee => assignee.total > 0)
|
|
.sort((a, b) => b.total - a.total)
|
|
}
|
|
|
|
const getStatusColor = status => {
|
|
switch (status) {
|
|
case 'inProgress':
|
|
return TASK_COLOR.IN_PROGRESS
|
|
case 'overdue':
|
|
return TASK_COLOR.OVERDUE
|
|
case 'scheduled':
|
|
return TASK_COLOR.COMPLETED
|
|
case 'pendingReview':
|
|
return TASK_COLOR.PENDING_REVIEW
|
|
default:
|
|
return TASK_COLOR.DEFAULT
|
|
}
|
|
}
|
|
|
|
const maxTasks = Math.max(...assigneeData.map(a => a.total), 1)
|
|
|
|
if (isCircleMembersLoading) {
|
|
return (
|
|
<Sheet
|
|
variant='plain'
|
|
sx={{
|
|
p: 2,
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
boxShadow: 'sm',
|
|
borderRadius: 20,
|
|
width: '315px',
|
|
minHeight: 300,
|
|
mb: 1,
|
|
}}
|
|
>
|
|
<Typography level='body-sm' color='neutral'>
|
|
{t('assigneeCard.loading')}
|
|
</Typography>
|
|
</Sheet>
|
|
)
|
|
}
|
|
|
|
if (assigneeData.length === 0) {
|
|
return (
|
|
<Sheet
|
|
variant='plain'
|
|
sx={{
|
|
p: 2,
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
boxShadow: 'sm',
|
|
borderRadius: 20,
|
|
width: '315px',
|
|
minHeight: 300,
|
|
mb: 1,
|
|
}}
|
|
>
|
|
<EmptyState
|
|
variant='no-results'
|
|
size='sm'
|
|
icon={<Person />}
|
|
title='No one has tasks yet'
|
|
description='Assign a task to someone in your circle and their workload shows up here.'
|
|
/>
|
|
</Sheet>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<Sheet
|
|
variant='plain'
|
|
sx={{
|
|
p: 2,
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
boxShadow: 'sm',
|
|
borderRadius: 20,
|
|
width: '315px',
|
|
minHeight: 300,
|
|
mb: 1,
|
|
}}
|
|
>
|
|
{/* Header */}
|
|
<Box sx={{ mb: 2 }}>
|
|
<Box
|
|
sx={{
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
justifyContent: 'flex-start',
|
|
gap: 1,
|
|
}}
|
|
>
|
|
<BarChart color='' />
|
|
<Typography level='title-md'>{t('assigneeCard.title')}</Typography>
|
|
</Box>
|
|
</Box>
|
|
|
|
{/* Legend */}
|
|
<Box
|
|
sx={{
|
|
display: 'grid',
|
|
gridTemplateColumns: 'repeat(2, 1fr)',
|
|
gap: 1,
|
|
mb: 3,
|
|
px: 1,
|
|
}}
|
|
>
|
|
{[
|
|
{
|
|
key: 'inProgress',
|
|
label: t('filter.status.inProgress'),
|
|
color: getStatusColor('inProgress'),
|
|
},
|
|
{
|
|
key: 'overdue',
|
|
label: t('group.overdue'),
|
|
color: getStatusColor('overdue'),
|
|
},
|
|
{
|
|
key: 'scheduled',
|
|
label: t('assigneeCard.scheduled'),
|
|
color: getStatusColor('scheduled'),
|
|
},
|
|
{
|
|
key: 'pendingReview',
|
|
label: t('assigneeCard.pendingReview'),
|
|
color: getStatusColor('pendingReview'),
|
|
},
|
|
].map(status => (
|
|
<Box
|
|
key={status.key}
|
|
sx={{
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
gap: 0.5,
|
|
minWidth: 0,
|
|
}}
|
|
>
|
|
<Box
|
|
sx={{
|
|
width: 10,
|
|
height: 10,
|
|
borderRadius: '50%',
|
|
backgroundColor: status.color,
|
|
flexShrink: 0,
|
|
}}
|
|
/>
|
|
<Typography
|
|
level='body-xs'
|
|
sx={{
|
|
fontSize: '10px',
|
|
whiteSpace: 'nowrap',
|
|
overflow: 'hidden',
|
|
textOverflow: 'ellipsis',
|
|
}}
|
|
>
|
|
{status.label}
|
|
</Typography>
|
|
</Box>
|
|
))}
|
|
</Box>
|
|
|
|
{/* Chart Container */}
|
|
<Box sx={{ position: 'relative', height: 200 }}>
|
|
{/* Chart */}
|
|
<Box
|
|
sx={{
|
|
display: 'flex',
|
|
alignItems: 'end',
|
|
gap: 1,
|
|
height: '100%',
|
|
pl: 4,
|
|
pr: 2,
|
|
pt: 2,
|
|
}}
|
|
>
|
|
{assigneeData.slice(0, 6).map((assignee, index) => {
|
|
const barHeight = Math.max((assignee.total / maxTasks) * 140, 8)
|
|
|
|
return (
|
|
<Box
|
|
key={assignee.id}
|
|
sx={{
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
alignItems: 'center',
|
|
flex: 1,
|
|
minWidth: 0,
|
|
maxWidth: 45,
|
|
}}
|
|
>
|
|
{/* Avatar */}
|
|
<Avatar
|
|
size='sm'
|
|
src={resolvePhotoURL(assignee.image)}
|
|
sx={{
|
|
width: 28,
|
|
height: 28,
|
|
mb: 1,
|
|
border: '2px solid white',
|
|
boxShadow: 'sm',
|
|
fontSize: '12px',
|
|
}}
|
|
>
|
|
{assignee.name?.charAt(0) || <Person />}
|
|
</Avatar>
|
|
|
|
{/* Stacked bars */}
|
|
<Box
|
|
sx={{
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
alignItems: 'center',
|
|
height: barHeight,
|
|
width: '100%',
|
|
maxWidth: 28,
|
|
borderRadius: 4,
|
|
overflow: 'hidden',
|
|
border: '1px solid #E5E7EB',
|
|
backgroundColor: '#F9FAFB',
|
|
}}
|
|
>
|
|
{/* Pending Review - bottom */}
|
|
{assignee.pendingReview > 0 && (
|
|
<Box
|
|
sx={{
|
|
width: '100%',
|
|
height: `${(assignee.pendingReview / assignee.total) * 100}%`,
|
|
backgroundColor: getStatusColor('pendingReview'),
|
|
order: 4,
|
|
}}
|
|
/>
|
|
)}
|
|
|
|
{/* Scheduled */}
|
|
{assignee.scheduled > 0 && (
|
|
<Box
|
|
sx={{
|
|
width: '100%',
|
|
height: `${(assignee.scheduled / assignee.total) * 100}%`,
|
|
backgroundColor: getStatusColor('scheduled'),
|
|
order: 3,
|
|
}}
|
|
/>
|
|
)}
|
|
|
|
{/* In Progress */}
|
|
{assignee.inProgress > 0 && (
|
|
<Box
|
|
sx={{
|
|
width: '100%',
|
|
height: `${(assignee.inProgress / assignee.total) * 100}%`,
|
|
backgroundColor: getStatusColor('inProgress'),
|
|
order: 2,
|
|
}}
|
|
/>
|
|
)}
|
|
|
|
{/* Overdue - top */}
|
|
{assignee.overdue > 0 && (
|
|
<Box
|
|
sx={{
|
|
width: '100%',
|
|
height: `${(assignee.overdue / assignee.total) * 100}%`,
|
|
backgroundColor: getStatusColor('overdue'),
|
|
order: 1,
|
|
}}
|
|
/>
|
|
)}
|
|
</Box>
|
|
|
|
{/* Name */}
|
|
<Box sx={{ mt: 1, textAlign: 'center', width: '100%' }}>
|
|
<Typography
|
|
level='body-xs'
|
|
sx={{
|
|
fontWeight: 500,
|
|
fontSize: '10px',
|
|
whiteSpace: 'nowrap',
|
|
overflow: 'hidden',
|
|
textOverflow: 'ellipsis',
|
|
}}
|
|
>
|
|
{assignee.name}
|
|
</Typography>
|
|
</Box>
|
|
</Box>
|
|
)
|
|
})}
|
|
</Box>
|
|
|
|
{/* Y-axis labels */}
|
|
<Box
|
|
sx={{
|
|
position: 'absolute',
|
|
left: 0,
|
|
bottom: 20,
|
|
height: 140,
|
|
width: 32,
|
|
pr: 0.5,
|
|
}}
|
|
>
|
|
{[0, 20, 40, 60, 80, 100].map((value, index) => {
|
|
const yPosition = (value / 100) * 140
|
|
return (
|
|
<Typography
|
|
key={value}
|
|
level='body-xs'
|
|
sx={{
|
|
fontSize: '9px',
|
|
color: 'text.secondary',
|
|
lineHeight: 1,
|
|
position: 'absolute',
|
|
bottom: `${yPosition}px`,
|
|
right: 4,
|
|
transform: 'translateY(50%)',
|
|
}}
|
|
>
|
|
{Math.round((value / 100) * maxTasks)}
|
|
</Typography>
|
|
)
|
|
})}
|
|
</Box>
|
|
</Box>
|
|
</Sheet>
|
|
)
|
|
}
|
|
|
|
export default TasksByAssigneeCard
|