Reviewed-on: #10 Co-authored-by: Drew Galbraith <drew@tiramisu.one> Co-committed-by: Drew Galbraith <drew@tiramisu.one>
528 lines
17 KiB
TypeScript
528 lines
17 KiB
TypeScript
import React, { useState, useMemo } from 'react'
|
|
import {
|
|
Box,
|
|
Typography,
|
|
FormControl,
|
|
InputLabel,
|
|
Select,
|
|
MenuItem,
|
|
Chip,
|
|
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, 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 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>
|
|
<Chip
|
|
label={task.status}
|
|
color={getStatusColor(task.status)}
|
|
size="small"
|
|
variant="outlined"
|
|
/>
|
|
</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>
|
|
)
|
|
}
|