This commit is contained in:
beabigegg
2025-09-01 08:51:39 +08:00
parent f3f2b7d596
commit 45a42f8e64
16 changed files with 5438 additions and 48 deletions

View File

@@ -22,7 +22,10 @@
"Bash(npm run dev:*)",
"Bash(del \"src\\components\\notifications\\EmailNotificationSettings.tsx\")",
"mcp__puppeteer__puppeteer_connect_active_tab",
"Bash(start chrome:*)"
"Bash(start chrome:*)",
"Bash(taskkill:*)",
"Bash(TASKKILL:*)",
"Bash(wmic process where:*)"
],
"deny": [],
"ask": []

2
.gitignore vendored
View File

@@ -28,7 +28,7 @@ __pycache__/
node_modules/
.next/
.swc/
components/
# --- 作業系統相關 (Operating System) ---
# 忽略 macOS 的系統檔案。

View File

@@ -46,18 +46,20 @@ const DashboardPage = () => {
// 檢查是否有有效的 token
const token = localStorage.getItem('access_token');
console.log('Dashboard - Access token:', token ? 'Found' : 'Not found');
if (!token) {
console.log('No access token found, skipping API call');
console.log('Dashboard - No access token found, redirecting to login');
setTodos([]);
window.location.href = '/login';
return;
}
const response = await todosApi.getTodos({ view: 'all' });
setTodos(response.todos || []);
} catch (error) {
} catch (error: any) {
console.error('Failed to fetch dashboard data:', error);
// 如果是認證錯誤,清除 token 並跳轉到登入頁
if (error.response?.status === 401) {
if (error?.response?.status === 401) {
localStorage.removeItem('access_token');
localStorage.removeItem('refresh_token');
localStorage.removeItem('user');

View File

@@ -10,10 +10,6 @@ export const metadata: Metadata = {
description: '專業待辦事項管理系統,支援多負責人協作、智能提醒與進度追蹤',
keywords: ['待辦事項', '任務管理', 'PANJIT', '協作工具'],
authors: [{ name: 'PANJIT IT Team' }],
themeColor: [
{ media: '(prefers-color-scheme: light)', color: '#ffffff' },
{ media: '(prefers-color-scheme: dark)', color: '#111827' },
],
};
export const viewport: Viewport = {
@@ -21,6 +17,10 @@ export const viewport: Viewport = {
initialScale: 1,
maximumScale: 1,
userScalable: false,
themeColor: [
{ media: '(prefers-color-scheme: light)', color: '#ffffff' },
{ media: '(prefers-color-scheme: dark)', color: '#111827' },
],
};
export default function RootLayout({

View File

@@ -38,6 +38,7 @@ import { toast } from 'react-hot-toast';
import TodoDialog from '@/components/todos/TodoDialog';
import TodoFilters from '@/components/todos/TodoFilters';
import DashboardLayout from '@/components/layout/DashboardLayout';
import dayjs from 'dayjs';
export default function PublicTodosPage() {
const router = useRouter();
@@ -94,7 +95,11 @@ export default function PublicTodosPage() {
toast.success('已取消追蹤');
} else {
await todosApi.followTodo(todo.id);
setFollowingTodos(prev => new Set([...prev, todo.id]));
setFollowingTodos(prev => {
const newSet = new Set(prev);
newSet.add(todo.id);
return newSet;
});
toast.success('已開始追蹤');
}
} catch (error) {
@@ -341,8 +346,24 @@ export default function PublicTodosPage() {
setShowTodoDialog(false);
setSelectedTodo(null);
}}
todo={selectedTodo}
readOnly={true}
todo={{
id: selectedTodo.id,
title: selectedTodo.title,
description: selectedTodo.description,
status: selectedTodo.status,
priority: selectedTodo.priority,
dueDate: selectedTodo.due_date ? dayjs(selectedTodo.due_date) : null,
starred: selectedTodo.starred,
responsible: selectedTodo.responsible_users_details?.map(u => ({
id: typeof u === 'string' ? u : u.ad_account,
name: typeof u === 'string' ? u : u.display_name || u.ad_account,
email: typeof u === 'string' ? '' : u.email || '',
avatar: '',
})) || [],
tags: selectedTodo.tags || [],
isPublic: selectedTodo.is_public,
}}
mode="edit"
/>
)}
</Container>

View File

@@ -20,10 +20,8 @@ import {
ViewList,
CalendarViewMonth,
FilterList,
Sort,
Search,
SelectAll,
MoreVert,
} from '@mui/icons-material';
import { motion, AnimatePresence } from 'framer-motion';
import { useTheme } from '@/providers/ThemeProvider';
@@ -36,12 +34,14 @@ import SearchBar from '@/components/todos/SearchBar';
import TodoDialog from '@/components/todos/TodoDialog';
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);
@@ -64,6 +64,49 @@ const TodosPage = () => {
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 () => {
@@ -72,9 +115,11 @@ const TodosPage = () => {
// 檢查是否有有效的 token
const token = localStorage.getItem('access_token');
console.log('Access token:', token ? 'Found' : 'Not found');
if (!token) {
console.log('No access token found, skipping API call');
console.log('No access token found, redirecting to login');
setTodos([]);
window.location.href = '/login';
return;
}
@@ -94,14 +139,16 @@ const TodosPage = () => {
}
// 獲取待辦事項
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) {
} catch (error: any) {
console.error('Failed to fetch todos:', error);
// 如果是認證錯誤,清除 token 並跳轉到登入頁
if (error.response?.status === 401) {
if (error?.response?.status === 401) {
localStorage.removeItem('access_token');
localStorage.removeItem('refresh_token');
localStorage.removeItem('user');
@@ -145,28 +192,33 @@ const TodosPage = () => {
}
}
// 視圖過濾
// 視圖過濾 - 修正:這裡應該是篩選而非直接返回
if (currentUser) {
switch (filterMode) {
case 'created':
return todo.creator_ad === currentUser.ad_account;
if (todo.creator_ad !== currentUser.ad_account) return false;
break;
case 'responsible':
return todo.responsible_users?.includes(currentUser.ad_account) || false;
if (!todo.responsible_users?.includes(currentUser.ad_account)) return false;
break;
case 'following':
return todo.followers?.includes(currentUser.ad_account) || false;
if (!todo.followers?.includes(currentUser.ad_account)) return false;
break;
default:
break; // 繼續其他篩選
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;
}
@@ -174,52 +226,90 @@ const TodosPage = () => {
if (appliedFilters.assignee && currentUser) {
switch (appliedFilters.assignee) {
case 'me':
if (!todo.responsible_users?.includes(currentUser.ad_account)) return false;
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) return false;
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)) return false;
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) return false;
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)) return false;
if (appliedFilters.dateTo && dueDate > new Date(appliedFilters.dateTo)) return false;
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) return false;
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') return false;
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') return false;
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) return false;
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 '我建立的';
@@ -307,7 +397,7 @@ const TodosPage = () => {
setTodos(prevTodos =>
prevTodos.map(todo =>
selectedTodos.includes(todo.id)
? { ...todo, status: 'DONE', completed_at: new Date().toISOString() }
? { ...todo, status: 'DONE' as const, completed_at: new Date().toISOString() }
: todo
)
);
@@ -351,8 +441,11 @@ const TodosPage = () => {
// 單個待辦事項狀態變更處理函數
const handleStatusChange = async (todoId: string, status: string) => {
try {
// 確保 status 是有效的類型
const validStatus = status as 'NEW' | 'DOING' | 'BLOCKED' | 'DONE';
// 使用 API 更新單個待辦事項的狀態
await todosApi.updateTodo(todoId, { status });
await todosApi.updateTodo(todoId, { status: validStatus });
// 更新本地狀態
setTodos(prevTodos =>
@@ -360,8 +453,8 @@ const TodosPage = () => {
todo.id === todoId
? {
...todo,
status,
completed_at: status === 'DONE' ? new Date().toISOString() : null
status: validStatus,
completed_at: validStatus === 'DONE' ? new Date().toISOString() : undefined
}
: todo
)
@@ -559,12 +652,6 @@ const TodosPage = () => {
<FilterList fontSize="small" />
</IconButton>
</Tooltip>
<Tooltip title="排序">
<IconButton size="small" sx={{ color: 'text.secondary' }}>
<Sort fontSize="small" />
</IconButton>
</Tooltip>
<Tooltip title="全選">
<IconButton
@@ -577,12 +664,6 @@ const TodosPage = () => {
<SelectAll fontSize="small" />
</IconButton>
</Tooltip>
<Tooltip title="更多選項">
<IconButton size="small" sx={{ color: 'text.secondary' }}>
<MoreVert fontSize="small" />
</IconButton>
</Tooltip>
</Box>
</Toolbar>
</Card>

View File

@@ -0,0 +1,424 @@
'use client';
import React, { useState, useEffect } from 'react';
import {
Box,
Drawer,
AppBar,
Toolbar,
Typography,
IconButton,
Avatar,
Menu,
MenuItem,
Badge,
Tooltip,
Divider,
useMediaQuery,
useTheme as useMuiTheme,
} from '@mui/material';
import {
Menu as MenuIcon,
Notifications,
Logout,
Brightness4,
Brightness7,
BrightnessAuto,
} from '@mui/icons-material';
import { useRouter } from 'next/navigation';
import { useAuth } from '@/providers/AuthProvider';
import { useTheme } from '@/providers/ThemeProvider';
import { motion, AnimatePresence } from 'framer-motion';
import Sidebar from './Sidebar';
import NotificationPanel from './NotificationPanel';
interface DashboardLayoutProps {
children: React.ReactNode;
}
const DRAWER_WIDTH = 280;
const COLLAPSED_WIDTH = 70;
const DashboardLayout: React.FC<DashboardLayoutProps> = ({ children }) => {
const [sidebarOpen, setSidebarOpen] = useState(true);
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
const [userMenuAnchor, setUserMenuAnchor] = useState<null | HTMLElement>(null);
const [notificationAnchor, setNotificationAnchor] = useState<null | HTMLElement>(null);
const [themeMenuAnchor, setThemeMenuAnchor] = useState<null | HTMLElement>(null);
const [notificationCount, setNotificationCount] = useState(0);
const router = useRouter();
const { user, logout } = useAuth();
const { themeMode, actualTheme, setThemeMode } = useTheme();
const muiTheme = useMuiTheme();
const isMobile = useMediaQuery(muiTheme.breakpoints.down('lg'));
// 響應式處理
useEffect(() => {
if (isMobile) {
setSidebarOpen(false);
setSidebarCollapsed(false);
} else {
setSidebarOpen(true);
}
}, [isMobile]);
// 保持 sidebar 狀態穩定
useEffect(() => {
// 確保在非移動裝置上 sidebar 始終是開啟的
if (!isMobile && !sidebarOpen) {
setSidebarOpen(true);
}
}, [isMobile, sidebarOpen]);
// 獲取通知數量
useEffect(() => {
const fetchNotificationCount = async () => {
try {
// 檢查是否有有效的 token
const token = localStorage.getItem('access_token');
if (!token) {
setNotificationCount(0);
return;
}
// TODO: 實際的通知數量 API 調用
// const response = await fetch('http://localhost:5000/api/notifications/count', {
// headers: {
// 'Authorization': `Bearer ${token}`,
// },
// });
//
// if (response.ok) {
// const data = await response.json();
// setNotificationCount(data.unread_count || 0);
// }
// 暫時設為 0直到實現通知 API
setNotificationCount(0);
} catch (error) {
console.error('Failed to fetch notification count:', error);
setNotificationCount(0);
}
};
fetchNotificationCount();
}, [user]);
const handleUserMenuOpen = (event: React.MouseEvent<HTMLElement>) => {
setUserMenuAnchor(event.currentTarget);
};
const handleUserMenuClose = () => {
setUserMenuAnchor(null);
};
const handleNotificationOpen = (event: React.MouseEvent<HTMLElement>) => {
setNotificationAnchor(event.currentTarget);
};
const handleNotificationClose = () => {
setNotificationAnchor(null);
};
const handleThemeMenuOpen = (event: React.MouseEvent<HTMLElement>) => {
setThemeMenuAnchor(event.currentTarget);
};
const handleThemeMenuClose = () => {
setThemeMenuAnchor(null);
};
const handleLogout = async () => {
handleUserMenuClose();
await logout();
};
const toggleSidebar = () => {
if (isMobile) {
setSidebarOpen(!sidebarOpen);
} else {
setSidebarCollapsed(!sidebarCollapsed);
}
};
const getDrawerWidth = () => {
if (isMobile) return DRAWER_WIDTH;
return sidebarCollapsed ? COLLAPSED_WIDTH : DRAWER_WIDTH;
};
const themeIcons = {
light: <Brightness7 />,
dark: <Brightness4 />,
auto: <BrightnessAuto />,
};
return (
<Box sx={{ display: 'flex', minHeight: '100vh' }}>
{/* App Bar */}
<AppBar
position="fixed"
elevation={0}
sx={{
width: { lg: `calc(100% - ${getDrawerWidth()}px)` },
ml: { lg: `${getDrawerWidth()}px` },
backgroundColor: actualTheme === 'dark'
? 'rgba(17, 24, 39, 0.9)'
: 'rgba(255, 255, 255, 0.9)',
backdropFilter: 'blur(20px)',
borderBottom: `1px solid ${actualTheme === 'dark'
? 'rgba(255, 255, 255, 0.1)'
: 'rgba(0, 0, 0, 0.1)'}`,
color: actualTheme === 'dark' ? '#f3f4f6' : '#111827',
transition: 'width 0.3s cubic-bezier(0.4, 0, 0.2, 1), margin-left 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
}}
>
<Toolbar sx={{ px: { xs: 2, sm: 3 } }}>
<IconButton
color="inherit"
aria-label="toggle sidebar"
edge="start"
onClick={toggleSidebar}
sx={{
mr: 2,
display: { lg: sidebarCollapsed ? 'inline-flex' : 'none' },
'&:hover': {
backgroundColor: actualTheme === 'dark'
? 'rgba(255, 255, 255, 0.1)'
: 'rgba(0, 0, 0, 0.1)',
}
}}
>
<MenuIcon />
</IconButton>
{/* 標題區域 */}
<Box sx={{ flexGrow: 1, display: 'flex', alignItems: 'center' }}>
<Typography
variant="h6"
noWrap
sx={{
fontWeight: 600,
background: actualTheme === 'dark'
? 'linear-gradient(45deg, #60a5fa 30%, #a78bfa 90%)'
: 'linear-gradient(45deg, #3b82f6 30%, #8b5cf6 90%)',
WebkitBackgroundClip: 'text',
WebkitTextFillColor: 'transparent',
backgroundClip: 'text',
}}
>
</Typography>
</Box>
{/* 右側工具列 */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
{/* 主題切換 */}
<Tooltip title="切換主題">
<IconButton
color="inherit"
onClick={handleThemeMenuOpen}
sx={{
'&:hover': {
backgroundColor: actualTheme === 'dark'
? 'rgba(255, 255, 255, 0.1)'
: 'rgba(0, 0, 0, 0.1)',
}
}}
>
{themeIcons[themeMode]}
</IconButton>
</Tooltip>
{/* 通知 */}
<Tooltip title="通知">
<IconButton
color="inherit"
onClick={handleNotificationOpen}
sx={{
'&:hover': {
backgroundColor: actualTheme === 'dark'
? 'rgba(255, 255, 255, 0.1)'
: 'rgba(0, 0, 0, 0.1)',
}
}}
>
<Badge badgeContent={notificationCount} color="error">
<Notifications />
</Badge>
</IconButton>
</Tooltip>
{/* 用戶菜單 */}
<Tooltip title="用戶選單">
<IconButton
onClick={handleUserMenuOpen}
sx={{
ml: 1,
'&:hover': {
backgroundColor: actualTheme === 'dark'
? 'rgba(255, 255, 255, 0.1)'
: 'rgba(0, 0, 0, 0.1)',
}
}}
>
<Avatar
sx={{
width: 36,
height: 36,
background: 'linear-gradient(45deg, #3b82f6 30%, #8b5cf6 90%)',
fontSize: '1rem',
fontWeight: 600,
}}
>
{user?.display_name?.charAt(0) || user?.ad_account?.charAt(0) || 'U'}
</Avatar>
</IconButton>
</Tooltip>
</Box>
</Toolbar>
</AppBar>
{/* Sidebar */}
<Drawer
variant={isMobile ? "temporary" : "persistent"}
anchor="left"
open={sidebarOpen}
onClose={() => isMobile && setSidebarOpen(false)}
sx={{
width: getDrawerWidth(),
flexShrink: 0,
'& .MuiDrawer-paper': {
width: getDrawerWidth(),
boxSizing: 'border-box',
backgroundColor: actualTheme === 'dark' ? '#1f2937' : '#ffffff',
borderRight: `1px solid ${actualTheme === 'dark'
? 'rgba(255, 255, 255, 0.1)'
: 'rgba(0, 0, 0, 0.1)'}`,
transition: 'width 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
overflowX: 'hidden',
},
}}
ModalProps={{
keepMounted: true, // 手機端性能優化
}}
>
<Sidebar
collapsed={sidebarCollapsed && !isMobile}
onToggleCollapse={() => setSidebarCollapsed(!sidebarCollapsed)}
onClose={() => isMobile && setSidebarOpen(false)}
/>
</Drawer>
{/* 主內容區域 */}
<Box
component="main"
sx={{
flexGrow: 1,
width: { lg: `calc(100% - ${getDrawerWidth()}px)` },
minHeight: '100vh',
backgroundColor: actualTheme === 'dark' ? '#111827' : '#f9fafb',
transition: 'width 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
}}
>
<Toolbar /> {/* 為 AppBar 預留空間 */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, ease: "easeOut" }}
>
<Box sx={{ p: { xs: 2, sm: 3 }, minHeight: 'calc(100vh - 64px)' }}>
{children}
</Box>
</motion.div>
</Box>
{/* 用戶選單 */}
<Menu
anchorEl={userMenuAnchor}
open={Boolean(userMenuAnchor)}
onClose={handleUserMenuClose}
sx={{
'& .MuiPaper-root': {
backgroundColor: actualTheme === 'dark' ? '#374151' : '#ffffff',
backdropFilter: 'blur(20px)',
border: `1px solid ${actualTheme === 'dark'
? 'rgba(255, 255, 255, 0.1)'
: 'rgba(0, 0, 0, 0.1)'}`,
borderRadius: 2,
minWidth: 200,
}
}}
transformOrigin={{ horizontal: 'right', vertical: 'top' }}
anchorOrigin={{ horizontal: 'right', vertical: 'bottom' }}
>
<Box sx={{ px: 2, py: 1 }}>
<Typography variant="subtitle2" color="text.secondary">
{user?.display_name || user?.ad_account}
</Typography>
<Typography variant="caption" color="text.disabled">
{user?.email}
</Typography>
</Box>
<Divider />
<MenuItem onClick={handleLogout} sx={{ py: 1, color: 'error.main' }}>
<Logout sx={{ mr: 2, fontSize: 20 }} />
</MenuItem>
</Menu>
{/* 主題選單 */}
<Menu
anchorEl={themeMenuAnchor}
open={Boolean(themeMenuAnchor)}
onClose={handleThemeMenuClose}
sx={{
'& .MuiPaper-root': {
backgroundColor: actualTheme === 'dark' ? '#374151' : '#ffffff',
backdropFilter: 'blur(20px)',
border: `1px solid ${actualTheme === 'dark'
? 'rgba(255, 255, 255, 0.1)'
: 'rgba(0, 0, 0, 0.1)'}`,
borderRadius: 2,
minWidth: 150,
}
}}
>
<MenuItem
onClick={() => { setThemeMode('light'); handleThemeMenuClose(); }}
selected={themeMode === 'light'}
>
<Brightness7 sx={{ mr: 2, fontSize: 20 }} />
</MenuItem>
<MenuItem
onClick={() => { setThemeMode('dark'); handleThemeMenuClose(); }}
selected={themeMode === 'dark'}
>
<Brightness4 sx={{ mr: 2, fontSize: 20 }} />
</MenuItem>
<MenuItem
onClick={() => { setThemeMode('auto'); handleThemeMenuClose(); }}
selected={themeMode === 'auto'}
>
<BrightnessAuto sx={{ mr: 2, fontSize: 20 }} />
</MenuItem>
</Menu>
{/* 通知面板 */}
<NotificationPanel
anchor={notificationAnchor}
open={Boolean(notificationAnchor)}
onClose={handleNotificationClose}
/>
</Box>
);
};
export default DashboardLayout;

View File

@@ -0,0 +1,447 @@
'use client';
import React, { useState, useEffect } from 'react';
import {
Popover,
Box,
Typography,
List,
ListItem,
ListItemText,
ListItemAvatar,
Avatar,
Chip,
IconButton,
Divider,
Button,
Badge,
} from '@mui/material';
import {
Schedule,
Assignment,
Person,
CheckCircle,
Warning,
Close,
MarkAsUnread,
Settings,
} from '@mui/icons-material';
import { motion, AnimatePresence } from 'framer-motion';
import { useTheme } from '@/providers/ThemeProvider';
import { notificationsApi } from '@/lib/api';
import { toast } from 'react-hot-toast';
import { useRouter } from 'next/navigation';
import EnhancedEmailNotificationSettings from '@/components/notifications/EnhancedEmailNotificationSettings';
interface NotificationPanelProps {
anchor: HTMLElement | null;
open: boolean;
onClose: () => void;
}
interface Notification {
id: string;
type: 'reminder' | 'assignment' | 'completion' | 'overdue';
title: string;
message: string;
time: string;
read: boolean;
avatar?: string;
actionable?: boolean;
todo_id?: string;
}
const NotificationPanel: React.FC<NotificationPanelProps> = ({
anchor,
open,
onClose,
}) => {
const { actualTheme } = useTheme();
const router = useRouter();
const [notifications, setNotifications] = useState<Notification[]>([]);
const [loading, setLoading] = useState(false);
const [settingsOpen, setSettingsOpen] = useState(false);
// 從 API 獲取通知資料
const fetchNotifications = async () => {
try {
setLoading(true);
// 檢查是否有有效的 token
const token = localStorage.getItem('access_token');
if (!token) {
console.log('No access token found, skipping notifications API call');
setNotifications([]);
return;
}
// 使用 API 客戶端調用
const data = await notificationsApi.getNotifications();
setNotifications(data.notifications || []);
} catch (error) {
console.error('Failed to fetch notifications:', error);
setNotifications([]);
} finally {
setLoading(false);
}
};
useEffect(() => {
// 只在面板打開時獲取通知
if (open) {
fetchNotifications();
}
}, [open]);
const getNotificationIcon = (type: string) => {
switch (type) {
case 'reminder':
return <Schedule sx={{ color: '#f59e0b' }} />;
case 'assignment':
return <Assignment sx={{ color: '#3b82f6' }} />;
case 'completion':
return <CheckCircle sx={{ color: '#10b981' }} />;
case 'overdue':
return <Warning sx={{ color: '#ef4444' }} />;
default:
return <Assignment />;
}
};
const getNotificationColor = (type: string) => {
switch (type) {
case 'reminder':
return '#f59e0b';
case 'assignment':
return '#3b82f6';
case 'completion':
return '#10b981';
case 'overdue':
return '#ef4444';
default:
return '#6b7280';
}
};
const unreadCount = notifications.filter(n => !n.read).length;
// 處理標記單個通知為已讀
const handleMarkAsRead = async (notificationId: string) => {
try {
await notificationsApi.markNotificationRead(notificationId);
setNotifications(prev => prev.map(n =>
n.id === notificationId ? { ...n, read: true } : n
));
toast.success('已標記為已讀');
} catch (error) {
toast.error('標記已讀失敗');
}
};
// 處理標記全部通知為已讀
const handleMarkAllAsRead = async () => {
try {
await notificationsApi.markAllNotificationsRead();
setNotifications(prev => prev.map(n => ({ ...n, read: true })));
toast.success('已將所有通知標記為已讀');
} catch (error) {
toast.error('標記全部已讀失敗');
}
};
// 處理查看單個通知
const handleViewNotification = (notification: Notification) => {
if (notification.todo_id) {
// 導航到對應的 todo
router.push(`/?highlight=${notification.todo_id}`);
onClose();
// 同時標記為已讀
if (!notification.read) {
handleMarkAsRead(notification.id);
}
}
};
// 處理查看全部通知 (導航到主頁)
const handleViewAll = () => {
router.push('/');
onClose();
};
return (
<Popover
open={open}
anchorEl={anchor}
onClose={onClose}
anchorOrigin={{
vertical: 'bottom',
horizontal: 'right',
}}
transformOrigin={{
vertical: 'top',
horizontal: 'right',
}}
slotProps={{
paper: {
sx: {
width: 380,
maxHeight: 500,
backgroundColor: actualTheme === 'dark' ? '#1f2937' : '#ffffff',
backdropFilter: 'blur(20px)',
border: `1px solid ${actualTheme === 'dark'
? 'rgba(255, 255, 255, 0.1)'
: 'rgba(0, 0, 0, 0.1)'}`,
borderRadius: 2,
boxShadow: '0 10px 25px rgba(0, 0, 0, 0.1)',
mt: 1,
}
}
}}
>
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.2 }}
>
{/* 標題區域 */}
<Box
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
p: 2,
borderBottom: `1px solid ${actualTheme === 'dark'
? 'rgba(255, 255, 255, 0.1)'
: 'rgba(0, 0, 0, 0.1)'}`,
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography variant="h6" sx={{ fontWeight: 600 }}>
</Typography>
{unreadCount > 0 && (
<Badge
badgeContent={unreadCount}
color="error"
sx={{
'& .MuiBadge-badge': {
fontSize: '0.75rem',
minWidth: 18,
height: 18,
}
}}
/>
)}
</Box>
<Box sx={{ display: 'flex', gap: 0.5 }}>
<IconButton
size="small"
title="郵件提醒設定"
onClick={() => setSettingsOpen(true)}
>
<Settings fontSize="small" />
</IconButton>
<IconButton size="small" onClick={onClose} title="關閉">
<Close fontSize="small" />
</IconButton>
</Box>
</Box>
{/* 通知清單 */}
<Box sx={{ maxHeight: 360, overflowY: 'auto' }}>
{loading ? (
<Box sx={{ display: 'flex', justifyContent: 'center', p: 4 }}>
<Typography variant="body2" color="text.secondary">
...
</Typography>
</Box>
) : notifications.length === 0 ? (
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', p: 4, gap: 1 }}>
<Assignment sx={{ fontSize: 48, color: 'text.secondary', opacity: 0.5 }} />
<Typography variant="body2" color="text.secondary" textAlign="center">
</Typography>
<Typography variant="caption" color="text.secondary" textAlign="center">
</Typography>
</Box>
) : (
<List sx={{ p: 0 }}>
<AnimatePresence>
{notifications.map((notification, index) => (
<motion.div
key={notification.id}
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -20 }}
transition={{ delay: index * 0.1 }}
>
<ListItem
sx={{
py: 1.5,
px: 2,
backgroundColor: !notification.read
? (actualTheme === 'dark'
? 'rgba(59, 130, 246, 0.05)'
: 'rgba(59, 130, 246, 0.02)')
: 'transparent',
borderLeft: !notification.read
? `3px solid ${getNotificationColor(notification.type)}`
: '3px solid transparent',
'&:hover': {
backgroundColor: actualTheme === 'dark'
? 'rgba(255, 255, 255, 0.05)'
: 'rgba(0, 0, 0, 0.04)',
},
cursor: 'pointer',
}}
>
<ListItemAvatar>
<Avatar
sx={{
width: 40,
height: 40,
backgroundColor: notification.avatar
? `${getNotificationColor(notification.type)}15`
: 'transparent',
border: notification.avatar
? `2px solid ${getNotificationColor(notification.type)}`
: 'none',
fontSize: '0.875rem',
fontWeight: 600,
color: getNotificationColor(notification.type),
}}
>
{notification.avatar || getNotificationIcon(notification.type)}
</Avatar>
</ListItemAvatar>
<ListItemText
primary={
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 0.5 }}>
<Typography
variant="subtitle2"
sx={{
fontWeight: notification.read ? 500 : 600,
color: actualTheme === 'dark' ? '#f3f4f6' : '#111827',
}}
>
{notification.title}
</Typography>
<Chip
size="small"
label={notification.time}
sx={{
fontSize: '0.7rem',
height: 18,
backgroundColor: actualTheme === 'dark'
? 'rgba(255, 255, 255, 0.1)'
: 'rgba(0, 0, 0, 0.06)',
color: actualTheme === 'dark' ? '#d1d5db' : '#6b7280',
}}
/>
</Box>
}
secondary={
<Box>
<Typography
variant="body2"
sx={{
color: actualTheme === 'dark' ? '#d1d5db' : '#4b5563',
mb: notification.actionable ? 1 : 0,
}}
>
{notification.message}
</Typography>
{notification.actionable && (
<Box sx={{ display: 'flex', gap: 1 }}>
<Button
size="small"
variant="outlined"
onClick={() => handleViewNotification(notification)}
sx={{
fontSize: '0.75rem',
py: 0.25,
px: 1,
minWidth: 'auto',
}}
>
</Button>
{!notification.read && (
<Button
size="small"
variant="text"
startIcon={<MarkAsUnread />}
onClick={() => handleMarkAsRead(notification.id)}
sx={{
fontSize: '0.75rem',
py: 0.25,
px: 1,
minWidth: 'auto',
}}
>
</Button>
)}
</Box>
)}
</Box>
}
/>
</ListItem>
{index < notifications.length - 1 && (
<Divider sx={{ ml: 2 }} />
)}
</motion.div>
))}
</AnimatePresence>
</List>
)}
</Box>
{/* 底部操作 */}
<Box
sx={{
p: 2,
borderTop: `1px solid ${actualTheme === 'dark'
? 'rgba(255, 255, 255, 0.1)'
: 'rgba(0, 0, 0, 0.1)'}`,
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
}}
>
<Button
size="small"
variant="text"
disabled={unreadCount === 0}
onClick={handleMarkAllAsRead}
>
</Button>
<Button
size="small"
variant="text"
onClick={handleViewAll}
sx={{ fontWeight: 600 }}
>
</Button>
</Box>
</motion.div>
{/* 郵件通知設定對話框 */}
<EnhancedEmailNotificationSettings
open={settingsOpen}
onClose={() => setSettingsOpen(false)}
/>
</Popover>
);
};
export default NotificationPanel;

View File

@@ -0,0 +1,621 @@
'use client';
import React, { useState, useEffect } from 'react';
import { useRouter, usePathname } from 'next/navigation';
import {
Box,
List,
ListItem,
ListItemButton,
ListItemIcon,
ListItemText,
Typography,
IconButton,
Tooltip,
Divider,
Badge,
Chip,
} from '@mui/material';
import {
Dashboard,
Assignment,
CalendarToday,
People,
Star,
CheckCircle,
Schedule,
Block,
FiberNew,
ExpandLess,
ExpandMore,
Language as Public,
ChevronLeft,
} from '@mui/icons-material';
import { motion, AnimatePresence } from 'framer-motion';
import { useTheme } from '@/providers/ThemeProvider';
import { todosApi } from '@/lib/api';
import { Todo } from '@/types';
interface SidebarProps {
collapsed: boolean;
onToggleCollapse: () => void;
onClose?: () => void;
}
interface NavItem {
id: string;
label: string;
icon: React.ReactNode;
path: string;
badge?: number;
color?: string;
}
interface NavGroup {
id: string;
label: string;
items: NavItem[];
expanded?: boolean;
}
const Sidebar: React.FC<SidebarProps> = ({ collapsed, onToggleCollapse, onClose }) => {
const router = useRouter();
const pathname = usePathname();
const { actualTheme } = useTheme();
const [expandedGroups, setExpandedGroups] = React.useState<Record<string, boolean>>({
views: true,
status: true,
});
const [todos, setTodos] = useState<Todo[]>([]);
const [loading, setLoading] = useState(true);
// 獲取待辦事項數據 - 暫時停用以避免重複 API 呼叫
// useEffect(() => {
// const fetchTodos = async () => {
// try {
// setLoading(true);
// const token = localStorage.getItem('access_token');
// if (!token) {
// setTodos([]);
// return;
// }
// const response = await todosApi.getTodos({ view: 'all' });
// if (response.success && response.todos) {
// setTodos(response.todos);
// } else {
// setTodos([]);
// }
// } catch (error) {
// console.error('Failed to fetch todos for sidebar:', error);
// setTodos([]);
// } finally {
// setLoading(false);
// }
// };
// fetchTodos();
// }, []);
// 暫時設為不載入狀態以減少 API 呼叫
useEffect(() => {
setLoading(false);
}, []);
// 獲取當前用戶信息
const getCurrentUser = () => {
try {
const userStr = localStorage.getItem('user');
if (userStr) {
const user = JSON.parse(userStr);
return {
ad_account: user.ad_account,
email: user.email
};
}
} catch (error) {
console.error('Failed to parse user from localStorage:', error);
}
return null;
};
// 計算各種統計數字
const getStatistics = () => {
if (loading || !todos.length) {
return {
total: 0,
created: 0,
assigned: 0,
following: 0,
new: 0,
doing: 0,
blocked: 0,
done: 0,
starred: 0
};
}
const currentUser = getCurrentUser();
if (!currentUser) {
return {
total: todos.length,
created: 0,
assigned: 0,
following: 0,
new: todos.filter(todo => todo.status === 'NEW').length,
doing: todos.filter(todo => todo.status === 'DOING').length,
blocked: todos.filter(todo => todo.status === 'BLOCKED').length,
done: todos.filter(todo => todo.status === 'DONE').length,
starred: todos.filter(todo => todo.starred).length
};
}
return {
total: todos.length,
created: todos.filter(todo =>
todo.creator_ad === currentUser.ad_account ||
todo.creator_email === currentUser.email
).length,
assigned: todos.filter(todo =>
todo.responsible_users?.includes(currentUser.ad_account) ||
todo.responsible_users?.includes(currentUser.email)
).length,
following: todos.filter(todo =>
todo.followers?.includes(currentUser.ad_account) ||
todo.followers?.includes(currentUser.email)
).length,
new: todos.filter(todo => todo.status === 'NEW').length,
doing: todos.filter(todo => todo.status === 'DOING').length,
blocked: todos.filter(todo => todo.status === 'BLOCKED').length,
done: todos.filter(todo => todo.status === 'DONE').length,
starred: todos.filter(todo => todo.starred).length
};
};
const stats = getStatistics();
const navGroups: NavGroup[] = [
{
id: 'main',
label: '主要功能',
items: [
{
id: 'dashboard',
label: '儀表板',
icon: <Dashboard />,
path: '/dashboard',
},
{
id: 'todos',
label: '待辦清單',
icon: <Assignment />,
path: '/todos',
badge: stats.total || undefined,
},
{
id: 'public',
label: '公開任務',
icon: <Public />,
path: '/public',
},
{
id: 'calendar',
label: '日曆視圖',
icon: <CalendarToday />,
path: '/calendar',
},
],
},
{
id: 'views',
label: '視圖篩選',
items: [
{
id: 'starred',
label: '已加星',
icon: <Star />,
path: '/todos?starred=true',
badge: stats.starred || undefined,
color: '#fbbf24',
},
{
id: 'my-todos',
label: '我建立的',
icon: <People />,
path: '/todos?view=created',
badge: stats.created || undefined,
},
{
id: 'assigned',
label: '指派給我',
icon: <Assignment />,
path: '/todos?view=responsible',
badge: stats.assigned || undefined,
},
{
id: 'following',
label: '我追蹤的',
icon: <People />,
path: '/todos?view=following',
badge: stats.following || undefined,
},
],
},
{
id: 'status',
label: '狀態分類',
items: [
{
id: 'new',
label: '新建立',
icon: <FiberNew />,
path: '/todos?status=NEW',
badge: stats.new || undefined,
color: '#6b7280',
},
{
id: 'doing',
label: '進行中',
icon: <Schedule />,
path: '/todos?status=DOING',
badge: stats.doing || undefined,
color: '#3b82f6',
},
{
id: 'blocked',
label: '已阻塞',
icon: <Block />,
path: '/todos?status=BLOCKED',
badge: stats.blocked || undefined,
color: '#ef4444',
},
{
id: 'done',
label: '已完成',
icon: <CheckCircle />,
path: '/todos?status=DONE',
badge: stats.done || undefined,
color: '#10b981',
},
],
},
];
const handleNavClick = (path: string) => {
router.push(path);
if (onClose) onClose(); // 手機版關閉側邊欄
};
const toggleGroup = (groupId: string) => {
if (collapsed) return; // 收合狀態下不允許展開群組
setExpandedGroups(prev => ({
...prev,
[groupId]: !prev[groupId],
}));
};
const isActive = (path: string) => {
if (path === '/dashboard') return pathname === path;
// 檢查是否含有查詢參數
if (path.includes('?')) {
const [basePath, queryString] = path.split('?');
if (pathname !== basePath) return false;
// 檢查查詢參數是否匹配
const urlParams = new URLSearchParams(window.location.search);
const pathParams = new URLSearchParams(queryString);
// 檢查每個路徑參數是否在當前 URL 中存在且相同
const pathParamsArray = Array.from(pathParams.entries());
for (const [key, value] of pathParamsArray) {
if (urlParams.get(key) !== value) {
return false;
}
}
return true;
}
// 沒有查詢參數的情況
return pathname.includes(path);
};
const renderNavItem = (item: NavItem) => {
const active = isActive(item.path);
return (
<motion.div
key={item.id}
initial={false}
whileHover={{ x: collapsed ? 0 : 4 }}
whileTap={{ scale: 0.98 }}
>
<ListItem disablePadding sx={{ mb: 0.5 }}>
<Tooltip title={collapsed ? item.label : ''} placement="right">
<ListItemButton
onClick={() => handleNavClick(item.path)}
sx={{
borderRadius: 2,
mx: 1,
minHeight: 44,
backgroundColor: active
? (actualTheme === 'dark' ? 'rgba(59, 130, 246, 0.15)' : 'rgba(59, 130, 246, 0.1)')
: 'transparent',
border: active
? `1px solid ${actualTheme === 'dark' ? 'rgba(59, 130, 246, 0.3)' : 'rgba(59, 130, 246, 0.2)'}`
: '1px solid transparent',
'&:hover': {
backgroundColor: active
? (actualTheme === 'dark' ? 'rgba(59, 130, 246, 0.2)' : 'rgba(59, 130, 246, 0.15)')
: (actualTheme === 'dark' ? 'rgba(255, 255, 255, 0.05)' : 'rgba(0, 0, 0, 0.04)'),
},
transition: 'all 0.2s ease',
}}
>
<ListItemIcon
sx={{
minWidth: collapsed ? 'auto' : 40,
color: active
? 'primary.main'
: item.color || (actualTheme === 'dark' ? '#d1d5db' : '#6b7280'),
justifyContent: 'center',
}}
>
{item.icon}
</ListItemIcon>
<AnimatePresence>
{!collapsed && (
<motion.div
initial={{ opacity: 0, width: 0 }}
animate={{ opacity: 1, width: 'auto' }}
exit={{ opacity: 0, width: 0 }}
transition={{ duration: 0.2 }}
style={{ overflow: 'hidden' }}
>
<ListItemText
primary={item.label}
sx={{
'& .MuiListItemText-primary': {
fontSize: '0.875rem',
fontWeight: active ? 600 : 500,
color: active
? 'primary.main'
: (actualTheme === 'dark' ? '#f3f4f6' : '#374151'),
},
}}
/>
{item.badge && (
<Badge
badgeContent={item.badge}
color="primary"
sx={{
'& .MuiBadge-badge': {
backgroundColor: item.color || 'primary.main',
fontSize: '0.75rem',
minWidth: 18,
height: 18,
},
}}
/>
)}
</motion.div>
)}
</AnimatePresence>
</ListItemButton>
</Tooltip>
</ListItem>
</motion.div>
);
};
return (
<Box sx={{ height: '100%', display: 'flex', flexDirection: 'column' }}>
{/* Logo 區域 */}
<Box
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: collapsed ? 'center' : 'space-between',
p: 2,
minHeight: 64,
borderBottom: `1px solid ${actualTheme === 'dark'
? 'rgba(255, 255, 255, 0.1)'
: 'rgba(0, 0, 0, 0.1)'}`,
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Box
component="img"
src="/panjit-logo.png"
alt="PANJIT Logo"
sx={{
width: 75,
height: 75,
filter: 'drop-shadow(0 2px 4px rgba(59, 130, 246, 0.3))'
}}
/>
</Box>
{!collapsed && (
<IconButton
onClick={onToggleCollapse}
size="small"
sx={{
backgroundColor: actualTheme === 'dark'
? 'rgba(255, 255, 255, 0.1)'
: 'rgba(0, 0, 0, 0.04)',
'&:hover': {
backgroundColor: actualTheme === 'dark'
? 'rgba(255, 255, 255, 0.15)'
: 'rgba(0, 0, 0, 0.08)',
},
}}
>
<ChevronLeft fontSize="small" />
</IconButton>
)}
</Box>
{/* 導航列表 */}
<Box sx={{ flex: 1, overflowY: 'auto', overflowX: 'hidden', py: 1 }}>
<List>
{navGroups.map((group) => (
<Box key={group.id}>
{/* 群組標題 */}
<AnimatePresence>
{!collapsed && group.label && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
exit={{ opacity: 0, height: 0 }}
transition={{ duration: 0.2 }}
>
<ListItem
disablePadding
sx={{
mt: group.id === 'main' ? 0 : 2,
mb: 1,
}}
>
<ListItemButton
onClick={() => toggleGroup(group.id)}
sx={{
borderRadius: 2,
mx: 1,
minHeight: 36,
'&:hover': {
backgroundColor: actualTheme === 'dark'
? 'rgba(255, 255, 255, 0.05)'
: 'rgba(0, 0, 0, 0.04)',
},
}}
disabled={group.id === 'main'}
>
<ListItemText
primary={group.label}
sx={{
'& .MuiListItemText-primary': {
fontSize: '0.75rem',
fontWeight: 600,
color: actualTheme === 'dark' ? '#9ca3af' : '#6b7280',
textTransform: 'uppercase',
letterSpacing: '0.05em',
},
}}
/>
{group.id !== 'main' && (
expandedGroups[group.id] ? <ExpandLess /> : <ExpandMore />
)}
</ListItemButton>
</ListItem>
</motion.div>
)}
</AnimatePresence>
{/* 群組項目 */}
<AnimatePresence>
{(collapsed || expandedGroups[group.id] || group.id === 'main') && (
<motion.div
initial={collapsed ? false : { opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
exit={{ opacity: 0, height: 0 }}
transition={{ duration: 0.3, ease: 'easeInOut' }}
>
{group.items.map(renderNavItem)}
</motion.div>
)}
</AnimatePresence>
{/* 分隔線 */}
{group.id === 'main' && !collapsed && (
<Divider sx={{ my: 2, mx: 2 }} />
)}
</Box>
))}
</List>
</Box>
{/* 底部快速狀態 */}
<AnimatePresence>
{!collapsed && (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 20 }}
transition={{ duration: 0.3 }}
>
<Box
sx={{
p: 2,
borderTop: `1px solid ${actualTheme === 'dark'
? 'rgba(255, 255, 255, 0.1)'
: 'rgba(0, 0, 0, 0.1)'}`,
}}
>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
{stats.doing > 0 && (
<Chip
label={`${stats.doing} 進行中`}
size="small"
sx={{
backgroundColor: 'rgba(59, 130, 246, 0.1)',
color: '#3b82f6',
fontSize: '0.75rem',
}}
/>
)}
{(() => {
const overdue = todos.filter(todo => {
if (!todo.due_date) return false;
const dueDate = new Date(todo.due_date);
const today = new Date();
today.setHours(0, 0, 0, 0);
return dueDate < today && todo.status !== 'DONE';
}).length;
return overdue > 0 ? (
<Chip
label={`${overdue} 逾期`}
size="small"
sx={{
backgroundColor: 'rgba(239, 68, 68, 0.1)',
color: '#ef4444',
fontSize: '0.75rem',
}}
/>
) : null;
})()}
{stats.blocked > 0 && (
<Chip
label={`${stats.blocked} 阻塞`}
size="small"
sx={{
backgroundColor: 'rgba(239, 68, 68, 0.1)',
color: '#ef4444',
fontSize: '0.75rem',
}}
/>
)}
{stats.total === 0 && !loading && (
<Chip
label="無待辦事項"
size="small"
sx={{
backgroundColor: 'rgba(16, 185, 129, 0.1)',
color: '#10b981',
fontSize: '0.75rem',
}}
/>
)}
</Box>
</Box>
</motion.div>
)}
</AnimatePresence>
</Box>
);
};
export default Sidebar;

View File

@@ -0,0 +1,591 @@
'use client';
import React, { useState, useEffect } from 'react';
import {
Dialog,
DialogTitle,
DialogContent,
DialogActions,
Box,
Typography,
Switch,
FormControlLabel,
TextField,
Select,
MenuItem,
FormControl,
InputLabel,
Button,
Divider,
Alert,
Card,
CardContent,
Chip,
IconButton,
Checkbox,
FormGroup,
Grid,
Accordion,
AccordionSummary,
AccordionDetails,
} from '@mui/material';
import {
Email,
Schedule,
Close,
NotificationImportant,
Settings,
Save,
ExpandMore,
Alarm,
Today,
CalendarMonth,
AccessTime,
} from '@mui/icons-material';
import { motion } from 'framer-motion';
import { useTheme } from '@/providers/ThemeProvider';
import { useAuth } from '@/providers/AuthProvider';
import { notificationsApi } from '@/lib/api';
import { toast } from 'react-hot-toast';
interface EnhancedEmailNotificationSettingsProps {
open: boolean;
onClose: () => void;
}
interface NotificationSettings {
// 基本設定
emailEnabled: boolean;
emailAddress: string;
// 到期提醒設定 - 支援多個天數
reminderDays: number[];
// 每日摘要
dailyDigestEnabled: boolean;
dailyDigestTime: string;
// 週報摘要
weeklyDigestEnabled: boolean;
weeklyDigestTime: string;
weeklyDigestDay: number; // 0=週日, 1=週一...
// 月報摘要
monthlyDigestEnabled: boolean;
monthlyDigestTime: string;
monthlyDigestDay: number; // 每月第幾日
// 其他通知
assignmentNotifications: boolean;
completionNotifications: boolean;
}
const EnhancedEmailNotificationSettings: React.FC<EnhancedEmailNotificationSettingsProps> = ({
open,
onClose,
}) => {
const { actualTheme } = useTheme();
const { user } = useAuth();
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [testEmailLoading, setTestEmailLoading] = useState(false);
const [settings, setSettings] = useState<NotificationSettings>({
emailEnabled: false,
emailAddress: user?.email || '',
reminderDays: [1, 3], // 預設前1天、前3天
dailyDigestEnabled: false,
dailyDigestTime: '09:00',
weeklyDigestEnabled: true,
weeklyDigestTime: '09:00',
weeklyDigestDay: 1, // 週一
monthlyDigestEnabled: false,
monthlyDigestTime: '09:00',
monthlyDigestDay: 1, // 每月1日
assignmentNotifications: true,
completionNotifications: false,
});
// 可選的提醒天數選項
const reminderDayOptions = [1, 2, 3, 5, 7, 14];
// 週幾選項
const weekDayOptions = [
{ value: 0, label: '週日' },
{ value: 1, label: '週一' },
{ value: 2, label: '週二' },
{ value: 3, label: '週三' },
{ value: 4, label: '週四' },
{ value: 5, label: '週五' },
{ value: 6, label: '週六' },
];
// 時間選項
const timeOptions = Array.from({ length: 24 }, (_, i) => {
const hour = i.toString().padStart(2, '0');
return `${hour}:00`;
});
// 載入用戶的通知設定
useEffect(() => {
if (open && user) {
loadSettings();
}
}, [open, user]);
const loadSettings = async () => {
try {
setLoading(true);
const data = await notificationsApi.getSettings();
setSettings(prev => ({
...prev,
emailEnabled: data.email_reminder_enabled || false,
reminderDays: data.reminder_days_before || [1, 3],
dailyDigestEnabled: false, // 暫時沒有每日摘要
weeklyDigestEnabled: data.weekly_summary_enabled || false,
weeklyDigestTime: data.weekly_summary_time || '09:00',
weeklyDigestDay: data.weekly_summary_day || 1,
monthlyDigestEnabled: data.monthly_summary_enabled || false,
monthlyDigestTime: data.monthly_summary_time || '09:00',
monthlyDigestDay: data.monthly_summary_day || 1,
assignmentNotifications: data.notification_enabled || true,
emailAddress: user?.email || data.email || '',
}));
} catch (error) {
console.error('Failed to load notification settings:', error);
} finally {
setLoading(false);
}
};
const handleSave = async () => {
try {
setSaving(true);
const payload = {
email_reminder_enabled: settings.emailEnabled,
notification_enabled: settings.assignmentNotifications,
weekly_summary_enabled: settings.weeklyDigestEnabled,
monthly_summary_enabled: settings.monthlyDigestEnabled,
reminder_days_before: settings.reminderDays,
weekly_summary_time: settings.weeklyDigestTime,
weekly_summary_day: settings.weeklyDigestDay,
monthly_summary_time: settings.monthlyDigestTime,
monthly_summary_day: settings.monthlyDigestDay,
};
await notificationsApi.updateSettings(payload);
toast.success('通知設定已儲存');
onClose();
} catch (error) {
console.error('Failed to save notification settings:', error);
toast.error('儲存通知設定失敗,請檢查網路連線');
} finally {
setSaving(false);
}
};
const handleTestEmail = async () => {
try {
setTestEmailLoading(true);
await notificationsApi.sendTestEmail(settings.emailAddress);
toast.success(`測試郵件已發送至 ${settings.emailAddress}!請檢查您的信箱`);
} catch (error) {
console.error('Failed to send test email:', error);
toast.error('發送測試郵件失敗,請檢查網路連線');
} finally {
setTestEmailLoading(false);
}
};
const handleReminderDayToggle = (day: number) => {
setSettings(prev => ({
...prev,
reminderDays: prev.reminderDays.includes(day)
? prev.reminderDays.filter(d => d !== day)
: [...prev.reminderDays, day].sort((a, b) => a - b)
}));
};
if (loading) {
return (
<Dialog open={open} onClose={onClose} maxWidth="sm" fullWidth>
<DialogContent>
<Box display="flex" justifyContent="center" py={4}>
<Typography>...</Typography>
</Box>
</DialogContent>
</Dialog>
);
}
return (
<Dialog
open={open}
onClose={onClose}
maxWidth="md"
fullWidth
sx={{
'& .MuiDialog-paper': {
backgroundColor: actualTheme === 'dark' ? '#1f2937' : '#ffffff',
borderRadius: 3,
minHeight: 700,
}
}}
>
<DialogTitle sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
pb: 1
}}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Email sx={{ color: 'primary.main' }} />
<Typography variant="h6" sx={{ fontWeight: 600 }}>
</Typography>
</Box>
<IconButton onClick={onClose} size="small">
<Close />
</IconButton>
</DialogTitle>
<DialogContent sx={{ px: 3 }}>
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3 }}
>
{/* 總開關 */}
<Card sx={{ mb: 3, backgroundColor: actualTheme === 'dark' ? '#374151' : '#f8fafc' }}>
<CardContent>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<Box>
<Typography variant="h6" sx={{ mb: 0.5 }}>
</Typography>
<Typography variant="body2" color="text.secondary">
</Typography>
</Box>
<Switch
checked={settings.emailEnabled}
onChange={(e) => setSettings(prev => ({ ...prev, emailEnabled: e.target.checked }))}
size="medium"
/>
</Box>
</CardContent>
</Card>
{settings.emailEnabled && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
exit={{ opacity: 0, height: 0 }}
transition={{ duration: 0.3 }}
>
{/* 基本設定 */}
<Accordion defaultExpanded sx={{ mb: 2 }}>
<AccordionSummary expandIcon={<ExpandMore />}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Settings />
<Typography variant="h6"></Typography>
</Box>
</AccordionSummary>
<AccordionDetails>
<TextField
fullWidth
label="郵件地址"
value={settings.emailAddress}
onChange={(e) => setSettings(prev => ({ ...prev, emailAddress: e.target.value }))}
helperText="通知將發送至此郵件地址"
sx={{ mb: 2 }}
disabled
/>
<Button
variant="outlined"
size="small"
onClick={handleTestEmail}
disabled={!settings.emailAddress || testEmailLoading}
sx={{ mb: 2 }}
>
{testEmailLoading ? '發送中...' : '發送測試郵件'}
</Button>
</AccordionDetails>
</Accordion>
{/* 到期提醒設定 */}
<Accordion sx={{ mb: 2 }}>
<AccordionSummary expandIcon={<ExpandMore />}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Alarm />
<Typography variant="h6"></Typography>
</Box>
</AccordionSummary>
<AccordionDetails>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
</Typography>
<FormGroup>
<Grid container spacing={1}>
{reminderDayOptions.map(day => (
<Grid item xs={6} sm={4} key={day}>
<FormControlLabel
control={
<Checkbox
checked={settings.reminderDays.includes(day)}
onChange={() => handleReminderDayToggle(day)}
size="small"
/>
}
label={`${day}`}
/>
</Grid>
))}
</Grid>
</FormGroup>
{settings.reminderDays.length > 0 && (
<Box sx={{ mt: 2 }}>
<Typography variant="caption" color="text.secondary">
</Typography>
<Box sx={{ display: 'flex', gap: 0.5, flexWrap: 'wrap', mt: 1 }}>
{settings.reminderDays.sort((a, b) => a - b).map(day => (
<Chip
key={day}
size="small"
label={`${day}`}
color="primary"
variant="outlined"
/>
))}
</Box>
</Box>
)}
</AccordionDetails>
</Accordion>
{/* 摘要郵件設定 */}
<Accordion sx={{ mb: 2 }}>
<AccordionSummary expandIcon={<ExpandMore />}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Schedule />
<Typography variant="h6"></Typography>
</Box>
</AccordionSummary>
<AccordionDetails>
<Grid container spacing={3}>
{/* 週報設定 */}
<Grid item xs={12} md={6}>
<Card sx={{ p: 2, border: `1px solid ${actualTheme === 'dark' ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.1)'}` }}>
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
<CalendarMonth sx={{ mr: 1, color: 'primary.main' }} />
<Typography variant="subtitle1" sx={{ fontWeight: 600 }}>
</Typography>
</Box>
<FormControlLabel
control={
<Switch
checked={settings.weeklyDigestEnabled}
onChange={(e) => setSettings(prev => ({ ...prev, weeklyDigestEnabled: e.target.checked }))}
/>
}
label="啟用週報"
sx={{ mb: 2 }}
/>
{settings.weeklyDigestEnabled && (
<Box>
<FormControl size="small" fullWidth sx={{ mb: 1 }}>
<InputLabel></InputLabel>
<Select
value={settings.weeklyDigestTime}
label="發送時間"
onChange={(e) => setSettings(prev => ({ ...prev, weeklyDigestTime: e.target.value }))}
>
{timeOptions.map(time => (
<MenuItem key={time} value={time}>{time}</MenuItem>
))}
</Select>
</FormControl>
<FormControl size="small" fullWidth>
<InputLabel></InputLabel>
<Select
value={settings.weeklyDigestDay}
label="發送日期"
onChange={(e) => setSettings(prev => ({ ...prev, weeklyDigestDay: e.target.value as number }))}
>
{weekDayOptions.map(option => (
<MenuItem key={option.value} value={option.value}>{option.label}</MenuItem>
))}
</Select>
</FormControl>
</Box>
)}
</Card>
</Grid>
{/* 月報設定 */}
<Grid item xs={12} md={6}>
<Card sx={{ p: 2, border: `1px solid ${actualTheme === 'dark' ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.1)'}` }}>
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
<Today sx={{ mr: 1, color: 'primary.main' }} />
<Typography variant="subtitle1" sx={{ fontWeight: 600 }}>
</Typography>
</Box>
<FormControlLabel
control={
<Switch
checked={settings.monthlyDigestEnabled}
onChange={(e) => setSettings(prev => ({ ...prev, monthlyDigestEnabled: e.target.checked }))}
/>
}
label="啟用月報"
sx={{ mb: 2 }}
/>
{settings.monthlyDigestEnabled && (
<Box>
<FormControl size="small" fullWidth sx={{ mb: 1 }}>
<InputLabel></InputLabel>
<Select
value={settings.monthlyDigestTime}
label="發送時間"
onChange={(e) => setSettings(prev => ({ ...prev, monthlyDigestTime: e.target.value }))}
>
{timeOptions.map(time => (
<MenuItem key={time} value={time}>{time}</MenuItem>
))}
</Select>
</FormControl>
<TextField
size="small"
type="number"
fullWidth
label="每月第幾日"
value={settings.monthlyDigestDay}
onChange={(e) => setSettings(prev => ({ ...prev, monthlyDigestDay: Math.max(1, Math.min(28, parseInt(e.target.value) || 1)) }))}
inputProps={{ min: 1, max: 28 }}
helperText="1-28日"
/>
</Box>
)}
</Card>
</Grid>
</Grid>
</AccordionDetails>
</Accordion>
{/* 其他通知設定 */}
<Accordion sx={{ mb: 2 }}>
<AccordionSummary expandIcon={<ExpandMore />}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<NotificationImportant />
<Typography variant="h6"></Typography>
</Box>
</AccordionSummary>
<AccordionDetails>
<FormControlLabel
control={
<Switch
checked={settings.assignmentNotifications}
onChange={(e) => setSettings(prev => ({ ...prev, assignmentNotifications: e.target.checked }))}
/>
}
label={
<Box>
<Typography variant="subtitle2"></Typography>
<Typography variant="caption" color="text.secondary" component="div">
</Typography>
</Box>
}
sx={{ mb: 2, alignItems: 'flex-start', ml: 0 }}
/>
<FormControlLabel
control={
<Switch
checked={settings.completionNotifications}
onChange={(e) => setSettings(prev => ({ ...prev, completionNotifications: e.target.checked }))}
/>
}
label={
<Box>
<Typography variant="subtitle2"></Typography>
<Typography variant="caption" color="text.secondary" component="div">
</Typography>
</Box>
}
sx={{ alignItems: 'flex-start', ml: 0 }}
/>
</AccordionDetails>
</Accordion>
{/* 設定預覽 */}
<Alert
severity="info"
sx={{
mb: 2,
backgroundColor: actualTheme === 'dark' ? 'rgba(59, 130, 246, 0.1)' : 'rgba(59, 130, 246, 0.05)',
color: actualTheme === 'dark' ? '#93c5fd' : '#1d4ed8',
}}
>
<Typography variant="body2" sx={{ mb: 1 }}>
<strong></strong>
</Typography>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
{settings.reminderDays.length > 0 && (
<Chip size="small" label={`到期提醒:前${settings.reminderDays.join('、')}`} />
)}
{settings.weeklyDigestEnabled && (
<Chip size="small" label={`週報:${weekDayOptions.find(d => d.value === settings.weeklyDigestDay)?.label} ${settings.weeklyDigestTime}`} />
)}
{settings.monthlyDigestEnabled && (
<Chip size="small" label={`月報:每月${settings.monthlyDigestDay}${settings.monthlyDigestTime}`} />
)}
{settings.assignmentNotifications && (
<Chip size="small" label="指派通知" />
)}
{settings.completionNotifications && (
<Chip size="small" label="完成通知" />
)}
</Box>
</Alert>
</motion.div>
)}
</motion.div>
</DialogContent>
<DialogActions sx={{ px: 3, py: 2 }}>
<Button onClick={onClose} color="inherit">
</Button>
<Button
onClick={handleSave}
variant="contained"
startIcon={<Save />}
disabled={saving}
sx={{ minWidth: 100 }}
>
{saving ? '儲存中...' : '儲存設定'}
</Button>
</DialogActions>
</Dialog>
);
};
export default EnhancedEmailNotificationSettings;

