Files
TODO_list_system/frontend/src/app/todos/page.tsx
beabigegg 8185b609f7 4th
2025-09-01 09:16:14 +08:00

813 lines
27 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'use client';
import React, { useState, useEffect } from 'react';
import {
Box,
Typography,
Button,
IconButton,
Toolbar,
Tooltip,
Fade,
Chip,
Card,
Skeleton,
CircularProgress,
Backdrop,
} from '@mui/material';
import {
Add,
ViewList,
CalendarViewMonth,
FilterList,
Search,
SelectAll,
CloudUpload,
} 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 ExcelImport from '@/components/todos/ExcelImport';
import { Todo } from '@/types';
import { todosApi } from '@/lib/api';
import { useSearchParams } from 'next/navigation';
type ViewMode = 'list' | 'calendar';
type FilterMode = 'all' | 'created' | 'responsible' | 'following';
const TodosPage = () => {
const { actualTheme } = useTheme();
const searchParams = useSearchParams();
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('');
const [showTodoDialog, setShowTodoDialog] = useState(false);
const [editingTodo, setEditingTodo] = useState<any>(null);
const [showExcelImport, setShowExcelImport] = useState(false);
const [todos, setTodos] = useState<Todo[]>([]);
const [loading, setLoading] = useState(true);
const [currentUser, setCurrentUser] = useState<any>(null);
// 讀取 URL 參數並設定篩選條件
useEffect(() => {
console.log('URL search params:', searchParams.toString());
// 當從 Sidebar 點擊時,應該清除所有其他篩選,只保留當前篩選
const viewParam = searchParams.get('view');
const statusParam = searchParams.get('status');
const starredParam = searchParams.get('starred');
// 重置所有篩選狀態
setFilterMode('all');
setAppliedFilters({
status: [],
priority: [],
assignee: '',
dateFrom: null,
dateTo: null,
starred: false,
overdue: false,
dueSoon: false,
});
// 根據 URL 參數設定對應的篩選
if (viewParam && ['created', 'responsible', 'following'].includes(viewParam)) {
setFilterMode(viewParam as FilterMode);
console.log('Setting filterMode to:', viewParam);
} else if (statusParam) {
// 狀態篩選:清除視圖篩選,只保留狀態篩選
setAppliedFilters(prev => ({
...prev,
status: [statusParam]
}));
console.log('Setting status filter to:', statusParam);
} else if (starredParam === 'true') {
// 星標篩選:清除其他篩選,只保留星標篩選
setAppliedFilters(prev => ({
...prev,
starred: true
}));
console.log('Setting starred filter to: true');
}
}, [searchParams]);
// 從 API 獲取資料
useEffect(() => {
const fetchTodos = async () => {
try {
setLoading(true);
// 檢查是否有有效的 token
const token = localStorage.getItem('access_token');
console.log('Access token:', token ? 'Found' : 'Not found');
if (!token) {
console.log('No access token found, redirecting to login');
setTodos([]);
window.location.href = '/login';
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);
}
// 獲取待辦事項
console.log('Fetching todos with filterMode:', filterMode);
const response = await todosApi.getTodos({
view: filterMode === 'all' ? 'all' : filterMode
});
console.log('Todos API response:', response);
setTodos(response.todos || []);
} catch (error: any) {
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':
if (todo.creator_ad !== currentUser.ad_account) return false;
break;
case 'responsible':
if (!todo.responsible_users?.includes(currentUser.ad_account)) return false;
break;
case 'following':
if (!todo.followers?.includes(currentUser.ad_account)) return false;
break;
default:
break; // 'all' 模式,繼續其他篩選
}
}
// 進階篩選
// 狀態篩選
if (appliedFilters.status.length > 0 && !appliedFilters.status.includes(todo.status)) {
console.log(`Todo ${todo.title} filtered out by status: ${todo.status} not in`, appliedFilters.status);
return false;
}
// 優先級篩選
if (appliedFilters.priority.length > 0 && !appliedFilters.priority.includes(todo.priority)) {
console.log(`Todo ${todo.title} filtered out by priority: ${todo.priority} not in`, appliedFilters.priority);
return false;
}
// 指派人篩選
if (appliedFilters.assignee && currentUser) {
switch (appliedFilters.assignee) {
case 'me':
if (!todo.responsible_users?.includes(currentUser.ad_account)) {
console.log(`Todo ${todo.title} filtered out: not assigned to me`);
return false;
}
break;
case 'created_by_me':
if (todo.creator_ad !== currentUser.ad_account) {
console.log(`Todo ${todo.title} filtered out: not created by me`);
return false;
}
break;
case 'followed_by_me':
if (!todo.followers?.includes(currentUser.ad_account)) {
console.log(`Todo ${todo.title} filtered out: not followed by me`);
return false;
}
break;
}
}
// 日期篩選
if (appliedFilters.dateFrom || appliedFilters.dateTo) {
if (!todo.due_date) {
console.log(`Todo ${todo.title} filtered out: no due date`);
return false;
}
const dueDate = new Date(todo.due_date);
if (appliedFilters.dateFrom && dueDate < new Date(appliedFilters.dateFrom)) {
console.log(`Todo ${todo.title} filtered out: due date before ${appliedFilters.dateFrom}`);
return false;
}
if (appliedFilters.dateTo && dueDate > new Date(appliedFilters.dateTo)) {
console.log(`Todo ${todo.title} filtered out: due date after ${appliedFilters.dateTo}`);
return false;
}
}
// 星號篩選
if (appliedFilters.starred && !todo.starred) {
console.log(`Todo ${todo.title} filtered out: not starred`);
return false;
}
// 逾期篩選
if (appliedFilters.overdue) {
if (!todo.due_date) {
console.log(`Todo ${todo.title} filtered out for overdue: no 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') {
console.log(`Todo ${todo.title} filtered out for overdue: not overdue or done`);
return false;
}
}
// 即將到期篩選
if (appliedFilters.dueSoon) {
if (!todo.due_date || todo.status === 'DONE') {
console.log(`Todo ${todo.title} filtered out for due soon: no due date or 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) {
console.log(`Todo ${todo.title} filtered out for due soon: not in 3-day window`);
return false;
}
}
return true;
});
// 加入除錯資訊
useEffect(() => {
console.log('Applied filters:', appliedFilters);
console.log('Total todos:', todos.length);
console.log('Filtered todos:', filteredTodos.length);
}, [appliedFilters, todos.length, filteredTodos.length]);
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' as const, 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 {
// 確保 status 是有效的類型
const validStatus = status as 'NEW' | 'DOING' | 'BLOCKED' | 'DONE';
// 使用 API 更新單個待辦事項的狀態
await todosApi.updateTodo(todoId, { status: validStatus });
// 更新本地狀態
setTodos(prevTodos =>
prevTodos.map(todo =>
todo.id === todoId
? {
...todo,
status: validStatus,
completed_at: validStatus === 'DONE' ? new Date().toISOString() : undefined
}
: 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>
<Box sx={{ display: 'flex', gap: 1 }}>
<Button
variant="outlined"
startIcon={<CloudUpload />}
onClick={() => setShowExcelImport(true)}
sx={{
textTransform: 'none',
fontWeight: 600,
px: 3,
py: 1.5,
borderRadius: 2,
}}
>
Excel
</Button>
<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>
</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"
onClick={handleSelectAll}
sx={{
color: selectedTodos.length > 0 ? 'primary.main' : 'text.secondary',
}}
>
<SelectAll 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)}
onApply={setAppliedFilters}
initialFilters={appliedFilters}
/>
</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 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}
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}
/>
{/* Excel 匯入對話框 */}
<ExcelImport
open={showExcelImport}
onClose={() => setShowExcelImport(false)}
onImportComplete={handleTodoCreated}
/>
</motion.div>
</DashboardLayout>
);
};
export default TodosPage;