diff --git a/.claude/settings.local.json b/.claude/settings.local.json index e3d78f6..d9c9ec5 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -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": [] diff --git a/.gitignore b/.gitignore index 24cfaee..8bb7adc 100644 --- a/.gitignore +++ b/.gitignore @@ -28,7 +28,7 @@ __pycache__/ node_modules/ .next/ .swc/ -components/ + # --- 作業系統相關 (Operating System) --- # 忽略 macOS 的系統檔案。 diff --git a/frontend/src/app/dashboard/page.tsx b/frontend/src/app/dashboard/page.tsx index f2da847..4c5f568 100644 --- a/frontend/src/app/dashboard/page.tsx +++ b/frontend/src/app/dashboard/page.tsx @@ -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'); diff --git a/frontend/src/app/layout.tsx b/frontend/src/app/layout.tsx index e353a74..2409380 100644 --- a/frontend/src/app/layout.tsx +++ b/frontend/src/app/layout.tsx @@ -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({ diff --git a/frontend/src/app/public/page.tsx b/frontend/src/app/public/page.tsx index 83346ce..fe0d0ba 100644 --- a/frontend/src/app/public/page.tsx +++ b/frontend/src/app/public/page.tsx @@ -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" /> )} diff --git a/frontend/src/app/todos/page.tsx b/frontend/src/app/todos/page.tsx index b072258..f8ff8c2 100644 --- a/frontend/src/app/todos/page.tsx +++ b/frontend/src/app/todos/page.tsx @@ -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('list'); const [filterMode, setFilterMode] = useState('all'); const [showFilters, setShowFilters] = useState(false); @@ -64,6 +64,49 @@ const TodosPage = () => { const [loading, setLoading] = useState(true); const [currentUser, setCurrentUser] = useState(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 = () => { - - - - - - { - - - - - - diff --git a/frontend/src/components/layout/DashboardLayout.tsx b/frontend/src/components/layout/DashboardLayout.tsx new file mode 100644 index 0000000..1e8e412 --- /dev/null +++ b/frontend/src/components/layout/DashboardLayout.tsx @@ -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 = ({ children }) => { + const [sidebarOpen, setSidebarOpen] = useState(true); + const [sidebarCollapsed, setSidebarCollapsed] = useState(false); + const [userMenuAnchor, setUserMenuAnchor] = useState(null); + const [notificationAnchor, setNotificationAnchor] = useState(null); + const [themeMenuAnchor, setThemeMenuAnchor] = useState(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) => { + setUserMenuAnchor(event.currentTarget); + }; + + const handleUserMenuClose = () => { + setUserMenuAnchor(null); + }; + + const handleNotificationOpen = (event: React.MouseEvent) => { + setNotificationAnchor(event.currentTarget); + }; + + const handleNotificationClose = () => { + setNotificationAnchor(null); + }; + + const handleThemeMenuOpen = (event: React.MouseEvent) => { + 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: , + dark: , + auto: , + }; + + return ( + + {/* App Bar */} + + + + + + + {/* 標題區域 */} + + + 待辦管理 + + + + {/* 右側工具列 */} + + {/* 主題切換 */} + + + {themeIcons[themeMode]} + + + + {/* 通知 */} + + + + + + + + + {/* 用戶菜單 */} + + + + {user?.display_name?.charAt(0) || user?.ad_account?.charAt(0) || 'U'} + + + + + + + + {/* Sidebar */} + 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, // 手機端性能優化 + }} + > + setSidebarCollapsed(!sidebarCollapsed)} + onClose={() => isMobile && setSidebarOpen(false)} + /> + + + {/* 主內容區域 */} + + {/* 為 AppBar 預留空間 */} + + + + {children} + + + + + {/* 用戶選單 */} + + + + {user?.display_name || user?.ad_account} + + + {user?.email} + + + + + + 登出 + + + + {/* 主題選單 */} + + { setThemeMode('light'); handleThemeMenuClose(); }} + selected={themeMode === 'light'} + > + + 亮色 + + { setThemeMode('dark'); handleThemeMenuClose(); }} + selected={themeMode === 'dark'} + > + + 暗色 + + { setThemeMode('auto'); handleThemeMenuClose(); }} + selected={themeMode === 'auto'} + > + + 跟隨系統 + + + + {/* 通知面板 */} + + + + ); +}; + +export default DashboardLayout; \ No newline at end of file diff --git a/frontend/src/components/layout/NotificationPanel.tsx b/frontend/src/components/layout/NotificationPanel.tsx new file mode 100644 index 0000000..a55a8ac --- /dev/null +++ b/frontend/src/components/layout/NotificationPanel.tsx @@ -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 = ({ + anchor, + open, + onClose, +}) => { + const { actualTheme } = useTheme(); + const router = useRouter(); + const [notifications, setNotifications] = useState([]); + 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 ; + case 'assignment': + return ; + case 'completion': + return ; + case 'overdue': + return ; + default: + return ; + } + }; + + 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 ( + + + {/* 標題區域 */} + + + + 通知 + + {unreadCount > 0 && ( + + )} + + + + setSettingsOpen(true)} + > + + + + + + + + + {/* 通知清單 */} + + {loading ? ( + + + 載入中... + + + ) : notifications.length === 0 ? ( + + + + 目前沒有新的通知 + + + 當有新的待辦事項更新時,您會在這裡看到通知 + + + ) : ( + + + {notifications.map((notification, index) => ( + + + + + {notification.avatar || getNotificationIcon(notification.type)} + + + + + + {notification.title} + + + + } + secondary={ + + + {notification.message} + + + {notification.actionable && ( + + + {!notification.read && ( + + )} + + )} + + } + /> + + {index < notifications.length - 1 && ( + + )} + + ))} + + + )} + + + {/* 底部操作 */} + + + + + + + + {/* 郵件通知設定對話框 */} + setSettingsOpen(false)} + /> + + ); +}; + +export default NotificationPanel; \ No newline at end of file diff --git a/frontend/src/components/layout/Sidebar.tsx b/frontend/src/components/layout/Sidebar.tsx new file mode 100644 index 0000000..f7f1ae7 --- /dev/null +++ b/frontend/src/components/layout/Sidebar.tsx @@ -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 = ({ collapsed, onToggleCollapse, onClose }) => { + const router = useRouter(); + const pathname = usePathname(); + const { actualTheme } = useTheme(); + const [expandedGroups, setExpandedGroups] = React.useState>({ + views: true, + status: true, + }); + const [todos, setTodos] = useState([]); + 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: , + path: '/dashboard', + }, + { + id: 'todos', + label: '待辦清單', + icon: , + path: '/todos', + badge: stats.total || undefined, + }, + { + id: 'public', + label: '公開任務', + icon: , + path: '/public', + }, + { + id: 'calendar', + label: '日曆視圖', + icon: , + path: '/calendar', + }, + ], + }, + { + id: 'views', + label: '視圖篩選', + items: [ + { + id: 'starred', + label: '已加星', + icon: , + path: '/todos?starred=true', + badge: stats.starred || undefined, + color: '#fbbf24', + }, + { + id: 'my-todos', + label: '我建立的', + icon: , + path: '/todos?view=created', + badge: stats.created || undefined, + }, + { + id: 'assigned', + label: '指派給我', + icon: , + path: '/todos?view=responsible', + badge: stats.assigned || undefined, + }, + { + id: 'following', + label: '我追蹤的', + icon: , + path: '/todos?view=following', + badge: stats.following || undefined, + }, + ], + }, + { + id: 'status', + label: '狀態分類', + items: [ + { + id: 'new', + label: '新建立', + icon: , + path: '/todos?status=NEW', + badge: stats.new || undefined, + color: '#6b7280', + }, + { + id: 'doing', + label: '進行中', + icon: , + path: '/todos?status=DOING', + badge: stats.doing || undefined, + color: '#3b82f6', + }, + { + id: 'blocked', + label: '已阻塞', + icon: , + path: '/todos?status=BLOCKED', + badge: stats.blocked || undefined, + color: '#ef4444', + }, + { + id: 'done', + label: '已完成', + icon: , + 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 ( + + + + 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', + }} + > + + {item.icon} + + + + {!collapsed && ( + + + {item.badge && ( + + )} + + )} + + + + + + ); + }; + + return ( + + {/* Logo 區域 */} + + + + + + {!collapsed && ( + + + + )} + + + {/* 導航列表 */} + + + {navGroups.map((group) => ( + + {/* 群組標題 */} + + {!collapsed && group.label && ( + + + 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'} + > + + {group.id !== 'main' && ( + expandedGroups[group.id] ? : + )} + + + + )} + + + {/* 群組項目 */} + + {(collapsed || expandedGroups[group.id] || group.id === 'main') && ( + + {group.items.map(renderNavItem)} + + )} + + + {/* 分隔線 */} + {group.id === 'main' && !collapsed && ( + + )} + + ))} + + + + {/* 底部快速狀態 */} + + {!collapsed && ( + + + + {stats.doing > 0 && ( + + )} + {(() => { + 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 ? ( + + ) : null; + })()} + {stats.blocked > 0 && ( + + )} + {stats.total === 0 && !loading && ( + + )} + + + + )} + + + ); +}; + +export default Sidebar; \ No newline at end of file diff --git a/frontend/src/components/notifications/EnhancedEmailNotificationSettings.tsx b/frontend/src/components/notifications/EnhancedEmailNotificationSettings.tsx new file mode 100644 index 0000000..093b297 --- /dev/null +++ b/frontend/src/components/notifications/EnhancedEmailNotificationSettings.tsx @@ -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 = ({ + 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({ + 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 ( + + + + 載入中... + + + + ); + } + + return ( + + + + + + 增強郵件提醒設定 + + + + + + + + + + {/* 總開關 */} + + + + + + 啟用郵件通知 + + + 接收待辦事項相關的郵件提醒通知 + + + setSettings(prev => ({ ...prev, emailEnabled: e.target.checked }))} + size="medium" + /> + + + + + {settings.emailEnabled && ( + + {/* 基本設定 */} + + }> + + + 基本設定 + + + + setSettings(prev => ({ ...prev, emailAddress: e.target.value }))} + helperText="通知將發送至此郵件地址" + sx={{ mb: 2 }} + disabled + /> + + + + + + {/* 到期提醒設定 */} + + }> + + + 到期提醒設定 + + + + + 選擇在到期日前幾天發送提醒郵件(可多選) + + + + + {reminderDayOptions.map(day => ( + + handleReminderDayToggle(day)} + size="small" + /> + } + label={`前 ${day} 天`} + /> + + ))} + + + + {settings.reminderDays.length > 0 && ( + + + 已選擇: + + + {settings.reminderDays.sort((a, b) => a - b).map(day => ( + + ))} + + + )} + + + + {/* 摘要郵件設定 */} + + }> + + + 摘要郵件設定 + + + + + {/* 週報設定 */} + + + + + + 週報摘要 + + + + setSettings(prev => ({ ...prev, weeklyDigestEnabled: e.target.checked }))} + /> + } + label="啟用週報" + sx={{ mb: 2 }} + /> + + {settings.weeklyDigestEnabled && ( + + + 發送時間 + + + + + 發送日期 + + + + )} + + + + {/* 月報設定 */} + + + + + + 月報摘要 + + + + setSettings(prev => ({ ...prev, monthlyDigestEnabled: e.target.checked }))} + /> + } + label="啟用月報" + sx={{ mb: 2 }} + /> + + {settings.monthlyDigestEnabled && ( + + + 發送時間 + + + + setSettings(prev => ({ ...prev, monthlyDigestDay: Math.max(1, Math.min(28, parseInt(e.target.value) || 1)) }))} + inputProps={{ min: 1, max: 28 }} + helperText="1-28日" + /> + + )} + + + + + + + {/* 其他通知設定 */} + + }> + + + 其他通知 + + + + setSettings(prev => ({ ...prev, assignmentNotifications: e.target.checked }))} + /> + } + label={ + + 指派通知 + + 有新的待辦事項指派給您時發送通知 + + + } + sx={{ mb: 2, alignItems: 'flex-start', ml: 0 }} + /> + + setSettings(prev => ({ ...prev, completionNotifications: e.target.checked }))} + /> + } + label={ + + 完成通知 + + 您指派的待辦事項被完成時發送通知 + + + } + sx={{ alignItems: 'flex-start', ml: 0 }} + /> + + + + {/* 設定預覽 */} + + + 當前設定預覽: + + + {settings.reminderDays.length > 0 && ( + + )} + {settings.weeklyDigestEnabled && ( + d.value === settings.weeklyDigestDay)?.label} ${settings.weeklyDigestTime}`} /> + )} + {settings.monthlyDigestEnabled && ( + + )} + {settings.assignmentNotifications && ( + + )} + {settings.completionNotifications && ( + + )} + + + + )} + + + + + + + + + ); +}; + +export default EnhancedEmailNotificationSettings; \ No newline at end of file diff --git a/frontend/src/components/todos/BatchActions.tsx b/frontend/src/components/todos/BatchActions.tsx new file mode 100644 index 0000000..f4b409c --- /dev/null +++ b/frontend/src/components/todos/BatchActions.tsx @@ -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 = ({ + selectedCount, + onClearSelection, + onBulkDelete, + onBulkComplete, + onBulkAssign, + onBulkStar, + onBulkEmail, + onBulkArchive, + onBulkStatusChange, +}) => { + const { actualTheme } = useTheme(); + + return ( + + + + {/* 左側資訊 */} + + + + 批次操作工具列 + + + + {/* 操作按鈕 */} + + {/* 完成 */} + {onBulkComplete && ( + + + + + + )} + + {/* 狀態變更按鈕 */} + {onBulkStatusChange && ( + <> + + onBulkStatusChange('NEW')} + sx={{ + color: 'text.secondary', + '&:hover': { + backgroundColor: 'rgba(107, 114, 128, 0.1)', + color: 'text.primary', + }, + }} + > + + + + + + onBulkStatusChange('DOING')} + sx={{ + color: 'primary.main', + '&:hover': { + backgroundColor: 'primary.main', + color: 'white', + }, + }} + > + + + + + + onBulkStatusChange('BLOCKED')} + sx={{ + color: 'error.main', + '&:hover': { + backgroundColor: 'error.main', + color: 'white', + }, + }} + > + + + + + )} + + {/* 加星號 */} + {onBulkStar && ( + + + + + + )} + + {/* 指派 */} + {onBulkAssign && ( + + + + + + )} + + {/* 發送提醒 */} + {onBulkEmail && ( + + + + + + )} + + {/* 封存 */} + {onBulkArchive && ( + + + + + + )} + + {/* 刪除 */} + {onBulkDelete && ( + + + + + + )} + + {/* 清除選擇 */} + + + + + + + + + + ); +}; + +export default BatchActions; \ No newline at end of file diff --git a/frontend/src/components/todos/CalendarView.tsx b/frontend/src/components/todos/CalendarView.tsx new file mode 100644 index 0000000..088e529 --- /dev/null +++ b/frontend/src/components/todos/CalendarView.tsx @@ -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 = ({ + todos, + selectedTodos, + onSelectionChange, + onEditTodo +}) => { + const { actualTheme } = useTheme(); + const [currentDate, setCurrentDate] = useState(dayjs()); + const [viewType, setViewType] = useState('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 ( + + + {/* 星期標題 */} + {['日', '一', '二', '三', '四', '五', '六'].map((day, index) => ( + + + + {day} + + + + ))} + + {/* 日期網格 */} + {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 ( + + + + + + {date.date()} + + {todosForDate.length > 0 && ( + + + + )} + + + + {todosForDate.slice(0, 3).map((todo) => ( + + 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)', + }, + }} + > + + {todo.starred && } + {todo.title} + + + + + {(() => { + 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) : '未指派'; + })()} + + + + + ))} + + {todosForDate.length > 3 && ( + + +{todosForDate.length - 3} 更多 + + )} + + + + + ); + }) + )} + + + ); + }; + + const renderWeekView = () => { + return ( + + + {getViewDates.map((date) => { + const todosForDate = getTodosForDate(date); + const isToday = date.isSame(dayjs(), 'day'); + + return ( + + + + {/* 日期標題 */} + + + {date.format('MM/DD')} + + + {date.format('dddd')} + + + + + + + {/* 待辦事項列表 */} + + + {todosForDate.map((todo, index) => ( + + 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)', + }, + }} + > + + + {todo.starred && } + {todo.title} + + + + + + + + {todo.priority === 'URGENT' ? '緊急' : + todo.priority === 'HIGH' ? '高' : + todo.priority === 'MEDIUM' ? '中' : '低'} + + + + {(() => { + 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 ( + + + + ); + })()} + + + + ))} + + + {todosForDate.length === 0 && ( + + 無待辦事項 + + )} + + + + + ); + })} + + + ); + }; + + const renderDayView = () => { + const todosForDate = getTodosForDate(currentDate); + + return ( + + + {/* 日期標題 */} + + + + + {currentDate.format('YYYY年 MM月 DD日')} + + + {currentDate.format('dddd')} + + + + } + label={`${todosForDate.length} 個待辦事項`} + color="primary" + variant="outlined" + /> + + + + {/* 待辦事項列表 */} + + {todosForDate.length > 0 ? ( + + + {todosForDate.map((todo, index) => ( + + + 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)', + }, + }} + > + + + {todo.starred && } + {todo.title} + + + + + {todo.description && ( + + {todo.description} + + )} + + + + + + + {(() => { + 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 ( + + + + ); + })()} + + + + + + ))} + + + ) : ( + + + + 今天沒有待辦事項 + + + 好好休息,或者開始規劃新的任務吧! + + + )} + + + + ); + }; + + return ( + + {/* 控制列 */} + + + {/* 導航控制 */} + + + + + + + + + + + {/* 標題 */} + + {getTitleText()} + + + {/* 視圖切換 */} + + + setViewType('month')} + sx={{ + backgroundColor: viewType === 'month' ? 'primary.main' : 'transparent', + color: viewType === 'month' ? 'white' : 'text.secondary', + '&:hover': { + backgroundColor: viewType === 'month' ? 'primary.dark' : 'action.hover', + }, + }} + > + + + + + setViewType('week')} + sx={{ + backgroundColor: viewType === 'week' ? 'primary.main' : 'transparent', + color: viewType === 'week' ? 'white' : 'text.secondary', + '&:hover': { + backgroundColor: viewType === 'week' ? 'primary.dark' : 'action.hover', + }, + }} + > + + + + + setViewType('day')} + sx={{ + backgroundColor: viewType === 'day' ? 'primary.main' : 'transparent', + color: viewType === 'day' ? 'white' : 'text.secondary', + '&:hover': { + backgroundColor: viewType === 'day' ? 'primary.dark' : 'action.hover', + }, + }} + > + + + + + + + + {/* 日曆內容 */} + + + {viewType === 'month' && renderMonthView()} + {viewType === 'week' && renderWeekView()} + {viewType === 'day' && renderDayView()} + + + + ); +}; + +export default CalendarView; \ No newline at end of file diff --git a/frontend/src/components/todos/SearchBar.tsx b/frontend/src/components/todos/SearchBar.tsx new file mode 100644 index 0000000..75aa572 --- /dev/null +++ b/frontend/src/components/todos/SearchBar.tsx @@ -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 = ({ value, onChange, onClose }) => { + const { actualTheme } = useTheme(); + const inputRef = useRef(null); + const [recentSearches, setRecentSearches] = useState([]); + + // 自動 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 ( + + + + onChange(e.target.value)} + onKeyDown={handleKeyDown} + InputProps={{ + startAdornment: ( + + + + ), + endAdornment: value && ( + + onChange('')} + sx={{ color: 'text.secondary' }} + > + + + + ), + }} + 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 && ( + + {/* 最近搜尋 */} + {recentSearches.length > 0 && ( + + + + + 最近搜尋 + + + + {recentSearches.map((search, index) => ( + + 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', + }} + /> + + ))} + + + )} + + {/* 如果沒有搜索歷史,顯示提示 */} + {recentSearches.length === 0 && ( + + + + 開始輸入以搜尋待辦事項 + + + 您的搜索歷史將會顯示在這裡 + + + )} + + )} + + {value && ( + + + 按 Enter 搜尋 "{value}" 或 Esc 取消 + + + )} + + + + ); +}; + +export default SearchBar; \ No newline at end of file diff --git a/frontend/src/components/todos/TodoDialog.tsx b/frontend/src/components/todos/TodoDialog.tsx new file mode 100644 index 0000000..e801aee --- /dev/null +++ b/frontend/src/components/todos/TodoDialog.tsx @@ -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 = ({ + 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({ + 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([]); + const [loadingUsers, setLoadingUsers] = useState(false); + const [searchTimeout, setSearchTimeout] = useState(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 ( + + {open && ( + + + {/* 對話框標題 */} + + + + + + + + {mode === 'create' ? '新增待辦事項' : '編輯待辦事項'} + + + {mode === 'create' ? '建立新的待辦任務' : '修改現有的待辦任務'} + + + + + + + + + + + + {error && ( + + + {error} + + + )} + + + {/* 基本資訊 */} + + + + + 基本資訊 + + + + + + + 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)', + } + }} + /> + handleInputChange('starred', !formData.starred)} + sx={{ + color: formData.starred ? '#fbbf24' : 'text.disabled', + '&:hover': { + color: '#fbbf24', + backgroundColor: 'rgba(251, 191, 36, 0.1)', + }, + }} + > + {formData.starred ? : } + + + + + + 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)', + } + }} + /> + + + + + 狀態 + + + + + + + 優先級 + + + + + + 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)', + } + } + } + }} + /> + + + + {/* 人員指派 */} + + + + + + 人員指派 + + + + + + { + setAssignToMyself(e.target.checked); + if (e.target.checked) { + // 清空已選的負責人 + handleInputChange('responsible', []); + } + }} + color="primary" + /> + } + label="指派給自己" + sx={{ mb: 2 }} + /> + + + {!assignToMyself && ( + + 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 ( +
  • + + {option.avatar} + + + {option.name} + + {option.department} + + +
  • + ); + }} + renderTags={(value, getTagProps) => + value.map((option, index) => ( + {option.avatar}} + label={option.name} + size="small" + sx={{ borderRadius: 2 }} + /> + )) + } + renderInput={(params) => ( + + )} + /> +
    + )} + + {/* 標籤和設定 */} + + + + 標籤和設定 + + + + + 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)', + } + }} + /> + + + {(formData.tags || []).map((tag, index) => ( + + handleRemoveTag(tag)} + deleteIcon={} + 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)', + }, + }} + /> + + ))} + + + + + handleInputChange('isPublic', e.target.checked)} + color="primary" + /> + } + label={ + + + + 公開此待辦事項 + + 其他用戶可以查看此待辦事項 + + + + } + /> + +
    +
    + + + + + + + +
    +
    + )} +
    + ); +}; + +export default TodoDialog; \ No newline at end of file diff --git a/frontend/src/components/todos/TodoFilters.tsx b/frontend/src/components/todos/TodoFilters.tsx new file mode 100644 index 0000000..6ae114a --- /dev/null +++ b/frontend/src/components/todos/TodoFilters.tsx @@ -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 = ({ 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 ( + + + {/* 標題區域 */} + + + + + 進階篩選 + + {getActiveFilterCount() > 0 && ( + + )} + + + + + + + + + + + + + + + {/* 狀態篩選 */} + + + }> + + + + 狀態 + + + + + + {statusOptions.map((option) => ( + 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', + }} + /> + ))} + + + + + + {/* 優先級篩選 */} + + + }> + + + + 優先級 + + + + + + {priorityOptions.map((option) => ( + 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', + }} + /> + ))} + + + + + + {/* 指派人篩選 */} + + + }> + + + + 指派人 + + + + + + + + + + + + {/* 日期範圍 */} + + + }> + + + + 到期日期 + + + + + + setFilters(prev => ({ ...prev, dateFrom: date }))} + slotProps={{ + textField: { + size: 'small', + fullWidth: true, + }, + }} + /> + setFilters(prev => ({ ...prev, dateTo: date }))} + slotProps={{ + textField: { + size: 'small', + fullWidth: true, + }, + }} + /> + + + + + + {/* 特殊篩選 */} + + + }> + + 特殊篩選 + + + + + setFilters(prev => ({ ...prev, starred: e.target.checked }))} + color="primary" + /> + } + label="僅顯示已加星項目" + /> + setFilters(prev => ({ ...prev, overdue: e.target.checked }))} + color="error" + /> + } + label="僅顯示逾期項目" + /> + setFilters(prev => ({ ...prev, dueSoon: e.target.checked }))} + color="warning" + /> + } + label="僅顯示即將到期項目" + /> + + + + + + + {/* 底部操作按鈕 */} + + + {getActiveFilterCount() > 0 + ? `${getActiveFilterCount()} 個篩選器已套用` + : '沒有套用篩選器'} + + + + + + + + + + + ); +}; + +export default TodoFilters; \ No newline at end of file diff --git a/frontend/src/components/todos/TodoList.tsx b/frontend/src/components/todos/TodoList.tsx new file mode 100644 index 0000000..69b5d0b --- /dev/null +++ b/frontend/src/components/todos/TodoList.tsx @@ -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 = ({ + 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 ; + case 'DOING': return ; + case 'BLOCKED': return ; + case 'DONE': return ; + default: return ; + } + }; + + 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) => { + 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 ( + + + + + 沒有找到待辦事項 + + + 嘗試調整篩選條件或建立新的待辦事項 + + + + + ); + } + + return ( + + + + {todos.map((todo) => { + const isSelected = selectedTodos.includes(todo.id); + const overdue = todo.due_date ? isOverdue(todo.due_date) : false; + + return ( + + + {/* 逾期標示 */} + {overdue && ( + + 逾期 + + )} + + + {/* 頂部區域 */} + + {/* 選擇框 */} + handleTodoSelect(todo.id)} + sx={{ + p: 0, + '&.Mui-checked': { + color: 'primary.main', + }, + }} + /> + + {/* 主要內容 */} + + {/* 標題和星標 */} + + + {todo.title} + + + + + + {todo.is_public ? ( + + ) : ( + + )} + + + + {todo.starred ? : } + + + + + {/* 描述 */} + {todo.description && ( + + {todo.description} + + )} + + {/* 標籤區域 */} + + {/* 狀態 */} + { + 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', + } : {}, + }} + /> + + {/* 優先級 */} + + + {/* 到期時間 */} + + + + {todo.due_date ? getDaysUntilDue(todo.due_date) : ''} + + + + + {/* 底部資訊 */} + + {/* 人員資訊 */} + + {/* 建立者 */} + + + 建立者: {todo.creator_display_name || todo.creator_ad} + + + + {/* 負責人 */} + + + + {(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 ( + + + + ); + })} + {(todo.responsible_users_details || todo.responsible_users || []).length > 3 && ( + + )} + + + + {/* 追蹤者 */} + {todo.followers.length > 0 && ( + + + 追蹤者 + + + )} + + + {/* 操作按鈕 */} + { + 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)', + }, + }} + > + + + + + + + + + {/* 右鍵菜單 */} + 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, + }, + }} + > + { + handleMenuClose(todo.id); + onEditTodo?.(todo); + }}> + + 編輯 + + handleFireEmail(todo.id)} sx={{ color: 'warning.main' }}> + + 緊急提醒 + + handleMenuClose(todo.id)} sx={{ color: 'error.main' }}> + + 刪除 + + + + + ); + })} + + + + ); +}; + +export default TodoList; \ No newline at end of file