View File

@@ -0,0 +1,285 @@
'use client';
import React from 'react';
import {
Card,
Box,
Typography,
Button,
IconButton,
Tooltip,
Chip,
} from '@mui/material';
import {
Close,
Delete,
Edit,
CheckCircle,
Star,
StarBorder,
Email,
Assignment,
Archive,
PlayCircle,
PauseCircle,
Flag,
} from '@mui/icons-material';
import { motion } from 'framer-motion';
import { useTheme } from '@/providers/ThemeProvider';
interface BatchActionsProps {
selectedCount: number;
onClearSelection: () => void;
onBulkDelete?: () => void;
onBulkComplete?: () => void;
onBulkAssign?: () => void;
onBulkStar?: () => void;
onBulkEmail?: () => void;
onBulkArchive?: () => void;
onBulkStatusChange?: (status: 'NEW' | 'DOING' | 'BLOCKED') => void;
}
const BatchActions: React.FC<BatchActionsProps> = ({
selectedCount,
onClearSelection,
onBulkDelete,
onBulkComplete,
onBulkAssign,
onBulkStar,
onBulkEmail,
onBulkArchive,
onBulkStatusChange,
}) => {
const { actualTheme } = useTheme();
return (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 20 }}
transition={{ duration: 0.3 }}
>
<Card
sx={{
mb: 3,
backgroundColor: actualTheme === 'dark' ? '#1f2937' : '#ffffff',
border: `2px solid`,
borderColor: 'primary.main',
boxShadow: '0 8px 32px rgba(59, 130, 246, 0.2)',
}}
>
<Box
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
px: 3,
py: 2,
}}
>
{/* 左側資訊 */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
<Chip
label={`已選擇 ${selectedCount}`}
color="primary"
sx={{
fontWeight: 600,
fontSize: '0.875rem',
}}
/>
<Typography variant="body2" color="text.secondary">
</Typography>
</Box>
{/* 操作按鈕 */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
{/* 完成 */}
{onBulkComplete && (
<Tooltip title="標記為完成">
<IconButton
size="small"
onClick={onBulkComplete}
sx={{
color: 'success.main',
'&:hover': {
backgroundColor: 'success.main',
color: 'white',
},
}}
>
<CheckCircle fontSize="small" />
</IconButton>
</Tooltip>
)}
{/* 狀態變更按鈕 */}
{onBulkStatusChange && (
<>
<Tooltip title="設為新任務">
<IconButton
size="small"
onClick={() => onBulkStatusChange('NEW')}
sx={{
color: 'text.secondary',
'&:hover': {
backgroundColor: 'rgba(107, 114, 128, 0.1)',
color: 'text.primary',
},
}}
>
<Flag fontSize="small" />
</IconButton>
</Tooltip>
<Tooltip title="設為進行中">
<IconButton
size="small"
onClick={() => onBulkStatusChange('DOING')}
sx={{
color: 'primary.main',
'&:hover': {
backgroundColor: 'primary.main',
color: 'white',
},
}}
>
<PlayCircle fontSize="small" />
</IconButton>
</Tooltip>
<Tooltip title="設為阻塞">
<IconButton
size="small"
onClick={() => onBulkStatusChange('BLOCKED')}
sx={{
color: 'error.main',
'&:hover': {
backgroundColor: 'error.main',
color: 'white',
},
}}
>
<PauseCircle fontSize="small" />
</IconButton>
</Tooltip>
</>
)}
{/* 加星號 */}
{onBulkStar && (
<Tooltip title="加入星號">
<IconButton
size="small"
onClick={onBulkStar}
sx={{
color: 'warning.main',
'&:hover': {
backgroundColor: 'warning.main',
color: 'white',
},
}}
>
<Star fontSize="small" />
</IconButton>
</Tooltip>
)}
{/* 指派 */}
{onBulkAssign && (
<Tooltip title="批次指派">
<IconButton
size="small"
onClick={onBulkAssign}
sx={{
color: 'info.main',
'&:hover': {
backgroundColor: 'info.main',
color: 'white',
},
}}
>
<Assignment fontSize="small" />
</IconButton>
</Tooltip>
)}
{/* 發送提醒 */}
{onBulkEmail && (
<Tooltip title="發送提醒">
<IconButton
size="small"
onClick={onBulkEmail}
sx={{
color: 'secondary.main',
'&:hover': {
backgroundColor: 'secondary.main',
color: 'white',
},
}}
>
<Email fontSize="small" />
</IconButton>
</Tooltip>
)}
{/* 封存 */}
{onBulkArchive && (
<Tooltip title="封存">
<IconButton
size="small"
onClick={onBulkArchive}
sx={{
color: 'text.secondary',
'&:hover': {
backgroundColor: 'action.hover',
},
}}
>
<Archive fontSize="small" />
</IconButton>
</Tooltip>
)}
{/* 刪除 */}
{onBulkDelete && (
<Tooltip title="刪除">
<IconButton
size="small"
onClick={onBulkDelete}
sx={{
color: 'error.main',
'&:hover': {
backgroundColor: 'error.main',
color: 'white',
},
}}
>
<Delete fontSize="small" />
</IconButton>
</Tooltip>
)}
{/* 清除選擇 */}
<Tooltip title="清除選擇">
<IconButton
size="small"
onClick={onClearSelection}
sx={{
color: 'text.secondary',
'&:hover': {
backgroundColor: 'action.hover',
},
}}
>
<Close fontSize="small" />
</IconButton>
</Tooltip>
</Box>
</Box>
</Card>
</motion.div>
);
};
export default BatchActions;

