1ST
This commit is contained in:
611
frontend/src/app/todos/page.tsx
Normal file
611
frontend/src/app/todos/page.tsx
Normal file
@@ -0,0 +1,611 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
Button,
|
||||
IconButton,
|
||||
Toolbar,
|
||||
Tooltip,
|
||||
Fade,
|
||||
Chip,
|
||||
Card,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Add,
|
||||
ViewList,
|
||||
CalendarViewMonth,
|
||||
FilterList,
|
||||
Sort,
|
||||
Search,
|
||||
SelectAll,
|
||||
MoreVert,
|
||||
} from '@mui/icons-material';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { useTheme } from '@/providers/ThemeProvider';
|
||||
import DashboardLayout from '@/components/layout/DashboardLayout';
|
||||
import TodoList from '@/components/todos/TodoList';
|
||||
import CalendarView from '@/components/todos/CalendarView';
|
||||
import TodoFilters from '@/components/todos/TodoFilters';
|
||||
import BatchActions from '@/components/todos/BatchActions';
|
||||
import SearchBar from '@/components/todos/SearchBar';
|
||||
import TodoDialog from '@/components/todos/TodoDialog';
|
||||
import { Todo } from '@/types';
|
||||
import { todosApi } from '@/lib/api';
|
||||
|
||||
type ViewMode = 'list' | 'calendar';
|
||||
type FilterMode = 'all' | 'created' | 'responsible' | 'following';
|
||||
|
||||
const TodosPage = () => {
|
||||
const { actualTheme } = useTheme();
|
||||
const [viewMode, setViewMode] = useState<ViewMode>('list');
|
||||
const [filterMode, setFilterMode] = useState<FilterMode>('all');
|
||||
const [showFilters, setShowFilters] = useState(false);
|
||||
const [showSearch, setShowSearch] = useState(false);
|
||||
const [selectedTodos, setSelectedTodos] = useState<string[]>([]);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [showTodoDialog, setShowTodoDialog] = useState(false);
|
||||
const [editingTodo, setEditingTodo] = useState<any>(null);
|
||||
const [todos, setTodos] = useState<Todo[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [currentUser, setCurrentUser] = useState<any>(null);
|
||||
|
||||
// 從 API 獲取資料
|
||||
useEffect(() => {
|
||||
const fetchTodos = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
// 檢查是否有有效的 token
|
||||
const token = localStorage.getItem('access_token');
|
||||
if (!token) {
|
||||
console.log('No access token found, skipping API call');
|
||||
setTodos([]);
|
||||
return;
|
||||
}
|
||||
|
||||
// 獲取當前用戶信息
|
||||
try {
|
||||
const userResponse = await fetch('http://localhost:5000/api/auth/me', {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
},
|
||||
});
|
||||
if (userResponse.ok) {
|
||||
const userData = await userResponse.json();
|
||||
setCurrentUser(userData);
|
||||
}
|
||||
} catch (userError) {
|
||||
console.warn('Failed to fetch user data:', userError);
|
||||
}
|
||||
|
||||
// 獲取待辦事項
|
||||
const response = await todosApi.getTodos({
|
||||
view: filterMode === 'all' ? 'all' : filterMode
|
||||
});
|
||||
setTodos(response.todos || []);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch todos:', error);
|
||||
// 如果是認證錯誤,清除 token 並跳轉到登入頁
|
||||
if (error.response?.status === 401) {
|
||||
localStorage.removeItem('access_token');
|
||||
localStorage.removeItem('refresh_token');
|
||||
localStorage.removeItem('user');
|
||||
window.location.href = '/login';
|
||||
}
|
||||
setTodos([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchTodos();
|
||||
}, [filterMode]);
|
||||
|
||||
const containerVariants = {
|
||||
hidden: { opacity: 0 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
transition: {
|
||||
staggerChildren: 0.1,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const itemVariants = {
|
||||
hidden: { opacity: 0, y: 20 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
transition: { duration: 0.5 },
|
||||
},
|
||||
};
|
||||
|
||||
const filteredTodos = todos.filter(todo => {
|
||||
// 搜尋過濾
|
||||
if (searchQuery) {
|
||||
const query = searchQuery.toLowerCase();
|
||||
if (!todo.title.toLowerCase().includes(query) &&
|
||||
!todo.description?.toLowerCase().includes(query)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// 視圖過濾
|
||||
if (currentUser) {
|
||||
switch (filterMode) {
|
||||
case 'created':
|
||||
return todo.creator_ad === currentUser.ad_account;
|
||||
case 'responsible':
|
||||
return todo.responsible_users?.includes(currentUser.ad_account) || false;
|
||||
case 'following':
|
||||
return todo.followers?.includes(currentUser.ad_account) || false;
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
const getFilterModeLabel = (mode: FilterMode) => {
|
||||
switch (mode) {
|
||||
case 'created': return '我建立的';
|
||||
case 'responsible': return '指派給我';
|
||||
case 'following': return '我追蹤的';
|
||||
default: return '所有待辦';
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectAll = () => {
|
||||
if (selectedTodos.length === filteredTodos.length) {
|
||||
setSelectedTodos([]);
|
||||
} else {
|
||||
setSelectedTodos(filteredTodos.map(todo => todo.id));
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateTodo = () => {
|
||||
setEditingTodo(null);
|
||||
setShowTodoDialog(true);
|
||||
};
|
||||
|
||||
const handleEditTodo = (todo: any) => {
|
||||
setEditingTodo(todo);
|
||||
setShowTodoDialog(true);
|
||||
};
|
||||
|
||||
const handleSaveTodo = (todoData: any) => {
|
||||
console.log('Saving todo:', todoData);
|
||||
// 這裡會調用 API 來儲存待辦事項
|
||||
// 儲存成功後可以更新 todos 列表
|
||||
};
|
||||
|
||||
const handleCloseTodoDialog = () => {
|
||||
setShowTodoDialog(false);
|
||||
setEditingTodo(null);
|
||||
};
|
||||
|
||||
const handleTodoCreated = async () => {
|
||||
// 刷新待辦事項列表
|
||||
try {
|
||||
const response = await todosApi.getTodos({
|
||||
view: filterMode === 'all' ? 'all' : filterMode
|
||||
});
|
||||
setTodos(response.todos || []);
|
||||
} catch (error) {
|
||||
console.error('Failed to refresh todos:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// 批次操作處理函數
|
||||
const handleBulkStatusChange = async (status: 'NEW' | 'DOING' | 'BLOCKED') => {
|
||||
try {
|
||||
if (selectedTodos.length === 0) return;
|
||||
|
||||
// 使用批次更新 API
|
||||
await todosApi.batchUpdateTodos(selectedTodos, { status });
|
||||
|
||||
// 更新本地狀態
|
||||
setTodos(prevTodos =>
|
||||
prevTodos.map(todo =>
|
||||
selectedTodos.includes(todo.id)
|
||||
? { ...todo, status }
|
||||
: todo
|
||||
)
|
||||
);
|
||||
|
||||
// 清除選擇
|
||||
setSelectedTodos([]);
|
||||
|
||||
console.log(`批次更新 ${selectedTodos.length} 個待辦事項狀態為 ${status}`);
|
||||
} catch (error) {
|
||||
console.error('批次狀態更新失敗:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBulkComplete = async () => {
|
||||
try {
|
||||
if (selectedTodos.length === 0) return;
|
||||
|
||||
// 使用批次更新 API 設為完成
|
||||
await todosApi.batchUpdateTodos(selectedTodos, { status: 'DONE' });
|
||||
|
||||
// 更新本地狀態
|
||||
setTodos(prevTodos =>
|
||||
prevTodos.map(todo =>
|
||||
selectedTodos.includes(todo.id)
|
||||
? { ...todo, status: 'DONE', completed_at: new Date().toISOString() }
|
||||
: todo
|
||||
)
|
||||
);
|
||||
|
||||
// 清除選擇
|
||||
setSelectedTodos([]);
|
||||
|
||||
console.log(`批次完成 ${selectedTodos.length} 個待辦事項`);
|
||||
} catch (error) {
|
||||
console.error('批次完成失敗:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBulkDelete = async () => {
|
||||
try {
|
||||
if (selectedTodos.length === 0) return;
|
||||
|
||||
if (!confirm(`確定要刪除 ${selectedTodos.length} 個待辦事項嗎?此操作無法復原。`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 逐一刪除待辦事項(如果沒有批次刪除 API)
|
||||
for (const todoId of selectedTodos) {
|
||||
await todosApi.deleteTodo(todoId);
|
||||
}
|
||||
|
||||
// 從本地狀態中移除
|
||||
setTodos(prevTodos =>
|
||||
prevTodos.filter(todo => !selectedTodos.includes(todo.id))
|
||||
);
|
||||
|
||||
// 清除選擇
|
||||
setSelectedTodos([]);
|
||||
|
||||
console.log(`批次刪除 ${selectedTodos.length} 個待辦事項`);
|
||||
} catch (error) {
|
||||
console.error('批次刪除失敗:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// 單個待辦事項狀態變更處理函數
|
||||
const handleStatusChange = async (todoId: string, status: string) => {
|
||||
try {
|
||||
// 使用 API 更新單個待辦事項的狀態
|
||||
await todosApi.updateTodo(todoId, { status });
|
||||
|
||||
// 更新本地狀態
|
||||
setTodos(prevTodos =>
|
||||
prevTodos.map(todo =>
|
||||
todo.id === todoId
|
||||
? {
|
||||
...todo,
|
||||
status,
|
||||
completed_at: status === 'DONE' ? new Date().toISOString() : null
|
||||
}
|
||||
: todo
|
||||
)
|
||||
);
|
||||
|
||||
console.log(`待辦事項 ${todoId} 狀態已更新為 ${status}`);
|
||||
} catch (error) {
|
||||
console.error('狀態更新失敗:', error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<DashboardLayout>
|
||||
<motion.div
|
||||
variants={containerVariants}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
>
|
||||
{/* 標題區域 */}
|
||||
<Box sx={{ mb: 3 }}>
|
||||
<motion.div variants={itemVariants}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 2 }}>
|
||||
<Box>
|
||||
<Typography
|
||||
variant="h4"
|
||||
sx={{
|
||||
fontWeight: 700,
|
||||
mb: 0.5,
|
||||
background: actualTheme === 'dark'
|
||||
? 'linear-gradient(45deg, #f3f4f6 30%, #d1d5db 90%)'
|
||||
: 'linear-gradient(45deg, #111827 30%, #374151 90%)',
|
||||
WebkitBackgroundClip: 'text',
|
||||
WebkitTextFillColor: 'transparent',
|
||||
backgroundClip: 'text',
|
||||
}}
|
||||
>
|
||||
待辦清單
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||
<Typography variant="body1" color="text.secondary">
|
||||
{getFilterModeLabel(filterMode)} · {filteredTodos.length} 項目
|
||||
</Typography>
|
||||
{selectedTodos.length > 0 && (
|
||||
<Chip
|
||||
label={`已選擇 ${selectedTodos.length} 項`}
|
||||
color="primary"
|
||||
size="small"
|
||||
sx={{ fontWeight: 600 }}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={<Add />}
|
||||
onClick={handleCreateTodo}
|
||||
sx={{
|
||||
background: 'linear-gradient(45deg, #3b82f6 30%, #8b5cf6 90%)',
|
||||
textTransform: 'none',
|
||||
fontWeight: 600,
|
||||
px: 3,
|
||||
py: 1.5,
|
||||
borderRadius: 2,
|
||||
boxShadow: '0 4px 12px rgba(59, 130, 246, 0.3)',
|
||||
'&:hover': {
|
||||
background: 'linear-gradient(45deg, #2563eb 30%, #7c3aed 90%)',
|
||||
boxShadow: '0 6px 16px rgba(59, 130, 246, 0.4)',
|
||||
transform: 'translateY(-1px)',
|
||||
},
|
||||
}}
|
||||
>
|
||||
新增待辦
|
||||
</Button>
|
||||
</Box>
|
||||
</motion.div>
|
||||
</Box>
|
||||
|
||||
{/* 工具列 */}
|
||||
<motion.div variants={itemVariants}>
|
||||
<Card
|
||||
sx={{
|
||||
mb: 3,
|
||||
backgroundColor: actualTheme === 'dark' ? '#1f2937' : '#ffffff',
|
||||
border: `1px solid ${actualTheme === 'dark'
|
||||
? 'rgba(255, 255, 255, 0.1)'
|
||||
: 'rgba(0, 0, 0, 0.1)'}`,
|
||||
}}
|
||||
>
|
||||
<Toolbar
|
||||
sx={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
gap: 2,
|
||||
px: { xs: 2, sm: 3 },
|
||||
py: 1,
|
||||
}}
|
||||
>
|
||||
{/* 左側工具 */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
{/* 視圖切換 */}
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
backgroundColor: actualTheme === 'dark'
|
||||
? 'rgba(255, 255, 255, 0.05)'
|
||||
: 'rgba(0, 0, 0, 0.04)',
|
||||
borderRadius: 1.5,
|
||||
p: 0.5,
|
||||
}}
|
||||
>
|
||||
<Tooltip title="清單視圖">
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => setViewMode('list')}
|
||||
sx={{
|
||||
backgroundColor: viewMode === 'list' ? 'primary.main' : 'transparent',
|
||||
color: viewMode === 'list' ? 'white' : 'text.secondary',
|
||||
'&:hover': {
|
||||
backgroundColor: viewMode === 'list' ? 'primary.dark' : 'action.hover',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<ViewList fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title="日曆視圖">
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => setViewMode('calendar')}
|
||||
sx={{
|
||||
backgroundColor: viewMode === 'calendar' ? 'primary.main' : 'transparent',
|
||||
color: viewMode === 'calendar' ? 'white' : 'text.secondary',
|
||||
'&:hover': {
|
||||
backgroundColor: viewMode === 'calendar' ? 'primary.dark' : 'action.hover',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<CalendarViewMonth fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
|
||||
{/* 篩選器切換 */}
|
||||
<Box sx={{ display: 'flex', gap: 0.5 }}>
|
||||
{(['all', 'created', 'responsible', 'following'] as FilterMode[]).map((mode) => (
|
||||
<Chip
|
||||
key={mode}
|
||||
label={getFilterModeLabel(mode)}
|
||||
variant={filterMode === mode ? 'filled' : 'outlined'}
|
||||
color={filterMode === mode ? 'primary' : 'default'}
|
||||
size="small"
|
||||
clickable
|
||||
onClick={() => setFilterMode(mode)}
|
||||
sx={{
|
||||
fontSize: '0.75rem',
|
||||
fontWeight: filterMode === mode ? 600 : 400,
|
||||
'&:hover': {
|
||||
transform: 'translateY(-1px)',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* 右側工具 */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Tooltip title="搜尋">
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => setShowSearch(!showSearch)}
|
||||
sx={{
|
||||
color: showSearch ? 'primary.main' : 'text.secondary',
|
||||
backgroundColor: showSearch
|
||||
? (actualTheme === 'dark' ? 'rgba(59, 130, 246, 0.1)' : 'rgba(59, 130, 246, 0.1)')
|
||||
: 'transparent',
|
||||
}}
|
||||
>
|
||||
<Search fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip title="篩選">
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => setShowFilters(!showFilters)}
|
||||
sx={{
|
||||
color: showFilters ? 'primary.main' : 'text.secondary',
|
||||
backgroundColor: showFilters
|
||||
? (actualTheme === 'dark' ? 'rgba(59, 130, 246, 0.1)' : 'rgba(59, 130, 246, 0.1)')
|
||||
: 'transparent',
|
||||
}}
|
||||
>
|
||||
<FilterList fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip title="排序">
|
||||
<IconButton size="small" sx={{ color: 'text.secondary' }}>
|
||||
<Sort fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip title="全選">
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={handleSelectAll}
|
||||
sx={{
|
||||
color: selectedTodos.length > 0 ? 'primary.main' : 'text.secondary',
|
||||
}}
|
||||
>
|
||||
<SelectAll fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip title="更多選項">
|
||||
<IconButton size="small" sx={{ color: 'text.secondary' }}>
|
||||
<MoreVert fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
</Toolbar>
|
||||
</Card>
|
||||
</motion.div>
|
||||
|
||||
{/* 搜尋列 */}
|
||||
<AnimatePresence>
|
||||
{showSearch && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, height: 0, marginBottom: 0 }}
|
||||
animate={{ opacity: 1, height: 'auto', marginBottom: 24 }}
|
||||
exit={{ opacity: 0, height: 0, marginBottom: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
<SearchBar
|
||||
value={searchQuery}
|
||||
onChange={setSearchQuery}
|
||||
onClose={() => setShowSearch(false)}
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* 進階篩選 */}
|
||||
<AnimatePresence>
|
||||
{showFilters && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, height: 0, marginBottom: 0 }}
|
||||
animate={{ opacity: 1, height: 'auto', marginBottom: 24 }}
|
||||
exit={{ opacity: 0, height: 0, marginBottom: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
<TodoFilters onClose={() => setShowFilters(false)} />
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* 批次操作工具列 */}
|
||||
<AnimatePresence>
|
||||
{selectedTodos.length > 0 && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: 20 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
<BatchActions
|
||||
selectedCount={selectedTodos.length}
|
||||
onClearSelection={() => setSelectedTodos([])}
|
||||
onBulkStatusChange={handleBulkStatusChange}
|
||||
onBulkComplete={handleBulkComplete}
|
||||
onBulkDelete={handleBulkDelete}
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* 主要內容區域 */}
|
||||
<motion.div variants={itemVariants}>
|
||||
<Fade in={true} timeout={500}>
|
||||
<Box>
|
||||
{viewMode === 'list' ? (
|
||||
<TodoList
|
||||
todos={filteredTodos}
|
||||
selectedTodos={selectedTodos}
|
||||
onSelectionChange={setSelectedTodos}
|
||||
viewMode={viewMode}
|
||||
onEditTodo={handleEditTodo}
|
||||
onStatusChange={handleStatusChange}
|
||||
/>
|
||||
) : (
|
||||
<CalendarView
|
||||
todos={filteredTodos}
|
||||
selectedTodos={selectedTodos}
|
||||
onSelectionChange={setSelectedTodos}
|
||||
onEditTodo={handleEditTodo}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
</Fade>
|
||||
</motion.div>
|
||||
|
||||
{/* 新增/編輯待辦對話框 */}
|
||||
<TodoDialog
|
||||
open={showTodoDialog}
|
||||
onClose={handleCloseTodoDialog}
|
||||
todo={editingTodo}
|
||||
mode={editingTodo ? 'edit' : 'create'}
|
||||
onSave={handleSaveTodo}
|
||||
onTodoCreated={handleTodoCreated}
|
||||
/>
|
||||
</motion.div>
|
||||
</DashboardLayout>
|
||||
);
|
||||
};
|
||||
|
||||
export default TodosPage;
|
Reference in New Issue
Block a user