backup
This commit is contained in:
465
frontend/src/components/layout/DashboardLayout.tsx
Normal file
465
frontend/src/components/layout/DashboardLayout.tsx
Normal file
@@ -0,0 +1,465 @@
|
||||
'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('(max-width: 768px)'); // 調整為平板以下尺寸才隱藏側邊欄
|
||||
const isTablet = useMediaQuery('(max-width: 1024px) and (min-width: 769px)'); // 平板尺寸自動收合
|
||||
|
||||
// 響應式處理
|
||||
useEffect(() => {
|
||||
if (isMobile) {
|
||||
setSidebarOpen(false);
|
||||
setSidebarCollapsed(false);
|
||||
} else if (isTablet) {
|
||||
setSidebarOpen(true);
|
||||
setSidebarCollapsed(true); // 平板尺寸自動收合側邊欄
|
||||
} else {
|
||||
setSidebarOpen(true);
|
||||
setSidebarCollapsed(false); // 桌面尺寸完全展開
|
||||
}
|
||||
}, [isMobile, isTablet]);
|
||||
|
||||
// 保持 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 = (event?: React.MouseEvent) => {
|
||||
// 防止事件冒泡
|
||||
if (event) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}
|
||||
|
||||
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: isMobile ? '100%' : `calc(100% - ${getDrawerWidth()}px)`,
|
||||
ml: isMobile ? 0 : `${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: isMobile || sidebarCollapsed ? 'flex' : 'none',
|
||||
zIndex: 1301, // 確保按鈕在 Drawer modal 之上
|
||||
position: 'relative',
|
||||
pointerEvents: 'auto',
|
||||
cursor: 'pointer',
|
||||
minWidth: '48px',
|
||||
minHeight: '48px',
|
||||
border: process.env.NODE_ENV === 'development' ? '2px solid lime' : 'none', // 更明顯的除錯邊框
|
||||
'&:hover': {
|
||||
backgroundColor: actualTheme === 'dark'
|
||||
? 'rgba(255, 255, 255, 0.1)'
|
||||
: 'rgba(0, 0, 0, 0.1)',
|
||||
},
|
||||
'&:active': {
|
||||
backgroundColor: actualTheme === 'dark'
|
||||
? 'rgba(255, 255, 255, 0.2)'
|
||||
: 'rgba(0, 0, 0, 0.2)',
|
||||
}
|
||||
}}
|
||||
>
|
||||
<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"
|
||||
sx={{
|
||||
'& .MuiBadge-badge': {
|
||||
borderRadius: '50%',
|
||||
minWidth: 20,
|
||||
height: 20,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: '0.75rem',
|
||||
fontWeight: 600,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<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, // 手機端性能優化
|
||||
style: {
|
||||
zIndex: isMobile ? 1300 : undefined, // 確保 modal 不會遮擋按鈕
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Sidebar
|
||||
collapsed={sidebarCollapsed && !isMobile}
|
||||
onToggleCollapse={() => setSidebarCollapsed(!sidebarCollapsed)}
|
||||
onClose={() => isMobile && setSidebarOpen(false)}
|
||||
/>
|
||||
</Drawer>
|
||||
|
||||
{/* 主內容區域 */}
|
||||
<Box
|
||||
component="main"
|
||||
sx={{
|
||||
flexGrow: 1,
|
||||
width: isMobile ? '100%' : `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;
|
451
frontend/src/components/layout/NotificationPanel.tsx
Normal file
451
frontend/src/components/layout/NotificationPanel.tsx
Normal file
@@ -0,0 +1,451 @@
|
||||
'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,
|
||||
borderRadius: '50%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</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;
|
637
frontend/src/components/layout/Sidebar.tsx
Normal file
637
frontend/src/components/layout/Sidebar.tsx
Normal file
@@ -0,0 +1,637 @@
|
||||
'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);
|
||||
|
||||
// 獲取待辦事項數據
|
||||
useEffect(() => {
|
||||
const fetchTodos = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
const token = localStorage.getItem('access_token');
|
||||
if (!token) {
|
||||
setTodos([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await todosApi.getTodos({ view: 'dashboard' });
|
||||
setTodos(response.todos || []);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch todos for sidebar:', error);
|
||||
setTodos([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchTodos();
|
||||
}, []);
|
||||
|
||||
// 獲取當前用戶信息
|
||||
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 || (actualTheme === 'dark' ? '#3b82f6' : '#1976d2'),
|
||||
color: '#ffffff',
|
||||
fontSize: '0.75rem',
|
||||
minWidth: 20,
|
||||
height: 20,
|
||||
fontWeight: 600,
|
||||
borderRadius: '50%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</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: actualTheme === 'dark'
|
||||
? 'rgba(59, 130, 246, 0.2)'
|
||||
: 'rgba(59, 130, 246, 0.1)',
|
||||
color: actualTheme === 'dark'
|
||||
? '#60a5fa'
|
||||
: '#3b82f6',
|
||||
fontSize: '0.75rem',
|
||||
fontWeight: 500,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{(() => {
|
||||
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: actualTheme === 'dark'
|
||||
? 'rgba(239, 68, 68, 0.2)'
|
||||
: 'rgba(239, 68, 68, 0.1)',
|
||||
color: actualTheme === 'dark'
|
||||
? '#f87171'
|
||||
: '#ef4444',
|
||||
fontSize: '0.75rem',
|
||||
fontWeight: 500,
|
||||
}}
|
||||
/>
|
||||
) : null;
|
||||
})()}
|
||||
{stats.blocked > 0 && (
|
||||
<Chip
|
||||
label={`${stats.blocked} 阻塞`}
|
||||
size="small"
|
||||
sx={{
|
||||
backgroundColor: actualTheme === 'dark'
|
||||
? 'rgba(239, 68, 68, 0.2)'
|
||||
: 'rgba(239, 68, 68, 0.1)',
|
||||
color: actualTheme === 'dark'
|
||||
? '#f87171'
|
||||
: '#ef4444',
|
||||
fontSize: '0.75rem',
|
||||
fontWeight: 500,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{stats.total === 0 && !loading && (
|
||||
<Chip
|
||||
label="無待辦事項"
|
||||
size="small"
|
||||
sx={{
|
||||
backgroundColor: actualTheme === 'dark'
|
||||
? 'rgba(16, 185, 129, 0.2)'
|
||||
: 'rgba(16, 185, 129, 0.1)',
|
||||
color: actualTheme === 'dark'
|
||||
? '#34d399'
|
||||
: '#10b981',
|
||||
fontSize: '0.75rem',
|
||||
fontWeight: 500,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default Sidebar;
|
Reference in New Issue
Block a user