View File

@@ -0,0 +1,902 @@
'use client';
import React, { useState, useMemo } from 'react';
import {
Box,
Card,
Typography,
IconButton,
Button,
Chip,
Avatar,
Tooltip,
Badge,
Grid,
Paper,
Divider,
} from '@mui/material';
import {
ChevronLeft,
ChevronRight,
Today,
CalendarToday,
ViewWeek,
ViewDay,
Event,
Flag,
Person,
Star,
Circle,
} from '@mui/icons-material';
import { motion, AnimatePresence } from 'framer-motion';
import { useTheme } from '@/providers/ThemeProvider';
import { Todo } from '@/types';
import dayjs, { Dayjs } from 'dayjs';
import isoWeek from 'dayjs/plugin/isoWeek';
import isSameOrAfter from 'dayjs/plugin/isSameOrAfter';
import isSameOrBefore from 'dayjs/plugin/isSameOrBefore';
dayjs.extend(isoWeek);
dayjs.extend(isSameOrAfter);
dayjs.extend(isSameOrBefore);
interface CalendarViewProps {
todos: Todo[];
selectedTodos: string[];
onSelectionChange: (selected: string[]) => void;
onEditTodo?: (todo: Todo) => void;
}
type ViewType = 'month' | 'week' | 'day';
const CalendarView: React.FC<CalendarViewProps> = ({
todos,
selectedTodos,
onSelectionChange,
onEditTodo
}) => {
const { actualTheme } = useTheme();
const [currentDate, setCurrentDate] = useState<Dayjs>(dayjs());
const [viewType, setViewType] = useState<ViewType>('month');
const getPriorityColor = (priority: string) => {
switch (priority) {
case 'URGENT': return '#ef4444';
case 'HIGH': return '#f97316';
case 'MEDIUM': return '#f59e0b';
case 'LOW': return '#6b7280';
default: return '#6b7280';
}
};
const getStatusColor = (status: string) => {
switch (status) {
case 'NEW': return '#6b7280';
case 'DOING': return '#3b82f6';
case 'BLOCKED': return '#ef4444';
case 'DONE': return '#10b981';
default: return '#6b7280';
}
};
// 獲取當前視圖的日期範圍
const getViewDates = useMemo(() => {
switch (viewType) {
case 'month': {
const startOfMonth = currentDate.startOf('month');
const endOfMonth = currentDate.endOf('month');
const startOfWeek = startOfMonth.startOf('week');
const endOfWeek = endOfMonth.endOf('week');
const dates = [];
let current = startOfWeek;
while (current.isSameOrBefore(endOfWeek)) {
dates.push(current);
current = current.add(1, 'day');
}
return dates;
}
case 'week': {
const startOfWeek = currentDate.startOf('week');
const dates = [];
for (let i = 0; i < 7; i++) {
dates.push(startOfWeek.add(i, 'day'));
}
return dates;
}
case 'day':
return [currentDate];
default:
return [];
}
}, [currentDate, viewType]);
// 獲取指定日期的待辦事項
const getTodosForDate = (date: Dayjs) => {
return todos.filter(todo =>
todo.due_date && dayjs(todo.due_date).format('YYYY-MM-DD') === date.format('YYYY-MM-DD')
);
};
const handlePrevious = () => {
switch (viewType) {
case 'month':
setCurrentDate(prev => prev.subtract(1, 'month'));
break;
case 'week':
setCurrentDate(prev => prev.subtract(1, 'week'));
break;
case 'day':
setCurrentDate(prev => prev.subtract(1, 'day'));
break;
}
};
const handleNext = () => {
switch (viewType) {
case 'month':
setCurrentDate(prev => prev.add(1, 'month'));
break;
case 'week':
setCurrentDate(prev => prev.add(1, 'week'));
break;
case 'day':
setCurrentDate(prev => prev.add(1, 'day'));
break;
}
};
const handleToday = () => {
setCurrentDate(dayjs());
};
const getTitleText = () => {
switch (viewType) {
case 'month':
return currentDate.format('YYYY年 MM月');
case 'week':
return `${currentDate.startOf('week').format('MM/DD')} - ${currentDate.endOf('week').format('MM/DD')}`;
case 'day':
return currentDate.format('YYYY年 MM月 DD日');
}
};
const handleTodoClick = (todo: Todo, event: React.MouseEvent) => {
if (event.ctrlKey || event.metaKey) {
// 多選模式
const newSelected = selectedTodos.includes(todo.id)
? selectedTodos.filter(id => id !== todo.id)
: [...selectedTodos, todo.id];
onSelectionChange(newSelected);
} else {
// 編輯模式
onEditTodo?.(todo);
}
};
const containerVariants = {
hidden: { opacity: 0 },
visible: {
opacity: 1,
transition: {
staggerChildren: 0.05,
},
},
};
const itemVariants = {
hidden: { opacity: 0, scale: 0.95 },
visible: {
opacity: 1,
scale: 1,
transition: { duration: 0.3 },
},
};
const renderMonthView = () => {
const weeks = [];
const dates = getViewDates;
for (let i = 0; i < dates.length; i += 7) {
weeks.push(dates.slice(i, i + 7));
}
return (
<motion.div
variants={containerVariants}
initial="hidden"
animate="visible"
>
<Grid container spacing={1}>
{/* 星期標題 */}
{['日', '一', '二', '三', '四', '五', '六'].map((day, index) => (
<Grid item xs key={index}>
<Paper
sx={{
p: 1,
textAlign: 'center',
backgroundColor: actualTheme === 'dark' ? '#374151' : '#f3f4f6',
border: 'none',
}}
>
<Typography variant="caption" sx={{ fontWeight: 600, color: 'text.secondary' }}>
{day}
</Typography>
</Paper>
</Grid>
))}
{/* 日期網格 */}
{weeks.map((week, weekIndex) =>
week.map((date, dayIndex) => {
const todosForDate = getTodosForDate(date);
const isCurrentMonth = date.month() === currentDate.month();
const isToday = date.isSame(dayjs(), 'day');
return (
<Grid item xs key={`${weekIndex}-${dayIndex}`}>
<motion.div variants={itemVariants}>
<Card
sx={{
minHeight: 120,
p: 1,
backgroundColor: actualTheme === 'dark'
? (isCurrentMonth ? '#1f2937' : '#111827')
: (isCurrentMonth ? '#ffffff' : '#f9fafb'),
border: isToday
? `2px solid ${actualTheme === 'dark' ? '#60a5fa' : '#3b82f6'}`
: `1px solid ${actualTheme === 'dark'
? 'rgba(255, 255, 255, 0.1)'
: 'rgba(0, 0, 0, 0.1)'}`,
cursor: 'pointer',
transition: 'all 0.2s ease',
'&:hover': {
backgroundColor: actualTheme === 'dark'
? 'rgba(59, 130, 246, 0.1)'
: 'rgba(59, 130, 246, 0.05)',
transform: 'translateY(-1px)',
},
}}
>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 0.5 }}>
<Typography
variant="caption"
sx={{
fontWeight: isToday ? 700 : 500,
color: isCurrentMonth
? (isToday ? 'primary.main' : 'text.primary')
: 'text.disabled',
}}
>
{date.date()}
</Typography>
{todosForDate.length > 0 && (
<Badge
badgeContent={todosForDate.length}
color="primary"
sx={{
'& .MuiBadge-badge': {
fontSize: '0.6rem',
minWidth: 16,
height: 16,
},
}}
>
<Circle sx={{ fontSize: 8, color: 'primary.main' }} />
</Badge>
)}
</Box>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}>
{todosForDate.slice(0, 3).map((todo) => (
<motion.div
key={todo.id}
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
>
<Box
onClick={(e) => handleTodoClick(todo, e)}
sx={{
p: 0.5,
borderRadius: 1,
backgroundColor: selectedTodos.includes(todo.id)
? 'rgba(59, 130, 246, 0.2)'
: `${getPriorityColor(todo.priority)}15`,
borderLeft: `3px solid ${getPriorityColor(todo.priority)}`,
cursor: 'pointer',
transition: 'all 0.2s ease',
'&:hover': {
backgroundColor: `${getPriorityColor(todo.priority)}25`,
transform: 'translateX(2px)',
},
}}
>
<Typography
variant="caption"
sx={{
display: 'block',
fontWeight: 600,
fontSize: '0.65rem',
color: 'text.primary',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{todo.starred && <Star sx={{ fontSize: 10, color: '#fbbf24', mr: 0.25 }} />}
{todo.title}
</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.25, mt: 0.25 }}>
<Circle
sx={{
fontSize: 6,
color: getStatusColor(todo.status),
}}
/>
<Typography
variant="caption"
sx={{
fontSize: '0.6rem',
color: 'text.secondary',
}}
>
{(() => {
const firstUser = todo.responsible_users_details?.[0] ||
(todo.responsible_users?.[0] ? {ad_account: todo.responsible_users[0], display_name: todo.responsible_users[0]} : null);
return firstUser ? (firstUser.display_name || firstUser.ad_account) : '未指派';
})()}
</Typography>
</Box>
</Box>
</motion.div>
))}
{todosForDate.length > 3 && (
<Typography
variant="caption"
sx={{
fontSize: '0.6rem',
color: 'text.secondary',
textAlign: 'center',
mt: 0.5,
}}
>
+{todosForDate.length - 3}
</Typography>
)}
</Box>
</Card>
</motion.div>
</Grid>
);
})
)}
</Grid>
</motion.div>
);
};
const renderWeekView = () => {
return (
<motion.div
variants={containerVariants}
initial="hidden"
animate="visible"
>
<Grid container spacing={2}>
{getViewDates.map((date) => {
const todosForDate = getTodosForDate(date);
const isToday = date.isSame(dayjs(), 'day');
return (
<Grid item xs key={date.format('YYYY-MM-DD')}>
<motion.div variants={itemVariants}>
<Card
sx={{
minHeight: 400,
backgroundColor: actualTheme === 'dark' ? '#1f2937' : '#ffffff',
border: isToday
? `2px solid ${actualTheme === 'dark' ? '#60a5fa' : '#3b82f6'}`
: `1px solid ${actualTheme === 'dark'
? 'rgba(255, 255, 255, 0.1)'
: 'rgba(0, 0, 0, 0.1)'}`,
}}
>
{/* 日期標題 */}
<Box
sx={{
p: 2,
borderBottom: `1px solid ${actualTheme === 'dark'
? 'rgba(255, 255, 255, 0.1)'
: 'rgba(0, 0, 0, 0.1)'}`,
backgroundColor: isToday
? (actualTheme === 'dark' ? 'rgba(59, 130, 246, 0.1)' : 'rgba(59, 130, 246, 0.05)')
: 'transparent',
}}
>
<Typography
variant="h6"
sx={{
fontWeight: 700,
color: isToday ? 'primary.main' : 'text.primary',
textAlign: 'center',
}}
>
{date.format('MM/DD')}
</Typography>
<Typography
variant="caption"
sx={{
color: 'text.secondary',
display: 'block',
textAlign: 'center',
}}
>
{date.format('dddd')}
</Typography>
<Badge
badgeContent={todosForDate.length}
color="primary"
sx={{
display: 'flex',
justifyContent: 'center',
mt: 0.5,
'& .MuiBadge-badge': {
fontSize: '0.7rem',
minWidth: 18,
height: 18,
},
}}
>
<Event sx={{ color: 'text.secondary' }} />
</Badge>
</Box>
{/* 待辦事項列表 */}
<Box sx={{ p: 1.5, display: 'flex', flexDirection: 'column', gap: 1 }}>
<AnimatePresence>
{todosForDate.map((todo, index) => (
<motion.div
key={todo.id}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, x: -20 }}
transition={{ delay: index * 0.1 }}
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
>
<Card
onClick={(e) => handleTodoClick(todo, e)}
sx={{
p: 1.5,
cursor: 'pointer',
backgroundColor: selectedTodos.includes(todo.id)
? 'rgba(59, 130, 246, 0.1)'
: (actualTheme === 'dark' ? 'rgba(255, 255, 255, 0.05)' : '#fafafa'),
borderLeft: `4px solid ${getPriorityColor(todo.priority)}`,
transition: 'all 0.2s ease',
'&:hover': {
backgroundColor: actualTheme === 'dark'
? 'rgba(255, 255, 255, 0.1)'
: 'rgba(0, 0, 0, 0.04)',
transform: 'translateX(4px)',
},
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 0.5 }}>
<Typography
variant="body2"
sx={{
fontWeight: 600,
flex: 1,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{todo.starred && <Star sx={{ fontSize: 14, color: '#fbbf24', mr: 0.5 }} />}
{todo.title}
</Typography>
<Chip
size="small"
label={todo.status === 'DOING' ? '進行中' :
todo.status === 'BLOCKED' ? '阻塞' :
todo.status === 'DONE' ? '完成' : '新建'}
sx={{
backgroundColor: `${getStatusColor(todo.status)}15`,
color: getStatusColor(todo.status),
fontSize: '0.6rem',
height: 20,
}}
/>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Flag sx={{ fontSize: 12, color: getPriorityColor(todo.priority) }} />
<Typography variant="caption" color="text.secondary">
{todo.priority === 'URGENT' ? '緊急' :
todo.priority === 'HIGH' ? '高' :
todo.priority === 'MEDIUM' ? '中' : '低'}
</Typography>
<Person sx={{ fontSize: 12, color: 'text.secondary', ml: 'auto' }} />
{(() => {
const firstUser = todo.responsible_users_details?.[0] ||
(todo.responsible_users?.[0] ? {ad_account: todo.responsible_users[0], display_name: todo.responsible_users[0]} : null);
const displayName = firstUser ? (firstUser.display_name || firstUser.ad_account) : '未指派';
const adAccount = firstUser ? firstUser.ad_account : '';
const fullName = firstUser ? `${adAccount} ${displayName}` : '未指派';
return (
<Tooltip title={fullName}>
<Chip
size="small"
label={displayName}
sx={{
height: 20,
fontSize: '0.65rem',
backgroundColor: 'primary.main',
color: 'white',
borderRadius: 0.5,
'& .MuiChip-label': {
px: 1,
},
}}
/>
</Tooltip>
);
})()}
</Box>
</Card>
</motion.div>
))}
</AnimatePresence>
{todosForDate.length === 0 && (
<Typography
variant="caption"
sx={{
color: 'text.secondary',
textAlign: 'center',
py: 4,
fontStyle: 'italic',
}}
>
</Typography>
)}
</Box>
</Card>
</motion.div>
</Grid>
);
})}
</Grid>
</motion.div>
);
};
const renderDayView = () => {
const todosForDate = getTodosForDate(currentDate);
return (
<motion.div
variants={containerVariants}
initial="hidden"
animate="visible"
>
<Card
sx={{
backgroundColor: actualTheme === 'dark' ? '#1f2937' : '#ffffff',
border: `1px solid ${actualTheme === 'dark'
? 'rgba(255, 255, 255, 0.1)'
: 'rgba(0, 0, 0, 0.1)'}`,
}}
>
{/* 日期標題 */}
<Box sx={{ p: 3, borderBottom: `1px solid ${actualTheme === 'dark'
? 'rgba(255, 255, 255, 0.1)'
: 'rgba(0, 0, 0, 0.1)'}` }}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 2 }}>
<CalendarToday sx={{ color: 'primary.main', fontSize: 24 }} />
<Typography variant="h5" sx={{ fontWeight: 700, color: 'primary.main' }}>
{currentDate.format('YYYY年 MM月 DD日')}
</Typography>
<Typography variant="subtitle1" color="text.secondary">
{currentDate.format('dddd')}
</Typography>
</Box>
<Box sx={{ display: 'flex', justifyContent: 'center', mt: 2 }}>
<Chip
icon={<Event />}
label={`${todosForDate.length} 個待辦事項`}
color="primary"
variant="outlined"
/>
</Box>
</Box>
{/* 待辦事項列表 */}
<Box sx={{ p: 3 }}>
{todosForDate.length > 0 ? (
<Grid container spacing={2}>
<AnimatePresence>
{todosForDate.map((todo, index) => (
<Grid item xs={12} sm={6} md={4} key={todo.id}>
<motion.div
variants={itemVariants}
initial="hidden"
animate="visible"
exit={{ opacity: 0, scale: 0.9 }}
transition={{ delay: index * 0.1 }}
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
>
<Card
onClick={(e) => handleTodoClick(todo, e)}
sx={{
p: 2,
cursor: 'pointer',
backgroundColor: selectedTodos.includes(todo.id)
? 'rgba(59, 130, 246, 0.1)'
: (actualTheme === 'dark' ? '#374151' : '#f9fafb'),
borderLeft: `4px solid ${getPriorityColor(todo.priority)}`,
transition: 'all 0.3s ease',
'&:hover': {
backgroundColor: actualTheme === 'dark'
? 'rgba(255, 255, 255, 0.1)'
: 'rgba(0, 0, 0, 0.04)',
transform: 'translateY(-4px)',
boxShadow: actualTheme === 'dark'
? '0 8px 25px rgba(0, 0, 0, 0.3)'
: '0 8px 25px rgba(0, 0, 0, 0.1)',
},
}}
>
<Box sx={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', mb: 1 }}>
<Typography
variant="h6"
sx={{
fontWeight: 600,
flex: 1,
mr: 1,
}}
>
{todo.starred && <Star sx={{ fontSize: 16, color: '#fbbf24', mr: 0.5 }} />}
{todo.title}
</Typography>
<Chip
size="small"
label={todo.priority === 'URGENT' ? '緊急' :
todo.priority === 'HIGH' ? '高' :
todo.priority === 'MEDIUM' ? '中' : '低'}
sx={{
backgroundColor: `${getPriorityColor(todo.priority)}15`,
color: getPriorityColor(todo.priority),
fontWeight: 600,
}}
/>
</Box>
{todo.description && (
<Typography
variant="body2"
color="text.secondary"
sx={{
mb: 2,
overflow: 'hidden',
display: '-webkit-box',
WebkitLineClamp: 2,
WebkitBoxOrient: 'vertical',
}}
>
{todo.description}
</Typography>
)}
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<Chip
size="small"
label={todo.status === 'DOING' ? '進行中' :
todo.status === 'BLOCKED' ? '已阻塞' :
todo.status === 'DONE' ? '已完成' : '新建立'}
sx={{
backgroundColor: `${getStatusColor(todo.status)}15`,
color: getStatusColor(todo.status),
fontWeight: 600,
}}
/>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<Person sx={{ fontSize: 14, color: 'text.secondary' }} />
{(() => {
const firstUser = todo.responsible_users_details?.[0] ||
(todo.responsible_users?.[0] ? {ad_account: todo.responsible_users[0], display_name: todo.responsible_users[0]} : null);
const displayName = firstUser ? (firstUser.display_name || firstUser.ad_account) : '未指派';
const adAccount = firstUser ? firstUser.ad_account : '';
const fullName = firstUser ? `${adAccount} ${displayName}` : '未指派';
return (
<Tooltip title={fullName}>
<Chip
size="small"
label={displayName}
sx={{
height: 22,
fontSize: '0.7rem',
backgroundColor: 'primary.main',
color: 'white',
borderRadius: 0.5,
'& .MuiChip-label': {
px: 1,
},
}}
/>
</Tooltip>
);
})()}
</Box>
</Box>
</Card>
</motion.div>
</Grid>
))}
</AnimatePresence>
</Grid>
) : (
<Box
sx={{
textAlign: 'center',
py: 8,
}}
>
<Event sx={{ fontSize: 48, color: 'text.secondary', mb: 2 }} />
<Typography variant="h6" color="text.secondary" sx={{ mb: 1 }}>
</Typography>
<Typography variant="body2" color="text.secondary">
</Typography>
</Box>
)}
</Box>
</Card>
</motion.div>
);
};
return (
<Box>
{/* 控制列 */}
<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)'}`,
}}
>
<Box sx={{ p: 2, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
{/* 導航控制 */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<IconButton onClick={handlePrevious} sx={{ color: 'primary.main' }}>
<ChevronLeft />
</IconButton>
<Button
variant="outlined"
onClick={handleToday}
startIcon={<Today />}
sx={{
textTransform: 'none',
fontWeight: 600,
borderRadius: 2,
px: 3,
}}
>
</Button>
<IconButton onClick={handleNext} sx={{ color: 'primary.main' }}>
<ChevronRight />
</IconButton>
</Box>
{/* 標題 */}
<Typography
variant="h5"
sx={{
fontWeight: 700,
background: actualTheme === 'dark'
? 'linear-gradient(45deg, #f3f4f6 30%, #d1d5db 90%)'
: 'linear-gradient(45deg, #111827 30%, #374151 90%)',
WebkitBackgroundClip: 'text',
WebkitTextFillColor: 'transparent',
backgroundClip: 'text',
}}
>
{getTitleText()}
</Typography>
{/* 視圖切換 */}
<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={() => setViewType('month')}
sx={{
backgroundColor: viewType === 'month' ? 'primary.main' : 'transparent',
color: viewType === 'month' ? 'white' : 'text.secondary',
'&:hover': {
backgroundColor: viewType === 'month' ? 'primary.dark' : 'action.hover',
},
}}
>
<CalendarToday fontSize="small" />
</IconButton>
</Tooltip>
<Tooltip title="週視圖">
<IconButton
size="small"
onClick={() => setViewType('week')}
sx={{
backgroundColor: viewType === 'week' ? 'primary.main' : 'transparent',
color: viewType === 'week' ? 'white' : 'text.secondary',
'&:hover': {
backgroundColor: viewType === 'week' ? 'primary.dark' : 'action.hover',
},
}}
>
<ViewWeek fontSize="small" />
</IconButton>
</Tooltip>
<Tooltip title="日視圖">
<IconButton
size="small"
onClick={() => setViewType('day')}
sx={{
backgroundColor: viewType === 'day' ? 'primary.main' : 'transparent',
color: viewType === 'day' ? 'white' : 'text.secondary',
'&:hover': {
backgroundColor: viewType === 'day' ? 'primary.dark' : 'action.hover',
},
}}
>
<ViewDay fontSize="small" />
</IconButton>
</Tooltip>
</Box>
</Box>
</Card>
{/* 日曆內容 */}
<AnimatePresence mode="wait">
<motion.div
key={viewType}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
transition={{ duration: 0.3 }}
>
{viewType === 'month' && renderMonthView()}
{viewType === 'week' && renderWeekView()}
{viewType === 'day' && renderDayView()}
</motion.div>
</AnimatePresence>
</Box>
);
};
export default CalendarView;

View File

@@ -0,0 +1,213 @@
'use client';
import React, { useRef, useEffect, useState } from 'react';
import {
Card,
TextField,
InputAdornment,
IconButton,
Box,
Typography,
Chip,
} from '@mui/material';
import {
Search,
Close,
History,
TrendingUp,
} from '@mui/icons-material';
import { motion } from 'framer-motion';
import { useTheme } from '@/providers/ThemeProvider';
interface SearchBarProps {
value: string;
onChange: (value: string) => void;
onClose: () => void;
}
const SearchBar: React.FC<SearchBarProps> = ({ value, onChange, onClose }) => {
const { actualTheme } = useTheme();
const inputRef = useRef<HTMLInputElement>(null);
const [recentSearches, setRecentSearches] = useState<string[]>([]);
// 自動 focus
useEffect(() => {
if (inputRef.current) {
inputRef.current.focus();
}
}, []);
// 從 localStorage 載入搜索歷史
useEffect(() => {
try {
const storedSearches = localStorage.getItem('recent_searches');
if (storedSearches) {
const searches = JSON.parse(storedSearches);
setRecentSearches(Array.isArray(searches) ? searches.slice(0, 6) : []);
}
} catch (error) {
console.error('Failed to load search history:', error);
setRecentSearches([]);
}
}, []);
// 儲存搜索到歷史記錄
const saveSearch = (searchTerm: string) => {
if (!searchTerm.trim()) return;
try {
const updatedSearches = [searchTerm, ...recentSearches.filter(s => s !== searchTerm)].slice(0, 6);
localStorage.setItem('recent_searches', JSON.stringify(updatedSearches));
setRecentSearches(updatedSearches);
} catch (error) {
console.error('Failed to save search history:', error);
}
};
const handleKeyDown = (event: React.KeyboardEvent) => {
if (event.key === 'Escape') {
onClose();
} else if (event.key === 'Enter' && value.trim()) {
saveSearch(value.trim());
}
};
const handleSearchSelect = (searchTerm: string) => {
onChange(searchTerm);
saveSearch(searchTerm);
};
return (
<motion.div
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
transition={{ duration: 0.3 }}
>
<Card
sx={{
backgroundColor: actualTheme === 'dark' ? '#1f2937' : '#ffffff',
border: `1px solid ${actualTheme === 'dark'
? 'rgba(255, 255, 255, 0.1)'
: 'rgba(0, 0, 0, 0.1)'}`,
boxShadow: '0 4px 20px rgba(0, 0, 0, 0.1)',
}}
>
<Box sx={{ p: 3 }}>
<TextField
ref={inputRef}
fullWidth
placeholder="搜尋待辦事項..."
value={value}
onChange={(e) => onChange(e.target.value)}
onKeyDown={handleKeyDown}
InputProps={{
startAdornment: (
<InputAdornment position="start">
<Search sx={{ color: 'text.secondary' }} />
</InputAdornment>
),
endAdornment: value && (
<InputAdornment position="end">
<IconButton
size="small"
onClick={() => onChange('')}
sx={{ color: 'text.secondary' }}
>
<Close fontSize="small" />
</IconButton>
</InputAdornment>
),
}}
sx={{
'& .MuiOutlinedInput-root': {
borderRadius: 2,
backgroundColor: actualTheme === 'dark'
? 'rgba(255, 255, 255, 0.05)'
: 'rgba(0, 0, 0, 0.02)',
'&:hover': {
backgroundColor: actualTheme === 'dark'
? 'rgba(255, 255, 255, 0.08)'
: 'rgba(0, 0, 0, 0.04)',
},
'&.Mui-focused': {
backgroundColor: actualTheme === 'dark'
? 'rgba(255, 255, 255, 0.1)'
: 'rgba(0, 0, 0, 0.06)',
boxShadow: '0 0 0 2px rgba(59, 130, 246, 0.2)',
},
},
}}
/>
{!value && (
<Box sx={{ mt: 3 }}>
{/* 最近搜尋 */}
{recentSearches.length > 0 && (
<Box sx={{ mb: 3 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
<History sx={{ fontSize: 18, color: 'text.secondary' }} />
<Typography variant="subtitle2" color="text.secondary">
</Typography>
</Box>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
{recentSearches.map((search, index) => (
<motion.div
key={index}
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ delay: index * 0.1 }}
>
<Chip
label={search}
variant="outlined"
size="small"
clickable
onClick={() => handleSearchSelect(search)}
sx={{
'&:hover': {
backgroundColor: actualTheme === 'dark'
? 'rgba(59, 130, 246, 0.1)'
: 'rgba(59, 130, 246, 0.05)',
borderColor: 'primary.main',
transform: 'translateY(-1px)',
},
transition: 'all 0.2s ease',
}}
/>
</motion.div>
))}
</Box>
</Box>
)}
{/* 如果沒有搜索歷史,顯示提示 */}
{recentSearches.length === 0 && (
<Box sx={{ textAlign: 'center', py: 4 }}>
<Search sx={{ fontSize: 48, color: 'text.secondary', mb: 2, opacity: 0.5 }} />
<Typography variant="body2" color="text.secondary">
</Typography>
<Typography variant="caption" color="text.secondary" sx={{ mt: 0.5, display: 'block' }}>
</Typography>
</Box>
)}
</Box>
)}
{value && (
<Box sx={{ mt: 2 }}>
<Typography variant="body2" color="text.secondary">
Enter "{value}" Esc
</Typography>
</Box>
)}
</Box>
</Card>
</motion.div>
);
};
export default SearchBar;

View File

@@ -0,0 +1,782 @@
'use client';
import React, { useState, useEffect } from 'react';
import {
Dialog,
DialogTitle,
DialogContent,
DialogActions,
TextField,
Button,
Box,
Typography,
FormControl,
InputLabel,
Select,
MenuItem,
Chip,
IconButton,
Grid,
Autocomplete,
Avatar,
Divider,
FormControlLabel,
Switch,
Alert,
CircularProgress,
} from '@mui/material';
import {
Close,
Save,
Person,
Schedule,
Flag,
Star,
StarBorder,
Add,
Delete,
CalendarToday,
Assignment,
Description,
Visibility,
} from '@mui/icons-material';
import { DatePicker } from '@mui/x-date-pickers/DatePicker';
import { motion, AnimatePresence } from 'framer-motion';
import { useTheme } from '@/providers/ThemeProvider';
import { useAuth } from '@/providers/AuthProvider';
import { usersApi, todosApi } from '@/lib/api';
import { Todo as GlobalTodo } from '@/types';
import dayjs, { Dayjs } from 'dayjs';
interface User {
id: string;
name: string;
email: string;
avatar: string;
department?: string;
}
interface LocalTodo {
id?: string;
title: string;
description?: string;
status: 'NEW' | 'DOING' | 'BLOCKED' | 'DONE';
priority: 'LOW' | 'MEDIUM' | 'HIGH' | 'URGENT';
dueDate: Dayjs | null;
starred: boolean;
creator?: User;
responsible: User[];
tags: string[];
isPublic: boolean;
}
interface TodoDialogProps {
open: boolean;
onClose: () => void;
todo?: LocalTodo;
mode?: 'create' | 'edit';
onSave?: (todo: GlobalTodo) => void;
onTodoCreated?: () => void;
}
const TodoDialog: React.FC<TodoDialogProps> = ({
open,
onClose,
todo,
mode = 'create',
onSave,
onTodoCreated
}) => {
const { actualTheme } = useTheme();
const { user } = useAuth();
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
// 表單狀態
const [formData, setFormData] = useState<LocalTodo>({
title: '',
description: '',
status: 'NEW',
priority: 'MEDIUM',
dueDate: null,
starred: false,
responsible: [],
tags: [],
isPublic: true,
});
const [tagInput, setTagInput] = useState('');
const [assignToMyself, setAssignToMyself] = useState(false);
// 用戶資料
const [availableUsers, setAvailableUsers] = useState<User[]>([]);
const [loadingUsers, setLoadingUsers] = useState(false);
const [searchTimeout, setSearchTimeout] = useState<NodeJS.Timeout | null>(null);
// 搜尋用戶 (帶防抖功能)
const searchUsers = (searchTerm: string) => {
// 清除之前的搜尋計時器
if (searchTimeout) {
clearTimeout(searchTimeout);
}
if (!searchTerm.trim()) {
setAvailableUsers([]);
setLoadingUsers(false);
return;
}
// 設定新的搜尋計時器
const timeout = setTimeout(async () => {
try {
setLoadingUsers(true);
const users = await usersApi.searchUsers(searchTerm);
const transformedUsers = users.map(user => ({
id: user.ad_account || user.email,
name: user.display_name || user.ad_account || '',
email: user.email || '',
avatar: (user.display_name || user.ad_account || '').charAt(0).toUpperCase(),
department: '員工'
}));
setAvailableUsers(transformedUsers);
} catch (error) {
console.error('Failed to search users:', error);
setAvailableUsers([]);
} finally {
setLoadingUsers(false);
}
}, 300); // 300ms 防抖延遲
setSearchTimeout(timeout);
};
const statusOptions = [
{ value: 'NEW', label: '新建立', color: '#6b7280' },
{ value: 'DOING', label: '進行中', color: '#3b82f6' },
{ value: 'BLOCKED', label: '已阻塞', color: '#ef4444' },
{ value: 'DONE', label: '已完成', color: '#10b981' },
];
const priorityOptions = [
{ value: 'LOW', label: '低', color: '#6b7280' },
{ value: 'MEDIUM', label: '中', color: '#f59e0b' },
{ value: 'HIGH', label: '高', color: '#f97316' },
{ value: 'URGENT', label: '緊急', color: '#ef4444' },
];
useEffect(() => {
if (todo && mode === 'edit') {
// 轉換 API 數據格式為 TodoDialog 期望的格式
const apiTodo = todo as any; // 從 API 來的數據格式
const editTodo = {
...todo,
dueDate: apiTodo.due_date ? dayjs(apiTodo.due_date) : null,
responsible: (apiTodo.responsible_users || []).map((adAccount: string) => ({
id: adAccount,
name: adAccount, // 暫時使用 adAccount 作為 name之後可以從 LDAP 獲取完整資訊
email: adAccount,
avatar: adAccount.charAt(0).toUpperCase(),
department: '員工'
})),
tags: apiTodo.tags || [],
isPublic: true, // 預設值
};
setFormData(editTodo);
} else {
setFormData({
title: '',
description: '',
status: 'NEW',
priority: 'MEDIUM',
dueDate: null,
starred: false,
responsible: [],
tags: [],
isPublic: true,
});
}
setAssignToMyself(false);
setError('');
}, [todo, mode, open]);
// 清理計時器
useEffect(() => {
return () => {
if (searchTimeout) {
clearTimeout(searchTimeout);
}
};
}, [searchTimeout]);
const handleInputChange = (field: keyof LocalTodo, value: any) => {
setFormData(prev => ({
...prev,
[field]: value
}));
};
const handleAddTag = (event: React.KeyboardEvent) => {
if (event.key === 'Enter' && tagInput.trim()) {
event.preventDefault();
const newTag = tagInput.trim();
if (!(formData.tags || []).includes(newTag)) {
handleInputChange('tags', [...(formData.tags || []), newTag]);
}
setTagInput('');
}
};
const handleRemoveTag = (tagToRemove: string) => {
handleInputChange('tags', (formData.tags || []).filter(tag => tag !== tagToRemove));
};
const validateForm = (): boolean => {
if (!formData.title.trim()) {
setError('請輸入待辦事項標題');
return false;
}
if (!assignToMyself && (formData.responsible || []).length === 0) {
setError('請至少指派一位負責人');
return false;
}
return true;
};
const handleSave = async () => {
if (!validateForm()) return;
setLoading(true);
setError('');
try {
// 準備 API 請求數據
let responsibleUsers = formData.responsible?.map(user => user.id) || [];
// 如果選擇指派給自己,則使用當前用戶的 ad_account
if (assignToMyself && user?.ad_account) {
responsibleUsers = [user.ad_account];
}
const todoData = {
title: formData.title,
description: formData.description,
priority: formData.priority,
due_date: formData.dueDate ? formData.dueDate.format('YYYY-MM-DD') : undefined,
responsible_users: responsibleUsers,
starred: formData.starred,
is_public: formData.isPublic,
tags: formData.tags
};
let savedTodo;
if (mode === 'create') {
savedTodo = await todosApi.createTodo(todoData);
} else if (todo && todo.id) {
savedTodo = await todosApi.updateTodo(todo.id, todoData);
}
if (onSave && savedTodo) {
onSave(savedTodo);
}
onClose();
// 在對話框關閉後刷新數據
if (onTodoCreated && mode === 'create') {
setTimeout(() => {
onTodoCreated();
}, 100);
}
} catch (err: any) {
console.error('Save todo error:', err);
setError(err.response?.data?.error || '儲存時發生錯誤,請稍後再試');
} finally {
setLoading(false);
}
};
const dialogVariants = {
hidden: {
opacity: 0,
scale: 0.8,
y: 50,
},
visible: {
opacity: 1,
scale: 1,
y: 0,
transition: {
type: 'spring',
duration: 0.5,
bounce: 0.3,
}
},
exit: {
opacity: 0,
scale: 0.8,
y: 50,
transition: {
duration: 0.3,
}
}
};
return (
<AnimatePresence>
{open && (
<Dialog
open={open}
onClose={onClose}
maxWidth="md"
fullWidth
PaperProps={{
sx: {
backgroundColor: actualTheme === 'dark' ? '#1f2937' : '#ffffff',
backgroundImage: 'none',
borderRadius: 3,
minHeight: '70vh',
}
}}
>
<motion.div
variants={dialogVariants}
initial="hidden"
animate="visible"
exit="exit"
>
{/* 對話框標題 */}
<DialogTitle
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
pb: 1,
background: actualTheme === 'dark'
? 'linear-gradient(135deg, #374151 0%, #1f2937 100%)'
: 'linear-gradient(135deg, #f8fafc 0%, #e2e8f0 100%)',
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
<Box
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: 48,
height: 48,
borderRadius: '50%',
background: 'linear-gradient(45deg, #3b82f6 30%, #8b5cf6 90%)',
color: 'white',
}}
>
<Assignment />
</Box>
<Box>
<Typography variant="h6" sx={{ fontWeight: 700, mb: 0.5 }}>
{mode === 'create' ? '新增待辦事項' : '編輯待辦事項'}
</Typography>
<Typography variant="body2" color="text.secondary">
{mode === 'create' ? '建立新的待辦任務' : '修改現有的待辦任務'}
</Typography>
</Box>
</Box>
<IconButton
onClick={onClose}
sx={{
color: 'text.secondary',
'&:hover': {
backgroundColor: actualTheme === 'dark'
? 'rgba(255, 255, 255, 0.1)'
: 'rgba(0, 0, 0, 0.04)',
}
}}
>
<Close />
</IconButton>
</DialogTitle>
<Divider />
<DialogContent sx={{ p: 3 }}>
{error && (
<motion.div
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
style={{ marginBottom: 16 }}
>
<Alert severity="error" sx={{ borderRadius: 2 }}>
{error}
</Alert>
</motion.div>
)}
<Grid container spacing={3}>
{/* 基本資訊 */}
<Grid item xs={12}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
<Description sx={{ color: 'primary.main', fontSize: 20 }} />
<Typography variant="h6" sx={{ fontWeight: 600 }}>
</Typography>
</Box>
</Grid>
<Grid item xs={12}>
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center' }}>
<TextField
fullWidth
label="待辦事項標題"
placeholder="請輸入待辦事項標題..."
value={formData.title}
onChange={(e) => handleInputChange('title', e.target.value)}
error={!formData.title.trim() && error.includes('標題')}
sx={{
'& .MuiOutlinedInput-root': {
borderRadius: 2,
backgroundColor: actualTheme === 'dark'
? 'rgba(255, 255, 255, 0.05)'
: 'rgba(0, 0, 0, 0.02)',
}
}}
/>
<IconButton
onClick={() => handleInputChange('starred', !formData.starred)}
sx={{
color: formData.starred ? '#fbbf24' : 'text.disabled',
'&:hover': {
color: '#fbbf24',
backgroundColor: 'rgba(251, 191, 36, 0.1)',
},
}}
>
{formData.starred ? <Star /> : <StarBorder />}
</IconButton>
</Box>
</Grid>
<Grid item xs={12}>
<TextField
fullWidth
multiline
rows={3}
label="詳細描述"
placeholder="請輸入詳細描述..."
value={formData.description || ''}
onChange={(e) => handleInputChange('description', e.target.value)}
sx={{
'& .MuiOutlinedInput-root': {
borderRadius: 2,
backgroundColor: actualTheme === 'dark'
? 'rgba(255, 255, 255, 0.05)'
: 'rgba(0, 0, 0, 0.02)',
}
}}
/>
</Grid>
<Grid item xs={12} sm={6}>
<FormControl fullWidth>
<InputLabel></InputLabel>
<Select
value={formData.status}
label="狀態"
onChange={(e) => handleInputChange('status', e.target.value)}
sx={{
borderRadius: 2,
backgroundColor: actualTheme === 'dark'
? 'rgba(255, 255, 255, 0.05)'
: 'rgba(0, 0, 0, 0.02)',
}}
>
{statusOptions.map((option) => (
<MenuItem key={option.value} value={option.value}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Box
sx={{
width: 12,
height: 12,
borderRadius: '50%',
backgroundColor: option.color,
}}
/>
{option.label}
</Box>
</MenuItem>
))}
</Select>
</FormControl>
</Grid>
<Grid item xs={12} sm={6}>
<FormControl fullWidth>
<InputLabel></InputLabel>
<Select
value={formData.priority}
label="優先級"
onChange={(e) => handleInputChange('priority', e.target.value)}
sx={{
borderRadius: 2,
backgroundColor: actualTheme === 'dark'
? 'rgba(255, 255, 255, 0.05)'
: 'rgba(0, 0, 0, 0.02)',
}}
>
{priorityOptions.map((option) => (
<MenuItem key={option.value} value={option.value}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Flag sx={{ fontSize: 16, color: option.color }} />
{option.label}
</Box>
</MenuItem>
))}
</Select>
</FormControl>
</Grid>
<Grid item xs={12} sm={6}>
<DatePicker
label="到期日期"
value={formData.dueDate}
onChange={(date) => handleInputChange('dueDate', date)}
slotProps={{
textField: {
fullWidth: true,
error: false,
sx: {
'& .MuiOutlinedInput-root': {
borderRadius: 2,
backgroundColor: actualTheme === 'dark'
? 'rgba(255, 255, 255, 0.05)'
: 'rgba(0, 0, 0, 0.02)',
}
}
}
}}
/>
</Grid>
{/* 人員指派 */}
<Grid item xs={12}>
<Divider sx={{ my: 2 }} />
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
<Person sx={{ color: 'primary.main', fontSize: 20 }} />
<Typography variant="h6" sx={{ fontWeight: 600 }}>
</Typography>
</Box>
</Grid>
<Grid item xs={12}>
<FormControlLabel
control={
<Switch
checked={assignToMyself}
onChange={(e) => {
setAssignToMyself(e.target.checked);
if (e.target.checked) {
// 清空已選的負責人
handleInputChange('responsible', []);
}
}}
color="primary"
/>
}
label="指派給自己"
sx={{ mb: 2 }}
/>
</Grid>
{!assignToMyself && (
<Grid item xs={12}>
<Autocomplete
multiple
options={availableUsers}
value={formData.responsible}
onChange={(_, newValue) => handleInputChange('responsible', newValue)}
onInputChange={(_, value) => searchUsers(value)}
loading={loadingUsers}
getOptionLabel={(option) => `${option.name} (${option.department})`}
isOptionEqualToValue={(option, value) => option.id === value.id}
disableCloseOnSelect
disabledItemsFocusable={false}
forcePopupIcon={false}
clearOnBlur={false}
noOptionsText="輸入帳號或姓名進行搜尋"
loadingText="搜尋中..."
renderOption={(props, option) => {
const { key, ...otherProps } = props;
return (
<li key={key} {...otherProps}>
<Avatar sx={{ width: 32, height: 32, mr: 1, fontSize: '0.9rem' }}>
{option.avatar}
</Avatar>
<Box>
<Typography variant="body2">{option.name}</Typography>
<Typography variant="caption" color="text.secondary">
{option.department}
</Typography>
</Box>
</li>
);
}}
renderTags={(value, getTagProps) =>
value.map((option, index) => (
<Chip
{...getTagProps({ index })}
key={option.id}
avatar={<Avatar sx={{ fontSize: '0.8rem' }}>{option.avatar}</Avatar>}
label={option.name}
size="small"
sx={{ borderRadius: 2 }}
/>
))
}
renderInput={(params) => (
<TextField
{...params}
label="負責人 (可選擇多人)"
placeholder="選擇負責人..."
error={(formData.responsible || []).length === 0 && error.includes('負責人')}
helperText="可以指派多位負責人共同處理此待辦事項"
sx={{
'& .MuiOutlinedInput-root': {
borderRadius: 2,
backgroundColor: actualTheme === 'dark'
? 'rgba(255, 255, 255, 0.05)'
: 'rgba(0, 0, 0, 0.02)',
}
}}
/>
)}
/>
</Grid>
)}
{/* 標籤和設定 */}
<Grid item xs={12}>
<Divider sx={{ my: 2 }} />
<Typography variant="h6" sx={{ fontWeight: 600, mb: 2 }}>
</Typography>
</Grid>
<Grid item xs={12}>
<TextField
fullWidth
label="新增標籤"
placeholder="輸入標籤並按 Enter..."
value={tagInput}
onChange={(e) => setTagInput(e.target.value)}
onKeyDown={handleAddTag}
sx={{
'& .MuiOutlinedInput-root': {
borderRadius: 2,
backgroundColor: actualTheme === 'dark'
? 'rgba(255, 255, 255, 0.05)'
: 'rgba(0, 0, 0, 0.02)',
}
}}
/>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1, mt: 2 }}>
{(formData.tags || []).map((tag, index) => (
<motion.div
key={tag}
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ delay: index * 0.1 }}
>
<Chip
label={tag}
onDelete={() => handleRemoveTag(tag)}
deleteIcon={<Delete sx={{ fontSize: 16 }} />}
sx={{
borderRadius: 2,
backgroundColor: actualTheme === 'dark'
? 'rgba(139, 92, 246, 0.2)'
: 'rgba(139, 92, 246, 0.1)',
color: '#8b5cf6',
'&:hover': {
backgroundColor: actualTheme === 'dark'
? 'rgba(139, 92, 246, 0.3)'
: 'rgba(139, 92, 246, 0.15)',
},
}}
/>
</motion.div>
))}
</Box>
</Grid>
<Grid item xs={12}>
<FormControlLabel
control={
<Switch
checked={formData.isPublic}
onChange={(e) => handleInputChange('isPublic', e.target.checked)}
color="primary"
/>
}
label={
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Visibility sx={{ fontSize: 18, color: 'text.secondary' }} />
<Typography>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block' }}>
</Typography>
</Typography>
</Box>
}
/>
</Grid>
</Grid>
</DialogContent>
<Divider />
<DialogActions sx={{ p: 3, gap: 1 }}>
<Button
onClick={onClose}
variant="outlined"
sx={{
borderRadius: 2,
textTransform: 'none',
fontWeight: 600,
px: 3,
}}
>
</Button>
<Button
onClick={handleSave}
variant="contained"
disabled={loading}
startIcon={loading ? <CircularProgress size={18} /> : <Save />}
sx={{
background: 'linear-gradient(45deg, #3b82f6 30%, #8b5cf6 90%)',
borderRadius: 2,
textTransform: 'none',
fontWeight: 600,
px: 3,
'&:hover': {
background: 'linear-gradient(45deg, #2563eb 30%, #7c3aed 90%)',
boxShadow: '0 4px 12px rgba(59, 130, 246, 0.3)',
},
'&:disabled': {
background: 'rgba(0, 0, 0, 0.12)',
},
}}
>
{loading ? '儲存中...' : '儲存'}
</Button>
</DialogActions>
</motion.div>
</Dialog>
)}
</AnimatePresence>
);
};
export default TodoDialog;

