2nd
This commit is contained in:
@@ -12,6 +12,8 @@ import {
|
||||
Avatar,
|
||||
AvatarGroup,
|
||||
IconButton,
|
||||
Skeleton,
|
||||
CircularProgress,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Assignment,
|
||||
@@ -216,137 +218,168 @@ const DashboardPage = () => {
|
||||
</Box>
|
||||
|
||||
{/* 統計卡片 */}
|
||||
<Grid container spacing={3} sx={{ mb: 4 }}>
|
||||
<Grid item xs={12} sm={6} md={3}>
|
||||
<motion.div variants={itemVariants}>
|
||||
<Card
|
||||
sx={{
|
||||
background: actualTheme === 'dark'
|
||||
? 'linear-gradient(135deg, #1f2937 0%, #374151 100%)'
|
||||
: 'linear-gradient(135deg, #ffffff 0%, #f9fafb 100%)',
|
||||
border: `1px solid ${actualTheme === 'dark'
|
||||
? 'rgba(255, 255, 255, 0.1)'
|
||||
: 'rgba(0, 0, 0, 0.1)'}`,
|
||||
transition: 'transform 0.2s ease, box-shadow 0.2s ease',
|
||||
'&:hover': {
|
||||
transform: 'translateY(-2px)',
|
||||
boxShadow: '0 8px 25px rgba(0, 0, 0, 0.15)',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<CardContent>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<Box>
|
||||
<Typography color="text.secondary" gutterBottom>
|
||||
總待辦
|
||||
</Typography>
|
||||
<Typography variant="h4" sx={{ fontWeight: 700 }}>
|
||||
{stats.total}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Assignment sx={{ fontSize: 40, color: '#6b7280', opacity: 0.8 }} />
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
{loading ? (
|
||||
<Grid container spacing={3} sx={{ mb: 4 }}>
|
||||
{Array.from({ length: 4 }).map((_, index) => (
|
||||
<Grid item xs={12} sm={6} md={3} key={index}>
|
||||
<motion.div variants={itemVariants}>
|
||||
<Card
|
||||
sx={{
|
||||
background: actualTheme === 'dark'
|
||||
? 'linear-gradient(135deg, #1f2937 0%, #374151 100%)'
|
||||
: 'linear-gradient(135deg, #ffffff 0%, #f9fafb 100%)',
|
||||
border: `1px solid ${actualTheme === 'dark'
|
||||
? 'rgba(255, 255, 255, 0.1)'
|
||||
: 'rgba(0, 0, 0, 0.1)'}`,
|
||||
}}
|
||||
>
|
||||
<CardContent>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<Box>
|
||||
<Skeleton variant="text" width={60} height={20} sx={{ mb: 1 }} />
|
||||
<Skeleton variant="text" width={40} height={40} />
|
||||
</Box>
|
||||
<Skeleton variant="circular" width={40} height={40} />
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
) : (
|
||||
<Grid container spacing={3} sx={{ mb: 4 }}>
|
||||
<Grid item xs={12} sm={6} md={3}>
|
||||
<motion.div variants={itemVariants}>
|
||||
<Card
|
||||
sx={{
|
||||
background: actualTheme === 'dark'
|
||||
? 'linear-gradient(135deg, #1f2937 0%, #374151 100%)'
|
||||
: 'linear-gradient(135deg, #ffffff 0%, #f9fafb 100%)',
|
||||
border: `1px solid ${actualTheme === 'dark'
|
||||
? 'rgba(255, 255, 255, 0.1)'
|
||||
: 'rgba(0, 0, 0, 0.1)'}`,
|
||||
transition: 'transform 0.2s ease, box-shadow 0.2s ease',
|
||||
'&:hover': {
|
||||
transform: 'translateY(-2px)',
|
||||
boxShadow: '0 8px 25px rgba(0, 0, 0, 0.15)',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<CardContent>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<Box>
|
||||
<Typography color="text.secondary" gutterBottom>
|
||||
總待辦
|
||||
</Typography>
|
||||
<Typography variant="h4" sx={{ fontWeight: 700 }}>
|
||||
{stats.total}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Assignment sx={{ fontSize: 40, color: '#6b7280', opacity: 0.8 }} />
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12} sm={6} md={3}>
|
||||
<motion.div variants={itemVariants}>
|
||||
<Card
|
||||
sx={{
|
||||
background: actualTheme === 'dark'
|
||||
? 'linear-gradient(135deg, #1e3a8a 0%, #3b82f6 100%)'
|
||||
: 'linear-gradient(135deg, #3b82f6 0%, #60a5fa 100%)',
|
||||
color: 'white',
|
||||
transition: 'transform 0.2s ease, box-shadow 0.2s ease',
|
||||
'&:hover': {
|
||||
transform: 'translateY(-2px)',
|
||||
boxShadow: '0 8px 25px rgba(59, 130, 246, 0.3)',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<CardContent>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<Box>
|
||||
<Typography sx={{ color: 'rgba(255, 255, 255, 0.8)' }} gutterBottom>
|
||||
進行中
|
||||
</Typography>
|
||||
<Typography variant="h4" sx={{ fontWeight: 700, color: 'white' }}>
|
||||
{stats.doing}
|
||||
</Typography>
|
||||
<Grid item xs={12} sm={6} md={3}>
|
||||
<motion.div variants={itemVariants}>
|
||||
<Card
|
||||
sx={{
|
||||
background: actualTheme === 'dark'
|
||||
? 'linear-gradient(135deg, #1e3a8a 0%, #3b82f6 100%)'
|
||||
: 'linear-gradient(135deg, #3b82f6 0%, #60a5fa 100%)',
|
||||
color: 'white',
|
||||
transition: 'transform 0.2s ease, box-shadow 0.2s ease',
|
||||
'&:hover': {
|
||||
transform: 'translateY(-2px)',
|
||||
boxShadow: '0 8px 25px rgba(59, 130, 246, 0.3)',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<CardContent>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<Box>
|
||||
<Typography sx={{ color: 'rgba(255, 255, 255, 0.8)' }} gutterBottom>
|
||||
進行中
|
||||
</Typography>
|
||||
<Typography variant="h4" sx={{ fontWeight: 700, color: 'white' }}>
|
||||
{stats.doing}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Schedule sx={{ fontSize: 40, color: 'rgba(255, 255, 255, 0.8)' }} />
|
||||
</Box>
|
||||
<Schedule sx={{ fontSize: 40, color: 'rgba(255, 255, 255, 0.8)' }} />
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
</Grid>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12} sm={6} md={3}>
|
||||
<motion.div variants={itemVariants}>
|
||||
<Card
|
||||
sx={{
|
||||
background: actualTheme === 'dark'
|
||||
? 'linear-gradient(135deg, #065f46 0%, #10b981 100%)'
|
||||
: 'linear-gradient(135deg, #10b981 0%, #34d399 100%)',
|
||||
color: 'white',
|
||||
transition: 'transform 0.2s ease, box-shadow 0.2s ease',
|
||||
'&:hover': {
|
||||
transform: 'translateY(-2px)',
|
||||
boxShadow: '0 8px 25px rgba(16, 185, 129, 0.3)',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<CardContent>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<Box>
|
||||
<Typography sx={{ color: 'rgba(255, 255, 255, 0.8)' }} gutterBottom>
|
||||
已完成
|
||||
</Typography>
|
||||
<Typography variant="h4" sx={{ fontWeight: 700, color: 'white' }}>
|
||||
{stats.completed}
|
||||
</Typography>
|
||||
<Grid item xs={12} sm={6} md={3}>
|
||||
<motion.div variants={itemVariants}>
|
||||
<Card
|
||||
sx={{
|
||||
background: actualTheme === 'dark'
|
||||
? 'linear-gradient(135deg, #065f46 0%, #10b981 100%)'
|
||||
: 'linear-gradient(135deg, #10b981 0%, #34d399 100%)',
|
||||
color: 'white',
|
||||
transition: 'transform 0.2s ease, box-shadow 0.2s ease',
|
||||
'&:hover': {
|
||||
transform: 'translateY(-2px)',
|
||||
boxShadow: '0 8px 25px rgba(16, 185, 129, 0.3)',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<CardContent>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<Box>
|
||||
<Typography sx={{ color: 'rgba(255, 255, 255, 0.8)' }} gutterBottom>
|
||||
已完成
|
||||
</Typography>
|
||||
<Typography variant="h4" sx={{ fontWeight: 700, color: 'white' }}>
|
||||
{stats.completed}
|
||||
</Typography>
|
||||
</Box>
|
||||
<CheckCircle sx={{ fontSize: 40, color: 'rgba(255, 255, 255, 0.8)' }} />
|
||||
</Box>
|
||||
<CheckCircle sx={{ fontSize: 40, color: 'rgba(255, 255, 255, 0.8)' }} />
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
</Grid>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12} sm={6} md={3}>
|
||||
<motion.div variants={itemVariants}>
|
||||
<Card
|
||||
sx={{
|
||||
background: actualTheme === 'dark'
|
||||
? 'linear-gradient(135deg, #991b1b 0%, #ef4444 100%)'
|
||||
: 'linear-gradient(135deg, #ef4444 0%, #f87171 100%)',
|
||||
color: 'white',
|
||||
transition: 'transform 0.2s ease, box-shadow 0.2s ease',
|
||||
'&:hover': {
|
||||
transform: 'translateY(-2px)',
|
||||
boxShadow: '0 8px 25px rgba(239, 68, 68, 0.3)',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<CardContent>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<Box>
|
||||
<Typography sx={{ color: 'rgba(255, 255, 255, 0.8)' }} gutterBottom>
|
||||
已逾期
|
||||
</Typography>
|
||||
<Typography variant="h4" sx={{ fontWeight: 700, color: 'white' }}>
|
||||
{stats.overdue}
|
||||
</Typography>
|
||||
<Grid item xs={12} sm={6} md={3}>
|
||||
<motion.div variants={itemVariants}>
|
||||
<Card
|
||||
sx={{
|
||||
background: actualTheme === 'dark'
|
||||
? 'linear-gradient(135deg, #991b1b 0%, #ef4444 100%)'
|
||||
: 'linear-gradient(135deg, #ef4444 0%, #f87171 100%)',
|
||||
color: 'white',
|
||||
transition: 'transform 0.2s ease, box-shadow 0.2s ease',
|
||||
'&:hover': {
|
||||
transform: 'translateY(-2px)',
|
||||
boxShadow: '0 8px 25px rgba(239, 68, 68, 0.3)',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<CardContent>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<Box>
|
||||
<Typography sx={{ color: 'rgba(255, 255, 255, 0.8)' }} gutterBottom>
|
||||
已逾期
|
||||
</Typography>
|
||||
<Typography variant="h4" sx={{ fontWeight: 700, color: 'white' }}>
|
||||
{stats.overdue}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Warning sx={{ fontSize: 40, color: 'rgba(255, 255, 255, 0.8)' }} />
|
||||
</Box>
|
||||
<Warning sx={{ fontSize: 40, color: 'rgba(255, 255, 255, 0.8)' }} />
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Grid>
|
||||
)}
|
||||
|
||||
{/* 主要內容區域 */}
|
||||
<Grid container spacing={3}>
|
||||
|
351
frontend/src/app/public/page.tsx
Normal file
351
frontend/src/app/public/page.tsx
Normal file
@@ -0,0 +1,351 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Container,
|
||||
Typography,
|
||||
Card,
|
||||
CardContent,
|
||||
Chip,
|
||||
IconButton,
|
||||
Button,
|
||||
TextField,
|
||||
InputAdornment,
|
||||
Grid,
|
||||
Skeleton,
|
||||
Alert,
|
||||
Tooltip,
|
||||
Badge,
|
||||
Avatar,
|
||||
AvatarGroup,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Search,
|
||||
Public as PublicIcon,
|
||||
PersonAdd,
|
||||
PersonRemove,
|
||||
Star,
|
||||
StarBorder,
|
||||
FilterList,
|
||||
Refresh,
|
||||
Visibility,
|
||||
} from '@mui/icons-material';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { todosApi } from '@/lib/api';
|
||||
import { Todo, TodoFilter } from '@/types';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import TodoDialog from '@/components/todos/TodoDialog';
|
||||
import TodoFilters from '@/components/todos/TodoFilters';
|
||||
import DashboardLayout from '@/components/layout/DashboardLayout';
|
||||
|
||||
export default function PublicTodosPage() {
|
||||
const router = useRouter();
|
||||
const [todos, setTodos] = useState<Todo[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [filters, setFilters] = useState<TodoFilter>({});
|
||||
const [showFilters, setShowFilters] = useState(false);
|
||||
const [selectedTodo, setSelectedTodo] = useState<Todo | null>(null);
|
||||
const [showTodoDialog, setShowTodoDialog] = useState(false);
|
||||
const [followingTodos, setFollowingTodos] = useState<Set<string>>(new Set());
|
||||
|
||||
useEffect(() => {
|
||||
fetchPublicTodos();
|
||||
fetchFollowingStatus();
|
||||
}, [filters, searchTerm]);
|
||||
|
||||
const fetchPublicTodos = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await todosApi.getPublicTodos({
|
||||
...filters,
|
||||
search: searchTerm,
|
||||
});
|
||||
setTodos(response.todos);
|
||||
} catch (error) {
|
||||
toast.error('載入公開任務失敗');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchFollowingStatus = async () => {
|
||||
try {
|
||||
const response = await todosApi.getFollowingTodos();
|
||||
const followingIds = new Set(response.todos.map(t => t.id));
|
||||
setFollowingTodos(followingIds);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch following status:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleFollow = async (todo: Todo) => {
|
||||
try {
|
||||
const isFollowing = followingTodos.has(todo.id);
|
||||
|
||||
if (isFollowing) {
|
||||
await todosApi.unfollowTodo(todo.id);
|
||||
setFollowingTodos(prev => {
|
||||
const newSet = new Set(prev);
|
||||
newSet.delete(todo.id);
|
||||
return newSet;
|
||||
});
|
||||
toast.success('已取消追蹤');
|
||||
} else {
|
||||
await todosApi.followTodo(todo.id);
|
||||
setFollowingTodos(prev => new Set([...prev, todo.id]));
|
||||
toast.success('已開始追蹤');
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error('操作失敗');
|
||||
}
|
||||
};
|
||||
|
||||
const handleViewTodo = (todo: Todo) => {
|
||||
setSelectedTodo(todo);
|
||||
setShowTodoDialog(true);
|
||||
};
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
const colors = {
|
||||
NEW: 'default',
|
||||
DOING: 'primary',
|
||||
BLOCKED: 'error',
|
||||
DONE: 'success',
|
||||
};
|
||||
return colors[status as keyof typeof colors] || 'default';
|
||||
};
|
||||
|
||||
const getPriorityColor = (priority: string) => {
|
||||
const colors = {
|
||||
LOW: 'default',
|
||||
MEDIUM: 'info',
|
||||
HIGH: 'warning',
|
||||
URGENT: 'error',
|
||||
};
|
||||
return colors[priority as keyof typeof colors] || 'default';
|
||||
};
|
||||
|
||||
return (
|
||||
<DashboardLayout>
|
||||
<Container maxWidth="xl" sx={{ py: 3 }}>
|
||||
{/* Header */}
|
||||
<Box sx={{ mb: 3, display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||
<PublicIcon sx={{ fontSize: 32, color: 'primary.main' }} />
|
||||
<Typography variant="h4" sx={{ flexGrow: 1 }}>
|
||||
公開任務
|
||||
</Typography>
|
||||
<Button
|
||||
variant="outlined"
|
||||
startIcon={<Refresh />}
|
||||
onClick={fetchPublicTodos}
|
||||
>
|
||||
重新整理
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
{/* Search and Filters */}
|
||||
<Card sx={{ mb: 3 }}>
|
||||
<CardContent>
|
||||
<Box sx={{ display: 'flex', gap: 2, mb: 2 }}>
|
||||
<TextField
|
||||
fullWidth
|
||||
placeholder="搜尋公開任務..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
InputProps={{
|
||||
startAdornment: (
|
||||
<InputAdornment position="start">
|
||||
<Search />
|
||||
</InputAdornment>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
variant="outlined"
|
||||
startIcon={<FilterList />}
|
||||
onClick={() => setShowFilters(!showFilters)}
|
||||
>
|
||||
篩選
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
{showFilters && (
|
||||
<TodoFilters
|
||||
onClose={() => setShowFilters(false)}
|
||||
onApply={setFilters}
|
||||
initialFilters={filters}
|
||||
/>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Todos List */}
|
||||
{loading ? (
|
||||
<Grid container spacing={2}>
|
||||
{[1, 2, 3].map((i) => (
|
||||
<Grid item xs={12} md={6} lg={4} key={i}>
|
||||
<Skeleton variant="rectangular" height={200} />
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
) : todos.length === 0 ? (
|
||||
<Alert severity="info">目前沒有公開任務</Alert>
|
||||
) : (
|
||||
<Grid container spacing={2}>
|
||||
{todos.map((todo) => (
|
||||
<Grid item xs={12} md={6} lg={4} key={todo.id}>
|
||||
<Card
|
||||
sx={{
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
transition: 'transform 0.2s',
|
||||
'&:hover': {
|
||||
transform: 'translateY(-4px)',
|
||||
boxShadow: 3,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<CardContent sx={{ flexGrow: 1 }}>
|
||||
{/* Title and Status */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'flex-start', mb: 2 }}>
|
||||
<Box sx={{ flexGrow: 1 }}>
|
||||
<Typography
|
||||
variant="h6"
|
||||
sx={{
|
||||
cursor: 'pointer',
|
||||
'&:hover': { color: 'primary.main' },
|
||||
}}
|
||||
onClick={() => handleViewTodo(todo)}
|
||||
>
|
||||
{todo.starred && <Star sx={{ fontSize: 18, color: '#fbbf24', mr: 0.5 }} />}
|
||||
{todo.title}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
建立者: {todo.creator_display_name || todo.creator_ad}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Tooltip title={followingTodos.has(todo.id) ? '取消追蹤' : '追蹤'}>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => handleToggleFollow(todo)}
|
||||
color={followingTodos.has(todo.id) ? 'primary' : 'default'}
|
||||
>
|
||||
{followingTodos.has(todo.id) ? <PersonRemove /> : <PersonAdd />}
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
|
||||
{/* Description */}
|
||||
{todo.description && (
|
||||
<Typography
|
||||
variant="body2"
|
||||
color="text.secondary"
|
||||
sx={{
|
||||
mb: 2,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
display: '-webkit-box',
|
||||
WebkitLineClamp: 2,
|
||||
WebkitBoxOrient: 'vertical',
|
||||
}}
|
||||
>
|
||||
{todo.description}
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
{/* Chips */}
|
||||
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap', mb: 2 }}>
|
||||
<Chip
|
||||
label={todo.status}
|
||||
size="small"
|
||||
color={getStatusColor(todo.status) as any}
|
||||
/>
|
||||
<Chip
|
||||
label={todo.priority}
|
||||
size="small"
|
||||
color={getPriorityColor(todo.priority) as any}
|
||||
variant="outlined"
|
||||
/>
|
||||
{todo.due_date && (
|
||||
<Chip
|
||||
label={`到期: ${new Date(todo.due_date).toLocaleDateString()}`}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Tags */}
|
||||
{todo.tags && todo.tags.length > 0 && (
|
||||
<Box sx={{ display: 'flex', gap: 0.5, flexWrap: 'wrap', mb: 2 }}>
|
||||
{todo.tags.map((tag, index) => (
|
||||
<Chip
|
||||
key={index}
|
||||
label={tag}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
sx={{ borderRadius: 1 }}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Followers */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
追蹤者:
|
||||
</Typography>
|
||||
{todo.followers.length > 0 ? (
|
||||
<AvatarGroup max={3} sx={{ '& .MuiAvatar-root': { width: 24, height: 24, fontSize: 12 } }}>
|
||||
{todo.followers_details?.map((follower) => (
|
||||
<Tooltip key={follower.ad_account} title={follower.display_name}>
|
||||
<Avatar sx={{ bgcolor: 'primary.main' }}>
|
||||
{follower.display_name.charAt(0)}
|
||||
</Avatar>
|
||||
</Tooltip>
|
||||
))}
|
||||
</AvatarGroup>
|
||||
) : (
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
無
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
</CardContent>
|
||||
|
||||
{/* Actions */}
|
||||
<Box sx={{ p: 2, pt: 0, display: 'flex', gap: 1 }}>
|
||||
<Button
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
startIcon={<Visibility />}
|
||||
onClick={() => handleViewTodo(todo)}
|
||||
>
|
||||
查看詳情
|
||||
</Button>
|
||||
</Box>
|
||||
</Card>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
)}
|
||||
|
||||
{/* Todo Dialog */}
|
||||
{selectedTodo && (
|
||||
<TodoDialog
|
||||
open={showTodoDialog}
|
||||
onClose={() => {
|
||||
setShowTodoDialog(false);
|
||||
setSelectedTodo(null);
|
||||
}}
|
||||
todo={selectedTodo}
|
||||
readOnly={true}
|
||||
/>
|
||||
)}
|
||||
</Container>
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
@@ -11,6 +11,9 @@ import {
|
||||
Fade,
|
||||
Chip,
|
||||
Card,
|
||||
Skeleton,
|
||||
CircularProgress,
|
||||
Backdrop,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Add,
|
||||
@@ -42,6 +45,16 @@ const TodosPage = () => {
|
||||
const [viewMode, setViewMode] = useState<ViewMode>('list');
|
||||
const [filterMode, setFilterMode] = useState<FilterMode>('all');
|
||||
const [showFilters, setShowFilters] = useState(false);
|
||||
const [appliedFilters, setAppliedFilters] = useState({
|
||||
status: [] as string[],
|
||||
priority: [] as string[],
|
||||
assignee: '',
|
||||
dateFrom: null as any,
|
||||
dateTo: null as any,
|
||||
starred: false,
|
||||
overdue: false,
|
||||
dueSoon: false,
|
||||
});
|
||||
const [showSearch, setShowSearch] = useState(false);
|
||||
const [selectedTodos, setSelectedTodos] = useState<string[]>([]);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
@@ -142,10 +155,68 @@ const TodosPage = () => {
|
||||
case 'following':
|
||||
return todo.followers?.includes(currentUser.ad_account) || false;
|
||||
default:
|
||||
return true;
|
||||
break; // 繼續其他篩選
|
||||
}
|
||||
}
|
||||
|
||||
// 進階篩選
|
||||
// 狀態篩選
|
||||
if (appliedFilters.status.length > 0 && !appliedFilters.status.includes(todo.status)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 優先級篩選
|
||||
if (appliedFilters.priority.length > 0 && !appliedFilters.priority.includes(todo.priority)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 指派人篩選
|
||||
if (appliedFilters.assignee && currentUser) {
|
||||
switch (appliedFilters.assignee) {
|
||||
case 'me':
|
||||
if (!todo.responsible_users?.includes(currentUser.ad_account)) return false;
|
||||
break;
|
||||
case 'created_by_me':
|
||||
if (todo.creator_ad !== currentUser.ad_account) return false;
|
||||
break;
|
||||
case 'followed_by_me':
|
||||
if (!todo.followers?.includes(currentUser.ad_account)) return false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// 日期篩選
|
||||
if (appliedFilters.dateFrom || appliedFilters.dateTo) {
|
||||
if (!todo.due_date) return false;
|
||||
const dueDate = new Date(todo.due_date);
|
||||
if (appliedFilters.dateFrom && dueDate < new Date(appliedFilters.dateFrom)) return false;
|
||||
if (appliedFilters.dateTo && dueDate > new Date(appliedFilters.dateTo)) return false;
|
||||
}
|
||||
|
||||
// 星號篩選
|
||||
if (appliedFilters.starred && !todo.starred) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 逾期篩選
|
||||
if (appliedFilters.overdue) {
|
||||
if (!todo.due_date) return false;
|
||||
const dueDate = new Date(todo.due_date);
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
if (dueDate >= today || todo.status === 'DONE') return false;
|
||||
}
|
||||
|
||||
// 即將到期篩選
|
||||
if (appliedFilters.dueSoon) {
|
||||
if (!todo.due_date || todo.status === 'DONE') return false;
|
||||
const dueDate = new Date(todo.due_date);
|
||||
const today = new Date();
|
||||
const threeDaysFromNow = new Date();
|
||||
threeDaysFromNow.setDate(today.getDate() + 3);
|
||||
if (dueDate < today || dueDate > threeDaysFromNow) return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
@@ -544,7 +615,11 @@ const TodosPage = () => {
|
||||
exit={{ opacity: 0, height: 0, marginBottom: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
<TodoFilters onClose={() => setShowFilters(false)} />
|
||||
<TodoFilters
|
||||
onClose={() => setShowFilters(false)}
|
||||
onApply={setAppliedFilters}
|
||||
initialFilters={appliedFilters}
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
@@ -572,8 +647,28 @@ const TodosPage = () => {
|
||||
{/* 主要內容區域 */}
|
||||
<motion.div variants={itemVariants}>
|
||||
<Fade in={true} timeout={500}>
|
||||
<Box>
|
||||
{viewMode === 'list' ? (
|
||||
<Box sx={{ position: 'relative', minHeight: '400px' }}>
|
||||
{loading ? (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
{Array.from({ length: 3 }).map((_, index) => (
|
||||
<Card key={index} sx={{ p: 3 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 2 }}>
|
||||
<Skeleton variant="rectangular" width={20} height={20} />
|
||||
<Skeleton variant="text" width="60%" height={32} />
|
||||
<Skeleton variant="circular" width={24} height={24} sx={{ ml: 'auto' }} />
|
||||
</Box>
|
||||
<Skeleton variant="text" width="80%" height={20} sx={{ mb: 1 }} />
|
||||
<Skeleton variant="text" width="40%" height={20} sx={{ mb: 2 }} />
|
||||
<Box sx={{ display: 'flex', gap: 1, mb: 2 }}>
|
||||
<Skeleton variant="rounded" width={60} height={24} />
|
||||
<Skeleton variant="rounded" width={50} height={24} />
|
||||
<Skeleton variant="rounded" width={70} height={24} />
|
||||
</Box>
|
||||
<Skeleton variant="text" width="30%" height={16} />
|
||||
</Card>
|
||||
))}
|
||||
</Box>
|
||||
) : viewMode === 'list' ? (
|
||||
<TodoList
|
||||
todos={filteredTodos}
|
||||
selectedTodos={selectedTodos}
|
||||
|
@@ -186,6 +186,50 @@ export const todosApi = {
|
||||
message: request.note,
|
||||
});
|
||||
},
|
||||
|
||||
getPublicTodos: async (filters?: TodoFilter): Promise<TodosResponse> => {
|
||||
const params = new URLSearchParams();
|
||||
|
||||
if (filters) {
|
||||
Object.entries(filters).forEach(([key, value]) => {
|
||||
if (value !== undefined && value !== null && value !== '') {
|
||||
if (key === 'tags' && Array.isArray(value)) {
|
||||
value.forEach(tag => params.append('tags', tag));
|
||||
} else {
|
||||
params.append(key, value.toString());
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const response = await api.get(`/api/todos/public?${params.toString()}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getFollowingTodos: async (page = 1, perPage = 20): Promise<TodosResponse> => {
|
||||
const response = await api.get(`/api/todos/following?page=${page}&per_page=${perPage}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
updateTodoVisibility: async (id: string, isPublic: boolean): Promise<{ message: string; is_public: boolean }> => {
|
||||
const response = await api.patch(`/api/todos/${id}/visibility`, { is_public: isPublic });
|
||||
return response.data;
|
||||
},
|
||||
|
||||
followTodo: async (id: string): Promise<{ message: string }> => {
|
||||
const response = await api.post(`/api/todos/${id}/follow`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
unfollowTodo: async (id: string): Promise<{ message: string }> => {
|
||||
const response = await api.delete(`/api/todos/${id}/follow`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
toggleStar: async (id: string): Promise<{ message: string; starred: boolean }> => {
|
||||
const response = await api.patch(`/api/todos/${id}/star`);
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
|
||||
// Users API
|
||||
|
@@ -19,6 +19,8 @@ export interface Todo {
|
||||
creator_display_name?: string;
|
||||
creator_email?: string;
|
||||
starred: boolean;
|
||||
is_public: boolean;
|
||||
tags: string[];
|
||||
responsible_users: string[];
|
||||
followers: string[];
|
||||
responsible_users_details?: UserDetail[];
|
||||
@@ -32,6 +34,8 @@ export interface TodoCreate {
|
||||
priority?: 'LOW' | 'MEDIUM' | 'HIGH' | 'URGENT';
|
||||
due_date?: string;
|
||||
starred?: boolean;
|
||||
is_public?: boolean;
|
||||
tags?: string[];
|
||||
responsible_users?: string[];
|
||||
followers?: string[];
|
||||
}
|
||||
@@ -47,7 +51,8 @@ export interface TodoFilter {
|
||||
due_from?: string;
|
||||
due_to?: string;
|
||||
search?: string;
|
||||
view?: 'all' | 'created' | 'responsible' | 'following';
|
||||
view?: 'all' | 'created' | 'responsible' | 'following' | 'public';
|
||||
tags?: string[];
|
||||
}
|
||||
|
||||
// User Types
|
||||
|
Reference in New Issue
Block a user