639 lines
19 KiB
TypeScript
639 lines
19 KiB
TypeScript
'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,
|
|
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 && (
|
|
<Box
|
|
component="span"
|
|
sx={{
|
|
ml: 1,
|
|
backgroundColor: item.color || (actualTheme === 'dark' ? '#3b82f6' : '#1976d2'),
|
|
color: '#ffffff',
|
|
fontSize: '0.75rem',
|
|
lineHeight: 1,
|
|
minWidth: 20,
|
|
height: 20,
|
|
px: 1,
|
|
fontWeight: 600,
|
|
borderRadius: 10,
|
|
display: 'inline-flex',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
}}
|
|
>
|
|
{item.badge}
|
|
</Box>
|
|
)}
|
|
</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;
|