View File

@@ -0,0 +1,472 @@
'use client';
import React, { useState } from 'react';
import {
Card,
Box,
Typography,
FormControl,
InputLabel,
Select,
MenuItem,
Chip,
Button,
IconButton,
Divider,
Grid,
Accordion,
AccordionSummary,
AccordionDetails,
FormControlLabel,
Switch,
Slider,
} from '@mui/material';
import {
Close,
ExpandMore,
FilterList,
Refresh,
Tune,
Schedule,
Flag,
Person,
} from '@mui/icons-material';
import { DatePicker } from '@mui/x-date-pickers/DatePicker';
import { motion } from 'framer-motion';
import { useTheme } from '@/providers/ThemeProvider';
import dayjs from 'dayjs';
interface TodoFiltersProps {
onClose: () => void;
onApply: (filters: any) => void;
initialFilters?: any;
}
const TodoFilters: React.FC<TodoFiltersProps> = ({ onClose, onApply, initialFilters }) => {
const { actualTheme } = useTheme();
// 篩選狀態
const [filters, setFilters] = useState({
status: initialFilters?.status || [] as string[],
priority: initialFilters?.priority || [] as string[],
assignee: initialFilters?.assignee || '',
dateFrom: initialFilters?.dateFrom ? dayjs(initialFilters.dateFrom) : null as dayjs.Dayjs | null,
dateTo: initialFilters?.dateTo ? dayjs(initialFilters.dateTo) : null as dayjs.Dayjs | null,
starred: initialFilters?.starred || false,
overdue: initialFilters?.overdue || false,
dueSoon: initialFilters?.dueSoon || false,
});
const statusOptions = [
{ value: 'NEW', label: '新建立', color: '#6b7280' },
{ value: 'DOING', label: '進行中', color: '#3b82f6' },
{ value: 'BLOCKED', label: '已阻塞', color: '#ef4444' },
{ value: 'DONE', label: '已完成', color: '#10b981' },
];
const priorityOptions = [
{ value: 'LOW', label: '低', color: '#6b7280' },
{ value: 'MEDIUM', label: '中', color: '#f59e0b' },
{ value: 'HIGH', label: '高', color: '#f97316' },
{ value: 'URGENT', label: '緊急', color: '#ef4444' },
];
const assigneeOptions = [
{ value: '', label: '所有人' },
{ value: 'me', label: '指派給我' },
{ value: 'created_by_me', label: '我建立的' },
{ value: 'followed_by_me', label: '我追蹤的' },
];
const handleStatusToggle = (status: string) => {
setFilters(prev => ({
...prev,
status: prev.status.includes(status)
? prev.status.filter((s: string) => s !== status)
: [...prev.status, status]
}));
};
const handlePriorityToggle = (priority: string) => {
setFilters(prev => ({
...prev,
priority: prev.priority.includes(priority)
? prev.priority.filter((p: string) => p !== priority)
: [...prev.priority, priority]
}));
};
const handleReset = () => {
const resetFilters = {
status: [],
priority: [],
assignee: '',
dateFrom: null,
dateTo: null,
starred: false,
overdue: false,
dueSoon: false,
};
setFilters(resetFilters);
onApply(resetFilters);
};
const getActiveFilterCount = () => {
let count = 0;
if (filters.status.length > 0) count++;
if (filters.priority.length > 0) count++;
if (filters.assignee) count++;
if (filters.dateFrom || filters.dateTo) count++;
if (filters.starred || filters.overdue || filters.dueSoon) count++;
return count;
};
return (
<motion.div
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
transition={{ duration: 0.3 }}
>
<Card
sx={{
backgroundColor: actualTheme === 'dark' ? '#1f2937' : '#ffffff',
border: `1px solid ${actualTheme === 'dark'
? 'rgba(255, 255, 255, 0.1)'
: 'rgba(0, 0, 0, 0.1)'}`,
boxShadow: '0 4px 20px rgba(0, 0, 0, 0.1)',
}}
>
{/* 標題區域 */}
<Box
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
p: 3,
pb: 2,
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<FilterList sx={{ color: 'primary.main' }} />
<Typography variant="h6" sx={{ fontWeight: 600 }}>
</Typography>
{getActiveFilterCount() > 0 && (
<Chip
label={`${getActiveFilterCount()} 個篩選器`}
color="primary"
size="small"
sx={{ ml: 1 }}
/>
)}
</Box>
<Box sx={{ display: 'flex', gap: 1 }}>
<Button
size="small"
startIcon={<Refresh />}
onClick={handleReset}
disabled={getActiveFilterCount() === 0}
>
</Button>
<IconButton size="small" onClick={onClose}>
<Close />
</IconButton>
</Box>
</Box>
<Divider />
<Box sx={{ p: 3 }}>
<Grid container spacing={3}>
{/* 狀態篩選 */}
<Grid item xs={12} md={6}>
<Accordion
defaultExpanded
elevation={0}
sx={{
backgroundColor: 'transparent',
'&:before': { display: 'none' },
}}
>
<AccordionSummary expandIcon={<ExpandMore />}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Tune sx={{ fontSize: 20, color: 'text.secondary' }} />
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
</Typography>
</Box>
</AccordionSummary>
<AccordionDetails sx={{ pt: 0 }}>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
{statusOptions.map((option) => (
<Chip
key={option.value}
label={option.label}
variant={filters.status.includes(option.value) ? 'filled' : 'outlined'}
clickable
onClick={() => handleStatusToggle(option.value)}
sx={{
backgroundColor: filters.status.includes(option.value)
? `${option.color}15`
: 'transparent',
color: filters.status.includes(option.value)
? option.color
: 'text.primary',
borderColor: option.color,
'&:hover': {
backgroundColor: `${option.color}20`,
transform: 'translateY(-1px)',
},
transition: 'all 0.2s ease',
}}
/>
))}
</Box>
</AccordionDetails>
</Accordion>
</Grid>
{/* 優先級篩選 */}
<Grid item xs={12} md={6}>
<Accordion
defaultExpanded
elevation={0}
sx={{
backgroundColor: 'transparent',
'&:before': { display: 'none' },
}}
>
<AccordionSummary expandIcon={<ExpandMore />}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Flag sx={{ fontSize: 20, color: 'text.secondary' }} />
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
</Typography>
</Box>
</AccordionSummary>
<AccordionDetails sx={{ pt: 0 }}>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
{priorityOptions.map((option) => (
<Chip
key={option.value}
label={option.label}
variant={filters.priority.includes(option.value) ? 'filled' : 'outlined'}
clickable
onClick={() => handlePriorityToggle(option.value)}
sx={{
backgroundColor: filters.priority.includes(option.value)
? `${option.color}15`
: 'transparent',
color: filters.priority.includes(option.value)
? option.color
: 'text.primary',
borderColor: option.color,
'&:hover': {
backgroundColor: `${option.color}20`,
transform: 'translateY(-1px)',
},
transition: 'all 0.2s ease',
}}
/>
))}
</Box>
</AccordionDetails>
</Accordion>
</Grid>
{/* 指派人篩選 */}
<Grid item xs={12} md={6}>
<Accordion
elevation={0}
sx={{
backgroundColor: 'transparent',
'&:before': { display: 'none' },
}}
>
<AccordionSummary expandIcon={<ExpandMore />}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Person sx={{ fontSize: 20, color: 'text.secondary' }} />
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
</Typography>
</Box>
</AccordionSummary>
<AccordionDetails sx={{ pt: 0 }}>
<FormControl fullWidth size="small">
<Select
value={filters.assignee}
onChange={(e) => setFilters(prev => ({ ...prev, assignee: e.target.value }))}
sx={{
backgroundColor: actualTheme === 'dark'
? 'rgba(255, 255, 255, 0.05)'
: 'rgba(0, 0, 0, 0.02)',
}}
>
{assigneeOptions.map((option) => (
<MenuItem key={option.value} value={option.value}>
{option.label}
</MenuItem>
))}
</Select>
</FormControl>
</AccordionDetails>
</Accordion>
</Grid>
{/* 日期範圍 */}
<Grid item xs={12} md={6}>
<Accordion
elevation={0}
sx={{
backgroundColor: 'transparent',
'&:before': { display: 'none' },
}}
>
<AccordionSummary expandIcon={<ExpandMore />}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Schedule sx={{ fontSize: 20, color: 'text.secondary' }} />
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
</Typography>
</Box>
</AccordionSummary>
<AccordionDetails sx={{ pt: 0 }}>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<DatePicker
label="開始日期"
value={filters.dateFrom}
onChange={(date) => setFilters(prev => ({ ...prev, dateFrom: date }))}
slotProps={{
textField: {
size: 'small',
fullWidth: true,
},
}}
/>
<DatePicker
label="結束日期"
value={filters.dateTo}
onChange={(date) => setFilters(prev => ({ ...prev, dateTo: date }))}
slotProps={{
textField: {
size: 'small',
fullWidth: true,
},
}}
/>
</Box>
</AccordionDetails>
</Accordion>
</Grid>
{/* 特殊篩選 */}
<Grid item xs={12}>
<Accordion
elevation={0}
sx={{
backgroundColor: 'transparent',
'&:before': { display: 'none' },
}}
>
<AccordionSummary expandIcon={<ExpandMore />}>
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
</Typography>
</AccordionSummary>
<AccordionDetails sx={{ pt: 0 }}>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
<FormControlLabel
control={
<Switch
checked={filters.starred}
onChange={(e) => setFilters(prev => ({ ...prev, starred: e.target.checked }))}
color="primary"
/>
}
label="僅顯示已加星項目"
/>
<FormControlLabel
control={
<Switch
checked={filters.overdue}
onChange={(e) => setFilters(prev => ({ ...prev, overdue: e.target.checked }))}
color="error"
/>
}
label="僅顯示逾期項目"
/>
<FormControlLabel
control={
<Switch
checked={filters.dueSoon}
onChange={(e) => setFilters(prev => ({ ...prev, dueSoon: e.target.checked }))}
color="warning"
/>
}
label="僅顯示即將到期項目"
/>
</Box>
</AccordionDetails>
</Accordion>
</Grid>
</Grid>
{/* 底部操作按鈕 */}
<Box
sx={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
mt: 3,
pt: 2,
borderTop: `1px solid ${actualTheme === 'dark'
? 'rgba(255, 255, 255, 0.1)'
: 'rgba(0, 0, 0, 0.1)'}`,
}}
>
<Typography variant="body2" color="text.secondary">
{getActiveFilterCount() > 0
? `${getActiveFilterCount()} 個篩選器已套用`
: '沒有套用篩選器'}
</Typography>
<Box sx={{ display: 'flex', gap: 1 }}>
<Button
variant="outlined"
size="small"
onClick={onClose}
>
</Button>
<Button
variant="contained"
size="small"
onClick={() => {
const filtersToApply = {
...filters,
dateFrom: filters.dateFrom ? filters.dateFrom.toDate() : null,
dateTo: filters.dateTo ? filters.dateTo.toDate() : null,
};
console.log('Applying filters:', filtersToApply);
onApply(filtersToApply);
// 不要立即關閉,讓使用者可以看到篩選結果
// onClose();
}}
sx={{
background: 'linear-gradient(45deg, #3b82f6 30%, #8b5cf6 90%)',
}}
>
</Button>
</Box>
</Box>
</Box>
</Card>
</motion.div>
);
};
export default TodoFilters;

