backup
This commit is contained in:
60
frontend/.dockerignore
Normal file
60
frontend/.dockerignore
Normal file
@@ -0,0 +1,60 @@
|
||||
node_modules
|
||||
.git
|
||||
.next
|
||||
out
|
||||
build
|
||||
dist
|
||||
coverage
|
||||
|
||||
# Environment files
|
||||
.env
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
|
||||
# Logs
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
*.log
|
||||
|
||||
# Runtime data
|
||||
pids
|
||||
*.pid
|
||||
*.seed
|
||||
*.pid.lock
|
||||
|
||||
# Coverage directory used by tools like istanbul
|
||||
coverage/
|
||||
*.lcov
|
||||
|
||||
# nyc test coverage
|
||||
.nyc_output
|
||||
|
||||
# IDE files
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# OS files
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
*.tmp
|
||||
*.temp
|
||||
|
||||
# Documentation
|
||||
README.md
|
||||
CHANGELOG.md
|
||||
LICENSE
|
||||
docs/
|
||||
|
||||
# Docker files
|
||||
Dockerfile*
|
||||
.dockerignore
|
||||
docker-compose*.yml
|
||||
|
||||
# Git files
|
||||
.gitignore
|
||||
.gitattributes
|
184
frontend/.env.example
Normal file
184
frontend/.env.example
Normal file
@@ -0,0 +1,184 @@
|
||||
# Frontend Environment Configuration
|
||||
# 複製此檔案為 .env.local 並填入實際值
|
||||
|
||||
# ===========================================
|
||||
# 基本設定
|
||||
# ===========================================
|
||||
|
||||
# Next.js 環境模式
|
||||
NODE_ENV=development
|
||||
NEXT_PUBLIC_APP_NAME="PANJIT Todo List"
|
||||
NEXT_PUBLIC_APP_VERSION="1.0.0"
|
||||
|
||||
# ===========================================
|
||||
# 後端 API 設定
|
||||
# ===========================================
|
||||
|
||||
# 後端 API 基本網址
|
||||
NEXT_PUBLIC_API_URL=http://localhost:12011
|
||||
NEXT_PUBLIC_BACKEND_URL=http://localhost:12011
|
||||
|
||||
# API 版本
|
||||
NEXT_PUBLIC_API_VERSION=v1
|
||||
|
||||
# ===========================================
|
||||
# 認證設定
|
||||
# ===========================================
|
||||
|
||||
# JWT Token 設定
|
||||
NEXT_PUBLIC_JWT_EXPIRES_IN=7d
|
||||
NEXT_PUBLIC_REFRESH_TOKEN_EXPIRES_IN=30d
|
||||
|
||||
# AD/LDAP 認證設定 (如果需要前端顯示)
|
||||
NEXT_PUBLIC_AD_DOMAIN=panjit.com.tw
|
||||
|
||||
|
||||
# ===========================================
|
||||
# 主題與 UI 設定
|
||||
# ===========================================
|
||||
|
||||
# 預設主題模式 (light | dark | system)
|
||||
NEXT_PUBLIC_DEFAULT_THEME=system
|
||||
|
||||
# 主題顏色設定
|
||||
NEXT_PUBLIC_PRIMARY_COLOR=#3b82f6
|
||||
NEXT_PUBLIC_SECONDARY_COLOR=#8b5cf6
|
||||
|
||||
# UI 設定
|
||||
NEXT_PUBLIC_SIDEBAR_DEFAULT_COLLAPSED=false
|
||||
NEXT_PUBLIC_ANIMATION_ENABLED=true
|
||||
|
||||
# ===========================================
|
||||
# 功能開關
|
||||
# ===========================================
|
||||
|
||||
# 功能啟用設定
|
||||
NEXT_PUBLIC_CALENDAR_VIEW_ENABLED=true
|
||||
NEXT_PUBLIC_BATCH_OPERATIONS_ENABLED=true
|
||||
NEXT_PUBLIC_SEARCH_ENABLED=true
|
||||
NEXT_PUBLIC_ADVANCED_FILTERS_ENABLED=true
|
||||
NEXT_PUBLIC_EXCEL_IMPORT_ENABLED=true
|
||||
|
||||
# 實驗性功能
|
||||
NEXT_PUBLIC_EXPERIMENTAL_FEATURES=false
|
||||
NEXT_PUBLIC_DEBUG_MODE=false
|
||||
|
||||
# ===========================================
|
||||
# 分析與監控
|
||||
# ===========================================
|
||||
|
||||
# Google Analytics (如果需要)
|
||||
# NEXT_PUBLIC_GA_ID=G-XXXXXXXXXX
|
||||
|
||||
# Sentry 錯誤監控 (如果需要)
|
||||
# NEXT_PUBLIC_SENTRY_DSN=https://your-sentry-dsn
|
||||
|
||||
# 效能監控
|
||||
NEXT_PUBLIC_PERFORMANCE_MONITORING=false
|
||||
|
||||
# ===========================================
|
||||
# 郵件與通知設定
|
||||
# ===========================================
|
||||
|
||||
# 郵件服務設定 (顯示用)
|
||||
NEXT_PUBLIC_SMTP_ENABLED=true
|
||||
NEXT_PUBLIC_EMAIL_DOMAIN=panjit.com.tw
|
||||
|
||||
# 通知設定
|
||||
NEXT_PUBLIC_PUSH_NOTIFICATIONS=true
|
||||
NEXT_PUBLIC_EMAIL_NOTIFICATIONS=true
|
||||
|
||||
# ===========================================
|
||||
# 檔案與媒體設定
|
||||
# ===========================================
|
||||
|
||||
# 檔案上傳設定
|
||||
NEXT_PUBLIC_MAX_FILE_SIZE=10485760 # 10MB
|
||||
NEXT_PUBLIC_ALLOWED_FILE_TYPES=.xlsx,.xls,.csv
|
||||
|
||||
# 頭像設定
|
||||
NEXT_PUBLIC_AVATAR_MAX_SIZE=2097152 # 2MB
|
||||
NEXT_PUBLIC_AVATAR_ALLOWED_TYPES=.jpg,.jpeg,.png,.gif
|
||||
|
||||
# ===========================================
|
||||
# 快取與效能
|
||||
# ===========================================
|
||||
|
||||
# API 快取設定
|
||||
NEXT_PUBLIC_API_CACHE_ENABLED=true
|
||||
NEXT_PUBLIC_API_CACHE_DURATION=300000 # 5 minutes
|
||||
|
||||
# 靜態資源 CDN (生產環境)
|
||||
# NEXT_PUBLIC_CDN_URL=https://cdn.example.com
|
||||
|
||||
# ===========================================
|
||||
# 本地化設定
|
||||
# ===========================================
|
||||
|
||||
# 語言設定
|
||||
NEXT_PUBLIC_DEFAULT_LOCALE=zh-TW
|
||||
NEXT_PUBLIC_SUPPORTED_LOCALES=zh-TW,zh-CN,en-US
|
||||
|
||||
# 時區設定
|
||||
NEXT_PUBLIC_DEFAULT_TIMEZONE=Asia/Taipei
|
||||
|
||||
# 日期格式
|
||||
NEXT_PUBLIC_DATE_FORMAT=YYYY-MM-DD
|
||||
NEXT_PUBLIC_DATETIME_FORMAT=YYYY-MM-DD HH:mm
|
||||
NEXT_PUBLIC_TIME_FORMAT=HH:mm
|
||||
|
||||
# ===========================================
|
||||
# 開發工具設定
|
||||
# ===========================================
|
||||
|
||||
# 開發模式設定
|
||||
NEXT_PUBLIC_DEV_TOOLS=true
|
||||
NEXT_PUBLIC_MOCK_API=false
|
||||
|
||||
# Redux DevTools
|
||||
NEXT_PUBLIC_REDUX_DEVTOOLS=true
|
||||
|
||||
# React Query DevTools
|
||||
NEXT_PUBLIC_REACT_QUERY_DEVTOOLS=true
|
||||
|
||||
# ===========================================
|
||||
# 安全設定
|
||||
# ===========================================
|
||||
|
||||
# CORS 設定 (僅供參考,實際由後端控制)
|
||||
NEXT_PUBLIC_ALLOWED_ORIGINS=http://localhost:12012,http://localhost:12011
|
||||
|
||||
# CSP 設定提示
|
||||
NEXT_PUBLIC_CSP_ENABLED=false
|
||||
|
||||
# ===========================================
|
||||
# 部署環境特定設定
|
||||
# ===========================================
|
||||
|
||||
# 生產環境設定
|
||||
# NODE_ENV=production
|
||||
# NEXT_PUBLIC_API_URL=https://api.yourdomain.com
|
||||
|
||||
# 測試環境設定
|
||||
# NODE_ENV=staging
|
||||
# NEXT_PUBLIC_API_URL=https://staging-api.yourdomain.com
|
||||
|
||||
# ===========================================
|
||||
# 範例說明
|
||||
# ===========================================
|
||||
|
||||
# 📝 設定指南:
|
||||
# 1. 複製此檔案為 .env.local
|
||||
# 2. 根據您的環境修改對應的值
|
||||
# 3. 確保 .env.local 已加入 .gitignore
|
||||
# 4. 生產環境使用不同的 API 網址和金鑰
|
||||
|
||||
# 🔒 安全提醒:
|
||||
# - 請勿將包含敏感資訊的 .env.local 提交到版本控制
|
||||
# - API 金鑰和密碼應該定期更換
|
||||
# - 生產環境務必使用 HTTPS
|
||||
|
||||
# 🚀 效能優化:
|
||||
# - 生產環境建議啟用 CDN
|
||||
# - 根據需求調整快取設定
|
||||
# - 監控和分析工具可選擇性啟用
|
184
frontend/.env.local
Normal file
184
frontend/.env.local
Normal file
@@ -0,0 +1,184 @@
|
||||
# Frontend Environment Configuration
|
||||
# 複製此檔案為 .env.local 並填入實際值
|
||||
|
||||
# ===========================================
|
||||
# 基本設定
|
||||
# ===========================================
|
||||
|
||||
# Next.js 環境模式
|
||||
NODE_ENV=development
|
||||
NEXT_PUBLIC_APP_NAME="PANJIT Todo List"
|
||||
NEXT_PUBLIC_APP_VERSION="1.0.0"
|
||||
|
||||
# ===========================================
|
||||
# 後端 API 設定
|
||||
# ===========================================
|
||||
|
||||
# 後端 API 基本網址
|
||||
NEXT_PUBLIC_API_URL=http://localhost:12011
|
||||
NEXT_PUBLIC_BACKEND_URL=http://localhost:12011
|
||||
|
||||
# API 版本
|
||||
NEXT_PUBLIC_API_VERSION=v1
|
||||
|
||||
# ===========================================
|
||||
# 認證設定
|
||||
# ===========================================
|
||||
|
||||
# JWT Token 設定
|
||||
NEXT_PUBLIC_JWT_EXPIRES_IN=7d
|
||||
NEXT_PUBLIC_REFRESH_TOKEN_EXPIRES_IN=30d
|
||||
|
||||
# AD/LDAP 認證設定 (如果需要前端顯示)
|
||||
NEXT_PUBLIC_AD_DOMAIN=panjit.com.tw
|
||||
|
||||
|
||||
# ===========================================
|
||||
# 主題與 UI 設定
|
||||
# ===========================================
|
||||
|
||||
# 預設主題模式 (light | dark | system)
|
||||
NEXT_PUBLIC_DEFAULT_THEME=system
|
||||
|
||||
# 主題顏色設定
|
||||
NEXT_PUBLIC_PRIMARY_COLOR=#3b82f6
|
||||
NEXT_PUBLIC_SECONDARY_COLOR=#8b5cf6
|
||||
|
||||
# UI 設定
|
||||
NEXT_PUBLIC_SIDEBAR_DEFAULT_COLLAPSED=false
|
||||
NEXT_PUBLIC_ANIMATION_ENABLED=true
|
||||
|
||||
# ===========================================
|
||||
# 功能開關
|
||||
# ===========================================
|
||||
|
||||
# 功能啟用設定
|
||||
NEXT_PUBLIC_CALENDAR_VIEW_ENABLED=true
|
||||
NEXT_PUBLIC_BATCH_OPERATIONS_ENABLED=true
|
||||
NEXT_PUBLIC_SEARCH_ENABLED=true
|
||||
NEXT_PUBLIC_ADVANCED_FILTERS_ENABLED=true
|
||||
NEXT_PUBLIC_EXCEL_IMPORT_ENABLED=true
|
||||
|
||||
# 實驗性功能
|
||||
NEXT_PUBLIC_EXPERIMENTAL_FEATURES=false
|
||||
NEXT_PUBLIC_DEBUG_MODE=false
|
||||
|
||||
# ===========================================
|
||||
# 分析與監控
|
||||
# ===========================================
|
||||
|
||||
# Google Analytics (如果需要)
|
||||
# NEXT_PUBLIC_GA_ID=G-XXXXXXXXXX
|
||||
|
||||
# Sentry 錯誤監控 (如果需要)
|
||||
# NEXT_PUBLIC_SENTRY_DSN=https://your-sentry-dsn
|
||||
|
||||
# 效能監控
|
||||
NEXT_PUBLIC_PERFORMANCE_MONITORING=false
|
||||
|
||||
# ===========================================
|
||||
# 郵件與通知設定
|
||||
# ===========================================
|
||||
|
||||
# 郵件服務設定 (顯示用)
|
||||
NEXT_PUBLIC_SMTP_ENABLED=true
|
||||
NEXT_PUBLIC_EMAIL_DOMAIN=panjit.com.tw
|
||||
|
||||
# 通知設定
|
||||
NEXT_PUBLIC_PUSH_NOTIFICATIONS=true
|
||||
NEXT_PUBLIC_EMAIL_NOTIFICATIONS=true
|
||||
|
||||
# ===========================================
|
||||
# 檔案與媒體設定
|
||||
# ===========================================
|
||||
|
||||
# 檔案上傳設定
|
||||
NEXT_PUBLIC_MAX_FILE_SIZE=10485760 # 10MB
|
||||
NEXT_PUBLIC_ALLOWED_FILE_TYPES=.xlsx,.xls,.csv
|
||||
|
||||
# 頭像設定
|
||||
NEXT_PUBLIC_AVATAR_MAX_SIZE=2097152 # 2MB
|
||||
NEXT_PUBLIC_AVATAR_ALLOWED_TYPES=.jpg,.jpeg,.png,.gif
|
||||
|
||||
# ===========================================
|
||||
# 快取與效能
|
||||
# ===========================================
|
||||
|
||||
# API 快取設定
|
||||
NEXT_PUBLIC_API_CACHE_ENABLED=true
|
||||
NEXT_PUBLIC_API_CACHE_DURATION=300000 # 5 minutes
|
||||
|
||||
# 靜態資源 CDN (生產環境)
|
||||
# NEXT_PUBLIC_CDN_URL=https://cdn.example.com
|
||||
|
||||
# ===========================================
|
||||
# 本地化設定
|
||||
# ===========================================
|
||||
|
||||
# 語言設定
|
||||
NEXT_PUBLIC_DEFAULT_LOCALE=zh-TW
|
||||
NEXT_PUBLIC_SUPPORTED_LOCALES=zh-TW,zh-CN,en-US
|
||||
|
||||
# 時區設定
|
||||
NEXT_PUBLIC_DEFAULT_TIMEZONE=Asia/Taipei
|
||||
|
||||
# 日期格式
|
||||
NEXT_PUBLIC_DATE_FORMAT=YYYY-MM-DD
|
||||
NEXT_PUBLIC_DATETIME_FORMAT=YYYY-MM-DD HH:mm
|
||||
NEXT_PUBLIC_TIME_FORMAT=HH:mm
|
||||
|
||||
# ===========================================
|
||||
# 開發工具設定
|
||||
# ===========================================
|
||||
|
||||
# 開發模式設定
|
||||
NEXT_PUBLIC_DEV_TOOLS=true
|
||||
NEXT_PUBLIC_MOCK_API=false
|
||||
|
||||
# Redux DevTools
|
||||
NEXT_PUBLIC_REDUX_DEVTOOLS=true
|
||||
|
||||
# React Query DevTools
|
||||
NEXT_PUBLIC_REACT_QUERY_DEVTOOLS=true
|
||||
|
||||
# ===========================================
|
||||
# 安全設定
|
||||
# ===========================================
|
||||
|
||||
# CORS 設定 (僅供參考,實際由後端控制)
|
||||
NEXT_PUBLIC_ALLOWED_ORIGINS=http://localhost:12012,http://localhost:12011
|
||||
|
||||
# CSP 設定提示
|
||||
NEXT_PUBLIC_CSP_ENABLED=false
|
||||
|
||||
# ===========================================
|
||||
# 部署環境特定設定
|
||||
# ===========================================
|
||||
|
||||
# 生產環境設定
|
||||
# NODE_ENV=production
|
||||
# NEXT_PUBLIC_API_URL=https://api.yourdomain.com
|
||||
|
||||
# 測試環境設定
|
||||
# NODE_ENV=staging
|
||||
# NEXT_PUBLIC_API_URL=https://staging-api.yourdomain.com
|
||||
|
||||
# ===========================================
|
||||
# 範例說明
|
||||
# ===========================================
|
||||
|
||||
# 📝 設定指南:
|
||||
# 1. 複製此檔案為 .env.local
|
||||
# 2. 根據您的環境修改對應的值
|
||||
# 3. 確保 .env.local 已加入 .gitignore
|
||||
# 4. 生產環境使用不同的 API 網址和金鑰
|
||||
|
||||
# 🔒 安全提醒:
|
||||
# - 請勿將包含敏感資訊的 .env.local 提交到版本控制
|
||||
# - API 金鑰和密碼應該定期更換
|
||||
# - 生產環境務必使用 HTTPS
|
||||
|
||||
# 🚀 效能優化:
|
||||
# - 生產環境建議啟用 CDN
|
||||
# - 根據需求調整快取設定
|
||||
# - 監控和分析工具可選擇性啟用
|
5
frontend/next-env.d.ts
vendored
Normal file
5
frontend/next-env.d.ts
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/basic-features/typescript for more information.
|
58
frontend/next.config.js
Normal file
58
frontend/next.config.js
Normal file
@@ -0,0 +1,58 @@
|
||||
const crypto = require('crypto')
|
||||
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
reactStrictMode: true,
|
||||
swcMinify: true,
|
||||
output: 'export',
|
||||
trailingSlash: true,
|
||||
images: {
|
||||
unoptimized: true,
|
||||
},
|
||||
env: {
|
||||
NEXT_PUBLIC_API_URL: '',
|
||||
},
|
||||
webpack: (config, { dev, isServer }) => {
|
||||
// 在生產環境中禁用 HMR 相關功能
|
||||
if (!dev && !isServer) {
|
||||
config.optimization = {
|
||||
...config.optimization,
|
||||
splitChunks: {
|
||||
chunks: 'all',
|
||||
cacheGroups: {
|
||||
default: false,
|
||||
vendors: false,
|
||||
framework: {
|
||||
chunks: 'all',
|
||||
name: 'framework',
|
||||
test: /(?<!node_modules.*)[\\/]node_modules[\\/](react|react-dom|scheduler|prop-types|use-subscription)[\\/]/,
|
||||
priority: 40,
|
||||
enforce: true,
|
||||
},
|
||||
lib: {
|
||||
test(module) {
|
||||
return module.size() > 160000 && /node_modules[/\\]/.test(module.identifier())
|
||||
},
|
||||
name(module) {
|
||||
const hash = crypto.createHash('sha1')
|
||||
hash.update(module.identifier())
|
||||
return hash.digest('hex').substring(0, 8)
|
||||
},
|
||||
priority: 30,
|
||||
minChunks: 1,
|
||||
reuseExistingChunk: true,
|
||||
},
|
||||
commons: {
|
||||
name: 'commons',
|
||||
minChunks: 2,
|
||||
priority: 20,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
return config
|
||||
},
|
||||
}
|
||||
|
||||
module.exports = nextConfig
|
7808
frontend/package-lock.json
generated
Normal file
7808
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
46
frontend/package.json
Normal file
46
frontend/package.json
Normal file
@@ -0,0 +1,46 @@
|
||||
{
|
||||
"name": "todo-system-frontend",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev -p 12012",
|
||||
"build": "next build",
|
||||
"start": "next start -p 12012",
|
||||
"lint": "next lint",
|
||||
"type-check": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@emotion/react": "^11.11.3",
|
||||
"@emotion/styled": "^11.11.0",
|
||||
"@mui/icons-material": "^5.15.3",
|
||||
"@mui/material": "^5.15.3",
|
||||
"@mui/x-date-pickers": "^6.19.0",
|
||||
"@reduxjs/toolkit": "^2.0.1",
|
||||
"@tanstack/react-query": "^5.17.9",
|
||||
"axios": "^1.6.5",
|
||||
"dayjs": "^1.11.10",
|
||||
"framer-motion": "^10.18.0",
|
||||
"next": "14.0.4",
|
||||
"next-themes": "^0.2.1",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-hook-form": "^7.48.2",
|
||||
"react-hot-toast": "^2.4.1",
|
||||
"react-redux": "^9.0.4",
|
||||
"recharts": "^2.10.3",
|
||||
"xlsx": "^0.18.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.10.6",
|
||||
"@types/react": "^18.2.46",
|
||||
"@types/react-dom": "^18.2.18",
|
||||
"@typescript-eslint/eslint-plugin": "^6.17.0",
|
||||
"@typescript-eslint/parser": "^6.17.0",
|
||||
"autoprefixer": "^10.4.16",
|
||||
"eslint": "^8.56.0",
|
||||
"eslint-config-next": "14.0.4",
|
||||
"postcss": "^8.4.33",
|
||||
"tailwindcss": "^3.4.0",
|
||||
"typescript": "^5.3.3"
|
||||
}
|
||||
}
|
BIN
frontend/public/panjit-logo.png
Normal file
BIN
frontend/public/panjit-logo.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 6.8 KiB |
182
frontend/src/app/calendar/page.tsx
Normal file
182
frontend/src/app/calendar/page.tsx
Normal file
@@ -0,0 +1,182 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
Paper,
|
||||
Skeleton,
|
||||
Alert,
|
||||
} from '@mui/material';
|
||||
import { motion } from 'framer-motion';
|
||||
import { useTheme } from '@/providers/ThemeProvider';
|
||||
import DashboardLayout from '@/components/layout/DashboardLayout';
|
||||
import CalendarView from '@/components/todos/CalendarView';
|
||||
import { Todo } from '@/types';
|
||||
import { todosApi } from '@/lib/api';
|
||||
|
||||
const CalendarPage: React.FC = () => {
|
||||
const { actualTheme } = useTheme();
|
||||
const [todos, setTodos] = useState<Todo[]>([]);
|
||||
const [selectedTodos, setSelectedTodos] = useState<string[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchTodos = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const token = localStorage.getItem('access_token');
|
||||
if (!token) {
|
||||
setTodos([]);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await todosApi.getTodos({ view: 'all' });
|
||||
setTodos(response.todos || []);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch todos:', error);
|
||||
setError('無法載入待辦事項,請重新整理頁面');
|
||||
setTodos([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchTodos();
|
||||
}, []);
|
||||
|
||||
const handleSelectionChange = (selected: string[]) => {
|
||||
setSelectedTodos(selected);
|
||||
};
|
||||
|
||||
const handleEditTodo = (todo: Todo) => {
|
||||
// TODO: 實作編輯功能,可以開啟編輯對話框或導航到編輯頁面
|
||||
console.log('Edit todo:', todo);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<DashboardLayout>
|
||||
<Box>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
>
|
||||
<Box sx={{ mb: 3 }}>
|
||||
<Typography
|
||||
variant="h4"
|
||||
sx={{
|
||||
fontWeight: 700,
|
||||
mb: 1,
|
||||
background: actualTheme === 'dark'
|
||||
? 'linear-gradient(45deg, #60a5fa 30%, #a78bfa 90%)'
|
||||
: 'linear-gradient(45deg, #3b82f6 30%, #8b5cf6 90%)',
|
||||
WebkitBackgroundClip: 'text',
|
||||
WebkitTextFillColor: 'transparent',
|
||||
backgroundClip: 'text',
|
||||
}}
|
||||
>
|
||||
日曆視圖
|
||||
</Typography>
|
||||
<Typography variant="body1" color="text.secondary">
|
||||
以日曆方式檢視您的待辦事項
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{/* Loading Skeleton */}
|
||||
<Paper
|
||||
sx={{
|
||||
p: 3,
|
||||
backgroundColor: actualTheme === 'dark' ? '#1f2937' : '#ffffff',
|
||||
border: `1px solid ${actualTheme === 'dark'
|
||||
? 'rgba(255, 255, 255, 0.1)'
|
||||
: 'rgba(0, 0, 0, 0.1)'}`,
|
||||
mb: 3,
|
||||
}}
|
||||
>
|
||||
<Skeleton variant="rectangular" height={60} sx={{ mb: 2, borderRadius: 1 }} />
|
||||
</Paper>
|
||||
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(7, 1fr)', gap: 1 }}>
|
||||
{Array.from({ length: 35 }).map((_, index) => (
|
||||
<Skeleton
|
||||
key={index}
|
||||
variant="rectangular"
|
||||
height={120}
|
||||
sx={{ borderRadius: 1 }}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
</motion.div>
|
||||
</Box>
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<DashboardLayout>
|
||||
<Box>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
>
|
||||
<Box sx={{ mb: 3 }}>
|
||||
<Typography
|
||||
variant="h4"
|
||||
sx={{
|
||||
fontWeight: 700,
|
||||
mb: 1,
|
||||
background: actualTheme === 'dark'
|
||||
? 'linear-gradient(45deg, #60a5fa 30%, #a78bfa 90%)'
|
||||
: 'linear-gradient(45deg, #3b82f6 30%, #8b5cf6 90%)',
|
||||
WebkitBackgroundClip: 'text',
|
||||
WebkitTextFillColor: 'transparent',
|
||||
backgroundClip: 'text',
|
||||
}}
|
||||
>
|
||||
日曆視圖
|
||||
</Typography>
|
||||
<Typography variant="body1" color="text.secondary">
|
||||
以日曆方式檢視您的待辦事項,支援月、週、日三種檢視模式
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{error && (
|
||||
<Alert
|
||||
severity="error"
|
||||
sx={{ mb: 3 }}
|
||||
onClose={() => setError(null)}
|
||||
>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{selectedTodos.length > 0 && (
|
||||
<Alert
|
||||
severity="info"
|
||||
sx={{ mb: 3 }}
|
||||
onClose={() => setSelectedTodos([])}
|
||||
>
|
||||
已選擇 {selectedTodos.length} 個待辦事項
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<CalendarView
|
||||
todos={todos}
|
||||
selectedTodos={selectedTodos}
|
||||
onSelectionChange={handleSelectionChange}
|
||||
onEditTodo={handleEditTodo}
|
||||
/>
|
||||
</motion.div>
|
||||
</Box>
|
||||
</DashboardLayout>
|
||||
);
|
||||
};
|
||||
|
||||
export default CalendarPage;
|
579
frontend/src/app/dashboard/page.tsx
Normal file
579
frontend/src/app/dashboard/page.tsx
Normal file
@@ -0,0 +1,579 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Grid,
|
||||
Card,
|
||||
CardContent,
|
||||
Typography,
|
||||
Chip,
|
||||
Button,
|
||||
Avatar,
|
||||
AvatarGroup,
|
||||
IconButton,
|
||||
Skeleton,
|
||||
CircularProgress,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Assignment,
|
||||
Schedule,
|
||||
CheckCircle,
|
||||
Warning,
|
||||
Add,
|
||||
CalendarToday,
|
||||
Star,
|
||||
People,
|
||||
} from '@mui/icons-material';
|
||||
import { motion } from 'framer-motion';
|
||||
import { useTheme } from '@/providers/ThemeProvider';
|
||||
import DashboardLayout from '@/components/layout/DashboardLayout';
|
||||
import TodoDialog from '@/components/todos/TodoDialog';
|
||||
import { todosApi } from '@/lib/api';
|
||||
import { Todo } from '@/types';
|
||||
|
||||
const DashboardPage = () => {
|
||||
const { actualTheme } = useTheme();
|
||||
const [todos, setTodos] = useState<Todo[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [todoDialogOpen, setTodoDialogOpen] = useState(false);
|
||||
|
||||
// 從 API 獲取資料
|
||||
useEffect(() => {
|
||||
const fetchDashboardData = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
// 檢查是否有有效的 token
|
||||
const token = localStorage.getItem('access_token');
|
||||
console.log('Dashboard - Access token:', token ? 'Found' : 'Not found');
|
||||
if (!token) {
|
||||
console.log('Dashboard - No access token found, redirecting to login');
|
||||
setTodos([]);
|
||||
window.location.href = '/login';
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await todosApi.getTodos({ view: 'dashboard' });
|
||||
setTodos(response.todos || []);
|
||||
} catch (error: any) {
|
||||
console.error('Failed to fetch dashboard data:', error);
|
||||
// 如果是認證錯誤,清除 token 並跳轉到登入頁
|
||||
if (error?.response?.status === 401) {
|
||||
localStorage.removeItem('access_token');
|
||||
localStorage.removeItem('refresh_token');
|
||||
localStorage.removeItem('user');
|
||||
window.location.href = '/login';
|
||||
}
|
||||
setTodos([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchDashboardData();
|
||||
}, []);
|
||||
|
||||
const handleTodoCreated = async () => {
|
||||
setTodoDialogOpen(false);
|
||||
// 重新載入待辦事項資料
|
||||
try {
|
||||
console.log('Refreshing dashboard data after todo creation...');
|
||||
const token = localStorage.getItem('access_token');
|
||||
if (token) {
|
||||
const response = await todosApi.getTodos({ view: 'dashboard' });
|
||||
console.log('Updated todos:', response.todos?.length || 0, 'items');
|
||||
setTodos(response.todos || []);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to refresh dashboard data:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// 計算統計數據
|
||||
const stats = {
|
||||
total: todos.length,
|
||||
doing: todos.filter(todo => todo.status === 'DOING').length,
|
||||
completed: todos.filter(todo => todo.status === 'DONE').length,
|
||||
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,
|
||||
};
|
||||
|
||||
// 最近的待辦事項(最多顯示3個)
|
||||
const recentTodos = todos
|
||||
.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime())
|
||||
.slice(0, 3)
|
||||
.map(todo => ({
|
||||
id: todo.id,
|
||||
title: todo.title,
|
||||
dueDate: todo.due_date ? new Date(todo.due_date).toLocaleDateString('zh-TW') : '無截止日期',
|
||||
priority: todo.priority,
|
||||
status: todo.status,
|
||||
assignees: (todo.responsible_users_details || todo.responsible_users || []).map(user =>
|
||||
typeof user === 'string'
|
||||
? user.substring(0, 1).toUpperCase()
|
||||
: (user.display_name || user.ad_account).substring(0, 1).toUpperCase()
|
||||
),
|
||||
}));
|
||||
|
||||
// 即將到期的項目
|
||||
const upcomingDeadlines = todos
|
||||
.filter(todo => {
|
||||
if (!todo.due_date || todo.status === 'DONE') return false;
|
||||
const dueDate = new Date(todo.due_date);
|
||||
const today = new Date();
|
||||
const threeDaysFromNow = new Date();
|
||||
threeDaysFromNow.setDate(today.getDate() + 3);
|
||||
return dueDate >= today && dueDate <= threeDaysFromNow;
|
||||
})
|
||||
.sort((a, b) => new Date(a.due_date!).getTime() - new Date(b.due_date!).getTime())
|
||||
.slice(0, 3)
|
||||
.map(todo => {
|
||||
const dueDate = new Date(todo.due_date!);
|
||||
const today = new Date();
|
||||
const diffTime = dueDate.getTime() - today.getTime();
|
||||
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
|
||||
|
||||
let dateText = '';
|
||||
if (diffDays === 0) dateText = '今天';
|
||||
else if (diffDays === 1) dateText = '明天';
|
||||
else dateText = dueDate.toLocaleDateString('zh-TW');
|
||||
|
||||
return {
|
||||
title: todo.title,
|
||||
date: dateText,
|
||||
urgent: diffDays <= 1,
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
const containerVariants = {
|
||||
hidden: { opacity: 0 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
transition: {
|
||||
staggerChildren: 0.1,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const itemVariants = {
|
||||
hidden: { opacity: 0, y: 20 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
transition: { duration: 0.5 },
|
||||
},
|
||||
};
|
||||
|
||||
const 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';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<DashboardLayout>
|
||||
<motion.div
|
||||
variants={containerVariants}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
>
|
||||
<Box sx={{ mb: 4 }}>
|
||||
<Typography
|
||||
variant="h4"
|
||||
sx={{
|
||||
fontWeight: 700,
|
||||
mb: 1,
|
||||
background: actualTheme === 'dark'
|
||||
? 'linear-gradient(45deg, #f3f4f6 30%, #d1d5db 90%)'
|
||||
: 'linear-gradient(45deg, #111827 30%, #374151 90%)',
|
||||
WebkitBackgroundClip: 'text',
|
||||
WebkitTextFillColor: 'transparent',
|
||||
backgroundClip: 'text',
|
||||
}}
|
||||
>
|
||||
儀表板
|
||||
</Typography>
|
||||
<Typography variant="body1" color="text.secondary">
|
||||
歡迎回來!這裡是您的待辦事項概覽
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{/* 統計卡片 */}
|
||||
{loading ? (
|
||||
<Grid container spacing={3} sx={{ mb: 4 }}>
|
||||
{Array.from({ length: 4 }).map((_, index) => (
|
||||
<Grid item xs={12} sm={6} md={3} key={index}>
|
||||
<motion.div variants={itemVariants}>
|
||||
<Card
|
||||
sx={{
|
||||
background: actualTheme === 'dark'
|
||||
? 'linear-gradient(135deg, #1f2937 0%, #374151 100%)'
|
||||
: 'linear-gradient(135deg, #ffffff 0%, #f9fafb 100%)',
|
||||
border: `1px solid ${actualTheme === 'dark'
|
||||
? 'rgba(255, 255, 255, 0.1)'
|
||||
: 'rgba(0, 0, 0, 0.1)'}`,
|
||||
}}
|
||||
>
|
||||
<CardContent>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<Box>
|
||||
<Skeleton variant="text" width={60} height={20} sx={{ mb: 1 }} />
|
||||
<Skeleton variant="text" width={40} height={40} />
|
||||
</Box>
|
||||
<Skeleton variant="circular" width={40} height={40} />
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
) : (
|
||||
<Grid container spacing={3} sx={{ mb: 4 }}>
|
||||
<Grid item xs={12} sm={6} md={3}>
|
||||
<motion.div variants={itemVariants}>
|
||||
<Card
|
||||
sx={{
|
||||
background: actualTheme === 'dark'
|
||||
? 'linear-gradient(135deg, #1f2937 0%, #374151 100%)'
|
||||
: 'linear-gradient(135deg, #ffffff 0%, #f9fafb 100%)',
|
||||
border: `1px solid ${actualTheme === 'dark'
|
||||
? 'rgba(255, 255, 255, 0.1)'
|
||||
: 'rgba(0, 0, 0, 0.1)'}`,
|
||||
transition: 'transform 0.2s ease, box-shadow 0.2s ease',
|
||||
'&:hover': {
|
||||
transform: 'translateY(-2px)',
|
||||
boxShadow: '0 8px 25px rgba(0, 0, 0, 0.15)',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<CardContent>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<Box>
|
||||
<Typography color="text.secondary" gutterBottom>
|
||||
總待辦
|
||||
</Typography>
|
||||
<Typography variant="h4" sx={{ fontWeight: 700 }}>
|
||||
{stats.total}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Assignment sx={{ fontSize: 40, color: '#6b7280', opacity: 0.8 }} />
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12} sm={6} md={3}>
|
||||
<motion.div variants={itemVariants}>
|
||||
<Card
|
||||
sx={{
|
||||
background: actualTheme === 'dark'
|
||||
? 'linear-gradient(135deg, #1e3a8a 0%, #3b82f6 100%)'
|
||||
: 'linear-gradient(135deg, #3b82f6 0%, #60a5fa 100%)',
|
||||
color: 'white',
|
||||
transition: 'transform 0.2s ease, box-shadow 0.2s ease',
|
||||
'&:hover': {
|
||||
transform: 'translateY(-2px)',
|
||||
boxShadow: '0 8px 25px rgba(59, 130, 246, 0.3)',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<CardContent>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<Box>
|
||||
<Typography sx={{ color: 'rgba(255, 255, 255, 0.8)' }} gutterBottom>
|
||||
進行中
|
||||
</Typography>
|
||||
<Typography variant="h4" sx={{ fontWeight: 700, color: 'white' }}>
|
||||
{stats.doing}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Schedule sx={{ fontSize: 40, color: 'rgba(255, 255, 255, 0.8)' }} />
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12} sm={6} md={3}>
|
||||
<motion.div variants={itemVariants}>
|
||||
<Card
|
||||
sx={{
|
||||
background: actualTheme === 'dark'
|
||||
? 'linear-gradient(135deg, #065f46 0%, #10b981 100%)'
|
||||
: 'linear-gradient(135deg, #10b981 0%, #34d399 100%)',
|
||||
color: 'white',
|
||||
transition: 'transform 0.2s ease, box-shadow 0.2s ease',
|
||||
'&:hover': {
|
||||
transform: 'translateY(-2px)',
|
||||
boxShadow: '0 8px 25px rgba(16, 185, 129, 0.3)',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<CardContent>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<Box>
|
||||
<Typography sx={{ color: 'rgba(255, 255, 255, 0.8)' }} gutterBottom>
|
||||
已完成
|
||||
</Typography>
|
||||
<Typography variant="h4" sx={{ fontWeight: 700, color: 'white' }}>
|
||||
{stats.completed}
|
||||
</Typography>
|
||||
</Box>
|
||||
<CheckCircle sx={{ fontSize: 40, color: 'rgba(255, 255, 255, 0.8)' }} />
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12} sm={6} md={3}>
|
||||
<motion.div variants={itemVariants}>
|
||||
<Card
|
||||
sx={{
|
||||
background: actualTheme === 'dark'
|
||||
? 'linear-gradient(135deg, #991b1b 0%, #ef4444 100%)'
|
||||
: 'linear-gradient(135deg, #ef4444 0%, #f87171 100%)',
|
||||
color: 'white',
|
||||
transition: 'transform 0.2s ease, box-shadow 0.2s ease',
|
||||
'&:hover': {
|
||||
transform: 'translateY(-2px)',
|
||||
boxShadow: '0 8px 25px rgba(239, 68, 68, 0.3)',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<CardContent>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<Box>
|
||||
<Typography sx={{ color: 'rgba(255, 255, 255, 0.8)' }} gutterBottom>
|
||||
已逾期
|
||||
</Typography>
|
||||
<Typography variant="h4" sx={{ fontWeight: 700, color: 'white' }}>
|
||||
{stats.overdue}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Warning sx={{ fontSize: 40, color: 'rgba(255, 255, 255, 0.8)' }} />
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
</Grid>
|
||||
</Grid>
|
||||
)}
|
||||
|
||||
{/* 主要內容區域 */}
|
||||
<Grid container spacing={3}>
|
||||
{/* 最近待辦 */}
|
||||
<Grid item xs={12} lg={8}>
|
||||
<motion.div variants={itemVariants}>
|
||||
<Card
|
||||
sx={{
|
||||
background: actualTheme === 'dark' ? '#1f2937' : '#ffffff',
|
||||
border: `1px solid ${actualTheme === 'dark'
|
||||
? 'rgba(255, 255, 255, 0.1)'
|
||||
: 'rgba(0, 0, 0, 0.1)'}`,
|
||||
}}
|
||||
>
|
||||
<CardContent>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 3 }}>
|
||||
<Typography variant="h6" sx={{ fontWeight: 600 }}>
|
||||
最近待辦
|
||||
</Typography>
|
||||
<Button
|
||||
startIcon={<Add />}
|
||||
variant="contained"
|
||||
size="small"
|
||||
onClick={() => setTodoDialogOpen(true)}
|
||||
sx={{
|
||||
background: 'linear-gradient(45deg, #3b82f6 30%, #8b5cf6 90%)',
|
||||
textTransform: 'none',
|
||||
}}
|
||||
>
|
||||
新增待辦
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
{recentTodos.map((todo, index) => (
|
||||
<motion.div
|
||||
key={todo.id}
|
||||
initial={{ opacity: 0, x: -20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ delay: index * 0.1 }}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
p: 2,
|
||||
borderRadius: 2,
|
||||
backgroundColor: actualTheme === 'dark'
|
||||
? 'rgba(255, 255, 255, 0.05)'
|
||||
: 'rgba(0, 0, 0, 0.02)',
|
||||
border: `1px solid ${actualTheme === 'dark'
|
||||
? 'rgba(255, 255, 255, 0.1)'
|
||||
: 'rgba(0, 0, 0, 0.1)'}`,
|
||||
transition: 'all 0.2s ease',
|
||||
'&:hover': {
|
||||
backgroundColor: actualTheme === 'dark'
|
||||
? 'rgba(255, 255, 255, 0.08)'
|
||||
: 'rgba(0, 0, 0, 0.04)',
|
||||
transform: 'translateX(4px)',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', mb: 1 }}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 600, flex: 1 }}>
|
||||
{todo.title}
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', gap: 1, ml: 2 }}>
|
||||
<Chip
|
||||
label={todo.priority}
|
||||
size="small"
|
||||
sx={{
|
||||
backgroundColor: `${getPriorityColor(todo.priority)}15`,
|
||||
color: getPriorityColor(todo.priority),
|
||||
fontWeight: 600,
|
||||
fontSize: '0.75rem',
|
||||
}}
|
||||
/>
|
||||
<Chip
|
||||
label={todo.status}
|
||||
size="small"
|
||||
sx={{
|
||||
backgroundColor: `${getStatusColor(todo.status)}15`,
|
||||
color: getStatusColor(todo.status),
|
||||
fontWeight: 600,
|
||||
fontSize: '0.75rem',
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<CalendarToday sx={{ fontSize: 16, color: 'text.secondary' }} />
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{todo.dueDate}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<AvatarGroup max={3} sx={{ '& .MuiAvatar-root': { width: 28, height: 28, fontSize: '0.75rem' } }}>
|
||||
{todo.assignees.map((assignee, idx) => (
|
||||
<Avatar key={idx} sx={{ backgroundColor: 'primary.main' }}>
|
||||
{assignee}
|
||||
</Avatar>
|
||||
))}
|
||||
</AvatarGroup>
|
||||
</Box>
|
||||
</Box>
|
||||
</motion.div>
|
||||
))}
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
</Grid>
|
||||
|
||||
{/* 右側面板 */}
|
||||
<Grid item xs={12} lg={4}>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||
|
||||
{/* 即將到期 */}
|
||||
<motion.div variants={itemVariants}>
|
||||
<Card
|
||||
sx={{
|
||||
background: actualTheme === 'dark' ? '#1f2937' : '#ffffff',
|
||||
border: `1px solid ${actualTheme === 'dark'
|
||||
? 'rgba(255, 255, 255, 0.1)'
|
||||
: 'rgba(0, 0, 0, 0.1)'}`,
|
||||
}}
|
||||
>
|
||||
<CardContent>
|
||||
<Typography variant="h6" sx={{ fontWeight: 600, mb: 2 }}>
|
||||
即將到期
|
||||
</Typography>
|
||||
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
{upcomingDeadlines.map((item, index) => (
|
||||
<Box
|
||||
key={index}
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
p: 1.5,
|
||||
borderRadius: 1.5,
|
||||
backgroundColor: item.urgent
|
||||
? (actualTheme === 'dark' ? 'rgba(239, 68, 68, 0.1)' : 'rgba(239, 68, 68, 0.05)')
|
||||
: (actualTheme === 'dark' ? 'rgba(255, 255, 255, 0.05)' : 'rgba(0, 0, 0, 0.02)'),
|
||||
border: item.urgent
|
||||
? `1px solid ${actualTheme === 'dark' ? 'rgba(239, 68, 68, 0.3)' : 'rgba(239, 68, 68, 0.2)'}`
|
||||
: `1px solid transparent`,
|
||||
}}
|
||||
>
|
||||
<Box sx={{ flex: 1 }}>
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{
|
||||
fontWeight: 500,
|
||||
color: item.urgent ? '#ef4444' : 'text.primary',
|
||||
}}
|
||||
>
|
||||
{item.title}
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
color: item.urgent ? '#ef4444' : 'text.secondary',
|
||||
}}
|
||||
>
|
||||
{item.date}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{item.urgent && (
|
||||
<Warning sx={{ color: '#ef4444', fontSize: 20 }} />
|
||||
)}
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
</Box>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</motion.div>
|
||||
|
||||
{/* 新增待辦對話框 */}
|
||||
<TodoDialog
|
||||
open={todoDialogOpen}
|
||||
onClose={() => setTodoDialogOpen(false)}
|
||||
onTodoCreated={handleTodoCreated}
|
||||
/>
|
||||
</DashboardLayout>
|
||||
);
|
||||
};
|
||||
|
||||
export default DashboardPage;
|
207
frontend/src/app/globals.css
Normal file
207
frontend/src/app/globals.css
Normal file
@@ -0,0 +1,207 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
:root {
|
||||
--foreground-rgb: 0, 0, 0;
|
||||
--background-start-rgb: 214, 219, 220;
|
||||
--background-end-rgb: 255, 255, 255;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--foreground-rgb: 255, 255, 255;
|
||||
--background-start-rgb: 0, 0, 0;
|
||||
--background-end-rgb: 0, 0, 0;
|
||||
}
|
||||
}
|
||||
|
||||
body {
|
||||
color: rgb(var(--foreground-rgb));
|
||||
background: linear-gradient(
|
||||
to bottom,
|
||||
transparent,
|
||||
rgb(var(--background-end-rgb))
|
||||
)
|
||||
rgb(var(--background-start-rgb));
|
||||
}
|
||||
|
||||
/* Custom scrollbar */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
@apply bg-gray-100 dark:bg-gray-700;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
@apply bg-gray-400 dark:bg-gray-500 rounded-full;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
@apply bg-gray-500 dark:bg-gray-400;
|
||||
}
|
||||
|
||||
/* Loading animations */
|
||||
.loading-skeleton {
|
||||
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
|
||||
background-size: 200% 100%;
|
||||
animation: loading 1.5s infinite;
|
||||
}
|
||||
|
||||
.dark .loading-skeleton {
|
||||
background: linear-gradient(90deg, #374151 25%, #4b5563 50%, #374151 75%);
|
||||
background-size: 200% 100%;
|
||||
}
|
||||
|
||||
@keyframes loading {
|
||||
0% {
|
||||
background-position: 200% 0;
|
||||
}
|
||||
100% {
|
||||
background-position: -200% 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* Focus rings */
|
||||
.focus-ring {
|
||||
@apply focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 focus-visible:ring-offset-2 dark:focus-visible:ring-offset-gray-800;
|
||||
}
|
||||
|
||||
/* Button variants */
|
||||
.btn-primary {
|
||||
@apply bg-primary-600 hover:bg-primary-700 text-white font-medium py-2 px-4 rounded-lg transition-colors duration-200 focus-ring;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
@apply bg-gray-200 hover:bg-gray-300 dark:bg-gray-700 dark:hover:bg-gray-600 text-gray-900 dark:text-gray-100 font-medium py-2 px-4 rounded-lg transition-colors duration-200 focus-ring;
|
||||
}
|
||||
|
||||
.btn-ghost {
|
||||
@apply hover:bg-gray-100 dark:hover:bg-gray-800 text-gray-700 dark:text-gray-300 font-medium py-2 px-4 rounded-lg transition-colors duration-200 focus-ring;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
@apply bg-red-600 hover:bg-red-700 text-white font-medium py-2 px-4 rounded-lg transition-colors duration-200 focus-ring;
|
||||
}
|
||||
|
||||
/* Card styles */
|
||||
.card {
|
||||
@apply bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-6;
|
||||
}
|
||||
|
||||
.card-compact {
|
||||
@apply bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-4;
|
||||
}
|
||||
|
||||
/* Input styles */
|
||||
.input {
|
||||
@apply w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500 focus-ring;
|
||||
}
|
||||
|
||||
.input-error {
|
||||
@apply border-red-500 focus-visible:ring-red-500;
|
||||
}
|
||||
|
||||
/* Status colors */
|
||||
.status-new {
|
||||
@apply bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300;
|
||||
}
|
||||
|
||||
.status-doing {
|
||||
@apply bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-300;
|
||||
}
|
||||
|
||||
.status-blocked {
|
||||
@apply bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-300;
|
||||
}
|
||||
|
||||
.status-done {
|
||||
@apply bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300;
|
||||
}
|
||||
|
||||
/* Priority colors */
|
||||
.priority-low {
|
||||
@apply bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300;
|
||||
}
|
||||
|
||||
.priority-medium {
|
||||
@apply bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-300;
|
||||
}
|
||||
|
||||
.priority-high {
|
||||
@apply bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-300;
|
||||
}
|
||||
|
||||
.priority-urgent {
|
||||
@apply bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-300;
|
||||
}
|
||||
|
||||
/* Animations */
|
||||
.slide-in-right {
|
||||
animation: slideInRight 0.3s ease-out;
|
||||
}
|
||||
|
||||
.slide-in-left {
|
||||
animation: slideInLeft 0.3s ease-out;
|
||||
}
|
||||
|
||||
@keyframes slideInRight {
|
||||
from {
|
||||
transform: translateX(100%);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateX(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideInLeft {
|
||||
from {
|
||||
transform: translateX(-100%);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateX(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* Custom utilities */
|
||||
.text-balance {
|
||||
text-wrap: balance;
|
||||
}
|
||||
|
||||
.truncate-2 {
|
||||
overflow: hidden;
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
}
|
||||
|
||||
.truncate-3 {
|
||||
overflow: hidden;
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 3;
|
||||
}
|
||||
|
||||
/* Print styles */
|
||||
@media print {
|
||||
.no-print {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
body {
|
||||
background: white !important;
|
||||
color: black !important;
|
||||
}
|
||||
|
||||
.card {
|
||||
border: 1px solid #ccc !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
}
|
43
frontend/src/app/layout.tsx
Normal file
43
frontend/src/app/layout.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import type { Metadata, Viewport } from 'next';
|
||||
import { Inter } from 'next/font/google';
|
||||
import { Providers } from '@/providers';
|
||||
import EnvironmentWrapper from '@/components/EnvironmentWrapper';
|
||||
import './globals.css';
|
||||
|
||||
const inter = Inter({ subsets: ['latin'] });
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'PANJIT To-Do System',
|
||||
description: '專業待辦事項管理系統,支援多負責人協作、智能提醒與進度追蹤',
|
||||
keywords: ['待辦事項', '任務管理', 'PANJIT', '協作工具'],
|
||||
authors: [{ name: 'PANJIT IT Team' }],
|
||||
};
|
||||
|
||||
export const viewport: Viewport = {
|
||||
width: 'device-width',
|
||||
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({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<html lang="zh-TW" suppressHydrationWarning>
|
||||
<body className={inter.className}>
|
||||
<EnvironmentWrapper>
|
||||
<Providers>
|
||||
{children}
|
||||
</Providers>
|
||||
</EnvironmentWrapper>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
358
frontend/src/app/login/page.tsx
Normal file
358
frontend/src/app/login/page.tsx
Normal file
@@ -0,0 +1,358 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import {
|
||||
Box,
|
||||
Card,
|
||||
CardContent,
|
||||
TextField,
|
||||
Button,
|
||||
Typography,
|
||||
InputAdornment,
|
||||
IconButton,
|
||||
Fade,
|
||||
Container,
|
||||
Alert,
|
||||
CircularProgress,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Visibility,
|
||||
VisibilityOff,
|
||||
Person,
|
||||
Lock,
|
||||
} from '@mui/icons-material';
|
||||
import { motion } from 'framer-motion';
|
||||
import { useAuth } from '@/providers/AuthProvider';
|
||||
import { useTheme } from '@/providers/ThemeProvider';
|
||||
|
||||
const LoginPage = () => {
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const { login, isAuthenticated } = useAuth();
|
||||
const { actualTheme } = useTheme();
|
||||
const router = useRouter();
|
||||
|
||||
// 如果已登入,重定向到儀表板
|
||||
useEffect(() => {
|
||||
if (isAuthenticated) {
|
||||
router.push('/dashboard');
|
||||
}
|
||||
}, [isAuthenticated, router]);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!username.trim() || !password.trim()) {
|
||||
setError('請輸入帳號和密碼');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
const success = await login(username.trim(), password);
|
||||
if (success) {
|
||||
router.push('/dashboard');
|
||||
}
|
||||
} catch (err) {
|
||||
setError('登入失敗,請檢查您的帳號密碼');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const cardVariants = {
|
||||
hidden: {
|
||||
opacity: 0,
|
||||
y: 50,
|
||||
scale: 0.95
|
||||
},
|
||||
visible: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
scale: 1,
|
||||
transition: {
|
||||
duration: 0.6,
|
||||
ease: [0.6, -0.05, 0.01, 0.99]
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const logoVariants = {
|
||||
hidden: { opacity: 0, y: -20 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
transition: {
|
||||
delay: 0.2,
|
||||
duration: 0.5
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
minHeight: '100vh',
|
||||
background: actualTheme === 'dark'
|
||||
? 'linear-gradient(135deg, #1a1a2e 0%, #16213e 50%, #0f3460 100%)'
|
||||
: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
p: 2,
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{/* 背景裝飾 */}
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: '-50%',
|
||||
right: '-50%',
|
||||
width: '200%',
|
||||
height: '200%',
|
||||
background: actualTheme === 'dark'
|
||||
? 'radial-gradient(circle, rgba(96, 165, 250, 0.05) 0%, transparent 70%)'
|
||||
: 'radial-gradient(circle, rgba(255, 255, 255, 0.1) 0%, transparent 70%)',
|
||||
animation: 'float 20s ease-in-out infinite',
|
||||
}}
|
||||
/>
|
||||
|
||||
<Container maxWidth="sm">
|
||||
<motion.div
|
||||
variants={cardVariants}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
>
|
||||
<Card
|
||||
elevation={24}
|
||||
sx={{
|
||||
backdropFilter: 'blur(20px)',
|
||||
backgroundColor: actualTheme === 'dark'
|
||||
? 'rgba(31, 41, 55, 0.8)'
|
||||
: 'rgba(255, 255, 255, 0.9)',
|
||||
border: actualTheme === 'dark'
|
||||
? '1px solid rgba(255, 255, 255, 0.1)'
|
||||
: '1px solid rgba(255, 255, 255, 0.2)',
|
||||
borderRadius: 4,
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<CardContent sx={{ p: 6 }}>
|
||||
{/* Logo 區域 */}
|
||||
<motion.div
|
||||
variants={logoVariants}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
>
|
||||
<Box sx={{ textAlign: 'center', mb: 4 }}>
|
||||
<Box
|
||||
component="img"
|
||||
src="/panjit-logo.png"
|
||||
alt="PANJIT Logo"
|
||||
sx={{
|
||||
width: 180,
|
||||
height: 180,
|
||||
mb: 2,
|
||||
filter: 'drop-shadow(0 4px 8px rgba(59, 130, 246, 0.3))'
|
||||
}}
|
||||
/>
|
||||
<Typography
|
||||
variant="h4"
|
||||
component="h1"
|
||||
gutterBottom
|
||||
sx={{
|
||||
fontWeight: 700,
|
||||
background: actualTheme === 'dark'
|
||||
? 'linear-gradient(45deg, #60a5fa 30%, #a78bfa 90%)'
|
||||
: 'linear-gradient(45deg, #3b82f6 30%, #8b5cf6 90%)',
|
||||
WebkitBackgroundClip: 'text',
|
||||
WebkitTextFillColor: 'transparent',
|
||||
backgroundClip: 'text',
|
||||
}}
|
||||
>
|
||||
To-Do
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="body1"
|
||||
color="text.secondary"
|
||||
sx={{
|
||||
opacity: 0.8,
|
||||
fontSize: '1.1rem',
|
||||
}}
|
||||
>
|
||||
專業待辦事項管理系統
|
||||
</Typography>
|
||||
</Box>
|
||||
</motion.div>
|
||||
|
||||
{/* 登入表單 */}
|
||||
<Box component="form" onSubmit={handleSubmit} sx={{ mt: 2 }}>
|
||||
<Fade in={!!error} timeout={300}>
|
||||
<Box sx={{ mb: 2 }}>
|
||||
{error && (
|
||||
<Alert
|
||||
severity="error"
|
||||
sx={{
|
||||
borderRadius: 2,
|
||||
'& .MuiAlert-message': {
|
||||
fontSize: '0.9rem'
|
||||
}
|
||||
}}
|
||||
>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
</Box>
|
||||
</Fade>
|
||||
|
||||
<TextField
|
||||
fullWidth
|
||||
label="AD 帳號"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
margin="normal"
|
||||
disabled={isLoading}
|
||||
autoComplete="username"
|
||||
InputProps={{
|
||||
startAdornment: (
|
||||
<InputAdornment position="start">
|
||||
<Person color="action" />
|
||||
</InputAdornment>
|
||||
),
|
||||
}}
|
||||
sx={{
|
||||
mb: 3,
|
||||
'& .MuiOutlinedInput-root': {
|
||||
borderRadius: 2,
|
||||
transition: 'all 0.3s ease',
|
||||
'&:hover': {
|
||||
transform: 'translateY(-2px)',
|
||||
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.1)',
|
||||
},
|
||||
'&.Mui-focused': {
|
||||
transform: 'translateY(-2px)',
|
||||
boxShadow: '0 4px 20px rgba(59, 130, 246, 0.2)',
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<TextField
|
||||
fullWidth
|
||||
label="密碼"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
margin="normal"
|
||||
disabled={isLoading}
|
||||
autoComplete="current-password"
|
||||
InputProps={{
|
||||
startAdornment: (
|
||||
<InputAdornment position="start">
|
||||
<Lock color="action" />
|
||||
</InputAdornment>
|
||||
),
|
||||
endAdornment: (
|
||||
<InputAdornment position="end">
|
||||
<IconButton
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
edge="end"
|
||||
disabled={isLoading}
|
||||
size="small"
|
||||
>
|
||||
{showPassword ? <VisibilityOff /> : <Visibility />}
|
||||
</IconButton>
|
||||
</InputAdornment>
|
||||
),
|
||||
}}
|
||||
sx={{
|
||||
mb: 4,
|
||||
'& .MuiOutlinedInput-root': {
|
||||
borderRadius: 2,
|
||||
transition: 'all 0.3s ease',
|
||||
'&:hover': {
|
||||
transform: 'translateY(-2px)',
|
||||
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.1)',
|
||||
},
|
||||
'&.Mui-focused': {
|
||||
transform: 'translateY(-2px)',
|
||||
boxShadow: '0 4px 20px rgba(59, 130, 246, 0.2)',
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
fullWidth
|
||||
variant="contained"
|
||||
disabled={isLoading}
|
||||
sx={{
|
||||
py: 2,
|
||||
fontSize: '1.1rem',
|
||||
fontWeight: 600,
|
||||
borderRadius: 2,
|
||||
textTransform: 'none',
|
||||
background: 'linear-gradient(45deg, #3b82f6 30%, #8b5cf6 90%)',
|
||||
boxShadow: '0 4px 20px rgba(59, 130, 246, 0.3)',
|
||||
transition: 'all 0.3s ease',
|
||||
'&:hover': {
|
||||
background: 'linear-gradient(45deg, #2563eb 30%, #7c3aed 90%)',
|
||||
boxShadow: '0 6px 25px rgba(59, 130, 246, 0.4)',
|
||||
transform: 'translateY(-2px)',
|
||||
},
|
||||
'&:disabled': {
|
||||
background: 'linear-gradient(45deg, #9ca3af 30%, #9ca3af 90%)',
|
||||
}
|
||||
}}
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<CircularProgress size={24} sx={{ mr: 2, color: 'white' }} />
|
||||
登入中...
|
||||
</>
|
||||
) : (
|
||||
'登入系統'
|
||||
)}
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
{/* 底部資訊 */}
|
||||
<Box sx={{ mt: 4, textAlign: 'center' }}>
|
||||
<Typography
|
||||
variant="caption"
|
||||
color="text.secondary"
|
||||
sx={{
|
||||
opacity: 0.7,
|
||||
fontSize: '0.8rem'
|
||||
}}
|
||||
>
|
||||
使用您的 AD 帳號登入 • 支援企業單一登入
|
||||
</Typography>
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
</Container>
|
||||
|
||||
<style jsx global>{`
|
||||
@keyframes float {
|
||||
0%, 100% { transform: translateY(0px) rotate(0deg); }
|
||||
33% { transform: translateY(-30px) rotate(120deg); }
|
||||
66% { transform: translateY(-20px) rotate(240deg); }
|
||||
}
|
||||
`}</style>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default LoginPage;
|
41
frontend/src/app/page.tsx
Normal file
41
frontend/src/app/page.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Box, CircularProgress, Typography } from '@mui/material';
|
||||
|
||||
export default function HomePage() {
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
// 檢查是否已登入
|
||||
const token = localStorage.getItem('access_token');
|
||||
|
||||
if (token) {
|
||||
// 如果已登入,跳轉到 dashboard
|
||||
router.replace('/dashboard');
|
||||
} else {
|
||||
// 如果未登入,跳轉到登入頁面
|
||||
router.replace('/login');
|
||||
}
|
||||
}, [router]);
|
||||
|
||||
// 顯示載入中的畫面
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
minHeight: '100vh',
|
||||
gap: 2,
|
||||
}}
|
||||
>
|
||||
<CircularProgress size={60} />
|
||||
<Typography variant="h6" color="text.secondary">
|
||||
正在載入 PANJIT Todo List...
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
}
|
372
frontend/src/app/public/page.tsx
Normal file
372
frontend/src/app/public/page.tsx
Normal file
@@ -0,0 +1,372 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Container,
|
||||
Typography,
|
||||
Card,
|
||||
CardContent,
|
||||
Chip,
|
||||
IconButton,
|
||||
Button,
|
||||
TextField,
|
||||
InputAdornment,
|
||||
Grid,
|
||||
Skeleton,
|
||||
Alert,
|
||||
Tooltip,
|
||||
Badge,
|
||||
Avatar,
|
||||
AvatarGroup,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Search,
|
||||
Public as PublicIcon,
|
||||
PersonAdd,
|
||||
PersonRemove,
|
||||
Star,
|
||||
StarBorder,
|
||||
FilterList,
|
||||
Refresh,
|
||||
Visibility,
|
||||
} from '@mui/icons-material';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { todosApi } from '@/lib/api';
|
||||
import { Todo, TodoFilter } from '@/types';
|
||||
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();
|
||||
const [todos, setTodos] = useState<Todo[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [filters, setFilters] = useState<TodoFilter>({});
|
||||
const [showFilters, setShowFilters] = useState(false);
|
||||
const [selectedTodo, setSelectedTodo] = useState<Todo | null>(null);
|
||||
const [showTodoDialog, setShowTodoDialog] = useState(false);
|
||||
const [followingTodos, setFollowingTodos] = useState<Set<string>>(new Set());
|
||||
|
||||
useEffect(() => {
|
||||
fetchPublicTodos();
|
||||
fetchFollowingStatus();
|
||||
}, [filters, searchTerm]);
|
||||
|
||||
const fetchPublicTodos = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await todosApi.getPublicTodos({
|
||||
...filters,
|
||||
search: searchTerm,
|
||||
});
|
||||
setTodos(response.todos);
|
||||
} catch (error) {
|
||||
toast.error('載入公開任務失敗');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchFollowingStatus = async () => {
|
||||
try {
|
||||
const response = await todosApi.getFollowingTodos();
|
||||
const followingIds = new Set(response.todos.map(t => t.id));
|
||||
setFollowingTodos(followingIds);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch following status:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleFollow = async (todo: Todo) => {
|
||||
try {
|
||||
const isFollowing = followingTodos.has(todo.id);
|
||||
|
||||
if (isFollowing) {
|
||||
await todosApi.unfollowTodo(todo.id);
|
||||
setFollowingTodos(prev => {
|
||||
const newSet = new Set(prev);
|
||||
newSet.delete(todo.id);
|
||||
return newSet;
|
||||
});
|
||||
toast.success('已取消追蹤');
|
||||
} else {
|
||||
await todosApi.followTodo(todo.id);
|
||||
setFollowingTodos(prev => {
|
||||
const newSet = new Set(prev);
|
||||
newSet.add(todo.id);
|
||||
return newSet;
|
||||
});
|
||||
toast.success('已開始追蹤');
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error('操作失敗');
|
||||
}
|
||||
};
|
||||
|
||||
const handleViewTodo = (todo: Todo) => {
|
||||
setSelectedTodo(todo);
|
||||
setShowTodoDialog(true);
|
||||
};
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
const colors = {
|
||||
NEW: 'default',
|
||||
DOING: 'primary',
|
||||
BLOCKED: 'error',
|
||||
DONE: 'success',
|
||||
};
|
||||
return colors[status as keyof typeof colors] || 'default';
|
||||
};
|
||||
|
||||
const getPriorityColor = (priority: string) => {
|
||||
const colors = {
|
||||
LOW: 'default',
|
||||
MEDIUM: 'info',
|
||||
HIGH: 'warning',
|
||||
URGENT: 'error',
|
||||
};
|
||||
return colors[priority as keyof typeof colors] || 'default';
|
||||
};
|
||||
|
||||
return (
|
||||
<DashboardLayout>
|
||||
<Container maxWidth="xl" sx={{ py: 3 }}>
|
||||
{/* Header */}
|
||||
<Box sx={{ mb: 3, display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||
<PublicIcon sx={{ fontSize: 32, color: 'primary.main' }} />
|
||||
<Typography variant="h4" sx={{ flexGrow: 1 }}>
|
||||
公開任務
|
||||
</Typography>
|
||||
<Button
|
||||
variant="outlined"
|
||||
startIcon={<Refresh />}
|
||||
onClick={fetchPublicTodos}
|
||||
>
|
||||
重新整理
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
{/* Search and Filters */}
|
||||
<Card sx={{ mb: 3 }}>
|
||||
<CardContent>
|
||||
<Box sx={{ display: 'flex', gap: 2, mb: 2 }}>
|
||||
<TextField
|
||||
fullWidth
|
||||
placeholder="搜尋公開任務..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
InputProps={{
|
||||
startAdornment: (
|
||||
<InputAdornment position="start">
|
||||
<Search />
|
||||
</InputAdornment>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
variant="outlined"
|
||||
startIcon={<FilterList />}
|
||||
onClick={() => setShowFilters(!showFilters)}
|
||||
>
|
||||
篩選
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
{showFilters && (
|
||||
<TodoFilters
|
||||
onClose={() => setShowFilters(false)}
|
||||
onApply={setFilters}
|
||||
initialFilters={filters}
|
||||
/>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Todos List */}
|
||||
{loading ? (
|
||||
<Grid container spacing={2}>
|
||||
{[1, 2, 3].map((i) => (
|
||||
<Grid item xs={12} md={6} lg={4} key={i}>
|
||||
<Skeleton variant="rectangular" height={200} />
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
) : todos.length === 0 ? (
|
||||
<Alert severity="info">目前沒有公開任務</Alert>
|
||||
) : (
|
||||
<Grid container spacing={2}>
|
||||
{todos.map((todo) => (
|
||||
<Grid item xs={12} md={6} lg={4} key={todo.id}>
|
||||
<Card
|
||||
sx={{
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
transition: 'transform 0.2s',
|
||||
'&:hover': {
|
||||
transform: 'translateY(-4px)',
|
||||
boxShadow: 3,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<CardContent sx={{ flexGrow: 1 }}>
|
||||
{/* Title and Status */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'flex-start', mb: 2 }}>
|
||||
<Box sx={{ flexGrow: 1 }}>
|
||||
<Typography
|
||||
variant="h6"
|
||||
sx={{
|
||||
cursor: 'pointer',
|
||||
'&:hover': { color: 'primary.main' },
|
||||
}}
|
||||
onClick={() => handleViewTodo(todo)}
|
||||
>
|
||||
{todo.starred && <Star sx={{ fontSize: 18, color: '#fbbf24', mr: 0.5 }} />}
|
||||
{todo.title}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
建立者: {todo.creator_display_name || todo.creator_ad}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Tooltip title={followingTodos.has(todo.id) ? '取消追蹤' : '追蹤'}>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => handleToggleFollow(todo)}
|
||||
color={followingTodos.has(todo.id) ? 'primary' : 'default'}
|
||||
>
|
||||
{followingTodos.has(todo.id) ? <PersonRemove /> : <PersonAdd />}
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
|
||||
{/* Description */}
|
||||
{todo.description && (
|
||||
<Typography
|
||||
variant="body2"
|
||||
color="text.secondary"
|
||||
sx={{
|
||||
mb: 2,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
display: '-webkit-box',
|
||||
WebkitLineClamp: 2,
|
||||
WebkitBoxOrient: 'vertical',
|
||||
}}
|
||||
>
|
||||
{todo.description}
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
{/* Chips */}
|
||||
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap', mb: 2 }}>
|
||||
<Chip
|
||||
label={todo.status}
|
||||
size="small"
|
||||
color={getStatusColor(todo.status) as any}
|
||||
/>
|
||||
<Chip
|
||||
label={todo.priority}
|
||||
size="small"
|
||||
color={getPriorityColor(todo.priority) as any}
|
||||
variant="outlined"
|
||||
/>
|
||||
{todo.due_date && (
|
||||
<Chip
|
||||
label={`到期: ${new Date(todo.due_date).toLocaleDateString()}`}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Tags */}
|
||||
{todo.tags && todo.tags.length > 0 && (
|
||||
<Box sx={{ display: 'flex', gap: 0.5, flexWrap: 'wrap', mb: 2 }}>
|
||||
{todo.tags.map((tag, index) => (
|
||||
<Chip
|
||||
key={index}
|
||||
label={tag}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
sx={{ borderRadius: 1 }}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Followers */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
追蹤者:
|
||||
</Typography>
|
||||
{todo.followers.length > 0 ? (
|
||||
<AvatarGroup max={3} sx={{ '& .MuiAvatar-root': { width: 24, height: 24, fontSize: 12 } }}>
|
||||
{todo.followers_details?.map((follower) => (
|
||||
<Tooltip key={follower.ad_account} title={follower.display_name}>
|
||||
<Avatar sx={{ bgcolor: 'primary.main' }}>
|
||||
{follower.display_name.charAt(0)}
|
||||
</Avatar>
|
||||
</Tooltip>
|
||||
))}
|
||||
</AvatarGroup>
|
||||
) : (
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
無
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
</CardContent>
|
||||
|
||||
{/* Actions */}
|
||||
<Box sx={{ p: 2, pt: 0, display: 'flex', gap: 1 }}>
|
||||
<Button
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
startIcon={<Visibility />}
|
||||
onClick={() => handleViewTodo(todo)}
|
||||
>
|
||||
查看詳情
|
||||
</Button>
|
||||
</Box>
|
||||
</Card>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
)}
|
||||
|
||||
{/* Todo Dialog */}
|
||||
{selectedTodo && (
|
||||
<TodoDialog
|
||||
open={showTodoDialog}
|
||||
onClose={() => {
|
||||
setShowTodoDialog(false);
|
||||
setSelectedTodo(null);
|
||||
}}
|
||||
todo={{
|
||||
id: selectedTodo.id,
|
||||
title: selectedTodo.title,
|
||||
description: selectedTodo.description,
|
||||
status: selectedTodo.status,
|
||||
priority: selectedTodo.priority,
|
||||
dueDate: selectedTodo.due_date ? dayjs(selectedTodo.due_date) : null,
|
||||
starred: selectedTodo.starred,
|
||||
responsible: selectedTodo.responsible_users_details?.map(u => ({
|
||||
id: typeof u === 'string' ? u : u.ad_account,
|
||||
name: typeof u === 'string' ? u : u.display_name || u.ad_account,
|
||||
email: typeof u === 'string' ? '' : u.email || '',
|
||||
avatar: '',
|
||||
})) || [],
|
||||
tags: selectedTodo.tags || [],
|
||||
isPublic: selectedTodo.is_public,
|
||||
}}
|
||||
mode="edit"
|
||||
/>
|
||||
)}
|
||||
</Container>
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
264
frontend/src/app/settings/page.tsx
Normal file
264
frontend/src/app/settings/page.tsx
Normal file
@@ -0,0 +1,264 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
Card,
|
||||
CardContent,
|
||||
Switch,
|
||||
FormControlLabel,
|
||||
Button,
|
||||
Divider,
|
||||
Grid,
|
||||
Alert,
|
||||
Snackbar,
|
||||
Slider,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Notifications,
|
||||
Save,
|
||||
VolumeUp,
|
||||
Email,
|
||||
Sms,
|
||||
Refresh,
|
||||
} from '@mui/icons-material';
|
||||
import { motion } from 'framer-motion';
|
||||
import { useTheme } from '@/providers/ThemeProvider';
|
||||
import DashboardLayout from '@/components/layout/DashboardLayout';
|
||||
|
||||
const SettingsPage = () => {
|
||||
const { actualTheme } = useTheme();
|
||||
const [showSuccess, setShowSuccess] = useState(false);
|
||||
|
||||
|
||||
// 郵件通知設定
|
||||
const [notificationSettings, setNotificationSettings] = useState({
|
||||
emailNotifications: true,
|
||||
todoReminders: true,
|
||||
deadlineAlerts: true,
|
||||
weeklyReports: true,
|
||||
soundEnabled: true,
|
||||
soundVolume: 70,
|
||||
});
|
||||
|
||||
|
||||
|
||||
const handleSave = () => {
|
||||
console.log('郵件通知設定已儲存:', notificationSettings);
|
||||
setShowSuccess(true);
|
||||
};
|
||||
|
||||
|
||||
|
||||
|
||||
const renderNotificationSettings = () => (
|
||||
<Card sx={{
|
||||
backgroundColor: actualTheme === 'dark' ? '#1f2937' : '#ffffff',
|
||||
border: `1px solid ${actualTheme === 'dark' ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)'}`,
|
||||
}}>
|
||||
<CardContent sx={{ p: 3 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 3 }}>
|
||||
<Notifications sx={{ color: 'primary.main', fontSize: 24 }} />
|
||||
<Typography variant="h6" sx={{ fontWeight: 600 }}>
|
||||
郵件通知設定
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Grid container spacing={3}>
|
||||
<Grid item xs={12}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 2, color: 'text.secondary' }}>
|
||||
通知方式
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={notificationSettings.emailNotifications}
|
||||
onChange={(e) => setNotificationSettings(prev => ({ ...prev, emailNotifications: e.target.checked }))}
|
||||
color="primary"
|
||||
/>
|
||||
}
|
||||
label={
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Email sx={{ fontSize: 18, color: 'text.secondary' }} />
|
||||
電子信箱通知
|
||||
</Box>
|
||||
}
|
||||
/>
|
||||
</Box>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12}>
|
||||
<Divider sx={{ my: 2 }} />
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 2, color: 'text.secondary' }}>
|
||||
通知內容
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={notificationSettings.todoReminders}
|
||||
onChange={(e) => setNotificationSettings(prev => ({ ...prev, todoReminders: e.target.checked }))}
|
||||
color="primary"
|
||||
/>
|
||||
}
|
||||
label="待辦事項提醒"
|
||||
/>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={notificationSettings.deadlineAlerts}
|
||||
onChange={(e) => setNotificationSettings(prev => ({ ...prev, deadlineAlerts: e.target.checked }))}
|
||||
color="primary"
|
||||
/>
|
||||
}
|
||||
label="截止日期警告"
|
||||
/>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={notificationSettings.weeklyReports}
|
||||
onChange={(e) => setNotificationSettings(prev => ({ ...prev, weeklyReports: e.target.checked }))}
|
||||
color="primary"
|
||||
/>
|
||||
}
|
||||
label="每週報告"
|
||||
/>
|
||||
</Box>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12}>
|
||||
<Divider sx={{ my: 2 }} />
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 2, color: 'text.secondary' }}>
|
||||
聲音設定
|
||||
</Typography>
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={notificationSettings.soundEnabled}
|
||||
onChange={(e) => setNotificationSettings(prev => ({ ...prev, soundEnabled: e.target.checked }))}
|
||||
color="primary"
|
||||
/>
|
||||
}
|
||||
label={
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<VolumeUp sx={{ fontSize: 18, color: 'text.secondary' }} />
|
||||
啟用通知聲音
|
||||
</Box>
|
||||
}
|
||||
/>
|
||||
</Box>
|
||||
{notificationSettings.soundEnabled && (
|
||||
<Box sx={{ px: 2, mb: 2 }}>
|
||||
<Typography variant="caption" color="text.secondary" gutterBottom>
|
||||
音量大小: {notificationSettings.soundVolume}%
|
||||
</Typography>
|
||||
<Slider
|
||||
value={notificationSettings.soundVolume}
|
||||
onChange={(_, value) => setNotificationSettings(prev => ({ ...prev, soundVolume: value as number }))}
|
||||
min={0}
|
||||
max={100}
|
||||
step={10}
|
||||
marks
|
||||
sx={{ color: 'primary.main' }}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
</Grid>
|
||||
</Grid>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
|
||||
return (
|
||||
<DashboardLayout>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
>
|
||||
{/* 標題區域 */}
|
||||
<Box sx={{ mb: 3 }}>
|
||||
<Typography
|
||||
variant="h4"
|
||||
sx={{
|
||||
fontWeight: 700,
|
||||
mb: 0.5,
|
||||
background: actualTheme === 'dark'
|
||||
? 'linear-gradient(45deg, #f3f4f6 30%, #d1d5db 90%)'
|
||||
: 'linear-gradient(45deg, #111827 30%, #374151 90%)',
|
||||
WebkitBackgroundClip: 'text',
|
||||
WebkitTextFillColor: 'transparent',
|
||||
backgroundClip: 'text',
|
||||
}}
|
||||
>
|
||||
郵件通知設定
|
||||
</Typography>
|
||||
<Typography variant="body1" color="text.secondary">
|
||||
管理您的郵件通知偏好設定
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{/* 設定內容 */}
|
||||
{renderNotificationSettings()}
|
||||
|
||||
{/* 儲存按鈕 */}
|
||||
<Box sx={{ display: 'flex', justifyContent: 'flex-end', gap: 2, mt: 3 }}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
startIcon={<Refresh />}
|
||||
sx={{
|
||||
borderRadius: 2,
|
||||
textTransform: 'none',
|
||||
fontWeight: 600,
|
||||
px: 3,
|
||||
}}
|
||||
>
|
||||
重置
|
||||
</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={<Save />}
|
||||
onClick={handleSave}
|
||||
sx={{
|
||||
background: 'linear-gradient(45deg, #3b82f6 30%, #8b5cf6 90%)',
|
||||
borderRadius: 2,
|
||||
textTransform: 'none',
|
||||
fontWeight: 600,
|
||||
px: 3,
|
||||
'&:hover': {
|
||||
background: 'linear-gradient(45deg, #2563eb 30%, #7c3aed 90%)',
|
||||
boxShadow: '0 4px 12px rgba(59, 130, 246, 0.3)',
|
||||
},
|
||||
}}
|
||||
>
|
||||
儲存變更
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
{/* 成功通知 */}
|
||||
<Snackbar
|
||||
open={showSuccess}
|
||||
autoHideDuration={3000}
|
||||
onClose={() => setShowSuccess(false)}
|
||||
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
|
||||
>
|
||||
<Alert
|
||||
severity="success"
|
||||
onClose={() => setShowSuccess(false)}
|
||||
sx={{
|
||||
borderRadius: 2,
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
郵件通知設定已成功儲存!
|
||||
</Alert>
|
||||
</Snackbar>
|
||||
</motion.div>
|
||||
</DashboardLayout>
|
||||
);
|
||||
};
|
||||
|
||||
export default SettingsPage;
|
806
frontend/src/app/todos/page.tsx
Normal file
806
frontend/src/app/todos/page.tsx
Normal file
@@ -0,0 +1,806 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
Button,
|
||||
IconButton,
|
||||
Toolbar,
|
||||
Tooltip,
|
||||
Fade,
|
||||
Chip,
|
||||
Card,
|
||||
Skeleton,
|
||||
CircularProgress,
|
||||
Backdrop,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Add,
|
||||
ViewList,
|
||||
CalendarViewMonth,
|
||||
FilterList,
|
||||
Search,
|
||||
SelectAll,
|
||||
CloudUpload,
|
||||
} from '@mui/icons-material';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { useTheme } from '@/providers/ThemeProvider';
|
||||
import DashboardLayout from '@/components/layout/DashboardLayout';
|
||||
import TodoList from '@/components/todos/TodoList';
|
||||
import CalendarView from '@/components/todos/CalendarView';
|
||||
import TodoFilters from '@/components/todos/TodoFilters';
|
||||
import BatchActions from '@/components/todos/BatchActions';
|
||||
import SearchBar from '@/components/todos/SearchBar';
|
||||
import TodoDialog from '@/components/todos/TodoDialog';
|
||||
import ExcelImport from '@/components/todos/ExcelImport';
|
||||
import { Todo } from '@/types';
|
||||
import { todosApi, authApi } from '@/lib/api';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
|
||||
type ViewMode = 'list' | 'calendar';
|
||||
type FilterMode = 'all' | 'created' | 'responsible' | 'following';
|
||||
|
||||
const TodosPage = () => {
|
||||
const { actualTheme } = useTheme();
|
||||
const searchParams = useSearchParams();
|
||||
const [viewMode, setViewMode] = useState<ViewMode>('list');
|
||||
const [filterMode, setFilterMode] = useState<FilterMode>('all');
|
||||
const [showFilters, setShowFilters] = useState(false);
|
||||
const [appliedFilters, setAppliedFilters] = useState({
|
||||
status: [] as string[],
|
||||
priority: [] as string[],
|
||||
assignee: '',
|
||||
dateFrom: null as any,
|
||||
dateTo: null as any,
|
||||
starred: false,
|
||||
overdue: false,
|
||||
dueSoon: false,
|
||||
});
|
||||
const [showSearch, setShowSearch] = useState(false);
|
||||
const [selectedTodos, setSelectedTodos] = useState<string[]>([]);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [showTodoDialog, setShowTodoDialog] = useState(false);
|
||||
const [editingTodo, setEditingTodo] = useState<any>(null);
|
||||
const [showExcelImport, setShowExcelImport] = useState(false);
|
||||
const [todos, setTodos] = useState<Todo[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [currentUser, setCurrentUser] = useState<any>(null);
|
||||
|
||||
// 讀取 URL 參數並設定篩選條件
|
||||
useEffect(() => {
|
||||
console.log('URL search params:', searchParams.toString());
|
||||
|
||||
// 當從 Sidebar 點擊時,應該清除所有其他篩選,只保留當前篩選
|
||||
const viewParam = searchParams.get('view');
|
||||
const statusParam = searchParams.get('status');
|
||||
const starredParam = searchParams.get('starred');
|
||||
|
||||
// 重置所有篩選狀態
|
||||
setFilterMode('all');
|
||||
setAppliedFilters({
|
||||
status: [],
|
||||
priority: [],
|
||||
assignee: '',
|
||||
dateFrom: null,
|
||||
dateTo: null,
|
||||
starred: false,
|
||||
overdue: false,
|
||||
dueSoon: false,
|
||||
});
|
||||
|
||||
// 根據 URL 參數設定對應的篩選
|
||||
if (viewParam && ['created', 'responsible', 'following'].includes(viewParam)) {
|
||||
setFilterMode(viewParam as FilterMode);
|
||||
console.log('Setting filterMode to:', viewParam);
|
||||
} else if (statusParam) {
|
||||
// 狀態篩選:清除視圖篩選,只保留狀態篩選
|
||||
setAppliedFilters(prev => ({
|
||||
...prev,
|
||||
status: [statusParam]
|
||||
}));
|
||||
console.log('Setting status filter to:', statusParam);
|
||||
} else if (starredParam === 'true') {
|
||||
// 星標篩選:清除其他篩選,只保留星標篩選
|
||||
setAppliedFilters(prev => ({
|
||||
...prev,
|
||||
starred: true
|
||||
}));
|
||||
console.log('Setting starred filter to: true');
|
||||
}
|
||||
}, [searchParams]);
|
||||
|
||||
// 從 API 獲取資料
|
||||
useEffect(() => {
|
||||
const fetchTodos = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
// 檢查是否有有效的 token
|
||||
const token = localStorage.getItem('access_token');
|
||||
console.log('Access token:', token ? 'Found' : 'Not found');
|
||||
if (!token) {
|
||||
console.log('No access token found, redirecting to login');
|
||||
setTodos([]);
|
||||
window.location.href = '/login';
|
||||
return;
|
||||
}
|
||||
|
||||
// 獲取當前用戶信息
|
||||
try {
|
||||
const userData = await authApi.getCurrentUser();
|
||||
setCurrentUser(userData);
|
||||
} catch (userError) {
|
||||
console.warn('Failed to fetch user data:', userError);
|
||||
}
|
||||
|
||||
// 獲取待辦事項
|
||||
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: any) {
|
||||
console.error('Failed to fetch todos:', error);
|
||||
// 如果是認證錯誤,清除 token 並跳轉到登入頁
|
||||
if (error?.response?.status === 401) {
|
||||
localStorage.removeItem('access_token');
|
||||
localStorage.removeItem('refresh_token');
|
||||
localStorage.removeItem('user');
|
||||
window.location.href = '/login';
|
||||
}
|
||||
setTodos([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchTodos();
|
||||
}, [filterMode]);
|
||||
|
||||
const containerVariants = {
|
||||
hidden: { opacity: 0 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
transition: {
|
||||
staggerChildren: 0.1,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const itemVariants = {
|
||||
hidden: { opacity: 0, y: 20 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
transition: { duration: 0.5 },
|
||||
},
|
||||
};
|
||||
|
||||
const filteredTodos = todos.filter(todo => {
|
||||
// 搜尋過濾
|
||||
if (searchQuery) {
|
||||
const query = searchQuery.toLowerCase();
|
||||
if (!todo.title.toLowerCase().includes(query) &&
|
||||
!todo.description?.toLowerCase().includes(query)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// 視圖過濾 - 修正:這裡應該是篩選而非直接返回
|
||||
if (currentUser) {
|
||||
switch (filterMode) {
|
||||
case 'created':
|
||||
if (todo.creator_ad !== currentUser.ad_account) return false;
|
||||
break;
|
||||
case 'responsible':
|
||||
if (!todo.responsible_users?.includes(currentUser.ad_account)) return false;
|
||||
break;
|
||||
case 'following':
|
||||
if (!todo.followers?.includes(currentUser.ad_account)) return false;
|
||||
break;
|
||||
default:
|
||||
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;
|
||||
}
|
||||
|
||||
// 指派人篩選
|
||||
if (appliedFilters.assignee && currentUser) {
|
||||
switch (appliedFilters.assignee) {
|
||||
case 'me':
|
||||
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) {
|
||||
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)) {
|
||||
console.log(`Todo ${todo.title} filtered out: not followed by me`);
|
||||
return false;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// 日期篩選
|
||||
if (appliedFilters.dateFrom || appliedFilters.dateTo) {
|
||||
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)) {
|
||||
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) {
|
||||
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') {
|
||||
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') {
|
||||
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) {
|
||||
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 '我建立的';
|
||||
case 'responsible': return '指派給我';
|
||||
case 'following': return '我追蹤的';
|
||||
default: return '所有待辦';
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectAll = () => {
|
||||
if (selectedTodos.length === filteredTodos.length) {
|
||||
setSelectedTodos([]);
|
||||
} else {
|
||||
setSelectedTodos(filteredTodos.map(todo => todo.id));
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateTodo = () => {
|
||||
setEditingTodo(null);
|
||||
setShowTodoDialog(true);
|
||||
};
|
||||
|
||||
const handleEditTodo = (todo: any) => {
|
||||
setEditingTodo(todo);
|
||||
setShowTodoDialog(true);
|
||||
};
|
||||
|
||||
const handleSaveTodo = (todoData: any) => {
|
||||
console.log('Saving todo:', todoData);
|
||||
// 這裡會調用 API 來儲存待辦事項
|
||||
// 儲存成功後可以更新 todos 列表
|
||||
};
|
||||
|
||||
const handleCloseTodoDialog = () => {
|
||||
setShowTodoDialog(false);
|
||||
setEditingTodo(null);
|
||||
};
|
||||
|
||||
const handleTodoCreated = async () => {
|
||||
// 刷新待辦事項列表
|
||||
try {
|
||||
const response = await todosApi.getTodos({
|
||||
view: filterMode === 'all' ? 'all' : filterMode
|
||||
});
|
||||
setTodos(response.todos || []);
|
||||
} catch (error) {
|
||||
console.error('Failed to refresh todos:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// 批次操作處理函數
|
||||
const handleBulkStatusChange = async (status: 'NEW' | 'DOING' | 'BLOCKED') => {
|
||||
try {
|
||||
if (selectedTodos.length === 0) return;
|
||||
|
||||
// 使用批次更新 API
|
||||
await todosApi.batchUpdateTodos(selectedTodos, { status });
|
||||
|
||||
// 更新本地狀態
|
||||
setTodos(prevTodos =>
|
||||
prevTodos.map(todo =>
|
||||
selectedTodos.includes(todo.id)
|
||||
? { ...todo, status }
|
||||
: todo
|
||||
)
|
||||
);
|
||||
|
||||
// 清除選擇
|
||||
setSelectedTodos([]);
|
||||
|
||||
console.log(`批次更新 ${selectedTodos.length} 個待辦事項狀態為 ${status}`);
|
||||
} catch (error) {
|
||||
console.error('批次狀態更新失敗:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBulkComplete = async () => {
|
||||
try {
|
||||
if (selectedTodos.length === 0) return;
|
||||
|
||||
// 使用批次更新 API 設為完成
|
||||
await todosApi.batchUpdateTodos(selectedTodos, { status: 'DONE' });
|
||||
|
||||
// 更新本地狀態
|
||||
setTodos(prevTodos =>
|
||||
prevTodos.map(todo =>
|
||||
selectedTodos.includes(todo.id)
|
||||
? { ...todo, status: 'DONE' as const, completed_at: new Date().toISOString() }
|
||||
: todo
|
||||
)
|
||||
);
|
||||
|
||||
// 清除選擇
|
||||
setSelectedTodos([]);
|
||||
|
||||
console.log(`批次完成 ${selectedTodos.length} 個待辦事項`);
|
||||
} catch (error) {
|
||||
console.error('批次完成失敗:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBulkDelete = async () => {
|
||||
try {
|
||||
if (selectedTodos.length === 0) return;
|
||||
|
||||
if (!confirm(`確定要刪除 ${selectedTodos.length} 個待辦事項嗎?此操作無法復原。`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 逐一刪除待辦事項(如果沒有批次刪除 API)
|
||||
for (const todoId of selectedTodos) {
|
||||
await todosApi.deleteTodo(todoId);
|
||||
}
|
||||
|
||||
// 從本地狀態中移除
|
||||
setTodos(prevTodos =>
|
||||
prevTodos.filter(todo => !selectedTodos.includes(todo.id))
|
||||
);
|
||||
|
||||
// 清除選擇
|
||||
setSelectedTodos([]);
|
||||
|
||||
console.log(`批次刪除 ${selectedTodos.length} 個待辦事項`);
|
||||
} catch (error) {
|
||||
console.error('批次刪除失敗:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// 單個待辦事項狀態變更處理函數
|
||||
const handleStatusChange = async (todoId: string, status: string) => {
|
||||
try {
|
||||
// 確保 status 是有效的類型
|
||||
const validStatus = status as 'NEW' | 'DOING' | 'BLOCKED' | 'DONE';
|
||||
|
||||
// 使用 API 更新單個待辦事項的狀態
|
||||
await todosApi.updateTodo(todoId, { status: validStatus });
|
||||
|
||||
// 更新本地狀態
|
||||
setTodos(prevTodos =>
|
||||
prevTodos.map(todo =>
|
||||
todo.id === todoId
|
||||
? {
|
||||
...todo,
|
||||
status: validStatus,
|
||||
completed_at: validStatus === 'DONE' ? new Date().toISOString() : undefined
|
||||
}
|
||||
: todo
|
||||
)
|
||||
);
|
||||
|
||||
console.log(`待辦事項 ${todoId} 狀態已更新為 ${status}`);
|
||||
} catch (error) {
|
||||
console.error('狀態更新失敗:', error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<DashboardLayout>
|
||||
<motion.div
|
||||
variants={containerVariants}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
>
|
||||
{/* 標題區域 */}
|
||||
<Box sx={{ mb: 3 }}>
|
||||
<motion.div variants={itemVariants}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 2 }}>
|
||||
<Box>
|
||||
<Typography
|
||||
variant="h4"
|
||||
sx={{
|
||||
fontWeight: 700,
|
||||
mb: 0.5,
|
||||
background: actualTheme === 'dark'
|
||||
? 'linear-gradient(45deg, #f3f4f6 30%, #d1d5db 90%)'
|
||||
: 'linear-gradient(45deg, #111827 30%, #374151 90%)',
|
||||
WebkitBackgroundClip: 'text',
|
||||
WebkitTextFillColor: 'transparent',
|
||||
backgroundClip: 'text',
|
||||
}}
|
||||
>
|
||||
待辦清單
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||
<Typography variant="body1" color="text.secondary">
|
||||
{getFilterModeLabel(filterMode)} · {filteredTodos.length} 項目
|
||||
</Typography>
|
||||
{selectedTodos.length > 0 && (
|
||||
<Chip
|
||||
label={`已選擇 ${selectedTodos.length} 項`}
|
||||
color="primary"
|
||||
size="small"
|
||||
sx={{ fontWeight: 600 }}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: 'flex', gap: 1 }}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
startIcon={<CloudUpload />}
|
||||
onClick={() => setShowExcelImport(true)}
|
||||
sx={{
|
||||
textTransform: 'none',
|
||||
fontWeight: 600,
|
||||
px: 3,
|
||||
py: 1.5,
|
||||
borderRadius: 2,
|
||||
}}
|
||||
>
|
||||
Excel 匯入
|
||||
</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={<Add />}
|
||||
onClick={handleCreateTodo}
|
||||
sx={{
|
||||
background: 'linear-gradient(45deg, #3b82f6 30%, #8b5cf6 90%)',
|
||||
textTransform: 'none',
|
||||
fontWeight: 600,
|
||||
px: 3,
|
||||
py: 1.5,
|
||||
borderRadius: 2,
|
||||
boxShadow: '0 4px 12px rgba(59, 130, 246, 0.3)',
|
||||
'&:hover': {
|
||||
background: 'linear-gradient(45deg, #2563eb 30%, #7c3aed 90%)',
|
||||
boxShadow: '0 6px 16px rgba(59, 130, 246, 0.4)',
|
||||
transform: 'translateY(-1px)',
|
||||
},
|
||||
}}
|
||||
>
|
||||
新增待辦
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
</motion.div>
|
||||
</Box>
|
||||
|
||||
{/* 工具列 */}
|
||||
<motion.div variants={itemVariants}>
|
||||
<Card
|
||||
sx={{
|
||||
mb: 3,
|
||||
backgroundColor: actualTheme === 'dark' ? '#1f2937' : '#ffffff',
|
||||
border: `1px solid ${actualTheme === 'dark'
|
||||
? 'rgba(255, 255, 255, 0.1)'
|
||||
: 'rgba(0, 0, 0, 0.1)'}`,
|
||||
}}
|
||||
>
|
||||
<Toolbar
|
||||
sx={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
gap: 2,
|
||||
px: { xs: 2, sm: 3 },
|
||||
py: 1,
|
||||
}}
|
||||
>
|
||||
{/* 左側工具 */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
{/* 視圖切換 */}
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
backgroundColor: actualTheme === 'dark'
|
||||
? 'rgba(255, 255, 255, 0.05)'
|
||||
: 'rgba(0, 0, 0, 0.04)',
|
||||
borderRadius: 1.5,
|
||||
p: 0.5,
|
||||
}}
|
||||
>
|
||||
<Tooltip title="清單視圖">
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => setViewMode('list')}
|
||||
sx={{
|
||||
backgroundColor: viewMode === 'list' ? 'primary.main' : 'transparent',
|
||||
color: viewMode === 'list' ? 'white' : 'text.secondary',
|
||||
'&:hover': {
|
||||
backgroundColor: viewMode === 'list' ? 'primary.dark' : 'action.hover',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<ViewList fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title="日曆視圖">
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => setViewMode('calendar')}
|
||||
sx={{
|
||||
backgroundColor: viewMode === 'calendar' ? 'primary.main' : 'transparent',
|
||||
color: viewMode === 'calendar' ? 'white' : 'text.secondary',
|
||||
'&:hover': {
|
||||
backgroundColor: viewMode === 'calendar' ? 'primary.dark' : 'action.hover',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<CalendarViewMonth fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
|
||||
{/* 篩選器切換 */}
|
||||
<Box sx={{ display: 'flex', gap: 0.5 }}>
|
||||
{(['all', 'created', 'responsible', 'following'] as FilterMode[]).map((mode) => (
|
||||
<Chip
|
||||
key={mode}
|
||||
label={getFilterModeLabel(mode)}
|
||||
variant={filterMode === mode ? 'filled' : 'outlined'}
|
||||
color={filterMode === mode ? 'primary' : 'default'}
|
||||
size="small"
|
||||
clickable
|
||||
onClick={() => setFilterMode(mode)}
|
||||
sx={{
|
||||
fontSize: '0.75rem',
|
||||
fontWeight: filterMode === mode ? 600 : 400,
|
||||
'&:hover': {
|
||||
transform: 'translateY(-1px)',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* 右側工具 */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Tooltip title="搜尋">
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => setShowSearch(!showSearch)}
|
||||
sx={{
|
||||
color: showSearch ? 'primary.main' : 'text.secondary',
|
||||
backgroundColor: showSearch
|
||||
? (actualTheme === 'dark' ? 'rgba(59, 130, 246, 0.1)' : 'rgba(59, 130, 246, 0.1)')
|
||||
: 'transparent',
|
||||
}}
|
||||
>
|
||||
<Search fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip title="篩選">
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => setShowFilters(!showFilters)}
|
||||
sx={{
|
||||
color: showFilters ? 'primary.main' : 'text.secondary',
|
||||
backgroundColor: showFilters
|
||||
? (actualTheme === 'dark' ? 'rgba(59, 130, 246, 0.1)' : 'rgba(59, 130, 246, 0.1)')
|
||||
: 'transparent',
|
||||
}}
|
||||
>
|
||||
<FilterList fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip title="全選">
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={handleSelectAll}
|
||||
sx={{
|
||||
color: selectedTodos.length > 0 ? 'primary.main' : 'text.secondary',
|
||||
}}
|
||||
>
|
||||
<SelectAll fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
</Toolbar>
|
||||
</Card>
|
||||
</motion.div>
|
||||
|
||||
{/* 搜尋列 */}
|
||||
<AnimatePresence>
|
||||
{showSearch && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, height: 0, marginBottom: 0 }}
|
||||
animate={{ opacity: 1, height: 'auto', marginBottom: 24 }}
|
||||
exit={{ opacity: 0, height: 0, marginBottom: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
<SearchBar
|
||||
value={searchQuery}
|
||||
onChange={setSearchQuery}
|
||||
onClose={() => setShowSearch(false)}
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* 進階篩選 */}
|
||||
<AnimatePresence>
|
||||
{showFilters && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, height: 0, marginBottom: 0 }}
|
||||
animate={{ opacity: 1, height: 'auto', marginBottom: 24 }}
|
||||
exit={{ opacity: 0, height: 0, marginBottom: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
<TodoFilters
|
||||
onClose={() => setShowFilters(false)}
|
||||
onApply={setAppliedFilters}
|
||||
initialFilters={appliedFilters}
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* 批次操作工具列 */}
|
||||
<AnimatePresence>
|
||||
{selectedTodos.length > 0 && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: 20 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
<BatchActions
|
||||
selectedCount={selectedTodos.length}
|
||||
onClearSelection={() => setSelectedTodos([])}
|
||||
onBulkStatusChange={handleBulkStatusChange}
|
||||
onBulkComplete={handleBulkComplete}
|
||||
onBulkDelete={handleBulkDelete}
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* 主要內容區域 */}
|
||||
<motion.div variants={itemVariants}>
|
||||
<Fade in={true} timeout={500}>
|
||||
<Box sx={{ position: 'relative', minHeight: '400px' }}>
|
||||
{loading ? (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
{Array.from({ length: 3 }).map((_, index) => (
|
||||
<Card key={index} sx={{ p: 3 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 2 }}>
|
||||
<Skeleton variant="rectangular" width={20} height={20} />
|
||||
<Skeleton variant="text" width="60%" height={32} />
|
||||
<Skeleton variant="circular" width={24} height={24} sx={{ ml: 'auto' }} />
|
||||
</Box>
|
||||
<Skeleton variant="text" width="80%" height={20} sx={{ mb: 1 }} />
|
||||
<Skeleton variant="text" width="40%" height={20} sx={{ mb: 2 }} />
|
||||
<Box sx={{ display: 'flex', gap: 1, mb: 2 }}>
|
||||
<Skeleton variant="rounded" width={60} height={24} />
|
||||
<Skeleton variant="rounded" width={50} height={24} />
|
||||
<Skeleton variant="rounded" width={70} height={24} />
|
||||
</Box>
|
||||
<Skeleton variant="text" width="30%" height={16} />
|
||||
</Card>
|
||||
))}
|
||||
</Box>
|
||||
) : viewMode === 'list' ? (
|
||||
<TodoList
|
||||
todos={filteredTodos}
|
||||
selectedTodos={selectedTodos}
|
||||
onSelectionChange={setSelectedTodos}
|
||||
viewMode={viewMode}
|
||||
onEditTodo={handleEditTodo}
|
||||
onStatusChange={handleStatusChange}
|
||||
/>
|
||||
) : (
|
||||
<CalendarView
|
||||
todos={filteredTodos}
|
||||
selectedTodos={selectedTodos}
|
||||
onSelectionChange={setSelectedTodos}
|
||||
onEditTodo={handleEditTodo}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
</Fade>
|
||||
</motion.div>
|
||||
|
||||
{/* 新增/編輯待辦對話框 */}
|
||||
<TodoDialog
|
||||
open={showTodoDialog}
|
||||
onClose={handleCloseTodoDialog}
|
||||
todo={editingTodo}
|
||||
mode={editingTodo ? 'edit' : 'create'}
|
||||
onSave={handleSaveTodo}
|
||||
onTodoCreated={handleTodoCreated}
|
||||
/>
|
||||
|
||||
{/* Excel 匯入對話框 */}
|
||||
<ExcelImport
|
||||
open={showExcelImport}
|
||||
onClose={() => setShowExcelImport(false)}
|
||||
onImportComplete={handleTodoCreated}
|
||||
/>
|
||||
</motion.div>
|
||||
</DashboardLayout>
|
||||
);
|
||||
};
|
||||
|
||||
export default TodosPage;
|
77
frontend/src/components/EnvironmentWrapper.tsx
Normal file
77
frontend/src/components/EnvironmentWrapper.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect } from 'react'
|
||||
|
||||
interface EnvironmentWrapperProps {
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
export default function EnvironmentWrapper({ children }: EnvironmentWrapperProps) {
|
||||
useEffect(() => {
|
||||
// 在生產環境中禁用 HMR 和開發工具
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
// 禁用 React DevTools 提示
|
||||
if (typeof window !== 'undefined') {
|
||||
const consoleWarn = console.warn
|
||||
const consoleLog = console.log
|
||||
|
||||
console.warn = (...args: any[]) => {
|
||||
const message = args[0]
|
||||
if (typeof message === 'string' &&
|
||||
(message.includes('Download the React DevTools') ||
|
||||
message.includes('React DevTools'))) {
|
||||
return
|
||||
}
|
||||
consoleWarn.apply(console, args)
|
||||
}
|
||||
|
||||
console.log = (...args: any[]) => {
|
||||
const message = args[0]
|
||||
if (typeof message === 'string' &&
|
||||
(message.includes('Download the React DevTools') ||
|
||||
message.includes('React DevTools'))) {
|
||||
return
|
||||
}
|
||||
consoleLog.apply(console, args)
|
||||
}
|
||||
}
|
||||
|
||||
// 阻止 HMR WebSocket 連接嘗試
|
||||
if (typeof window !== 'undefined') {
|
||||
const originalWebSocket = window.WebSocket
|
||||
window.WebSocket = class extends WebSocket {
|
||||
constructor(url: string | URL, protocols?: string | string[]) {
|
||||
const urlString = url.toString()
|
||||
|
||||
// 阻止 HMR 相關的 WebSocket 連接
|
||||
if (urlString.includes('_next/webpack-hmr') ||
|
||||
urlString.includes('sockjs-node') ||
|
||||
urlString.includes('hot-update')) {
|
||||
console.warn('HMR WebSocket connection blocked in production environment')
|
||||
// 創建一個假的 WebSocket 以避免錯誤
|
||||
super('ws://localhost:1')
|
||||
this.close()
|
||||
return this
|
||||
}
|
||||
|
||||
super(url, protocols)
|
||||
}
|
||||
} as any
|
||||
}
|
||||
}
|
||||
|
||||
// 環境檢查和警告
|
||||
if (process.env.NODE_ENV === 'development' && typeof window !== 'undefined') {
|
||||
console.info('🔧 Development mode detected')
|
||||
console.info('📍 Frontend running on port:', window.location.port)
|
||||
console.info('🔗 API URL:', process.env.NEXT_PUBLIC_API_URL)
|
||||
}
|
||||
|
||||
if (process.env.NODE_ENV === 'production' && typeof window !== 'undefined') {
|
||||
console.info('🚀 Production mode detected')
|
||||
console.info('✅ HMR and dev tools have been disabled')
|
||||
}
|
||||
}, [])
|
||||
|
||||
return <>{children}</>
|
||||
}
|
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;
|
@@ -0,0 +1,591 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
Box,
|
||||
Typography,
|
||||
Switch,
|
||||
FormControlLabel,
|
||||
TextField,
|
||||
Select,
|
||||
MenuItem,
|
||||
FormControl,
|
||||
InputLabel,
|
||||
Button,
|
||||
Divider,
|
||||
Alert,
|
||||
Card,
|
||||
CardContent,
|
||||
Chip,
|
||||
IconButton,
|
||||
Checkbox,
|
||||
FormGroup,
|
||||
Grid,
|
||||
Accordion,
|
||||
AccordionSummary,
|
||||
AccordionDetails,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Email,
|
||||
Schedule,
|
||||
Close,
|
||||
NotificationImportant,
|
||||
Settings,
|
||||
Save,
|
||||
ExpandMore,
|
||||
Alarm,
|
||||
Today,
|
||||
CalendarMonth,
|
||||
AccessTime,
|
||||
} from '@mui/icons-material';
|
||||
import { motion } from 'framer-motion';
|
||||
import { useTheme } from '@/providers/ThemeProvider';
|
||||
import { useAuth } from '@/providers/AuthProvider';
|
||||
import { notificationsApi } from '@/lib/api';
|
||||
import { toast } from 'react-hot-toast';
|
||||
|
||||
interface EnhancedEmailNotificationSettingsProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
interface NotificationSettings {
|
||||
// 基本設定
|
||||
emailEnabled: boolean;
|
||||
emailAddress: string;
|
||||
|
||||
// 到期提醒設定 - 支援多個天數
|
||||
reminderDays: number[];
|
||||
|
||||
// 每日摘要
|
||||
dailyDigestEnabled: boolean;
|
||||
dailyDigestTime: string;
|
||||
|
||||
// 週報摘要
|
||||
weeklyDigestEnabled: boolean;
|
||||
weeklyDigestTime: string;
|
||||
weeklyDigestDay: number; // 0=週日, 1=週一...
|
||||
|
||||
// 月報摘要
|
||||
monthlyDigestEnabled: boolean;
|
||||
monthlyDigestTime: string;
|
||||
monthlyDigestDay: number; // 每月第幾日
|
||||
|
||||
// 其他通知
|
||||
assignmentNotifications: boolean;
|
||||
completionNotifications: boolean;
|
||||
}
|
||||
|
||||
const EnhancedEmailNotificationSettings: React.FC<EnhancedEmailNotificationSettingsProps> = ({
|
||||
open,
|
||||
onClose,
|
||||
}) => {
|
||||
const { actualTheme } = useTheme();
|
||||
const { user } = useAuth();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [testEmailLoading, setTestEmailLoading] = useState(false);
|
||||
|
||||
const [settings, setSettings] = useState<NotificationSettings>({
|
||||
emailEnabled: false,
|
||||
emailAddress: user?.email || '',
|
||||
reminderDays: [1, 3], // 預設前1天、前3天
|
||||
dailyDigestEnabled: false,
|
||||
dailyDigestTime: '09:00',
|
||||
weeklyDigestEnabled: true,
|
||||
weeklyDigestTime: '09:00',
|
||||
weeklyDigestDay: 1, // 週一
|
||||
monthlyDigestEnabled: false,
|
||||
monthlyDigestTime: '09:00',
|
||||
monthlyDigestDay: 1, // 每月1日
|
||||
assignmentNotifications: true,
|
||||
completionNotifications: false,
|
||||
});
|
||||
|
||||
// 可選的提醒天數選項
|
||||
const reminderDayOptions = [1, 2, 3, 5, 7, 14];
|
||||
|
||||
// 週幾選項
|
||||
const weekDayOptions = [
|
||||
{ value: 0, label: '週日' },
|
||||
{ value: 1, label: '週一' },
|
||||
{ value: 2, label: '週二' },
|
||||
{ value: 3, label: '週三' },
|
||||
{ value: 4, label: '週四' },
|
||||
{ value: 5, label: '週五' },
|
||||
{ value: 6, label: '週六' },
|
||||
];
|
||||
|
||||
// 時間選項
|
||||
const timeOptions = Array.from({ length: 24 }, (_, i) => {
|
||||
const hour = i.toString().padStart(2, '0');
|
||||
return `${hour}:00`;
|
||||
});
|
||||
|
||||
// 載入用戶的通知設定
|
||||
useEffect(() => {
|
||||
if (open && user) {
|
||||
loadSettings();
|
||||
}
|
||||
}, [open, user]);
|
||||
|
||||
const loadSettings = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const data = await notificationsApi.getSettings();
|
||||
|
||||
setSettings(prev => ({
|
||||
...prev,
|
||||
emailEnabled: data.email_reminder_enabled || false,
|
||||
reminderDays: data.reminder_days_before || [1, 3],
|
||||
dailyDigestEnabled: false, // 暫時沒有每日摘要
|
||||
weeklyDigestEnabled: data.weekly_summary_enabled || false,
|
||||
weeklyDigestTime: data.weekly_summary_time || '09:00',
|
||||
weeklyDigestDay: data.weekly_summary_day || 1,
|
||||
monthlyDigestEnabled: data.monthly_summary_enabled || false,
|
||||
monthlyDigestTime: data.monthly_summary_time || '09:00',
|
||||
monthlyDigestDay: data.monthly_summary_day || 1,
|
||||
assignmentNotifications: data.notification_enabled || true,
|
||||
emailAddress: user?.email || data.email || '',
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error('Failed to load notification settings:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
setSaving(true);
|
||||
|
||||
const payload = {
|
||||
email_reminder_enabled: settings.emailEnabled,
|
||||
notification_enabled: settings.assignmentNotifications,
|
||||
weekly_summary_enabled: settings.weeklyDigestEnabled,
|
||||
monthly_summary_enabled: settings.monthlyDigestEnabled,
|
||||
reminder_days_before: settings.reminderDays,
|
||||
weekly_summary_time: settings.weeklyDigestTime,
|
||||
weekly_summary_day: settings.weeklyDigestDay,
|
||||
monthly_summary_time: settings.monthlyDigestTime,
|
||||
monthly_summary_day: settings.monthlyDigestDay,
|
||||
};
|
||||
|
||||
await notificationsApi.updateSettings(payload);
|
||||
toast.success('通知設定已儲存');
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error('Failed to save notification settings:', error);
|
||||
toast.error('儲存通知設定失敗,請檢查網路連線');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTestEmail = async () => {
|
||||
try {
|
||||
setTestEmailLoading(true);
|
||||
|
||||
await notificationsApi.sendTestEmail(settings.emailAddress);
|
||||
toast.success(`測試郵件已發送至 ${settings.emailAddress}!請檢查您的信箱`);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to send test email:', error);
|
||||
toast.error('發送測試郵件失敗,請檢查網路連線');
|
||||
} finally {
|
||||
setTestEmailLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleReminderDayToggle = (day: number) => {
|
||||
setSettings(prev => ({
|
||||
...prev,
|
||||
reminderDays: prev.reminderDays.includes(day)
|
||||
? prev.reminderDays.filter(d => d !== day)
|
||||
: [...prev.reminderDays, day].sort((a, b) => a - b)
|
||||
}));
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Dialog open={open} onClose={onClose} maxWidth="sm" fullWidth>
|
||||
<DialogContent>
|
||||
<Box display="flex" justifyContent="center" py={4}>
|
||||
<Typography>載入中...</Typography>
|
||||
</Box>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
maxWidth="md"
|
||||
fullWidth
|
||||
sx={{
|
||||
'& .MuiDialog-paper': {
|
||||
backgroundColor: actualTheme === 'dark' ? '#1f2937' : '#ffffff',
|
||||
borderRadius: 3,
|
||||
minHeight: 700,
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogTitle sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
pb: 1
|
||||
}}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Email sx={{ color: 'primary.main' }} />
|
||||
<Typography variant="h6" sx={{ fontWeight: 600 }}>
|
||||
增強郵件提醒設定
|
||||
</Typography>
|
||||
</Box>
|
||||
<IconButton onClick={onClose} size="small">
|
||||
<Close />
|
||||
</IconButton>
|
||||
</DialogTitle>
|
||||
|
||||
<DialogContent sx={{ px: 3 }}>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
{/* 總開關 */}
|
||||
<Card sx={{ mb: 3, backgroundColor: actualTheme === 'dark' ? '#374151' : '#f8fafc' }}>
|
||||
<CardContent>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<Box>
|
||||
<Typography variant="h6" sx={{ mb: 0.5 }}>
|
||||
啟用郵件通知
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
接收待辦事項相關的郵件提醒通知
|
||||
</Typography>
|
||||
</Box>
|
||||
<Switch
|
||||
checked={settings.emailEnabled}
|
||||
onChange={(e) => setSettings(prev => ({ ...prev, emailEnabled: e.target.checked }))}
|
||||
size="medium"
|
||||
/>
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{settings.emailEnabled && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: 'auto' }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
{/* 基本設定 */}
|
||||
<Accordion defaultExpanded sx={{ mb: 2 }}>
|
||||
<AccordionSummary expandIcon={<ExpandMore />}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Settings />
|
||||
<Typography variant="h6">基本設定</Typography>
|
||||
</Box>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="郵件地址"
|
||||
value={settings.emailAddress}
|
||||
onChange={(e) => setSettings(prev => ({ ...prev, emailAddress: e.target.value }))}
|
||||
helperText="通知將發送至此郵件地址"
|
||||
sx={{ mb: 2 }}
|
||||
disabled
|
||||
/>
|
||||
|
||||
<Button
|
||||
variant="outlined"
|
||||
size="small"
|
||||
onClick={handleTestEmail}
|
||||
disabled={!settings.emailAddress || testEmailLoading}
|
||||
sx={{ mb: 2 }}
|
||||
>
|
||||
{testEmailLoading ? '發送中...' : '發送測試郵件'}
|
||||
</Button>
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
|
||||
{/* 到期提醒設定 */}
|
||||
<Accordion sx={{ mb: 2 }}>
|
||||
<AccordionSummary expandIcon={<ExpandMore />}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Alarm />
|
||||
<Typography variant="h6">到期提醒設定</Typography>
|
||||
</Box>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
|
||||
選擇在到期日前幾天發送提醒郵件(可多選)
|
||||
</Typography>
|
||||
|
||||
<FormGroup>
|
||||
<Grid container spacing={1}>
|
||||
{reminderDayOptions.map(day => (
|
||||
<Grid item xs={6} sm={4} key={day}>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Checkbox
|
||||
checked={settings.reminderDays.includes(day)}
|
||||
onChange={() => handleReminderDayToggle(day)}
|
||||
size="small"
|
||||
/>
|
||||
}
|
||||
label={`前 ${day} 天`}
|
||||
/>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
</FormGroup>
|
||||
|
||||
{settings.reminderDays.length > 0 && (
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
已選擇:
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', gap: 0.5, flexWrap: 'wrap', mt: 1 }}>
|
||||
{settings.reminderDays.sort((a, b) => a - b).map(day => (
|
||||
<Chip
|
||||
key={day}
|
||||
size="small"
|
||||
label={`前${day}天`}
|
||||
color="primary"
|
||||
variant="outlined"
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
|
||||
{/* 摘要郵件設定 */}
|
||||
<Accordion sx={{ mb: 2 }}>
|
||||
<AccordionSummary expandIcon={<ExpandMore />}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Schedule />
|
||||
<Typography variant="h6">摘要郵件設定</Typography>
|
||||
</Box>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails>
|
||||
<Grid container spacing={3}>
|
||||
{/* 週報設定 */}
|
||||
<Grid item xs={12} md={6}>
|
||||
<Card sx={{ p: 2, border: `1px solid ${actualTheme === 'dark' ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.1)'}` }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
|
||||
<CalendarMonth sx={{ mr: 1, color: 'primary.main' }} />
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 600 }}>
|
||||
週報摘要
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={settings.weeklyDigestEnabled}
|
||||
onChange={(e) => setSettings(prev => ({ ...prev, weeklyDigestEnabled: e.target.checked }))}
|
||||
/>
|
||||
}
|
||||
label="啟用週報"
|
||||
sx={{ mb: 2 }}
|
||||
/>
|
||||
|
||||
{settings.weeklyDigestEnabled && (
|
||||
<Box>
|
||||
<FormControl size="small" fullWidth sx={{ mb: 1 }}>
|
||||
<InputLabel>發送時間</InputLabel>
|
||||
<Select
|
||||
value={settings.weeklyDigestTime}
|
||||
label="發送時間"
|
||||
onChange={(e) => setSettings(prev => ({ ...prev, weeklyDigestTime: e.target.value }))}
|
||||
>
|
||||
{timeOptions.map(time => (
|
||||
<MenuItem key={time} value={time}>{time}</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<FormControl size="small" fullWidth>
|
||||
<InputLabel>發送日期</InputLabel>
|
||||
<Select
|
||||
value={settings.weeklyDigestDay}
|
||||
label="發送日期"
|
||||
onChange={(e) => setSettings(prev => ({ ...prev, weeklyDigestDay: e.target.value as number }))}
|
||||
>
|
||||
{weekDayOptions.map(option => (
|
||||
<MenuItem key={option.value} value={option.value}>{option.label}</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Box>
|
||||
)}
|
||||
</Card>
|
||||
</Grid>
|
||||
|
||||
{/* 月報設定 */}
|
||||
<Grid item xs={12} md={6}>
|
||||
<Card sx={{ p: 2, border: `1px solid ${actualTheme === 'dark' ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.1)'}` }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
|
||||
<Today sx={{ mr: 1, color: 'primary.main' }} />
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 600 }}>
|
||||
月報摘要
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={settings.monthlyDigestEnabled}
|
||||
onChange={(e) => setSettings(prev => ({ ...prev, monthlyDigestEnabled: e.target.checked }))}
|
||||
/>
|
||||
}
|
||||
label="啟用月報"
|
||||
sx={{ mb: 2 }}
|
||||
/>
|
||||
|
||||
{settings.monthlyDigestEnabled && (
|
||||
<Box>
|
||||
<FormControl size="small" fullWidth sx={{ mb: 1 }}>
|
||||
<InputLabel>發送時間</InputLabel>
|
||||
<Select
|
||||
value={settings.monthlyDigestTime}
|
||||
label="發送時間"
|
||||
onChange={(e) => setSettings(prev => ({ ...prev, monthlyDigestTime: e.target.value }))}
|
||||
>
|
||||
{timeOptions.map(time => (
|
||||
<MenuItem key={time} value={time}>{time}</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<TextField
|
||||
size="small"
|
||||
type="number"
|
||||
fullWidth
|
||||
label="每月第幾日"
|
||||
value={settings.monthlyDigestDay}
|
||||
onChange={(e) => setSettings(prev => ({ ...prev, monthlyDigestDay: Math.max(1, Math.min(28, parseInt(e.target.value) || 1)) }))}
|
||||
inputProps={{ min: 1, max: 28 }}
|
||||
helperText="1-28日"
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
</Card>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
|
||||
{/* 其他通知設定 */}
|
||||
<Accordion sx={{ mb: 2 }}>
|
||||
<AccordionSummary expandIcon={<ExpandMore />}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<NotificationImportant />
|
||||
<Typography variant="h6">其他通知</Typography>
|
||||
</Box>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={settings.assignmentNotifications}
|
||||
onChange={(e) => setSettings(prev => ({ ...prev, assignmentNotifications: e.target.checked }))}
|
||||
/>
|
||||
}
|
||||
label={
|
||||
<Box>
|
||||
<Typography variant="subtitle2">指派通知</Typography>
|
||||
<Typography variant="caption" color="text.secondary" component="div">
|
||||
有新的待辦事項指派給您時發送通知
|
||||
</Typography>
|
||||
</Box>
|
||||
}
|
||||
sx={{ mb: 2, alignItems: 'flex-start', ml: 0 }}
|
||||
/>
|
||||
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={settings.completionNotifications}
|
||||
onChange={(e) => setSettings(prev => ({ ...prev, completionNotifications: e.target.checked }))}
|
||||
/>
|
||||
}
|
||||
label={
|
||||
<Box>
|
||||
<Typography variant="subtitle2">完成通知</Typography>
|
||||
<Typography variant="caption" color="text.secondary" component="div">
|
||||
您指派的待辦事項被完成時發送通知
|
||||
</Typography>
|
||||
</Box>
|
||||
}
|
||||
sx={{ alignItems: 'flex-start', ml: 0 }}
|
||||
/>
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
|
||||
{/* 設定預覽 */}
|
||||
<Alert
|
||||
severity="info"
|
||||
sx={{
|
||||
mb: 2,
|
||||
backgroundColor: actualTheme === 'dark' ? 'rgba(59, 130, 246, 0.1)' : 'rgba(59, 130, 246, 0.05)',
|
||||
color: actualTheme === 'dark' ? '#93c5fd' : '#1d4ed8',
|
||||
}}
|
||||
>
|
||||
<Typography variant="body2" sx={{ mb: 1 }}>
|
||||
<strong>當前設定預覽:</strong>
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
|
||||
{settings.reminderDays.length > 0 && (
|
||||
<Chip size="small" label={`到期提醒:前${settings.reminderDays.join('、')}天`} />
|
||||
)}
|
||||
{settings.weeklyDigestEnabled && (
|
||||
<Chip size="small" label={`週報:${weekDayOptions.find(d => d.value === settings.weeklyDigestDay)?.label} ${settings.weeklyDigestTime}`} />
|
||||
)}
|
||||
{settings.monthlyDigestEnabled && (
|
||||
<Chip size="small" label={`月報:每月${settings.monthlyDigestDay}日 ${settings.monthlyDigestTime}`} />
|
||||
)}
|
||||
{settings.assignmentNotifications && (
|
||||
<Chip size="small" label="指派通知" />
|
||||
)}
|
||||
{settings.completionNotifications && (
|
||||
<Chip size="small" label="完成通知" />
|
||||
)}
|
||||
</Box>
|
||||
</Alert>
|
||||
</motion.div>
|
||||
)}
|
||||
</motion.div>
|
||||
</DialogContent>
|
||||
|
||||
<DialogActions sx={{ px: 3, py: 2 }}>
|
||||
<Button onClick={onClose} color="inherit">
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
variant="contained"
|
||||
startIcon={<Save />}
|
||||
disabled={saving}
|
||||
sx={{ minWidth: 100 }}
|
||||
>
|
||||
{saving ? '儲存中...' : '儲存設定'}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default EnhancedEmailNotificationSettings;
|
285
frontend/src/components/todos/BatchActions.tsx
Normal file
285
frontend/src/components/todos/BatchActions.tsx
Normal file
@@ -0,0 +1,285 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
Card,
|
||||
Box,
|
||||
Typography,
|
||||
Button,
|
||||
IconButton,
|
||||
Tooltip,
|
||||
Chip,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Close,
|
||||
Delete,
|
||||
Edit,
|
||||
CheckCircle,
|
||||
Star,
|
||||
StarBorder,
|
||||
Email,
|
||||
Assignment,
|
||||
Archive,
|
||||
PlayCircle,
|
||||
PauseCircle,
|
||||
Flag,
|
||||
} from '@mui/icons-material';
|
||||
import { motion } from 'framer-motion';
|
||||
import { useTheme } from '@/providers/ThemeProvider';
|
||||
|
||||
interface BatchActionsProps {
|
||||
selectedCount: number;
|
||||
onClearSelection: () => void;
|
||||
onBulkDelete?: () => void;
|
||||
onBulkComplete?: () => void;
|
||||
onBulkAssign?: () => void;
|
||||
onBulkStar?: () => void;
|
||||
onBulkEmail?: () => void;
|
||||
onBulkArchive?: () => void;
|
||||
onBulkStatusChange?: (status: 'NEW' | 'DOING' | 'BLOCKED') => void;
|
||||
}
|
||||
|
||||
const BatchActions: React.FC<BatchActionsProps> = ({
|
||||
selectedCount,
|
||||
onClearSelection,
|
||||
onBulkDelete,
|
||||
onBulkComplete,
|
||||
onBulkAssign,
|
||||
onBulkStar,
|
||||
onBulkEmail,
|
||||
onBulkArchive,
|
||||
onBulkStatusChange,
|
||||
}) => {
|
||||
const { actualTheme } = useTheme();
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: 20 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
<Card
|
||||
sx={{
|
||||
mb: 3,
|
||||
backgroundColor: actualTheme === 'dark' ? '#1f2937' : '#ffffff',
|
||||
border: `2px solid`,
|
||||
borderColor: 'primary.main',
|
||||
boxShadow: '0 8px 32px rgba(59, 130, 246, 0.2)',
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
px: 3,
|
||||
py: 2,
|
||||
}}
|
||||
>
|
||||
{/* 左側資訊 */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||
<Chip
|
||||
label={`已選擇 ${selectedCount} 項`}
|
||||
color="primary"
|
||||
sx={{
|
||||
fontWeight: 600,
|
||||
fontSize: '0.875rem',
|
||||
}}
|
||||
/>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
批次操作工具列
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{/* 操作按鈕 */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
{/* 完成 */}
|
||||
{onBulkComplete && (
|
||||
<Tooltip title="標記為完成">
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={onBulkComplete}
|
||||
sx={{
|
||||
color: 'success.main',
|
||||
'&:hover': {
|
||||
backgroundColor: 'success.main',
|
||||
color: 'white',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<CheckCircle fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{/* 狀態變更按鈕 */}
|
||||
{onBulkStatusChange && (
|
||||
<>
|
||||
<Tooltip title="設為新任務">
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => onBulkStatusChange('NEW')}
|
||||
sx={{
|
||||
color: 'text.secondary',
|
||||
'&:hover': {
|
||||
backgroundColor: 'rgba(107, 114, 128, 0.1)',
|
||||
color: 'text.primary',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Flag fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip title="設為進行中">
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => onBulkStatusChange('DOING')}
|
||||
sx={{
|
||||
color: 'primary.main',
|
||||
'&:hover': {
|
||||
backgroundColor: 'primary.main',
|
||||
color: 'white',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<PlayCircle fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip title="設為阻塞">
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => onBulkStatusChange('BLOCKED')}
|
||||
sx={{
|
||||
color: 'error.main',
|
||||
'&:hover': {
|
||||
backgroundColor: 'error.main',
|
||||
color: 'white',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<PauseCircle fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 加星號 */}
|
||||
{onBulkStar && (
|
||||
<Tooltip title="加入星號">
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={onBulkStar}
|
||||
sx={{
|
||||
color: 'warning.main',
|
||||
'&:hover': {
|
||||
backgroundColor: 'warning.main',
|
||||
color: 'white',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Star fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{/* 指派 */}
|
||||
{onBulkAssign && (
|
||||
<Tooltip title="批次指派">
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={onBulkAssign}
|
||||
sx={{
|
||||
color: 'info.main',
|
||||
'&:hover': {
|
||||
backgroundColor: 'info.main',
|
||||
color: 'white',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Assignment fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{/* 發送提醒 */}
|
||||
{onBulkEmail && (
|
||||
<Tooltip title="發送提醒">
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={onBulkEmail}
|
||||
sx={{
|
||||
color: 'secondary.main',
|
||||
'&:hover': {
|
||||
backgroundColor: 'secondary.main',
|
||||
color: 'white',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Email fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{/* 封存 */}
|
||||
{onBulkArchive && (
|
||||
<Tooltip title="封存">
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={onBulkArchive}
|
||||
sx={{
|
||||
color: 'text.secondary',
|
||||
'&:hover': {
|
||||
backgroundColor: 'action.hover',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Archive fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{/* 刪除 */}
|
||||
{onBulkDelete && (
|
||||
<Tooltip title="刪除">
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={onBulkDelete}
|
||||
sx={{
|
||||
color: 'error.main',
|
||||
'&:hover': {
|
||||
backgroundColor: 'error.main',
|
||||
color: 'white',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Delete fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{/* 清除選擇 */}
|
||||
<Tooltip title="清除選擇">
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={onClearSelection}
|
||||
sx={{
|
||||
color: 'text.secondary',
|
||||
'&:hover': {
|
||||
backgroundColor: 'action.hover',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Close fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
</Box>
|
||||
</Card>
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BatchActions;
|
935
frontend/src/components/todos/CalendarView.tsx
Normal file
935
frontend/src/components/todos/CalendarView.tsx
Normal file
@@ -0,0 +1,935 @@
|
||||
'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';
|
||||
import weekday from 'dayjs/plugin/weekday';
|
||||
import localeData from 'dayjs/plugin/localeData';
|
||||
|
||||
dayjs.extend(isoWeek);
|
||||
dayjs.extend(isSameOrAfter);
|
||||
dayjs.extend(isSameOrBefore);
|
||||
dayjs.extend(weekday);
|
||||
dayjs.extend(localeData);
|
||||
|
||||
// 設定週的開始為週日
|
||||
dayjs.Ls.en.weekStart = 0;
|
||||
|
||||
interface CalendarViewProps {
|
||||
todos: Todo[];
|
||||
selectedTodos: string[];
|
||||
onSelectionChange: (selected: string[]) => void;
|
||||
onEditTodo?: (todo: Todo) => void;
|
||||
}
|
||||
|
||||
type ViewType = 'month' | 'week' | 'day';
|
||||
|
||||
const CalendarView: React.FC<CalendarViewProps> = ({
|
||||
todos,
|
||||
selectedTodos,
|
||||
onSelectionChange,
|
||||
onEditTodo
|
||||
}) => {
|
||||
const { actualTheme } = useTheme();
|
||||
const [currentDate, setCurrentDate] = useState<Dayjs>(dayjs());
|
||||
const [viewType, setViewType] = useState<ViewType>('month');
|
||||
|
||||
const getPriorityColor = (priority: string) => {
|
||||
switch (priority) {
|
||||
case 'URGENT': return '#ef4444';
|
||||
case 'HIGH': return '#f97316';
|
||||
case 'MEDIUM': return '#f59e0b';
|
||||
case 'LOW': return '#6b7280';
|
||||
default: return '#6b7280';
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'NEW': return '#6b7280';
|
||||
case 'DOING': return '#3b82f6';
|
||||
case 'BLOCKED': return '#ef4444';
|
||||
case 'DONE': return '#10b981';
|
||||
default: return '#6b7280';
|
||||
}
|
||||
};
|
||||
|
||||
// 獲取當前視圖的日期範圍
|
||||
const getViewDates = useMemo(() => {
|
||||
switch (viewType) {
|
||||
case 'month': {
|
||||
const startOfMonth = currentDate.startOf('month');
|
||||
const endOfMonth = currentDate.endOf('month');
|
||||
|
||||
// 獲取月份第一天是星期幾 (0=週日, 1=週一, ..., 6=週六)
|
||||
const firstDayWeekday = startOfMonth.day();
|
||||
// 獲取月份最後一天是星期幾
|
||||
const lastDayWeekday = endOfMonth.day();
|
||||
|
||||
// 計算需要顯示的第一天(從包含本月第一天的那週的週日開始)
|
||||
const startOfWeek = startOfMonth.subtract(firstDayWeekday, 'day');
|
||||
// 計算需要顯示的最後一天(到包含本月最後一天的那週的週六結束)
|
||||
const endOfWeek = endOfMonth.add(6 - lastDayWeekday, 'day');
|
||||
|
||||
const dates = [];
|
||||
let current = startOfWeek;
|
||||
|
||||
while (current.isSameOrBefore(endOfWeek)) {
|
||||
dates.push(current);
|
||||
current = current.add(1, 'day');
|
||||
}
|
||||
|
||||
return dates;
|
||||
}
|
||||
case 'week': {
|
||||
const startOfWeek = currentDate.startOf('week');
|
||||
const dates = [];
|
||||
|
||||
for (let i = 0; i < 7; i++) {
|
||||
dates.push(startOfWeek.add(i, 'day'));
|
||||
}
|
||||
|
||||
return dates;
|
||||
}
|
||||
case 'day':
|
||||
return [currentDate];
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
}, [currentDate, viewType]);
|
||||
|
||||
// 獲取指定日期的待辦事項
|
||||
const getTodosForDate = (date: Dayjs) => {
|
||||
return todos.filter(todo =>
|
||||
todo.due_date && dayjs(todo.due_date).format('YYYY-MM-DD') === date.format('YYYY-MM-DD')
|
||||
);
|
||||
};
|
||||
|
||||
const handlePrevious = () => {
|
||||
switch (viewType) {
|
||||
case 'month':
|
||||
setCurrentDate(prev => prev.subtract(1, 'month'));
|
||||
break;
|
||||
case 'week':
|
||||
setCurrentDate(prev => prev.subtract(1, 'week'));
|
||||
break;
|
||||
case 'day':
|
||||
setCurrentDate(prev => prev.subtract(1, 'day'));
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
const handleNext = () => {
|
||||
switch (viewType) {
|
||||
case 'month':
|
||||
setCurrentDate(prev => prev.add(1, 'month'));
|
||||
break;
|
||||
case 'week':
|
||||
setCurrentDate(prev => prev.add(1, 'week'));
|
||||
break;
|
||||
case 'day':
|
||||
setCurrentDate(prev => prev.add(1, 'day'));
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
const handleToday = () => {
|
||||
setCurrentDate(dayjs());
|
||||
};
|
||||
|
||||
const getTitleText = () => {
|
||||
switch (viewType) {
|
||||
case 'month':
|
||||
return currentDate.format('YYYY年 MM月');
|
||||
case 'week':
|
||||
return `${currentDate.startOf('week').format('MM/DD')} - ${currentDate.endOf('week').format('MM/DD')}`;
|
||||
case 'day':
|
||||
return currentDate.format('YYYY年 MM月 DD日');
|
||||
}
|
||||
};
|
||||
|
||||
const handleTodoClick = (todo: Todo, event: React.MouseEvent) => {
|
||||
if (event.ctrlKey || event.metaKey) {
|
||||
// 多選模式
|
||||
const newSelected = selectedTodos.includes(todo.id)
|
||||
? selectedTodos.filter(id => id !== todo.id)
|
||||
: [...selectedTodos, todo.id];
|
||||
onSelectionChange(newSelected);
|
||||
} else {
|
||||
// 編輯模式
|
||||
onEditTodo?.(todo);
|
||||
}
|
||||
};
|
||||
|
||||
const containerVariants = {
|
||||
hidden: { opacity: 0 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
transition: {
|
||||
staggerChildren: 0.05,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const itemVariants = {
|
||||
hidden: { opacity: 0, scale: 0.95 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
scale: 1,
|
||||
transition: { duration: 0.3 },
|
||||
},
|
||||
};
|
||||
|
||||
const renderMonthView = () => {
|
||||
const weeks = [];
|
||||
const dates = getViewDates;
|
||||
|
||||
for (let i = 0; i < dates.length; i += 7) {
|
||||
weeks.push(dates.slice(i, i + 7));
|
||||
}
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
variants={containerVariants}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
backgroundColor: actualTheme === 'dark' ? '#1f2937' : '#ffffff',
|
||||
border: `1px solid ${actualTheme === 'dark'
|
||||
? 'rgba(255, 255, 255, 0.1)'
|
||||
: 'rgba(0, 0, 0, 0.1)'}`,
|
||||
borderRadius: 2,
|
||||
overflow: 'hidden'
|
||||
}}
|
||||
>
|
||||
{/* 星期標題行 */}
|
||||
<Box sx={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(7, 1fr)',
|
||||
backgroundColor: actualTheme === 'dark' ? '#374151' : '#f3f4f6',
|
||||
borderBottom: `1px solid ${actualTheme === 'dark'
|
||||
? 'rgba(255, 255, 255, 0.1)'
|
||||
: 'rgba(0, 0, 0, 0.1)'}`,
|
||||
}}>
|
||||
{['日', '一', '二', '三', '四', '五', '六'].map((day, index) => (
|
||||
<Box
|
||||
key={index}
|
||||
sx={{
|
||||
p: 2,
|
||||
textAlign: 'center',
|
||||
borderRight: index < 6 ? `1px solid ${actualTheme === 'dark'
|
||||
? 'rgba(255, 255, 255, 0.1)'
|
||||
: 'rgba(0, 0, 0, 0.1)'}` : 'none',
|
||||
}}
|
||||
>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600, color: 'text.secondary' }}>
|
||||
{day}
|
||||
</Typography>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
|
||||
{/* 日期網格 */}
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column' }}>
|
||||
{weeks.map((week, weekIndex) => (
|
||||
<Box
|
||||
key={weekIndex}
|
||||
sx={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(7, 1fr)',
|
||||
borderBottom: weekIndex < weeks.length - 1 ? `1px solid ${actualTheme === 'dark'
|
||||
? 'rgba(255, 255, 255, 0.1)'
|
||||
: 'rgba(0, 0, 0, 0.1)'}` : 'none',
|
||||
}}
|
||||
>
|
||||
{week.map((date, dayIndex) => {
|
||||
const todosForDate = getTodosForDate(date);
|
||||
const isCurrentMonth = date.month() === currentDate.month();
|
||||
const isToday = date.isSame(dayjs(), 'day');
|
||||
|
||||
return (
|
||||
<Box
|
||||
key={`${weekIndex}-${dayIndex}`}
|
||||
sx={{
|
||||
minHeight: 120,
|
||||
p: 1.5,
|
||||
borderRight: dayIndex < 6 ? `1px solid ${actualTheme === 'dark'
|
||||
? 'rgba(255, 255, 255, 0.1)'
|
||||
: 'rgba(0, 0, 0, 0.1)'}` : 'none',
|
||||
backgroundColor: isToday
|
||||
? (actualTheme === 'dark' ? 'rgba(59, 130, 246, 0.1)' : 'rgba(59, 130, 246, 0.05)')
|
||||
: (isCurrentMonth
|
||||
? 'transparent'
|
||||
: (actualTheme === 'dark' ? 'rgba(0, 0, 0, 0.2)' : 'rgba(0, 0, 0, 0.02)')),
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s ease',
|
||||
position: 'relative',
|
||||
'&:hover': {
|
||||
backgroundColor: actualTheme === 'dark'
|
||||
? 'rgba(59, 130, 246, 0.1)'
|
||||
: 'rgba(59, 130, 246, 0.05)',
|
||||
},
|
||||
}}
|
||||
>
|
||||
{/* 日期數字和徽章 */}
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 1 }}>
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{
|
||||
fontWeight: isToday ? 700 : (isCurrentMonth ? 500 : 400),
|
||||
color: isCurrentMonth
|
||||
? (isToday ? 'primary.main' : 'text.primary')
|
||||
: 'text.disabled',
|
||||
fontSize: '0.875rem',
|
||||
}}
|
||||
>
|
||||
{date.date()}
|
||||
</Typography>
|
||||
{todosForDate.length > 0 && (
|
||||
<Badge
|
||||
badgeContent={todosForDate.length}
|
||||
color="primary"
|
||||
sx={{
|
||||
'& .MuiBadge-badge': {
|
||||
fontSize: '0.7rem',
|
||||
minWidth: 18,
|
||||
height: 18,
|
||||
right: -6,
|
||||
top: -6,
|
||||
borderRadius: '50%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* 待辦事項列表 */}
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}>
|
||||
{todosForDate.slice(0, 2).map((todo) => (
|
||||
<motion.div
|
||||
key={todo.id}
|
||||
whileHover={{ scale: 1.01 }}
|
||||
whileTap={{ scale: 0.99 }}
|
||||
>
|
||||
<Box
|
||||
onClick={(e) => handleTodoClick(todo, e)}
|
||||
sx={{
|
||||
p: 0.5,
|
||||
borderRadius: 1,
|
||||
backgroundColor: selectedTodos.includes(todo.id)
|
||||
? 'rgba(59, 130, 246, 0.2)'
|
||||
: `${getPriorityColor(todo.priority)}15`,
|
||||
borderLeft: `2px solid ${getPriorityColor(todo.priority)}`,
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s ease',
|
||||
'&:hover': {
|
||||
backgroundColor: `${getPriorityColor(todo.priority)}25`,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
display: 'block',
|
||||
fontWeight: 600,
|
||||
fontSize: '0.7rem',
|
||||
color: 'text.primary',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
lineHeight: 1.2,
|
||||
}}
|
||||
>
|
||||
{todo.starred && <Star sx={{ fontSize: 10, color: '#fbbf24', mr: 0.25 }} />}
|
||||
{todo.title}
|
||||
</Typography>
|
||||
</Box>
|
||||
</motion.div>
|
||||
))}
|
||||
|
||||
{todosForDate.length > 2 && (
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
fontSize: '0.65rem',
|
||||
color: 'text.secondary',
|
||||
textAlign: 'center',
|
||||
fontStyle: 'italic',
|
||||
}}
|
||||
>
|
||||
+{todosForDate.length - 2} 項
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderWeekView = () => {
|
||||
return (
|
||||
<motion.div
|
||||
variants={containerVariants}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
>
|
||||
<Grid container spacing={2}>
|
||||
{getViewDates.map((date) => {
|
||||
const todosForDate = getTodosForDate(date);
|
||||
const isToday = date.isSame(dayjs(), 'day');
|
||||
|
||||
return (
|
||||
<Grid item xs key={date.format('YYYY-MM-DD')}>
|
||||
<motion.div variants={itemVariants}>
|
||||
<Card
|
||||
sx={{
|
||||
minHeight: 400,
|
||||
backgroundColor: actualTheme === 'dark' ? '#1f2937' : '#ffffff',
|
||||
border: isToday
|
||||
? `2px solid ${actualTheme === 'dark' ? '#60a5fa' : '#3b82f6'}`
|
||||
: `1px solid ${actualTheme === 'dark'
|
||||
? 'rgba(255, 255, 255, 0.1)'
|
||||
: 'rgba(0, 0, 0, 0.1)'}`,
|
||||
}}
|
||||
>
|
||||
{/* 日期標題 */}
|
||||
<Box
|
||||
sx={{
|
||||
p: 2,
|
||||
borderBottom: `1px solid ${actualTheme === 'dark'
|
||||
? 'rgba(255, 255, 255, 0.1)'
|
||||
: 'rgba(0, 0, 0, 0.1)'}`,
|
||||
backgroundColor: isToday
|
||||
? (actualTheme === 'dark' ? 'rgba(59, 130, 246, 0.1)' : 'rgba(59, 130, 246, 0.05)')
|
||||
: 'transparent',
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
variant="h6"
|
||||
sx={{
|
||||
fontWeight: 700,
|
||||
color: isToday ? 'primary.main' : 'text.primary',
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
{date.format('MM/DD')}
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
color: 'text.secondary',
|
||||
display: 'block',
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
{date.format('dddd')}
|
||||
</Typography>
|
||||
<Badge
|
||||
badgeContent={todosForDate.length}
|
||||
color="primary"
|
||||
sx={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
mt: 0.5,
|
||||
'& .MuiBadge-badge': {
|
||||
fontSize: '0.7rem',
|
||||
minWidth: 18,
|
||||
height: 18,
|
||||
borderRadius: '50%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Event sx={{ color: 'text.secondary' }} />
|
||||
</Badge>
|
||||
</Box>
|
||||
|
||||
{/* 待辦事項列表 */}
|
||||
<Box sx={{ p: 1.5, display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||
<AnimatePresence>
|
||||
{todosForDate.map((todo, index) => (
|
||||
<motion.div
|
||||
key={todo.id}
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, x: -20 }}
|
||||
transition={{ delay: index * 0.1 }}
|
||||
whileHover={{ scale: 1.02 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
>
|
||||
<Card
|
||||
onClick={(e) => handleTodoClick(todo, e)}
|
||||
sx={{
|
||||
p: 1.5,
|
||||
cursor: 'pointer',
|
||||
backgroundColor: selectedTodos.includes(todo.id)
|
||||
? 'rgba(59, 130, 246, 0.1)'
|
||||
: (actualTheme === 'dark' ? 'rgba(255, 255, 255, 0.05)' : '#fafafa'),
|
||||
borderLeft: `4px solid ${getPriorityColor(todo.priority)}`,
|
||||
transition: 'all 0.2s ease',
|
||||
'&:hover': {
|
||||
backgroundColor: actualTheme === 'dark'
|
||||
? 'rgba(255, 255, 255, 0.1)'
|
||||
: 'rgba(0, 0, 0, 0.04)',
|
||||
transform: 'translateX(4px)',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 0.5 }}>
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{
|
||||
fontWeight: 600,
|
||||
flex: 1,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{todo.starred && <Star sx={{ fontSize: 14, color: '#fbbf24', mr: 0.5 }} />}
|
||||
{todo.title}
|
||||
</Typography>
|
||||
<Chip
|
||||
size="small"
|
||||
label={todo.status === 'DOING' ? '進行中' :
|
||||
todo.status === 'BLOCKED' ? '阻塞' :
|
||||
todo.status === 'DONE' ? '完成' : '新建'}
|
||||
sx={{
|
||||
backgroundColor: `${getStatusColor(todo.status)}15`,
|
||||
color: getStatusColor(todo.status),
|
||||
fontSize: '0.6rem',
|
||||
height: 20,
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Flag sx={{ fontSize: 12, color: getPriorityColor(todo.priority) }} />
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{todo.priority === 'URGENT' ? '緊急' :
|
||||
todo.priority === 'HIGH' ? '高' :
|
||||
todo.priority === 'MEDIUM' ? '中' : '低'}
|
||||
</Typography>
|
||||
|
||||
<Person sx={{ fontSize: 12, color: 'text.secondary', ml: 'auto' }} />
|
||||
{(() => {
|
||||
const firstUser = todo.responsible_users_details?.[0] ||
|
||||
(todo.responsible_users?.[0] ? {ad_account: todo.responsible_users[0], display_name: todo.responsible_users[0]} : null);
|
||||
const displayName = firstUser ? (firstUser.display_name || firstUser.ad_account) : '未指派';
|
||||
const adAccount = firstUser ? firstUser.ad_account : '';
|
||||
const fullName = firstUser ? `${adAccount} ${displayName}` : '未指派';
|
||||
|
||||
return (
|
||||
<Tooltip title={fullName}>
|
||||
<Chip
|
||||
size="small"
|
||||
label={displayName}
|
||||
sx={{
|
||||
height: 20,
|
||||
fontSize: '0.65rem',
|
||||
backgroundColor: 'primary.main',
|
||||
color: 'white',
|
||||
borderRadius: 0.5,
|
||||
'& .MuiChip-label': {
|
||||
px: 1,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
);
|
||||
})()}
|
||||
</Box>
|
||||
</Card>
|
||||
</motion.div>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
|
||||
{todosForDate.length === 0 && (
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
color: 'text.secondary',
|
||||
textAlign: 'center',
|
||||
py: 4,
|
||||
fontStyle: 'italic',
|
||||
}}
|
||||
>
|
||||
無待辦事項
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
</Card>
|
||||
</motion.div>
|
||||
</Grid>
|
||||
);
|
||||
})}
|
||||
</Grid>
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderDayView = () => {
|
||||
const todosForDate = getTodosForDate(currentDate);
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
variants={containerVariants}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
>
|
||||
<Card
|
||||
sx={{
|
||||
backgroundColor: actualTheme === 'dark' ? '#1f2937' : '#ffffff',
|
||||
border: `1px solid ${actualTheme === 'dark'
|
||||
? 'rgba(255, 255, 255, 0.1)'
|
||||
: 'rgba(0, 0, 0, 0.1)'}`,
|
||||
}}
|
||||
>
|
||||
{/* 日期標題 */}
|
||||
<Box sx={{ p: 3, borderBottom: `1px solid ${actualTheme === 'dark'
|
||||
? 'rgba(255, 255, 255, 0.1)'
|
||||
: 'rgba(0, 0, 0, 0.1)'}` }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 2 }}>
|
||||
<CalendarToday sx={{ color: 'primary.main', fontSize: 24 }} />
|
||||
<Typography variant="h5" sx={{ fontWeight: 700, color: 'primary.main' }}>
|
||||
{currentDate.format('YYYY年 MM月 DD日')}
|
||||
</Typography>
|
||||
<Typography variant="subtitle1" color="text.secondary">
|
||||
{currentDate.format('dddd')}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', mt: 2 }}>
|
||||
<Chip
|
||||
icon={<Event />}
|
||||
label={`${todosForDate.length} 個待辦事項`}
|
||||
color="primary"
|
||||
variant="outlined"
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* 待辦事項列表 */}
|
||||
<Box sx={{ p: 3 }}>
|
||||
{todosForDate.length > 0 ? (
|
||||
<Grid container spacing={2}>
|
||||
<AnimatePresence>
|
||||
{todosForDate.map((todo, index) => (
|
||||
<Grid item xs={12} sm={6} md={4} key={todo.id}>
|
||||
<motion.div
|
||||
variants={itemVariants}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
exit={{ opacity: 0, scale: 0.9 }}
|
||||
transition={{ delay: index * 0.1 }}
|
||||
whileHover={{ scale: 1.02 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
>
|
||||
<Card
|
||||
onClick={(e) => handleTodoClick(todo, e)}
|
||||
sx={{
|
||||
p: 2,
|
||||
cursor: 'pointer',
|
||||
backgroundColor: selectedTodos.includes(todo.id)
|
||||
? 'rgba(59, 130, 246, 0.1)'
|
||||
: (actualTheme === 'dark' ? '#374151' : '#f9fafb'),
|
||||
borderLeft: `4px solid ${getPriorityColor(todo.priority)}`,
|
||||
transition: 'all 0.3s ease',
|
||||
'&:hover': {
|
||||
backgroundColor: actualTheme === 'dark'
|
||||
? 'rgba(255, 255, 255, 0.1)'
|
||||
: 'rgba(0, 0, 0, 0.04)',
|
||||
transform: 'translateY(-4px)',
|
||||
boxShadow: actualTheme === 'dark'
|
||||
? '0 8px 25px rgba(0, 0, 0, 0.3)'
|
||||
: '0 8px 25px rgba(0, 0, 0, 0.1)',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', mb: 1 }}>
|
||||
<Typography
|
||||
variant="h6"
|
||||
sx={{
|
||||
fontWeight: 600,
|
||||
flex: 1,
|
||||
mr: 1,
|
||||
}}
|
||||
>
|
||||
{todo.starred && <Star sx={{ fontSize: 16, color: '#fbbf24', mr: 0.5 }} />}
|
||||
{todo.title}
|
||||
</Typography>
|
||||
<Chip
|
||||
size="small"
|
||||
label={todo.priority === 'URGENT' ? '緊急' :
|
||||
todo.priority === 'HIGH' ? '高' :
|
||||
todo.priority === 'MEDIUM' ? '中' : '低'}
|
||||
sx={{
|
||||
backgroundColor: `${getPriorityColor(todo.priority)}15`,
|
||||
color: getPriorityColor(todo.priority),
|
||||
fontWeight: 600,
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{todo.description && (
|
||||
<Typography
|
||||
variant="body2"
|
||||
color="text.secondary"
|
||||
sx={{
|
||||
mb: 2,
|
||||
overflow: 'hidden',
|
||||
display: '-webkit-box',
|
||||
WebkitLineClamp: 2,
|
||||
WebkitBoxOrient: 'vertical',
|
||||
}}
|
||||
>
|
||||
{todo.description}
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<Chip
|
||||
size="small"
|
||||
label={todo.status === 'DOING' ? '進行中' :
|
||||
todo.status === 'BLOCKED' ? '已阻塞' :
|
||||
todo.status === 'DONE' ? '已完成' : '新建立'}
|
||||
sx={{
|
||||
backgroundColor: `${getStatusColor(todo.status)}15`,
|
||||
color: getStatusColor(todo.status),
|
||||
fontWeight: 600,
|
||||
}}
|
||||
/>
|
||||
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
||||
<Person sx={{ fontSize: 14, color: 'text.secondary' }} />
|
||||
{(() => {
|
||||
const firstUser = todo.responsible_users_details?.[0] ||
|
||||
(todo.responsible_users?.[0] ? {ad_account: todo.responsible_users[0], display_name: todo.responsible_users[0]} : null);
|
||||
const displayName = firstUser ? (firstUser.display_name || firstUser.ad_account) : '未指派';
|
||||
const adAccount = firstUser ? firstUser.ad_account : '';
|
||||
const fullName = firstUser ? `${adAccount} ${displayName}` : '未指派';
|
||||
|
||||
return (
|
||||
<Tooltip title={fullName}>
|
||||
<Chip
|
||||
size="small"
|
||||
label={displayName}
|
||||
sx={{
|
||||
height: 22,
|
||||
fontSize: '0.7rem',
|
||||
backgroundColor: 'primary.main',
|
||||
color: 'white',
|
||||
borderRadius: 0.5,
|
||||
'& .MuiChip-label': {
|
||||
px: 1,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
);
|
||||
})()}
|
||||
</Box>
|
||||
</Box>
|
||||
</Card>
|
||||
</motion.div>
|
||||
</Grid>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</Grid>
|
||||
) : (
|
||||
<Box
|
||||
sx={{
|
||||
textAlign: 'center',
|
||||
py: 8,
|
||||
}}
|
||||
>
|
||||
<Event sx={{ fontSize: 48, color: 'text.secondary', mb: 2 }} />
|
||||
<Typography variant="h6" color="text.secondary" sx={{ mb: 1 }}>
|
||||
今天沒有待辦事項
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
好好休息,或者開始規劃新的任務吧!
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Card>
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box>
|
||||
{/* 控制列 */}
|
||||
<Card
|
||||
sx={{
|
||||
mb: 3,
|
||||
backgroundColor: actualTheme === 'dark' ? '#1f2937' : '#ffffff',
|
||||
border: `1px solid ${actualTheme === 'dark'
|
||||
? 'rgba(255, 255, 255, 0.1)'
|
||||
: 'rgba(0, 0, 0, 0.1)'}`,
|
||||
}}
|
||||
>
|
||||
<Box sx={{ p: 2, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
{/* 導航控制 */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<IconButton onClick={handlePrevious} sx={{ color: 'primary.main' }}>
|
||||
<ChevronLeft />
|
||||
</IconButton>
|
||||
<Button
|
||||
variant="outlined"
|
||||
onClick={handleToday}
|
||||
startIcon={<Today />}
|
||||
sx={{
|
||||
textTransform: 'none',
|
||||
fontWeight: 600,
|
||||
borderRadius: 2,
|
||||
px: 3,
|
||||
}}
|
||||
>
|
||||
今天
|
||||
</Button>
|
||||
<IconButton onClick={handleNext} sx={{ color: 'primary.main' }}>
|
||||
<ChevronRight />
|
||||
</IconButton>
|
||||
</Box>
|
||||
|
||||
{/* 標題 */}
|
||||
<Typography
|
||||
variant="h5"
|
||||
sx={{
|
||||
fontWeight: 700,
|
||||
background: actualTheme === 'dark'
|
||||
? 'linear-gradient(45deg, #f3f4f6 30%, #d1d5db 90%)'
|
||||
: 'linear-gradient(45deg, #111827 30%, #374151 90%)',
|
||||
WebkitBackgroundClip: 'text',
|
||||
WebkitTextFillColor: 'transparent',
|
||||
backgroundClip: 'text',
|
||||
}}
|
||||
>
|
||||
{getTitleText()}
|
||||
</Typography>
|
||||
|
||||
{/* 視圖切換 */}
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
backgroundColor: actualTheme === 'dark'
|
||||
? 'rgba(255, 255, 255, 0.05)'
|
||||
: 'rgba(0, 0, 0, 0.04)',
|
||||
borderRadius: 1.5,
|
||||
p: 0.5,
|
||||
}}
|
||||
>
|
||||
<Tooltip title="月視圖">
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => setViewType('month')}
|
||||
sx={{
|
||||
backgroundColor: viewType === 'month' ? 'primary.main' : 'transparent',
|
||||
color: viewType === 'month' ? 'white' : 'text.secondary',
|
||||
'&:hover': {
|
||||
backgroundColor: viewType === 'month' ? 'primary.dark' : 'action.hover',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<CalendarToday fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title="週視圖">
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => setViewType('week')}
|
||||
sx={{
|
||||
backgroundColor: viewType === 'week' ? 'primary.main' : 'transparent',
|
||||
color: viewType === 'week' ? 'white' : 'text.secondary',
|
||||
'&:hover': {
|
||||
backgroundColor: viewType === 'week' ? 'primary.dark' : 'action.hover',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<ViewWeek fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title="日視圖">
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => setViewType('day')}
|
||||
sx={{
|
||||
backgroundColor: viewType === 'day' ? 'primary.main' : 'transparent',
|
||||
color: viewType === 'day' ? 'white' : 'text.secondary',
|
||||
'&:hover': {
|
||||
backgroundColor: viewType === 'day' ? 'primary.dark' : 'action.hover',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<ViewDay fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
</Box>
|
||||
</Card>
|
||||
|
||||
{/* 日曆內容 */}
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
key={viewType}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -20 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
{viewType === 'month' && renderMonthView()}
|
||||
{viewType === 'week' && renderWeekView()}
|
||||
{viewType === 'day' && renderDayView()}
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default CalendarView;
|
446
frontend/src/components/todos/ExcelImport.tsx
Normal file
446
frontend/src/components/todos/ExcelImport.tsx
Normal file
@@ -0,0 +1,446 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
Button,
|
||||
Box,
|
||||
Typography,
|
||||
LinearProgress,
|
||||
Alert,
|
||||
Stepper,
|
||||
Step,
|
||||
StepLabel,
|
||||
Table,
|
||||
TableHead,
|
||||
TableBody,
|
||||
TableRow,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
Paper,
|
||||
Chip,
|
||||
IconButton,
|
||||
Tooltip,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
CloudUpload,
|
||||
Download,
|
||||
CheckCircle,
|
||||
Error,
|
||||
Edit,
|
||||
Delete,
|
||||
} from '@mui/icons-material';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { useTheme } from '@/providers/ThemeProvider';
|
||||
import { toast } from 'react-hot-toast';
|
||||
|
||||
interface ExcelImportProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onImportComplete?: () => void;
|
||||
}
|
||||
|
||||
interface TodoImportData {
|
||||
row: number;
|
||||
title: string;
|
||||
description: string;
|
||||
status: string;
|
||||
priority: string;
|
||||
due_date: string | null;
|
||||
responsible_users: string[];
|
||||
followers: string[];
|
||||
is_public: boolean;
|
||||
}
|
||||
|
||||
const ExcelImport: React.FC<ExcelImportProps> = ({ open, onClose, onImportComplete }) => {
|
||||
const { actualTheme } = useTheme();
|
||||
const [activeStep, setActiveStep] = useState(0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [parsedData, setParsedData] = useState<TodoImportData[]>([]);
|
||||
const [parseErrors, setParseErrors] = useState<string[]>([]);
|
||||
const [importErrors, setImportErrors] = useState<any[]>([]);
|
||||
|
||||
const steps = ['上傳檔案', '預覽資料', '確認匯入'];
|
||||
|
||||
const handleDownloadTemplate = async () => {
|
||||
try {
|
||||
const token = localStorage.getItem('access_token');
|
||||
const response = await fetch('/api/excel/template', {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const blob = await response.blob();
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = 'todo_import_template.xlsx';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
document.body.removeChild(a);
|
||||
toast.success('模板下載成功!');
|
||||
} else {
|
||||
toast.error('模板下載失敗');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Download template error:', error);
|
||||
toast.error('模板下載失敗');
|
||||
}
|
||||
};
|
||||
|
||||
const handleFileUpload = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const selectedFile = event.target.files?.[0];
|
||||
if (!selectedFile) return;
|
||||
|
||||
setFile(selectedFile);
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('file', selectedFile);
|
||||
|
||||
const token = localStorage.getItem('access_token');
|
||||
const response = await fetch('/api/excel/upload', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
},
|
||||
body: formData,
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (response.ok) {
|
||||
setParsedData(result.data || []);
|
||||
setParseErrors(result.errors || []);
|
||||
setActiveStep(1);
|
||||
toast.success(`成功解析 ${result.total} 筆資料`);
|
||||
} else {
|
||||
toast.error(result.error || '檔案解析失敗');
|
||||
setParseErrors([result.error || '檔案解析失敗']);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('File upload error:', error);
|
||||
toast.error('檔案上傳失敗');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleImport = async () => {
|
||||
if (parsedData.length === 0) return;
|
||||
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const token = localStorage.getItem('access_token');
|
||||
const response = await fetch('/api/excel/import', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
todos: parsedData
|
||||
}),
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (response.ok) {
|
||||
setImportErrors(result.errors || []);
|
||||
setActiveStep(2);
|
||||
toast.success(`成功匯入 ${result.imported} 筆待辦事項`);
|
||||
if (onImportComplete) {
|
||||
onImportComplete();
|
||||
}
|
||||
} else {
|
||||
toast.error(result.error || '匯入失敗');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Import error:', error);
|
||||
toast.error('匯入失敗');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setActiveStep(0);
|
||||
setFile(null);
|
||||
setParsedData([]);
|
||||
setParseErrors([]);
|
||||
setImportErrors([]);
|
||||
onClose();
|
||||
};
|
||||
|
||||
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 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 renderStepContent = () => {
|
||||
switch (activeStep) {
|
||||
case 0:
|
||||
return (
|
||||
<Box sx={{ textAlign: 'center', py: 4 }}>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
Excel 檔案匯入
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 4 }}>
|
||||
請下載模板,填入待辦事項資料後上傳
|
||||
</Typography>
|
||||
|
||||
<Box sx={{ display: 'flex', gap: 2, justifyContent: 'center', mb: 4 }}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
startIcon={<Download />}
|
||||
onClick={handleDownloadTemplate}
|
||||
sx={{ px: 3 }}
|
||||
>
|
||||
下載模板
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
border: '2px dashed',
|
||||
borderColor: actualTheme === 'dark' ? '#374151' : '#d1d5db',
|
||||
borderRadius: 2,
|
||||
p: 4,
|
||||
cursor: 'pointer',
|
||||
'&:hover': {
|
||||
borderColor: 'primary.main',
|
||||
backgroundColor: actualTheme === 'dark' ? 'rgba(59, 130, 246, 0.05)' : 'rgba(59, 130, 246, 0.02)',
|
||||
},
|
||||
}}
|
||||
onClick={() => document.getElementById('file-input')?.click()}
|
||||
>
|
||||
<input
|
||||
id="file-input"
|
||||
type="file"
|
||||
accept=".xlsx,.xls,.csv"
|
||||
style={{ display: 'none' }}
|
||||
onChange={handleFileUpload}
|
||||
/>
|
||||
<CloudUpload sx={{ fontSize: 48, color: 'text.secondary', mb: 2 }} />
|
||||
<Typography variant="h6" gutterBottom>
|
||||
點擊上傳檔案
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
支援 .xlsx, .xls, .csv 格式
|
||||
</Typography>
|
||||
{file && (
|
||||
<Typography variant="body2" sx={{ mt: 2, fontWeight: 600 }}>
|
||||
已選擇: {file.name}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
|
||||
case 1:
|
||||
return (
|
||||
<Box>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
預覽資料 ({parsedData.length} 筆)
|
||||
</Typography>
|
||||
|
||||
{parseErrors.length > 0 && (
|
||||
<Alert severity="warning" sx={{ mb: 2 }}>
|
||||
<Typography variant="subtitle2">解析警告:</Typography>
|
||||
<ul style={{ margin: '8px 0', paddingLeft: '20px' }}>
|
||||
{parseErrors.map((error, index) => (
|
||||
<li key={index}>{error}</li>
|
||||
))}
|
||||
</ul>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<TableContainer component={Paper} sx={{ maxHeight: 400, mb: 2 }}>
|
||||
<Table stickyHeader size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>行</TableCell>
|
||||
<TableCell>標題</TableCell>
|
||||
<TableCell>狀態</TableCell>
|
||||
<TableCell>優先級</TableCell>
|
||||
<TableCell>到期日</TableCell>
|
||||
<TableCell>負責人</TableCell>
|
||||
<TableCell>公開設定</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{parsedData.map((todo, index) => (
|
||||
<TableRow key={index}>
|
||||
<TableCell>{todo.row}</TableCell>
|
||||
<TableCell>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600 }}>
|
||||
{todo.title}
|
||||
</Typography>
|
||||
{todo.description && (
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block' }}>
|
||||
{todo.description.substring(0, 50)}{todo.description.length > 50 ? '...' : ''}
|
||||
</Typography>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Chip
|
||||
label={todo.status}
|
||||
size="small"
|
||||
sx={{
|
||||
backgroundColor: `${getStatusColor(todo.status)}15`,
|
||||
color: getStatusColor(todo.status),
|
||||
}}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Chip
|
||||
label={todo.priority}
|
||||
size="small"
|
||||
sx={{
|
||||
backgroundColor: `${getPriorityColor(todo.priority)}15`,
|
||||
color: getPriorityColor(todo.priority),
|
||||
}}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Typography variant="body2">
|
||||
{todo.due_date || '-'}
|
||||
</Typography>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Typography variant="body2">
|
||||
{(todo.responsible_users && todo.responsible_users.length > 0) ? todo.responsible_users.join(', ') : '-'}
|
||||
</Typography>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Typography variant="body2">
|
||||
{todo.is_public ? '是' : '否'}
|
||||
</Typography>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</Box>
|
||||
);
|
||||
|
||||
case 2:
|
||||
return (
|
||||
<Box sx={{ textAlign: 'center', py: 4 }}>
|
||||
<CheckCircle sx={{ fontSize: 64, color: 'success.main', mb: 2 }} />
|
||||
<Typography variant="h6" gutterBottom>
|
||||
匯入完成!
|
||||
</Typography>
|
||||
|
||||
{importErrors.length > 0 && (
|
||||
<Alert severity="warning" sx={{ mt: 2 }}>
|
||||
<Typography variant="subtitle2">部分資料匯入失敗:</Typography>
|
||||
<ul style={{ margin: '8px 0', paddingLeft: '20px' }}>
|
||||
{importErrors.map((error, index) => (
|
||||
<li key={index}>第 {error.row} 行: {error.error}</li>
|
||||
))}
|
||||
</ul>
|
||||
</Alert>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onClose={handleClose}
|
||||
maxWidth="lg"
|
||||
fullWidth
|
||||
sx={{
|
||||
'& .MuiDialog-paper': {
|
||||
backgroundColor: actualTheme === 'dark' ? '#1f2937' : '#ffffff',
|
||||
borderRadius: 2,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<DialogTitle>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<Typography variant="h6">Excel 匯入</Typography>
|
||||
<Stepper activeStep={activeStep} sx={{ flex: 1, mx: 4 }}>
|
||||
{steps.map((label) => (
|
||||
<Step key={label}>
|
||||
<StepLabel>{label}</StepLabel>
|
||||
</Step>
|
||||
))}
|
||||
</Stepper>
|
||||
</Box>
|
||||
</DialogTitle>
|
||||
|
||||
<DialogContent>
|
||||
{loading && <LinearProgress sx={{ mb: 2 }} />}
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
key={activeStep}
|
||||
initial={{ opacity: 0, x: 20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: -20 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
{renderStepContent()}
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</DialogContent>
|
||||
|
||||
<DialogActions>
|
||||
<Button onClick={handleClose}>取消</Button>
|
||||
{activeStep === 1 && (
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={handleImport}
|
||||
disabled={loading || parsedData.length === 0}
|
||||
startIcon={<CloudUpload />}
|
||||
>
|
||||
匯入資料
|
||||
</Button>
|
||||
)}
|
||||
{activeStep === 2 && (
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={handleClose}
|
||||
color="success"
|
||||
>
|
||||
完成
|
||||
</Button>
|
||||
)}
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default ExcelImport;
|
213
frontend/src/components/todos/SearchBar.tsx
Normal file
213
frontend/src/components/todos/SearchBar.tsx
Normal file
@@ -0,0 +1,213 @@
|
||||
'use client';
|
||||
|
||||
import React, { useRef, useEffect, useState } from 'react';
|
||||
import {
|
||||
Card,
|
||||
TextField,
|
||||
InputAdornment,
|
||||
IconButton,
|
||||
Box,
|
||||
Typography,
|
||||
Chip,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Search,
|
||||
Close,
|
||||
History,
|
||||
TrendingUp,
|
||||
} from '@mui/icons-material';
|
||||
import { motion } from 'framer-motion';
|
||||
import { useTheme } from '@/providers/ThemeProvider';
|
||||
|
||||
interface SearchBarProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const SearchBar: React.FC<SearchBarProps> = ({ value, onChange, onClose }) => {
|
||||
const { actualTheme } = useTheme();
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const [recentSearches, setRecentSearches] = useState<string[]>([]);
|
||||
|
||||
// 自動 focus
|
||||
useEffect(() => {
|
||||
if (inputRef.current) {
|
||||
inputRef.current.focus();
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 從 localStorage 載入搜索歷史
|
||||
useEffect(() => {
|
||||
try {
|
||||
const storedSearches = localStorage.getItem('recent_searches');
|
||||
if (storedSearches) {
|
||||
const searches = JSON.parse(storedSearches);
|
||||
setRecentSearches(Array.isArray(searches) ? searches.slice(0, 6) : []);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load search history:', error);
|
||||
setRecentSearches([]);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 儲存搜索到歷史記錄
|
||||
const saveSearch = (searchTerm: string) => {
|
||||
if (!searchTerm.trim()) return;
|
||||
|
||||
try {
|
||||
const updatedSearches = [searchTerm, ...recentSearches.filter(s => s !== searchTerm)].slice(0, 6);
|
||||
localStorage.setItem('recent_searches', JSON.stringify(updatedSearches));
|
||||
setRecentSearches(updatedSearches);
|
||||
} catch (error) {
|
||||
console.error('Failed to save search history:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (event: React.KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
onClose();
|
||||
} else if (event.key === 'Enter' && value.trim()) {
|
||||
saveSearch(value.trim());
|
||||
}
|
||||
};
|
||||
|
||||
const handleSearchSelect = (searchTerm: string) => {
|
||||
onChange(searchTerm);
|
||||
saveSearch(searchTerm);
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -20 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
<Card
|
||||
sx={{
|
||||
backgroundColor: actualTheme === 'dark' ? '#1f2937' : '#ffffff',
|
||||
border: `1px solid ${actualTheme === 'dark'
|
||||
? 'rgba(255, 255, 255, 0.1)'
|
||||
: 'rgba(0, 0, 0, 0.1)'}`,
|
||||
boxShadow: '0 4px 20px rgba(0, 0, 0, 0.1)',
|
||||
}}
|
||||
>
|
||||
<Box sx={{ p: 3 }}>
|
||||
<TextField
|
||||
ref={inputRef}
|
||||
fullWidth
|
||||
placeholder="搜尋待辦事項..."
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
InputProps={{
|
||||
startAdornment: (
|
||||
<InputAdornment position="start">
|
||||
<Search sx={{ color: 'text.secondary' }} />
|
||||
</InputAdornment>
|
||||
),
|
||||
endAdornment: value && (
|
||||
<InputAdornment position="end">
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => onChange('')}
|
||||
sx={{ color: 'text.secondary' }}
|
||||
>
|
||||
<Close fontSize="small" />
|
||||
</IconButton>
|
||||
</InputAdornment>
|
||||
),
|
||||
}}
|
||||
sx={{
|
||||
'& .MuiOutlinedInput-root': {
|
||||
borderRadius: 2,
|
||||
backgroundColor: actualTheme === 'dark'
|
||||
? 'rgba(255, 255, 255, 0.05)'
|
||||
: 'rgba(0, 0, 0, 0.02)',
|
||||
'&:hover': {
|
||||
backgroundColor: actualTheme === 'dark'
|
||||
? 'rgba(255, 255, 255, 0.08)'
|
||||
: 'rgba(0, 0, 0, 0.04)',
|
||||
},
|
||||
'&.Mui-focused': {
|
||||
backgroundColor: actualTheme === 'dark'
|
||||
? 'rgba(255, 255, 255, 0.1)'
|
||||
: 'rgba(0, 0, 0, 0.06)',
|
||||
boxShadow: '0 0 0 2px rgba(59, 130, 246, 0.2)',
|
||||
},
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
{!value && (
|
||||
<Box sx={{ mt: 3 }}>
|
||||
{/* 最近搜尋 */}
|
||||
{recentSearches.length > 0 && (
|
||||
<Box sx={{ mb: 3 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
|
||||
<History sx={{ fontSize: 18, color: 'text.secondary' }} />
|
||||
<Typography variant="subtitle2" color="text.secondary">
|
||||
最近搜尋
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
|
||||
{recentSearches.map((search, index) => (
|
||||
<motion.div
|
||||
key={index}
|
||||
initial={{ opacity: 0, scale: 0.9 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ delay: index * 0.1 }}
|
||||
>
|
||||
<Chip
|
||||
label={search}
|
||||
variant="outlined"
|
||||
size="small"
|
||||
clickable
|
||||
onClick={() => handleSearchSelect(search)}
|
||||
sx={{
|
||||
'&:hover': {
|
||||
backgroundColor: actualTheme === 'dark'
|
||||
? 'rgba(59, 130, 246, 0.1)'
|
||||
: 'rgba(59, 130, 246, 0.05)',
|
||||
borderColor: 'primary.main',
|
||||
transform: 'translateY(-1px)',
|
||||
},
|
||||
transition: 'all 0.2s ease',
|
||||
}}
|
||||
/>
|
||||
</motion.div>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* 如果沒有搜索歷史,顯示提示 */}
|
||||
{recentSearches.length === 0 && (
|
||||
<Box sx={{ textAlign: 'center', py: 4 }}>
|
||||
<Search sx={{ fontSize: 48, color: 'text.secondary', mb: 2, opacity: 0.5 }} />
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
開始輸入以搜尋待辦事項
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ mt: 0.5, display: 'block' }}>
|
||||
您的搜索歷史將會顯示在這裡
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{value && (
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
按 Enter 搜尋 "{value}" 或 Esc 取消
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Card>
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SearchBar;
|
715
frontend/src/components/todos/TodoDialog.tsx
Normal file
715
frontend/src/components/todos/TodoDialog.tsx
Normal file
@@ -0,0 +1,715 @@
|
||||
'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[];
|
||||
isPublic: boolean;
|
||||
tags?: string[];
|
||||
}
|
||||
|
||||
interface TodoDialogProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
todo?: LocalTodo;
|
||||
mode?: 'create' | 'edit';
|
||||
onSave?: (todo: GlobalTodo) => void;
|
||||
onTodoCreated?: () => void;
|
||||
}
|
||||
|
||||
const TodoDialog: React.FC<TodoDialogProps> = ({
|
||||
open,
|
||||
onClose,
|
||||
todo,
|
||||
mode = 'create',
|
||||
onSave,
|
||||
onTodoCreated
|
||||
}) => {
|
||||
const { actualTheme } = useTheme();
|
||||
const { user } = useAuth();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
// 表單狀態
|
||||
const [formData, setFormData] = useState<LocalTodo>({
|
||||
title: '',
|
||||
description: '',
|
||||
status: 'NEW',
|
||||
priority: 'MEDIUM',
|
||||
dueDate: null,
|
||||
starred: false,
|
||||
responsible: [],
|
||||
isPublic: false, // 預設為非公開
|
||||
});
|
||||
|
||||
const [assignToMyself, setAssignToMyself] = useState(false);
|
||||
|
||||
// 用戶資料
|
||||
const [availableUsers, setAvailableUsers] = useState<User[]>([]);
|
||||
const [loadingUsers, setLoadingUsers] = useState(false);
|
||||
const [searchTimeout, setSearchTimeout] = useState<NodeJS.Timeout | null>(null);
|
||||
|
||||
// 搜尋用戶 (帶防抖功能)
|
||||
const searchUsers = (searchTerm: string) => {
|
||||
// 清除之前的搜尋計時器
|
||||
if (searchTimeout) {
|
||||
clearTimeout(searchTimeout);
|
||||
}
|
||||
|
||||
if (!searchTerm.trim()) {
|
||||
setAvailableUsers([]);
|
||||
setLoadingUsers(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// 設定新的搜尋計時器
|
||||
const timeout = setTimeout(async () => {
|
||||
try {
|
||||
setLoadingUsers(true);
|
||||
const users = await usersApi.searchUsers(searchTerm);
|
||||
const transformedUsers = users.map(user => ({
|
||||
id: user.ad_account || user.email,
|
||||
name: user.display_name || user.ad_account || '',
|
||||
email: user.email || '',
|
||||
avatar: (user.display_name || user.ad_account || '').charAt(0).toUpperCase(),
|
||||
department: '員工'
|
||||
}));
|
||||
setAvailableUsers(transformedUsers);
|
||||
} catch (error) {
|
||||
console.error('Failed to search users:', error);
|
||||
setAvailableUsers([]);
|
||||
} finally {
|
||||
setLoadingUsers(false);
|
||||
}
|
||||
}, 300); // 300ms 防抖延遲
|
||||
|
||||
setSearchTimeout(timeout);
|
||||
};
|
||||
|
||||
const statusOptions = [
|
||||
{ value: 'NEW', label: '新建立', color: '#6b7280' },
|
||||
{ value: 'DOING', label: '進行中', color: '#3b82f6' },
|
||||
{ value: 'BLOCKED', label: '已阻塞', color: '#ef4444' },
|
||||
{ value: 'DONE', label: '已完成', color: '#10b981' },
|
||||
];
|
||||
|
||||
const priorityOptions = [
|
||||
{ value: 'LOW', label: '低', color: '#6b7280' },
|
||||
{ value: 'MEDIUM', label: '中', color: '#f59e0b' },
|
||||
{ value: 'HIGH', label: '高', color: '#f97316' },
|
||||
{ value: 'URGENT', label: '緊急', color: '#ef4444' },
|
||||
];
|
||||
|
||||
useEffect(() => {
|
||||
if (todo && mode === 'edit') {
|
||||
// 轉換 API 數據格式為 TodoDialog 期望的格式
|
||||
const apiTodo = todo as any; // 從 API 來的數據格式
|
||||
const editTodo = {
|
||||
...todo,
|
||||
dueDate: apiTodo.due_date ? dayjs(apiTodo.due_date) : null,
|
||||
responsible: (apiTodo.responsible_users || []).map((adAccount: string) => ({
|
||||
id: adAccount,
|
||||
name: adAccount, // 暫時使用 adAccount 作為 name,之後可以從 LDAP 獲取完整資訊
|
||||
email: adAccount,
|
||||
avatar: adAccount.charAt(0).toUpperCase(),
|
||||
department: '員工'
|
||||
})),
|
||||
isPublic: false, // 預設值
|
||||
};
|
||||
setFormData(editTodo);
|
||||
} else {
|
||||
setFormData({
|
||||
title: '',
|
||||
description: '',
|
||||
status: 'NEW',
|
||||
priority: 'MEDIUM',
|
||||
dueDate: null,
|
||||
starred: false,
|
||||
responsible: [],
|
||||
isPublic: false,
|
||||
});
|
||||
}
|
||||
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 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,
|
||||
};
|
||||
|
||||
let savedTodo;
|
||||
if (mode === 'create') {
|
||||
savedTodo = await todosApi.createTodo(todoData);
|
||||
} else if (todo && todo.id) {
|
||||
savedTodo = await todosApi.updateTodo(todo.id, todoData);
|
||||
}
|
||||
|
||||
if (onSave && savedTodo) {
|
||||
onSave(savedTodo);
|
||||
}
|
||||
|
||||
onClose();
|
||||
|
||||
// 在對話框關閉後刷新數據
|
||||
if (onTodoCreated && mode === 'create') {
|
||||
setTimeout(() => {
|
||||
onTodoCreated();
|
||||
}, 100);
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error('Save todo error:', err);
|
||||
setError(err.response?.data?.error || '儲存時發生錯誤,請稍後再試');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const dialogVariants = {
|
||||
hidden: {
|
||||
opacity: 0,
|
||||
scale: 0.8,
|
||||
y: 50,
|
||||
},
|
||||
visible: {
|
||||
opacity: 1,
|
||||
scale: 1,
|
||||
y: 0,
|
||||
transition: {
|
||||
type: 'spring',
|
||||
duration: 0.5,
|
||||
bounce: 0.3,
|
||||
}
|
||||
},
|
||||
exit: {
|
||||
opacity: 0,
|
||||
scale: 0.8,
|
||||
y: 50,
|
||||
transition: {
|
||||
duration: 0.3,
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{open && (
|
||||
<Dialog
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
maxWidth="md"
|
||||
fullWidth
|
||||
PaperProps={{
|
||||
sx: {
|
||||
backgroundColor: actualTheme === 'dark' ? '#1f2937' : '#ffffff',
|
||||
backgroundImage: 'none',
|
||||
borderRadius: 3,
|
||||
minHeight: '70vh',
|
||||
}
|
||||
}}
|
||||
>
|
||||
<motion.div
|
||||
variants={dialogVariants}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
exit="exit"
|
||||
>
|
||||
{/* 對話框標題 */}
|
||||
<DialogTitle
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
pb: 1,
|
||||
background: actualTheme === 'dark'
|
||||
? 'linear-gradient(135deg, #374151 0%, #1f2937 100%)'
|
||||
: 'linear-gradient(135deg, #f8fafc 0%, #e2e8f0 100%)',
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: 48,
|
||||
height: 48,
|
||||
borderRadius: '50%',
|
||||
background: 'linear-gradient(45deg, #3b82f6 30%, #8b5cf6 90%)',
|
||||
color: 'white',
|
||||
}}
|
||||
>
|
||||
<Assignment />
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography variant="h6" sx={{ fontWeight: 700, mb: 0.5 }}>
|
||||
{mode === 'create' ? '新增待辦事項' : '編輯待辦事項'}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{mode === 'create' ? '建立新的待辦任務' : '修改現有的待辦任務'}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
<IconButton
|
||||
onClick={onClose}
|
||||
sx={{
|
||||
color: 'text.secondary',
|
||||
'&:hover': {
|
||||
backgroundColor: actualTheme === 'dark'
|
||||
? 'rgba(255, 255, 255, 0.1)'
|
||||
: 'rgba(0, 0, 0, 0.04)',
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Close />
|
||||
</IconButton>
|
||||
</DialogTitle>
|
||||
|
||||
<Divider />
|
||||
|
||||
<DialogContent sx={{ p: 3 }}>
|
||||
{error && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
style={{ marginBottom: 16 }}
|
||||
>
|
||||
<Alert severity="error" sx={{ borderRadius: 2 }}>
|
||||
{error}
|
||||
</Alert>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
<Grid container spacing={3}>
|
||||
{/* 基本資訊 */}
|
||||
<Grid item xs={12}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
|
||||
<Description sx={{ color: 'primary.main', fontSize: 20 }} />
|
||||
<Typography variant="h6" sx={{ fontWeight: 600 }}>
|
||||
基本資訊
|
||||
</Typography>
|
||||
</Box>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12}>
|
||||
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center' }}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="待辦事項標題"
|
||||
placeholder="請輸入待辦事項標題..."
|
||||
value={formData.title}
|
||||
onChange={(e) => handleInputChange('title', e.target.value)}
|
||||
error={!formData.title.trim() && error.includes('標題')}
|
||||
sx={{
|
||||
'& .MuiOutlinedInput-root': {
|
||||
borderRadius: 2,
|
||||
backgroundColor: actualTheme === 'dark'
|
||||
? 'rgba(255, 255, 255, 0.05)'
|
||||
: 'rgba(0, 0, 0, 0.02)',
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<IconButton
|
||||
onClick={() => handleInputChange('starred', !formData.starred)}
|
||||
sx={{
|
||||
color: formData.starred ? '#fbbf24' : 'text.disabled',
|
||||
'&:hover': {
|
||||
color: '#fbbf24',
|
||||
backgroundColor: 'rgba(251, 191, 36, 0.1)',
|
||||
},
|
||||
}}
|
||||
>
|
||||
{formData.starred ? <Star /> : <StarBorder />}
|
||||
</IconButton>
|
||||
</Box>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12}>
|
||||
<TextField
|
||||
fullWidth
|
||||
multiline
|
||||
rows={3}
|
||||
label="詳細描述"
|
||||
placeholder="請輸入詳細描述..."
|
||||
value={formData.description || ''}
|
||||
onChange={(e) => handleInputChange('description', e.target.value)}
|
||||
sx={{
|
||||
'& .MuiOutlinedInput-root': {
|
||||
borderRadius: 2,
|
||||
backgroundColor: actualTheme === 'dark'
|
||||
? 'rgba(255, 255, 255, 0.05)'
|
||||
: 'rgba(0, 0, 0, 0.02)',
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12} sm={6}>
|
||||
<FormControl fullWidth>
|
||||
<InputLabel>狀態</InputLabel>
|
||||
<Select
|
||||
value={formData.status}
|
||||
label="狀態"
|
||||
onChange={(e) => handleInputChange('status', e.target.value)}
|
||||
sx={{
|
||||
borderRadius: 2,
|
||||
backgroundColor: actualTheme === 'dark'
|
||||
? 'rgba(255, 255, 255, 0.05)'
|
||||
: 'rgba(0, 0, 0, 0.02)',
|
||||
}}
|
||||
>
|
||||
{statusOptions.map((option) => (
|
||||
<MenuItem key={option.value} value={option.value}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Box
|
||||
sx={{
|
||||
width: 12,
|
||||
height: 12,
|
||||
borderRadius: '50%',
|
||||
backgroundColor: option.color,
|
||||
}}
|
||||
/>
|
||||
{option.label}
|
||||
</Box>
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12} sm={6}>
|
||||
<FormControl fullWidth>
|
||||
<InputLabel>優先級</InputLabel>
|
||||
<Select
|
||||
value={formData.priority}
|
||||
label="優先級"
|
||||
onChange={(e) => handleInputChange('priority', e.target.value)}
|
||||
sx={{
|
||||
borderRadius: 2,
|
||||
backgroundColor: actualTheme === 'dark'
|
||||
? 'rgba(255, 255, 255, 0.05)'
|
||||
: 'rgba(0, 0, 0, 0.02)',
|
||||
}}
|
||||
>
|
||||
{priorityOptions.map((option) => (
|
||||
<MenuItem key={option.value} value={option.value}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Flag sx={{ fontSize: 16, color: option.color }} />
|
||||
{option.label}
|
||||
</Box>
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12} sm={6}>
|
||||
<DatePicker
|
||||
label="到期日期"
|
||||
value={formData.dueDate}
|
||||
onChange={(date) => handleInputChange('dueDate', date)}
|
||||
slotProps={{
|
||||
textField: {
|
||||
fullWidth: true,
|
||||
error: false,
|
||||
sx: {
|
||||
'& .MuiOutlinedInput-root': {
|
||||
borderRadius: 2,
|
||||
backgroundColor: actualTheme === 'dark'
|
||||
? 'rgba(255, 255, 255, 0.05)'
|
||||
: 'rgba(0, 0, 0, 0.02)',
|
||||
}
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
|
||||
{/* 人員指派 */}
|
||||
<Grid item xs={12}>
|
||||
<Divider sx={{ my: 2 }} />
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
|
||||
<Person sx={{ color: 'primary.main', fontSize: 20 }} />
|
||||
<Typography variant="h6" sx={{ fontWeight: 600 }}>
|
||||
人員指派
|
||||
</Typography>
|
||||
</Box>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12}>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={assignToMyself}
|
||||
onChange={(e) => {
|
||||
setAssignToMyself(e.target.checked);
|
||||
if (e.target.checked) {
|
||||
// 清空已選的負責人
|
||||
handleInputChange('responsible', []);
|
||||
}
|
||||
}}
|
||||
color="primary"
|
||||
/>
|
||||
}
|
||||
label="指派給自己"
|
||||
sx={{ mb: 2 }}
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
{!assignToMyself && (
|
||||
<Grid item xs={12}>
|
||||
<Autocomplete
|
||||
multiple
|
||||
options={availableUsers}
|
||||
value={formData.responsible}
|
||||
onChange={(_, newValue) => handleInputChange('responsible', newValue)}
|
||||
onInputChange={(_, value) => searchUsers(value)}
|
||||
loading={loadingUsers}
|
||||
getOptionLabel={(option) => `${option.name} (${option.department})`}
|
||||
isOptionEqualToValue={(option, value) => option.id === value.id}
|
||||
disableCloseOnSelect
|
||||
disabledItemsFocusable={false}
|
||||
forcePopupIcon={false}
|
||||
clearOnBlur={false}
|
||||
noOptionsText="輸入帳號或姓名進行搜尋"
|
||||
loadingText="搜尋中..."
|
||||
renderOption={(props, option) => {
|
||||
const { key, ...otherProps } = props;
|
||||
return (
|
||||
<li key={key} {...otherProps}>
|
||||
<Avatar sx={{ width: 32, height: 32, mr: 1, fontSize: '0.9rem' }}>
|
||||
{option.avatar}
|
||||
</Avatar>
|
||||
<Box>
|
||||
<Typography variant="body2">{option.name}</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{option.department}
|
||||
</Typography>
|
||||
</Box>
|
||||
</li>
|
||||
);
|
||||
}}
|
||||
renderTags={(value, getTagProps) =>
|
||||
value.map((option, index) => (
|
||||
<Chip
|
||||
{...getTagProps({ index })}
|
||||
key={option.id}
|
||||
avatar={<Avatar sx={{ fontSize: '0.8rem' }}>{option.avatar}</Avatar>}
|
||||
label={option.name}
|
||||
size="small"
|
||||
sx={{ borderRadius: 2 }}
|
||||
/>
|
||||
))
|
||||
}
|
||||
renderInput={(params) => (
|
||||
<TextField
|
||||
{...params}
|
||||
label="負責人 (可選擇多人)"
|
||||
placeholder="選擇負責人..."
|
||||
error={(formData.responsible || []).length === 0 && error.includes('負責人')}
|
||||
helperText="可以指派多位負責人共同處理此待辦事項"
|
||||
sx={{
|
||||
'& .MuiOutlinedInput-root': {
|
||||
borderRadius: 2,
|
||||
backgroundColor: actualTheme === 'dark'
|
||||
? 'rgba(255, 255, 255, 0.05)'
|
||||
: 'rgba(0, 0, 0, 0.02)',
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Grid>
|
||||
)}
|
||||
|
||||
{/* 設定 */}
|
||||
<Grid item xs={12}>
|
||||
<Divider sx={{ my: 2 }} />
|
||||
<Typography variant="h6" sx={{ fontWeight: 600, mb: 2 }}>
|
||||
設定
|
||||
</Typography>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12}>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={formData.isPublic}
|
||||
onChange={(e) => handleInputChange('isPublic', e.target.checked)}
|
||||
color="primary"
|
||||
/>
|
||||
}
|
||||
label={
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Visibility sx={{ fontSize: 18, color: 'text.secondary' }} />
|
||||
<Typography>
|
||||
公開此待辦事項
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block' }}>
|
||||
其他用戶可以查看此待辦事項
|
||||
</Typography>
|
||||
</Typography>
|
||||
</Box>
|
||||
}
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</DialogContent>
|
||||
|
||||
<Divider />
|
||||
|
||||
<DialogActions sx={{ p: 3, gap: 1 }}>
|
||||
<Button
|
||||
onClick={onClose}
|
||||
variant="outlined"
|
||||
sx={{
|
||||
borderRadius: 2,
|
||||
textTransform: 'none',
|
||||
fontWeight: 600,
|
||||
px: 3,
|
||||
}}
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
variant="contained"
|
||||
disabled={loading}
|
||||
startIcon={loading ? <CircularProgress size={18} /> : <Save />}
|
||||
sx={{
|
||||
background: 'linear-gradient(45deg, #3b82f6 30%, #8b5cf6 90%)',
|
||||
borderRadius: 2,
|
||||
textTransform: 'none',
|
||||
fontWeight: 600,
|
||||
px: 3,
|
||||
'&:hover': {
|
||||
background: 'linear-gradient(45deg, #2563eb 30%, #7c3aed 90%)',
|
||||
boxShadow: '0 4px 12px rgba(59, 130, 246, 0.3)',
|
||||
},
|
||||
'&:disabled': {
|
||||
background: 'rgba(0, 0, 0, 0.12)',
|
||||
},
|
||||
}}
|
||||
>
|
||||
{loading ? '儲存中...' : '儲存'}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</motion.div>
|
||||
</Dialog>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
};
|
||||
|
||||
export default TodoDialog;
|
472
frontend/src/components/todos/TodoFilters.tsx
Normal file
472
frontend/src/components/todos/TodoFilters.tsx
Normal file
@@ -0,0 +1,472 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Card,
|
||||
Box,
|
||||
Typography,
|
||||
FormControl,
|
||||
InputLabel,
|
||||
Select,
|
||||
MenuItem,
|
||||
Chip,
|
||||
Button,
|
||||
IconButton,
|
||||
Divider,
|
||||
Grid,
|
||||
Accordion,
|
||||
AccordionSummary,
|
||||
AccordionDetails,
|
||||
FormControlLabel,
|
||||
Switch,
|
||||
Slider,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Close,
|
||||
ExpandMore,
|
||||
FilterList,
|
||||
Refresh,
|
||||
Tune,
|
||||
Schedule,
|
||||
Flag,
|
||||
Person,
|
||||
} from '@mui/icons-material';
|
||||
import { DatePicker } from '@mui/x-date-pickers/DatePicker';
|
||||
import { motion } from 'framer-motion';
|
||||
import { useTheme } from '@/providers/ThemeProvider';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
interface TodoFiltersProps {
|
||||
onClose: () => void;
|
||||
onApply: (filters: any) => void;
|
||||
initialFilters?: any;
|
||||
}
|
||||
|
||||
const TodoFilters: React.FC<TodoFiltersProps> = ({ onClose, onApply, initialFilters }) => {
|
||||
const { actualTheme } = useTheme();
|
||||
|
||||
// 篩選狀態
|
||||
const [filters, setFilters] = useState({
|
||||
status: initialFilters?.status || [] as string[],
|
||||
priority: initialFilters?.priority || [] as string[],
|
||||
assignee: initialFilters?.assignee || '',
|
||||
dateFrom: initialFilters?.dateFrom ? dayjs(initialFilters.dateFrom) : null as dayjs.Dayjs | null,
|
||||
dateTo: initialFilters?.dateTo ? dayjs(initialFilters.dateTo) : null as dayjs.Dayjs | null,
|
||||
starred: initialFilters?.starred || false,
|
||||
overdue: initialFilters?.overdue || false,
|
||||
dueSoon: initialFilters?.dueSoon || false,
|
||||
});
|
||||
|
||||
const statusOptions = [
|
||||
{ value: 'NEW', label: '新建立', color: '#6b7280' },
|
||||
{ value: 'DOING', label: '進行中', color: '#3b82f6' },
|
||||
{ value: 'BLOCKED', label: '已阻塞', color: '#ef4444' },
|
||||
{ value: 'DONE', label: '已完成', color: '#10b981' },
|
||||
];
|
||||
|
||||
const priorityOptions = [
|
||||
{ value: 'LOW', label: '低', color: '#6b7280' },
|
||||
{ value: 'MEDIUM', label: '中', color: '#f59e0b' },
|
||||
{ value: 'HIGH', label: '高', color: '#f97316' },
|
||||
{ value: 'URGENT', label: '緊急', color: '#ef4444' },
|
||||
];
|
||||
|
||||
const assigneeOptions = [
|
||||
{ value: '', label: '所有人' },
|
||||
{ value: 'me', label: '指派給我' },
|
||||
{ value: 'created_by_me', label: '我建立的' },
|
||||
{ value: 'followed_by_me', label: '我追蹤的' },
|
||||
];
|
||||
|
||||
const handleStatusToggle = (status: string) => {
|
||||
setFilters(prev => ({
|
||||
...prev,
|
||||
status: prev.status.includes(status)
|
||||
? prev.status.filter((s: string) => s !== status)
|
||||
: [...prev.status, status]
|
||||
}));
|
||||
};
|
||||
|
||||
const handlePriorityToggle = (priority: string) => {
|
||||
setFilters(prev => ({
|
||||
...prev,
|
||||
priority: prev.priority.includes(priority)
|
||||
? prev.priority.filter((p: string) => p !== priority)
|
||||
: [...prev.priority, priority]
|
||||
}));
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
const resetFilters = {
|
||||
status: [],
|
||||
priority: [],
|
||||
assignee: '',
|
||||
dateFrom: null,
|
||||
dateTo: null,
|
||||
starred: false,
|
||||
overdue: false,
|
||||
dueSoon: false,
|
||||
};
|
||||
setFilters(resetFilters);
|
||||
onApply(resetFilters);
|
||||
};
|
||||
|
||||
const getActiveFilterCount = () => {
|
||||
let count = 0;
|
||||
if (filters.status.length > 0) count++;
|
||||
if (filters.priority.length > 0) count++;
|
||||
if (filters.assignee) count++;
|
||||
if (filters.dateFrom || filters.dateTo) count++;
|
||||
if (filters.starred || filters.overdue || filters.dueSoon) count++;
|
||||
return count;
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -20 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
<Card
|
||||
sx={{
|
||||
backgroundColor: actualTheme === 'dark' ? '#1f2937' : '#ffffff',
|
||||
border: `1px solid ${actualTheme === 'dark'
|
||||
? 'rgba(255, 255, 255, 0.1)'
|
||||
: 'rgba(0, 0, 0, 0.1)'}`,
|
||||
boxShadow: '0 4px 20px rgba(0, 0, 0, 0.1)',
|
||||
}}
|
||||
>
|
||||
{/* 標題區域 */}
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
p: 3,
|
||||
pb: 2,
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<FilterList sx={{ color: 'primary.main' }} />
|
||||
<Typography variant="h6" sx={{ fontWeight: 600 }}>
|
||||
進階篩選
|
||||
</Typography>
|
||||
{getActiveFilterCount() > 0 && (
|
||||
<Chip
|
||||
label={`${getActiveFilterCount()} 個篩選器`}
|
||||
color="primary"
|
||||
size="small"
|
||||
sx={{ ml: 1 }}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: 'flex', gap: 1 }}>
|
||||
<Button
|
||||
size="small"
|
||||
startIcon={<Refresh />}
|
||||
onClick={handleReset}
|
||||
disabled={getActiveFilterCount() === 0}
|
||||
>
|
||||
重置
|
||||
</Button>
|
||||
<IconButton size="small" onClick={onClose}>
|
||||
<Close />
|
||||
</IconButton>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Divider />
|
||||
|
||||
<Box sx={{ p: 3 }}>
|
||||
<Grid container spacing={3}>
|
||||
{/* 狀態篩選 */}
|
||||
<Grid item xs={12} md={6}>
|
||||
<Accordion
|
||||
defaultExpanded
|
||||
elevation={0}
|
||||
sx={{
|
||||
backgroundColor: 'transparent',
|
||||
'&:before': { display: 'none' },
|
||||
}}
|
||||
>
|
||||
<AccordionSummary expandIcon={<ExpandMore />}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Tune sx={{ fontSize: 20, color: 'text.secondary' }} />
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
|
||||
狀態
|
||||
</Typography>
|
||||
</Box>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails sx={{ pt: 0 }}>
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
|
||||
{statusOptions.map((option) => (
|
||||
<Chip
|
||||
key={option.value}
|
||||
label={option.label}
|
||||
variant={filters.status.includes(option.value) ? 'filled' : 'outlined'}
|
||||
clickable
|
||||
onClick={() => handleStatusToggle(option.value)}
|
||||
sx={{
|
||||
backgroundColor: filters.status.includes(option.value)
|
||||
? `${option.color}15`
|
||||
: 'transparent',
|
||||
color: filters.status.includes(option.value)
|
||||
? option.color
|
||||
: 'text.primary',
|
||||
borderColor: option.color,
|
||||
'&:hover': {
|
||||
backgroundColor: `${option.color}20`,
|
||||
transform: 'translateY(-1px)',
|
||||
},
|
||||
transition: 'all 0.2s ease',
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
</Grid>
|
||||
|
||||
{/* 優先級篩選 */}
|
||||
<Grid item xs={12} md={6}>
|
||||
<Accordion
|
||||
defaultExpanded
|
||||
elevation={0}
|
||||
sx={{
|
||||
backgroundColor: 'transparent',
|
||||
'&:before': { display: 'none' },
|
||||
}}
|
||||
>
|
||||
<AccordionSummary expandIcon={<ExpandMore />}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Flag sx={{ fontSize: 20, color: 'text.secondary' }} />
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
|
||||
優先級
|
||||
</Typography>
|
||||
</Box>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails sx={{ pt: 0 }}>
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
|
||||
{priorityOptions.map((option) => (
|
||||
<Chip
|
||||
key={option.value}
|
||||
label={option.label}
|
||||
variant={filters.priority.includes(option.value) ? 'filled' : 'outlined'}
|
||||
clickable
|
||||
onClick={() => handlePriorityToggle(option.value)}
|
||||
sx={{
|
||||
backgroundColor: filters.priority.includes(option.value)
|
||||
? `${option.color}15`
|
||||
: 'transparent',
|
||||
color: filters.priority.includes(option.value)
|
||||
? option.color
|
||||
: 'text.primary',
|
||||
borderColor: option.color,
|
||||
'&:hover': {
|
||||
backgroundColor: `${option.color}20`,
|
||||
transform: 'translateY(-1px)',
|
||||
},
|
||||
transition: 'all 0.2s ease',
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
</Grid>
|
||||
|
||||
{/* 指派人篩選 */}
|
||||
<Grid item xs={12} md={6}>
|
||||
<Accordion
|
||||
elevation={0}
|
||||
sx={{
|
||||
backgroundColor: 'transparent',
|
||||
'&:before': { display: 'none' },
|
||||
}}
|
||||
>
|
||||
<AccordionSummary expandIcon={<ExpandMore />}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Person sx={{ fontSize: 20, color: 'text.secondary' }} />
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
|
||||
指派人
|
||||
</Typography>
|
||||
</Box>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails sx={{ pt: 0 }}>
|
||||
<FormControl fullWidth size="small">
|
||||
<Select
|
||||
value={filters.assignee}
|
||||
onChange={(e) => setFilters(prev => ({ ...prev, assignee: e.target.value }))}
|
||||
sx={{
|
||||
backgroundColor: actualTheme === 'dark'
|
||||
? 'rgba(255, 255, 255, 0.05)'
|
||||
: 'rgba(0, 0, 0, 0.02)',
|
||||
}}
|
||||
>
|
||||
{assigneeOptions.map((option) => (
|
||||
<MenuItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
</Grid>
|
||||
|
||||
{/* 日期範圍 */}
|
||||
<Grid item xs={12} md={6}>
|
||||
<Accordion
|
||||
elevation={0}
|
||||
sx={{
|
||||
backgroundColor: 'transparent',
|
||||
'&:before': { display: 'none' },
|
||||
}}
|
||||
>
|
||||
<AccordionSummary expandIcon={<ExpandMore />}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Schedule sx={{ fontSize: 20, color: 'text.secondary' }} />
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
|
||||
到期日期
|
||||
</Typography>
|
||||
</Box>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails sx={{ pt: 0 }}>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
<DatePicker
|
||||
label="開始日期"
|
||||
value={filters.dateFrom}
|
||||
onChange={(date) => setFilters(prev => ({ ...prev, dateFrom: date }))}
|
||||
slotProps={{
|
||||
textField: {
|
||||
size: 'small',
|
||||
fullWidth: true,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<DatePicker
|
||||
label="結束日期"
|
||||
value={filters.dateTo}
|
||||
onChange={(date) => setFilters(prev => ({ ...prev, dateTo: date }))}
|
||||
slotProps={{
|
||||
textField: {
|
||||
size: 'small',
|
||||
fullWidth: true,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
</Grid>
|
||||
|
||||
{/* 特殊篩選 */}
|
||||
<Grid item xs={12}>
|
||||
<Accordion
|
||||
elevation={0}
|
||||
sx={{
|
||||
backgroundColor: 'transparent',
|
||||
'&:before': { display: 'none' },
|
||||
}}
|
||||
>
|
||||
<AccordionSummary expandIcon={<ExpandMore />}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
|
||||
特殊篩選
|
||||
</Typography>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails sx={{ pt: 0 }}>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={filters.starred}
|
||||
onChange={(e) => setFilters(prev => ({ ...prev, starred: e.target.checked }))}
|
||||
color="primary"
|
||||
/>
|
||||
}
|
||||
label="僅顯示已加星項目"
|
||||
/>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={filters.overdue}
|
||||
onChange={(e) => setFilters(prev => ({ ...prev, overdue: e.target.checked }))}
|
||||
color="error"
|
||||
/>
|
||||
}
|
||||
label="僅顯示逾期項目"
|
||||
/>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={filters.dueSoon}
|
||||
onChange={(e) => setFilters(prev => ({ ...prev, dueSoon: e.target.checked }))}
|
||||
color="warning"
|
||||
/>
|
||||
}
|
||||
label="僅顯示即將到期項目"
|
||||
/>
|
||||
</Box>
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
{/* 底部操作按鈕 */}
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
mt: 3,
|
||||
pt: 2,
|
||||
borderTop: `1px solid ${actualTheme === 'dark'
|
||||
? 'rgba(255, 255, 255, 0.1)'
|
||||
: 'rgba(0, 0, 0, 0.1)'}`,
|
||||
}}
|
||||
>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{getActiveFilterCount() > 0
|
||||
? `${getActiveFilterCount()} 個篩選器已套用`
|
||||
: '沒有套用篩選器'}
|
||||
</Typography>
|
||||
|
||||
<Box sx={{ display: 'flex', gap: 1 }}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
size="small"
|
||||
onClick={onClose}
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
size="small"
|
||||
onClick={() => {
|
||||
const filtersToApply = {
|
||||
...filters,
|
||||
dateFrom: filters.dateFrom ? filters.dateFrom.toDate() : null,
|
||||
dateTo: filters.dateTo ? filters.dateTo.toDate() : null,
|
||||
};
|
||||
console.log('Applying filters:', filtersToApply);
|
||||
onApply(filtersToApply);
|
||||
// 不要立即關閉,讓使用者可以看到篩選結果
|
||||
// onClose();
|
||||
}}
|
||||
sx={{
|
||||
background: 'linear-gradient(45deg, #3b82f6 30%, #8b5cf6 90%)',
|
||||
}}
|
||||
>
|
||||
套用篩選
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</Card>
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TodoFilters;
|
550
frontend/src/components/todos/TodoList.tsx
Normal file
550
frontend/src/components/todos/TodoList.tsx
Normal file
@@ -0,0 +1,550 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
Box,
|
||||
Card,
|
||||
CardContent,
|
||||
Typography,
|
||||
Checkbox,
|
||||
Chip,
|
||||
IconButton,
|
||||
Menu,
|
||||
MenuItem,
|
||||
Tooltip,
|
||||
Badge,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Star,
|
||||
StarBorder,
|
||||
MoreVert,
|
||||
CalendarToday,
|
||||
Person,
|
||||
Edit,
|
||||
Delete,
|
||||
Flag,
|
||||
PlayCircle,
|
||||
PauseCircle,
|
||||
CheckCircle,
|
||||
NotificationImportant,
|
||||
Public,
|
||||
Lock,
|
||||
} from '@mui/icons-material';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { useTheme } from '@/providers/ThemeProvider';
|
||||
import { Todo } from '@/types';
|
||||
import { todosApi } from '@/lib/api';
|
||||
import { toast } from 'react-hot-toast';
|
||||
|
||||
interface TodoListProps {
|
||||
todos: Todo[];
|
||||
selectedTodos: string[];
|
||||
onSelectionChange: (selected: string[]) => void;
|
||||
viewMode: 'list' | 'calendar';
|
||||
onEditTodo?: (todo: Todo) => void;
|
||||
onStatusChange?: (todoId: string, status: string) => void;
|
||||
}
|
||||
|
||||
const TodoList: React.FC<TodoListProps> = ({
|
||||
todos,
|
||||
selectedTodos,
|
||||
onSelectionChange,
|
||||
viewMode,
|
||||
onEditTodo,
|
||||
onStatusChange,
|
||||
}) => {
|
||||
const { actualTheme } = useTheme();
|
||||
const [menuAnchor, setMenuAnchor] = React.useState<{ [key: string]: HTMLElement | null }>({});
|
||||
|
||||
const getPriorityColor = (priority: string) => {
|
||||
switch (priority) {
|
||||
case 'URGENT': return '#ef4444';
|
||||
case 'HIGH': return '#f97316';
|
||||
case 'MEDIUM': return '#f59e0b';
|
||||
case 'LOW': return '#6b7280';
|
||||
default: return '#6b7280';
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'NEW': return '#6b7280';
|
||||
case 'DOING': return '#3b82f6';
|
||||
case 'BLOCKED': return '#ef4444';
|
||||
case 'DONE': return '#10b981';
|
||||
default: return '#6b7280';
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusIcon = (status: string) => {
|
||||
switch (status) {
|
||||
case 'NEW': return <Flag sx={{ fontSize: 16 }} />;
|
||||
case 'DOING': return <PlayCircle sx={{ fontSize: 16 }} />;
|
||||
case 'BLOCKED': return <PauseCircle sx={{ fontSize: 16 }} />;
|
||||
case 'DONE': return <CheckCircle sx={{ fontSize: 16 }} />;
|
||||
default: return <Flag sx={{ fontSize: 16 }} />;
|
||||
}
|
||||
};
|
||||
|
||||
const handleTodoSelect = (todoId: string) => {
|
||||
const newSelected = selectedTodos.includes(todoId)
|
||||
? selectedTodos.filter(id => id !== todoId)
|
||||
: [...selectedTodos, todoId];
|
||||
onSelectionChange(newSelected);
|
||||
};
|
||||
|
||||
const handleMenuOpen = (todoId: string, event: React.MouseEvent<HTMLElement>) => {
|
||||
setMenuAnchor({ ...menuAnchor, [todoId]: event.currentTarget });
|
||||
};
|
||||
|
||||
const handleMenuClose = (todoId: string) => {
|
||||
setMenuAnchor({ ...menuAnchor, [todoId]: null });
|
||||
};
|
||||
|
||||
const isOverdue = (dueDate: string) => {
|
||||
return new Date(dueDate) < new Date() && new Date(dueDate).toDateString() !== new Date().toDateString();
|
||||
};
|
||||
|
||||
const getDaysUntilDue = (dueDate: string) => {
|
||||
const today = new Date();
|
||||
const due = new Date(dueDate);
|
||||
const diffTime = due.getTime() - today.getTime();
|
||||
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
|
||||
|
||||
if (diffDays < 0) return `逾期 ${Math.abs(diffDays)} 天`;
|
||||
if (diffDays === 0) return '今天到期';
|
||||
if (diffDays === 1) return '明天到期';
|
||||
return `${diffDays} 天後到期`;
|
||||
};
|
||||
|
||||
const handleFireEmail = async (todoId: string) => {
|
||||
try {
|
||||
await todosApi.fireEmail({ todo_id: todoId });
|
||||
toast.success('緊急提醒已發送!');
|
||||
handleMenuClose(todoId);
|
||||
} catch (error: any) {
|
||||
console.error('Fire email error:', error);
|
||||
if (error.response?.data?.quota_exceeded) {
|
||||
toast.error(error.response.data.error);
|
||||
} else if (error.response?.data?.cooldown_remaining) {
|
||||
toast.error(error.response.data.error);
|
||||
} else {
|
||||
toast.error(error.response?.data?.error || '發送緊急提醒時發生錯誤');
|
||||
}
|
||||
handleMenuClose(todoId);
|
||||
}
|
||||
};
|
||||
|
||||
const containerVariants = {
|
||||
hidden: { opacity: 0 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
transition: {
|
||||
staggerChildren: 0.1,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const itemVariants = {
|
||||
hidden: { opacity: 0, y: 20 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
transition: { duration: 0.4, ease: 'easeOut' },
|
||||
},
|
||||
};
|
||||
|
||||
if (todos.length === 0) {
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
>
|
||||
<Card
|
||||
sx={{
|
||||
textAlign: 'center',
|
||||
py: 8,
|
||||
backgroundColor: actualTheme === 'dark' ? '#1f2937' : '#ffffff',
|
||||
border: `1px solid ${actualTheme === 'dark'
|
||||
? 'rgba(255, 255, 255, 0.1)'
|
||||
: 'rgba(0, 0, 0, 0.1)'}`,
|
||||
}}
|
||||
>
|
||||
<CardContent>
|
||||
<Typography
|
||||
variant="h6"
|
||||
color="text.secondary"
|
||||
gutterBottom
|
||||
sx={{ fontWeight: 500 }}
|
||||
>
|
||||
沒有找到待辦事項
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
嘗試調整篩選條件或建立新的待辦事項
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
variants={containerVariants}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
<AnimatePresence>
|
||||
{todos.map((todo) => {
|
||||
const isSelected = selectedTodos.includes(todo.id);
|
||||
const overdue = todo.due_date ? isOverdue(todo.due_date) : false;
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
key={todo.id}
|
||||
variants={itemVariants}
|
||||
layout
|
||||
exit={{ opacity: 0, x: -300 }}
|
||||
whileHover={{ scale: 1.01 }}
|
||||
whileTap={{ scale: 0.99 }}
|
||||
>
|
||||
<Card
|
||||
sx={{
|
||||
position: 'relative',
|
||||
backgroundColor: actualTheme === 'dark' ? '#1f2937' : '#ffffff',
|
||||
border: isSelected
|
||||
? `2px solid ${actualTheme === 'dark' ? '#60a5fa' : '#3b82f6'}`
|
||||
: `1px solid ${actualTheme === 'dark'
|
||||
? 'rgba(255, 255, 255, 0.1)'
|
||||
: 'rgba(0, 0, 0, 0.1)'}`,
|
||||
borderLeft: `4px solid ${getPriorityColor(todo.priority)}`,
|
||||
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
'&:hover': {
|
||||
boxShadow: actualTheme === 'dark'
|
||||
? '0 8px 25px rgba(0, 0, 0, 0.3)'
|
||||
: '0 8px 25px rgba(0, 0, 0, 0.1)',
|
||||
transform: 'translateY(-2px)',
|
||||
},
|
||||
...(overdue && {
|
||||
backgroundColor: actualTheme === 'dark'
|
||||
? 'rgba(239, 68, 68, 0.05)'
|
||||
: 'rgba(239, 68, 68, 0.02)',
|
||||
}),
|
||||
}}
|
||||
>
|
||||
{/* 逾期標示 */}
|
||||
{overdue && (
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: -1,
|
||||
right: -1,
|
||||
backgroundColor: '#ef4444',
|
||||
color: 'white',
|
||||
px: 1,
|
||||
py: 0.25,
|
||||
borderRadius: '0 0 0 8px',
|
||||
fontSize: '0.7rem',
|
||||
fontWeight: 600,
|
||||
zIndex: 1,
|
||||
}}
|
||||
>
|
||||
逾期
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<CardContent sx={{ p: 3 }}>
|
||||
{/* 頂部區域 */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 2, mb: 2 }}>
|
||||
{/* 選擇框 */}
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
onChange={() => handleTodoSelect(todo.id)}
|
||||
sx={{
|
||||
p: 0,
|
||||
'&.Mui-checked': {
|
||||
color: 'primary.main',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 主要內容 */}
|
||||
<Box sx={{ flex: 1, minWidth: 0 }}>
|
||||
{/* 標題和星標 */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1 }}>
|
||||
<Typography
|
||||
variant="h5"
|
||||
sx={{
|
||||
fontWeight: 600,
|
||||
fontSize: '1.3rem',
|
||||
flex: 1,
|
||||
textDecoration: todo.status === 'DONE' ? 'line-through' : 'none',
|
||||
opacity: todo.status === 'DONE' ? 0.7 : 1,
|
||||
}}
|
||||
>
|
||||
{todo.title}
|
||||
</Typography>
|
||||
|
||||
<Box sx={{ display: 'flex', gap: 0.5 }}>
|
||||
<Tooltip title={todo.is_public ? '公開' : '私人'}>
|
||||
<IconButton size="small">
|
||||
{todo.is_public ? (
|
||||
<Public sx={{ fontSize: 18, color: 'primary.main' }} />
|
||||
) : (
|
||||
<Lock sx={{ fontSize: 18, color: 'text.secondary' }} />
|
||||
)}
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<IconButton
|
||||
size="small"
|
||||
sx={{
|
||||
color: todo.starred ? '#fbbf24' : 'text.disabled',
|
||||
'&:hover': {
|
||||
color: '#fbbf24',
|
||||
backgroundColor: 'rgba(251, 191, 36, 0.1)',
|
||||
},
|
||||
}}
|
||||
>
|
||||
{todo.starred ? <Star /> : <StarBorder />}
|
||||
</IconButton>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* 描述 */}
|
||||
{todo.description && (
|
||||
<Typography
|
||||
variant="body1"
|
||||
color="text.secondary"
|
||||
sx={{
|
||||
mb: 2,
|
||||
fontSize: '1rem',
|
||||
opacity: todo.status === 'DONE' ? 0.6 : 1,
|
||||
overflow: 'hidden',
|
||||
display: '-webkit-box',
|
||||
WebkitLineClamp: 2,
|
||||
WebkitBoxOrient: 'vertical',
|
||||
}}
|
||||
>
|
||||
{todo.description}
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
{/* 標籤區域 */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2, flexWrap: 'wrap' }}>
|
||||
{/* 狀態 */}
|
||||
<Chip
|
||||
icon={getStatusIcon(todo.status)}
|
||||
label={todo.status === 'DOING' ? '進行中' :
|
||||
todo.status === 'BLOCKED' ? '已阻塞' :
|
||||
todo.status === 'DONE' ? '已完成' : '新建立'}
|
||||
size="medium"
|
||||
onClick={onStatusChange ? (e) => {
|
||||
e.stopPropagation();
|
||||
// 循環切換狀態:NEW -> DOING -> DONE -> NEW
|
||||
const nextStatus = todo.status === 'NEW' ? 'DOING' :
|
||||
todo.status === 'DOING' ? 'DONE' :
|
||||
todo.status === 'DONE' ? 'NEW' :
|
||||
todo.status === 'BLOCKED' ? 'DOING' : 'NEW';
|
||||
onStatusChange(todo.id, nextStatus);
|
||||
} : undefined}
|
||||
sx={{
|
||||
backgroundColor: `${getStatusColor(todo.status)}15`,
|
||||
color: getStatusColor(todo.status),
|
||||
fontWeight: 600,
|
||||
cursor: onStatusChange ? 'pointer' : 'default',
|
||||
'& .MuiChip-icon': {
|
||||
color: getStatusColor(todo.status),
|
||||
},
|
||||
'&:hover': onStatusChange ? {
|
||||
backgroundColor: `${getStatusColor(todo.status)}25`,
|
||||
transform: 'scale(1.05)',
|
||||
transition: 'all 0.2s ease-in-out',
|
||||
} : {},
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 優先級 */}
|
||||
<Chip
|
||||
label={todo.priority === 'URGENT' ? '緊急' :
|
||||
todo.priority === 'HIGH' ? '高' :
|
||||
todo.priority === 'MEDIUM' ? '中' : '低'}
|
||||
size="small"
|
||||
sx={{
|
||||
backgroundColor: `${getPriorityColor(todo.priority)}15`,
|
||||
color: getPriorityColor(todo.priority),
|
||||
fontWeight: 600,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 到期時間 */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
||||
<CalendarToday sx={{ fontSize: 16, color: 'text.secondary' }} />
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{
|
||||
fontSize: '0.9rem',
|
||||
color: overdue ? '#ef4444' : 'text.secondary',
|
||||
fontWeight: overdue ? 600 : 400,
|
||||
}}
|
||||
>
|
||||
{todo.due_date ? getDaysUntilDue(todo.due_date) : ''}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* 底部資訊 */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
{/* 人員資訊 */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||
{/* 建立者 */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ fontSize: '0.7rem' }}>
|
||||
建立者: {todo.creator_display_name || todo.creator_ad}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{/* 負責人 */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Person sx={{ fontSize: 16, color: 'text.secondary' }} />
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flexWrap: 'wrap' }}>
|
||||
{(todo.responsible_users_details || todo.responsible_users || []).slice(0, 3).map((user, index) => {
|
||||
const displayName = typeof user === 'string'
|
||||
? user
|
||||
: user.display_name || user.ad_account;
|
||||
const adAccount = typeof user === 'string'
|
||||
? user
|
||||
: user.ad_account;
|
||||
const fullName = typeof user === 'string'
|
||||
? user
|
||||
: `${adAccount} ${displayName}`;
|
||||
|
||||
return (
|
||||
<Tooltip key={index} title={fullName}>
|
||||
<Chip
|
||||
size="small"
|
||||
label={fullName}
|
||||
sx={{
|
||||
backgroundColor: 'primary.main',
|
||||
color: 'white',
|
||||
fontSize: '0.8rem',
|
||||
fontWeight: 500,
|
||||
height: 28,
|
||||
borderRadius: 1,
|
||||
'& .MuiChip-label': {
|
||||
px: 1.5,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
{(todo.responsible_users_details || todo.responsible_users || []).length > 3 && (
|
||||
<Chip
|
||||
size="small"
|
||||
label={`+${(todo.responsible_users_details || todo.responsible_users || []).length - 3}`}
|
||||
sx={{
|
||||
backgroundColor: 'grey.400',
|
||||
color: 'white',
|
||||
fontSize: '0.8rem',
|
||||
fontWeight: 500,
|
||||
height: 28,
|
||||
borderRadius: 1,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* 追蹤者 */}
|
||||
{todo.followers.length > 0 && (
|
||||
<Badge
|
||||
badgeContent={todo.followers.length}
|
||||
color="secondary"
|
||||
sx={{
|
||||
'& .MuiBadge-badge': {
|
||||
fontSize: '0.6rem',
|
||||
minWidth: 16,
|
||||
height: 16,
|
||||
borderRadius: '50%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
追蹤者
|
||||
</Typography>
|
||||
</Badge>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* 操作按鈕 */}
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
handleMenuOpen(todo.id, e);
|
||||
}}
|
||||
sx={{
|
||||
color: 'text.secondary',
|
||||
'&:hover': {
|
||||
backgroundColor: actualTheme === 'dark'
|
||||
? 'rgba(255, 255, 255, 0.1)'
|
||||
: 'rgba(0, 0, 0, 0.04)',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<MoreVert />
|
||||
</IconButton>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
</CardContent>
|
||||
|
||||
{/* 右鍵菜單 */}
|
||||
<Menu
|
||||
anchorEl={menuAnchor[todo.id]}
|
||||
open={Boolean(menuAnchor[todo.id])}
|
||||
onClose={() => handleMenuClose(todo.id)}
|
||||
sx={{
|
||||
'& .MuiPaper-root': {
|
||||
backgroundColor: actualTheme === 'dark' ? '#374151' : '#ffffff',
|
||||
border: `1px solid ${actualTheme === 'dark'
|
||||
? 'rgba(255, 255, 255, 0.1)'
|
||||
: 'rgba(0, 0, 0, 0.1)'}`,
|
||||
borderRadius: 2,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<MenuItem onClick={() => {
|
||||
handleMenuClose(todo.id);
|
||||
onEditTodo?.(todo);
|
||||
}}>
|
||||
<Edit sx={{ mr: 2, fontSize: 18 }} />
|
||||
編輯
|
||||
</MenuItem>
|
||||
<MenuItem onClick={() => handleFireEmail(todo.id)} sx={{ color: 'warning.main' }}>
|
||||
<NotificationImportant sx={{ mr: 2, fontSize: 18 }} />
|
||||
緊急提醒
|
||||
</MenuItem>
|
||||
<MenuItem onClick={() => handleMenuClose(todo.id)} sx={{ color: 'error.main' }}>
|
||||
<Delete sx={{ mr: 2, fontSize: 18 }} />
|
||||
刪除
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
</Card>
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
</AnimatePresence>
|
||||
</Box>
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TodoList;
|
365
frontend/src/lib/api.ts
Normal file
365
frontend/src/lib/api.ts
Normal file
@@ -0,0 +1,365 @@
|
||||
import axios, { AxiosResponse, AxiosError } from 'axios';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import {
|
||||
Todo,
|
||||
TodoCreate,
|
||||
TodoUpdate,
|
||||
TodoFilter,
|
||||
TodosResponse,
|
||||
User,
|
||||
UserPreferences,
|
||||
LdapUser,
|
||||
LoginRequest,
|
||||
LoginResponse,
|
||||
FireEmailRequest,
|
||||
FireEmailQuota,
|
||||
ImportJob,
|
||||
} from '@/types';
|
||||
|
||||
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || '';
|
||||
|
||||
// Create axios instance
|
||||
const api = axios.create({
|
||||
baseURL: API_BASE_URL,
|
||||
timeout: 30000,
|
||||
});
|
||||
|
||||
// Request interceptor to add auth token
|
||||
api.interceptors.request.use(
|
||||
(config) => {
|
||||
const token = localStorage.getItem('access_token');
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
return config;
|
||||
},
|
||||
(error) => Promise.reject(error)
|
||||
);
|
||||
|
||||
// Response interceptor for error handling
|
||||
api.interceptors.response.use(
|
||||
(response) => response,
|
||||
async (error: AxiosError) => {
|
||||
const originalRequest = error.config as any;
|
||||
|
||||
if (error.response?.status === 401 && !originalRequest._retry) {
|
||||
originalRequest._retry = true;
|
||||
|
||||
try {
|
||||
const refreshToken = localStorage.getItem('refresh_token');
|
||||
if (refreshToken) {
|
||||
const response = await api.post('/api/auth/refresh', {}, {
|
||||
headers: { Authorization: `Bearer ${refreshToken}` },
|
||||
});
|
||||
|
||||
const { access_token } = response.data;
|
||||
localStorage.setItem('access_token', access_token);
|
||||
|
||||
// Retry original request (mark it to skip toast on failure)
|
||||
originalRequest._isRetry = true;
|
||||
return api(originalRequest);
|
||||
}
|
||||
} catch (refreshError) {
|
||||
// Refresh failed, redirect to login
|
||||
localStorage.removeItem('access_token');
|
||||
localStorage.removeItem('refresh_token');
|
||||
localStorage.removeItem('user');
|
||||
window.location.href = '/login';
|
||||
return Promise.reject(refreshError);
|
||||
}
|
||||
}
|
||||
|
||||
// Show error toast (skip for retry requests to avoid duplicates)
|
||||
if (!originalRequest._isRetry) {
|
||||
const errorData = (error as any).response?.data;
|
||||
const status = (error as any).response?.status;
|
||||
let errorMessage = 'An error occurred';
|
||||
|
||||
if (errorData?.message) {
|
||||
errorMessage = errorData.message;
|
||||
} else if (errorData?.error) {
|
||||
errorMessage = errorData.error;
|
||||
} else if ((error as any).message) {
|
||||
errorMessage = (error as any).message;
|
||||
}
|
||||
|
||||
// Special handling for database connection errors
|
||||
if (status === 503) {
|
||||
toast.error(errorMessage, {
|
||||
duration: 5000,
|
||||
style: {
|
||||
backgroundColor: '#fef3c7',
|
||||
color: '#92400e',
|
||||
},
|
||||
});
|
||||
} else if (status === 504) {
|
||||
toast.error(errorMessage, {
|
||||
duration: 4000,
|
||||
style: {
|
||||
backgroundColor: '#fee2e2',
|
||||
color: '#991b1b',
|
||||
},
|
||||
});
|
||||
} else if (status !== 401) {
|
||||
toast.error(errorMessage);
|
||||
}
|
||||
}
|
||||
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
// Auth API
|
||||
export const authApi = {
|
||||
login: async (credentials: LoginRequest): Promise<LoginResponse> => {
|
||||
const response = await api.post('/api/auth/login', credentials);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
logout: async (): Promise<void> => {
|
||||
await api.post('/api/auth/logout');
|
||||
localStorage.removeItem('access_token');
|
||||
localStorage.removeItem('refresh_token');
|
||||
localStorage.removeItem('user');
|
||||
},
|
||||
|
||||
getCurrentUser: async (): Promise<User> => {
|
||||
const response = await api.get('/api/auth/me');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
validateToken: async (): Promise<boolean> => {
|
||||
try {
|
||||
await api.get('/api/auth/validate');
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
// Todos API
|
||||
export const todosApi = {
|
||||
getTodos: async (filter: TodoFilter & { page?: number; per_page?: number }): Promise<TodosResponse> => {
|
||||
const params = new URLSearchParams();
|
||||
|
||||
Object.entries(filter).forEach(([key, value]) => {
|
||||
if (value !== undefined && value !== null && value !== '') {
|
||||
params.append(key, value.toString());
|
||||
}
|
||||
});
|
||||
|
||||
const response = await api.get(`/api/todos?${params.toString()}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getTodo: async (id: string): Promise<Todo> => {
|
||||
const response = await api.get(`/api/todos/${id}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
createTodo: async (todo: TodoCreate): Promise<Todo> => {
|
||||
const response = await api.post('/api/todos', todo);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
updateTodo: async (id: string, updates: Partial<TodoUpdate>): Promise<Todo> => {
|
||||
const response = await api.patch(`/api/todos/${id}`, updates);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
deleteTodo: async (id: string): Promise<void> => {
|
||||
await api.delete(`/api/todos/${id}`);
|
||||
},
|
||||
|
||||
batchUpdateTodos: async (todoIds: string[], updates: Partial<TodoUpdate>): Promise<{ updated: number; errors: any[] }> => {
|
||||
const response = await api.patch('/api/todos/batch', {
|
||||
todo_ids: todoIds,
|
||||
updates,
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
|
||||
fireEmail: async (request: FireEmailRequest): Promise<void> => {
|
||||
await api.post('/api/notifications/fire-email', {
|
||||
todo_id: request.todo_id,
|
||||
message: request.note,
|
||||
});
|
||||
},
|
||||
|
||||
getPublicTodos: async (filters?: TodoFilter): Promise<TodosResponse> => {
|
||||
const params = new URLSearchParams();
|
||||
|
||||
if (filters) {
|
||||
Object.entries(filters).forEach(([key, value]) => {
|
||||
if (value !== undefined && value !== null && value !== '') {
|
||||
if (key === 'tags' && Array.isArray(value)) {
|
||||
value.forEach(tag => params.append('tags', tag));
|
||||
} else {
|
||||
params.append(key, value.toString());
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const response = await api.get(`/api/todos/public?${params.toString()}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getFollowingTodos: async (page = 1, perPage = 20): Promise<TodosResponse> => {
|
||||
const response = await api.get(`/api/todos/following?page=${page}&per_page=${perPage}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
updateTodoVisibility: async (id: string, isPublic: boolean): Promise<{ message: string; is_public: boolean }> => {
|
||||
const response = await api.patch(`/api/todos/${id}/visibility`, { is_public: isPublic });
|
||||
return response.data;
|
||||
},
|
||||
|
||||
followTodo: async (id: string): Promise<{ message: string }> => {
|
||||
const response = await api.post(`/api/todos/${id}/follow`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
unfollowTodo: async (id: string): Promise<{ message: string }> => {
|
||||
const response = await api.delete(`/api/todos/${id}/follow`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
toggleStar: async (id: string): Promise<{ message: string; starred: boolean }> => {
|
||||
const response = await api.patch(`/api/todos/${id}/star`);
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
|
||||
// Users API
|
||||
export const usersApi = {
|
||||
searchUsers: async (query: string): Promise<LdapUser[]> => {
|
||||
const response = await api.get(`/api/users/search?q=${encodeURIComponent(query)}`);
|
||||
return response.data.users;
|
||||
},
|
||||
|
||||
getPreferences: async (): Promise<UserPreferences> => {
|
||||
const response = await api.get('/api/users/preferences');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
updatePreferences: async (preferences: Partial<UserPreferences>): Promise<UserPreferences> => {
|
||||
const response = await api.patch('/api/users/preferences', preferences);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getFireEmailQuota: async (): Promise<FireEmailQuota> => {
|
||||
const response = await api.get('/api/users/fire-email-quota');
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
|
||||
// Import API
|
||||
export const importApi = {
|
||||
downloadTemplate: async (): Promise<Blob> => {
|
||||
const response = await api.get('/api/imports/template', {
|
||||
responseType: 'blob',
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
|
||||
uploadFile: async (file: File): Promise<ImportJob> => {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
const response = await api.post('/api/imports', formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
|
||||
importTodos: async (todos: any[]): Promise<any> => {
|
||||
const response = await api.post('/api/excel/import', { todos });
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getImportJob: async (jobId: string): Promise<ImportJob> => {
|
||||
const response = await api.get(`/api/imports/${jobId}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
downloadErrors: async (jobId: string): Promise<Blob> => {
|
||||
const response = await api.get(`/api/imports/${jobId}/errors`, {
|
||||
responseType: 'blob',
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
// Admin API (if needed)
|
||||
export const adminApi = {
|
||||
getStats: async (days: number = 30): Promise<any> => {
|
||||
const response = await api.get(`/api/admin/stats?days=${days}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getAuditLogs: async (params: any): Promise<any> => {
|
||||
const queryParams = new URLSearchParams(params).toString();
|
||||
const response = await api.get(`/api/admin/audit-logs?${queryParams}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getMailLogs: async (params: any): Promise<any> => {
|
||||
const queryParams = new URLSearchParams(params).toString();
|
||||
const response = await api.get(`/api/admin/mail-logs?${queryParams}`);
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
|
||||
// Notifications API
|
||||
export const notificationsApi = {
|
||||
getSettings: async (): Promise<any> => {
|
||||
const response = await api.get('/api/notifications/settings');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
updateSettings: async (settings: any): Promise<any> => {
|
||||
const response = await api.patch('/api/notifications/settings', settings);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
sendTestEmail: async (recipientEmail?: string): Promise<void> => {
|
||||
await api.post('/api/notifications/test', recipientEmail ? { recipient_email: recipientEmail } : {});
|
||||
},
|
||||
|
||||
sendDigest: async (type: 'weekly' | 'monthly' = 'weekly'): Promise<void> => {
|
||||
await api.post('/api/notifications/digest', { type });
|
||||
},
|
||||
|
||||
markNotificationRead: async (notificationId: string): Promise<void> => {
|
||||
await api.post('/api/notifications/mark-read', { notification_id: notificationId });
|
||||
},
|
||||
|
||||
markAllNotificationsRead: async (): Promise<void> => {
|
||||
await api.post('/api/notifications/mark-all-read');
|
||||
},
|
||||
|
||||
getNotifications: async (): Promise<any> => {
|
||||
const response = await api.get('/api/notifications/');
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
|
||||
// Health API
|
||||
export const healthApi = {
|
||||
check: async (): Promise<any> => {
|
||||
const response = await api.get('/api/health/healthz');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
readiness: async (): Promise<any> => {
|
||||
const response = await api.get('/api/health/readiness');
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
|
||||
export default api;
|
210
frontend/src/lib/theme.ts
Normal file
210
frontend/src/lib/theme.ts
Normal file
@@ -0,0 +1,210 @@
|
||||
import { createTheme, ThemeOptions } from '@mui/material/styles';
|
||||
|
||||
const getDesignTokens = (mode: 'light' | 'dark'): ThemeOptions => ({
|
||||
palette: {
|
||||
mode,
|
||||
...(mode === 'light'
|
||||
? {
|
||||
// Light mode colors
|
||||
primary: {
|
||||
main: '#3b82f6',
|
||||
light: '#60a5fa',
|
||||
dark: '#2563eb',
|
||||
contrastText: '#ffffff',
|
||||
},
|
||||
secondary: {
|
||||
main: '#8b5cf6',
|
||||
light: '#a78bfa',
|
||||
dark: '#7c3aed',
|
||||
contrastText: '#ffffff',
|
||||
},
|
||||
error: {
|
||||
main: '#ef4444',
|
||||
light: '#f87171',
|
||||
dark: '#dc2626',
|
||||
},
|
||||
warning: {
|
||||
main: '#f59e0b',
|
||||
light: '#fbbf24',
|
||||
dark: '#d97706',
|
||||
},
|
||||
info: {
|
||||
main: '#06b6d4',
|
||||
light: '#22d3ee',
|
||||
dark: '#0891b2',
|
||||
},
|
||||
success: {
|
||||
main: '#10b981',
|
||||
light: '#34d399',
|
||||
dark: '#059669',
|
||||
},
|
||||
background: {
|
||||
default: '#ffffff',
|
||||
paper: '#f9fafb',
|
||||
},
|
||||
text: {
|
||||
primary: '#111827',
|
||||
secondary: '#4b5563',
|
||||
disabled: '#9ca3af',
|
||||
},
|
||||
divider: '#e5e7eb',
|
||||
}
|
||||
: {
|
||||
// Dark mode colors
|
||||
primary: {
|
||||
main: '#60a5fa',
|
||||
light: '#93c5fd',
|
||||
dark: '#3b82f6',
|
||||
contrastText: '#111827',
|
||||
},
|
||||
secondary: {
|
||||
main: '#a78bfa',
|
||||
light: '#c4b5fd',
|
||||
dark: '#8b5cf6',
|
||||
contrastText: '#111827',
|
||||
},
|
||||
error: {
|
||||
main: '#f87171',
|
||||
light: '#fca5a5',
|
||||
dark: '#ef4444',
|
||||
},
|
||||
warning: {
|
||||
main: '#fbbf24',
|
||||
light: '#fcd34d',
|
||||
dark: '#f59e0b',
|
||||
},
|
||||
info: {
|
||||
main: '#22d3ee',
|
||||
light: '#67e8f9',
|
||||
dark: '#06b6d4',
|
||||
},
|
||||
success: {
|
||||
main: '#34d399',
|
||||
light: '#6ee7b7',
|
||||
dark: '#10b981',
|
||||
},
|
||||
background: {
|
||||
default: '#111827',
|
||||
paper: '#1f2937',
|
||||
},
|
||||
text: {
|
||||
primary: '#f3f4f6',
|
||||
secondary: '#d1d5db',
|
||||
disabled: '#6b7280',
|
||||
},
|
||||
divider: '#374151',
|
||||
}),
|
||||
},
|
||||
typography: {
|
||||
fontFamily: [
|
||||
'-apple-system',
|
||||
'BlinkMacSystemFont',
|
||||
'"Segoe UI"',
|
||||
'Roboto',
|
||||
'"Helvetica Neue"',
|
||||
'Arial',
|
||||
'sans-serif',
|
||||
'"Apple Color Emoji"',
|
||||
'"Segoe UI Emoji"',
|
||||
'"Segoe UI Symbol"',
|
||||
].join(','),
|
||||
h1: {
|
||||
fontSize: '2.5rem',
|
||||
fontWeight: 700,
|
||||
lineHeight: 1.2,
|
||||
},
|
||||
h2: {
|
||||
fontSize: '2rem',
|
||||
fontWeight: 600,
|
||||
lineHeight: 1.3,
|
||||
},
|
||||
h3: {
|
||||
fontSize: '1.75rem',
|
||||
fontWeight: 600,
|
||||
lineHeight: 1.4,
|
||||
},
|
||||
h4: {
|
||||
fontSize: '1.5rem',
|
||||
fontWeight: 600,
|
||||
lineHeight: 1.4,
|
||||
},
|
||||
h5: {
|
||||
fontSize: '1.25rem',
|
||||
fontWeight: 600,
|
||||
lineHeight: 1.5,
|
||||
},
|
||||
h6: {
|
||||
fontSize: '1rem',
|
||||
fontWeight: 600,
|
||||
lineHeight: 1.5,
|
||||
},
|
||||
},
|
||||
shape: {
|
||||
borderRadius: 8,
|
||||
},
|
||||
components: {
|
||||
MuiButton: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
textTransform: 'none',
|
||||
borderRadius: '0.5rem',
|
||||
fontWeight: 500,
|
||||
},
|
||||
contained: {
|
||||
boxShadow: 'none',
|
||||
'&:hover': {
|
||||
boxShadow: '0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1)',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiPaper: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
backgroundImage: 'none',
|
||||
},
|
||||
rounded: {
|
||||
borderRadius: '0.75rem',
|
||||
},
|
||||
elevation1: {
|
||||
boxShadow: mode === 'light'
|
||||
? '0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1)'
|
||||
: '0 1px 3px 0 rgb(0 0 0 / 0.3), 0 1px 2px -1px rgb(0 0 0 / 0.3)',
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiCard: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
borderRadius: '0.75rem',
|
||||
boxShadow: mode === 'light'
|
||||
? '0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1)'
|
||||
: '0 1px 3px 0 rgb(0 0 0 / 0.3), 0 1px 2px -1px rgb(0 0 0 / 0.3)',
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiChip: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
borderRadius: '0.375rem',
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiTextField: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
'& .MuiOutlinedInput-root': {
|
||||
borderRadius: '0.5rem',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const createAppTheme = (mode: 'light' | 'dark') => {
|
||||
return createTheme(getDesignTokens(mode));
|
||||
};
|
||||
|
||||
export const lightTheme = createAppTheme('light');
|
||||
export const darkTheme = createAppTheme('dark');
|
180
frontend/src/providers/AuthProvider.tsx
Normal file
180
frontend/src/providers/AuthProvider.tsx
Normal file
@@ -0,0 +1,180 @@
|
||||
'use client';
|
||||
|
||||
import React, { createContext, useContext, useEffect, useState } from 'react';
|
||||
import { useRouter, usePathname } from 'next/navigation';
|
||||
import { authApi } from '@/lib/api';
|
||||
import { User, AuthState } from '@/types';
|
||||
import { toast } from 'react-hot-toast';
|
||||
|
||||
interface AuthContextType extends AuthState {
|
||||
login: (username: string, password: string) => Promise<boolean>;
|
||||
logout: () => Promise<void>;
|
||||
refreshAuth: () => Promise<void>;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
||||
|
||||
export const useAuth = () => {
|
||||
const context = useContext(AuthContext);
|
||||
if (!context) {
|
||||
throw new Error('useAuth must be used within an AuthProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
interface AuthProviderProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export const AuthProvider: React.FC<AuthProviderProps> = ({ children }) => {
|
||||
const [authState, setAuthState] = useState<AuthState>({
|
||||
isAuthenticated: false,
|
||||
user: null,
|
||||
token: null,
|
||||
refreshToken: null,
|
||||
});
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
|
||||
// Public routes that don't require authentication
|
||||
const publicRoutes = ['/login', '/'];
|
||||
|
||||
useEffect(() => {
|
||||
initializeAuth();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// Redirect logic
|
||||
if (!isLoading) {
|
||||
if (!authState.isAuthenticated && !publicRoutes.includes(pathname)) {
|
||||
router.push('/login');
|
||||
} else if (authState.isAuthenticated && pathname === '/login') {
|
||||
router.push('/dashboard');
|
||||
}
|
||||
}
|
||||
}, [authState.isAuthenticated, pathname, isLoading, router]);
|
||||
|
||||
const initializeAuth = async () => {
|
||||
try {
|
||||
const token = localStorage.getItem('access_token');
|
||||
const refreshToken = localStorage.getItem('refresh_token');
|
||||
const userStr = localStorage.getItem('user');
|
||||
|
||||
if (token && refreshToken && userStr) {
|
||||
const user = JSON.parse(userStr);
|
||||
|
||||
// Validate token
|
||||
const isValid = await authApi.validateToken();
|
||||
if (isValid) {
|
||||
setAuthState({
|
||||
isAuthenticated: true,
|
||||
user,
|
||||
token,
|
||||
refreshToken,
|
||||
});
|
||||
} else {
|
||||
// Token invalid, clear storage
|
||||
clearAuthData();
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Auth initialization error:', error);
|
||||
clearAuthData();
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const login = async (username: string, password: string): Promise<boolean> => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
|
||||
const response = await authApi.login({ username, password });
|
||||
|
||||
// Store auth data
|
||||
localStorage.setItem('access_token', response.access_token);
|
||||
localStorage.setItem('refresh_token', response.refresh_token);
|
||||
localStorage.setItem('user', JSON.stringify(response.user));
|
||||
|
||||
setAuthState({
|
||||
isAuthenticated: true,
|
||||
user: response.user,
|
||||
token: response.access_token,
|
||||
refreshToken: response.refresh_token,
|
||||
});
|
||||
|
||||
toast.success(`歡迎,${response.user.display_name}!`);
|
||||
return true;
|
||||
} catch (error: any) {
|
||||
console.error('Login error:', error);
|
||||
|
||||
let errorMessage = '登入失敗';
|
||||
if (error.response?.status === 401) {
|
||||
errorMessage = '帳號或密碼錯誤';
|
||||
} else if (error.response?.data?.error) {
|
||||
errorMessage = error.response.data.error;
|
||||
}
|
||||
|
||||
toast.error(errorMessage);
|
||||
return false;
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const logout = async (): Promise<void> => {
|
||||
try {
|
||||
await authApi.logout();
|
||||
} catch (error) {
|
||||
console.error('Logout error:', error);
|
||||
} finally {
|
||||
clearAuthData();
|
||||
toast.success('已登出');
|
||||
}
|
||||
};
|
||||
|
||||
const refreshAuth = async (): Promise<void> => {
|
||||
try {
|
||||
const user = await authApi.getCurrentUser();
|
||||
setAuthState(prev => ({
|
||||
...prev,
|
||||
user,
|
||||
}));
|
||||
|
||||
// Update user in localStorage
|
||||
localStorage.setItem('user', JSON.stringify(user));
|
||||
} catch (error) {
|
||||
console.error('Refresh auth error:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const clearAuthData = () => {
|
||||
localStorage.removeItem('access_token');
|
||||
localStorage.removeItem('refresh_token');
|
||||
localStorage.removeItem('user');
|
||||
|
||||
setAuthState({
|
||||
isAuthenticated: false,
|
||||
user: null,
|
||||
token: null,
|
||||
refreshToken: null,
|
||||
});
|
||||
};
|
||||
|
||||
const contextValue: AuthContextType = {
|
||||
...authState,
|
||||
login,
|
||||
logout,
|
||||
refreshAuth,
|
||||
isLoading,
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={contextValue}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
};
|
98
frontend/src/providers/ThemeProvider.tsx
Normal file
98
frontend/src/providers/ThemeProvider.tsx
Normal file
@@ -0,0 +1,98 @@
|
||||
'use client';
|
||||
|
||||
import React, { createContext, useContext, useEffect, useState } from 'react';
|
||||
import { ThemeProvider as MuiThemeProvider, CssBaseline } from '@mui/material';
|
||||
import { createAppTheme } from '@/lib/theme';
|
||||
|
||||
type ThemeMode = 'light' | 'dark' | 'auto';
|
||||
|
||||
interface ThemeContextType {
|
||||
themeMode: ThemeMode;
|
||||
actualTheme: 'light' | 'dark';
|
||||
setThemeMode: (mode: ThemeMode) => void;
|
||||
}
|
||||
|
||||
const ThemeContext = createContext<ThemeContextType | undefined>(undefined);
|
||||
|
||||
export const useTheme = () => {
|
||||
const context = useContext(ThemeContext);
|
||||
if (!context) {
|
||||
throw new Error('useTheme must be used within a ThemeProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
interface ThemeProviderProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export const ThemeProvider: React.FC<ThemeProviderProps> = ({ children }) => {
|
||||
const [themeMode, setThemeMode] = useState<ThemeMode>('auto');
|
||||
const [actualTheme, setActualTheme] = useState<'light' | 'dark'>('light');
|
||||
|
||||
useEffect(() => {
|
||||
// Load saved theme preference
|
||||
const savedTheme = localStorage.getItem('themeMode') as ThemeMode | null;
|
||||
if (savedTheme) {
|
||||
setThemeMode(savedTheme);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const updateActualTheme = () => {
|
||||
if (themeMode === 'auto') {
|
||||
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
setActualTheme(prefersDark ? 'dark' : 'light');
|
||||
} else {
|
||||
setActualTheme(themeMode as 'light' | 'dark');
|
||||
}
|
||||
};
|
||||
|
||||
updateActualTheme();
|
||||
|
||||
// Listen for system theme changes
|
||||
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
const handleChange = () => {
|
||||
if (themeMode === 'auto') {
|
||||
updateActualTheme();
|
||||
}
|
||||
};
|
||||
|
||||
mediaQuery.addEventListener('change', handleChange);
|
||||
return () => mediaQuery.removeEventListener('change', handleChange);
|
||||
}, [themeMode]);
|
||||
|
||||
useEffect(() => {
|
||||
// Update document class for Tailwind
|
||||
if (actualTheme === 'dark') {
|
||||
document.documentElement.classList.add('dark');
|
||||
} else {
|
||||
document.documentElement.classList.remove('dark');
|
||||
}
|
||||
}, [actualTheme]);
|
||||
|
||||
const handleSetThemeMode = (mode: ThemeMode) => {
|
||||
setThemeMode(mode);
|
||||
localStorage.setItem('themeMode', mode);
|
||||
};
|
||||
|
||||
const theme = React.useMemo(
|
||||
() => createAppTheme(actualTheme),
|
||||
[actualTheme]
|
||||
);
|
||||
|
||||
return (
|
||||
<ThemeContext.Provider
|
||||
value={{
|
||||
themeMode,
|
||||
actualTheme,
|
||||
setThemeMode: handleSetThemeMode,
|
||||
}}
|
||||
>
|
||||
<MuiThemeProvider theme={theme}>
|
||||
<CssBaseline />
|
||||
{children}
|
||||
</MuiThemeProvider>
|
||||
</ThemeContext.Provider>
|
||||
);
|
||||
};
|
91
frontend/src/providers/index.tsx
Normal file
91
frontend/src/providers/index.tsx
Normal file
@@ -0,0 +1,91 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { Provider as ReduxProvider } from 'react-redux';
|
||||
import { Toaster } from 'react-hot-toast';
|
||||
import { LocalizationProvider } from '@mui/x-date-pickers/LocalizationProvider';
|
||||
import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs';
|
||||
import { ThemeProvider } from './ThemeProvider';
|
||||
import { AuthProvider } from './AuthProvider';
|
||||
import { store } from '@/store';
|
||||
import 'dayjs/locale/zh-tw';
|
||||
|
||||
// Create a client
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: (failureCount, error: any) => {
|
||||
// Don't retry on 401/403 errors
|
||||
if (error?.response?.status === 401 || error?.response?.status === 403) {
|
||||
return false;
|
||||
}
|
||||
return failureCount < 3;
|
||||
},
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||
gcTime: 10 * 60 * 1000, // 10 minutes (formerly cacheTime)
|
||||
},
|
||||
mutations: {
|
||||
retry: 1,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
interface ProvidersProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export const Providers: React.FC<ProvidersProps> = ({ children }) => {
|
||||
return (
|
||||
<ReduxProvider store={store}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<LocalizationProvider dateAdapter={AdapterDayjs} adapterLocale="zh-tw">
|
||||
<ThemeProvider>
|
||||
<AuthProvider>
|
||||
{children}
|
||||
<Toaster
|
||||
position="top-right"
|
||||
toastOptions={{
|
||||
duration: 4000,
|
||||
className: 'text-sm',
|
||||
style: {
|
||||
borderRadius: '0.5rem',
|
||||
background: 'var(--toast-bg)',
|
||||
color: 'var(--toast-text)',
|
||||
boxShadow: '0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1)',
|
||||
},
|
||||
success: {
|
||||
style: {
|
||||
background: '#10b981',
|
||||
color: '#ffffff',
|
||||
},
|
||||
iconTheme: {
|
||||
primary: '#ffffff',
|
||||
secondary: '#10b981',
|
||||
},
|
||||
},
|
||||
error: {
|
||||
style: {
|
||||
background: '#ef4444',
|
||||
color: '#ffffff',
|
||||
},
|
||||
iconTheme: {
|
||||
primary: '#ffffff',
|
||||
secondary: '#ef4444',
|
||||
},
|
||||
},
|
||||
loading: {
|
||||
style: {
|
||||
background: '#3b82f6',
|
||||
color: '#ffffff',
|
||||
},
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</AuthProvider>
|
||||
</ThemeProvider>
|
||||
</LocalizationProvider>
|
||||
</QueryClientProvider>
|
||||
</ReduxProvider>
|
||||
);
|
||||
};
|
21
frontend/src/store/index.ts
Normal file
21
frontend/src/store/index.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { configureStore } from '@reduxjs/toolkit';
|
||||
import authReducer from './slices/authSlice';
|
||||
import todosReducer from './slices/todosSlice';
|
||||
import uiReducer from './slices/uiSlice';
|
||||
|
||||
export const store = configureStore({
|
||||
reducer: {
|
||||
auth: authReducer,
|
||||
todos: todosReducer,
|
||||
ui: uiReducer,
|
||||
},
|
||||
middleware: (getDefaultMiddleware) =>
|
||||
getDefaultMiddleware({
|
||||
serializableCheck: {
|
||||
ignoredActions: ['persist/PERSIST', 'persist/REHYDRATE'],
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
export type RootState = ReturnType<typeof store.getState>;
|
||||
export type AppDispatch = typeof store.dispatch;
|
37
frontend/src/store/slices/authSlice.ts
Normal file
37
frontend/src/store/slices/authSlice.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
|
||||
import { User } from '@/types';
|
||||
|
||||
interface AuthState {
|
||||
user: User | null;
|
||||
token: string | null;
|
||||
isAuthenticated: boolean;
|
||||
}
|
||||
|
||||
const initialState: AuthState = {
|
||||
user: null,
|
||||
token: null,
|
||||
isAuthenticated: false,
|
||||
};
|
||||
|
||||
const authSlice = createSlice({
|
||||
name: 'auth',
|
||||
initialState,
|
||||
reducers: {
|
||||
setAuth: (state, action: PayloadAction<{ user: User; token: string }>) => {
|
||||
state.user = action.payload.user;
|
||||
state.token = action.payload.token;
|
||||
state.isAuthenticated = true;
|
||||
},
|
||||
updateUser: (state, action: PayloadAction<User>) => {
|
||||
state.user = action.payload;
|
||||
},
|
||||
clearAuth: (state) => {
|
||||
state.user = null;
|
||||
state.token = null;
|
||||
state.isAuthenticated = false;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const { setAuth, updateUser, clearAuth } = authSlice.actions;
|
||||
export default authSlice.reducer;
|
119
frontend/src/store/slices/todosSlice.ts
Normal file
119
frontend/src/store/slices/todosSlice.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
|
||||
import { Todo, TodoFilter, ViewType } from '@/types';
|
||||
|
||||
interface TodosState {
|
||||
todos: Todo[];
|
||||
selectedTodos: string[];
|
||||
filter: TodoFilter;
|
||||
viewType: ViewType;
|
||||
sortField: 'created_at' | 'due_date' | 'priority' | 'title';
|
||||
sortOrder: 'asc' | 'desc';
|
||||
isLoading: boolean;
|
||||
pagination: {
|
||||
page: number;
|
||||
per_page: number;
|
||||
total: number;
|
||||
pages: number;
|
||||
};
|
||||
}
|
||||
|
||||
const initialState: TodosState = {
|
||||
todos: [],
|
||||
selectedTodos: [],
|
||||
filter: {
|
||||
view: 'all',
|
||||
},
|
||||
viewType: 'list',
|
||||
sortField: 'created_at',
|
||||
sortOrder: 'desc',
|
||||
isLoading: false,
|
||||
pagination: {
|
||||
page: 1,
|
||||
per_page: 20,
|
||||
total: 0,
|
||||
pages: 0,
|
||||
},
|
||||
};
|
||||
|
||||
const todosSlice = createSlice({
|
||||
name: 'todos',
|
||||
initialState,
|
||||
reducers: {
|
||||
setTodos: (state, action: PayloadAction<Todo[]>) => {
|
||||
state.todos = action.payload;
|
||||
},
|
||||
addTodo: (state, action: PayloadAction<Todo>) => {
|
||||
state.todos.unshift(action.payload);
|
||||
},
|
||||
updateTodo: (state, action: PayloadAction<Todo>) => {
|
||||
const index = state.todos.findIndex(todo => todo.id === action.payload.id);
|
||||
if (index !== -1) {
|
||||
state.todos[index] = action.payload;
|
||||
}
|
||||
},
|
||||
removeTodo: (state, action: PayloadAction<string>) => {
|
||||
state.todos = state.todos.filter(todo => todo.id !== action.payload);
|
||||
},
|
||||
setSelectedTodos: (state, action: PayloadAction<string[]>) => {
|
||||
state.selectedTodos = action.payload;
|
||||
},
|
||||
toggleTodoSelection: (state, action: PayloadAction<string>) => {
|
||||
const todoId = action.payload;
|
||||
if (state.selectedTodos.includes(todoId)) {
|
||||
state.selectedTodos = state.selectedTodos.filter(id => id !== todoId);
|
||||
} else {
|
||||
state.selectedTodos.push(todoId);
|
||||
}
|
||||
},
|
||||
selectAllTodos: (state) => {
|
||||
state.selectedTodos = state.todos.map(todo => todo.id);
|
||||
},
|
||||
clearSelectedTodos: (state) => {
|
||||
state.selectedTodos = [];
|
||||
},
|
||||
setFilter: (state, action: PayloadAction<TodoFilter>) => {
|
||||
state.filter = { ...state.filter, ...action.payload };
|
||||
state.pagination.page = 1; // Reset to first page when filter changes
|
||||
},
|
||||
clearFilter: (state) => {
|
||||
state.filter = { view: 'all' };
|
||||
state.pagination.page = 1;
|
||||
},
|
||||
setViewType: (state, action: PayloadAction<ViewType>) => {
|
||||
state.viewType = action.payload;
|
||||
},
|
||||
setSorting: (state, action: PayloadAction<{ field: string; order: 'asc' | 'desc' }>) => {
|
||||
state.sortField = action.payload.field as any;
|
||||
state.sortOrder = action.payload.order;
|
||||
},
|
||||
setLoading: (state, action: PayloadAction<boolean>) => {
|
||||
state.isLoading = action.payload;
|
||||
},
|
||||
setPagination: (state, action: PayloadAction<Partial<TodosState['pagination']>>) => {
|
||||
state.pagination = { ...state.pagination, ...action.payload };
|
||||
},
|
||||
setPage: (state, action: PayloadAction<number>) => {
|
||||
state.pagination.page = action.payload;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const {
|
||||
setTodos,
|
||||
addTodo,
|
||||
updateTodo,
|
||||
removeTodo,
|
||||
setSelectedTodos,
|
||||
toggleTodoSelection,
|
||||
selectAllTodos,
|
||||
clearSelectedTodos,
|
||||
setFilter,
|
||||
clearFilter,
|
||||
setViewType,
|
||||
setSorting,
|
||||
setLoading,
|
||||
setPagination,
|
||||
setPage,
|
||||
} = todosSlice.actions;
|
||||
|
||||
export default todosSlice.reducer;
|
168
frontend/src/store/slices/uiSlice.ts
Normal file
168
frontend/src/store/slices/uiSlice.ts
Normal file
@@ -0,0 +1,168 @@
|
||||
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
|
||||
|
||||
interface UIState {
|
||||
sidebarOpen: boolean;
|
||||
sidebarCollapsed: boolean;
|
||||
searchOpen: boolean;
|
||||
filterPanelOpen: boolean;
|
||||
createTodoDialogOpen: boolean;
|
||||
editTodoDialogOpen: boolean;
|
||||
deleteTodoDialogOpen: boolean;
|
||||
batchActionsOpen: boolean;
|
||||
settingsDialogOpen: boolean;
|
||||
aiChatOpen: boolean;
|
||||
importDialogOpen: boolean;
|
||||
currentEditingTodo: string | null;
|
||||
currentDeletingTodos: string[];
|
||||
notifications: Array<{
|
||||
id: string;
|
||||
message: string;
|
||||
type: 'info' | 'success' | 'warning' | 'error';
|
||||
timestamp: number;
|
||||
read: boolean;
|
||||
}>;
|
||||
isOnline: boolean;
|
||||
lastSync: string | null;
|
||||
}
|
||||
|
||||
const initialState: UIState = {
|
||||
sidebarOpen: true,
|
||||
sidebarCollapsed: false,
|
||||
searchOpen: false,
|
||||
filterPanelOpen: false,
|
||||
createTodoDialogOpen: false,
|
||||
editTodoDialogOpen: false,
|
||||
deleteTodoDialogOpen: false,
|
||||
batchActionsOpen: false,
|
||||
settingsDialogOpen: false,
|
||||
aiChatOpen: false,
|
||||
importDialogOpen: false,
|
||||
currentEditingTodo: null,
|
||||
currentDeletingTodos: [],
|
||||
notifications: [],
|
||||
isOnline: true,
|
||||
lastSync: null,
|
||||
};
|
||||
|
||||
const uiSlice = createSlice({
|
||||
name: 'ui',
|
||||
initialState,
|
||||
reducers: {
|
||||
toggleSidebar: (state) => {
|
||||
state.sidebarOpen = !state.sidebarOpen;
|
||||
},
|
||||
setSidebarOpen: (state, action: PayloadAction<boolean>) => {
|
||||
state.sidebarOpen = action.payload;
|
||||
},
|
||||
toggleSidebarCollapsed: (state) => {
|
||||
state.sidebarCollapsed = !state.sidebarCollapsed;
|
||||
},
|
||||
setSidebarCollapsed: (state, action: PayloadAction<boolean>) => {
|
||||
state.sidebarCollapsed = action.payload;
|
||||
},
|
||||
setSearchOpen: (state, action: PayloadAction<boolean>) => {
|
||||
state.searchOpen = action.payload;
|
||||
},
|
||||
setFilterPanelOpen: (state, action: PayloadAction<boolean>) => {
|
||||
state.filterPanelOpen = action.payload;
|
||||
},
|
||||
setCreateTodoDialogOpen: (state, action: PayloadAction<boolean>) => {
|
||||
state.createTodoDialogOpen = action.payload;
|
||||
},
|
||||
setEditTodoDialogOpen: (state, action: PayloadAction<boolean>) => {
|
||||
state.editTodoDialogOpen = action.payload;
|
||||
},
|
||||
setDeleteTodoDialogOpen: (state, action: PayloadAction<boolean>) => {
|
||||
state.deleteTodoDialogOpen = action.payload;
|
||||
},
|
||||
setBatchActionsOpen: (state, action: PayloadAction<boolean>) => {
|
||||
state.batchActionsOpen = action.payload;
|
||||
},
|
||||
setSettingsDialogOpen: (state, action: PayloadAction<boolean>) => {
|
||||
state.settingsDialogOpen = action.payload;
|
||||
},
|
||||
setAiChatOpen: (state, action: PayloadAction<boolean>) => {
|
||||
state.aiChatOpen = action.payload;
|
||||
},
|
||||
setImportDialogOpen: (state, action: PayloadAction<boolean>) => {
|
||||
state.importDialogOpen = action.payload;
|
||||
},
|
||||
setCurrentEditingTodo: (state, action: PayloadAction<string | null>) => {
|
||||
state.currentEditingTodo = action.payload;
|
||||
},
|
||||
setCurrentDeletingTodos: (state, action: PayloadAction<string[]>) => {
|
||||
state.currentDeletingTodos = action.payload;
|
||||
},
|
||||
addNotification: (state, action: PayloadAction<Omit<UIState['notifications'][0], 'id' | 'timestamp' | 'read'>>) => {
|
||||
const notification = {
|
||||
...action.payload,
|
||||
id: Date.now().toString(),
|
||||
timestamp: Date.now(),
|
||||
read: false,
|
||||
};
|
||||
state.notifications.unshift(notification);
|
||||
},
|
||||
markNotificationAsRead: (state, action: PayloadAction<string>) => {
|
||||
const notification = state.notifications.find(n => n.id === action.payload);
|
||||
if (notification) {
|
||||
notification.read = true;
|
||||
}
|
||||
},
|
||||
markAllNotificationsAsRead: (state) => {
|
||||
state.notifications.forEach(n => n.read = true);
|
||||
},
|
||||
removeNotification: (state, action: PayloadAction<string>) => {
|
||||
state.notifications = state.notifications.filter(n => n.id !== action.payload);
|
||||
},
|
||||
clearNotifications: (state) => {
|
||||
state.notifications = [];
|
||||
},
|
||||
setOnlineStatus: (state, action: PayloadAction<boolean>) => {
|
||||
state.isOnline = action.payload;
|
||||
},
|
||||
setLastSync: (state, action: PayloadAction<string>) => {
|
||||
state.lastSync = action.payload;
|
||||
},
|
||||
closeAllDialogs: (state) => {
|
||||
state.createTodoDialogOpen = false;
|
||||
state.editTodoDialogOpen = false;
|
||||
state.deleteTodoDialogOpen = false;
|
||||
state.settingsDialogOpen = false;
|
||||
state.aiChatOpen = false;
|
||||
state.importDialogOpen = false;
|
||||
state.filterPanelOpen = false;
|
||||
state.searchOpen = false;
|
||||
state.batchActionsOpen = false;
|
||||
state.currentEditingTodo = null;
|
||||
state.currentDeletingTodos = [];
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const {
|
||||
toggleSidebar,
|
||||
setSidebarOpen,
|
||||
toggleSidebarCollapsed,
|
||||
setSidebarCollapsed,
|
||||
setSearchOpen,
|
||||
setFilterPanelOpen,
|
||||
setCreateTodoDialogOpen,
|
||||
setEditTodoDialogOpen,
|
||||
setDeleteTodoDialogOpen,
|
||||
setBatchActionsOpen,
|
||||
setSettingsDialogOpen,
|
||||
setAiChatOpen,
|
||||
setImportDialogOpen,
|
||||
setCurrentEditingTodo,
|
||||
setCurrentDeletingTodos,
|
||||
addNotification,
|
||||
markNotificationAsRead,
|
||||
markAllNotificationsAsRead,
|
||||
removeNotification,
|
||||
clearNotifications,
|
||||
setOnlineStatus,
|
||||
setLastSync,
|
||||
closeAllDialogs,
|
||||
} = uiSlice.actions;
|
||||
|
||||
export default uiSlice.reducer;
|
185
frontend/src/types/index.ts
Normal file
185
frontend/src/types/index.ts
Normal file
@@ -0,0 +1,185 @@
|
||||
// User Detail Types
|
||||
export interface UserDetail {
|
||||
ad_account: string;
|
||||
display_name: string;
|
||||
email: string;
|
||||
}
|
||||
|
||||
// Todo Types
|
||||
export interface Todo {
|
||||
id: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
status: 'NEW' | 'DOING' | 'BLOCKED' | 'DONE';
|
||||
priority: 'LOW' | 'MEDIUM' | 'HIGH' | 'URGENT';
|
||||
due_date?: string;
|
||||
created_at: string;
|
||||
completed_at?: string;
|
||||
creator_ad: string;
|
||||
creator_display_name?: string;
|
||||
creator_email?: string;
|
||||
starred: boolean;
|
||||
is_public: boolean;
|
||||
responsible_users: string[];
|
||||
followers: string[];
|
||||
responsible_users_details?: UserDetail[];
|
||||
followers_details?: UserDetail[];
|
||||
tags?: string[];
|
||||
}
|
||||
|
||||
export interface TodoCreate {
|
||||
title: string;
|
||||
description?: string;
|
||||
status?: 'NEW' | 'DOING' | 'BLOCKED' | 'DONE';
|
||||
priority?: 'LOW' | 'MEDIUM' | 'HIGH' | 'URGENT';
|
||||
due_date?: string;
|
||||
starred?: boolean;
|
||||
is_public?: boolean;
|
||||
responsible_users?: string[];
|
||||
followers?: string[];
|
||||
tags?: string[];
|
||||
}
|
||||
|
||||
export interface TodoUpdate extends Partial<TodoCreate> {
|
||||
id: string;
|
||||
}
|
||||
|
||||
export interface TodoFilter {
|
||||
status?: string;
|
||||
priority?: string;
|
||||
starred?: boolean;
|
||||
due_from?: string;
|
||||
due_to?: string;
|
||||
search?: string;
|
||||
view?: 'all' | 'created' | 'responsible' | 'following' | 'public' | 'dashboard';
|
||||
}
|
||||
|
||||
// User Types
|
||||
export interface User {
|
||||
ad_account: string;
|
||||
display_name: string;
|
||||
email: string;
|
||||
theme?: 'light' | 'dark' | 'auto';
|
||||
language?: string;
|
||||
}
|
||||
|
||||
export interface UserPreferences {
|
||||
ad_account: string;
|
||||
email: string;
|
||||
display_name: string;
|
||||
theme: 'light' | 'dark' | 'auto';
|
||||
language: string;
|
||||
timezone: string;
|
||||
notification_enabled: boolean;
|
||||
email_reminder_enabled: boolean;
|
||||
weekly_summary_enabled: boolean;
|
||||
}
|
||||
|
||||
export interface LdapUser {
|
||||
ad_account: string;
|
||||
display_name: string;
|
||||
email: string;
|
||||
}
|
||||
|
||||
// Auth Types
|
||||
export interface LoginRequest {
|
||||
username: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export interface LoginResponse {
|
||||
access_token: string;
|
||||
refresh_token: string;
|
||||
user: User;
|
||||
}
|
||||
|
||||
export interface AuthState {
|
||||
isAuthenticated: boolean;
|
||||
user: User | null;
|
||||
token: string | null;
|
||||
refreshToken: string | null;
|
||||
}
|
||||
|
||||
// API Response Types
|
||||
export interface ApiResponse<T = any> {
|
||||
data?: T;
|
||||
error?: string;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export interface PaginatedResponse<T> {
|
||||
items: T[];
|
||||
total: number;
|
||||
page: number;
|
||||
per_page: number;
|
||||
pages: number;
|
||||
}
|
||||
|
||||
export interface TodosResponse {
|
||||
todos: Todo[];
|
||||
total: number;
|
||||
page: number;
|
||||
per_page: number;
|
||||
pages: number;
|
||||
}
|
||||
|
||||
// Fire Email Types
|
||||
export interface FireEmailRequest {
|
||||
todo_id: string;
|
||||
recipients?: string[];
|
||||
note?: string;
|
||||
}
|
||||
|
||||
export interface FireEmailQuota {
|
||||
used: number;
|
||||
limit: number;
|
||||
remaining: number;
|
||||
}
|
||||
|
||||
// Import Types
|
||||
export interface ImportJob {
|
||||
id: string;
|
||||
actor_ad: string;
|
||||
filename: string;
|
||||
total_rows: number;
|
||||
success_rows: number;
|
||||
failed_rows: number;
|
||||
status: 'PENDING' | 'PROCESSING' | 'COMPLETED' | 'FAILED';
|
||||
error_file_path?: string;
|
||||
error_details?: any;
|
||||
created_at: string;
|
||||
completed_at?: string;
|
||||
}
|
||||
|
||||
|
||||
// Theme Types
|
||||
export type ThemeMode = 'light' | 'dark' | 'auto';
|
||||
|
||||
// Utility Types
|
||||
export type ViewType = 'list' | 'calendar';
|
||||
export type SortField = 'created_at' | 'due_date' | 'priority' | 'title';
|
||||
export type SortOrder = 'asc' | 'desc';
|
||||
|
||||
// Component Props Types
|
||||
export interface BaseComponentProps {
|
||||
className?: string;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
// Status and Priority Options
|
||||
export const TODO_STATUSES = ['NEW', 'DOING', 'BLOCKED', 'DONE'] as const;
|
||||
export const TODO_PRIORITIES = ['LOW', 'MEDIUM', 'HIGH', 'URGENT'] as const;
|
||||
|
||||
export const STATUS_COLORS = {
|
||||
NEW: '#6b7280',
|
||||
DOING: '#3b82f6',
|
||||
BLOCKED: '#ef4444',
|
||||
DONE: '#10b981',
|
||||
} as const;
|
||||
|
||||
export const PRIORITY_COLORS = {
|
||||
LOW: '#6b7280',
|
||||
MEDIUM: '#f59e0b',
|
||||
HIGH: '#f97316',
|
||||
URGENT: '#ef4444',
|
||||
} as const;
|
69
frontend/tailwind.config.js
Normal file
69
frontend/tailwind.config.js
Normal file
@@ -0,0 +1,69 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
darkMode: 'class',
|
||||
content: [
|
||||
'./src/pages/**/*.{js,ts,jsx,tsx,mdx}',
|
||||
'./src/components/**/*.{js,ts,jsx,tsx,mdx}',
|
||||
'./src/app/**/*.{js,ts,jsx,tsx,mdx}',
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
primary: {
|
||||
50: '#eff6ff',
|
||||
100: '#dbeafe',
|
||||
200: '#bfdbfe',
|
||||
300: '#93c5fd',
|
||||
400: '#60a5fa',
|
||||
500: '#3b82f6',
|
||||
600: '#2563eb',
|
||||
700: '#1d4ed8',
|
||||
800: '#1e40af',
|
||||
900: '#1e3a8a',
|
||||
},
|
||||
dark: {
|
||||
bg: '#111827',
|
||||
card: '#1f2937',
|
||||
hover: '#374151',
|
||||
border: '#4b5563',
|
||||
text: {
|
||||
primary: '#f3f4f6',
|
||||
secondary: '#d1d5db',
|
||||
muted: '#9ca3af',
|
||||
},
|
||||
},
|
||||
light: {
|
||||
bg: '#ffffff',
|
||||
card: '#f9fafb',
|
||||
hover: '#f3f4f6',
|
||||
border: '#e5e7eb',
|
||||
text: {
|
||||
primary: '#111827',
|
||||
secondary: '#4b5563',
|
||||
muted: '#6b7280',
|
||||
},
|
||||
},
|
||||
},
|
||||
animation: {
|
||||
'fade-in': 'fadeIn 0.5s ease-in-out',
|
||||
'slide-up': 'slideUp 0.3s ease-out',
|
||||
'slide-down': 'slideDown 0.3s ease-out',
|
||||
},
|
||||
keyframes: {
|
||||
fadeIn: {
|
||||
'0%': { opacity: '0' },
|
||||
'100%': { opacity: '1' },
|
||||
},
|
||||
slideUp: {
|
||||
'0%': { transform: 'translateY(10px)', opacity: '0' },
|
||||
'100%': { transform: 'translateY(0)', opacity: '1' },
|
||||
},
|
||||
slideDown: {
|
||||
'0%': { transform: 'translateY(-10px)', opacity: '0' },
|
||||
'100%': { transform: 'translateY(0)', opacity: '1' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
34
frontend/tsconfig.json
Normal file
34
frontend/tsconfig.json
Normal file
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "es5",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "node",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve",
|
||||
"incremental": true,
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"@/*": ["./src/*"],
|
||||
"@/components/*": ["./src/components/*"],
|
||||
"@/lib/*": ["./src/lib/*"],
|
||||
"@/hooks/*": ["./src/hooks/*"],
|
||||
"@/store/*": ["./src/store/*"],
|
||||
"@/types/*": ["./src/types/*"],
|
||||
"@/styles/*": ["./src/styles/*"]
|
||||
}
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
Reference in New Issue
Block a user