captains-log/frontend/app/components/TaskList.tsx

661 lines
22 KiB
TypeScript

import React, { useState, useMemo } from 'react'
import {
Box,
Typography,
FormControl,
InputLabel,
Select,
MenuItem,
Stack,
Paper,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
useMediaQuery,
useTheme,
Checkbox,
Toolbar,
Button,
Dialog,
DialogTitle,
DialogContent,
DialogContentText,
DialogActions,
TableSortLabel,
Tooltip,
} from '@mui/material'
import { Delete as DeleteIcon } from '@mui/icons-material'
import { useTasks } from '~/hooks/useTasks'
import { TaskStatus, type Task } from '~/types/task'
import LoadingSpinner from './LoadingSpinner'
type SortOption =
| 'created_desc'
| 'created_asc'
| 'title_asc'
| 'title_desc'
| 'status'
interface TaskListProps {
className?: string
initialTasks?: Task[]
}
export function TaskList({ className, initialTasks }: TaskListProps) {
const { tasks, loading, error, updateTask, deleteTask } = useTasks({
autoFetch: !initialTasks,
initialData: initialTasks,
})
const [statusFilter, setStatusFilter] = useState<TaskStatus | 'all'>('all')
const [sortBy, setSortBy] = useState<SortOption>('created_desc')
const [selectedTaskIds, setSelectedTaskIds] = useState<Set<string>>(new Set())
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false)
const theme = useTheme()
const isMobile = useMediaQuery(theme.breakpoints.down('md'))
const filteredAndSortedTasks = useMemo(() => {
let filteredTasks = tasks
// Apply status filter
if (statusFilter !== 'all') {
filteredTasks = tasks.filter(task => task.status === statusFilter)
}
// Apply sorting
const sorted = [...filteredTasks].sort((a, b) => {
switch (sortBy) {
case 'created_desc':
return (
new Date(b.created_at).getTime() - new Date(a.created_at).getTime()
)
case 'created_asc':
return (
new Date(a.created_at).getTime() - new Date(b.created_at).getTime()
)
case 'title_asc':
return a.title.localeCompare(b.title)
case 'title_desc':
return b.title.localeCompare(a.title)
case 'status': {
const statusOrder = {
[TaskStatus.Todo]: 0,
[TaskStatus.Backlog]: 1,
[TaskStatus.Done]: 2,
}
return statusOrder[a.status] - statusOrder[b.status]
}
default:
return 0
}
})
return sorted
}, [tasks, statusFilter, sortBy])
const getStatusColor = (status: TaskStatus) => {
switch (status) {
case TaskStatus.Todo:
return 'primary'
case TaskStatus.Done:
return 'success'
case TaskStatus.Backlog:
return 'default'
default:
return 'default'
}
}
const formatCompactDate = (dateString: string) => {
const date = new Date(dateString)
const now = new Date()
const diffInDays = Math.floor(
(now.getTime() - date.getTime()) / (1000 * 60 * 60 * 24)
)
if (diffInDays === 0) return 'Today'
if (diffInDays === 1) return 'Yesterday'
if (diffInDays < 7) return `${diffInDays}d ago`
if (diffInDays < 30) return `${Math.floor(diffInDays / 7)}w ago`
if (diffInDays < 365) return `${Math.floor(diffInDays / 30)}mo ago`
return `${Math.floor(diffInDays / 365)}y ago`
}
const formatFullTimestamp = (dateString: string) => {
const date = new Date(dateString)
return date.toLocaleString(undefined, {
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
timeZoneName: 'short',
})
}
const handleSelectAll = (checked: boolean) => {
if (checked) {
setSelectedTaskIds(new Set(filteredAndSortedTasks.map(task => task.id)))
} else {
setSelectedTaskIds(new Set())
}
}
const handleSelectTask = (taskId: string, checked: boolean) => {
const newSelected = new Set(selectedTaskIds)
if (checked) {
newSelected.add(taskId)
} else {
newSelected.delete(taskId)
}
setSelectedTaskIds(newSelected)
}
const handleBulkDelete = async () => {
try {
const results = await Promise.all(
Array.from(selectedTaskIds).map(taskId => deleteTask(taskId))
)
// Check if all deletions were successful
const allSuccessful = results.every(result => result === true)
if (allSuccessful) {
setSelectedTaskIds(new Set())
setDeleteDialogOpen(false)
} else {
console.error('Some tasks failed to delete')
// Clear selection of successfully deleted tasks
const failedTaskIds = Array.from(selectedTaskIds).filter(
(taskId, index) => !results[index]
)
setSelectedTaskIds(new Set(failedTaskIds))
}
} catch (error) {
console.error('Error deleting tasks:', error)
}
}
const handleSort = (field: string) => {
let newSortBy: SortOption
switch (field) {
case 'title':
newSortBy = sortBy === 'title_asc' ? 'title_desc' : 'title_asc'
break
case 'status':
newSortBy = 'status'
break
case 'created':
newSortBy = sortBy === 'created_asc' ? 'created_desc' : 'created_asc'
break
default:
return
}
setSortBy(newSortBy)
}
const getSortDirection = (field: string): 'asc' | 'desc' | false => {
switch (field) {
case 'title':
if (sortBy === 'title_asc') return 'asc'
if (sortBy === 'title_desc') return 'desc'
return false
case 'status':
return sortBy === 'status' ? 'asc' : false
case 'created':
if (sortBy === 'created_asc') return 'asc'
if (sortBy === 'created_desc') return 'desc'
return false
default:
return false
}
}
const handleStatusChange = async (taskId: string, newStatus: TaskStatus) => {
const result = await updateTask(taskId, { status: newStatus })
if (!result) {
console.error('Failed to update task status')
}
}
const isAllSelected =
filteredAndSortedTasks.length > 0 &&
filteredAndSortedTasks.every(task => selectedTaskIds.has(task.id))
const isIndeterminate = selectedTaskIds.size > 0 && !isAllSelected
if (loading) {
return <LoadingSpinner />
}
if (error) {
return (
<Box sx={{ p: 2, textAlign: 'center' }}>
<Typography color="error" variant="h6">
Error loading tasks
</Typography>
<Typography color="text.secondary" variant="body2">
{error}
</Typography>
</Box>
)
}
return (
<Box className={className}>
{/* Filter and Sort Controls */}
<Paper sx={{ p: 2, mb: 2 }}>
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2}>
<FormControl size="small" sx={{ minWidth: 120 }}>
<InputLabel>Status</InputLabel>
<Select
value={statusFilter}
label="Status"
onChange={e =>
setStatusFilter(e.target.value as TaskStatus | 'all')
}
>
<MenuItem value="all">All Tasks</MenuItem>
<MenuItem value={TaskStatus.Todo}>Todo</MenuItem>
<MenuItem value={TaskStatus.Done}>Done</MenuItem>
<MenuItem value={TaskStatus.Backlog}>Backlog</MenuItem>
</Select>
</FormControl>
<FormControl size="small" sx={{ minWidth: 150 }}>
<InputLabel>Sort by</InputLabel>
<Select
value={sortBy}
label="Sort by"
onChange={e => setSortBy(e.target.value as SortOption)}
>
<MenuItem value="created_desc">Newest First</MenuItem>
<MenuItem value="created_asc">Oldest First</MenuItem>
<MenuItem value="title_asc">Title A-Z</MenuItem>
<MenuItem value="title_desc">Title Z-A</MenuItem>
<MenuItem value="status">By Status</MenuItem>
</Select>
</FormControl>
<Typography
variant="body2"
color="text.secondary"
sx={{ display: 'flex', alignItems: 'center' }}
>
{filteredAndSortedTasks.length} task
{filteredAndSortedTasks.length !== 1 ? 's' : ''}
</Typography>
</Stack>
</Paper>
{/* Bulk Actions Toolbar */}
{selectedTaskIds.size > 0 && (
<Paper sx={{ mb: 2 }}>
<Toolbar sx={{ pl: 2, pr: 1 }}>
<Typography
sx={{ flex: '1 1 100%' }}
color="inherit"
variant="subtitle1"
component="div"
>
{selectedTaskIds.size} task{selectedTaskIds.size > 1 ? 's' : ''}{' '}
selected
</Typography>
<Button
variant="contained"
color="error"
startIcon={<DeleteIcon />}
onClick={() => setDeleteDialogOpen(true)}
>
Delete
</Button>
</Toolbar>
</Paper>
)}
{/* Task Table */}
{filteredAndSortedTasks.length === 0 ? (
<Paper sx={{ p: 4, textAlign: 'center' }}>
<Typography variant="h6" color="text.secondary" gutterBottom>
No tasks found
</Typography>
<Typography variant="body2" color="text.secondary">
{statusFilter === 'all'
? 'Create your first task to get started!'
: `No ${statusFilter} tasks found.`}
</Typography>
</Paper>
) : (
<TableContainer component={Paper}>
<Table size="small" sx={{ '& .MuiTableCell-root': { py: 1 } }}>
<TableHead>
<TableRow>
<TableCell padding="checkbox">
<Checkbox
color="primary"
indeterminate={isIndeterminate}
checked={isAllSelected}
onChange={e => handleSelectAll(e.target.checked)}
inputProps={{
'aria-label': 'select all tasks',
}}
/>
</TableCell>
<TableCell sx={{ fontWeight: 'bold' }}>
<TableSortLabel
active={getSortDirection('title') !== false}
direction={getSortDirection('title') || 'asc'}
onClick={() => handleSort('title')}
>
Title
</TableSortLabel>
</TableCell>
{!isMobile && (
<TableCell sx={{ fontWeight: 'bold' }}>Description</TableCell>
)}
<TableCell sx={{ fontWeight: 'bold', width: 100 }}>
<TableSortLabel
active={getSortDirection('status') !== false}
direction={getSortDirection('status') || 'asc'}
onClick={() => handleSort('status')}
>
Status
</TableSortLabel>
</TableCell>
<TableCell sx={{ fontWeight: 'bold', width: 120 }}>
<TableSortLabel
active={getSortDirection('created') !== false}
direction={getSortDirection('created') || 'asc'}
onClick={() => handleSort('created')}
>
Created
</TableSortLabel>
</TableCell>
{!isMobile && (
<TableCell sx={{ fontWeight: 'bold', width: 120 }}>
Completed
</TableCell>
)}
</TableRow>
</TableHead>
<TableBody>
{filteredAndSortedTasks.map(task => (
<TableRow
key={task.id}
sx={{
'&:hover': {
backgroundColor: 'action.hover',
},
'&:last-child td, &:last-child th': { border: 0 },
backgroundColor: selectedTaskIds.has(task.id)
? 'action.selected'
: 'inherit',
}}
>
<TableCell padding="checkbox">
<Checkbox
color="primary"
checked={selectedTaskIds.has(task.id)}
onChange={e =>
handleSelectTask(task.id, e.target.checked)
}
inputProps={{
'aria-labelledby': `task-${task.id}`,
}}
/>
</TableCell>
<TableCell>
<Box>
<Typography
id={`task-${task.id}`}
variant="body1"
component="div"
sx={{
textDecoration:
task.status === TaskStatus.Done
? 'line-through'
: 'none',
opacity: task.status === TaskStatus.Done ? 0.7 : 1,
fontWeight: 500,
}}
>
{task.title}
</Typography>
{isMobile && task.description && (
<Typography
variant="body2"
color="text.secondary"
sx={{ mt: 0.5 }}
>
{task.description}
</Typography>
)}
</Box>
</TableCell>
{!isMobile && (
<TableCell>
<Typography
variant="body2"
color="text.secondary"
sx={{
maxWidth: 300,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{task.description || '—'}
</Typography>
</TableCell>
)}
<TableCell>
<Select
value={task.status}
onChange={e =>
handleStatusChange(
task.id,
e.target.value as TaskStatus
)
}
size="small"
variant="outlined"
MenuProps={{
PaperProps: {
sx: {
padding: '4px',
minWidth: '100px',
'& .MuiList-root': {
padding: 0,
},
},
},
}}
sx={{
minWidth: 90,
height: '24px',
borderRadius: '16px',
backgroundColor:
getStatusColor(task.status) === 'primary'
? 'primary.main'
: getStatusColor(task.status) === 'success'
? 'success.main'
: 'action.disabled',
color:
getStatusColor(task.status) === 'default'
? 'text.primary'
: 'white',
fontSize: '0.75rem',
'& .MuiOutlinedInput-notchedOutline': {
border: 'none',
},
'&:hover .MuiOutlinedInput-notchedOutline': {
border: 'none',
},
'&.Mui-focused .MuiOutlinedInput-notchedOutline': {
border: 'none',
},
'& .MuiSelect-select': {
padding: '2px 12px 2px 12px',
paddingRight: '24px !important',
display: 'flex',
alignItems: 'center',
height: '20px',
lineHeight: '20px',
},
'& .MuiSelect-icon': {
right: '4px',
color:
getStatusColor(task.status) === 'default'
? 'text.primary'
: 'white',
fontSize: '1rem',
},
}}
>
<MenuItem
value={TaskStatus.Todo}
sx={{
backgroundColor: 'primary.main',
color: 'white',
margin: '2px 4px',
borderRadius: '12px',
fontSize: '0.75rem',
minHeight: '24px',
padding: '2px 8px',
'&:hover': {
backgroundColor: 'primary.dark',
},
'&.Mui-selected': {
backgroundColor: 'primary.main',
'&:hover': {
backgroundColor: 'primary.dark',
},
},
}}
>
Todo
</MenuItem>
<MenuItem
value={TaskStatus.Done}
sx={{
backgroundColor: 'success.main',
color: 'white',
margin: '2px 4px',
borderRadius: '12px',
fontSize: '0.75rem',
minHeight: '24px',
padding: '2px 8px',
'&:hover': {
backgroundColor: 'success.dark',
},
'&.Mui-selected': {
backgroundColor: 'success.main',
'&:hover': {
backgroundColor: 'success.dark',
},
},
}}
>
Done
</MenuItem>
<MenuItem
value={TaskStatus.Backlog}
sx={{
backgroundColor: 'action.disabled',
color: 'text.primary',
margin: '2px 4px',
borderRadius: '12px',
fontSize: '0.75rem',
minHeight: '24px',
padding: '2px 8px',
'&:hover': {
backgroundColor: 'action.hover',
},
'&.Mui-selected': {
backgroundColor: 'action.disabled',
'&:hover': {
backgroundColor: 'action.hover',
},
},
}}
>
Backlog
</MenuItem>
</Select>
</TableCell>
<TableCell>
<Tooltip
title={formatFullTimestamp(task.created_at)}
arrow
placement="top"
>
<Typography
variant="body2"
color="text.secondary"
sx={{ cursor: 'help' }}
>
{formatCompactDate(task.created_at)}
</Typography>
</Tooltip>
</TableCell>
{!isMobile && (
<TableCell>
{task.completed_at ? (
<Tooltip
title={formatFullTimestamp(task.completed_at)}
arrow
placement="top"
>
<Typography
variant="body2"
color="text.secondary"
sx={{ cursor: 'help' }}
>
{formatCompactDate(task.completed_at)}
</Typography>
</Tooltip>
) : (
<Typography variant="body2" color="text.secondary">
</Typography>
)}
</TableCell>
)}
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
)}
{/* Delete Confirmation Dialog */}
<Dialog
open={deleteDialogOpen}
onClose={() => setDeleteDialogOpen(false)}
aria-labelledby="delete-dialog-title"
aria-describedby="delete-dialog-description"
>
<DialogTitle id="delete-dialog-title">
Delete {selectedTaskIds.size} task
{selectedTaskIds.size > 1 ? 's' : ''}?
</DialogTitle>
<DialogContent>
<DialogContentText id="delete-dialog-description">
This action cannot be undone. Are you sure you want to delete the
selected task{selectedTaskIds.size > 1 ? 's' : ''}?
</DialogContentText>
</DialogContent>
<DialogActions>
<Button onClick={() => setDeleteDialogOpen(false)}>Cancel</Button>
<Button onClick={handleBulkDelete} color="error" autoFocus>
Delete
</Button>
</DialogActions>
</Dialog>
</Box>
)
}