View File

@@ -0,0 +1,546 @@
'use client';
import React from 'react';
import {
Box,
Card,
CardContent,
Typography,
Checkbox,
Chip,
IconButton,
Menu,
MenuItem,
Tooltip,
Badge,
} from '@mui/material';
import {
Star,
StarBorder,
MoreVert,
CalendarToday,
Person,
Edit,
Delete,
Flag,
PlayCircle,
PauseCircle,
CheckCircle,
NotificationImportant,
Public,
Lock,
} from '@mui/icons-material';
import { motion, AnimatePresence } from 'framer-motion';
import { useTheme } from '@/providers/ThemeProvider';
import { Todo } from '@/types';
import { todosApi } from '@/lib/api';
import { toast } from 'react-hot-toast';
interface TodoListProps {
todos: Todo[];
selectedTodos: string[];
onSelectionChange: (selected: string[]) => void;
viewMode: 'list' | 'calendar';
onEditTodo?: (todo: Todo) => void;
onStatusChange?: (todoId: string, status: string) => void;
}
const TodoList: React.FC<TodoListProps> = ({
todos,
selectedTodos,
onSelectionChange,
viewMode,
onEditTodo,
onStatusChange,
}) => {
const { actualTheme } = useTheme();
const [menuAnchor, setMenuAnchor] = React.useState<{ [key: string]: HTMLElement | null }>({});
const getPriorityColor = (priority: string) => {
switch (priority) {
case 'URGENT': return '#ef4444';
case 'HIGH': return '#f97316';
case 'MEDIUM': return '#f59e0b';
case 'LOW': return '#6b7280';
default: return '#6b7280';
}
};
const getStatusColor = (status: string) => {
switch (status) {
case 'NEW': return '#6b7280';
case 'DOING': return '#3b82f6';
case 'BLOCKED': return '#ef4444';
case 'DONE': return '#10b981';
default: return '#6b7280';
}
};
const getStatusIcon = (status: string) => {
switch (status) {
case 'NEW': return <Flag sx={{ fontSize: 16 }} />;
case 'DOING': return <PlayCircle sx={{ fontSize: 16 }} />;
case 'BLOCKED': return <PauseCircle sx={{ fontSize: 16 }} />;
case 'DONE': return <CheckCircle sx={{ fontSize: 16 }} />;
default: return <Flag sx={{ fontSize: 16 }} />;
}
};
const handleTodoSelect = (todoId: string) => {
const newSelected = selectedTodos.includes(todoId)
? selectedTodos.filter(id => id !== todoId)
: [...selectedTodos, todoId];
onSelectionChange(newSelected);
};
const handleMenuOpen = (todoId: string, event: React.MouseEvent<HTMLElement>) => {
setMenuAnchor({ ...menuAnchor, [todoId]: event.currentTarget });
};
const handleMenuClose = (todoId: string) => {
setMenuAnchor({ ...menuAnchor, [todoId]: null });
};
const isOverdue = (dueDate: string) => {
return new Date(dueDate) < new Date() && new Date(dueDate).toDateString() !== new Date().toDateString();
};
const getDaysUntilDue = (dueDate: string) => {
const today = new Date();
const due = new Date(dueDate);
const diffTime = due.getTime() - today.getTime();
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
if (diffDays < 0) return `逾期 ${Math.abs(diffDays)}`;
if (diffDays === 0) return '今天到期';
if (diffDays === 1) return '明天到期';
return `${diffDays} 天後到期`;
};
const handleFireEmail = async (todoId: string) => {
try {
await todosApi.fireEmail({ todo_id: todoId });
toast.success('緊急提醒已發送!');
handleMenuClose(todoId);
} catch (error: any) {
console.error('Fire email error:', error);
if (error.response?.data?.quota_exceeded) {
toast.error(error.response.data.error);
} else if (error.response?.data?.cooldown_remaining) {
toast.error(error.response.data.error);
} else {
toast.error(error.response?.data?.error || '發送緊急提醒時發生錯誤');
}
handleMenuClose(todoId);
}
};
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.4, ease: 'easeOut' },
},
};
if (todos.length === 0) {
return (
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.5 }}
>
<Card
sx={{
textAlign: 'center',
py: 8,
backgroundColor: actualTheme === 'dark' ? '#1f2937' : '#ffffff',
border: `1px solid ${actualTheme === 'dark'
? 'rgba(255, 255, 255, 0.1)'
: 'rgba(0, 0, 0, 0.1)'}`,
}}
>
<CardContent>
<Typography
variant="h6"
color="text.secondary"
gutterBottom
sx={{ fontWeight: 500 }}
>
</Typography>
<Typography variant="body2" color="text.secondary">
調
</Typography>
</CardContent>
</Card>
</motion.div>
);
}
return (
<motion.div
variants={containerVariants}
initial="hidden"
animate="visible"
>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<AnimatePresence>
{todos.map((todo) => {
const isSelected = selectedTodos.includes(todo.id);
const overdue = todo.due_date ? isOverdue(todo.due_date) : false;
return (
<motion.div
key={todo.id}
variants={itemVariants}
layout
exit={{ opacity: 0, x: -300 }}
whileHover={{ scale: 1.01 }}
whileTap={{ scale: 0.99 }}
>
<Card
sx={{
position: 'relative',
backgroundColor: actualTheme === 'dark' ? '#1f2937' : '#ffffff',
border: isSelected
? `2px solid ${actualTheme === 'dark' ? '#60a5fa' : '#3b82f6'}`
: `1px solid ${actualTheme === 'dark'
? 'rgba(255, 255, 255, 0.1)'
: 'rgba(0, 0, 0, 0.1)'}`,
borderLeft: `4px solid ${getPriorityColor(todo.priority)}`,
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
'&:hover': {
boxShadow: actualTheme === 'dark'
? '0 8px 25px rgba(0, 0, 0, 0.3)'
: '0 8px 25px rgba(0, 0, 0, 0.1)',
transform: 'translateY(-2px)',
},
...(overdue && {
backgroundColor: actualTheme === 'dark'
? 'rgba(239, 68, 68, 0.05)'
: 'rgba(239, 68, 68, 0.02)',
}),
}}
>
{/* 逾期標示 */}
{overdue && (
<Box
sx={{
position: 'absolute',
top: -1,
right: -1,
backgroundColor: '#ef4444',
color: 'white',
px: 1,
py: 0.25,
borderRadius: '0 0 0 8px',
fontSize: '0.7rem',
fontWeight: 600,
zIndex: 1,
}}
>
</Box>
)}
<CardContent sx={{ p: 3 }}>
{/* 頂部區域 */}
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 2, mb: 2 }}>
{/* 選擇框 */}
<Checkbox
checked={isSelected}
onChange={() => handleTodoSelect(todo.id)}
sx={{
p: 0,
'&.Mui-checked': {
color: 'primary.main',
},
}}
/>
{/* 主要內容 */}
<Box sx={{ flex: 1, minWidth: 0 }}>
{/* 標題和星標 */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1 }}>
<Typography
variant="h5"
sx={{
fontWeight: 600,
fontSize: '1.3rem',
flex: 1,
textDecoration: todo.status === 'DONE' ? 'line-through' : 'none',
opacity: todo.status === 'DONE' ? 0.7 : 1,
}}
>
{todo.title}
</Typography>
<Box sx={{ display: 'flex', gap: 0.5 }}>
<Tooltip title={todo.is_public ? '公開' : '私人'}>
<IconButton size="small">
{todo.is_public ? (
<Public sx={{ fontSize: 18, color: 'primary.main' }} />
) : (
<Lock sx={{ fontSize: 18, color: 'text.secondary' }} />
)}
</IconButton>
</Tooltip>
<IconButton
size="small"
sx={{
color: todo.starred ? '#fbbf24' : 'text.disabled',
'&:hover': {
color: '#fbbf24',
backgroundColor: 'rgba(251, 191, 36, 0.1)',
},
}}
>
{todo.starred ? <Star /> : <StarBorder />}
</IconButton>
</Box>
</Box>
{/* 描述 */}
{todo.description && (
<Typography
variant="body1"
color="text.secondary"
sx={{
mb: 2,
fontSize: '1rem',
opacity: todo.status === 'DONE' ? 0.6 : 1,
overflow: 'hidden',
display: '-webkit-box',
WebkitLineClamp: 2,
WebkitBoxOrient: 'vertical',
}}
>
{todo.description}
</Typography>
)}
{/* 標籤區域 */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2, flexWrap: 'wrap' }}>
{/* 狀態 */}
<Chip
icon={getStatusIcon(todo.status)}
label={todo.status === 'DOING' ? '進行中' :
todo.status === 'BLOCKED' ? '已阻塞' :
todo.status === 'DONE' ? '已完成' : '新建立'}
size="medium"
onClick={onStatusChange ? (e) => {
e.stopPropagation();
// 循環切換狀態NEW -> DOING -> DONE -> NEW
const nextStatus = todo.status === 'NEW' ? 'DOING' :
todo.status === 'DOING' ? 'DONE' :
todo.status === 'DONE' ? 'NEW' :
todo.status === 'BLOCKED' ? 'DOING' : 'NEW';
onStatusChange(todo.id, nextStatus);
} : undefined}
sx={{
backgroundColor: `${getStatusColor(todo.status)}15`,
color: getStatusColor(todo.status),
fontWeight: 600,
cursor: onStatusChange ? 'pointer' : 'default',
'& .MuiChip-icon': {
color: getStatusColor(todo.status),
},
'&:hover': onStatusChange ? {
backgroundColor: `${getStatusColor(todo.status)}25`,
transform: 'scale(1.05)',
transition: 'all 0.2s ease-in-out',
} : {},
}}
/>
{/* 優先級 */}
<Chip
label={todo.priority === 'URGENT' ? '緊急' :
todo.priority === 'HIGH' ? '高' :
todo.priority === 'MEDIUM' ? '中' : '低'}
size="small"
sx={{
backgroundColor: `${getPriorityColor(todo.priority)}15`,
color: getPriorityColor(todo.priority),
fontWeight: 600,
}}
/>
{/* 到期時間 */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<CalendarToday sx={{ fontSize: 16, color: 'text.secondary' }} />
<Typography
variant="body2"
sx={{
fontSize: '0.9rem',
color: overdue ? '#ef4444' : 'text.secondary',
fontWeight: overdue ? 600 : 400,
}}
>
{todo.due_date ? getDaysUntilDue(todo.due_date) : ''}
</Typography>
</Box>
</Box>
{/* 底部資訊 */}
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
{/* 人員資訊 */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
{/* 建立者 */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography variant="caption" color="text.secondary" sx={{ fontSize: '0.7rem' }}>
: {todo.creator_display_name || todo.creator_ad}
</Typography>
</Box>
{/* 負責人 */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Person sx={{ fontSize: 16, color: 'text.secondary' }} />
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flexWrap: 'wrap' }}>
{(todo.responsible_users_details || todo.responsible_users || []).slice(0, 3).map((user, index) => {
const displayName = typeof user === 'string'
? user
: user.display_name || user.ad_account;
const adAccount = typeof user === 'string'
? user
: user.ad_account;
const fullName = typeof user === 'string'
? user
: `${adAccount} ${displayName}`;
return (
<Tooltip key={index} title={fullName}>
<Chip
size="small"
label={fullName}
sx={{
backgroundColor: 'primary.main',
color: 'white',
fontSize: '0.8rem',
fontWeight: 500,
height: 28,
borderRadius: 1,
'& .MuiChip-label': {
px: 1.5,
},
}}
/>
</Tooltip>
);
})}
{(todo.responsible_users_details || todo.responsible_users || []).length > 3 && (
<Chip
size="small"
label={`+${(todo.responsible_users_details || todo.responsible_users || []).length - 3}`}
sx={{
backgroundColor: 'grey.400',
color: 'white',
fontSize: '0.8rem',
fontWeight: 500,
height: 28,
borderRadius: 1,
}}
/>
)}
</Box>
</Box>
{/* 追蹤者 */}
{todo.followers.length > 0 && (
<Badge
badgeContent={todo.followers.length}
color="secondary"
sx={{
'& .MuiBadge-badge': {
fontSize: '0.6rem',
minWidth: 16,
height: 16,
},
}}
>
<Typography variant="caption" color="text.secondary">
</Typography>
</Badge>
)}
</Box>
{/* 操作按鈕 */}
<IconButton
size="small"
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
handleMenuOpen(todo.id, e);
}}
sx={{
color: 'text.secondary',
'&:hover': {
backgroundColor: actualTheme === 'dark'
? 'rgba(255, 255, 255, 0.1)'
: 'rgba(0, 0, 0, 0.04)',
},
}}
>
<MoreVert />
</IconButton>
</Box>
</Box>
</Box>
</CardContent>
{/* 右鍵菜單 */}
<Menu
anchorEl={menuAnchor[todo.id]}
open={Boolean(menuAnchor[todo.id])}
onClose={() => handleMenuClose(todo.id)}
sx={{
'& .MuiPaper-root': {
backgroundColor: actualTheme === 'dark' ? '#374151' : '#ffffff',
border: `1px solid ${actualTheme === 'dark'
? 'rgba(255, 255, 255, 0.1)'
: 'rgba(0, 0, 0, 0.1)'}`,
borderRadius: 2,
},
}}
>
<MenuItem onClick={() => {
handleMenuClose(todo.id);
onEditTodo?.(todo);
}}>
<Edit sx={{ mr: 2, fontSize: 18 }} />
</MenuItem>
<MenuItem onClick={() => handleFireEmail(todo.id)} sx={{ color: 'warning.main' }}>
<NotificationImportant sx={{ mr: 2, fontSize: 18 }} />
</MenuItem>
<MenuItem onClick={() => handleMenuClose(todo.id)} sx={{ color: 'error.main' }}>
<Delete sx={{ mr: 2, fontSize: 18 }} />
</MenuItem>
</Menu>
</Card>
</motion.div>
);
})}
</AnimatePresence>
</Box>
</motion.div>
);
};
export default TodoList;