813 lines
27 KiB
TypeScript
813 lines
27 KiB
TypeScript
'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; |