This commit is contained in:
beabigegg
2025-08-29 16:25:46 +08:00
commit b0c86302ff
65 changed files with 19786 additions and 0 deletions

View 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;