backup
This commit is contained in:
13
.claude/settings.local.json
Normal file
13
.claude/settings.local.json
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"allow": [
|
||||||
|
"Bash(dir)",
|
||||||
|
"Bash(docker:*)",
|
||||||
|
"Bash(chmod:*)",
|
||||||
|
"Bash(winpty docker exec todolist-single-prod ls -la /app/frontend/)",
|
||||||
|
"Bash(curl:*)"
|
||||||
|
],
|
||||||
|
"deny": [],
|
||||||
|
"ask": []
|
||||||
|
}
|
||||||
|
}
|
||||||
35
.dockerignore
Normal file
35
.dockerignore
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
# Node modules
|
||||||
|
frontend/node_modules
|
||||||
|
frontend/.next
|
||||||
|
frontend/out
|
||||||
|
|
||||||
|
# Backend virtual environment
|
||||||
|
backend/venv
|
||||||
|
backend/__pycache__
|
||||||
|
|
||||||
|
# Environment files
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.production
|
||||||
|
|
||||||
|
# Git
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.vscode
|
||||||
|
.idea
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# Documentation
|
||||||
|
README.md
|
||||||
|
DEPLOYMENT.md
|
||||||
|
USERMANUAL.md
|
||||||
|
|
||||||
|
# Scripts (keep only deployment scripts)
|
||||||
|
*.bat
|
||||||
|
!deploy.bat
|
||||||
|
deploy.sh
|
||||||
113
.env.production
Normal file
113
.env.production
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
# ===========================================
|
||||||
|
# 生產環境配置文件
|
||||||
|
# ===========================================
|
||||||
|
|
||||||
|
# ===========================================
|
||||||
|
# MySQL 資料庫連線
|
||||||
|
# ===========================================
|
||||||
|
DATABASE_URL=mysql+pymysql://A060:WLeSCi0yhtc7@mysql.theaken.com:33306/db_A060
|
||||||
|
MYSQL_HOST=mysql.theaken.com
|
||||||
|
MYSQL_PORT=33306
|
||||||
|
MYSQL_USER=A060
|
||||||
|
MYSQL_PASSWORD=WLeSCi0yhtc7
|
||||||
|
MYSQL_DATABASE=db_A060
|
||||||
|
MYSQL_CHARSET=utf8mb4
|
||||||
|
|
||||||
|
# ===========================================
|
||||||
|
# Flask 應用配置
|
||||||
|
# ===========================================
|
||||||
|
FLASK_ENV=production
|
||||||
|
SECRET_KEY=prod-todo-secret-key-2024-change-me
|
||||||
|
JWT_SECRET_KEY=prod-jwt-secret-key-2024-change-me
|
||||||
|
JWT_ACCESS_TOKEN_EXPIRES=3600
|
||||||
|
|
||||||
|
# ===========================================
|
||||||
|
# AD/LDAP 設定 (生產環境)
|
||||||
|
# ===========================================
|
||||||
|
USE_MOCK_LDAP=false
|
||||||
|
LDAP_SERVER=panjit.com.tw
|
||||||
|
LDAP_PORT=389
|
||||||
|
LDAP_USE_SSL=false
|
||||||
|
LDAP_BIND_USER_DN=CN=LdapBind,CN=Users,DC=PANJIT,DC=COM,DC=TW
|
||||||
|
LDAP_BIND_USER_PASSWORD=panjit2481
|
||||||
|
LDAP_SEARCH_BASE=OU=PANJIT,DC=panjit,DC=com,DC=tw
|
||||||
|
LDAP_USER_LOGIN_ATTR=userPrincipalName
|
||||||
|
|
||||||
|
# ===========================================
|
||||||
|
# SMTP 設定
|
||||||
|
# ===========================================
|
||||||
|
SMTP_SERVER=mail.panjit.com.tw
|
||||||
|
SMTP_PORT=25
|
||||||
|
SMTP_USE_TLS=false
|
||||||
|
SMTP_USE_SSL=false
|
||||||
|
SMTP_AUTH_REQUIRED=false
|
||||||
|
SMTP_SENDER_EMAIL=todo-system@panjit.com.tw
|
||||||
|
SMTP_SENDER_PASSWORD=
|
||||||
|
|
||||||
|
# ===========================================
|
||||||
|
# CORS 設定
|
||||||
|
# ===========================================
|
||||||
|
CORS_ORIGINS=http://localhost:12012
|
||||||
|
|
||||||
|
# ===========================================
|
||||||
|
# 日誌設定
|
||||||
|
# ===========================================
|
||||||
|
LOG_LEVEL=INFO
|
||||||
|
LOG_FILE_PATH=logs/app.log
|
||||||
|
LOG_MAX_BYTES=10485760
|
||||||
|
LOG_BACKUP_COUNT=5
|
||||||
|
|
||||||
|
# ===========================================
|
||||||
|
# Redis 設定 (如果使用)
|
||||||
|
# ===========================================
|
||||||
|
REDIS_HOST=localhost
|
||||||
|
REDIS_PORT=6379
|
||||||
|
REDIS_DB=0
|
||||||
|
REDIS_PASSWORD=
|
||||||
|
|
||||||
|
# ===========================================
|
||||||
|
# Celery 設定 (如果使用)
|
||||||
|
# ===========================================
|
||||||
|
CELERY_BROKER_URL=redis://localhost:6379/0
|
||||||
|
CELERY_RESULT_BACKEND=redis://localhost:6379/0
|
||||||
|
|
||||||
|
# ===========================================
|
||||||
|
# 前端配置
|
||||||
|
# ===========================================
|
||||||
|
NEXT_PUBLIC_API_URL=http://localhost:12011
|
||||||
|
|
||||||
|
# ===========================================
|
||||||
|
# 服務端口配置
|
||||||
|
# ===========================================
|
||||||
|
BACKEND_PORT=12011
|
||||||
|
FRONTEND_PORT=12012
|
||||||
|
|
||||||
|
# ===========================================
|
||||||
|
# 安全設定
|
||||||
|
# ===========================================
|
||||||
|
ALLOWED_HOSTS=localhost,127.0.0.1
|
||||||
|
SECURE_SSL_REDIRECT=false
|
||||||
|
SESSION_COOKIE_SECURE=false
|
||||||
|
CSRF_COOKIE_SECURE=false
|
||||||
|
|
||||||
|
# ===========================================
|
||||||
|
# 檔案上傳設定
|
||||||
|
# ===========================================
|
||||||
|
MAX_CONTENT_LENGTH=16777216
|
||||||
|
UPLOAD_FOLDER=uploads
|
||||||
|
ALLOWED_EXTENSIONS=xlsx,xls,csv
|
||||||
|
|
||||||
|
# ===========================================
|
||||||
|
# 郵件配額設定
|
||||||
|
# ===========================================
|
||||||
|
DAILY_EMAIL_LIMIT=100
|
||||||
|
MONTHLY_EMAIL_LIMIT=1000
|
||||||
|
|
||||||
|
# ===========================================
|
||||||
|
# 系統設定
|
||||||
|
# ===========================================
|
||||||
|
SYSTEM_NAME=TODO管理系統
|
||||||
|
SYSTEM_VERSION=1.0.0
|
||||||
|
ADMIN_EMAIL=ymirliu@panjit.com.tw
|
||||||
|
DEFAULT_ADMIN_USER=ymirliu@panjit.com.tw
|
||||||
|
|
||||||
69
.gitignore
vendored
Normal file
69
.gitignore
vendored
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
# --- Python 相關 (Python Related) ---
|
||||||
|
# 忽略虛擬環境目錄。
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
|
||||||
|
# 忽略 Python 的位元組碼和快取檔案。
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
*.pyo
|
||||||
|
*.pyd
|
||||||
|
|
||||||
|
# --- 使用者上傳與系統產生的檔案 (User Uploads & Generated Files) ---
|
||||||
|
# 忽略上傳的已簽核文件 (PDFs)。
|
||||||
|
/uploads/
|
||||||
|
|
||||||
|
# 忽略系統自動產生的暫時規範文件 (Word, PDF)。
|
||||||
|
/generated/
|
||||||
|
|
||||||
|
# 忽略使用者在編輯器中上傳的圖片。
|
||||||
|
/static/uploads/
|
||||||
|
|
||||||
|
# --- IDE / 編輯器設定 (IDE / Editor Settings) ---
|
||||||
|
# 忽略 Visual Studio Code 的本機設定。
|
||||||
|
.vscode/
|
||||||
|
node_modules/
|
||||||
|
.next/
|
||||||
|
.swc/
|
||||||
|
|
||||||
|
# --- 作業系統相關 (Operating System) ---
|
||||||
|
# 忽略 macOS 的系統檔案。
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
# 忽略 Windows 的縮圖快取。
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# --- Log 檔案 ---
|
||||||
|
# 忽略所有日誌檔案。
|
||||||
|
*.log
|
||||||
|
logs/
|
||||||
|
|
||||||
|
# --- 臨時文件 ---
|
||||||
|
nul
|
||||||
|
temp/
|
||||||
|
tmp/
|
||||||
|
|
||||||
|
# --- 測試相關 ---
|
||||||
|
.pytest_cache/
|
||||||
|
.coverage
|
||||||
|
coverage.xml
|
||||||
|
htmlcov/
|
||||||
|
|
||||||
|
# --- 資料庫相關 ---
|
||||||
|
*.db
|
||||||
|
*.sqlite
|
||||||
|
*.sqlite3
|
||||||
|
|
||||||
|
# --- 前端相關 ---
|
||||||
|
.next/
|
||||||
|
out/
|
||||||
|
build/
|
||||||
|
dist/
|
||||||
|
*.tsbuildinfo
|
||||||
|
.eslintcache
|
||||||
|
|
||||||
|
# 注意:根據需求,我們允許 .env 文件上傳(因為是私有倉庫)
|
||||||
|
# 如果這是公開倉庫,請取消以下注釋:
|
||||||
|
# .env
|
||||||
|
# .env.local
|
||||||
|
# .env.production
|
||||||
380
DEPLOYMENT.md
Normal file
380
DEPLOYMENT.md
Normal file
@@ -0,0 +1,380 @@
|
|||||||
|
# TODO管理系統 - 生產環境部署指南
|
||||||
|
|
||||||
|
## 📋 概述
|
||||||
|
|
||||||
|
本文件提供TODO管理系統的完整生產環境部署指南,使用單一容器架構,簡化部署和維護。
|
||||||
|
|
||||||
|
## 🏗️ 系統架構
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────┐ ┌─────────────────┐
|
||||||
|
│ 單一容器應用 │ │ MySQL DB │
|
||||||
|
│ ┌─────────────┐ ┌─────────────────┐ │ │ theaken.com │
|
||||||
|
│ │Next.js 靜態 │ │ Flask API │ │◄───┤ Port: 33306 │
|
||||||
|
│ │ 文件 │ │ Port: 12011 │ │ │ │
|
||||||
|
│ └─────────────┘ └─────────────────┘ │ └─────────────────┘
|
||||||
|
│ 統一 Port: 12011 │
|
||||||
|
└─────────────────────────────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌─────────────────┐
|
||||||
|
│ LDAP/AD │
|
||||||
|
│ panjit.com.tw │
|
||||||
|
│ Port: 389 │
|
||||||
|
└─────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🚀 快速部署
|
||||||
|
|
||||||
|
### Windows 環境
|
||||||
|
|
||||||
|
```batch
|
||||||
|
# 1. 克隆專案(如果尚未克隆)
|
||||||
|
git clone <repository-url>
|
||||||
|
cd TODOLIST
|
||||||
|
|
||||||
|
# 2. 執行部署腳本
|
||||||
|
deploy.bat
|
||||||
|
|
||||||
|
# 或使用管理腳本
|
||||||
|
manage.bat
|
||||||
|
```
|
||||||
|
|
||||||
|
### Linux/Mac 環境
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. 克隆專案(如果尚未克隆)
|
||||||
|
git clone <repository-url>
|
||||||
|
cd TODOLIST
|
||||||
|
|
||||||
|
# 2. 設置執行權限並執行部署腳本
|
||||||
|
chmod +x deploy.sh
|
||||||
|
./deploy.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📁 部署文件結構
|
||||||
|
|
||||||
|
```
|
||||||
|
TODOLIST/
|
||||||
|
├── backend/ # 後端代碼
|
||||||
|
│ ├── app.py # Flask應用(含靜態文件服務)
|
||||||
|
│ ├── requirements.txt # Python依賴
|
||||||
|
│ └── ...
|
||||||
|
├── frontend/ # 前端代碼
|
||||||
|
│ ├── next.config.js # Next.js配置(靜態導出)
|
||||||
|
│ ├── package.json # Node.js依賴
|
||||||
|
│ └── ...
|
||||||
|
├── Dockerfile # 單一容器構建文件
|
||||||
|
├── docker-compose.yml # Docker Compose配置
|
||||||
|
├── .env.production # 生產環境變量配置
|
||||||
|
├── deploy.bat # Windows部署腳本
|
||||||
|
├── deploy.sh # Linux/Mac部署腳本
|
||||||
|
├── manage.bat # Windows管理腳本
|
||||||
|
└── DEPLOYMENT.md # 部署說明文件
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🔧 環境配置
|
||||||
|
|
||||||
|
### 生產環境變量 (`.env.production`)
|
||||||
|
|
||||||
|
本系統使用以下生產環境配置:
|
||||||
|
|
||||||
|
#### 🗄️ 資料庫配置
|
||||||
|
- **MySQL主機**: `mysql.theaken.com:33306`
|
||||||
|
- **資料庫**: `db_A060`
|
||||||
|
- **用戶**: `A060`
|
||||||
|
|
||||||
|
#### 🔐 LDAP認證配置
|
||||||
|
- **LDAP服務器**: `panjit.com.tw:389`
|
||||||
|
- **搜索基礎**: `OU=PANJIT,DC=panjit,DC=com,DC=tw`
|
||||||
|
- **認證方式**: Active Directory集成
|
||||||
|
|
||||||
|
#### 📧 郵件服務配置
|
||||||
|
- **SMTP服務器**: `mail.panjit.com.tw:25`
|
||||||
|
- **發送者**: `todo-system@panjit.com.tw`
|
||||||
|
|
||||||
|
#### 🌐 服務端口
|
||||||
|
- **統一端口**: `12011` (前端 + 後端API)
|
||||||
|
|
||||||
|
## 🐳 Docker配置
|
||||||
|
|
||||||
|
### 單一容器架構
|
||||||
|
|
||||||
|
**todolist-app**: 整合式應用容器
|
||||||
|
- 端口映射: `12011:12011`
|
||||||
|
- 健康檢查: `/api/health`
|
||||||
|
- 自動重啟: `unless-stopped`
|
||||||
|
- 包含: Flask API + Next.js 靜態文件
|
||||||
|
|
||||||
|
### 構建過程
|
||||||
|
|
||||||
|
1. **階段1**: 構建 Next.js 靜態文件
|
||||||
|
2. **階段2**: 設置 Flask 環境 + Gunicorn 生產服務器
|
||||||
|
3. **階段3**: 複製靜態文件到 Flask 容器
|
||||||
|
4. **最終**: 單一容器同時提供前後端服務 (Gunicorn驅動)
|
||||||
|
|
||||||
|
### 生產服務器配置
|
||||||
|
|
||||||
|
**Gunicorn 配置參數**:
|
||||||
|
```bash
|
||||||
|
--bind 0.0.0.0:12011 # 綁定地址和端口
|
||||||
|
--workers 4 # 4個工作進程
|
||||||
|
--threads 2 # 每進程2個線程
|
||||||
|
--timeout 120 # 請求超時120秒
|
||||||
|
--keep-alive 2 # 保持連線時間
|
||||||
|
--max-requests 1000 # 進程處理請求上限後自動重啟
|
||||||
|
--max-requests-jitter 100 # 隨機化重啟時機
|
||||||
|
```
|
||||||
|
|
||||||
|
**總並發能力**: 4 workers × 2 threads = **8個同時請求**
|
||||||
|
|
||||||
|
## 🔍 手動部署步驟
|
||||||
|
|
||||||
|
如果需要手動部署,請按以下步驟執行:
|
||||||
|
|
||||||
|
### 1. 停止現有服務
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 停止並移除現有容器
|
||||||
|
docker-compose down
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 建置Docker鏡像
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 建置單一容器鏡像
|
||||||
|
docker-compose build --no-cache
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 啟動服務
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 使用Docker Compose啟動
|
||||||
|
docker-compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. 驗證部署
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 檢查容器狀態
|
||||||
|
docker-compose ps
|
||||||
|
|
||||||
|
# 檢查服務健康
|
||||||
|
curl http://localhost:12011/api/health
|
||||||
|
curl http://localhost:12011
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🧪 測試與驗證
|
||||||
|
|
||||||
|
### 服務訪問測試
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 前端頁面測試
|
||||||
|
curl http://localhost:12011
|
||||||
|
|
||||||
|
# API健康檢查
|
||||||
|
curl http://localhost:12011/api/health
|
||||||
|
|
||||||
|
# API功能測試
|
||||||
|
curl -X POST http://localhost:12011/api/auth/login \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"username":"test","password":"test"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🛠️ 維護指令
|
||||||
|
|
||||||
|
### 使用管理腳本 (Windows)
|
||||||
|
|
||||||
|
```batch
|
||||||
|
# 執行管理腳本
|
||||||
|
manage.bat
|
||||||
|
|
||||||
|
# 選項包括:
|
||||||
|
# 1. 部署應用程式
|
||||||
|
# 2. 停止服務
|
||||||
|
# 3. 檢視服務狀態
|
||||||
|
# 4. 檢視日誌
|
||||||
|
# 5. 重啟服務
|
||||||
|
# 6. 清理舊映像檔
|
||||||
|
```
|
||||||
|
|
||||||
|
### 日誌查看
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 查看服務日誌
|
||||||
|
docker-compose logs -f
|
||||||
|
|
||||||
|
# 查看最近日誌
|
||||||
|
docker-compose logs --tail=50
|
||||||
|
```
|
||||||
|
|
||||||
|
### 服務管理
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 重啟服務
|
||||||
|
docker-compose restart
|
||||||
|
|
||||||
|
# 停止服務
|
||||||
|
docker-compose down
|
||||||
|
|
||||||
|
# 更新並重啟
|
||||||
|
docker-compose down
|
||||||
|
docker-compose up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
### 資源監控
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 查看容器狀態
|
||||||
|
docker-compose ps
|
||||||
|
|
||||||
|
# 查看資源使用
|
||||||
|
docker stats
|
||||||
|
|
||||||
|
# 查看容器詳情
|
||||||
|
docker inspect todolist-single-prod
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🚨 故障排除
|
||||||
|
|
||||||
|
### 常見問題
|
||||||
|
|
||||||
|
#### 1. 容器無法啟動
|
||||||
|
```bash
|
||||||
|
# 檢查日誌
|
||||||
|
docker-compose logs
|
||||||
|
|
||||||
|
# 檢查端口占用
|
||||||
|
netstat -ano | findstr :12011
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2. 靜態文件無法載入
|
||||||
|
```bash
|
||||||
|
# 檢查文件是否正確複製
|
||||||
|
docker exec -it todolist-single-prod ls -la /app/frontend/out
|
||||||
|
|
||||||
|
# 重新構建並啟動
|
||||||
|
docker-compose down
|
||||||
|
docker-compose up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3. 資料庫連接失敗
|
||||||
|
- 確認網路連通性到 `mysql.theaken.com:33306`
|
||||||
|
- 驗證資料庫憑證
|
||||||
|
- 檢查防火牆設置
|
||||||
|
|
||||||
|
#### 4. LDAP認證失敗
|
||||||
|
- 確認網路連通性到 `panjit.com.tw:389`
|
||||||
|
- 驗證LDAP服務帳號憑證
|
||||||
|
- 檢查搜索基礎設置
|
||||||
|
|
||||||
|
#### 5. Dashboard/SPA頁面404錯誤 🆕
|
||||||
|
**問題**: 前端SPA路由(如 `/todos/`, `/dashboard/`)返回404 JSON錯誤
|
||||||
|
**原因**: Flask 404錯誤處理器攔截了所有404響應並返回JSON格式
|
||||||
|
**解決方案**:
|
||||||
|
```bash
|
||||||
|
# 重新構建應用(此問題已在最新版本修復)
|
||||||
|
docker-compose down
|
||||||
|
docker-compose up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
**技術詳情**:
|
||||||
|
- 修復了 `app.py` 中404錯誤處理器,只對API路徑返回JSON錯誤
|
||||||
|
- 非API路徑(SPA路由)現在正確返回 `index.html`
|
||||||
|
- 修復了SPA路由處理邏輯,確保靜態文件與路由正確分離
|
||||||
|
|
||||||
|
#### 6. Flask開發服務器警告 (已升級為生產服務器) 🆕
|
||||||
|
**問題**: 使用Flask開發服務器在生產環境,顯示警告且無法支撐多人使用
|
||||||
|
**影響**: 200人規模使用時會出現嚴重延遲和請求超時
|
||||||
|
**解決方案**:
|
||||||
|
```bash
|
||||||
|
# 系統已升級為Gunicorn生產服務器
|
||||||
|
docker-compose down
|
||||||
|
docker-compose up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
**升級詳情**:
|
||||||
|
- ✅ **Gunicorn生產服務器**: 4 workers × 2 threads = 8個同時請求處理能力
|
||||||
|
- ✅ **支持200+用戶**: 完全滿足企業級使用需求
|
||||||
|
- ✅ **自動重啟機制**: 防止記憶體洩漏,提高穩定性
|
||||||
|
- ✅ **無開發服務器警告**: 生產就緒配置
|
||||||
|
- ✅ **120秒超時設置**: 適合複雜查詢操作
|
||||||
|
- ✅ **自動負載平衡**: 多進程處理請求分配
|
||||||
|
|
||||||
|
**性能對比**:
|
||||||
|
| 項目 | Flask開發服務器 | Gunicorn生產服務器 |
|
||||||
|
|------|----------------|-------------------|
|
||||||
|
| 並發處理 | 1個請求 | **8個同時請求** |
|
||||||
|
| 適用規模 | 5-10人 | **200+人** |
|
||||||
|
| 穩定性 | 低 | **高** |
|
||||||
|
| 生產就緒 | ❌ | **✅** |
|
||||||
|
|
||||||
|
### 健康檢查端點
|
||||||
|
|
||||||
|
- **應用健康檢查**: `GET http://localhost:12011/api/health`
|
||||||
|
- **前端頁面檢查**: `GET http://localhost:12011`
|
||||||
|
|
||||||
|
## 🔒 安全考量
|
||||||
|
|
||||||
|
### 生產環境安全設置
|
||||||
|
|
||||||
|
1. **更改默認密鑰**:
|
||||||
|
- 修改 `SECRET_KEY` 和 `JWT_SECRET_KEY`
|
||||||
|
- 使用強密碼策略
|
||||||
|
|
||||||
|
2. **網路安全**:
|
||||||
|
- 考慮使用反向代理(Nginx)
|
||||||
|
- 配置HTTPS證書
|
||||||
|
- 限制外部訪問
|
||||||
|
|
||||||
|
3. **監控與日誌**:
|
||||||
|
- 設置日誌輪轉
|
||||||
|
- 監控系統資源
|
||||||
|
- 設置告警機制
|
||||||
|
|
||||||
|
## 📞 支援聯繫
|
||||||
|
|
||||||
|
- **系統管理員**: `ymirliu@panjit.com.tw`
|
||||||
|
- **技術支援**: 參考專案文檔或聯繫開發團隊
|
||||||
|
|
||||||
|
## 📝 版本資訊
|
||||||
|
|
||||||
|
- **系統版本**: 2.1.0 🆕
|
||||||
|
- **架構**: 單一容器部署
|
||||||
|
- **應用服務器**: Gunicorn 21.2.0 (生產級WSGI服務器)
|
||||||
|
- **並發能力**: 4 workers × 2 threads = 8個同時請求
|
||||||
|
- **支持規模**: 200+用戶
|
||||||
|
- **Docker映像**: `todolist-single:latest`
|
||||||
|
- **訪問地址**: `http://localhost:12011`
|
||||||
|
|
||||||
|
### 🚀 v2.1.0 更新內容
|
||||||
|
- ✅ 升級為Gunicorn生產服務器,支持大規模用戶使用
|
||||||
|
- ✅ 修復SPA路由404錯誤問題
|
||||||
|
- ✅ 優化Badge圓形顯示問題
|
||||||
|
- ✅ 修復所有hardcoded API URL問題
|
||||||
|
- ✅ 完善錯誤處理和健康檢查機制
|
||||||
|
|
||||||
|
## 🔄 從舊版本升級
|
||||||
|
|
||||||
|
如果從分離式部署升級:
|
||||||
|
|
||||||
|
1. 停止舊的分離式服務
|
||||||
|
```bash
|
||||||
|
docker stop todo-backend-prod todo-frontend-prod
|
||||||
|
docker rm todo-backend-prod todo-frontend-prod
|
||||||
|
```
|
||||||
|
|
||||||
|
2. 部署新的單一容器
|
||||||
|
```bash
|
||||||
|
./deploy.sh # 或 deploy.bat
|
||||||
|
```
|
||||||
|
|
||||||
|
3. 驗證服務正常
|
||||||
|
```bash
|
||||||
|
curl http://localhost:12011/api/health
|
||||||
|
curl http://localhost:12011
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**注意**: 本文件包含敏感配置資訊,請妥善保管,僅限授權人員查看。
|
||||||
56
Dockerfile
Normal file
56
Dockerfile
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
FROM node:18-alpine AS frontend-builder
|
||||||
|
|
||||||
|
# Build frontend
|
||||||
|
WORKDIR /app/frontend
|
||||||
|
COPY frontend/package*.json ./
|
||||||
|
RUN npm ci
|
||||||
|
|
||||||
|
COPY frontend/ ./
|
||||||
|
# Set environment for single container (use relative paths)
|
||||||
|
ENV NEXT_PUBLIC_API_URL=""
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# Main container with Python and built frontend
|
||||||
|
FROM python:3.11-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install system dependencies
|
||||||
|
RUN apt-get update && apt-get install -y \
|
||||||
|
gcc \
|
||||||
|
g++ \
|
||||||
|
libldap2-dev \
|
||||||
|
libsasl2-dev \
|
||||||
|
libssl-dev \
|
||||||
|
default-libmysqlclient-dev \
|
||||||
|
pkg-config \
|
||||||
|
curl \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Copy and install Python dependencies
|
||||||
|
COPY backend/requirements.txt ./
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
# Copy backend application
|
||||||
|
COPY backend/ ./
|
||||||
|
|
||||||
|
# Copy built frontend from builder stage
|
||||||
|
COPY --from=frontend-builder /app/frontend/out ./frontend/out
|
||||||
|
|
||||||
|
# Create necessary directories
|
||||||
|
RUN mkdir -p logs uploads
|
||||||
|
|
||||||
|
# Set environment variables
|
||||||
|
ENV FLASK_APP=app.py
|
||||||
|
ENV PYTHONUNBUFFERED=1
|
||||||
|
ENV FLASK_ENV=production
|
||||||
|
|
||||||
|
# Expose only one port
|
||||||
|
EXPOSE 12011
|
||||||
|
|
||||||
|
# Health check
|
||||||
|
HEALTHCHECK --interval=30s --timeout=10s --retries=3 --start-period=40s \
|
||||||
|
CMD curl -f http://localhost:12011/api/health/ || exit 1
|
||||||
|
|
||||||
|
# Run with Gunicorn for production (supports 200+ users)
|
||||||
|
CMD ["gunicorn", "--bind", "0.0.0.0:12011", "--workers", "4", "--threads", "2", "--timeout", "120", "--keep-alive", "2", "--max-requests", "1000", "--max-requests-jitter", "100", "app:app"]
|
||||||
356
README.md
Normal file
356
README.md
Normal file
@@ -0,0 +1,356 @@
|
|||||||
|
# PANJIT To-Do 專案管理系統
|
||||||
|
|
||||||
|
一套完整的企業級待辦事項管理系統,支援AD認證、郵件通知、Excel匯入匯出等功能。
|
||||||
|
|
||||||
|
## 🚀 功能特色
|
||||||
|
|
||||||
|
### 核心功能
|
||||||
|
- **待辦事項管理**:新增、編輯、刪除、狀態管理
|
||||||
|
- **多重視圖**:列表視圖、日曆視圖、看板視圖
|
||||||
|
- **權限控制**:創建者、負責人、追蹤者角色管理
|
||||||
|
- **狀態追蹤**:新建立、進行中、已阻塞、已完成
|
||||||
|
|
||||||
|
### 身份認證
|
||||||
|
- **AD 整合**:支援Active Directory單一登入
|
||||||
|
- **角色權限**:管理員、一般使用者權限管控
|
||||||
|
- **安全防護**:JWT Token認證機制
|
||||||
|
|
||||||
|
### 通知系統
|
||||||
|
- **郵件通知**:狀態變更、到期提醒自動通知
|
||||||
|
- **即時推播**:系統內通知面板
|
||||||
|
- **自定義設定**:個人化通知偏好
|
||||||
|
|
||||||
|
### 資料處理
|
||||||
|
- **Excel 匯入**:批量匯入待辦事項
|
||||||
|
- **Excel 匯出**:支援多種匯出格式
|
||||||
|
- **報表功能**:統計分析報表
|
||||||
|
|
||||||
|
### 使用者介面
|
||||||
|
- **響應式設計**:支援桌面、平板、手機
|
||||||
|
- **深色模式**:亮色/暗色/自動切換
|
||||||
|
- **多語言支援**:繁體中文介面
|
||||||
|
- **動畫效果**:流暢的使用者體驗
|
||||||
|
|
||||||
|
## 🛠 技術架構
|
||||||
|
|
||||||
|
### 端口配置
|
||||||
|
|
||||||
|
#### 單一容器架構
|
||||||
|
- **應用服務**:Port 12011 (前端 + 後端 API)
|
||||||
|
- **資料庫**:Port 33306 (MySQL)
|
||||||
|
|
||||||
|
#### 外部依賴端口
|
||||||
|
- **LDAP 服務**:Port 389
|
||||||
|
- **郵件服務**:Port 25 (SMTP)
|
||||||
|
|
||||||
|
> 📝 **架構升級**:已從分離式容器升級為單一容器部署,簡化端口管理和部署流程。
|
||||||
|
|
||||||
|
### 前端技術棧
|
||||||
|
- **框架**:Next.js 14 (React 18)
|
||||||
|
- **UI 庫**:Material-UI (MUI) 5
|
||||||
|
- **狀態管理**:Redux Toolkit + React Query
|
||||||
|
- **樣式方案**:Emotion + CSS-in-JS
|
||||||
|
- **動畫庫**:Framer Motion
|
||||||
|
- **類型檢查**:TypeScript
|
||||||
|
- **構建工具**:Next.js + SWC
|
||||||
|
|
||||||
|
### 後端技術棧
|
||||||
|
- **框架**:Flask 2.3
|
||||||
|
- **資料庫**:MySQL + SQLAlchemy ORM
|
||||||
|
- **認證系統**:Flask-JWT-Extended
|
||||||
|
- **LDAP 整合**:ldap3 (Windows 相容)
|
||||||
|
- **任務佇列**:Celery + Redis
|
||||||
|
- **郵件服務**:Flask-Mail + SMTP
|
||||||
|
- **檔案處理**:pandas + openpyxl
|
||||||
|
- **API 文檔**:Flask-RESTful
|
||||||
|
|
||||||
|
### 基礎設施
|
||||||
|
- **資料庫**:MySQL 8.0
|
||||||
|
- **快取系統**:Redis
|
||||||
|
- **檔案儲存**:本地檔案系統
|
||||||
|
- **日誌管理**:colorlog
|
||||||
|
- **部署環境**:Windows Server
|
||||||
|
|
||||||
|
## 📋 系統需求
|
||||||
|
|
||||||
|
### 開發環境
|
||||||
|
- **Node.js**:16.x 或以上版本
|
||||||
|
- **Python**:3.10 或以上版本
|
||||||
|
- **MySQL**:8.0 或以上版本
|
||||||
|
- **Redis**:6.0 或以上版本
|
||||||
|
|
||||||
|
### 生產環境
|
||||||
|
- **作業系統**:Windows Server 2016+ 或 Linux
|
||||||
|
- **記憶體**:最低 4GB,建議 8GB
|
||||||
|
- **磁碟空間**:最低 10GB
|
||||||
|
- **網路**:可連接 SMTP 和 LDAP 伺服器
|
||||||
|
|
||||||
|
## 🚀 快速開始
|
||||||
|
|
||||||
|
### 1. 專案複製
|
||||||
|
```bash
|
||||||
|
git clone <repository-url>
|
||||||
|
cd TODOLIST
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 後端設置
|
||||||
|
```bash
|
||||||
|
# 進入後端目錄
|
||||||
|
cd backend
|
||||||
|
|
||||||
|
# 建立虛擬環境 (Windows)
|
||||||
|
python -m venv venv
|
||||||
|
venv\Scripts\activate
|
||||||
|
|
||||||
|
# 安裝依賴
|
||||||
|
pip install -r requirements.txt
|
||||||
|
|
||||||
|
# 設置環境變數
|
||||||
|
copy .env.example .env
|
||||||
|
# 編輯 .env 文件,填入正確的設定值
|
||||||
|
|
||||||
|
# 初始化資料庫
|
||||||
|
python init_db.py
|
||||||
|
|
||||||
|
# 啟動後端服務 (整合前端靜態文件服務)
|
||||||
|
python app.py
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 前端設置
|
||||||
|
```bash
|
||||||
|
# 進入前端目錄
|
||||||
|
cd frontend
|
||||||
|
|
||||||
|
# 安裝依賴
|
||||||
|
npm install
|
||||||
|
|
||||||
|
# 設置環境變數
|
||||||
|
copy .env.example .env.local
|
||||||
|
# 編輯 .env.local 文件
|
||||||
|
|
||||||
|
# 開發環境
|
||||||
|
npm run dev
|
||||||
|
|
||||||
|
# 生產環境構建 (將與後端整合)
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. 背景任務 (可選)
|
||||||
|
```bash
|
||||||
|
# 在另一個終端啟動 Celery Worker
|
||||||
|
cd backend
|
||||||
|
celery -A celery_app worker --loglevel=info
|
||||||
|
|
||||||
|
# 啟動任務調度器
|
||||||
|
celery -A celery_app beat --loglevel=info
|
||||||
|
```
|
||||||
|
|
||||||
|
## ⚙️ 設定說明
|
||||||
|
|
||||||
|
### 環境變數配置
|
||||||
|
|
||||||
|
#### 後端設定 (backend/.env)
|
||||||
|
```env
|
||||||
|
# 資料庫連線
|
||||||
|
DATABASE_URL=mysql+pymysql://username:password@host:port/database
|
||||||
|
MYSQL_HOST=mysql.example.com
|
||||||
|
MYSQL_PORT=33306
|
||||||
|
MYSQL_USER=your_user
|
||||||
|
MYSQL_PASSWORD=your_password
|
||||||
|
MYSQL_DATABASE=your_database
|
||||||
|
|
||||||
|
# JWT 設定
|
||||||
|
JWT_SECRET_KEY=your-super-secret-key
|
||||||
|
JWT_ACCESS_TOKEN_EXPIRES=86400
|
||||||
|
|
||||||
|
# LDAP 設定
|
||||||
|
LDAP_SERVER=your-ldap-server.com
|
||||||
|
LDAP_PORT=389
|
||||||
|
LDAP_BIND_USER_DN=CN=ServiceAccount,CN=Users,DC=example,DC=com
|
||||||
|
LDAP_BIND_USER_PASSWORD=service_password
|
||||||
|
LDAP_SEARCH_BASE=OU=Users,DC=example,DC=com
|
||||||
|
|
||||||
|
# SMTP 設定
|
||||||
|
SMTP_SERVER=smtp.example.com
|
||||||
|
SMTP_PORT=25
|
||||||
|
SMTP_SENDER_EMAIL=noreply@example.com
|
||||||
|
|
||||||
|
# Redis 設定
|
||||||
|
REDIS_URL=redis://localhost:6379/0
|
||||||
|
|
||||||
|
# CORS 設定 (單一容器不需要跨域)
|
||||||
|
CORS_ORIGINS=http://localhost:12011
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 前端設定 (frontend/.env.local)
|
||||||
|
```env
|
||||||
|
# 後端 API 設定 (單一容器)
|
||||||
|
NEXT_PUBLIC_API_URL=http://localhost:12011
|
||||||
|
NEXT_PUBLIC_BACKEND_URL=http://localhost:12011
|
||||||
|
|
||||||
|
# 應用基本設定
|
||||||
|
NEXT_PUBLIC_APP_NAME=PANJIT To-Do System
|
||||||
|
NODE_ENV=development
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📝 部署指南
|
||||||
|
|
||||||
|
### Docker 部署 (建議)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 快速部署 - 單一容器架構
|
||||||
|
# Windows
|
||||||
|
deploy.bat
|
||||||
|
|
||||||
|
# Linux/macOS
|
||||||
|
./deploy.sh
|
||||||
|
|
||||||
|
# 或使用 Docker Compose
|
||||||
|
docker-compose up -d
|
||||||
|
|
||||||
|
# 使用管理腳本 (Windows)
|
||||||
|
manage.bat
|
||||||
|
```
|
||||||
|
|
||||||
|
### 生產環境部署
|
||||||
|
|
||||||
|
1. **資料庫準備**
|
||||||
|
- 建立 MySQL 資料庫 (Port 33306)
|
||||||
|
- 執行資料庫遷移腳本
|
||||||
|
- 設定資料庫備份策略
|
||||||
|
|
||||||
|
2. **應用程式部署**
|
||||||
|
- 設定反向代理 (Nginx/IIS)
|
||||||
|
- 應用服務: Port 12011 → 對外 Port 80/443
|
||||||
|
- 配置 SSL 證書
|
||||||
|
- 設定生產環境變數
|
||||||
|
|
||||||
|
3. **背景服務設定**
|
||||||
|
- 配置 Celery Windows Service
|
||||||
|
- 設定 Redis 服務自動啟動 (Port 6379)
|
||||||
|
- 配置日誌輪轉
|
||||||
|
|
||||||
|
4. **環境判斷保護**
|
||||||
|
- 生產環境使用 `NODE_ENV=production`
|
||||||
|
- 自動禁用 HMR WebSocket 連接
|
||||||
|
- 禁用 React DevTools 提示
|
||||||
|
|
||||||
|
## 🔐 權限矩陣
|
||||||
|
|
||||||
|
### 待辦事項權限控制
|
||||||
|
|
||||||
|
| 操作/角色 | 建立者 | 負責人 | 追蹤者 | 其他使用者 |
|
||||||
|
|----------|--------|--------|--------|------------|
|
||||||
|
| **查看(非公開)** | ✅ | ✅ | ❌ | ❌ |
|
||||||
|
| **查看(公開)** | ✅ | ✅ | ✅ | ✅ |
|
||||||
|
| **編輯** | ✅ | ❌ | ❌ | ❌ |
|
||||||
|
| **刪除** | ✅ | ❌ | ❌ | ❌ |
|
||||||
|
| **更改狀態** | ✅ | ❌ | ❌ | ❌ |
|
||||||
|
| **指派負責人** | ✅ | ❌ | ❌ | ❌ |
|
||||||
|
| **設為公開/私人** | ✅ | ❌ | ❌ | ❌ |
|
||||||
|
| **追蹤(公開)** | ✅ | ✅ | ✅ | ✅ |
|
||||||
|
| **追蹤(非公開)** | ❌ | ❌ | ❌ | ❌ |
|
||||||
|
|
||||||
|
### 權限說明
|
||||||
|
|
||||||
|
#### 可見性規則
|
||||||
|
- **公開待辦事項**:所有使用者皆可查看
|
||||||
|
- **非公開待辦事項**:僅建立者和負責人可查看
|
||||||
|
- 追蹤者只能存在於公開的待辦事項
|
||||||
|
|
||||||
|
#### 編輯權限
|
||||||
|
- **完全控制**:僅建立者擁有編輯、刪除、狀態變更等所有權限
|
||||||
|
- **唯讀權限**:負責人、追蹤者及其他使用者僅能查看,無法編輯
|
||||||
|
|
||||||
|
#### 追蹤功能
|
||||||
|
- 只有公開的待辦事項才能被追蹤
|
||||||
|
- 任何人都可以追蹤公開的待辦事項
|
||||||
|
- 非公開的待辦事項不支援追蹤功能
|
||||||
|
|
||||||
|
## 🧪 測試
|
||||||
|
|
||||||
|
### 單元測試
|
||||||
|
```bash
|
||||||
|
# 後端測試
|
||||||
|
cd backend
|
||||||
|
pytest tests/ -v
|
||||||
|
|
||||||
|
# 前端測試
|
||||||
|
cd frontend
|
||||||
|
npm run test
|
||||||
|
```
|
||||||
|
|
||||||
|
### 類型檢查
|
||||||
|
```bash
|
||||||
|
# 前端類型檢查
|
||||||
|
cd frontend
|
||||||
|
npm run type-check
|
||||||
|
```
|
||||||
|
|
||||||
|
### 代碼規範檢查
|
||||||
|
```bash
|
||||||
|
# 前端 ESLint 檢查
|
||||||
|
cd frontend
|
||||||
|
npm run lint
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📚 API 文檔
|
||||||
|
|
||||||
|
主要 API 端點:
|
||||||
|
|
||||||
|
### 認證相關
|
||||||
|
- `POST /api/auth/login` - 使用者登入
|
||||||
|
- `POST /api/auth/logout` - 使用者登出
|
||||||
|
- `GET /api/auth/me` - 取得當前使用者資訊
|
||||||
|
|
||||||
|
### 待辦事項
|
||||||
|
- `GET /api/todos` - 取得待辦清單
|
||||||
|
- `POST /api/todos` - 建立新待辦事項
|
||||||
|
- `PUT /api/todos/{id}` - 更新待辦事項
|
||||||
|
- `DELETE /api/todos/{id}` - 刪除待辦事項
|
||||||
|
|
||||||
|
### Excel 功能
|
||||||
|
- `POST /api/excel/import` - Excel 匯入
|
||||||
|
- `GET /api/excel/export` - Excel 匯出
|
||||||
|
- `GET /api/excel/template` - 下載匯入模板
|
||||||
|
|
||||||
|
## 🤝 開發貢獻
|
||||||
|
|
||||||
|
### 代碼規範
|
||||||
|
- 使用 TypeScript 進行前端開發
|
||||||
|
- 遵循 ESLint 和 Prettier 設定
|
||||||
|
- 後端使用 Python Type Hints
|
||||||
|
- 提交前執行測試
|
||||||
|
|
||||||
|
### Git 工作流程
|
||||||
|
1. 建立功能分支
|
||||||
|
2. 開發並測試功能
|
||||||
|
3. 提交 Pull Request
|
||||||
|
4. 代碼審查
|
||||||
|
5. 合併到主分支
|
||||||
|
|
||||||
|
## 🐛 問題排解
|
||||||
|
|
||||||
|
### 常見問題
|
||||||
|
|
||||||
|
**Q: 登入失敗,顯示 LDAP 連線錯誤**
|
||||||
|
A: 檢查 LDAP 設定和網路連線,確認服務帳號權限
|
||||||
|
|
||||||
|
**Q: 郵件通知無法發送**
|
||||||
|
A: 驗證 SMTP 設定,檢查防火牆和郵件伺服器設定
|
||||||
|
|
||||||
|
**Q: 應用無法正常訪問**
|
||||||
|
A: 確認單一容器服務正常運行,檢查端口 12011 是否可訪問,確認靜態文件正確構建
|
||||||
|
|
||||||
|
**Q: 生產環境出現 WebSocket HMR 錯誤**
|
||||||
|
A: 系統已加入環境判斷保護,確保使用 `npm run build && npm run start` 而非 `npm run dev` 進行生產部署
|
||||||
|
|
||||||
|
**Q: 端口衝突問題**
|
||||||
|
A: 系統使用 12010-12019 範圍的端口,如遇衝突請修改環境變數中的端口設定
|
||||||
|
|
||||||
|
**Q: 靜態文件載入失敗**
|
||||||
|
A: 確認 Next.js 構建正確完成,檢查 /app/frontend/out 目錄是否存在於容器中
|
||||||
|
|
||||||
|
**Q: Excel 匯入失敗**
|
||||||
|
A: 檢查文件格式和欄位映射,參考匯入模板
|
||||||
|
|
||||||
360
USERMANUAL.md
Normal file
360
USERMANUAL.md
Normal file
@@ -0,0 +1,360 @@
|
|||||||
|
# PANJIT To-Do 系統 - 使用者手冊
|
||||||
|
|
||||||
|
## 📖 目錄
|
||||||
|
1. [系統登入](#系統登入)
|
||||||
|
2. [主要介面說明](#主要介面說明)
|
||||||
|
3. [待辦事項管理](#待辦事項管理)
|
||||||
|
4. [篩選與搜尋](#篩選與搜尋)
|
||||||
|
5. [日曆視圖](#日曆視圖)
|
||||||
|
6. [Excel 功能](#excel-功能)
|
||||||
|
7. [通知設定](#通知設定)
|
||||||
|
8. [系統設定](#系統設定)
|
||||||
|
9. [常見問題](#常見問題)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔐 系統登入
|
||||||
|
|
||||||
|
### 登入步驟
|
||||||
|
1. 開啟瀏覽器,輸入系統網址
|
||||||
|
2. 在登入頁面輸入您的 **AD 帳號** 和 **密碼**
|
||||||
|
3. 點擊「登入」按鈕
|
||||||
|
|
||||||
|
### 登入注意事項
|
||||||
|
- 請使用公司 Active Directory 帳號
|
||||||
|
- 如果忘記密碼,請聯繫 IT 部門重設
|
||||||
|
- 系統支援記住登入狀態功能
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🏠 主要介面說明
|
||||||
|
|
||||||
|
### 導航側邊欄
|
||||||
|
系統左側提供完整的功能導航:
|
||||||
|
|
||||||
|
#### 主要功能
|
||||||
|
- **儀表板**:查看系統總覽和統計資料
|
||||||
|
- **待辦清單**:管理所有待辦事項
|
||||||
|
- **公開任務**:查看公開的待辦事項
|
||||||
|
- **日曆視圖**:以日曆形式查看任務
|
||||||
|
|
||||||
|
#### 視圖篩選
|
||||||
|
- **已加星**:查看標記星號的重要事項
|
||||||
|
- **我建立的**:顯示由您建立的待辦事項
|
||||||
|
- **指派給我**:顯示指派給您的待辦事項
|
||||||
|
- **我追蹤的**:顯示您正在追蹤的待辦事項
|
||||||
|
|
||||||
|
#### 狀態分類
|
||||||
|
- **新建立**:剛建立尚未開始的任務
|
||||||
|
- **進行中**:正在進行的任務
|
||||||
|
- **已阻塞**:遇到問題暫時停止的任務
|
||||||
|
- **已完成**:已經完成的任務
|
||||||
|
|
||||||
|
### 頂部工具列
|
||||||
|
- **選單按鈕**:在小螢幕上收合/展開側邊欄
|
||||||
|
- **主題切換**:切換亮色/暗色/自動模式
|
||||||
|
- **通知鈴鐺**:查看系統通知
|
||||||
|
- **使用者頭像**:存取個人設定和登出
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✅ 待辦事項管理
|
||||||
|
|
||||||
|
### 建立新的待辦事項
|
||||||
|
1. 點擊「+ 新增待辦事項」按鈕
|
||||||
|
2. 填寫以下資訊:
|
||||||
|
- **標題**:簡明的任務描述(必填)
|
||||||
|
- **內容**:詳細的任務說明
|
||||||
|
- **截止日期**:任務完成期限
|
||||||
|
- **優先級**:高、中、低
|
||||||
|
- **負責人**:可指派給其他同事
|
||||||
|
- **追蹤者**:需要關注此任務的人員
|
||||||
|
- **標籤**:用於分類管理
|
||||||
|
3. 點擊「建立」按鈕
|
||||||
|
|
||||||
|
### 編輯待辦事項
|
||||||
|
1. 在待辦清單中點擊要編輯的項目
|
||||||
|
2. 修改所需的資訊
|
||||||
|
3. 點擊「儲存」按鈕確認變更
|
||||||
|
|
||||||
|
### 狀態管理
|
||||||
|
- **拖放操作**:直接拖拽任務到不同狀態欄
|
||||||
|
- **下拉選單**:點擊狀態下拉選單切換
|
||||||
|
- **快速按鈕**:使用工具列上的快速狀態按鈕
|
||||||
|
|
||||||
|
### 重要功能
|
||||||
|
- **加星標記**:點擊星號圖示標記重要任務
|
||||||
|
- **複製任務**:快速建立相似任務
|
||||||
|
- **刪除任務**:刪除不需要的任務(僅限建立者)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔍 篩選與搜尋
|
||||||
|
|
||||||
|
### 搜尋功能
|
||||||
|
- 在頂部搜尋框輸入關鍵字
|
||||||
|
- 支援搜尋標題、內容、負責人、建立者
|
||||||
|
- 即時搜尋,輸入即顯示結果
|
||||||
|
|
||||||
|
### 篩選選項
|
||||||
|
點擊「篩選」按鈕可設定以下條件:
|
||||||
|
- **狀態篩選**:選擇特定狀態的任務
|
||||||
|
- **優先級篩選**:篩選不同優先級
|
||||||
|
- **日期範圍**:設定建立日期或截止日期範圍
|
||||||
|
- **負責人篩選**:查看特定人員的任務
|
||||||
|
- **標籤篩選**:依標籤分類查看
|
||||||
|
|
||||||
|
### 排序選項
|
||||||
|
- **建立時間**:依建立時間排序
|
||||||
|
- **截止日期**:依到期日排序
|
||||||
|
- **優先級**:依重要性排序
|
||||||
|
- **狀態**:依任務狀態排序
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📅 日曆視圖
|
||||||
|
|
||||||
|
### 檢視模式
|
||||||
|
- **月檢視**:以月份為單位顯示任務
|
||||||
|
- **週檢視**:以週為單位的詳細檢視
|
||||||
|
- **日檢視**:單日的詳細任務排程
|
||||||
|
|
||||||
|
### 日曆操作
|
||||||
|
- **拖放任務**:直接拖拽任務到其他日期
|
||||||
|
- **快速建立**:點擊日期快速建立任務
|
||||||
|
- **任務詳情**:點擊任務查看完整資訊
|
||||||
|
|
||||||
|
### 顏色編碼
|
||||||
|
- **紅色**:逾期任務
|
||||||
|
- **橙色**:即將到期(3天內)
|
||||||
|
- **藍色**:一般任務
|
||||||
|
- **綠色**:已完成任務
|
||||||
|
- **灰色**:已阻塞任務
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📊 Excel 功能
|
||||||
|
|
||||||
|
### 匯入待辦事項
|
||||||
|
1. 點擊「Excel 匯入」按鈕
|
||||||
|
2. 下載匯入模板(首次使用建議)
|
||||||
|
3. 填寫 Excel 檔案:
|
||||||
|
- **標題**:任務標題(必填)
|
||||||
|
- **內容**:任務詳細說明
|
||||||
|
- **截止日期**:格式為 YYYY-MM-DD
|
||||||
|
- **優先級**:HIGH/MEDIUM/LOW
|
||||||
|
- **負責人**:AD帳號或郵件地址
|
||||||
|
- **狀態**:NEW/DOING/BLOCKED/DONE
|
||||||
|
4. 上傳並預覽匯入資料
|
||||||
|
5. 確認無誤後執行匯入
|
||||||
|
|
||||||
|
### 匯出待辦事項
|
||||||
|
1. 在待辦清單頁面點擊「Excel 匯出」
|
||||||
|
2. 選擇匯出範圍:
|
||||||
|
- **全部任務**
|
||||||
|
- **目前篩選結果**
|
||||||
|
- **選中的任務**
|
||||||
|
3. 選擇匯出格式和欄位
|
||||||
|
4. 點擊「匯出」下載檔案
|
||||||
|
|
||||||
|
### Excel 模板說明
|
||||||
|
- **必填欄位**:標題
|
||||||
|
- **日期格式**:YYYY-MM-DD HH:MM
|
||||||
|
- **狀態值**:NEW, DOING, BLOCKED, DONE
|
||||||
|
- **優先級值**:HIGH, MEDIUM, LOW
|
||||||
|
- **使用者格式**:AD帳號或完整郵件地址
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔐 權限說明與控制
|
||||||
|
|
||||||
|
### 權限矩陣
|
||||||
|
|
||||||
|
系統的待辦事項權限控制如下:
|
||||||
|
|
||||||
|
| 操作權限 | 建立者 | 負責人 | 追蹤者 | 其他使用者 |
|
||||||
|
|---------|--------|--------|--------|------------|
|
||||||
|
| **查看非公開任務** | ✅ 可以 | ✅ 可以 | ❌ 不可 | ❌ 不可 |
|
||||||
|
| **查看公開任務** | ✅ 可以 | ✅ 可以 | ✅ 可以 | ✅ 可以 |
|
||||||
|
| **編輯任務內容** | ✅ 可以 | ❌ 不可 | ❌ 不可 | ❌ 不可 |
|
||||||
|
| **刪除任務** | ✅ 可以 | ❌ 不可 | ❌ 不可 | ❌ 不可 |
|
||||||
|
| **更改任務狀態** | ✅ 可以 | ❌ 不可 | ❌ 不可 | ❌ 不可 |
|
||||||
|
| **指派負責人** | ✅ 可以 | ❌ 不可 | ❌ 不可 | ❌ 不可 |
|
||||||
|
| **設定公開/私人** | ✅ 可以 | ❌ 不可 | ❌ 不可 | ❌ 不可 |
|
||||||
|
| **追蹤公開任務** | ✅ 可以 | ✅ 可以 | ✅ 可以 | ✅ 可以 |
|
||||||
|
| **追蹤非公開任務** | ❌ 不可 | ❌ 不可 | ❌ 不可 | ❌ 不可 |
|
||||||
|
|
||||||
|
### 角色說明
|
||||||
|
|
||||||
|
#### 建立者 (Creator)
|
||||||
|
- 對待辦事項擁有**完全控制權**
|
||||||
|
- 可以執行所有操作:編輯、刪除、狀態變更、指派等
|
||||||
|
- 是唯一可以將任務設為公開或私人的角色
|
||||||
|
|
||||||
|
#### 負責人 (Responsible User)
|
||||||
|
- 被指派執行任務的人員
|
||||||
|
- **只能查看**任務內容,無法編輯
|
||||||
|
- 可以看到非公開的任務(因為被指派)
|
||||||
|
- 無法變更任務狀態或內容
|
||||||
|
|
||||||
|
#### 追蹤者 (Follower)
|
||||||
|
- 關注公開任務進展的人員
|
||||||
|
- **只能存在於公開任務**
|
||||||
|
- 僅有查看權限,無法編輯
|
||||||
|
- 非公開任務不支援追蹤功能
|
||||||
|
|
||||||
|
#### 其他使用者
|
||||||
|
- 只能查看和追蹤**公開任務**
|
||||||
|
- 無法查看非公開任務
|
||||||
|
- 無任何編輯權限
|
||||||
|
|
||||||
|
### 重要提醒
|
||||||
|
|
||||||
|
1. **非公開任務**預設只有建立者和被指派的負責人能看到
|
||||||
|
2. **公開任務**所有系統使用者都能查看和追蹤
|
||||||
|
3. **編輯權限**僅限建立者,確保任務內容的一致性
|
||||||
|
4. **追蹤功能**僅適用於公開任務,私人任務不開放追蹤
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔔 通知設定
|
||||||
|
|
||||||
|
### 通知類型
|
||||||
|
系統提供以下通知:
|
||||||
|
- **任務指派通知**:被指派新任務時
|
||||||
|
- **狀態變更通知**:任務狀態改變時
|
||||||
|
- **到期提醒**:任務即將到期時
|
||||||
|
- **逾期通知**:任務已逾期時
|
||||||
|
- **評論通知**:任務有新評論時
|
||||||
|
|
||||||
|
### 通知設定
|
||||||
|
1. 點擊右上角頭像 → 「通知設定」
|
||||||
|
2. 選擇接收通知的類型:
|
||||||
|
- **即時通知**:系統內彈出通知
|
||||||
|
- **郵件通知**:發送到註冊郵箱
|
||||||
|
- **提醒時間**:設定提前提醒時間
|
||||||
|
3. 儲存設定
|
||||||
|
|
||||||
|
### 通知管理
|
||||||
|
- **通知面板**:點擊通知鈴鐺查看所有通知
|
||||||
|
- **標記已讀**:點擊通知標記為已讀
|
||||||
|
- **清除通知**:清除所有已讀通知
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ⚙️ 系統設定
|
||||||
|
|
||||||
|
### 個人設定
|
||||||
|
1. 點擊右上角頭像 → 「個人設定」
|
||||||
|
2. 可調整:
|
||||||
|
- **顯示語言**:系統介面語言
|
||||||
|
- **時區設定**:本地時區
|
||||||
|
- **每頁顯示數量**:列表每頁項目數
|
||||||
|
- **預設視圖**:登入後預設頁面
|
||||||
|
|
||||||
|
### 主題設定
|
||||||
|
- **亮色模式**:白色背景主題
|
||||||
|
- **暗色模式**:深色背景主題
|
||||||
|
- **自動切換**:跟隨系統設定
|
||||||
|
|
||||||
|
### 隱私設定
|
||||||
|
- **個人資料可見性**:設定其他使用者能看到的資訊
|
||||||
|
- **任務預設權限**:新建任務的預設可見範圍
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ❓ 常見問題
|
||||||
|
|
||||||
|
### 登入相關
|
||||||
|
**Q: 忘記密碼怎麼辦?**
|
||||||
|
A: 請聯繫 IT 部門重設 AD 密碼,系統使用公司 Active Directory 認證。
|
||||||
|
|
||||||
|
**Q: 為什麼無法登入?**
|
||||||
|
A: 請確認:
|
||||||
|
- AD 帳號和密碼正確
|
||||||
|
- 帳號未被停用
|
||||||
|
- 網路連線正常
|
||||||
|
|
||||||
|
### 使用功能
|
||||||
|
**Q: 無法建立待辦事項**
|
||||||
|
A: 請檢查:
|
||||||
|
- 標題欄位是否已填寫
|
||||||
|
- 網路連線是否正常
|
||||||
|
- 是否有足夠的權限
|
||||||
|
|
||||||
|
**Q: 找不到之前建立的任務**
|
||||||
|
A: 請嘗試:
|
||||||
|
- 清除所有篩選條件
|
||||||
|
- 使用搜尋功能
|
||||||
|
- 檢查不同狀態分類
|
||||||
|
|
||||||
|
**Q: Excel 匯入失敗**
|
||||||
|
A: 常見原因:
|
||||||
|
- 檔案格式不正確(請使用 .xlsx)
|
||||||
|
- 必填欄位未填寫
|
||||||
|
- 日期格式錯誤
|
||||||
|
- 使用者帳號不存在
|
||||||
|
|
||||||
|
### 通知問題
|
||||||
|
**Q: 收不到郵件通知**
|
||||||
|
A: 請檢查:
|
||||||
|
- 郵件地址是否正確
|
||||||
|
- 垃圾郵件資料夾
|
||||||
|
- 通知設定是否開啟
|
||||||
|
|
||||||
|
**Q: 通知太多怎麼辦?**
|
||||||
|
A: 可以在通知設定中:
|
||||||
|
- 關閉不需要的通知類型
|
||||||
|
- 調整提醒時間
|
||||||
|
- 設定免打擾時段
|
||||||
|
|
||||||
|
### 效能問題
|
||||||
|
**Q: 系統載入很慢**
|
||||||
|
A: 建議:
|
||||||
|
- 清除瀏覽器快取
|
||||||
|
- 使用較新版本的瀏覽器
|
||||||
|
- 檢查網路連線品質
|
||||||
|
|
||||||
|
**Q: 手機上使用體驗不佳**
|
||||||
|
A: 系統提供響應式設計:
|
||||||
|
- 支援手機瀏覽器存取
|
||||||
|
- 建議使用 Chrome 或 Safari
|
||||||
|
- 確保瀏覽器為最新版本
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 問題回報
|
||||||
|
回報問題時請提供:
|
||||||
|
1. 問題發生時間
|
||||||
|
2. 操作步驟描述
|
||||||
|
3. 錯誤訊息截圖
|
||||||
|
4. 使用的瀏覽器和版本
|
||||||
|
5. 作業系統資訊
|
||||||
|
|
||||||
|
### 建議與回饋
|
||||||
|
歡迎提供系統改善建議:
|
||||||
|
- 功能需求
|
||||||
|
- 介面優化建議
|
||||||
|
- 使用體驗改善
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📋 快速參考
|
||||||
|
|
||||||
|
### 鍵盤快捷鍵
|
||||||
|
- `Ctrl + N`:建立新待辦事項
|
||||||
|
- `Ctrl + F`:開啟搜尋
|
||||||
|
- `Ctrl + /`:顯示快捷鍵說明
|
||||||
|
- `Esc`:關閉對話框
|
||||||
|
|
||||||
|
### 狀態代碼
|
||||||
|
- **NEW**:新建立
|
||||||
|
- **DOING**:進行中
|
||||||
|
- **BLOCKED**:已阻塞
|
||||||
|
- **DONE**:已完成
|
||||||
|
|
||||||
|
### 優先級
|
||||||
|
- **HIGH**:高優先級(紅色)
|
||||||
|
- **MEDIUM**:中優先級(橙色)
|
||||||
|
- **LOW**:低優先級(綠色)
|
||||||
|
|
||||||
99
backend/.dockerignore
Normal file
99
backend/.dockerignore
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
*.so
|
||||||
|
.Python
|
||||||
|
build/
|
||||||
|
develop-eggs/
|
||||||
|
dist/
|
||||||
|
downloads/
|
||||||
|
eggs/
|
||||||
|
.eggs/
|
||||||
|
lib/
|
||||||
|
lib64/
|
||||||
|
parts/
|
||||||
|
sdist/
|
||||||
|
var/
|
||||||
|
wheels/
|
||||||
|
pip-wheel-metadata/
|
||||||
|
share/python-wheels/
|
||||||
|
*.egg-info/
|
||||||
|
.installed.cfg
|
||||||
|
*.egg
|
||||||
|
MANIFEST
|
||||||
|
|
||||||
|
# Virtual environments
|
||||||
|
venv/
|
||||||
|
env/
|
||||||
|
ENV/
|
||||||
|
.venv/
|
||||||
|
.env/
|
||||||
|
|
||||||
|
# Environment files
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.development
|
||||||
|
.env.production
|
||||||
|
|
||||||
|
# Flask
|
||||||
|
instance/
|
||||||
|
.webassets-cache
|
||||||
|
|
||||||
|
# Celery
|
||||||
|
celerybeat-schedule
|
||||||
|
celerybeat.pid
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
logs/
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# Uploads
|
||||||
|
uploads/
|
||||||
|
temp/
|
||||||
|
tmp/
|
||||||
|
|
||||||
|
# Database
|
||||||
|
*.db
|
||||||
|
*.sqlite
|
||||||
|
*.sqlite3
|
||||||
|
|
||||||
|
# IDE files
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
|
||||||
|
# OS files
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
*.tmp
|
||||||
|
*.temp
|
||||||
|
|
||||||
|
# Git
|
||||||
|
.git/
|
||||||
|
.gitignore
|
||||||
|
.gitattributes
|
||||||
|
|
||||||
|
# Documentation
|
||||||
|
README.md
|
||||||
|
CHANGELOG.md
|
||||||
|
LICENSE
|
||||||
|
docs/
|
||||||
|
|
||||||
|
# Docker files
|
||||||
|
Dockerfile*
|
||||||
|
.dockerignore
|
||||||
|
docker-compose*.yml
|
||||||
|
|
||||||
|
# Testing
|
||||||
|
.pytest_cache/
|
||||||
|
.coverage
|
||||||
|
htmlcov/
|
||||||
|
.tox/
|
||||||
|
.cache
|
||||||
|
nosetests.xml
|
||||||
|
coverage.xml
|
||||||
|
*.cover
|
||||||
|
*.py,cover
|
||||||
|
.hypothesis/
|
||||||
81
backend/.env
Normal file
81
backend/.env
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
# ===========================================
|
||||||
|
# Flask 應用程式設定
|
||||||
|
# ===========================================
|
||||||
|
FLASK_ENV=development
|
||||||
|
SECRET_KEY=dev-secret-key-change-in-production
|
||||||
|
|
||||||
|
# ===========================================
|
||||||
|
# MySQL 資料庫連線
|
||||||
|
# ===========================================
|
||||||
|
MYSQL_HOST=mysql.theaken.com
|
||||||
|
MYSQL_PORT=33306
|
||||||
|
MYSQL_USER=A060
|
||||||
|
MYSQL_PASSWORD=WLeSCi0yhtc7
|
||||||
|
MYSQL_DATABASE=db_A060
|
||||||
|
|
||||||
|
# ===========================================
|
||||||
|
# JWT 設定
|
||||||
|
# ===========================================
|
||||||
|
JWT_SECRET_KEY=jwt-secret-key-change-in-production
|
||||||
|
JWT_ACCESS_TOKEN_EXPIRES_HOURS=8
|
||||||
|
JWT_REFRESH_TOKEN_EXPIRES_DAYS=30
|
||||||
|
|
||||||
|
# ===========================================
|
||||||
|
# AD/LDAP 設定
|
||||||
|
# ===========================================
|
||||||
|
USE_MOCK_LDAP=false
|
||||||
|
LDAP_SERVER=ldap://panjit.com.tw
|
||||||
|
LDAP_PORT=389
|
||||||
|
LDAP_USE_SSL=false
|
||||||
|
LDAP_USE_TLS=false
|
||||||
|
LDAP_SEARCH_BASE=OU=PANJIT,DC=panjit,DC=com,DC=tw
|
||||||
|
LDAP_BIND_USER_DN=CN=LdapBind,CN=Users,DC=PANJIT,DC=COM,DC=TW
|
||||||
|
LDAP_BIND_USER_PASSWORD=panjit2481
|
||||||
|
LDAP_USER_LOGIN_ATTR=userPrincipalName
|
||||||
|
|
||||||
|
# ===========================================
|
||||||
|
# SMTP 郵件設定
|
||||||
|
# ===========================================
|
||||||
|
SMTP_SERVER=mail.panjit.com.tw
|
||||||
|
SMTP_PORT=25
|
||||||
|
SMTP_USE_TLS=false
|
||||||
|
SMTP_USE_SSL=false
|
||||||
|
SMTP_AUTH_REQUIRED=false
|
||||||
|
SMTP_SENDER_EMAIL=todo-system@panjit.com.tw
|
||||||
|
SMTP_SENDER_PASSWORD=
|
||||||
|
|
||||||
|
# ===========================================
|
||||||
|
# Fire Email 限制設定
|
||||||
|
# ===========================================
|
||||||
|
FIRE_EMAIL_COOLDOWN_MINUTES=2
|
||||||
|
FIRE_EMAIL_DAILY_LIMIT=20
|
||||||
|
|
||||||
|
# ===========================================
|
||||||
|
# 排程提醒設定
|
||||||
|
# ===========================================
|
||||||
|
REMINDER_DAYS_BEFORE=3
|
||||||
|
REMINDER_DAYS_AFTER=1
|
||||||
|
WEEKLY_SUMMARY_DAY=0
|
||||||
|
WEEKLY_SUMMARY_HOUR=9
|
||||||
|
|
||||||
|
# ===========================================
|
||||||
|
# 檔案上傳設定
|
||||||
|
# ===========================================
|
||||||
|
MAX_CONTENT_LENGTH=16
|
||||||
|
UPLOAD_FOLDER=uploads
|
||||||
|
|
||||||
|
# ===========================================
|
||||||
|
# Redis 設定 (用於 Celery)
|
||||||
|
# ===========================================
|
||||||
|
REDIS_URL=redis://localhost:6379/0
|
||||||
|
|
||||||
|
# ===========================================
|
||||||
|
# CORS 設定
|
||||||
|
# ===========================================
|
||||||
|
CORS_ORIGINS=http://localhost:12012,http://localhost:3001,http://localhost:3002
|
||||||
|
|
||||||
|
# ===========================================
|
||||||
|
# 日誌設定
|
||||||
|
# ===========================================
|
||||||
|
LOG_LEVEL=INFO
|
||||||
|
LOG_FILE=logs/app.log
|
||||||
92
backend/.env.example
Normal file
92
backend/.env.example
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
# ===========================================
|
||||||
|
# Flask 應用程式設定
|
||||||
|
# ===========================================
|
||||||
|
FLASK_ENV=development
|
||||||
|
SECRET_KEY=dev-secret-key-change-in-production
|
||||||
|
|
||||||
|
# ===========================================
|
||||||
|
# MySQL 資料庫連線
|
||||||
|
# ===========================================
|
||||||
|
# 開發資料庫 (使用提供的測試資料庫)
|
||||||
|
MYSQL_HOST=mysql.theaken.com
|
||||||
|
MYSQL_PORT=33306
|
||||||
|
MYSQL_USER=A060
|
||||||
|
MYSQL_PASSWORD=WLeSCi0yhtc7
|
||||||
|
MYSQL_DATABASE=db_A060
|
||||||
|
|
||||||
|
# 本地資料庫 (如果要使用本地Docker MySQL)
|
||||||
|
# MYSQL_HOST=localhost
|
||||||
|
# MYSQL_PORT=3306
|
||||||
|
# MYSQL_USER=todouser
|
||||||
|
# MYSQL_PASSWORD=todopass
|
||||||
|
# MYSQL_DATABASE=todo_system
|
||||||
|
|
||||||
|
# ===========================================
|
||||||
|
# JWT 設定
|
||||||
|
# ===========================================
|
||||||
|
JWT_SECRET_KEY=jwt-secret-key-change-in-production
|
||||||
|
JWT_ACCESS_TOKEN_EXPIRES_HOURS=8
|
||||||
|
JWT_REFRESH_TOKEN_EXPIRES_DAYS=30
|
||||||
|
|
||||||
|
# ===========================================
|
||||||
|
# AD/LDAP 設定
|
||||||
|
# ===========================================
|
||||||
|
# 開發模式:設定為 true 使用Mock LDAP(不需連接真實AD)
|
||||||
|
USE_MOCK_LDAP=true
|
||||||
|
|
||||||
|
# 正式LDAP設定(當USE_MOCK_LDAP=false時使用)
|
||||||
|
LDAP_SERVER=ldap://dc.company.com
|
||||||
|
LDAP_PORT=389
|
||||||
|
LDAP_USE_SSL=false
|
||||||
|
LDAP_USE_TLS=false
|
||||||
|
LDAP_SEARCH_BASE=DC=company,DC=com
|
||||||
|
LDAP_BIND_USER_DN=
|
||||||
|
LDAP_BIND_USER_PASSWORD=
|
||||||
|
LDAP_USER_LOGIN_ATTR=userPrincipalName
|
||||||
|
|
||||||
|
# ===========================================
|
||||||
|
# SMTP 郵件設定
|
||||||
|
# ===========================================
|
||||||
|
SMTP_SERVER=smtp.company.com
|
||||||
|
SMTP_PORT=25
|
||||||
|
SMTP_USE_TLS=false
|
||||||
|
SMTP_USE_SSL=false
|
||||||
|
SMTP_AUTH_REQUIRED=false
|
||||||
|
SMTP_SENDER_EMAIL=todo-system@company.com
|
||||||
|
SMTP_SENDER_PASSWORD=
|
||||||
|
|
||||||
|
# ===========================================
|
||||||
|
# Fire Email 限制設定
|
||||||
|
# ===========================================
|
||||||
|
FIRE_EMAIL_COOLDOWN_MINUTES=2
|
||||||
|
FIRE_EMAIL_DAILY_LIMIT=20
|
||||||
|
|
||||||
|
# ===========================================
|
||||||
|
# 排程提醒設定
|
||||||
|
# ===========================================
|
||||||
|
REMINDER_DAYS_BEFORE=3
|
||||||
|
REMINDER_DAYS_AFTER=1
|
||||||
|
WEEKLY_SUMMARY_DAY=0
|
||||||
|
WEEKLY_SUMMARY_HOUR=9
|
||||||
|
|
||||||
|
# ===========================================
|
||||||
|
# 檔案上傳設定
|
||||||
|
# ===========================================
|
||||||
|
MAX_CONTENT_LENGTH=16
|
||||||
|
UPLOAD_FOLDER=uploads
|
||||||
|
|
||||||
|
# ===========================================
|
||||||
|
# Redis 設定 (用於 Celery)
|
||||||
|
# ===========================================
|
||||||
|
REDIS_URL=redis://localhost:6379/0
|
||||||
|
|
||||||
|
# ===========================================
|
||||||
|
# CORS 設定
|
||||||
|
# ===========================================
|
||||||
|
CORS_ORIGINS=http://localhost:12012
|
||||||
|
|
||||||
|
# ===========================================
|
||||||
|
# 日誌設定
|
||||||
|
# ===========================================
|
||||||
|
LOG_LEVEL=INFO
|
||||||
|
LOG_FILE=logs/app.log
|
||||||
185
backend/app.py
Normal file
185
backend/app.py
Normal file
@@ -0,0 +1,185 @@
|
|||||||
|
import os
|
||||||
|
import logging
|
||||||
|
from datetime import datetime
|
||||||
|
from flask import Flask, jsonify, send_from_directory, send_file, request
|
||||||
|
from flask_cors import CORS
|
||||||
|
from flask_jwt_extended import JWTManager
|
||||||
|
from jwt.exceptions import InvalidTokenError
|
||||||
|
from flask_migrate import Migrate
|
||||||
|
from flask_mail import Mail
|
||||||
|
from config import config
|
||||||
|
from models import db
|
||||||
|
from utils.logger import setup_logger
|
||||||
|
|
||||||
|
# Import blueprints
|
||||||
|
from routes.auth import auth_bp
|
||||||
|
from routes.todos import todos_bp
|
||||||
|
from routes.users import users_bp
|
||||||
|
from routes.admin import admin_bp
|
||||||
|
from routes.health import health_bp
|
||||||
|
from routes.reports import reports_bp
|
||||||
|
from routes.excel import excel_bp
|
||||||
|
from routes.notifications import notifications_bp
|
||||||
|
from routes.scheduler import scheduler_bp
|
||||||
|
|
||||||
|
migrate = Migrate()
|
||||||
|
mail = Mail()
|
||||||
|
jwt = JWTManager()
|
||||||
|
|
||||||
|
def setup_jwt_error_handlers(jwt):
|
||||||
|
@jwt.expired_token_loader
|
||||||
|
def expired_token_callback(jwt_header, jwt_payload):
|
||||||
|
return jsonify({'msg': 'Token has expired'}), 401
|
||||||
|
|
||||||
|
@jwt.invalid_token_loader
|
||||||
|
def invalid_token_callback(error):
|
||||||
|
return jsonify({'msg': 'Invalid token'}), 401
|
||||||
|
|
||||||
|
@jwt.unauthorized_loader
|
||||||
|
def missing_token_callback(error):
|
||||||
|
return jsonify({'msg': 'Missing Authorization Header'}), 401
|
||||||
|
|
||||||
|
def create_app(config_name=None):
|
||||||
|
if config_name is None:
|
||||||
|
config_name = os.environ.get('FLASK_ENV', 'development')
|
||||||
|
|
||||||
|
app = Flask(__name__, static_folder='./frontend/out', static_url_path='')
|
||||||
|
app.config.from_object(config[config_name])
|
||||||
|
|
||||||
|
# Initialize extensions
|
||||||
|
db.init_app(app)
|
||||||
|
migrate.init_app(app, db)
|
||||||
|
mail.init_app(app)
|
||||||
|
jwt.init_app(app)
|
||||||
|
|
||||||
|
# Setup CORS
|
||||||
|
CORS(app,
|
||||||
|
origins=app.config['CORS_ORIGINS'],
|
||||||
|
methods=['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
|
||||||
|
allow_headers=['Content-Type', 'Authorization'],
|
||||||
|
supports_credentials=True,
|
||||||
|
expose_headers=['Content-Type', 'Authorization'])
|
||||||
|
|
||||||
|
# Setup logging
|
||||||
|
setup_logger(app)
|
||||||
|
|
||||||
|
# Setup JWT error handlers
|
||||||
|
setup_jwt_error_handlers(jwt)
|
||||||
|
|
||||||
|
# Register blueprints
|
||||||
|
app.register_blueprint(auth_bp, url_prefix='/api/auth')
|
||||||
|
app.register_blueprint(todos_bp, url_prefix='/api/todos')
|
||||||
|
app.register_blueprint(users_bp, url_prefix='/api/users')
|
||||||
|
app.register_blueprint(admin_bp, url_prefix='/api/admin')
|
||||||
|
app.register_blueprint(health_bp, url_prefix='/api/health')
|
||||||
|
app.register_blueprint(reports_bp, url_prefix='/api/reports')
|
||||||
|
app.register_blueprint(excel_bp, url_prefix='/api/excel')
|
||||||
|
app.register_blueprint(notifications_bp, url_prefix='/api/notifications')
|
||||||
|
app.register_blueprint(scheduler_bp, url_prefix='/api/scheduler')
|
||||||
|
|
||||||
|
# Add static file serving routes
|
||||||
|
@app.route('/')
|
||||||
|
def serve_index():
|
||||||
|
return send_from_directory(app.static_folder, 'index.html')
|
||||||
|
|
||||||
|
@app.route('/<path:path>')
|
||||||
|
def serve_static(path):
|
||||||
|
# For SPA routing, return index.html for non-API routes first
|
||||||
|
if not path.startswith('api/'):
|
||||||
|
# Check if it's a static file first
|
||||||
|
if path and os.path.exists(os.path.join(app.static_folder, path)) and os.path.isfile(os.path.join(app.static_folder, path)):
|
||||||
|
return send_from_directory(app.static_folder, path)
|
||||||
|
else:
|
||||||
|
# Return index.html for SPA routing
|
||||||
|
return send_from_directory(app.static_folder, 'index.html')
|
||||||
|
else:
|
||||||
|
return jsonify({'error': 'Not Found'}), 404
|
||||||
|
|
||||||
|
# Register error handlers
|
||||||
|
register_error_handlers(app)
|
||||||
|
|
||||||
|
# Create tables
|
||||||
|
with app.app_context():
|
||||||
|
db.create_all()
|
||||||
|
|
||||||
|
return app
|
||||||
|
|
||||||
|
def register_error_handlers(app):
|
||||||
|
@app.errorhandler(400)
|
||||||
|
def bad_request(error):
|
||||||
|
return jsonify({'error': 'Bad Request', 'message': str(error)}), 400
|
||||||
|
|
||||||
|
@app.errorhandler(401)
|
||||||
|
def unauthorized(error):
|
||||||
|
return jsonify({'error': 'Unauthorized', 'message': 'Authentication required'}), 401
|
||||||
|
|
||||||
|
@app.errorhandler(403)
|
||||||
|
def forbidden(error):
|
||||||
|
return jsonify({'error': 'Forbidden', 'message': 'Access denied'}), 403
|
||||||
|
|
||||||
|
@app.errorhandler(404)
|
||||||
|
def not_found(error):
|
||||||
|
# Only return JSON error for API routes
|
||||||
|
if request.path.startswith('/api/'):
|
||||||
|
return jsonify({'error': 'Not Found', 'message': 'Resource not found'}), 404
|
||||||
|
# For non-API routes, let Flask handle it normally (should be handled by our SPA route)
|
||||||
|
return app.send_static_file('index.html')
|
||||||
|
|
||||||
|
@app.errorhandler(500)
|
||||||
|
def internal_error(error):
|
||||||
|
db.session.rollback()
|
||||||
|
app.logger.error(f"Internal error: {error}")
|
||||||
|
return jsonify({'error': 'Internal Server Error', 'message': 'An error occurred'}), 500
|
||||||
|
|
||||||
|
# Database connection error handlers
|
||||||
|
from sqlalchemy.exc import OperationalError, DisconnectionError, TimeoutError
|
||||||
|
from pymysql.err import OperationalError as PyMySQLOperationalError, Error as PyMySQLError
|
||||||
|
|
||||||
|
@app.errorhandler(OperationalError)
|
||||||
|
def handle_db_operational_error(error):
|
||||||
|
db.session.rollback()
|
||||||
|
app.logger.error(f"Database operational error: {error}")
|
||||||
|
|
||||||
|
# Check if it's a connection timeout or server unavailable error
|
||||||
|
error_str = str(error)
|
||||||
|
if 'timed out' in error_str or 'Lost connection' in error_str or "Can't connect" in error_str:
|
||||||
|
return jsonify({
|
||||||
|
'error': 'Database Connection Error',
|
||||||
|
'message': '資料庫連線暫時不穩定,請稍後再試'
|
||||||
|
}), 503
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'error': 'Database Error',
|
||||||
|
'message': '資料庫操作失敗,請稍後再試'
|
||||||
|
}), 500
|
||||||
|
|
||||||
|
@app.errorhandler(DisconnectionError)
|
||||||
|
def handle_db_disconnection_error(error):
|
||||||
|
db.session.rollback()
|
||||||
|
app.logger.error(f"Database disconnection error: {error}")
|
||||||
|
return jsonify({
|
||||||
|
'error': 'Database Connection Lost',
|
||||||
|
'message': '資料庫連線中斷,正在重新連線,請稍後再試'
|
||||||
|
}), 503
|
||||||
|
|
||||||
|
@app.errorhandler(TimeoutError)
|
||||||
|
def handle_db_timeout_error(error):
|
||||||
|
db.session.rollback()
|
||||||
|
app.logger.error(f"Database timeout error: {error}")
|
||||||
|
return jsonify({
|
||||||
|
'error': 'Database Timeout',
|
||||||
|
'message': '資料庫操作超時,請稍後再試'
|
||||||
|
}), 504
|
||||||
|
|
||||||
|
@app.errorhandler(Exception)
|
||||||
|
def handle_exception(error):
|
||||||
|
db.session.rollback()
|
||||||
|
app.logger.error(f"Unhandled exception: {error}", exc_info=True)
|
||||||
|
return jsonify({'error': 'Server Error', 'message': 'An unexpected error occurred'}), 500
|
||||||
|
|
||||||
|
# Create app instance for Gunicorn
|
||||||
|
app = create_app()
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
debug_mode = os.environ.get('FLASK_ENV') == 'development'
|
||||||
|
app.run(host='0.0.0.0', port=12011, debug=debug_mode)
|
||||||
9
backend/celery_app.py
Normal file
9
backend/celery_app.py
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
"""
|
||||||
|
Celery Application Entry Point
|
||||||
|
用於啟動 Celery worker 和 beat scheduler
|
||||||
|
"""
|
||||||
|
|
||||||
|
from tasks import celery
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
celery.start()
|
||||||
139
backend/config.py
Normal file
139
backend/config.py
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
import os
|
||||||
|
from datetime import timedelta
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
# Flask
|
||||||
|
SECRET_KEY = os.getenv('SECRET_KEY', 'dev-secret-key-change-in-production')
|
||||||
|
DEBUG = False
|
||||||
|
TESTING = False
|
||||||
|
|
||||||
|
# Database
|
||||||
|
MYSQL_HOST = os.getenv('MYSQL_HOST', 'localhost')
|
||||||
|
MYSQL_PORT = int(os.getenv('MYSQL_PORT', 3306))
|
||||||
|
MYSQL_USER = os.getenv('MYSQL_USER', 'root')
|
||||||
|
MYSQL_PASSWORD = os.getenv('MYSQL_PASSWORD', '')
|
||||||
|
MYSQL_DATABASE = os.getenv('MYSQL_DATABASE', 'todo_system')
|
||||||
|
|
||||||
|
SQLALCHEMY_DATABASE_URI = f"mysql+pymysql://{MYSQL_USER}:{MYSQL_PASSWORD}@{MYSQL_HOST}:{MYSQL_PORT}/{MYSQL_DATABASE}?charset=utf8mb4"
|
||||||
|
SQLALCHEMY_TRACK_MODIFICATIONS = False
|
||||||
|
SQLALCHEMY_ECHO = False
|
||||||
|
|
||||||
|
# Database Connection Pool Settings
|
||||||
|
SQLALCHEMY_ENGINE_OPTIONS = {
|
||||||
|
'pool_pre_ping': True, # 每次使用前檢查連接
|
||||||
|
'pool_recycle': 300, # 5分鐘回收連接
|
||||||
|
'pool_timeout': 20, # 連接超時 20 秒
|
||||||
|
'max_overflow': 10, # 最大溢出連接數
|
||||||
|
'pool_size': 5, # 連接池大小
|
||||||
|
'connect_args': {
|
||||||
|
'connect_timeout': 10, # MySQL 連接超時
|
||||||
|
'read_timeout': 30, # MySQL 讀取超時
|
||||||
|
'write_timeout': 30, # MySQL 寫入超時
|
||||||
|
'charset': 'utf8mb4'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# JWT
|
||||||
|
JWT_SECRET_KEY = os.getenv('JWT_SECRET_KEY', SECRET_KEY)
|
||||||
|
JWT_ACCESS_TOKEN_EXPIRES = timedelta(hours=int(os.getenv('JWT_ACCESS_TOKEN_EXPIRES_HOURS', 8)))
|
||||||
|
JWT_REFRESH_TOKEN_EXPIRES = timedelta(days=int(os.getenv('JWT_REFRESH_TOKEN_EXPIRES_DAYS', 30)))
|
||||||
|
JWT_ALGORITHM = 'HS256'
|
||||||
|
|
||||||
|
# LDAP/AD
|
||||||
|
LDAP_SERVER = os.getenv('LDAP_SERVER', 'ldap://dc.company.com')
|
||||||
|
LDAP_PORT = int(os.getenv('LDAP_PORT', 389))
|
||||||
|
LDAP_USE_SSL = os.getenv('LDAP_USE_SSL', 'false').lower() == 'true'
|
||||||
|
LDAP_USE_TLS = os.getenv('LDAP_USE_TLS', 'false').lower() == 'true'
|
||||||
|
LDAP_SEARCH_BASE = os.getenv('LDAP_SEARCH_BASE', 'DC=company,DC=com')
|
||||||
|
LDAP_BIND_USER_DN = os.getenv('LDAP_BIND_USER_DN', '')
|
||||||
|
LDAP_BIND_USER_PASSWORD = os.getenv('LDAP_BIND_USER_PASSWORD', '')
|
||||||
|
LDAP_USER_LOGIN_ATTR = os.getenv('LDAP_USER_LOGIN_ATTR', 'userPrincipalName')
|
||||||
|
|
||||||
|
# SMTP Email
|
||||||
|
SMTP_SERVER = os.getenv('SMTP_SERVER', 'smtp.company.com')
|
||||||
|
SMTP_PORT = int(os.getenv('SMTP_PORT', 25))
|
||||||
|
SMTP_USE_TLS = os.getenv('SMTP_USE_TLS', 'false').lower() == 'true'
|
||||||
|
SMTP_USE_SSL = os.getenv('SMTP_USE_SSL', 'false').lower() == 'true'
|
||||||
|
SMTP_AUTH_REQUIRED = os.getenv('SMTP_AUTH_REQUIRED', 'false').lower() == 'true'
|
||||||
|
SMTP_SENDER_EMAIL = os.getenv('SMTP_SENDER_EMAIL', 'todo-system@company.com')
|
||||||
|
SMTP_SENDER_PASSWORD = os.getenv('SMTP_SENDER_PASSWORD', '')
|
||||||
|
|
||||||
|
# Mail Settings
|
||||||
|
MAIL_SERVER = SMTP_SERVER
|
||||||
|
MAIL_PORT = SMTP_PORT
|
||||||
|
MAIL_USE_TLS = SMTP_USE_TLS
|
||||||
|
MAIL_USE_SSL = SMTP_USE_SSL
|
||||||
|
MAIL_USERNAME = SMTP_SENDER_EMAIL if SMTP_AUTH_REQUIRED else None
|
||||||
|
MAIL_PASSWORD = SMTP_SENDER_PASSWORD if SMTP_AUTH_REQUIRED else None
|
||||||
|
MAIL_DEFAULT_SENDER = SMTP_SENDER_EMAIL
|
||||||
|
|
||||||
|
# Fire Email Limits
|
||||||
|
FIRE_EMAIL_COOLDOWN_MINUTES = int(os.getenv('FIRE_EMAIL_COOLDOWN_MINUTES', 2))
|
||||||
|
FIRE_EMAIL_DAILY_LIMIT = int(os.getenv('FIRE_EMAIL_DAILY_LIMIT', 20))
|
||||||
|
|
||||||
|
# Scheduled Reminders
|
||||||
|
REMINDER_DAYS_BEFORE = int(os.getenv('REMINDER_DAYS_BEFORE', 3))
|
||||||
|
REMINDER_DAYS_AFTER = int(os.getenv('REMINDER_DAYS_AFTER', 1))
|
||||||
|
WEEKLY_SUMMARY_DAY = int(os.getenv('WEEKLY_SUMMARY_DAY', 0)) # 0=Monday
|
||||||
|
WEEKLY_SUMMARY_HOUR = int(os.getenv('WEEKLY_SUMMARY_HOUR', 9))
|
||||||
|
|
||||||
|
|
||||||
|
# File Upload
|
||||||
|
MAX_CONTENT_LENGTH = int(os.getenv('MAX_CONTENT_LENGTH', 16)) * 1024 * 1024 # MB
|
||||||
|
UPLOAD_FOLDER = os.getenv('UPLOAD_FOLDER', 'uploads')
|
||||||
|
ALLOWED_EXTENSIONS = {'xlsx', 'xls', 'csv'}
|
||||||
|
|
||||||
|
# Pagination
|
||||||
|
ITEMS_PER_PAGE = int(os.getenv('ITEMS_PER_PAGE', 20))
|
||||||
|
|
||||||
|
# Redis (for caching and celery)
|
||||||
|
REDIS_URL = os.getenv('REDIS_URL', 'redis://localhost:6379/0')
|
||||||
|
|
||||||
|
# Celery
|
||||||
|
CELERY_BROKER_URL = REDIS_URL
|
||||||
|
CELERY_RESULT_BACKEND = REDIS_URL
|
||||||
|
|
||||||
|
# CORS
|
||||||
|
CORS_ORIGINS = os.getenv('CORS_ORIGINS', 'http://localhost:3000').split(',')
|
||||||
|
|
||||||
|
# Logging
|
||||||
|
LOG_LEVEL = os.getenv('LOG_LEVEL', 'INFO')
|
||||||
|
LOG_FILE = os.getenv('LOG_FILE', 'logs/app.log')
|
||||||
|
|
||||||
|
class DevelopmentConfig(Config):
|
||||||
|
DEBUG = True
|
||||||
|
SQLALCHEMY_ECHO = True
|
||||||
|
|
||||||
|
# 開發模式可使用Mock LDAP
|
||||||
|
USE_MOCK_LDAP = os.getenv('USE_MOCK_LDAP', 'true').lower() == 'true'
|
||||||
|
|
||||||
|
class ProductionConfig(Config):
|
||||||
|
DEBUG = False
|
||||||
|
TESTING = False
|
||||||
|
|
||||||
|
class TestingConfig(Config):
|
||||||
|
TESTING = True
|
||||||
|
SQLALCHEMY_DATABASE_URI = 'sqlite:///:memory:'
|
||||||
|
SQLALCHEMY_TRACK_MODIFICATIONS = False
|
||||||
|
|
||||||
|
# 禁用外部服務
|
||||||
|
CELERY_TASK_ALWAYS_EAGER = True
|
||||||
|
CELERY_TASK_EAGER_PROPAGATES = True
|
||||||
|
|
||||||
|
# 測試用的簡化設定
|
||||||
|
JWT_ACCESS_TOKEN_EXPIRES = timedelta(hours=1)
|
||||||
|
FIRE_EMAIL_COOLDOWN_MINUTES = 2
|
||||||
|
FIRE_EMAIL_DAILY_LIMIT = 3
|
||||||
|
|
||||||
|
# 禁用郵件發送
|
||||||
|
MAIL_SUPPRESS_SEND = True
|
||||||
|
|
||||||
|
config = {
|
||||||
|
'development': DevelopmentConfig,
|
||||||
|
'production': ProductionConfig,
|
||||||
|
'testing': TestingConfig,
|
||||||
|
'default': DevelopmentConfig
|
||||||
|
}
|
||||||
65
backend/init_db.py
Normal file
65
backend/init_db.py
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
資料庫初始化腳本
|
||||||
|
在現有資料庫中建立 todo 系統所需的表格
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
from flask import Flask
|
||||||
|
from config import config
|
||||||
|
from models import db
|
||||||
|
|
||||||
|
def init_database():
|
||||||
|
"""初始化資料庫表格"""
|
||||||
|
try:
|
||||||
|
# 建立 Flask app
|
||||||
|
app = Flask(__name__)
|
||||||
|
app.config.from_object(config['development'])
|
||||||
|
|
||||||
|
# 初始化資料庫
|
||||||
|
db.init_app(app)
|
||||||
|
|
||||||
|
with app.app_context():
|
||||||
|
print("正在建立資料庫表格...")
|
||||||
|
|
||||||
|
# 建立所有表格
|
||||||
|
db.create_all()
|
||||||
|
|
||||||
|
print("✅ 資料庫表格建立完成!")
|
||||||
|
print("\n建立的表格:")
|
||||||
|
for table in db.metadata.tables.keys():
|
||||||
|
print(f" - {table}")
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ 資料庫初始化失敗: {str(e)}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def main():
|
||||||
|
print("=" * 50)
|
||||||
|
print("PANJIT To-Do System - 資料庫初始化")
|
||||||
|
print("=" * 50)
|
||||||
|
|
||||||
|
# 檢查環境變數檔案
|
||||||
|
if not os.path.exists('.env'):
|
||||||
|
print("⚠️ 找不到 .env 檔案")
|
||||||
|
print("請先執行: copy .env.example .env")
|
||||||
|
return False
|
||||||
|
|
||||||
|
# 初始化資料庫
|
||||||
|
success = init_database()
|
||||||
|
|
||||||
|
if success:
|
||||||
|
print("\n🎉 初始化完成!")
|
||||||
|
print("現在可以啟動應用程式了")
|
||||||
|
else:
|
||||||
|
print("\n💥 初始化失敗")
|
||||||
|
print("請檢查資料庫連線設定")
|
||||||
|
|
||||||
|
return success
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
success = main()
|
||||||
|
sys.exit(0 if success else 1)
|
||||||
19
backend/migrations/add_public_feature.sql
Normal file
19
backend/migrations/add_public_feature.sql
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
-- Add public/private feature to TodoItem table
|
||||||
|
-- Date: 2025-08-29
|
||||||
|
|
||||||
|
-- Add is_public column to todo_item table
|
||||||
|
ALTER TABLE todo_item
|
||||||
|
ADD COLUMN is_public BOOLEAN DEFAULT FALSE COMMENT '是否公開';
|
||||||
|
|
||||||
|
-- Add tags column to todo_item table (JSON type for flexible tagging)
|
||||||
|
ALTER TABLE todo_item
|
||||||
|
ADD COLUMN tags JSON DEFAULT NULL COMMENT '標籤';
|
||||||
|
|
||||||
|
-- Create index for public todos query performance
|
||||||
|
CREATE INDEX idx_is_public ON todo_item(is_public);
|
||||||
|
|
||||||
|
-- Create index for tags search (if MySQL version supports JSON index)
|
||||||
|
-- CREATE INDEX idx_tags ON todo_item((CAST(tags AS CHAR(255))));
|
||||||
|
|
||||||
|
-- Update existing todos to be private by default
|
||||||
|
UPDATE todo_item SET is_public = FALSE WHERE is_public IS NULL;
|
||||||
257
backend/models.py
Normal file
257
backend/models.py
Normal file
@@ -0,0 +1,257 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
from flask_sqlalchemy import SQLAlchemy
|
||||||
|
from sqlalchemy.dialects.mysql import CHAR, ENUM, JSON, BIGINT
|
||||||
|
from sqlalchemy import text
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
db = SQLAlchemy()
|
||||||
|
|
||||||
|
def generate_uuid():
|
||||||
|
return str(uuid.uuid4())
|
||||||
|
|
||||||
|
class TodoItem(db.Model):
|
||||||
|
__tablename__ = 'todo_item'
|
||||||
|
|
||||||
|
id = db.Column(CHAR(36), primary_key=True, default=generate_uuid)
|
||||||
|
title = db.Column(db.String(200), nullable=False)
|
||||||
|
description = db.Column(db.Text)
|
||||||
|
status = db.Column(ENUM('NEW', 'DOING', 'BLOCKED', 'DONE'), default='NEW')
|
||||||
|
priority = db.Column(ENUM('LOW', 'MEDIUM', 'HIGH', 'URGENT'), default='MEDIUM')
|
||||||
|
due_date = db.Column(db.Date)
|
||||||
|
created_at = db.Column(db.DateTime, nullable=False, default=datetime.utcnow)
|
||||||
|
completed_at = db.Column(db.DateTime)
|
||||||
|
creator_ad = db.Column(db.String(128), nullable=False)
|
||||||
|
creator_display_name = db.Column(db.String(128))
|
||||||
|
creator_email = db.Column(db.String(256))
|
||||||
|
starred = db.Column(db.Boolean, default=False)
|
||||||
|
is_public = db.Column(db.Boolean, default=False)
|
||||||
|
tags = db.Column(JSON, default=list)
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
responsible_users = db.relationship('TodoItemResponsible', back_populates='todo', cascade='all, delete-orphan')
|
||||||
|
followers = db.relationship('TodoItemFollower', back_populates='todo', cascade='all, delete-orphan')
|
||||||
|
mail_logs = db.relationship('TodoMailLog', back_populates='todo', cascade='all, delete-orphan')
|
||||||
|
audit_logs = db.relationship('TodoAuditLog', back_populates='todo')
|
||||||
|
fire_email_logs = db.relationship('TodoFireEmailLog', back_populates='todo', cascade='all, delete-orphan')
|
||||||
|
|
||||||
|
def to_dict(self, include_user_details=True):
|
||||||
|
result = {
|
||||||
|
'id': self.id,
|
||||||
|
'title': self.title,
|
||||||
|
'description': self.description,
|
||||||
|
'status': self.status,
|
||||||
|
'priority': self.priority,
|
||||||
|
'due_date': self.due_date.isoformat() if self.due_date else None,
|
||||||
|
'created_at': self.created_at.isoformat(),
|
||||||
|
'completed_at': self.completed_at.isoformat() if self.completed_at else None,
|
||||||
|
'creator_ad': self.creator_ad,
|
||||||
|
'creator_display_name': self.creator_display_name,
|
||||||
|
'creator_email': self.creator_email,
|
||||||
|
'starred': self.starred,
|
||||||
|
'is_public': self.is_public,
|
||||||
|
'tags': self.tags if self.tags else [],
|
||||||
|
'responsible_users': [r.ad_account for r in self.responsible_users],
|
||||||
|
'followers': [f.ad_account for f in self.followers]
|
||||||
|
}
|
||||||
|
|
||||||
|
# 如果需要包含用戶詳細信息,則添加 display names
|
||||||
|
if include_user_details:
|
||||||
|
from utils.ldap_utils import validate_ad_accounts
|
||||||
|
|
||||||
|
# 獲取所有相關用戶的 display names
|
||||||
|
all_users = set([self.creator_ad] + [r.ad_account for r in self.responsible_users] + [f.ad_account for f in self.followers])
|
||||||
|
user_details = validate_ad_accounts(list(all_users))
|
||||||
|
|
||||||
|
# 添加用戶詳細信息
|
||||||
|
result['responsible_users_details'] = []
|
||||||
|
for r in self.responsible_users:
|
||||||
|
user_info = user_details.get(r.ad_account, {})
|
||||||
|
result['responsible_users_details'].append({
|
||||||
|
'ad_account': r.ad_account,
|
||||||
|
'display_name': user_info.get('display_name', r.ad_account),
|
||||||
|
'email': user_info.get('email', '')
|
||||||
|
})
|
||||||
|
|
||||||
|
result['followers_details'] = []
|
||||||
|
for f in self.followers:
|
||||||
|
user_info = user_details.get(f.ad_account, {})
|
||||||
|
result['followers_details'].append({
|
||||||
|
'ad_account': f.ad_account,
|
||||||
|
'display_name': user_info.get('display_name', f.ad_account),
|
||||||
|
'email': user_info.get('email', '')
|
||||||
|
})
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
def can_edit(self, user_ad):
|
||||||
|
"""Check if user can edit this todo"""
|
||||||
|
# Only creator can edit
|
||||||
|
return self.creator_ad == user_ad
|
||||||
|
|
||||||
|
def can_view(self, user_ad):
|
||||||
|
"""Check if user can view this todo"""
|
||||||
|
# Public todos can be viewed by anyone
|
||||||
|
if self.is_public:
|
||||||
|
return True
|
||||||
|
# Private todos can be viewed by creator and responsible users only
|
||||||
|
if self.creator_ad == user_ad:
|
||||||
|
return True
|
||||||
|
# Check if user is a responsible user
|
||||||
|
return any(r.ad_account == user_ad for r in self.responsible_users)
|
||||||
|
|
||||||
|
def can_follow(self, user_ad):
|
||||||
|
"""Check if user can follow this todo"""
|
||||||
|
# Anyone can follow public todos
|
||||||
|
if self.is_public:
|
||||||
|
return True
|
||||||
|
# For private todos, only creator/responsible can add followers
|
||||||
|
return False
|
||||||
|
|
||||||
|
class TodoItemResponsible(db.Model):
|
||||||
|
__tablename__ = 'todo_item_responsible'
|
||||||
|
|
||||||
|
todo_id = db.Column(CHAR(36), db.ForeignKey('todo_item.id', ondelete='CASCADE'), primary_key=True)
|
||||||
|
ad_account = db.Column(db.String(128), primary_key=True)
|
||||||
|
added_by = db.Column(db.String(128))
|
||||||
|
added_at = db.Column(db.DateTime, nullable=False, default=datetime.utcnow)
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
todo = db.relationship('TodoItem', back_populates='responsible_users')
|
||||||
|
|
||||||
|
class TodoItemFollower(db.Model):
|
||||||
|
__tablename__ = 'todo_item_follower'
|
||||||
|
|
||||||
|
todo_id = db.Column(CHAR(36), db.ForeignKey('todo_item.id', ondelete='CASCADE'), primary_key=True)
|
||||||
|
ad_account = db.Column(db.String(128), primary_key=True)
|
||||||
|
added_by = db.Column(db.String(128))
|
||||||
|
added_at = db.Column(db.DateTime, nullable=False, default=datetime.utcnow)
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
todo = db.relationship('TodoItem', back_populates='followers')
|
||||||
|
|
||||||
|
class TodoMailLog(db.Model):
|
||||||
|
__tablename__ = 'todo_mail_log'
|
||||||
|
|
||||||
|
id = db.Column(BIGINT, primary_key=True, autoincrement=True)
|
||||||
|
todo_id = db.Column(CHAR(36), db.ForeignKey('todo_item.id', ondelete='CASCADE'))
|
||||||
|
type = db.Column(ENUM('SCHEDULED', 'FIRE'), nullable=False)
|
||||||
|
triggered_by_ad = db.Column(db.String(128))
|
||||||
|
recipients = db.Column(db.Text)
|
||||||
|
subject = db.Column(db.String(255))
|
||||||
|
status = db.Column(ENUM('QUEUED', 'SENT', 'FAILED'), default='QUEUED')
|
||||||
|
provider_msg_id = db.Column(db.String(128))
|
||||||
|
error_text = db.Column(db.Text)
|
||||||
|
created_at = db.Column(db.DateTime, nullable=False, default=datetime.utcnow)
|
||||||
|
sent_at = db.Column(db.DateTime)
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
todo = db.relationship('TodoItem', back_populates='mail_logs')
|
||||||
|
|
||||||
|
class TodoAuditLog(db.Model):
|
||||||
|
__tablename__ = 'todo_audit_log'
|
||||||
|
|
||||||
|
id = db.Column(BIGINT, primary_key=True, autoincrement=True)
|
||||||
|
actor_ad = db.Column(db.String(128), nullable=False)
|
||||||
|
todo_id = db.Column(CHAR(36), db.ForeignKey('todo_item.id', ondelete='SET NULL'))
|
||||||
|
action = db.Column(ENUM('CREATE', 'UPDATE', 'DELETE', 'COMPLETE', 'IMPORT',
|
||||||
|
'MAIL_SENT', 'MAIL_FAIL', 'FIRE_EMAIL', 'DIGEST_EMAIL', 'BULK_REMINDER',
|
||||||
|
'FOLLOW', 'UNFOLLOW'), nullable=False)
|
||||||
|
detail = db.Column(JSON)
|
||||||
|
created_at = db.Column(db.DateTime, nullable=False, default=datetime.utcnow)
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
todo = db.relationship('TodoItem', back_populates='audit_logs')
|
||||||
|
|
||||||
|
class TodoUserPref(db.Model):
|
||||||
|
__tablename__ = 'todo_user_pref'
|
||||||
|
|
||||||
|
ad_account = db.Column(db.String(128), primary_key=True)
|
||||||
|
email = db.Column(db.String(256))
|
||||||
|
display_name = db.Column(db.String(128))
|
||||||
|
theme = db.Column(ENUM('light', 'dark', 'auto'), default='auto')
|
||||||
|
language = db.Column(db.String(10), default='zh-TW')
|
||||||
|
timezone = db.Column(db.String(50), default='Asia/Taipei')
|
||||||
|
notification_enabled = db.Column(db.Boolean, default=True)
|
||||||
|
email_reminder_enabled = db.Column(db.Boolean, default=True)
|
||||||
|
weekly_summary_enabled = db.Column(db.Boolean, default=True)
|
||||||
|
monthly_summary_enabled = db.Column(db.Boolean, default=False)
|
||||||
|
|
||||||
|
# 彈性的到期提醒天數設定 (JSON陣列,如 [1, 3, 5] 表示前1天、前3天、前5天提醒)
|
||||||
|
reminder_days_before = db.Column(JSON, default=lambda: [1, 3])
|
||||||
|
|
||||||
|
# 摘要郵件時間設定 (時:分格式,如 "09:00")
|
||||||
|
daily_summary_time = db.Column(db.String(5), default='09:00')
|
||||||
|
weekly_summary_time = db.Column(db.String(5), default='09:00')
|
||||||
|
monthly_summary_time = db.Column(db.String(5), default='09:00')
|
||||||
|
|
||||||
|
# 摘要郵件週幾發送 (0=週日, 1=週一, ..., 6=週六)
|
||||||
|
weekly_summary_day = db.Column(db.Integer, default=1) # 預設週一
|
||||||
|
monthly_summary_day = db.Column(db.Integer, default=1) # 預設每月1日
|
||||||
|
|
||||||
|
# Fire email 配額控制
|
||||||
|
fire_email_today_count = db.Column(db.Integer, default=0)
|
||||||
|
fire_email_last_reset = db.Column(db.Date)
|
||||||
|
|
||||||
|
created_at = db.Column(db.DateTime, nullable=False, default=datetime.utcnow)
|
||||||
|
updated_at = db.Column(db.DateTime, nullable=False, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||||
|
|
||||||
|
def to_dict(self):
|
||||||
|
return {
|
||||||
|
'ad_account': self.ad_account,
|
||||||
|
'email': self.email,
|
||||||
|
'display_name': self.display_name,
|
||||||
|
'theme': self.theme,
|
||||||
|
'language': self.language,
|
||||||
|
'timezone': self.timezone,
|
||||||
|
'notification_enabled': self.notification_enabled,
|
||||||
|
'email_reminder_enabled': self.email_reminder_enabled,
|
||||||
|
'weekly_summary_enabled': self.weekly_summary_enabled,
|
||||||
|
'monthly_summary_enabled': self.monthly_summary_enabled,
|
||||||
|
'reminder_days_before': self.reminder_days_before or [1, 3],
|
||||||
|
'daily_summary_time': self.daily_summary_time,
|
||||||
|
'weekly_summary_time': self.weekly_summary_time,
|
||||||
|
'monthly_summary_time': self.monthly_summary_time,
|
||||||
|
'weekly_summary_day': self.weekly_summary_day,
|
||||||
|
'monthly_summary_day': self.monthly_summary_day,
|
||||||
|
}
|
||||||
|
|
||||||
|
class TodoImportJob(db.Model):
|
||||||
|
__tablename__ = 'todo_import_job'
|
||||||
|
|
||||||
|
id = db.Column(CHAR(36), primary_key=True, default=generate_uuid)
|
||||||
|
actor_ad = db.Column(db.String(128), nullable=False)
|
||||||
|
filename = db.Column(db.String(255))
|
||||||
|
total_rows = db.Column(db.Integer, default=0)
|
||||||
|
success_rows = db.Column(db.Integer, default=0)
|
||||||
|
failed_rows = db.Column(db.Integer, default=0)
|
||||||
|
status = db.Column(ENUM('PENDING', 'PROCESSING', 'COMPLETED', 'FAILED'), default='PENDING')
|
||||||
|
error_file_path = db.Column(db.String(500))
|
||||||
|
error_details = db.Column(JSON)
|
||||||
|
created_at = db.Column(db.DateTime, nullable=False, default=datetime.utcnow)
|
||||||
|
completed_at = db.Column(db.DateTime)
|
||||||
|
|
||||||
|
def to_dict(self):
|
||||||
|
return {
|
||||||
|
'id': self.id,
|
||||||
|
'actor_ad': self.actor_ad,
|
||||||
|
'filename': self.filename,
|
||||||
|
'total_rows': self.total_rows,
|
||||||
|
'success_rows': self.success_rows,
|
||||||
|
'failed_rows': self.failed_rows,
|
||||||
|
'status': self.status,
|
||||||
|
'error_file_path': self.error_file_path,
|
||||||
|
'error_details': self.error_details,
|
||||||
|
'created_at': self.created_at.isoformat(),
|
||||||
|
'completed_at': self.completed_at.isoformat() if self.completed_at else None
|
||||||
|
}
|
||||||
|
|
||||||
|
class TodoFireEmailLog(db.Model):
|
||||||
|
__tablename__ = 'todo_fire_email_log'
|
||||||
|
|
||||||
|
id = db.Column(BIGINT, primary_key=True, autoincrement=True)
|
||||||
|
todo_id = db.Column(CHAR(36), db.ForeignKey('todo_item.id', ondelete='CASCADE'), nullable=False)
|
||||||
|
sender_ad = db.Column(db.String(128), nullable=False)
|
||||||
|
sent_at = db.Column(db.DateTime, nullable=False, default=datetime.utcnow)
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
todo = db.relationship('TodoItem', back_populates='fire_email_logs')
|
||||||
40
backend/requirements.txt
Normal file
40
backend/requirements.txt
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
# Flask and Extensions
|
||||||
|
Flask==2.3.3
|
||||||
|
Flask-JWT-Extended==4.5.3
|
||||||
|
Flask-CORS==4.0.0
|
||||||
|
Flask-SQLAlchemy==3.0.5
|
||||||
|
Flask-Migrate==4.0.5
|
||||||
|
Flask-Mail==0.9.1
|
||||||
|
|
||||||
|
# Database
|
||||||
|
SQLAlchemy==2.0.23
|
||||||
|
PyMySQL==1.1.0
|
||||||
|
|
||||||
|
# Task Queue
|
||||||
|
Celery==5.3.4
|
||||||
|
redis==5.0.1
|
||||||
|
|
||||||
|
# LDAP (Windows compatible)
|
||||||
|
ldap3==2.9.1
|
||||||
|
|
||||||
|
# Excel Processing
|
||||||
|
pandas==2.1.3
|
||||||
|
openpyxl==3.1.2
|
||||||
|
xlsxwriter==3.1.9
|
||||||
|
|
||||||
|
# Utilities
|
||||||
|
python-dotenv==1.0.0
|
||||||
|
Werkzeug==2.3.7
|
||||||
|
requests==2.31.0
|
||||||
|
colorlog==6.8.0
|
||||||
|
|
||||||
|
# Production WSGI Server
|
||||||
|
gunicorn==21.2.0
|
||||||
|
|
||||||
|
# Development and Testing
|
||||||
|
pytest==7.4.3
|
||||||
|
pytest-cov==4.1.0
|
||||||
|
pytest-flask==1.3.0
|
||||||
|
|
||||||
|
# Type hints
|
||||||
|
typing-extensions==4.8.0
|
||||||
191
backend/routes/admin.py
Normal file
191
backend/routes/admin.py
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
from flask import Blueprint, request, jsonify
|
||||||
|
from flask_jwt_extended import jwt_required, get_jwt_identity
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from sqlalchemy import func
|
||||||
|
from models import db, TodoItem, TodoAuditLog, TodoMailLog, TodoImportJob
|
||||||
|
from utils.logger import get_logger
|
||||||
|
|
||||||
|
admin_bp = Blueprint('admin', __name__)
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
# Admin users (in production, this should be in database or config)
|
||||||
|
ADMIN_USERS = ['admin', 'administrator']
|
||||||
|
|
||||||
|
def is_admin(identity):
|
||||||
|
"""Check if user is admin"""
|
||||||
|
return identity.lower() in ADMIN_USERS
|
||||||
|
|
||||||
|
@admin_bp.route('/stats', methods=['GET'])
|
||||||
|
@jwt_required()
|
||||||
|
def get_stats():
|
||||||
|
"""Get system statistics"""
|
||||||
|
try:
|
||||||
|
identity = get_jwt_identity()
|
||||||
|
|
||||||
|
if not is_admin(identity):
|
||||||
|
return jsonify({'error': 'Admin access required'}), 403
|
||||||
|
|
||||||
|
# Get date range
|
||||||
|
days = request.args.get('days', 30, type=int)
|
||||||
|
start_date = datetime.utcnow() - timedelta(days=days)
|
||||||
|
|
||||||
|
# Todo statistics
|
||||||
|
todo_stats = db.session.query(
|
||||||
|
func.count(TodoItem.id).label('total'),
|
||||||
|
func.sum(func.if_(TodoItem.status == 'NEW', 1, 0)).label('new'),
|
||||||
|
func.sum(func.if_(TodoItem.status == 'DOING', 1, 0)).label('doing'),
|
||||||
|
func.sum(func.if_(TodoItem.status == 'BLOCKED', 1, 0)).label('blocked'),
|
||||||
|
func.sum(func.if_(TodoItem.status == 'DONE', 1, 0)).label('done')
|
||||||
|
).filter(TodoItem.created_at >= start_date).first()
|
||||||
|
|
||||||
|
# User activity
|
||||||
|
active_users = db.session.query(
|
||||||
|
func.count(func.distinct(TodoAuditLog.actor_ad))
|
||||||
|
).filter(TodoAuditLog.created_at >= start_date).scalar()
|
||||||
|
|
||||||
|
# Email statistics
|
||||||
|
email_stats = db.session.query(
|
||||||
|
func.count(TodoMailLog.id).label('total'),
|
||||||
|
func.sum(func.if_(TodoMailLog.status == 'SENT', 1, 0)).label('sent'),
|
||||||
|
func.sum(func.if_(TodoMailLog.status == 'FAILED', 1, 0)).label('failed')
|
||||||
|
).filter(TodoMailLog.created_at >= start_date).first()
|
||||||
|
|
||||||
|
# Import statistics
|
||||||
|
import_stats = db.session.query(
|
||||||
|
func.count(TodoImportJob.id).label('total'),
|
||||||
|
func.sum(func.if_(TodoImportJob.status == 'COMPLETED', 1, 0)).label('completed'),
|
||||||
|
func.sum(func.if_(TodoImportJob.status == 'FAILED', 1, 0)).label('failed')
|
||||||
|
).filter(TodoImportJob.created_at >= start_date).first()
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'period_days': days,
|
||||||
|
'todos': {
|
||||||
|
'total': todo_stats.total or 0,
|
||||||
|
'new': todo_stats.new or 0,
|
||||||
|
'doing': todo_stats.doing or 0,
|
||||||
|
'blocked': todo_stats.blocked or 0,
|
||||||
|
'done': todo_stats.done or 0
|
||||||
|
},
|
||||||
|
'users': {
|
||||||
|
'active': active_users or 0
|
||||||
|
},
|
||||||
|
'emails': {
|
||||||
|
'total': email_stats.total or 0,
|
||||||
|
'sent': email_stats.sent or 0,
|
||||||
|
'failed': email_stats.failed or 0
|
||||||
|
},
|
||||||
|
'imports': {
|
||||||
|
'total': import_stats.total or 0,
|
||||||
|
'completed': import_stats.completed or 0,
|
||||||
|
'failed': import_stats.failed or 0
|
||||||
|
}
|
||||||
|
}), 200
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error fetching stats: {str(e)}")
|
||||||
|
return jsonify({'error': 'Failed to fetch statistics'}), 500
|
||||||
|
|
||||||
|
@admin_bp.route('/audit-logs', methods=['GET'])
|
||||||
|
@jwt_required()
|
||||||
|
def get_audit_logs():
|
||||||
|
"""Get audit logs"""
|
||||||
|
try:
|
||||||
|
identity = get_jwt_identity()
|
||||||
|
|
||||||
|
if not is_admin(identity):
|
||||||
|
return jsonify({'error': 'Admin access required'}), 403
|
||||||
|
|
||||||
|
page = request.args.get('page', 1, type=int)
|
||||||
|
per_page = request.args.get('per_page', 50, type=int)
|
||||||
|
actor = request.args.get('actor')
|
||||||
|
action = request.args.get('action')
|
||||||
|
todo_id = request.args.get('todo_id')
|
||||||
|
|
||||||
|
query = TodoAuditLog.query
|
||||||
|
|
||||||
|
if actor:
|
||||||
|
query = query.filter(TodoAuditLog.actor_ad == actor)
|
||||||
|
if action:
|
||||||
|
query = query.filter(TodoAuditLog.action == action)
|
||||||
|
if todo_id:
|
||||||
|
query = query.filter(TodoAuditLog.todo_id == todo_id)
|
||||||
|
|
||||||
|
query = query.order_by(TodoAuditLog.created_at.desc())
|
||||||
|
|
||||||
|
pagination = query.paginate(page=page, per_page=per_page, error_out=False)
|
||||||
|
|
||||||
|
logs = []
|
||||||
|
for log in pagination.items:
|
||||||
|
logs.append({
|
||||||
|
'id': log.id,
|
||||||
|
'actor_ad': log.actor_ad,
|
||||||
|
'todo_id': log.todo_id,
|
||||||
|
'action': log.action,
|
||||||
|
'detail': log.detail,
|
||||||
|
'created_at': log.created_at.isoformat()
|
||||||
|
})
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'logs': logs,
|
||||||
|
'total': pagination.total,
|
||||||
|
'page': page,
|
||||||
|
'per_page': per_page,
|
||||||
|
'pages': pagination.pages
|
||||||
|
}), 200
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error fetching audit logs: {str(e)}")
|
||||||
|
return jsonify({'error': 'Failed to fetch audit logs'}), 500
|
||||||
|
|
||||||
|
@admin_bp.route('/mail-logs', methods=['GET'])
|
||||||
|
@jwt_required()
|
||||||
|
def get_mail_logs():
|
||||||
|
"""Get mail logs"""
|
||||||
|
try:
|
||||||
|
identity = get_jwt_identity()
|
||||||
|
|
||||||
|
if not is_admin(identity):
|
||||||
|
return jsonify({'error': 'Admin access required'}), 403
|
||||||
|
|
||||||
|
page = request.args.get('page', 1, type=int)
|
||||||
|
per_page = request.args.get('per_page', 50, type=int)
|
||||||
|
status = request.args.get('status')
|
||||||
|
type_ = request.args.get('type')
|
||||||
|
|
||||||
|
query = TodoMailLog.query
|
||||||
|
|
||||||
|
if status:
|
||||||
|
query = query.filter(TodoMailLog.status == status)
|
||||||
|
if type_:
|
||||||
|
query = query.filter(TodoMailLog.type == type_)
|
||||||
|
|
||||||
|
query = query.order_by(TodoMailLog.created_at.desc())
|
||||||
|
|
||||||
|
pagination = query.paginate(page=page, per_page=per_page, error_out=False)
|
||||||
|
|
||||||
|
logs = []
|
||||||
|
for log in pagination.items:
|
||||||
|
logs.append({
|
||||||
|
'id': log.id,
|
||||||
|
'todo_id': log.todo_id,
|
||||||
|
'type': log.type,
|
||||||
|
'triggered_by_ad': log.triggered_by_ad,
|
||||||
|
'recipients': log.recipients,
|
||||||
|
'subject': log.subject,
|
||||||
|
'status': log.status,
|
||||||
|
'error_text': log.error_text,
|
||||||
|
'created_at': log.created_at.isoformat(),
|
||||||
|
'sent_at': log.sent_at.isoformat() if log.sent_at else None
|
||||||
|
})
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'logs': logs,
|
||||||
|
'total': pagination.total,
|
||||||
|
'page': page,
|
||||||
|
'per_page': per_page,
|
||||||
|
'pages': pagination.pages
|
||||||
|
}), 200
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error fetching mail logs: {str(e)}")
|
||||||
|
return jsonify({'error': 'Failed to fetch mail logs'}), 500
|
||||||
175
backend/routes/auth.py
Normal file
175
backend/routes/auth.py
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
from flask import Blueprint, request, jsonify
|
||||||
|
from flask_jwt_extended import (
|
||||||
|
create_access_token, create_refresh_token,
|
||||||
|
jwt_required, get_jwt_identity, get_jwt
|
||||||
|
)
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from flask import current_app
|
||||||
|
from models import db, TodoUserPref
|
||||||
|
from utils.logger import get_logger
|
||||||
|
|
||||||
|
auth_bp = Blueprint('auth', __name__)
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
@auth_bp.route('/login', methods=['POST'])
|
||||||
|
def login():
|
||||||
|
"""AD/LDAP Login"""
|
||||||
|
try:
|
||||||
|
data = request.get_json()
|
||||||
|
username = data.get('username', '').strip()
|
||||||
|
password = data.get('password', '')
|
||||||
|
|
||||||
|
if not username or not password:
|
||||||
|
return jsonify({'error': 'Username and password required'}), 400
|
||||||
|
|
||||||
|
# Authenticate with LDAP (or mock for development)
|
||||||
|
try:
|
||||||
|
if current_app.config.get('USE_MOCK_LDAP', False):
|
||||||
|
from utils.mock_ldap import authenticate_user
|
||||||
|
logger.info("Using Mock LDAP for development")
|
||||||
|
else:
|
||||||
|
from utils.ldap_utils import authenticate_user
|
||||||
|
logger.info("Using real LDAP authentication")
|
||||||
|
|
||||||
|
user_info = authenticate_user(username, password)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"LDAP authentication error, falling back to mock: {str(e)}")
|
||||||
|
from utils.mock_ldap import authenticate_user
|
||||||
|
user_info = authenticate_user(username, password)
|
||||||
|
|
||||||
|
if not user_info:
|
||||||
|
logger.warning(f"Failed login attempt for user: {username}")
|
||||||
|
return jsonify({'error': 'Invalid credentials'}), 401
|
||||||
|
|
||||||
|
ad_account = user_info['ad_account']
|
||||||
|
|
||||||
|
# Get or create user preferences
|
||||||
|
user_pref = TodoUserPref.query.filter_by(ad_account=ad_account).first()
|
||||||
|
if not user_pref:
|
||||||
|
user_pref = TodoUserPref(
|
||||||
|
ad_account=ad_account,
|
||||||
|
email=user_info['email'],
|
||||||
|
display_name=user_info['display_name']
|
||||||
|
)
|
||||||
|
db.session.add(user_pref)
|
||||||
|
db.session.commit()
|
||||||
|
logger.info(f"Created new user preference for: {ad_account}")
|
||||||
|
else:
|
||||||
|
# Update user info if changed
|
||||||
|
if user_pref.email != user_info['email'] or user_pref.display_name != user_info['display_name']:
|
||||||
|
user_pref.email = user_info['email']
|
||||||
|
user_pref.display_name = user_info['display_name']
|
||||||
|
user_pref.updated_at = datetime.utcnow()
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
# Create tokens
|
||||||
|
access_token = create_access_token(
|
||||||
|
identity=ad_account,
|
||||||
|
additional_claims={
|
||||||
|
'display_name': user_info['display_name'],
|
||||||
|
'email': user_info['email']
|
||||||
|
}
|
||||||
|
)
|
||||||
|
refresh_token = create_refresh_token(identity=ad_account)
|
||||||
|
|
||||||
|
logger.info(f"Successful login for user: {ad_account}")
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'access_token': access_token,
|
||||||
|
'refresh_token': refresh_token,
|
||||||
|
'user': {
|
||||||
|
'ad_account': ad_account,
|
||||||
|
'display_name': user_info['display_name'],
|
||||||
|
'email': user_info['email'],
|
||||||
|
'theme': user_pref.theme,
|
||||||
|
'language': user_pref.language
|
||||||
|
}
|
||||||
|
}), 200
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Login error: {str(e)}")
|
||||||
|
return jsonify({'error': 'Authentication failed'}), 500
|
||||||
|
|
||||||
|
@auth_bp.route('/refresh', methods=['POST'])
|
||||||
|
@jwt_required(refresh=True)
|
||||||
|
def refresh():
|
||||||
|
"""Refresh access token"""
|
||||||
|
try:
|
||||||
|
identity = get_jwt_identity()
|
||||||
|
|
||||||
|
# Get user info
|
||||||
|
user_pref = TodoUserPref.query.filter_by(ad_account=identity).first()
|
||||||
|
if not user_pref:
|
||||||
|
return jsonify({'error': 'User not found'}), 404
|
||||||
|
|
||||||
|
access_token = create_access_token(
|
||||||
|
identity=identity,
|
||||||
|
additional_claims={
|
||||||
|
'display_name': user_pref.display_name,
|
||||||
|
'email': user_pref.email
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return jsonify({'access_token': access_token}), 200
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Token refresh error: {str(e)}")
|
||||||
|
return jsonify({'error': 'Token refresh failed'}), 500
|
||||||
|
|
||||||
|
@auth_bp.route('/logout', methods=['POST'])
|
||||||
|
@jwt_required()
|
||||||
|
def logout():
|
||||||
|
"""Logout (client should remove tokens)"""
|
||||||
|
try:
|
||||||
|
identity = get_jwt_identity()
|
||||||
|
logger.info(f"User logged out: {identity}")
|
||||||
|
|
||||||
|
# In production, you might want to blacklist the token here
|
||||||
|
# For now, we'll rely on client-side token removal
|
||||||
|
|
||||||
|
return jsonify({'message': 'Logged out successfully'}), 200
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Logout error: {str(e)}")
|
||||||
|
return jsonify({'error': 'Logout failed'}), 500
|
||||||
|
|
||||||
|
@auth_bp.route('/me', methods=['GET'])
|
||||||
|
@jwt_required()
|
||||||
|
def get_current_user():
|
||||||
|
"""Get current user information"""
|
||||||
|
try:
|
||||||
|
identity = get_jwt_identity()
|
||||||
|
claims = get_jwt()
|
||||||
|
|
||||||
|
user_pref = TodoUserPref.query.filter_by(ad_account=identity).first()
|
||||||
|
if not user_pref:
|
||||||
|
return jsonify({'error': 'User not found'}), 404
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'ad_account': identity,
|
||||||
|
'display_name': claims.get('display_name', user_pref.display_name),
|
||||||
|
'email': claims.get('email', user_pref.email),
|
||||||
|
'preferences': user_pref.to_dict()
|
||||||
|
}), 200
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Get current user error: {str(e)}")
|
||||||
|
return jsonify({'error': 'Failed to get user information'}), 500
|
||||||
|
|
||||||
|
@auth_bp.route('/validate', methods=['GET'])
|
||||||
|
@jwt_required()
|
||||||
|
def validate_token():
|
||||||
|
"""Validate JWT token"""
|
||||||
|
try:
|
||||||
|
identity = get_jwt_identity()
|
||||||
|
claims = get_jwt()
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'valid': True,
|
||||||
|
'identity': identity,
|
||||||
|
'claims': claims
|
||||||
|
}), 200
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Token validation error: {str(e)}")
|
||||||
|
return jsonify({'valid': False}), 401
|
||||||
516
backend/routes/excel.py
Normal file
516
backend/routes/excel.py
Normal file
@@ -0,0 +1,516 @@
|
|||||||
|
"""
|
||||||
|
Excel Import/Export API Routes
|
||||||
|
處理 Excel 檔案的匯入和匯出功能
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime, date
|
||||||
|
from flask import Blueprint, request, jsonify, send_file, current_app
|
||||||
|
from flask_jwt_extended import jwt_required, get_jwt_identity, get_jwt
|
||||||
|
from werkzeug.utils import secure_filename
|
||||||
|
import pandas as pd
|
||||||
|
from openpyxl import Workbook
|
||||||
|
from openpyxl.styles import Font, Alignment, PatternFill
|
||||||
|
from openpyxl.utils.dataframe import dataframe_to_rows
|
||||||
|
from sqlalchemy import or_, and_
|
||||||
|
from models import (
|
||||||
|
db, TodoItem, TodoItemResponsible, TodoItemFollower,
|
||||||
|
TodoAuditLog
|
||||||
|
)
|
||||||
|
from utils.logger import get_logger
|
||||||
|
from utils.ldap_utils import validate_ad_accounts
|
||||||
|
import tempfile
|
||||||
|
import zipfile
|
||||||
|
|
||||||
|
excel_bp = Blueprint('excel', __name__)
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
# 允許的檔案類型
|
||||||
|
ALLOWED_EXTENSIONS = {'xlsx', 'xls', 'csv'}
|
||||||
|
|
||||||
|
def allowed_file(filename):
|
||||||
|
"""檢查檔案類型是否允許"""
|
||||||
|
return '.' in filename and filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS
|
||||||
|
|
||||||
|
def parse_date(date_str):
|
||||||
|
"""解析日期字串"""
|
||||||
|
if pd.isna(date_str) or not date_str:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if isinstance(date_str, datetime):
|
||||||
|
return date_str.date()
|
||||||
|
|
||||||
|
if isinstance(date_str, date):
|
||||||
|
return date_str
|
||||||
|
|
||||||
|
# 嘗試多種日期格式
|
||||||
|
date_formats = ['%Y-%m-%d', '%Y/%m/%d', '%d/%m/%Y', '%m/%d/%Y', '%Y%m%d']
|
||||||
|
for fmt in date_formats:
|
||||||
|
try:
|
||||||
|
return datetime.strptime(str(date_str), fmt).date()
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
@excel_bp.route('/upload', methods=['POST'])
|
||||||
|
@jwt_required()
|
||||||
|
def upload_excel():
|
||||||
|
"""Upload and parse Excel file for todo import"""
|
||||||
|
try:
|
||||||
|
identity = get_jwt_identity()
|
||||||
|
|
||||||
|
if 'file' not in request.files:
|
||||||
|
return jsonify({'error': '沒有選擇檔案'}), 400
|
||||||
|
|
||||||
|
file = request.files['file']
|
||||||
|
if file.filename == '':
|
||||||
|
return jsonify({'error': '沒有選擇檔案'}), 400
|
||||||
|
|
||||||
|
if not allowed_file(file.filename):
|
||||||
|
return jsonify({'error': '檔案類型不支援,請上傳 .xlsx, .xls 或 .csv 檔案'}), 400
|
||||||
|
|
||||||
|
# 儲存檔案到暫存目錄
|
||||||
|
filename = secure_filename(file.filename)
|
||||||
|
temp_dir = current_app.config.get('TEMP_FOLDER', tempfile.gettempdir())
|
||||||
|
filepath = os.path.join(temp_dir, f"{uuid.uuid4()}_{filename}")
|
||||||
|
file.save(filepath)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# 讀取 Excel/CSV 檔案
|
||||||
|
if filename.endswith('.csv'):
|
||||||
|
df = pd.read_csv(filepath, encoding='utf-8')
|
||||||
|
else:
|
||||||
|
df = pd.read_excel(filepath)
|
||||||
|
|
||||||
|
# 驗證必要欄位
|
||||||
|
required_columns = ['標題', 'title'] # 支援中英文欄位名
|
||||||
|
title_column = None
|
||||||
|
for col in required_columns:
|
||||||
|
if col in df.columns:
|
||||||
|
title_column = col
|
||||||
|
break
|
||||||
|
|
||||||
|
if not title_column:
|
||||||
|
return jsonify({
|
||||||
|
'error': '找不到必要欄位「標題」或「title」',
|
||||||
|
'columns': list(df.columns)
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
# 解析資料
|
||||||
|
todos_data = []
|
||||||
|
errors = []
|
||||||
|
|
||||||
|
for idx, row in df.iterrows():
|
||||||
|
try:
|
||||||
|
# 必要欄位
|
||||||
|
title = str(row[title_column]).strip()
|
||||||
|
if not title or title == 'nan':
|
||||||
|
errors.append(f'第 {idx + 2} 行:標題不能為空')
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 選擇性欄位
|
||||||
|
description = str(row.get('描述', row.get('description', ''))).strip()
|
||||||
|
if description == 'nan':
|
||||||
|
description = ''
|
||||||
|
|
||||||
|
# 狀態
|
||||||
|
status_mapping = {
|
||||||
|
'新建': 'NEW', '進行中': 'DOING', '完成': 'DONE', '阻塞': 'BLOCKED',
|
||||||
|
'NEW': 'NEW', 'DOING': 'DOING', 'DONE': 'DONE', 'BLOCKED': 'BLOCKED',
|
||||||
|
'新': 'NEW', '進行': 'DOING', '完': 'DONE', '阻': 'BLOCKED'
|
||||||
|
}
|
||||||
|
status_str = str(row.get('狀態', row.get('status', 'NEW'))).strip()
|
||||||
|
status = status_mapping.get(status_str, 'NEW')
|
||||||
|
|
||||||
|
# 優先級
|
||||||
|
priority_mapping = {
|
||||||
|
'緊急': 'URGENT', '高': 'HIGH', '中': 'MEDIUM', '低': 'LOW',
|
||||||
|
'URGENT': 'URGENT', 'HIGH': 'HIGH', 'MEDIUM': 'MEDIUM', 'LOW': 'LOW',
|
||||||
|
'緊急優先級': 'URGENT', '高優先級': 'HIGH', '中優先級': 'MEDIUM', '低優先級': 'LOW'
|
||||||
|
}
|
||||||
|
priority_str = str(row.get('優先級', row.get('priority', 'MEDIUM'))).strip()
|
||||||
|
priority = priority_mapping.get(priority_str, 'MEDIUM')
|
||||||
|
|
||||||
|
# 到期日
|
||||||
|
due_date = parse_date(row.get('到期日', row.get('due_date')))
|
||||||
|
|
||||||
|
# 負責人 (用分號或逗號分隔)
|
||||||
|
responsible_str = str(row.get('負責人', row.get('responsible_users', ''))).strip()
|
||||||
|
responsible_users = []
|
||||||
|
if responsible_str and responsible_str != 'nan':
|
||||||
|
responsible_users = [user.strip() for user in responsible_str.replace(',', ';').split(';') if user.strip()]
|
||||||
|
|
||||||
|
# 公開設定
|
||||||
|
is_public_str = str(row.get('公開設定', row.get('is_public', ''))).strip().lower()
|
||||||
|
is_public = is_public_str in ['是', 'yes', 'true', '1', 'y'] if is_public_str and is_public_str != 'nan' else False
|
||||||
|
|
||||||
|
todos_data.append({
|
||||||
|
'row': idx + 2,
|
||||||
|
'title': title,
|
||||||
|
'description': description,
|
||||||
|
'status': status,
|
||||||
|
'priority': priority,
|
||||||
|
'due_date': due_date.isoformat() if due_date else None,
|
||||||
|
'responsible_users': responsible_users,
|
||||||
|
'followers': [], # Excel模板中沒有followers欄位,初始化為空陣列
|
||||||
|
'is_public': is_public
|
||||||
|
})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
errors.append(f'第 {idx + 2} 行解析錯誤: {str(e)}')
|
||||||
|
|
||||||
|
# 清理暫存檔案
|
||||||
|
os.unlink(filepath)
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'data': todos_data,
|
||||||
|
'total': len(todos_data),
|
||||||
|
'errors': errors,
|
||||||
|
'columns': list(df.columns)
|
||||||
|
}), 200
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
# 清理暫存檔案
|
||||||
|
if os.path.exists(filepath):
|
||||||
|
os.unlink(filepath)
|
||||||
|
raise e
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Excel upload error: {str(e)}")
|
||||||
|
return jsonify({'error': f'檔案處理失敗: {str(e)}'}), 500
|
||||||
|
|
||||||
|
@excel_bp.route('/import', methods=['POST'])
|
||||||
|
@jwt_required()
|
||||||
|
def import_todos():
|
||||||
|
"""Import todos from parsed Excel data"""
|
||||||
|
try:
|
||||||
|
identity = get_jwt_identity()
|
||||||
|
claims = get_jwt()
|
||||||
|
data = request.get_json()
|
||||||
|
|
||||||
|
todos_data = data.get('todos', [])
|
||||||
|
if not todos_data:
|
||||||
|
return jsonify({'error': '沒有要匯入的資料'}), 400
|
||||||
|
|
||||||
|
imported_count = 0
|
||||||
|
errors = []
|
||||||
|
|
||||||
|
for todo_data in todos_data:
|
||||||
|
try:
|
||||||
|
# 驗證負責人的 AD 帳號
|
||||||
|
responsible_users = todo_data.get('responsible_users', [])
|
||||||
|
|
||||||
|
if responsible_users:
|
||||||
|
valid_responsible = validate_ad_accounts(responsible_users)
|
||||||
|
invalid_responsible = set(responsible_users) - set(valid_responsible.keys())
|
||||||
|
if invalid_responsible:
|
||||||
|
errors.append({
|
||||||
|
'row': todo_data.get('row', '?'),
|
||||||
|
'error': f'無效的負責人帳號: {", ".join(invalid_responsible)}'
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 建立待辦事項
|
||||||
|
due_date = None
|
||||||
|
if todo_data.get('due_date'):
|
||||||
|
due_date = datetime.strptime(todo_data['due_date'], '%Y-%m-%d').date()
|
||||||
|
|
||||||
|
# 處理公開設定
|
||||||
|
is_public = False # 預設為非公開
|
||||||
|
if todo_data.get('is_public'):
|
||||||
|
is_public_str = str(todo_data['is_public']).strip().lower()
|
||||||
|
is_public = is_public_str in ['是', 'yes', 'true', '1', 'y']
|
||||||
|
|
||||||
|
todo = TodoItem(
|
||||||
|
id=str(uuid.uuid4()),
|
||||||
|
title=todo_data['title'],
|
||||||
|
description=todo_data.get('description', ''),
|
||||||
|
status=todo_data.get('status', 'NEW'),
|
||||||
|
priority=todo_data.get('priority', 'MEDIUM'),
|
||||||
|
due_date=due_date,
|
||||||
|
creator_ad=identity,
|
||||||
|
creator_display_name=claims.get('display_name', identity),
|
||||||
|
creator_email=claims.get('email', ''),
|
||||||
|
starred=False,
|
||||||
|
is_public=is_public
|
||||||
|
)
|
||||||
|
db.session.add(todo)
|
||||||
|
|
||||||
|
# 新增負責人
|
||||||
|
if responsible_users:
|
||||||
|
for account in responsible_users:
|
||||||
|
# 使用驗證後的AD帳號,確保格式統一
|
||||||
|
ad_account = valid_responsible[account]['ad_account']
|
||||||
|
responsible = TodoItemResponsible(
|
||||||
|
todo_id=todo.id,
|
||||||
|
ad_account=ad_account,
|
||||||
|
added_by=identity
|
||||||
|
)
|
||||||
|
db.session.add(responsible)
|
||||||
|
|
||||||
|
# 因為匯入的待辦事項預設為非公開,所以不支援追蹤人功能
|
||||||
|
|
||||||
|
# 新增稽核記錄
|
||||||
|
audit = TodoAuditLog(
|
||||||
|
actor_ad=identity,
|
||||||
|
todo_id=todo.id,
|
||||||
|
action='CREATE',
|
||||||
|
detail={
|
||||||
|
'source': 'excel_import',
|
||||||
|
'title': todo.title,
|
||||||
|
'row': todo_data.get('row')
|
||||||
|
}
|
||||||
|
)
|
||||||
|
db.session.add(audit)
|
||||||
|
|
||||||
|
imported_count += 1
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
errors.append({
|
||||||
|
'row': todo_data.get('row', '?'),
|
||||||
|
'error': str(e)
|
||||||
|
})
|
||||||
|
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
logger.info(f"Excel import completed: {imported_count} todos imported by {identity}")
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'imported': imported_count,
|
||||||
|
'errors': errors,
|
||||||
|
'total_processed': len(todos_data)
|
||||||
|
}), 200
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
db.session.rollback()
|
||||||
|
logger.error(f"Excel import error: {str(e)}")
|
||||||
|
return jsonify({'error': '匯入失敗'}), 500
|
||||||
|
|
||||||
|
@excel_bp.route('/export', methods=['GET'])
|
||||||
|
@jwt_required()
|
||||||
|
def export_todos():
|
||||||
|
"""Export todos to Excel"""
|
||||||
|
try:
|
||||||
|
identity = get_jwt_identity()
|
||||||
|
|
||||||
|
# 篩選參數
|
||||||
|
status = request.args.get('status')
|
||||||
|
priority = request.args.get('priority')
|
||||||
|
due_from = request.args.get('due_from')
|
||||||
|
due_to = request.args.get('due_to')
|
||||||
|
view_type = request.args.get('view', 'all')
|
||||||
|
|
||||||
|
# 查詢待辦事項
|
||||||
|
query = TodoItem.query
|
||||||
|
|
||||||
|
# 套用檢視類型篩選
|
||||||
|
if view_type == 'created':
|
||||||
|
query = query.filter(TodoItem.creator_ad == identity)
|
||||||
|
elif view_type == 'responsible':
|
||||||
|
query = query.join(TodoItemResponsible).filter(
|
||||||
|
TodoItemResponsible.ad_account == identity
|
||||||
|
)
|
||||||
|
elif view_type == 'following':
|
||||||
|
query = query.join(TodoItemFollower).filter(
|
||||||
|
TodoItemFollower.ad_account == identity
|
||||||
|
)
|
||||||
|
else: # all
|
||||||
|
query = query.filter(
|
||||||
|
or_(
|
||||||
|
TodoItem.creator_ad == identity,
|
||||||
|
TodoItem.responsible_users.any(TodoItemResponsible.ad_account == identity),
|
||||||
|
TodoItem.followers.any(TodoItemFollower.ad_account == identity)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# 套用其他篩選條件
|
||||||
|
if status:
|
||||||
|
query = query.filter(TodoItem.status == status)
|
||||||
|
if priority:
|
||||||
|
query = query.filter(TodoItem.priority == priority)
|
||||||
|
if due_from:
|
||||||
|
query = query.filter(TodoItem.due_date >= datetime.strptime(due_from, '%Y-%m-%d').date())
|
||||||
|
if due_to:
|
||||||
|
query = query.filter(TodoItem.due_date <= datetime.strptime(due_to, '%Y-%m-%d').date())
|
||||||
|
|
||||||
|
todos = query.order_by(TodoItem.created_at.desc()).all()
|
||||||
|
|
||||||
|
# 準備資料
|
||||||
|
data = []
|
||||||
|
for todo in todos:
|
||||||
|
# 取得負責人和追蹤人
|
||||||
|
responsible_users = [r.ad_account for r in todo.responsible_users]
|
||||||
|
followers = [f.ad_account for f in todo.followers]
|
||||||
|
|
||||||
|
# 狀態和優先級的中文對應
|
||||||
|
status_mapping = {'NEW': '新建', 'DOING': '進行中', 'DONE': '完成', 'BLOCKED': '阻塞'}
|
||||||
|
priority_mapping = {'URGENT': '緊急', 'HIGH': '高', 'MEDIUM': '中', 'LOW': '低'}
|
||||||
|
|
||||||
|
data.append({
|
||||||
|
'編號': todo.id,
|
||||||
|
'標題': todo.title,
|
||||||
|
'描述': todo.description,
|
||||||
|
'狀態': status_mapping.get(todo.status, todo.status),
|
||||||
|
'優先級': priority_mapping.get(todo.priority, todo.priority),
|
||||||
|
'到期日': todo.due_date.strftime('%Y-%m-%d') if todo.due_date else '',
|
||||||
|
'建立者': todo.creator_ad,
|
||||||
|
'建立時間': todo.created_at.strftime('%Y-%m-%d %H:%M:%S'),
|
||||||
|
'完成時間': todo.completed_at.strftime('%Y-%m-%d %H:%M:%S') if todo.completed_at else '',
|
||||||
|
'負責人': '; '.join(responsible_users),
|
||||||
|
'追蹤人': '; '.join(followers),
|
||||||
|
'星號標記': '是' if todo.starred else '否'
|
||||||
|
})
|
||||||
|
|
||||||
|
# 建立 Excel 檔案
|
||||||
|
df = pd.DataFrame(data)
|
||||||
|
|
||||||
|
# 建立暫存檔案
|
||||||
|
temp_file = tempfile.NamedTemporaryFile(delete=False, suffix='.xlsx')
|
||||||
|
temp_filename = temp_file.name
|
||||||
|
temp_file.close()
|
||||||
|
|
||||||
|
# 使用 openpyxl 建立更美觀的 Excel
|
||||||
|
wb = Workbook()
|
||||||
|
ws = wb.active
|
||||||
|
ws.title = "待辦清單"
|
||||||
|
|
||||||
|
# 標題樣式
|
||||||
|
header_font = Font(bold=True, color="FFFFFF")
|
||||||
|
header_fill = PatternFill(start_color="4472C4", end_color="4472C4", fill_type="solid")
|
||||||
|
header_alignment = Alignment(horizontal="center", vertical="center")
|
||||||
|
|
||||||
|
# 寫入標題
|
||||||
|
if not df.empty:
|
||||||
|
for r_idx, row in enumerate(dataframe_to_rows(df, index=False, header=True), 1):
|
||||||
|
for c_idx, value in enumerate(row, 1):
|
||||||
|
cell = ws.cell(row=r_idx, column=c_idx, value=value)
|
||||||
|
if r_idx == 1: # 標題行
|
||||||
|
cell.font = header_font
|
||||||
|
cell.fill = header_fill
|
||||||
|
cell.alignment = header_alignment
|
||||||
|
|
||||||
|
# 自動調整列寬
|
||||||
|
for column in ws.columns:
|
||||||
|
max_length = 0
|
||||||
|
column_letter = column[0].column_letter
|
||||||
|
for cell in column:
|
||||||
|
try:
|
||||||
|
if len(str(cell.value)) > max_length:
|
||||||
|
max_length = len(str(cell.value))
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
adjusted_width = min(max_length + 2, 50)
|
||||||
|
ws.column_dimensions[column_letter].width = adjusted_width
|
||||||
|
|
||||||
|
wb.save(temp_filename)
|
||||||
|
|
||||||
|
# 產生檔案名稱
|
||||||
|
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||||
|
filename = f"todos_{timestamp}.xlsx"
|
||||||
|
|
||||||
|
logger.info(f"Excel export: {len(todos)} todos exported by {identity}")
|
||||||
|
|
||||||
|
return send_file(
|
||||||
|
temp_filename,
|
||||||
|
as_attachment=True,
|
||||||
|
download_name=filename,
|
||||||
|
mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Excel export error: {str(e)}")
|
||||||
|
return jsonify({'error': '匯出失敗'}), 500
|
||||||
|
|
||||||
|
@excel_bp.route('/template', methods=['GET'])
|
||||||
|
@jwt_required()
|
||||||
|
def download_template():
|
||||||
|
"""Download Excel import template"""
|
||||||
|
try:
|
||||||
|
# 建立範本資料
|
||||||
|
template_data = {
|
||||||
|
'標題': ['範例待辦事項1', '範例待辦事項2'],
|
||||||
|
'描述': ['這是第一個範例的詳細描述', '這是第二個範例的詳細描述'],
|
||||||
|
'狀態': ['新建', '進行中'],
|
||||||
|
'優先級': ['高', '中'],
|
||||||
|
'到期日': ['2025-12-31', '2026-01-15'],
|
||||||
|
'負責人': ['user1@panjit.com.tw', 'user2@panjit.com.tw'],
|
||||||
|
'公開設定': ['否', '是']
|
||||||
|
}
|
||||||
|
|
||||||
|
# 說明資料
|
||||||
|
instructions = {
|
||||||
|
'欄位說明': [
|
||||||
|
'標題 (必填)',
|
||||||
|
'描述 (選填)',
|
||||||
|
'狀態: 新建/進行中/完成/阻塞',
|
||||||
|
'優先級: 緊急/高/中/低',
|
||||||
|
'到期日: YYYY-MM-DD 格式',
|
||||||
|
'負責人: AD帳號,多人用分號分隔',
|
||||||
|
'公開設定: 是/否,決定其他人是否能看到此任務'
|
||||||
|
],
|
||||||
|
'說明': [
|
||||||
|
'請填入待辦事項的標題',
|
||||||
|
'可選填詳細描述',
|
||||||
|
'可選填 NEW/DOING/DONE/BLOCKED',
|
||||||
|
'可選填 URGENT/HIGH/MEDIUM/LOW',
|
||||||
|
'例如: 2024-12-31',
|
||||||
|
'例如: john@panjit.com.tw',
|
||||||
|
'是=公開任務,否=只有建立者和負責人能看到'
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
# 建立暫存檔案
|
||||||
|
temp_file = tempfile.NamedTemporaryFile(delete=False, suffix='.xlsx')
|
||||||
|
temp_filename = temp_file.name
|
||||||
|
temp_file.close()
|
||||||
|
|
||||||
|
# 建立 Excel 檔案
|
||||||
|
wb = Workbook()
|
||||||
|
|
||||||
|
# 範本資料工作表
|
||||||
|
ws_data = wb.active
|
||||||
|
ws_data.title = "匯入範本"
|
||||||
|
df_template = pd.DataFrame(template_data)
|
||||||
|
|
||||||
|
for r_idx, row in enumerate(dataframe_to_rows(df_template, index=False, header=True), 1):
|
||||||
|
for c_idx, value in enumerate(row, 1):
|
||||||
|
ws_data.cell(row=r_idx, column=c_idx, value=value)
|
||||||
|
|
||||||
|
# 說明工作表
|
||||||
|
ws_help = wb.create_sheet("使用說明")
|
||||||
|
df_help = pd.DataFrame(instructions)
|
||||||
|
|
||||||
|
for r_idx, row in enumerate(dataframe_to_rows(df_help, index=False, header=True), 1):
|
||||||
|
for c_idx, value in enumerate(row, 1):
|
||||||
|
ws_help.cell(row=r_idx, column=c_idx, value=value)
|
||||||
|
|
||||||
|
# 樣式設定
|
||||||
|
for ws in [ws_data, ws_help]:
|
||||||
|
for column in ws.columns:
|
||||||
|
max_length = 0
|
||||||
|
column_letter = column[0].column_letter
|
||||||
|
for cell in column:
|
||||||
|
try:
|
||||||
|
if len(str(cell.value)) > max_length:
|
||||||
|
max_length = len(str(cell.value))
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
adjusted_width = min(max_length + 2, 50)
|
||||||
|
ws.column_dimensions[column_letter].width = adjusted_width
|
||||||
|
|
||||||
|
wb.save(temp_filename)
|
||||||
|
|
||||||
|
logger.info(f"Template downloaded by {get_jwt_identity()}")
|
||||||
|
|
||||||
|
return send_file(
|
||||||
|
temp_filename,
|
||||||
|
as_attachment=True,
|
||||||
|
download_name="todo_import_template.xlsx",
|
||||||
|
mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Template download error: {str(e)}")
|
||||||
|
return jsonify({'error': '範本下載失敗'}), 500
|
||||||
126
backend/routes/health.py
Normal file
126
backend/routes/health.py
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
from flask import Blueprint, jsonify, current_app
|
||||||
|
from datetime import datetime
|
||||||
|
from models import db
|
||||||
|
from utils.logger import get_logger
|
||||||
|
import smtplib
|
||||||
|
import redis
|
||||||
|
|
||||||
|
health_bp = Blueprint('health', __name__)
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
@health_bp.route('/', methods=['GET'])
|
||||||
|
@health_bp.route('/healthz', methods=['GET'])
|
||||||
|
def health_check():
|
||||||
|
"""Basic health check"""
|
||||||
|
return jsonify({
|
||||||
|
'status': 'healthy',
|
||||||
|
'timestamp': datetime.utcnow().isoformat()
|
||||||
|
}), 200
|
||||||
|
|
||||||
|
@health_bp.route('/readiness', methods=['GET'])
|
||||||
|
def readiness_check():
|
||||||
|
"""Detailed readiness check"""
|
||||||
|
try:
|
||||||
|
checks = {
|
||||||
|
'database': False,
|
||||||
|
'ldap': False,
|
||||||
|
'smtp': False,
|
||||||
|
'redis': False
|
||||||
|
}
|
||||||
|
errors = []
|
||||||
|
|
||||||
|
# Check database
|
||||||
|
try:
|
||||||
|
db.session.execute(db.text('SELECT 1'))
|
||||||
|
checks['database'] = True
|
||||||
|
except Exception as e:
|
||||||
|
errors.append(f"Database check failed: {str(e)}")
|
||||||
|
logger.error(f"Database health check failed: {str(e)}")
|
||||||
|
|
||||||
|
# Check LDAP
|
||||||
|
try:
|
||||||
|
if current_app.config.get('USE_MOCK_LDAP', False):
|
||||||
|
from utils.mock_ldap import test_ldap_connection
|
||||||
|
else:
|
||||||
|
from utils.ldap_utils import test_ldap_connection
|
||||||
|
|
||||||
|
if test_ldap_connection():
|
||||||
|
checks['ldap'] = True
|
||||||
|
else:
|
||||||
|
errors.append("LDAP connection failed")
|
||||||
|
except Exception as e:
|
||||||
|
errors.append(f"LDAP check failed: {str(e)}")
|
||||||
|
logger.error(f"LDAP health check failed: {str(e)}")
|
||||||
|
|
||||||
|
# Check SMTP
|
||||||
|
try:
|
||||||
|
from flask import current_app
|
||||||
|
config = current_app.config
|
||||||
|
|
||||||
|
if config['SMTP_USE_SSL']:
|
||||||
|
server = smtplib.SMTP_SSL(config['SMTP_SERVER'], config['SMTP_PORT'], timeout=5)
|
||||||
|
else:
|
||||||
|
server = smtplib.SMTP(config['SMTP_SERVER'], config['SMTP_PORT'], timeout=5)
|
||||||
|
if config['SMTP_USE_TLS']:
|
||||||
|
server.starttls()
|
||||||
|
|
||||||
|
server.quit()
|
||||||
|
checks['smtp'] = True
|
||||||
|
except Exception as e:
|
||||||
|
errors.append(f"SMTP check failed: {str(e)}")
|
||||||
|
logger.error(f"SMTP health check failed: {str(e)}")
|
||||||
|
|
||||||
|
# Check Redis
|
||||||
|
try:
|
||||||
|
from flask import current_app
|
||||||
|
r = redis.from_url(current_app.config['REDIS_URL'])
|
||||||
|
r.ping()
|
||||||
|
checks['redis'] = True
|
||||||
|
except Exception as e:
|
||||||
|
errors.append(f"Redis check failed: {str(e)}")
|
||||||
|
logger.error(f"Redis health check failed: {str(e)}")
|
||||||
|
|
||||||
|
# Determine overall status
|
||||||
|
all_healthy = all(checks.values())
|
||||||
|
critical_healthy = checks['database'] # Database is critical
|
||||||
|
|
||||||
|
if all_healthy:
|
||||||
|
status_code = 200
|
||||||
|
status = 'healthy'
|
||||||
|
elif critical_healthy:
|
||||||
|
status_code = 200
|
||||||
|
status = 'degraded'
|
||||||
|
else:
|
||||||
|
status_code = 503
|
||||||
|
status = 'unhealthy'
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'status': status,
|
||||||
|
'checks': checks,
|
||||||
|
'errors': errors,
|
||||||
|
'timestamp': datetime.utcnow().isoformat()
|
||||||
|
}), status_code
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Readiness check error: {str(e)}")
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'error': str(e),
|
||||||
|
'timestamp': datetime.utcnow().isoformat()
|
||||||
|
}), 503
|
||||||
|
|
||||||
|
@health_bp.route('/liveness', methods=['GET'])
|
||||||
|
def liveness_check():
|
||||||
|
"""Kubernetes liveness probe"""
|
||||||
|
try:
|
||||||
|
# Simple check to see if the app is running
|
||||||
|
return jsonify({
|
||||||
|
'status': 'alive',
|
||||||
|
'timestamp': datetime.utcnow().isoformat()
|
||||||
|
}), 200
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Liveness check failed: {str(e)}")
|
||||||
|
return jsonify({
|
||||||
|
'status': 'dead',
|
||||||
|
'error': str(e)
|
||||||
|
}), 503
|
||||||
584
backend/routes/notifications.py
Normal file
584
backend/routes/notifications.py
Normal file
@@ -0,0 +1,584 @@
|
|||||||
|
"""
|
||||||
|
Notifications API Routes
|
||||||
|
處理通知相關功能,包括 email 通知和系統通知
|
||||||
|
"""
|
||||||
|
|
||||||
|
from flask import Blueprint, request, jsonify, current_app
|
||||||
|
from flask_jwt_extended import jwt_required, get_jwt_identity
|
||||||
|
from datetime import datetime, date, timedelta
|
||||||
|
from sqlalchemy import and_, or_
|
||||||
|
from models import (
|
||||||
|
db, TodoItem, TodoItemResponsible, TodoItemFollower,
|
||||||
|
TodoUserPref, TodoAuditLog, TodoFireEmailLog
|
||||||
|
)
|
||||||
|
from utils.logger import get_logger
|
||||||
|
from utils.email_service import EmailService
|
||||||
|
from utils.notification_service import NotificationService
|
||||||
|
import json
|
||||||
|
|
||||||
|
notifications_bp = Blueprint('notifications', __name__)
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
@notifications_bp.route('/', methods=['GET'])
|
||||||
|
@jwt_required()
|
||||||
|
def get_notifications():
|
||||||
|
"""Get user notifications"""
|
||||||
|
try:
|
||||||
|
identity = get_jwt_identity()
|
||||||
|
|
||||||
|
# 獲取最近7天的相關通知 (指派、完成、逾期等)
|
||||||
|
seven_days_ago = datetime.utcnow() - timedelta(days=7)
|
||||||
|
|
||||||
|
notifications = []
|
||||||
|
|
||||||
|
# 1. 獲取被指派的Todo (最近7天)
|
||||||
|
assigned_todos = db.session.query(TodoItem).join(TodoItemResponsible).filter(
|
||||||
|
and_(
|
||||||
|
TodoItemResponsible.ad_account == identity,
|
||||||
|
TodoItemResponsible.added_at >= seven_days_ago,
|
||||||
|
TodoItemResponsible.added_by != identity # 不是自己指派給自己
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
|
||||||
|
logger.info(f"Found {len(assigned_todos)} assigned todos for user {identity}")
|
||||||
|
|
||||||
|
for todo in assigned_todos:
|
||||||
|
responsible = next((r for r in todo.responsible_users if r.ad_account == identity), None)
|
||||||
|
if responsible and responsible.added_by:
|
||||||
|
notifications.append({
|
||||||
|
'id': f"assign_{todo.id}_{int(responsible.added_at.timestamp())}",
|
||||||
|
'type': 'assignment',
|
||||||
|
'title': '新的待辦事項指派',
|
||||||
|
'message': f'{responsible.added_by} 指派了「{todo.title}」給您',
|
||||||
|
'time': responsible.added_at.strftime('%m/%d %H:%M'),
|
||||||
|
'read': False,
|
||||||
|
'actionable': True,
|
||||||
|
'todo_id': todo.id
|
||||||
|
})
|
||||||
|
|
||||||
|
# 2. 獲取即將到期的Todo (明後天)
|
||||||
|
tomorrow = date.today() + timedelta(days=1)
|
||||||
|
day_after_tomorrow = date.today() + timedelta(days=2)
|
||||||
|
|
||||||
|
due_soon_todos = db.session.query(TodoItem).filter(
|
||||||
|
and_(
|
||||||
|
or_(
|
||||||
|
TodoItem.creator_ad == identity,
|
||||||
|
TodoItem.responsible_users.any(TodoItemResponsible.ad_account == identity)
|
||||||
|
),
|
||||||
|
TodoItem.due_date.in_([tomorrow, day_after_tomorrow]),
|
||||||
|
TodoItem.status != 'DONE'
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
|
||||||
|
for todo in due_soon_todos:
|
||||||
|
days_until_due = (todo.due_date - date.today()).days
|
||||||
|
notifications.append({
|
||||||
|
'id': f"due_{todo.id}_{todo.due_date}",
|
||||||
|
'type': 'reminder',
|
||||||
|
'title': '待辦事項即將到期',
|
||||||
|
'message': f'「{todo.title}」將在{days_until_due}天後到期',
|
||||||
|
'time': f'{todo.due_date.strftime("%m/%d")} 到期',
|
||||||
|
'read': False,
|
||||||
|
'actionable': True,
|
||||||
|
'todo_id': todo.id
|
||||||
|
})
|
||||||
|
|
||||||
|
# 3. 獲取逾期的Todo
|
||||||
|
overdue_todos = db.session.query(TodoItem).filter(
|
||||||
|
and_(
|
||||||
|
or_(
|
||||||
|
TodoItem.creator_ad == identity,
|
||||||
|
TodoItem.responsible_users.any(TodoItemResponsible.ad_account == identity)
|
||||||
|
),
|
||||||
|
TodoItem.due_date < date.today(),
|
||||||
|
TodoItem.status != 'DONE'
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
|
||||||
|
for todo in overdue_todos:
|
||||||
|
days_overdue = (date.today() - todo.due_date).days
|
||||||
|
notifications.append({
|
||||||
|
'id': f"overdue_{todo.id}_{todo.due_date}",
|
||||||
|
'type': 'overdue',
|
||||||
|
'title': '待辦事項已逾期',
|
||||||
|
'message': f'「{todo.title}」已逾期{days_overdue}天',
|
||||||
|
'time': f'逾期 {days_overdue} 天',
|
||||||
|
'read': False,
|
||||||
|
'actionable': True,
|
||||||
|
'todo_id': todo.id
|
||||||
|
})
|
||||||
|
|
||||||
|
# 按時間排序 (最新在前)
|
||||||
|
notifications.sort(key=lambda x: x['time'], reverse=True)
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'notifications': notifications,
|
||||||
|
'unread_count': len(notifications)
|
||||||
|
}), 200
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error fetching notifications: {str(e)}")
|
||||||
|
return jsonify({'error': '獲取通知失敗'}), 500
|
||||||
|
|
||||||
|
@notifications_bp.route('/fire-email', methods=['POST'])
|
||||||
|
@jwt_required()
|
||||||
|
def send_fire_email():
|
||||||
|
"""Send urgent fire email notification"""
|
||||||
|
try:
|
||||||
|
identity = get_jwt_identity()
|
||||||
|
data = request.get_json()
|
||||||
|
|
||||||
|
todo_id = data.get('todo_id')
|
||||||
|
custom_message = data.get('message', '')
|
||||||
|
|
||||||
|
if not todo_id:
|
||||||
|
return jsonify({'error': '待辦事項ID不能為空'}), 400
|
||||||
|
|
||||||
|
# 檢查待辦事項
|
||||||
|
todo = TodoItem.query.filter_by(id=todo_id).first()
|
||||||
|
if not todo:
|
||||||
|
return jsonify({'error': '找不到待辦事項'}), 404
|
||||||
|
|
||||||
|
# 檢查權限 (只有建立者或負責人可以發送 fire email)
|
||||||
|
if not (todo.creator_ad == identity or
|
||||||
|
any(r.ad_account == identity for r in todo.responsible_users)):
|
||||||
|
return jsonify({'error': '沒有權限發送緊急通知'}), 403
|
||||||
|
|
||||||
|
# 檢查用戶 fire email 配額
|
||||||
|
user_pref = TodoUserPref.query.filter_by(ad_account=identity).first()
|
||||||
|
if not user_pref:
|
||||||
|
return jsonify({'error': '找不到使用者設定'}), 404
|
||||||
|
|
||||||
|
# 檢查今日配額
|
||||||
|
today = date.today()
|
||||||
|
if user_pref.fire_email_last_reset != today:
|
||||||
|
user_pref.fire_email_today_count = 0
|
||||||
|
user_pref.fire_email_last_reset = today
|
||||||
|
|
||||||
|
daily_limit = current_app.config.get('FIRE_EMAIL_DAILY_LIMIT', 3)
|
||||||
|
if user_pref.fire_email_today_count >= daily_limit:
|
||||||
|
return jsonify({
|
||||||
|
'error': f'今日緊急通知配額已用完 ({daily_limit}次)',
|
||||||
|
'quota_exceeded': True
|
||||||
|
}), 429
|
||||||
|
|
||||||
|
# 檢查2分鐘冷卻機制
|
||||||
|
cooldown_minutes = current_app.config.get('FIRE_EMAIL_COOLDOWN_MINUTES', 2)
|
||||||
|
last_fire_log = TodoFireEmailLog.query.filter_by(
|
||||||
|
todo_id=todo_id
|
||||||
|
).order_by(TodoFireEmailLog.sent_at.desc()).first()
|
||||||
|
|
||||||
|
if last_fire_log:
|
||||||
|
time_since_last = datetime.utcnow() - last_fire_log.sent_at
|
||||||
|
if time_since_last.total_seconds() < cooldown_minutes * 60:
|
||||||
|
remaining_seconds = int(cooldown_minutes * 60 - time_since_last.total_seconds())
|
||||||
|
return jsonify({
|
||||||
|
'error': f'此待辦事項的緊急通知需要冷卻 {remaining_seconds} 秒後才能再次發送',
|
||||||
|
'cooldown_remaining': remaining_seconds
|
||||||
|
}), 429
|
||||||
|
|
||||||
|
# 準備收件人清單
|
||||||
|
recipients = set()
|
||||||
|
|
||||||
|
# 加入所有負責人
|
||||||
|
for responsible in todo.responsible_users:
|
||||||
|
recipients.add(responsible.ad_account)
|
||||||
|
|
||||||
|
# 加入所有追蹤人
|
||||||
|
for follower in todo.followers:
|
||||||
|
recipients.add(follower.ad_account)
|
||||||
|
|
||||||
|
# 如果是建立者發送,不包含自己
|
||||||
|
recipients.discard(identity)
|
||||||
|
|
||||||
|
if not recipients:
|
||||||
|
# 檢查是否只有發送者自己是相關人員
|
||||||
|
all_related_users = set()
|
||||||
|
for responsible in todo.responsible_users:
|
||||||
|
all_related_users.add(responsible.ad_account)
|
||||||
|
for follower in todo.followers:
|
||||||
|
all_related_users.add(follower.ad_account)
|
||||||
|
|
||||||
|
if len(all_related_users) == 1 and identity in all_related_users:
|
||||||
|
return jsonify({'error': '無法發送緊急通知:您是此待辦事項的唯一相關人員,請先指派其他負責人或追蹤人'}), 400
|
||||||
|
else:
|
||||||
|
return jsonify({'error': '沒有找到收件人'}), 400
|
||||||
|
|
||||||
|
# 發送郵件
|
||||||
|
email_service = EmailService()
|
||||||
|
success_count = 0
|
||||||
|
failed_recipients = []
|
||||||
|
|
||||||
|
for recipient in recipients:
|
||||||
|
try:
|
||||||
|
# 檢查收件人是否啟用郵件通知
|
||||||
|
recipient_pref = TodoUserPref.query.filter_by(ad_account=recipient).first()
|
||||||
|
if recipient_pref and not recipient_pref.email_reminder_enabled:
|
||||||
|
continue
|
||||||
|
|
||||||
|
success = email_service.send_fire_email(
|
||||||
|
todo=todo,
|
||||||
|
recipient=recipient,
|
||||||
|
sender=identity,
|
||||||
|
custom_message=custom_message
|
||||||
|
)
|
||||||
|
|
||||||
|
if success:
|
||||||
|
success_count += 1
|
||||||
|
else:
|
||||||
|
failed_recipients.append(recipient)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to send fire email to {recipient}: {str(e)}")
|
||||||
|
failed_recipients.append(recipient)
|
||||||
|
|
||||||
|
# 更新配額
|
||||||
|
user_pref.fire_email_today_count += 1
|
||||||
|
|
||||||
|
# 記錄 Fire Email 發送日誌 (用於冷卻檢查)
|
||||||
|
if success_count > 0:
|
||||||
|
fire_log = TodoFireEmailLog(
|
||||||
|
todo_id=todo_id,
|
||||||
|
sender_ad=identity
|
||||||
|
)
|
||||||
|
db.session.add(fire_log)
|
||||||
|
|
||||||
|
# 記錄稽核日誌
|
||||||
|
audit = TodoAuditLog(
|
||||||
|
actor_ad=identity,
|
||||||
|
todo_id=todo_id,
|
||||||
|
action='FIRE_EMAIL',
|
||||||
|
detail={
|
||||||
|
'recipients_count': len(recipients),
|
||||||
|
'success_count': success_count,
|
||||||
|
'failed_count': len(failed_recipients),
|
||||||
|
'custom_message': custom_message[:100] if custom_message else None
|
||||||
|
}
|
||||||
|
)
|
||||||
|
db.session.add(audit)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
logger.info(f"Fire email sent by {identity} for todo {todo_id}: {success_count}/{len(recipients)} successful")
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'sent': success_count,
|
||||||
|
'total_recipients': len(recipients),
|
||||||
|
'failed_recipients': failed_recipients,
|
||||||
|
'remaining_quota': max(0, daily_limit - user_pref.fire_email_today_count)
|
||||||
|
}), 200
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
db.session.rollback()
|
||||||
|
logger.error(f"Fire email error: {str(e)}")
|
||||||
|
return jsonify({'error': '發送緊急通知失敗'}), 500
|
||||||
|
|
||||||
|
@notifications_bp.route('/digest', methods=['POST'])
|
||||||
|
@jwt_required()
|
||||||
|
def send_digest():
|
||||||
|
"""Send digest email to user"""
|
||||||
|
try:
|
||||||
|
identity = get_jwt_identity()
|
||||||
|
data = request.get_json()
|
||||||
|
|
||||||
|
digest_type = data.get('type', 'weekly') # daily, weekly, monthly
|
||||||
|
|
||||||
|
# 檢查使用者偏好
|
||||||
|
user_pref = TodoUserPref.query.filter_by(ad_account=identity).first()
|
||||||
|
if not user_pref or not user_pref.email_reminder_enabled:
|
||||||
|
return jsonify({'error': '郵件通知未啟用'}), 400
|
||||||
|
|
||||||
|
# 準備摘要資料
|
||||||
|
notification_service = NotificationService()
|
||||||
|
digest_data = notification_service.prepare_digest(identity, digest_type)
|
||||||
|
|
||||||
|
# 發送摘要郵件
|
||||||
|
email_service = EmailService()
|
||||||
|
success = email_service.send_digest_email(identity, digest_data)
|
||||||
|
|
||||||
|
if success:
|
||||||
|
# 記錄稽核日誌
|
||||||
|
audit = TodoAuditLog(
|
||||||
|
actor_ad=identity,
|
||||||
|
todo_id=None,
|
||||||
|
action='DIGEST_EMAIL',
|
||||||
|
detail={'type': digest_type}
|
||||||
|
)
|
||||||
|
db.session.add(audit)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
logger.info(f"Digest email sent to {identity}: {digest_type}")
|
||||||
|
return jsonify({'message': '摘要郵件已發送'}), 200
|
||||||
|
else:
|
||||||
|
return jsonify({'error': '摘要郵件發送失敗'}), 500
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Digest email error: {str(e)}")
|
||||||
|
return jsonify({'error': '摘要郵件發送失敗'}), 500
|
||||||
|
|
||||||
|
@notifications_bp.route('/reminders/send', methods=['POST'])
|
||||||
|
@jwt_required()
|
||||||
|
def send_reminders():
|
||||||
|
"""Send reminder emails for due/overdue todos"""
|
||||||
|
try:
|
||||||
|
identity = get_jwt_identity()
|
||||||
|
|
||||||
|
# 管理員權限檢查 (簡化版本,實際應該檢查 AD 群組)
|
||||||
|
# TODO: 實作適當的管理員權限檢查
|
||||||
|
|
||||||
|
# 查找需要提醒的待辦事項
|
||||||
|
today = date.today()
|
||||||
|
tomorrow = today + timedelta(days=1)
|
||||||
|
|
||||||
|
# 即將到期的待辦事項 (明天到期)
|
||||||
|
due_tomorrow = db.session.query(TodoItem).filter(
|
||||||
|
and_(
|
||||||
|
TodoItem.due_date == tomorrow,
|
||||||
|
TodoItem.status != 'DONE'
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
|
||||||
|
# 已逾期的待辦事項
|
||||||
|
overdue = db.session.query(TodoItem).filter(
|
||||||
|
and_(
|
||||||
|
TodoItem.due_date < today,
|
||||||
|
TodoItem.status != 'DONE'
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
|
||||||
|
email_service = EmailService()
|
||||||
|
notification_service = NotificationService()
|
||||||
|
|
||||||
|
sent_count = 0
|
||||||
|
|
||||||
|
# 處理即將到期的提醒
|
||||||
|
for todo in due_tomorrow:
|
||||||
|
recipients = notification_service.get_notification_recipients(todo)
|
||||||
|
for recipient in recipients:
|
||||||
|
try:
|
||||||
|
if email_service.send_reminder_email(todo, recipient, 'due_tomorrow'):
|
||||||
|
sent_count += 1
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to send due tomorrow reminder to {recipient}: {str(e)}")
|
||||||
|
|
||||||
|
# 處理逾期提醒
|
||||||
|
for todo in overdue:
|
||||||
|
recipients = notification_service.get_notification_recipients(todo)
|
||||||
|
for recipient in recipients:
|
||||||
|
try:
|
||||||
|
if email_service.send_reminder_email(todo, recipient, 'overdue'):
|
||||||
|
sent_count += 1
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to send overdue reminder to {recipient}: {str(e)}")
|
||||||
|
|
||||||
|
# 記錄稽核日誌
|
||||||
|
audit = TodoAuditLog(
|
||||||
|
actor_ad=identity,
|
||||||
|
todo_id=None,
|
||||||
|
action='BULK_REMINDER',
|
||||||
|
detail={
|
||||||
|
'due_tomorrow_count': len(due_tomorrow),
|
||||||
|
'overdue_count': len(overdue),
|
||||||
|
'emails_sent': sent_count
|
||||||
|
}
|
||||||
|
)
|
||||||
|
db.session.add(audit)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
logger.info(f"Reminders sent by {identity}: {sent_count} emails sent")
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'emails_sent': sent_count,
|
||||||
|
'due_tomorrow': len(due_tomorrow),
|
||||||
|
'overdue': len(overdue)
|
||||||
|
}), 200
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Bulk reminder error: {str(e)}")
|
||||||
|
return jsonify({'error': '批量提醒發送失敗'}), 500
|
||||||
|
|
||||||
|
@notifications_bp.route('/settings', methods=['GET'])
|
||||||
|
@jwt_required()
|
||||||
|
def get_notification_settings():
|
||||||
|
"""Get user notification settings"""
|
||||||
|
try:
|
||||||
|
identity = get_jwt_identity()
|
||||||
|
|
||||||
|
user_pref = TodoUserPref.query.filter_by(ad_account=identity).first()
|
||||||
|
if not user_pref:
|
||||||
|
return jsonify({'error': '找不到使用者設定'}), 404
|
||||||
|
|
||||||
|
settings = {
|
||||||
|
'email_reminder_enabled': user_pref.email_reminder_enabled,
|
||||||
|
'notification_enabled': user_pref.notification_enabled,
|
||||||
|
'weekly_summary_enabled': user_pref.weekly_summary_enabled,
|
||||||
|
'monthly_summary_enabled': getattr(user_pref, 'monthly_summary_enabled', False),
|
||||||
|
'reminder_days_before': getattr(user_pref, 'reminder_days_before', [1, 3]),
|
||||||
|
'daily_summary_time': getattr(user_pref, 'daily_summary_time', '09:00'),
|
||||||
|
'weekly_summary_time': getattr(user_pref, 'weekly_summary_time', '09:00'),
|
||||||
|
'monthly_summary_time': getattr(user_pref, 'monthly_summary_time', '09:00'),
|
||||||
|
'weekly_summary_day': getattr(user_pref, 'weekly_summary_day', 1),
|
||||||
|
'monthly_summary_day': getattr(user_pref, 'monthly_summary_day', 1),
|
||||||
|
'fire_email_quota': {
|
||||||
|
'used_today': user_pref.fire_email_today_count,
|
||||||
|
'daily_limit': current_app.config.get('FIRE_EMAIL_DAILY_LIMIT', 3),
|
||||||
|
'last_reset': user_pref.fire_email_last_reset.isoformat() if user_pref.fire_email_last_reset else None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return jsonify(settings), 200
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error fetching notification settings: {str(e)}")
|
||||||
|
return jsonify({'error': '取得通知設定失敗'}), 500
|
||||||
|
|
||||||
|
@notifications_bp.route('/settings', methods=['PATCH'])
|
||||||
|
@jwt_required()
|
||||||
|
def update_notification_settings():
|
||||||
|
"""Update user notification settings"""
|
||||||
|
try:
|
||||||
|
identity = get_jwt_identity()
|
||||||
|
data = request.get_json()
|
||||||
|
|
||||||
|
user_pref = TodoUserPref.query.filter_by(ad_account=identity).first()
|
||||||
|
if not user_pref:
|
||||||
|
return jsonify({'error': '找不到使用者設定'}), 404
|
||||||
|
|
||||||
|
# 更新允許的欄位
|
||||||
|
if 'email_reminder_enabled' in data:
|
||||||
|
user_pref.email_reminder_enabled = bool(data['email_reminder_enabled'])
|
||||||
|
|
||||||
|
if 'notification_enabled' in data:
|
||||||
|
user_pref.notification_enabled = bool(data['notification_enabled'])
|
||||||
|
|
||||||
|
if 'weekly_summary_enabled' in data:
|
||||||
|
user_pref.weekly_summary_enabled = bool(data['weekly_summary_enabled'])
|
||||||
|
|
||||||
|
if 'monthly_summary_enabled' in data:
|
||||||
|
user_pref.monthly_summary_enabled = bool(data['monthly_summary_enabled'])
|
||||||
|
|
||||||
|
if 'reminder_days_before' in data and isinstance(data['reminder_days_before'], list):
|
||||||
|
user_pref.reminder_days_before = data['reminder_days_before']
|
||||||
|
|
||||||
|
if 'weekly_summary_time' in data:
|
||||||
|
user_pref.weekly_summary_time = str(data['weekly_summary_time'])
|
||||||
|
|
||||||
|
if 'monthly_summary_time' in data:
|
||||||
|
user_pref.monthly_summary_time = str(data['monthly_summary_time'])
|
||||||
|
|
||||||
|
if 'weekly_summary_day' in data:
|
||||||
|
user_pref.weekly_summary_day = int(data['weekly_summary_day'])
|
||||||
|
|
||||||
|
if 'monthly_summary_day' in data:
|
||||||
|
user_pref.monthly_summary_day = int(data['monthly_summary_day'])
|
||||||
|
|
||||||
|
user_pref.updated_at = datetime.utcnow()
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
logger.info(f"Notification settings updated for {identity}")
|
||||||
|
|
||||||
|
return jsonify({'message': '通知設定已更新'}), 200
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
db.session.rollback()
|
||||||
|
logger.error(f"Error updating notification settings: {str(e)}")
|
||||||
|
return jsonify({'error': '更新通知設定失敗'}), 500
|
||||||
|
|
||||||
|
@notifications_bp.route('/test', methods=['POST'])
|
||||||
|
@jwt_required()
|
||||||
|
def test_notification():
|
||||||
|
"""Send test notification email"""
|
||||||
|
try:
|
||||||
|
identity = get_jwt_identity()
|
||||||
|
data = request.get_json() or {}
|
||||||
|
|
||||||
|
# 檢查是否有直接指定的郵件地址
|
||||||
|
recipient_email = data.get('recipient_email')
|
||||||
|
|
||||||
|
email_service = EmailService()
|
||||||
|
|
||||||
|
if recipient_email:
|
||||||
|
# 直接發送到指定郵件地址
|
||||||
|
success = email_service.send_test_email_direct(recipient_email)
|
||||||
|
recipient_info = recipient_email
|
||||||
|
else:
|
||||||
|
# 使用 AD 帳號查詢
|
||||||
|
user_pref = TodoUserPref.query.filter_by(ad_account=identity).first()
|
||||||
|
if not user_pref:
|
||||||
|
return jsonify({'error': '找不到使用者設定'}), 404
|
||||||
|
|
||||||
|
success = email_service.send_test_email(identity)
|
||||||
|
recipient_info = identity
|
||||||
|
|
||||||
|
if success:
|
||||||
|
# 記錄稽核日誌
|
||||||
|
audit = TodoAuditLog(
|
||||||
|
actor_ad=identity,
|
||||||
|
todo_id=None,
|
||||||
|
action='MAIL_SENT',
|
||||||
|
detail={'recipient': recipient_info, 'type': 'test_email'}
|
||||||
|
)
|
||||||
|
db.session.add(audit)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
logger.info(f"Test email sent to {recipient_info}")
|
||||||
|
return jsonify({'message': '測試郵件已發送'}), 200
|
||||||
|
else:
|
||||||
|
return jsonify({'error': '測試郵件發送失敗'}), 500
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Test email error: {str(e)}")
|
||||||
|
return jsonify({'error': '測試郵件發送失敗'}), 500
|
||||||
|
|
||||||
|
@notifications_bp.route('/mark-read', methods=['POST'])
|
||||||
|
@jwt_required()
|
||||||
|
def mark_notification_read():
|
||||||
|
"""Mark single notification as read"""
|
||||||
|
try:
|
||||||
|
identity = get_jwt_identity()
|
||||||
|
data = request.get_json()
|
||||||
|
|
||||||
|
notification_id = data.get('notification_id')
|
||||||
|
if not notification_id:
|
||||||
|
return jsonify({'error': '通知ID不能為空'}), 400
|
||||||
|
|
||||||
|
# 這裡可以實作將已讀狀態存在 Redis 或 database 中
|
||||||
|
# 暫時返回成功,實際可以儲存在用戶的已讀列表中
|
||||||
|
logger.info(f"Marked notification {notification_id} as read for user {identity}")
|
||||||
|
|
||||||
|
return jsonify({'message': '已標記為已讀'}), 200
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Mark notification read error: {str(e)}")
|
||||||
|
return jsonify({'error': '標記已讀失敗'}), 500
|
||||||
|
|
||||||
|
@notifications_bp.route('/mark-all-read', methods=['POST'])
|
||||||
|
@jwt_required()
|
||||||
|
def mark_all_notifications_read():
|
||||||
|
"""Mark all notifications as read"""
|
||||||
|
try:
|
||||||
|
identity = get_jwt_identity()
|
||||||
|
|
||||||
|
# 這裡可以實作將所有通知標記為已讀
|
||||||
|
# 暫時返回成功
|
||||||
|
logger.info(f"Marked all notifications as read for user {identity}")
|
||||||
|
|
||||||
|
return jsonify({'message': '已將所有通知標記為已讀'}), 200
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Mark all notifications read error: {str(e)}")
|
||||||
|
return jsonify({'error': '標記全部已讀失敗'}), 500
|
||||||
|
|
||||||
|
@notifications_bp.route('/view-todo/<todo_id>', methods=['GET'])
|
||||||
|
@jwt_required()
|
||||||
|
def view_todo_from_notification():
|
||||||
|
"""Get todo details from notification click"""
|
||||||
|
try:
|
||||||
|
identity = get_jwt_identity()
|
||||||
|
|
||||||
|
# 這裡暫時返回成功,前端可以導航到對應的 todo
|
||||||
|
return jsonify({'message': '導航到待辦事項'}), 200
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"View todo from notification error: {str(e)}")
|
||||||
|
return jsonify({'error': '查看待辦事項失敗'}), 500
|
||||||
372
backend/routes/reports.py
Normal file
372
backend/routes/reports.py
Normal file
@@ -0,0 +1,372 @@
|
|||||||
|
"""
|
||||||
|
Reports API Routes
|
||||||
|
提供待辦清單的統計報表和分析
|
||||||
|
"""
|
||||||
|
|
||||||
|
from flask import Blueprint, request, jsonify
|
||||||
|
from flask_jwt_extended import jwt_required, get_jwt_identity
|
||||||
|
from datetime import datetime, date, timedelta
|
||||||
|
from sqlalchemy import func, and_, or_
|
||||||
|
from models import (
|
||||||
|
db, TodoItem, TodoItemResponsible, TodoItemFollower,
|
||||||
|
TodoAuditLog, TodoUserPref
|
||||||
|
)
|
||||||
|
from utils.logger import get_logger
|
||||||
|
import calendar
|
||||||
|
|
||||||
|
reports_bp = Blueprint('reports', __name__)
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
@reports_bp.route('/summary', methods=['GET'])
|
||||||
|
@jwt_required()
|
||||||
|
def get_summary():
|
||||||
|
"""Get user's todo summary"""
|
||||||
|
try:
|
||||||
|
identity = get_jwt_identity()
|
||||||
|
|
||||||
|
# Count todos by status for current user
|
||||||
|
query = TodoItem.query.filter(
|
||||||
|
or_(
|
||||||
|
TodoItem.creator_ad == identity,
|
||||||
|
TodoItem.responsible_users.any(TodoItemResponsible.ad_account == identity),
|
||||||
|
TodoItem.followers.any(TodoItemFollower.ad_account == identity)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
total = query.count()
|
||||||
|
completed = query.filter(TodoItem.status == 'DONE').count()
|
||||||
|
in_progress = query.filter(TodoItem.status == 'IN_PROGRESS').count()
|
||||||
|
new = query.filter(TodoItem.status == 'NEW').count()
|
||||||
|
|
||||||
|
# Overdue todos
|
||||||
|
today = date.today()
|
||||||
|
overdue = query.filter(
|
||||||
|
and_(
|
||||||
|
TodoItem.due_date < today,
|
||||||
|
TodoItem.status != 'DONE'
|
||||||
|
)
|
||||||
|
).count()
|
||||||
|
|
||||||
|
# Due today
|
||||||
|
due_today = query.filter(
|
||||||
|
and_(
|
||||||
|
TodoItem.due_date == today,
|
||||||
|
TodoItem.status != 'DONE'
|
||||||
|
)
|
||||||
|
).count()
|
||||||
|
|
||||||
|
# Due this week
|
||||||
|
week_end = today + timedelta(days=7)
|
||||||
|
due_this_week = query.filter(
|
||||||
|
and_(
|
||||||
|
TodoItem.due_date.between(today, week_end),
|
||||||
|
TodoItem.status != 'DONE'
|
||||||
|
)
|
||||||
|
).count()
|
||||||
|
|
||||||
|
# Priority distribution
|
||||||
|
high_priority = query.filter(TodoItem.priority == 'HIGH').count()
|
||||||
|
medium_priority = query.filter(TodoItem.priority == 'MEDIUM').count()
|
||||||
|
low_priority = query.filter(TodoItem.priority == 'LOW').count()
|
||||||
|
|
||||||
|
# Completion rate
|
||||||
|
completion_rate = (completed / total * 100) if total > 0 else 0
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'summary': {
|
||||||
|
'total': total,
|
||||||
|
'completed': completed,
|
||||||
|
'in_progress': in_progress,
|
||||||
|
'new': new,
|
||||||
|
'overdue': overdue,
|
||||||
|
'due_today': due_today,
|
||||||
|
'due_this_week': due_this_week,
|
||||||
|
'completion_rate': round(completion_rate, 1)
|
||||||
|
},
|
||||||
|
'priority_distribution': {
|
||||||
|
'high': high_priority,
|
||||||
|
'medium': medium_priority,
|
||||||
|
'low': low_priority
|
||||||
|
}
|
||||||
|
}), 200
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error fetching summary: {str(e)}")
|
||||||
|
return jsonify({'error': 'Failed to fetch summary'}), 500
|
||||||
|
|
||||||
|
@reports_bp.route('/activity', methods=['GET'])
|
||||||
|
@jwt_required()
|
||||||
|
def get_activity():
|
||||||
|
"""Get user's activity over time"""
|
||||||
|
try:
|
||||||
|
identity = get_jwt_identity()
|
||||||
|
days = request.args.get('days', 30, type=int)
|
||||||
|
|
||||||
|
# Get date range
|
||||||
|
end_date = date.today()
|
||||||
|
start_date = end_date - timedelta(days=days-1)
|
||||||
|
|
||||||
|
# Query audit logs for the user
|
||||||
|
logs = db.session.query(
|
||||||
|
func.date(TodoAuditLog.timestamp).label('date'),
|
||||||
|
func.count(TodoAuditLog.id).label('count'),
|
||||||
|
TodoAuditLog.action
|
||||||
|
).filter(
|
||||||
|
and_(
|
||||||
|
TodoAuditLog.actor_ad == identity,
|
||||||
|
func.date(TodoAuditLog.timestamp) >= start_date
|
||||||
|
)
|
||||||
|
).group_by(
|
||||||
|
func.date(TodoAuditLog.timestamp),
|
||||||
|
TodoAuditLog.action
|
||||||
|
).all()
|
||||||
|
|
||||||
|
# Organize by date and action
|
||||||
|
activity_data = {}
|
||||||
|
for log in logs:
|
||||||
|
date_str = log.date.isoformat()
|
||||||
|
if date_str not in activity_data:
|
||||||
|
activity_data[date_str] = {'CREATE': 0, 'UPDATE': 0, 'DELETE': 0}
|
||||||
|
activity_data[date_str][log.action] = log.count
|
||||||
|
|
||||||
|
# Fill in missing dates
|
||||||
|
current_date = start_date
|
||||||
|
while current_date <= end_date:
|
||||||
|
date_str = current_date.isoformat()
|
||||||
|
if date_str not in activity_data:
|
||||||
|
activity_data[date_str] = {'CREATE': 0, 'UPDATE': 0, 'DELETE': 0}
|
||||||
|
current_date += timedelta(days=1)
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'activity': activity_data,
|
||||||
|
'period': {
|
||||||
|
'start_date': start_date.isoformat(),
|
||||||
|
'end_date': end_date.isoformat(),
|
||||||
|
'days': days
|
||||||
|
}
|
||||||
|
}), 200
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error fetching activity: {str(e)}")
|
||||||
|
return jsonify({'error': 'Failed to fetch activity'}), 500
|
||||||
|
|
||||||
|
@reports_bp.route('/productivity', methods=['GET'])
|
||||||
|
@jwt_required()
|
||||||
|
def get_productivity():
|
||||||
|
"""Get productivity metrics"""
|
||||||
|
try:
|
||||||
|
identity = get_jwt_identity()
|
||||||
|
|
||||||
|
# Get date ranges
|
||||||
|
today = date.today()
|
||||||
|
week_start = today - timedelta(days=today.weekday())
|
||||||
|
month_start = today.replace(day=1)
|
||||||
|
|
||||||
|
# Base query for user's todos
|
||||||
|
base_query = TodoItem.query.filter(
|
||||||
|
or_(
|
||||||
|
TodoItem.creator_ad == identity,
|
||||||
|
TodoItem.responsible_users.any(TodoItemResponsible.ad_account == identity)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Today's completions
|
||||||
|
today_completed = base_query.filter(
|
||||||
|
and_(
|
||||||
|
func.date(TodoItem.completed_at) == today,
|
||||||
|
TodoItem.status == 'DONE'
|
||||||
|
)
|
||||||
|
).count()
|
||||||
|
|
||||||
|
# This week's completions
|
||||||
|
week_completed = base_query.filter(
|
||||||
|
and_(
|
||||||
|
func.date(TodoItem.completed_at) >= week_start,
|
||||||
|
TodoItem.status == 'DONE'
|
||||||
|
)
|
||||||
|
).count()
|
||||||
|
|
||||||
|
# This month's completions
|
||||||
|
month_completed = base_query.filter(
|
||||||
|
and_(
|
||||||
|
func.date(TodoItem.completed_at) >= month_start,
|
||||||
|
TodoItem.status == 'DONE'
|
||||||
|
)
|
||||||
|
).count()
|
||||||
|
|
||||||
|
# Average completion time (for completed todos)
|
||||||
|
completed_todos = base_query.filter(
|
||||||
|
and_(
|
||||||
|
TodoItem.status == 'DONE',
|
||||||
|
TodoItem.completed_at.isnot(None)
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
|
||||||
|
avg_completion_days = 0
|
||||||
|
if completed_todos:
|
||||||
|
total_days = 0
|
||||||
|
count = 0
|
||||||
|
for todo in completed_todos:
|
||||||
|
if todo.completed_at and todo.created_at:
|
||||||
|
days = (todo.completed_at.date() - todo.created_at.date()).days
|
||||||
|
total_days += days
|
||||||
|
count += 1
|
||||||
|
avg_completion_days = round(total_days / count, 1) if count > 0 else 0
|
||||||
|
|
||||||
|
# On-time completion rate (within due date)
|
||||||
|
on_time_todos = base_query.filter(
|
||||||
|
and_(
|
||||||
|
TodoItem.status == 'DONE',
|
||||||
|
TodoItem.due_date.isnot(None),
|
||||||
|
TodoItem.completed_at.isnot(None),
|
||||||
|
func.date(TodoItem.completed_at) <= TodoItem.due_date
|
||||||
|
)
|
||||||
|
).count()
|
||||||
|
|
||||||
|
total_due_todos = base_query.filter(
|
||||||
|
and_(
|
||||||
|
TodoItem.status == 'DONE',
|
||||||
|
TodoItem.due_date.isnot(None)
|
||||||
|
)
|
||||||
|
).count()
|
||||||
|
|
||||||
|
on_time_rate = (on_time_todos / total_due_todos * 100) if total_due_todos > 0 else 0
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'productivity': {
|
||||||
|
'today_completed': today_completed,
|
||||||
|
'week_completed': week_completed,
|
||||||
|
'month_completed': month_completed,
|
||||||
|
'avg_completion_days': avg_completion_days,
|
||||||
|
'on_time_rate': round(on_time_rate, 1),
|
||||||
|
'total_with_due_dates': total_due_todos,
|
||||||
|
'on_time_count': on_time_todos
|
||||||
|
}
|
||||||
|
}), 200
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error fetching productivity: {str(e)}")
|
||||||
|
return jsonify({'error': 'Failed to fetch productivity metrics'}), 500
|
||||||
|
|
||||||
|
@reports_bp.route('/team-overview', methods=['GET'])
|
||||||
|
@jwt_required()
|
||||||
|
def get_team_overview():
|
||||||
|
"""Get team overview for todos created by current user"""
|
||||||
|
try:
|
||||||
|
identity = get_jwt_identity()
|
||||||
|
|
||||||
|
# Get todos created by current user
|
||||||
|
created_todos = TodoItem.query.filter(TodoItem.creator_ad == identity)
|
||||||
|
|
||||||
|
# Get unique responsible users from these todos
|
||||||
|
responsible_stats = db.session.query(
|
||||||
|
TodoItemResponsible.ad_account,
|
||||||
|
func.count(TodoItem.id).label('total'),
|
||||||
|
func.sum(func.case([(TodoItem.status == 'DONE', 1)], else_=0)).label('completed'),
|
||||||
|
func.sum(func.case([(TodoItem.status == 'IN_PROGRESS', 1)], else_=0)).label('in_progress'),
|
||||||
|
func.sum(func.case([
|
||||||
|
(and_(TodoItem.due_date < date.today(), TodoItem.status != 'DONE'), 1)
|
||||||
|
], else_=0)).label('overdue')
|
||||||
|
).join(
|
||||||
|
TodoItem, TodoItemResponsible.todo_id == TodoItem.id
|
||||||
|
).filter(
|
||||||
|
TodoItem.creator_ad == identity
|
||||||
|
).group_by(
|
||||||
|
TodoItemResponsible.ad_account
|
||||||
|
).all()
|
||||||
|
|
||||||
|
team_stats = []
|
||||||
|
for stat in responsible_stats:
|
||||||
|
completion_rate = (stat.completed / stat.total * 100) if stat.total > 0 else 0
|
||||||
|
team_stats.append({
|
||||||
|
'ad_account': stat.ad_account,
|
||||||
|
'total_assigned': stat.total,
|
||||||
|
'completed': stat.completed,
|
||||||
|
'in_progress': stat.in_progress,
|
||||||
|
'overdue': stat.overdue,
|
||||||
|
'completion_rate': round(completion_rate, 1)
|
||||||
|
})
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'team_overview': team_stats,
|
||||||
|
'summary': {
|
||||||
|
'total_team_members': len(team_stats),
|
||||||
|
'total_assigned_todos': sum(stat['total_assigned'] for stat in team_stats),
|
||||||
|
'total_completed': sum(stat['completed'] for stat in team_stats),
|
||||||
|
'total_overdue': sum(stat['overdue'] for stat in team_stats)
|
||||||
|
}
|
||||||
|
}), 200
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error fetching team overview: {str(e)}")
|
||||||
|
return jsonify({'error': 'Failed to fetch team overview'}), 500
|
||||||
|
|
||||||
|
@reports_bp.route('/monthly-trends', methods=['GET'])
|
||||||
|
@jwt_required()
|
||||||
|
def get_monthly_trends():
|
||||||
|
"""Get monthly trends for the past year"""
|
||||||
|
try:
|
||||||
|
identity = get_jwt_identity()
|
||||||
|
months = request.args.get('months', 12, type=int)
|
||||||
|
|
||||||
|
# Calculate date range
|
||||||
|
today = date.today()
|
||||||
|
start_date = today.replace(day=1) - timedelta(days=30 * (months - 1))
|
||||||
|
|
||||||
|
# Base query
|
||||||
|
base_query = TodoItem.query.filter(
|
||||||
|
or_(
|
||||||
|
TodoItem.creator_ad == identity,
|
||||||
|
TodoItem.responsible_users.any(TodoItemResponsible.ad_account == identity)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Get monthly statistics
|
||||||
|
monthly_data = db.session.query(
|
||||||
|
func.year(TodoItem.created_at).label('year'),
|
||||||
|
func.month(TodoItem.created_at).label('month'),
|
||||||
|
func.count(TodoItem.id).label('created'),
|
||||||
|
func.sum(func.case([(TodoItem.status == 'DONE', 1)], else_=0)).label('completed')
|
||||||
|
).filter(
|
||||||
|
and_(
|
||||||
|
func.date(TodoItem.created_at) >= start_date,
|
||||||
|
or_(
|
||||||
|
TodoItem.creator_ad == identity,
|
||||||
|
TodoItem.responsible_users.any(TodoItemResponsible.ad_account == identity)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).group_by(
|
||||||
|
func.year(TodoItem.created_at),
|
||||||
|
func.month(TodoItem.created_at)
|
||||||
|
).order_by(
|
||||||
|
func.year(TodoItem.created_at),
|
||||||
|
func.month(TodoItem.created_at)
|
||||||
|
).all()
|
||||||
|
|
||||||
|
# Format the data
|
||||||
|
trends = []
|
||||||
|
for data in monthly_data:
|
||||||
|
month_name = calendar.month_name[data.month]
|
||||||
|
completion_rate = (data.completed / data.created * 100) if data.created > 0 else 0
|
||||||
|
|
||||||
|
trends.append({
|
||||||
|
'year': data.year,
|
||||||
|
'month': data.month,
|
||||||
|
'month_name': month_name,
|
||||||
|
'created': data.created,
|
||||||
|
'completed': data.completed,
|
||||||
|
'completion_rate': round(completion_rate, 1)
|
||||||
|
})
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'trends': trends,
|
||||||
|
'period': {
|
||||||
|
'months': months,
|
||||||
|
'start_date': start_date.isoformat(),
|
||||||
|
'end_date': today.isoformat()
|
||||||
|
}
|
||||||
|
}), 200
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error fetching monthly trends: {str(e)}")
|
||||||
|
return jsonify({'error': 'Failed to fetch monthly trends'}), 500
|
||||||
261
backend/routes/scheduler.py
Normal file
261
backend/routes/scheduler.py
Normal file
@@ -0,0 +1,261 @@
|
|||||||
|
"""
|
||||||
|
Scheduler API Routes
|
||||||
|
處理排程任務的管理和監控功能
|
||||||
|
"""
|
||||||
|
|
||||||
|
from flask import Blueprint, request, jsonify, current_app
|
||||||
|
from flask_jwt_extended import jwt_required, get_jwt_identity
|
||||||
|
from datetime import datetime, date, timedelta
|
||||||
|
from sqlalchemy import and_, or_
|
||||||
|
from models import (
|
||||||
|
db, TodoItem, TodoItemResponsible, TodoItemFollower,
|
||||||
|
TodoUserPref, TodoAuditLog
|
||||||
|
)
|
||||||
|
from utils.logger import get_logger
|
||||||
|
from utils.email_service import EmailService
|
||||||
|
from utils.notification_service import NotificationService
|
||||||
|
from tasks_simple import send_daily_reminders, send_weekly_summary, cleanup_old_logs
|
||||||
|
import json
|
||||||
|
|
||||||
|
scheduler_bp = Blueprint('scheduler', __name__)
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
@scheduler_bp.route('/trigger-daily-reminders', methods=['POST'])
|
||||||
|
@jwt_required()
|
||||||
|
def trigger_daily_reminders():
|
||||||
|
"""手動觸發每日提醒(管理員功能)"""
|
||||||
|
try:
|
||||||
|
identity = get_jwt_identity()
|
||||||
|
|
||||||
|
# TODO: 實作管理員權限檢查
|
||||||
|
# 這裡應該檢查用戶是否為管理員
|
||||||
|
|
||||||
|
# 直接執行任務
|
||||||
|
result = send_daily_reminders()
|
||||||
|
|
||||||
|
# 記錄稽核日誌
|
||||||
|
audit = TodoAuditLog(
|
||||||
|
actor_ad=identity,
|
||||||
|
todo_id=None,
|
||||||
|
action='MANUAL_REMINDER',
|
||||||
|
detail={
|
||||||
|
'result': result,
|
||||||
|
'triggered_by': identity
|
||||||
|
}
|
||||||
|
)
|
||||||
|
db.session.add(audit)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
logger.info(f"Daily reminders executed manually by {identity}")
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'message': '每日提醒任務已執行',
|
||||||
|
'result': result
|
||||||
|
}), 200
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error triggering daily reminders: {str(e)}")
|
||||||
|
return jsonify({'error': '觸發每日提醒失敗'}), 500
|
||||||
|
|
||||||
|
@scheduler_bp.route('/trigger-weekly-summary', methods=['POST'])
|
||||||
|
@jwt_required()
|
||||||
|
def trigger_weekly_summary():
|
||||||
|
"""手動觸發週報發送(管理員功能)"""
|
||||||
|
try:
|
||||||
|
identity = get_jwt_identity()
|
||||||
|
|
||||||
|
# TODO: 實作管理員權限檢查
|
||||||
|
|
||||||
|
# 直接執行任務
|
||||||
|
result = send_weekly_summary()
|
||||||
|
|
||||||
|
# 記錄稽核日誌
|
||||||
|
audit = TodoAuditLog(
|
||||||
|
actor_ad=identity,
|
||||||
|
todo_id=None,
|
||||||
|
action='MANUAL_SUMMARY',
|
||||||
|
detail={
|
||||||
|
'result': result,
|
||||||
|
'triggered_by': identity
|
||||||
|
}
|
||||||
|
)
|
||||||
|
db.session.add(audit)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
logger.info(f"Weekly summary executed manually by {identity}")
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'message': '週報發送任務已執行',
|
||||||
|
'result': result
|
||||||
|
}), 200
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error triggering weekly summary: {str(e)}")
|
||||||
|
return jsonify({'error': '觸發週報發送失敗'}), 500
|
||||||
|
|
||||||
|
@scheduler_bp.route('/task-status/<task_id>', methods=['GET'])
|
||||||
|
@jwt_required()
|
||||||
|
def get_task_status(task_id):
|
||||||
|
"""取得任務狀態(簡化版本)"""
|
||||||
|
try:
|
||||||
|
# 在簡化版本中,任務是同步執行的,所以狀態總是 completed
|
||||||
|
return jsonify({
|
||||||
|
'task_id': task_id,
|
||||||
|
'status': 'completed',
|
||||||
|
'message': '任務已同步執行完成'
|
||||||
|
}), 200
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting task status: {str(e)}")
|
||||||
|
return jsonify({'error': '取得任務狀態失敗'}), 500
|
||||||
|
|
||||||
|
@scheduler_bp.route('/scheduled-jobs', methods=['GET'])
|
||||||
|
@jwt_required()
|
||||||
|
def get_scheduled_jobs():
|
||||||
|
"""取得排程任務列表和狀態"""
|
||||||
|
try:
|
||||||
|
# 這裡可以返回 Celery Beat 的排程資訊
|
||||||
|
# 簡化版本,返回配置的排程任務
|
||||||
|
jobs = [
|
||||||
|
{
|
||||||
|
'name': 'daily-reminders',
|
||||||
|
'description': '每日提醒郵件',
|
||||||
|
'schedule': '每日早上9點',
|
||||||
|
'status': 'active',
|
||||||
|
'last_run': None # TODO: 從 Celery 取得實際執行時間
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'name': 'weekly-summary',
|
||||||
|
'description': '每週摘要報告',
|
||||||
|
'schedule': '每週一早上9點',
|
||||||
|
'status': 'active',
|
||||||
|
'last_run': None # TODO: 從 Celery 取得實際執行時間
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'name': 'cleanup-logs',
|
||||||
|
'description': '清理舊日誌',
|
||||||
|
'schedule': '每週執行一次',
|
||||||
|
'status': 'active',
|
||||||
|
'last_run': None # TODO: 從 Celery 取得實際執行時間
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
return jsonify({'jobs': jobs}), 200
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting scheduled jobs: {str(e)}")
|
||||||
|
return jsonify({'error': '取得排程任務列表失敗'}), 500
|
||||||
|
|
||||||
|
@scheduler_bp.route('/statistics', methods=['GET'])
|
||||||
|
@jwt_required()
|
||||||
|
def get_scheduler_statistics():
|
||||||
|
"""取得排程系統統計資訊"""
|
||||||
|
try:
|
||||||
|
identity = get_jwt_identity()
|
||||||
|
|
||||||
|
# 統計最近一週的自動化任務執行記錄
|
||||||
|
week_ago = datetime.utcnow() - timedelta(days=7)
|
||||||
|
|
||||||
|
auto_tasks = TodoAuditLog.query.filter(
|
||||||
|
and_(
|
||||||
|
TodoAuditLog.actor_ad == 'system',
|
||||||
|
TodoAuditLog.created_at >= week_ago,
|
||||||
|
TodoAuditLog.action.in_(['DAILY_REMINDER', 'WEEKLY_SUMMARY'])
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
|
||||||
|
# 統計手動觸發的任務
|
||||||
|
manual_tasks = TodoAuditLog.query.filter(
|
||||||
|
and_(
|
||||||
|
TodoAuditLog.created_at >= week_ago,
|
||||||
|
TodoAuditLog.action.in_(['MANUAL_REMINDER', 'MANUAL_SUMMARY'])
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
|
||||||
|
# 統計郵件發送情況
|
||||||
|
email_stats = {}
|
||||||
|
for task in auto_tasks:
|
||||||
|
if task.detail:
|
||||||
|
task_type = task.action.lower()
|
||||||
|
if 'emails_sent' in task.detail:
|
||||||
|
if task_type not in email_stats:
|
||||||
|
email_stats[task_type] = {'count': 0, 'emails': 0}
|
||||||
|
email_stats[task_type]['count'] += 1
|
||||||
|
email_stats[task_type]['emails'] += task.detail['emails_sent']
|
||||||
|
|
||||||
|
statistics = {
|
||||||
|
'recent_activity': {
|
||||||
|
'auto_tasks_count': len(auto_tasks),
|
||||||
|
'manual_tasks_count': len(manual_tasks),
|
||||||
|
'email_stats': email_stats
|
||||||
|
},
|
||||||
|
'system_health': {
|
||||||
|
'celery_status': 'running', # TODO: 實際檢查 Celery 狀態
|
||||||
|
'redis_status': 'connected', # TODO: 實際檢查 Redis 狀態
|
||||||
|
'last_daily_reminder': None, # TODO: 從記錄中取得
|
||||||
|
'last_weekly_summary': None # TODO: 從記錄中取得
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return jsonify(statistics), 200
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting scheduler statistics: {str(e)}")
|
||||||
|
return jsonify({'error': '取得排程統計資訊失敗'}), 500
|
||||||
|
|
||||||
|
@scheduler_bp.route('/preview-reminders', methods=['GET'])
|
||||||
|
@jwt_required()
|
||||||
|
def preview_reminders():
|
||||||
|
"""預覽即將發送的提醒郵件"""
|
||||||
|
try:
|
||||||
|
today = date.today()
|
||||||
|
tomorrow = today + timedelta(days=1)
|
||||||
|
|
||||||
|
# 查找明日到期的待辦事項
|
||||||
|
due_tomorrow = db.session.query(TodoItem).filter(
|
||||||
|
and_(
|
||||||
|
TodoItem.due_date == tomorrow,
|
||||||
|
TodoItem.status != 'DONE'
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
|
||||||
|
# 查找已逾期的待辦事項
|
||||||
|
overdue = db.session.query(TodoItem).filter(
|
||||||
|
and_(
|
||||||
|
TodoItem.due_date < today,
|
||||||
|
TodoItem.status != 'DONE'
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
|
||||||
|
# 統計會收到提醒的使用者
|
||||||
|
notification_service = NotificationService()
|
||||||
|
due_tomorrow_recipients = set()
|
||||||
|
overdue_recipients = set()
|
||||||
|
|
||||||
|
for todo in due_tomorrow:
|
||||||
|
recipients = notification_service.get_notification_recipients(todo)
|
||||||
|
due_tomorrow_recipients.update(recipients)
|
||||||
|
|
||||||
|
for todo in overdue:
|
||||||
|
recipients = notification_service.get_notification_recipients(todo)
|
||||||
|
overdue_recipients.update(recipients)
|
||||||
|
|
||||||
|
preview = {
|
||||||
|
'due_tomorrow': {
|
||||||
|
'todos_count': len(due_tomorrow),
|
||||||
|
'recipients_count': len(due_tomorrow_recipients),
|
||||||
|
'todos': [todo.to_dict() for todo in due_tomorrow[:5]] # 只顯示前5個
|
||||||
|
},
|
||||||
|
'overdue': {
|
||||||
|
'todos_count': len(overdue),
|
||||||
|
'recipients_count': len(overdue_recipients),
|
||||||
|
'todos': [todo.to_dict() for todo in overdue[:5]] # 只顯示前5個
|
||||||
|
},
|
||||||
|
'total_emails': len(due_tomorrow_recipients) + len(overdue_recipients)
|
||||||
|
}
|
||||||
|
|
||||||
|
return jsonify(preview), 200
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error previewing reminders: {str(e)}")
|
||||||
|
return jsonify({'error': '預覽提醒郵件失敗'}), 500
|
||||||
939
backend/routes/todos.py
Normal file
939
backend/routes/todos.py
Normal file
@@ -0,0 +1,939 @@
|
|||||||
|
from flask import Blueprint, request, jsonify
|
||||||
|
from flask_jwt_extended import jwt_required, get_jwt_identity, get_jwt
|
||||||
|
from datetime import datetime, date, timedelta
|
||||||
|
from sqlalchemy import or_, and_
|
||||||
|
from sqlalchemy.orm import selectinload, joinedload
|
||||||
|
from models import (
|
||||||
|
db, TodoItem, TodoItemResponsible, TodoItemFollower,
|
||||||
|
TodoAuditLog, TodoUserPref
|
||||||
|
)
|
||||||
|
from utils.logger import get_logger
|
||||||
|
from utils.ldap_utils import validate_ad_accounts
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
todos_bp = Blueprint('todos', __name__)
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
@todos_bp.route('', methods=['GET'])
|
||||||
|
@jwt_required()
|
||||||
|
def get_todos():
|
||||||
|
"""Get todos with filtering and pagination"""
|
||||||
|
try:
|
||||||
|
identity = get_jwt_identity()
|
||||||
|
page = request.args.get('page', 1, type=int)
|
||||||
|
per_page = request.args.get('per_page', 20, type=int)
|
||||||
|
|
||||||
|
# Filters
|
||||||
|
status = request.args.get('status')
|
||||||
|
priority = request.args.get('priority')
|
||||||
|
starred = request.args.get('starred', type=bool)
|
||||||
|
due_from = request.args.get('due_from')
|
||||||
|
due_to = request.args.get('due_to')
|
||||||
|
search = request.args.get('search')
|
||||||
|
view_type = request.args.get('view', 'all') # all, created, responsible, following, dashboard
|
||||||
|
|
||||||
|
# Base query with eager loading to prevent N+1 queries
|
||||||
|
query = TodoItem.query.options(
|
||||||
|
joinedload(TodoItem.responsible_users),
|
||||||
|
joinedload(TodoItem.followers)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Apply view type filter
|
||||||
|
if view_type == 'created':
|
||||||
|
query = query.filter(TodoItem.creator_ad == identity)
|
||||||
|
elif view_type == 'responsible':
|
||||||
|
query = query.join(TodoItemResponsible).filter(
|
||||||
|
TodoItemResponsible.ad_account == identity
|
||||||
|
)
|
||||||
|
elif view_type == 'following':
|
||||||
|
query = query.join(TodoItemFollower).filter(
|
||||||
|
TodoItemFollower.ad_account == identity
|
||||||
|
)
|
||||||
|
elif view_type == 'dashboard':
|
||||||
|
# Dashboard view: only user-related todos (excluding other people's public todos)
|
||||||
|
query = query.filter(
|
||||||
|
or_(
|
||||||
|
TodoItem.creator_ad == identity, # Created by user
|
||||||
|
TodoItem.responsible_users.any(TodoItemResponsible.ad_account == identity), # User is responsible
|
||||||
|
TodoItem.followers.any(TodoItemFollower.ad_account == identity) # User is follower
|
||||||
|
)
|
||||||
|
)
|
||||||
|
else: # all - show todos user can view (public + private with access)
|
||||||
|
query = query.filter(
|
||||||
|
or_(
|
||||||
|
TodoItem.is_public == True, # All public todos
|
||||||
|
TodoItem.creator_ad == identity, # Created by user
|
||||||
|
TodoItem.responsible_users.any(TodoItemResponsible.ad_account == identity), # User is responsible
|
||||||
|
TodoItem.followers.any(TodoItemFollower.ad_account == identity) # User is follower
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Apply filters
|
||||||
|
if status:
|
||||||
|
query = query.filter(TodoItem.status == status)
|
||||||
|
if priority:
|
||||||
|
query = query.filter(TodoItem.priority == priority)
|
||||||
|
if starred is not None:
|
||||||
|
query = query.filter(TodoItem.starred == starred)
|
||||||
|
if due_from:
|
||||||
|
query = query.filter(TodoItem.due_date >= datetime.strptime(due_from, '%Y-%m-%d').date())
|
||||||
|
if due_to:
|
||||||
|
query = query.filter(TodoItem.due_date <= datetime.strptime(due_to, '%Y-%m-%d').date())
|
||||||
|
if search:
|
||||||
|
query = query.filter(
|
||||||
|
or_(
|
||||||
|
TodoItem.title.contains(search),
|
||||||
|
TodoItem.description.contains(search)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Order by due date and priority (MySQL compatible)
|
||||||
|
query = query.order_by(
|
||||||
|
TodoItem.due_date.asc(),
|
||||||
|
TodoItem.priority.desc(),
|
||||||
|
TodoItem.created_at.desc()
|
||||||
|
)
|
||||||
|
|
||||||
|
# Paginate
|
||||||
|
pagination = query.paginate(page=page, per_page=per_page, error_out=False)
|
||||||
|
|
||||||
|
todos = [todo.to_dict() for todo in pagination.items]
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'todos': todos,
|
||||||
|
'total': pagination.total,
|
||||||
|
'page': page,
|
||||||
|
'per_page': per_page,
|
||||||
|
'pages': pagination.pages
|
||||||
|
}), 200
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error fetching todos: {str(e)}")
|
||||||
|
return jsonify({'error': 'Failed to fetch todos'}), 500
|
||||||
|
|
||||||
|
@todos_bp.route('/<todo_id>', methods=['GET'])
|
||||||
|
@jwt_required()
|
||||||
|
def get_todo(todo_id):
|
||||||
|
"""Get single todo details"""
|
||||||
|
try:
|
||||||
|
identity = get_jwt_identity()
|
||||||
|
|
||||||
|
todo = TodoItem.query.options(
|
||||||
|
joinedload(TodoItem.responsible_users),
|
||||||
|
joinedload(TodoItem.followers)
|
||||||
|
).filter_by(id=todo_id).first()
|
||||||
|
if not todo:
|
||||||
|
return jsonify({'error': 'Todo not found'}), 404
|
||||||
|
|
||||||
|
# Check permission
|
||||||
|
if not todo.can_view(identity):
|
||||||
|
return jsonify({'error': 'Access denied'}), 403
|
||||||
|
|
||||||
|
return jsonify(todo.to_dict()), 200
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error fetching todo {todo_id}: {str(e)}")
|
||||||
|
return jsonify({'error': 'Failed to fetch todo'}), 500
|
||||||
|
|
||||||
|
@todos_bp.route('', methods=['POST'])
|
||||||
|
@jwt_required()
|
||||||
|
def create_todo():
|
||||||
|
"""Create new todo"""
|
||||||
|
try:
|
||||||
|
identity = get_jwt_identity()
|
||||||
|
claims = get_jwt()
|
||||||
|
data = request.get_json()
|
||||||
|
|
||||||
|
# Validate required fields
|
||||||
|
if not data.get('title'):
|
||||||
|
return jsonify({'error': 'Title is required'}), 400
|
||||||
|
|
||||||
|
# Parse due date if provided
|
||||||
|
due_date = None
|
||||||
|
if data.get('due_date'):
|
||||||
|
try:
|
||||||
|
due_date = datetime.strptime(data['due_date'], '%Y-%m-%d').date()
|
||||||
|
except ValueError:
|
||||||
|
return jsonify({'error': 'Invalid due date format. Use YYYY-MM-DD'}), 400
|
||||||
|
|
||||||
|
# Create todo
|
||||||
|
todo = TodoItem(
|
||||||
|
id=str(uuid.uuid4()),
|
||||||
|
title=data['title'],
|
||||||
|
description=data.get('description', ''),
|
||||||
|
status=data.get('status', 'NEW'),
|
||||||
|
priority=data.get('priority', 'MEDIUM'),
|
||||||
|
due_date=due_date,
|
||||||
|
creator_ad=identity,
|
||||||
|
creator_display_name=claims.get('display_name', identity),
|
||||||
|
creator_email=claims.get('email', ''),
|
||||||
|
starred=data.get('starred', False),
|
||||||
|
is_public=data.get('is_public', False),
|
||||||
|
tags=data.get('tags', [])
|
||||||
|
)
|
||||||
|
db.session.add(todo)
|
||||||
|
|
||||||
|
# Add responsible users
|
||||||
|
responsible_accounts = data.get('responsible_users', [])
|
||||||
|
if responsible_accounts:
|
||||||
|
valid_accounts = validate_ad_accounts(responsible_accounts)
|
||||||
|
for account in responsible_accounts:
|
||||||
|
if account in valid_accounts:
|
||||||
|
responsible = TodoItemResponsible(
|
||||||
|
todo_id=todo.id,
|
||||||
|
ad_account=account,
|
||||||
|
added_by=identity
|
||||||
|
)
|
||||||
|
db.session.add(responsible)
|
||||||
|
|
||||||
|
# Add followers
|
||||||
|
follower_accounts = data.get('followers', [])
|
||||||
|
if follower_accounts:
|
||||||
|
valid_accounts = validate_ad_accounts(follower_accounts)
|
||||||
|
for account in follower_accounts:
|
||||||
|
if account in valid_accounts:
|
||||||
|
follower = TodoItemFollower(
|
||||||
|
todo_id=todo.id,
|
||||||
|
ad_account=account,
|
||||||
|
added_by=identity
|
||||||
|
)
|
||||||
|
db.session.add(follower)
|
||||||
|
|
||||||
|
# Add audit log
|
||||||
|
audit = TodoAuditLog(
|
||||||
|
actor_ad=identity,
|
||||||
|
todo_id=todo.id,
|
||||||
|
action='CREATE',
|
||||||
|
detail={'title': todo.title, 'due_date': str(due_date) if due_date else None}
|
||||||
|
)
|
||||||
|
db.session.add(audit)
|
||||||
|
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
logger.info(f"Todo created: {todo.id} by {identity}")
|
||||||
|
return jsonify(todo.to_dict()), 201
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
db.session.rollback()
|
||||||
|
logger.error(f"Error creating todo: {str(e)}")
|
||||||
|
return jsonify({'error': 'Failed to create todo'}), 500
|
||||||
|
|
||||||
|
@todos_bp.route('/<todo_id>', methods=['PATCH'])
|
||||||
|
@jwt_required()
|
||||||
|
def update_todo(todo_id):
|
||||||
|
"""Update todo"""
|
||||||
|
try:
|
||||||
|
identity = get_jwt_identity()
|
||||||
|
data = request.get_json()
|
||||||
|
|
||||||
|
todo = TodoItem.query.filter_by(id=todo_id).first()
|
||||||
|
if not todo:
|
||||||
|
return jsonify({'error': 'Todo not found'}), 404
|
||||||
|
|
||||||
|
# Check permission
|
||||||
|
if not todo.can_edit(identity):
|
||||||
|
return jsonify({'error': 'Access denied'}), 403
|
||||||
|
|
||||||
|
# Track changes for audit
|
||||||
|
changes = {}
|
||||||
|
|
||||||
|
# Update fields
|
||||||
|
if 'title' in data:
|
||||||
|
changes['title'] = {'old': todo.title, 'new': data['title']}
|
||||||
|
todo.title = data['title']
|
||||||
|
|
||||||
|
if 'description' in data:
|
||||||
|
changes['description'] = {'old': todo.description, 'new': data['description']}
|
||||||
|
todo.description = data['description']
|
||||||
|
|
||||||
|
if 'status' in data:
|
||||||
|
changes['status'] = {'old': todo.status, 'new': data['status']}
|
||||||
|
todo.status = data['status']
|
||||||
|
|
||||||
|
# Set completed_at if status is DONE
|
||||||
|
if data['status'] == 'DONE' and not todo.completed_at:
|
||||||
|
todo.completed_at = datetime.utcnow()
|
||||||
|
elif data['status'] != 'DONE':
|
||||||
|
todo.completed_at = None
|
||||||
|
|
||||||
|
if 'priority' in data:
|
||||||
|
changes['priority'] = {'old': todo.priority, 'new': data['priority']}
|
||||||
|
todo.priority = data['priority']
|
||||||
|
|
||||||
|
if 'due_date' in data:
|
||||||
|
old_due = str(todo.due_date) if todo.due_date else None
|
||||||
|
if data['due_date']:
|
||||||
|
todo.due_date = datetime.strptime(data['due_date'], '%Y-%m-%d').date()
|
||||||
|
new_due = data['due_date']
|
||||||
|
else:
|
||||||
|
todo.due_date = None
|
||||||
|
new_due = None
|
||||||
|
changes['due_date'] = {'old': old_due, 'new': new_due}
|
||||||
|
|
||||||
|
if 'starred' in data:
|
||||||
|
changes['starred'] = {'old': todo.starred, 'new': data['starred']}
|
||||||
|
todo.starred = data['starred']
|
||||||
|
|
||||||
|
if 'is_public' in data:
|
||||||
|
changes['is_public'] = {'old': todo.is_public, 'new': data['is_public']}
|
||||||
|
todo.is_public = data['is_public']
|
||||||
|
|
||||||
|
if 'tags' in data:
|
||||||
|
changes['tags'] = {'old': todo.tags, 'new': data['tags']}
|
||||||
|
todo.tags = data['tags']
|
||||||
|
|
||||||
|
# Update responsible users
|
||||||
|
if 'responsible_users' in data:
|
||||||
|
# Remove existing
|
||||||
|
TodoItemResponsible.query.filter_by(todo_id=todo_id).delete()
|
||||||
|
|
||||||
|
# Add new
|
||||||
|
responsible_accounts = data['responsible_users']
|
||||||
|
if responsible_accounts:
|
||||||
|
valid_accounts = validate_ad_accounts(responsible_accounts)
|
||||||
|
for account in responsible_accounts:
|
||||||
|
if account in valid_accounts:
|
||||||
|
responsible = TodoItemResponsible(
|
||||||
|
todo_id=todo.id,
|
||||||
|
ad_account=account,
|
||||||
|
added_by=identity
|
||||||
|
)
|
||||||
|
db.session.add(responsible)
|
||||||
|
|
||||||
|
changes['responsible_users'] = data['responsible_users']
|
||||||
|
|
||||||
|
# Update followers
|
||||||
|
if 'followers' in data:
|
||||||
|
# Remove existing
|
||||||
|
TodoItemFollower.query.filter_by(todo_id=todo_id).delete()
|
||||||
|
|
||||||
|
# Add new
|
||||||
|
follower_accounts = data['followers']
|
||||||
|
if follower_accounts:
|
||||||
|
valid_accounts = validate_ad_accounts(follower_accounts)
|
||||||
|
for account in follower_accounts:
|
||||||
|
if account in valid_accounts:
|
||||||
|
follower = TodoItemFollower(
|
||||||
|
todo_id=todo.id,
|
||||||
|
ad_account=account,
|
||||||
|
added_by=identity
|
||||||
|
)
|
||||||
|
db.session.add(follower)
|
||||||
|
|
||||||
|
changes['followers'] = data['followers']
|
||||||
|
|
||||||
|
# Add audit log
|
||||||
|
if changes:
|
||||||
|
audit = TodoAuditLog(
|
||||||
|
actor_ad=identity,
|
||||||
|
todo_id=todo.id,
|
||||||
|
action='UPDATE',
|
||||||
|
detail=changes
|
||||||
|
)
|
||||||
|
db.session.add(audit)
|
||||||
|
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
logger.info(f"Todo updated: {todo_id} by {identity}")
|
||||||
|
return jsonify(todo.to_dict()), 200
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
db.session.rollback()
|
||||||
|
logger.error(f"Error updating todo {todo_id}: {str(e)}")
|
||||||
|
return jsonify({'error': 'Failed to update todo'}), 500
|
||||||
|
|
||||||
|
@todos_bp.route('/<todo_id>', methods=['DELETE'])
|
||||||
|
@jwt_required()
|
||||||
|
def delete_todo(todo_id):
|
||||||
|
"""Delete todo"""
|
||||||
|
try:
|
||||||
|
identity = get_jwt_identity()
|
||||||
|
|
||||||
|
todo = TodoItem.query.filter_by(id=todo_id).first()
|
||||||
|
if not todo:
|
||||||
|
return jsonify({'error': 'Todo not found'}), 404
|
||||||
|
|
||||||
|
# Only creator can delete
|
||||||
|
if todo.creator_ad != identity:
|
||||||
|
return jsonify({'error': 'Only creator can delete todo'}), 403
|
||||||
|
|
||||||
|
# Add audit log before deletion
|
||||||
|
audit = TodoAuditLog(
|
||||||
|
actor_ad=identity,
|
||||||
|
todo_id=None, # Will be null after deletion
|
||||||
|
action='DELETE',
|
||||||
|
detail={'title': todo.title, 'deleted_todo_id': todo_id}
|
||||||
|
)
|
||||||
|
db.session.add(audit)
|
||||||
|
|
||||||
|
# Delete todo (cascades will handle related records)
|
||||||
|
db.session.delete(todo)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
logger.info(f"Todo deleted: {todo_id} by {identity}")
|
||||||
|
return jsonify({'message': 'Todo deleted successfully'}), 200
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
db.session.rollback()
|
||||||
|
logger.error(f"Error deleting todo {todo_id}: {str(e)}")
|
||||||
|
return jsonify({'error': 'Failed to delete todo'}), 500
|
||||||
|
|
||||||
|
@todos_bp.route('/batch', methods=['PATCH'])
|
||||||
|
@jwt_required()
|
||||||
|
def batch_update_todos():
|
||||||
|
"""Batch update multiple todos"""
|
||||||
|
try:
|
||||||
|
identity = get_jwt_identity()
|
||||||
|
data = request.get_json()
|
||||||
|
|
||||||
|
todo_ids = data.get('todo_ids', [])
|
||||||
|
updates = data.get('updates', {})
|
||||||
|
|
||||||
|
if not todo_ids or not updates:
|
||||||
|
return jsonify({'error': 'Todo IDs and updates required'}), 400
|
||||||
|
|
||||||
|
updated_count = 0
|
||||||
|
errors = []
|
||||||
|
|
||||||
|
for todo_id in todo_ids:
|
||||||
|
try:
|
||||||
|
todo = TodoItem.query.filter_by(id=todo_id).first()
|
||||||
|
if not todo:
|
||||||
|
errors.append({'todo_id': todo_id, 'error': 'Not found'})
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not todo.can_edit(identity):
|
||||||
|
errors.append({'todo_id': todo_id, 'error': 'Access denied'})
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Apply updates
|
||||||
|
if 'status' in updates:
|
||||||
|
todo.status = updates['status']
|
||||||
|
if updates['status'] == 'DONE':
|
||||||
|
todo.completed_at = datetime.utcnow()
|
||||||
|
else:
|
||||||
|
todo.completed_at = None
|
||||||
|
|
||||||
|
if 'priority' in updates:
|
||||||
|
todo.priority = updates['priority']
|
||||||
|
|
||||||
|
if 'due_date' in updates:
|
||||||
|
if updates['due_date']:
|
||||||
|
todo.due_date = datetime.strptime(updates['due_date'], '%Y-%m-%d').date()
|
||||||
|
else:
|
||||||
|
todo.due_date = None
|
||||||
|
|
||||||
|
# Add audit log
|
||||||
|
audit = TodoAuditLog(
|
||||||
|
actor_ad=identity,
|
||||||
|
todo_id=todo.id,
|
||||||
|
action='UPDATE',
|
||||||
|
detail={'batch_update': updates}
|
||||||
|
)
|
||||||
|
db.session.add(audit)
|
||||||
|
|
||||||
|
updated_count += 1
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
errors.append({'todo_id': todo_id, 'error': str(e)})
|
||||||
|
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
logger.info(f"Batch update: {updated_count} todos updated by {identity}")
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'updated': updated_count,
|
||||||
|
'errors': errors
|
||||||
|
}), 200
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
db.session.rollback()
|
||||||
|
logger.error(f"Error in batch update: {str(e)}")
|
||||||
|
return jsonify({'error': 'Batch update failed'}), 500
|
||||||
|
|
||||||
|
@todos_bp.route('/<todo_id>/responsible', methods=['POST'])
|
||||||
|
@jwt_required()
|
||||||
|
def add_responsible_user(todo_id):
|
||||||
|
"""Add responsible user to todo"""
|
||||||
|
try:
|
||||||
|
identity = get_jwt_identity()
|
||||||
|
data = request.get_json()
|
||||||
|
|
||||||
|
if not data or 'ad_account' not in data:
|
||||||
|
return jsonify({'error': 'AD account is required'}), 400
|
||||||
|
|
||||||
|
ad_account = data['ad_account']
|
||||||
|
|
||||||
|
# Get todo
|
||||||
|
todo = TodoItem.query.filter_by(id=todo_id).first()
|
||||||
|
if not todo:
|
||||||
|
return jsonify({'error': 'Todo not found'}), 404
|
||||||
|
|
||||||
|
# Check permission
|
||||||
|
if not todo.can_edit(identity):
|
||||||
|
return jsonify({'error': 'No permission to edit this todo'}), 403
|
||||||
|
|
||||||
|
# Validate AD account
|
||||||
|
valid_accounts = validate_ad_accounts([ad_account])
|
||||||
|
if ad_account not in valid_accounts:
|
||||||
|
return jsonify({'error': 'Invalid AD account'}), 400
|
||||||
|
|
||||||
|
# Check if already responsible
|
||||||
|
existing = TodoItemResponsible.query.filter_by(
|
||||||
|
todo_id=todo_id, ad_account=ad_account
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if existing:
|
||||||
|
return jsonify({'error': 'User is already responsible for this todo'}), 400
|
||||||
|
|
||||||
|
# Add responsible user
|
||||||
|
responsible = TodoItemResponsible(
|
||||||
|
todo_id=todo_id,
|
||||||
|
ad_account=ad_account,
|
||||||
|
added_by=identity
|
||||||
|
)
|
||||||
|
db.session.add(responsible)
|
||||||
|
|
||||||
|
# Log audit
|
||||||
|
audit = TodoAuditLog(
|
||||||
|
actor_ad=identity,
|
||||||
|
todo_id=todo_id,
|
||||||
|
action='UPDATE',
|
||||||
|
detail={
|
||||||
|
'field': 'responsible_users',
|
||||||
|
'action': 'add',
|
||||||
|
'ad_account': ad_account
|
||||||
|
}
|
||||||
|
)
|
||||||
|
db.session.add(audit)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
logger.info(f"Added responsible user {ad_account} to todo {todo_id} by {identity}")
|
||||||
|
return jsonify({'message': 'Responsible user added successfully'}), 201
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
db.session.rollback()
|
||||||
|
logger.error(f"Add responsible user error: {str(e)}")
|
||||||
|
return jsonify({'error': 'Failed to add responsible user'}), 500
|
||||||
|
|
||||||
|
@todos_bp.route('/<todo_id>/responsible/<ad_account>', methods=['DELETE'])
|
||||||
|
@jwt_required()
|
||||||
|
def remove_responsible_user(todo_id, ad_account):
|
||||||
|
"""Remove responsible user from todo"""
|
||||||
|
try:
|
||||||
|
identity = get_jwt_identity()
|
||||||
|
|
||||||
|
# Get todo
|
||||||
|
todo = TodoItem.query.filter_by(id=todo_id).first()
|
||||||
|
if not todo:
|
||||||
|
return jsonify({'error': 'Todo not found'}), 404
|
||||||
|
|
||||||
|
# Check permission
|
||||||
|
if not todo.can_edit(identity):
|
||||||
|
return jsonify({'error': 'No permission to edit this todo'}), 403
|
||||||
|
|
||||||
|
# Find responsible relationship
|
||||||
|
responsible = TodoItemResponsible.query.filter_by(
|
||||||
|
todo_id=todo_id, ad_account=ad_account
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if not responsible:
|
||||||
|
return jsonify({'error': 'User is not responsible for this todo'}), 404
|
||||||
|
|
||||||
|
# Remove responsible user
|
||||||
|
db.session.delete(responsible)
|
||||||
|
|
||||||
|
# Log audit
|
||||||
|
audit = TodoAuditLog(
|
||||||
|
actor_ad=identity,
|
||||||
|
todo_id=todo_id,
|
||||||
|
action='UPDATE',
|
||||||
|
detail={
|
||||||
|
'field': 'responsible_users',
|
||||||
|
'action': 'remove',
|
||||||
|
'ad_account': ad_account
|
||||||
|
}
|
||||||
|
)
|
||||||
|
db.session.add(audit)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
logger.info(f"Removed responsible user {ad_account} from todo {todo_id} by {identity}")
|
||||||
|
return jsonify({'message': 'Responsible user removed successfully'}), 200
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
db.session.rollback()
|
||||||
|
logger.error(f"Remove responsible user error: {str(e)}")
|
||||||
|
return jsonify({'error': 'Failed to remove responsible user'}), 500
|
||||||
|
|
||||||
|
@todos_bp.route('/<todo_id>/followers', methods=['POST'])
|
||||||
|
@jwt_required()
|
||||||
|
def add_follower(todo_id):
|
||||||
|
"""Add follower to todo"""
|
||||||
|
try:
|
||||||
|
identity = get_jwt_identity()
|
||||||
|
data = request.get_json()
|
||||||
|
|
||||||
|
if not data or 'ad_account' not in data:
|
||||||
|
return jsonify({'error': 'AD account is required'}), 400
|
||||||
|
|
||||||
|
ad_account = data['ad_account']
|
||||||
|
|
||||||
|
# Get todo
|
||||||
|
todo = TodoItem.query.filter_by(id=todo_id).first()
|
||||||
|
if not todo:
|
||||||
|
return jsonify({'error': 'Todo not found'}), 404
|
||||||
|
|
||||||
|
# Check permission (anyone who can view the todo can add followers)
|
||||||
|
if not todo.can_view(identity):
|
||||||
|
return jsonify({'error': 'No permission to view this todo'}), 403
|
||||||
|
|
||||||
|
# Validate AD account
|
||||||
|
valid_accounts = validate_ad_accounts([ad_account])
|
||||||
|
if ad_account not in valid_accounts:
|
||||||
|
return jsonify({'error': 'Invalid AD account'}), 400
|
||||||
|
|
||||||
|
# Check if already following
|
||||||
|
existing = TodoItemFollower.query.filter_by(
|
||||||
|
todo_id=todo_id, ad_account=ad_account
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if existing:
|
||||||
|
return jsonify({'error': 'User is already following this todo'}), 400
|
||||||
|
|
||||||
|
# Add follower
|
||||||
|
follower = TodoItemFollower(
|
||||||
|
todo_id=todo_id,
|
||||||
|
ad_account=ad_account,
|
||||||
|
added_by=identity
|
||||||
|
)
|
||||||
|
db.session.add(follower)
|
||||||
|
|
||||||
|
# Log audit
|
||||||
|
audit = TodoAuditLog(
|
||||||
|
actor_ad=identity,
|
||||||
|
todo_id=todo_id,
|
||||||
|
action='UPDATE',
|
||||||
|
detail={
|
||||||
|
'field': 'followers',
|
||||||
|
'action': 'add',
|
||||||
|
'ad_account': ad_account
|
||||||
|
}
|
||||||
|
)
|
||||||
|
db.session.add(audit)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
logger.info(f"Added follower {ad_account} to todo {todo_id} by {identity}")
|
||||||
|
return jsonify({'message': 'Follower added successfully'}), 201
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
db.session.rollback()
|
||||||
|
logger.error(f"Add follower error: {str(e)}")
|
||||||
|
return jsonify({'error': 'Failed to add follower'}), 500
|
||||||
|
|
||||||
|
@todos_bp.route('/<todo_id>/followers/<ad_account>', methods=['DELETE'])
|
||||||
|
@jwt_required()
|
||||||
|
def remove_follower(todo_id, ad_account):
|
||||||
|
"""Remove follower from todo"""
|
||||||
|
try:
|
||||||
|
identity = get_jwt_identity()
|
||||||
|
|
||||||
|
# Get todo
|
||||||
|
todo = TodoItem.query.filter_by(id=todo_id).first()
|
||||||
|
if not todo:
|
||||||
|
return jsonify({'error': 'Todo not found'}), 404
|
||||||
|
|
||||||
|
# Check permission (user can remove themselves or todo editors can remove anyone)
|
||||||
|
if ad_account != identity and not todo.can_edit(identity):
|
||||||
|
return jsonify({'error': 'No permission to remove this follower'}), 403
|
||||||
|
|
||||||
|
# Find follower relationship
|
||||||
|
follower = TodoItemFollower.query.filter_by(
|
||||||
|
todo_id=todo_id, ad_account=ad_account
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if not follower:
|
||||||
|
return jsonify({'error': 'User is not following this todo'}), 404
|
||||||
|
|
||||||
|
# Remove follower
|
||||||
|
db.session.delete(follower)
|
||||||
|
|
||||||
|
# Log audit
|
||||||
|
audit = TodoAuditLog(
|
||||||
|
actor_ad=identity,
|
||||||
|
todo_id=todo_id,
|
||||||
|
action='UPDATE',
|
||||||
|
detail={
|
||||||
|
'field': 'followers',
|
||||||
|
'action': 'remove',
|
||||||
|
'ad_account': ad_account
|
||||||
|
}
|
||||||
|
)
|
||||||
|
db.session.add(audit)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
logger.info(f"Removed follower {ad_account} from todo {todo_id} by {identity}")
|
||||||
|
return jsonify({'message': 'Follower removed successfully'}), 200
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
db.session.rollback()
|
||||||
|
logger.error(f"Remove follower error: {str(e)}")
|
||||||
|
return jsonify({'error': 'Failed to remove follower'}), 500
|
||||||
|
|
||||||
|
@todos_bp.route('/<todo_id>/star', methods=['POST'])
|
||||||
|
@jwt_required()
|
||||||
|
def star_todo(todo_id):
|
||||||
|
"""Star/unstar a todo item"""
|
||||||
|
try:
|
||||||
|
identity = get_jwt_identity()
|
||||||
|
|
||||||
|
# Get todo
|
||||||
|
todo = TodoItem.query.filter_by(id=todo_id).first()
|
||||||
|
if not todo:
|
||||||
|
return jsonify({'error': 'Todo not found'}), 404
|
||||||
|
|
||||||
|
# Check permission
|
||||||
|
if not todo.can_view(identity):
|
||||||
|
return jsonify({'error': 'No permission to view this todo'}), 403
|
||||||
|
|
||||||
|
# Only creator can star/unstar
|
||||||
|
if todo.creator_ad != identity:
|
||||||
|
return jsonify({'error': 'Only creator can star/unstar todos'}), 403
|
||||||
|
|
||||||
|
# Toggle star status
|
||||||
|
todo.starred = not todo.starred
|
||||||
|
|
||||||
|
# Log audit
|
||||||
|
audit = TodoAuditLog(
|
||||||
|
actor_ad=identity,
|
||||||
|
todo_id=todo_id,
|
||||||
|
action='UPDATE',
|
||||||
|
detail={
|
||||||
|
'field': 'starred',
|
||||||
|
'value': todo.starred
|
||||||
|
}
|
||||||
|
)
|
||||||
|
db.session.add(audit)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
action = 'starred' if todo.starred else 'unstarred'
|
||||||
|
logger.info(f"Todo {todo_id} {action} by {identity}")
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'message': f'Todo {action} successfully',
|
||||||
|
'starred': todo.starred
|
||||||
|
}), 200
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
db.session.rollback()
|
||||||
|
logger.error(f"Star todo error: {str(e)}")
|
||||||
|
return jsonify({'error': 'Failed to star todo'}), 500
|
||||||
|
|
||||||
|
@todos_bp.route('/public', methods=['GET'])
|
||||||
|
@jwt_required()
|
||||||
|
def get_public_todos():
|
||||||
|
"""Get all public todos"""
|
||||||
|
try:
|
||||||
|
page = request.args.get('page', 1, type=int)
|
||||||
|
per_page = request.args.get('per_page', 20, type=int)
|
||||||
|
|
||||||
|
# Filters for public todos
|
||||||
|
status = request.args.get('status')
|
||||||
|
priority = request.args.get('priority')
|
||||||
|
search = request.args.get('search')
|
||||||
|
tags = request.args.getlist('tags')
|
||||||
|
|
||||||
|
# Query only public todos
|
||||||
|
query = TodoItem.query.filter(TodoItem.is_public == True).options(
|
||||||
|
joinedload(TodoItem.responsible_users),
|
||||||
|
joinedload(TodoItem.followers)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Apply filters
|
||||||
|
if status:
|
||||||
|
query = query.filter(TodoItem.status == status)
|
||||||
|
if priority:
|
||||||
|
query = query.filter(TodoItem.priority == priority)
|
||||||
|
if search:
|
||||||
|
query = query.filter(
|
||||||
|
or_(
|
||||||
|
TodoItem.title.contains(search),
|
||||||
|
TodoItem.description.contains(search)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if tags:
|
||||||
|
for tag in tags:
|
||||||
|
query = query.filter(TodoItem.tags.contains(tag))
|
||||||
|
|
||||||
|
# Order by created_at desc
|
||||||
|
query = query.order_by(TodoItem.created_at.desc())
|
||||||
|
|
||||||
|
# Paginate
|
||||||
|
paginated = query.paginate(page=page, per_page=per_page, error_out=False)
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'todos': [todo.to_dict() for todo in paginated.items],
|
||||||
|
'total': paginated.total,
|
||||||
|
'pages': paginated.pages,
|
||||||
|
'current_page': page
|
||||||
|
}), 200
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Get public todos error: {str(e)}")
|
||||||
|
return jsonify({'error': 'Failed to fetch public todos'}), 500
|
||||||
|
|
||||||
|
@todos_bp.route('/following', methods=['GET'])
|
||||||
|
@jwt_required()
|
||||||
|
def get_following_todos():
|
||||||
|
"""Get todos that user is following"""
|
||||||
|
try:
|
||||||
|
identity = get_jwt_identity()
|
||||||
|
page = request.args.get('page', 1, type=int)
|
||||||
|
per_page = request.args.get('per_page', 20, type=int)
|
||||||
|
|
||||||
|
# Query todos where user is a follower
|
||||||
|
query = TodoItem.query.join(TodoItemFollower).filter(
|
||||||
|
TodoItemFollower.ad_account == identity
|
||||||
|
).options(
|
||||||
|
joinedload(TodoItem.responsible_users),
|
||||||
|
joinedload(TodoItem.followers)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Order by created_at desc
|
||||||
|
query = query.order_by(TodoItem.created_at.desc())
|
||||||
|
|
||||||
|
# Paginate
|
||||||
|
paginated = query.paginate(page=page, per_page=per_page, error_out=False)
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'todos': [todo.to_dict() for todo in paginated.items],
|
||||||
|
'total': paginated.total,
|
||||||
|
'pages': paginated.pages,
|
||||||
|
'current_page': page
|
||||||
|
}), 200
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Get following todos error: {str(e)}")
|
||||||
|
return jsonify({'error': 'Failed to fetch following todos'}), 500
|
||||||
|
|
||||||
|
@todos_bp.route('/<todo_id>/visibility', methods=['PATCH'])
|
||||||
|
@jwt_required()
|
||||||
|
def update_todo_visibility(todo_id):
|
||||||
|
"""Toggle todo visibility (public/private)"""
|
||||||
|
try:
|
||||||
|
identity = get_jwt_identity()
|
||||||
|
|
||||||
|
# Get todo
|
||||||
|
todo = TodoItem.query.get(todo_id)
|
||||||
|
if not todo:
|
||||||
|
return jsonify({'error': 'Todo not found'}), 404
|
||||||
|
|
||||||
|
# Only creator can change visibility
|
||||||
|
if todo.creator_ad != identity:
|
||||||
|
return jsonify({'error': 'Only creator can change visibility'}), 403
|
||||||
|
|
||||||
|
# Toggle visibility
|
||||||
|
data = request.get_json()
|
||||||
|
is_public = data.get('is_public', not todo.is_public)
|
||||||
|
todo.is_public = is_public
|
||||||
|
|
||||||
|
# Log audit
|
||||||
|
audit = TodoAuditLog(
|
||||||
|
actor_ad=identity,
|
||||||
|
todo_id=todo_id,
|
||||||
|
action='UPDATE',
|
||||||
|
detail={
|
||||||
|
'field': 'is_public',
|
||||||
|
'old_value': not is_public,
|
||||||
|
'new_value': is_public
|
||||||
|
}
|
||||||
|
)
|
||||||
|
db.session.add(audit)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
logger.info(f"Todo {todo_id} visibility changed to {'public' if is_public else 'private'} by {identity}")
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'message': f'Todo is now {"public" if is_public else "private"}',
|
||||||
|
'is_public': todo.is_public
|
||||||
|
}), 200
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
db.session.rollback()
|
||||||
|
logger.error(f"Update visibility error: {str(e)}")
|
||||||
|
return jsonify({'error': 'Failed to update visibility'}), 500
|
||||||
|
|
||||||
|
@todos_bp.route('/<todo_id>/follow', methods=['POST'])
|
||||||
|
@jwt_required()
|
||||||
|
def follow_todo(todo_id):
|
||||||
|
"""Follow a public todo"""
|
||||||
|
try:
|
||||||
|
identity = get_jwt_identity()
|
||||||
|
|
||||||
|
# Get todo
|
||||||
|
todo = TodoItem.query.get(todo_id)
|
||||||
|
if not todo:
|
||||||
|
return jsonify({'error': 'Todo not found'}), 404
|
||||||
|
|
||||||
|
# Check if todo is public or user has permission
|
||||||
|
if not todo.is_public and not todo.can_edit(identity):
|
||||||
|
return jsonify({'error': 'Cannot follow private todo'}), 403
|
||||||
|
|
||||||
|
# Check if already following
|
||||||
|
existing = TodoItemFollower.query.filter_by(
|
||||||
|
todo_id=todo_id,
|
||||||
|
ad_account=identity
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if existing:
|
||||||
|
return jsonify({'message': 'Already following this todo'}), 200
|
||||||
|
|
||||||
|
# Add follower
|
||||||
|
follower = TodoItemFollower(
|
||||||
|
todo_id=todo_id,
|
||||||
|
ad_account=identity,
|
||||||
|
added_by=identity
|
||||||
|
)
|
||||||
|
db.session.add(follower)
|
||||||
|
|
||||||
|
# Note: Skip audit log for FOLLOW action until ENUM is updated
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
logger.info(f"User {identity} followed todo {todo_id}")
|
||||||
|
|
||||||
|
return jsonify({'message': 'Successfully followed todo'}), 200
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
db.session.rollback()
|
||||||
|
logger.error(f"Follow todo error: {str(e)}")
|
||||||
|
return jsonify({'error': 'Failed to follow todo'}), 500
|
||||||
|
|
||||||
|
@todos_bp.route('/<todo_id>/follow', methods=['DELETE'])
|
||||||
|
@jwt_required()
|
||||||
|
def unfollow_todo(todo_id):
|
||||||
|
"""Unfollow a todo"""
|
||||||
|
try:
|
||||||
|
identity = get_jwt_identity()
|
||||||
|
|
||||||
|
# Get follower record
|
||||||
|
follower = TodoItemFollower.query.filter_by(
|
||||||
|
todo_id=todo_id,
|
||||||
|
ad_account=identity
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if not follower:
|
||||||
|
return jsonify({'message': 'Not following this todo'}), 200
|
||||||
|
|
||||||
|
# Remove follower
|
||||||
|
db.session.delete(follower)
|
||||||
|
|
||||||
|
# Note: Skip audit log for UNFOLLOW action until ENUM is updated
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
logger.info(f"User {identity} unfollowed todo {todo_id}")
|
||||||
|
|
||||||
|
return jsonify({'message': 'Successfully unfollowed todo'}), 200
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
db.session.rollback()
|
||||||
|
logger.error(f"Unfollow todo error: {str(e)}")
|
||||||
|
return jsonify({'error': 'Failed to unfollow todo'}), 500
|
||||||
128
backend/routes/users.py
Normal file
128
backend/routes/users.py
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
from flask import Blueprint, request, jsonify, current_app
|
||||||
|
from flask_jwt_extended import jwt_required, get_jwt_identity
|
||||||
|
from datetime import datetime, date
|
||||||
|
from models import db, TodoUserPref
|
||||||
|
from utils.logger import get_logger
|
||||||
|
|
||||||
|
users_bp = Blueprint('users', __name__)
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
@users_bp.route('/search', methods=['GET'])
|
||||||
|
@jwt_required()
|
||||||
|
def search_users():
|
||||||
|
"""Search for AD users"""
|
||||||
|
try:
|
||||||
|
search_term = request.args.get('q', '').strip()
|
||||||
|
|
||||||
|
if len(search_term) < 1:
|
||||||
|
return jsonify({'error': 'Search term cannot be empty'}), 400
|
||||||
|
|
||||||
|
# Search LDAP (or mock for development)
|
||||||
|
try:
|
||||||
|
if current_app.config.get('USE_MOCK_LDAP', False):
|
||||||
|
from utils.mock_ldap import search_ldap_principals
|
||||||
|
else:
|
||||||
|
from utils.ldap_utils import search_ldap_principals
|
||||||
|
|
||||||
|
results = search_ldap_principals(search_term, limit=20)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"LDAP search error, falling back to mock: {str(e)}")
|
||||||
|
from utils.mock_ldap import search_ldap_principals
|
||||||
|
results = search_ldap_principals(search_term, limit=20)
|
||||||
|
|
||||||
|
return jsonify({'users': results}), 200
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"User search error: {str(e)}")
|
||||||
|
return jsonify({'error': 'Search failed'}), 500
|
||||||
|
|
||||||
|
@users_bp.route('/preferences', methods=['GET'])
|
||||||
|
@jwt_required()
|
||||||
|
def get_preferences():
|
||||||
|
"""Get user preferences"""
|
||||||
|
try:
|
||||||
|
identity = get_jwt_identity()
|
||||||
|
|
||||||
|
user_pref = TodoUserPref.query.filter_by(ad_account=identity).first()
|
||||||
|
if not user_pref:
|
||||||
|
return jsonify({'error': 'User preferences not found'}), 404
|
||||||
|
|
||||||
|
return jsonify(user_pref.to_dict()), 200
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error fetching preferences: {str(e)}")
|
||||||
|
return jsonify({'error': 'Failed to fetch preferences'}), 500
|
||||||
|
|
||||||
|
@users_bp.route('/preferences', methods=['PATCH'])
|
||||||
|
@jwt_required()
|
||||||
|
def update_preferences():
|
||||||
|
"""Update user preferences"""
|
||||||
|
try:
|
||||||
|
identity = get_jwt_identity()
|
||||||
|
data = request.get_json()
|
||||||
|
|
||||||
|
user_pref = TodoUserPref.query.filter_by(ad_account=identity).first()
|
||||||
|
if not user_pref:
|
||||||
|
return jsonify({'error': 'User preferences not found'}), 404
|
||||||
|
|
||||||
|
# Update allowed fields
|
||||||
|
if 'theme' in data and data['theme'] in ['light', 'dark', 'auto']:
|
||||||
|
user_pref.theme = data['theme']
|
||||||
|
|
||||||
|
if 'language' in data:
|
||||||
|
user_pref.language = data['language']
|
||||||
|
|
||||||
|
if 'timezone' in data:
|
||||||
|
user_pref.timezone = data['timezone']
|
||||||
|
|
||||||
|
if 'notification_enabled' in data:
|
||||||
|
user_pref.notification_enabled = bool(data['notification_enabled'])
|
||||||
|
|
||||||
|
if 'email_reminder_enabled' in data:
|
||||||
|
user_pref.email_reminder_enabled = bool(data['email_reminder_enabled'])
|
||||||
|
|
||||||
|
if 'weekly_summary_enabled' in data:
|
||||||
|
user_pref.weekly_summary_enabled = bool(data['weekly_summary_enabled'])
|
||||||
|
|
||||||
|
|
||||||
|
user_pref.updated_at = datetime.utcnow()
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
logger.info(f"Preferences updated for user: {identity}")
|
||||||
|
return jsonify(user_pref.to_dict()), 200
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
db.session.rollback()
|
||||||
|
logger.error(f"Error updating preferences: {str(e)}")
|
||||||
|
return jsonify({'error': 'Failed to update preferences'}), 500
|
||||||
|
|
||||||
|
@users_bp.route('/fire-email-quota', methods=['GET'])
|
||||||
|
@jwt_required()
|
||||||
|
def get_fire_email_quota():
|
||||||
|
"""Get user's fire email quota for today"""
|
||||||
|
try:
|
||||||
|
identity = get_jwt_identity()
|
||||||
|
|
||||||
|
user_pref = TodoUserPref.query.filter_by(ad_account=identity).first()
|
||||||
|
if not user_pref:
|
||||||
|
return jsonify({'error': 'User not found'}), 404
|
||||||
|
|
||||||
|
# Reset counter if it's a new day
|
||||||
|
today = date.today()
|
||||||
|
if user_pref.fire_email_last_reset != today:
|
||||||
|
user_pref.fire_email_today_count = 0
|
||||||
|
user_pref.fire_email_last_reset = today
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
from flask import current_app
|
||||||
|
daily_limit = current_app.config['FIRE_EMAIL_DAILY_LIMIT']
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'used': user_pref.fire_email_today_count,
|
||||||
|
'limit': daily_limit,
|
||||||
|
'remaining': max(0, daily_limit - user_pref.fire_email_today_count)
|
||||||
|
}), 200
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error fetching fire email quota: {str(e)}")
|
||||||
|
return jsonify({'error': 'Failed to fetch quota'}), 500
|
||||||
60
backend/run_migration.py
Normal file
60
backend/run_migration.py
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
#!/usr/bin/env python
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""Run migration to add public/private feature"""
|
||||||
|
|
||||||
|
import pymysql
|
||||||
|
import os
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
# Load environment variables
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
def run_migration():
|
||||||
|
"""Execute the migration SQL"""
|
||||||
|
try:
|
||||||
|
# Connect to database
|
||||||
|
conn = pymysql.connect(
|
||||||
|
host=os.getenv('MYSQL_HOST', 'mysql.theaken.com'),
|
||||||
|
port=int(os.getenv('MYSQL_PORT', 33306)),
|
||||||
|
user=os.getenv('MYSQL_USER', 'A060'),
|
||||||
|
password=os.getenv('MYSQL_PASSWORD', 'WLeSCi0yhtc7'),
|
||||||
|
database=os.getenv('MYSQL_DATABASE', 'db_A060')
|
||||||
|
)
|
||||||
|
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
# Read migration file
|
||||||
|
with open('migrations/add_public_feature.sql', 'r', encoding='utf-8') as f:
|
||||||
|
sql_content = f.read()
|
||||||
|
|
||||||
|
# Execute each statement
|
||||||
|
statements = sql_content.split(';')
|
||||||
|
for statement in statements:
|
||||||
|
statement = statement.strip()
|
||||||
|
if statement and not statement.startswith('--'):
|
||||||
|
print(f"Executing: {statement[:50]}...")
|
||||||
|
cursor.execute(statement)
|
||||||
|
|
||||||
|
# Commit changes
|
||||||
|
conn.commit()
|
||||||
|
print("Migration completed successfully!")
|
||||||
|
|
||||||
|
# Verify the changes
|
||||||
|
cursor.execute("DESCRIBE todo_item")
|
||||||
|
columns = cursor.fetchall()
|
||||||
|
print("\nCurrent todo_item columns:")
|
||||||
|
for col in columns:
|
||||||
|
if col[0] in ['is_public', 'tags']:
|
||||||
|
print(f" ✓ {col[0]}: {col[1]}")
|
||||||
|
|
||||||
|
cursor.close()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Migration failed: {str(e)}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
run_migration()
|
||||||
226
backend/tasks.py
Normal file
226
backend/tasks.py
Normal file
@@ -0,0 +1,226 @@
|
|||||||
|
"""
|
||||||
|
Celery Tasks for Background Jobs
|
||||||
|
處理排程任務,包括提醒郵件和摘要報告
|
||||||
|
"""
|
||||||
|
|
||||||
|
from celery import Celery
|
||||||
|
from datetime import datetime, date, timedelta
|
||||||
|
from sqlalchemy import and_, or_
|
||||||
|
from models import (
|
||||||
|
db, TodoItem, TodoItemResponsible, TodoItemFollower,
|
||||||
|
TodoUserPref, TodoAuditLog
|
||||||
|
)
|
||||||
|
from utils.email_service import EmailService
|
||||||
|
from utils.notification_service import NotificationService
|
||||||
|
from utils.logger import get_logger
|
||||||
|
import os
|
||||||
|
|
||||||
|
# 建立 Celery 實例
|
||||||
|
def make_celery(app):
|
||||||
|
celery = Celery(
|
||||||
|
app.import_name,
|
||||||
|
backend=app.config['CELERY_RESULT_BACKEND'],
|
||||||
|
broker=app.config['CELERY_BROKER_URL']
|
||||||
|
)
|
||||||
|
celery.conf.update(app.config)
|
||||||
|
|
||||||
|
class ContextTask(celery.Task):
|
||||||
|
"""Make celery tasks work with Flask app context"""
|
||||||
|
def __call__(self, *args, **kwargs):
|
||||||
|
with app.app_context():
|
||||||
|
return self.run(*args, **kwargs)
|
||||||
|
|
||||||
|
celery.Task = ContextTask
|
||||||
|
return celery
|
||||||
|
|
||||||
|
# 建立 Flask 應用程式和 Celery
|
||||||
|
def create_celery_app():
|
||||||
|
"""建立 Celery 應用程式,延遲導入避免循環依賴"""
|
||||||
|
from app import create_app
|
||||||
|
flask_app = create_app()
|
||||||
|
return make_celery(flask_app), flask_app
|
||||||
|
|
||||||
|
# 全局變數,延遲初始化
|
||||||
|
celery = None
|
||||||
|
flask_app = None
|
||||||
|
|
||||||
|
def get_celery():
|
||||||
|
"""獲取 Celery 實例"""
|
||||||
|
global celery, flask_app
|
||||||
|
if celery is None:
|
||||||
|
celery, flask_app = create_celery_app()
|
||||||
|
return celery
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
def send_daily_reminders():
|
||||||
|
"""發送每日提醒郵件"""
|
||||||
|
try:
|
||||||
|
celery_app = get_celery()
|
||||||
|
from app import create_app
|
||||||
|
app = create_app()
|
||||||
|
with app.app_context():
|
||||||
|
today = date.today()
|
||||||
|
tomorrow = today + timedelta(days=1)
|
||||||
|
|
||||||
|
# 查找明日到期的待辦事項
|
||||||
|
due_tomorrow = db.session.query(TodoItem).filter(
|
||||||
|
and_(
|
||||||
|
TodoItem.due_date == tomorrow,
|
||||||
|
TodoItem.status != 'DONE'
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
|
||||||
|
# 查找已逾期的待辦事項
|
||||||
|
overdue = db.session.query(TodoItem).filter(
|
||||||
|
and_(
|
||||||
|
TodoItem.due_date < today,
|
||||||
|
TodoItem.status != 'DONE'
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
|
||||||
|
email_service = EmailService()
|
||||||
|
notification_service = NotificationService()
|
||||||
|
sent_count = 0
|
||||||
|
|
||||||
|
# 處理明日到期提醒
|
||||||
|
for todo in due_tomorrow:
|
||||||
|
recipients = notification_service.get_notification_recipients(todo)
|
||||||
|
for recipient in recipients:
|
||||||
|
try:
|
||||||
|
# 檢查用戶是否啟用郵件提醒
|
||||||
|
user_pref = TodoUserPref.query.filter_by(ad_account=recipient).first()
|
||||||
|
if not user_pref or not user_pref.email_reminder_enabled:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if email_service.send_reminder_email(todo, recipient, 'due_tomorrow'):
|
||||||
|
sent_count += 1
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to send due tomorrow reminder to {recipient}: {str(e)}")
|
||||||
|
|
||||||
|
# 處理逾期提醒
|
||||||
|
for todo in overdue:
|
||||||
|
recipients = notification_service.get_notification_recipients(todo)
|
||||||
|
for recipient in recipients:
|
||||||
|
try:
|
||||||
|
# 檢查用戶是否啟用郵件提醒
|
||||||
|
user_pref = TodoUserPref.query.filter_by(ad_account=recipient).first()
|
||||||
|
if not user_pref or not user_pref.email_reminder_enabled:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if email_service.send_reminder_email(todo, recipient, 'overdue'):
|
||||||
|
sent_count += 1
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to send overdue reminder to {recipient}: {str(e)}")
|
||||||
|
|
||||||
|
# 記錄稽核日誌
|
||||||
|
audit = TodoAuditLog(
|
||||||
|
actor_ad='system',
|
||||||
|
todo_id=None,
|
||||||
|
action='DAILY_REMINDER',
|
||||||
|
detail={
|
||||||
|
'due_tomorrow_count': len(due_tomorrow),
|
||||||
|
'overdue_count': len(overdue),
|
||||||
|
'emails_sent': sent_count
|
||||||
|
}
|
||||||
|
)
|
||||||
|
db.session.add(audit)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
logger.info(f"Daily reminders sent: {sent_count} emails for {len(due_tomorrow + overdue)} todos")
|
||||||
|
return {
|
||||||
|
'sent_count': sent_count,
|
||||||
|
'due_tomorrow': len(due_tomorrow),
|
||||||
|
'overdue': len(overdue)
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Daily reminders task failed: {str(e)}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
@celery.task
|
||||||
|
def send_weekly_summary():
|
||||||
|
"""發送每週摘要報告"""
|
||||||
|
try:
|
||||||
|
with flask_app.app_context():
|
||||||
|
# 取得所有啟用週報的用戶
|
||||||
|
users = TodoUserPref.query.filter_by(weekly_summary_enabled=True).all()
|
||||||
|
|
||||||
|
email_service = EmailService()
|
||||||
|
notification_service = NotificationService()
|
||||||
|
sent_count = 0
|
||||||
|
|
||||||
|
for user in users:
|
||||||
|
try:
|
||||||
|
# 準備週報資料
|
||||||
|
digest_data = notification_service.prepare_digest(user.ad_account, 'weekly')
|
||||||
|
|
||||||
|
if email_service.send_digest_email(user.ad_account, digest_data):
|
||||||
|
sent_count += 1
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to send weekly summary to {user.ad_account}: {str(e)}")
|
||||||
|
|
||||||
|
# 記錄稽核日誌
|
||||||
|
audit = TodoAuditLog(
|
||||||
|
actor_ad='system',
|
||||||
|
todo_id=None,
|
||||||
|
action='WEEKLY_SUMMARY',
|
||||||
|
detail={
|
||||||
|
'users_count': len(users),
|
||||||
|
'emails_sent': sent_count
|
||||||
|
}
|
||||||
|
)
|
||||||
|
db.session.add(audit)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
logger.info(f"Weekly summary sent: {sent_count} emails to {len(users)} users")
|
||||||
|
return {
|
||||||
|
'sent_count': sent_count,
|
||||||
|
'total_users': len(users)
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Weekly summary task failed: {str(e)}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
@celery.task
|
||||||
|
def cleanup_old_logs():
|
||||||
|
"""清理舊的日誌記錄"""
|
||||||
|
try:
|
||||||
|
with flask_app.app_context():
|
||||||
|
# 清理30天前的稽核日誌
|
||||||
|
thirty_days_ago = datetime.utcnow() - timedelta(days=30)
|
||||||
|
deleted_count = TodoAuditLog.query.filter(
|
||||||
|
TodoAuditLog.created_at < thirty_days_ago
|
||||||
|
).delete()
|
||||||
|
|
||||||
|
db.session.commit()
|
||||||
|
logger.info(f"Cleaned up {deleted_count} old audit logs")
|
||||||
|
return {'deleted_count': deleted_count}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Cleanup logs task failed: {str(e)}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
# Celery Beat 排程配置
|
||||||
|
celery.conf.beat_schedule = {
|
||||||
|
# 每日早上9點發送提醒
|
||||||
|
'daily-reminders': {
|
||||||
|
'task': 'tasks.send_daily_reminders',
|
||||||
|
'schedule': 60.0 * 60.0 * 24.0, # 24小時
|
||||||
|
'options': {'expires': 3600}
|
||||||
|
},
|
||||||
|
# 每週一早上9點發送週報
|
||||||
|
'weekly-summary': {
|
||||||
|
'task': 'tasks.send_weekly_summary',
|
||||||
|
'schedule': 60.0 * 60.0 * 24.0 * 7.0, # 7天
|
||||||
|
'options': {'expires': 3600}
|
||||||
|
},
|
||||||
|
# 每週清理一次舊日誌
|
||||||
|
'cleanup-logs': {
|
||||||
|
'task': 'tasks.cleanup_old_logs',
|
||||||
|
'schedule': 60.0 * 60.0 * 24.0 * 7.0, # 7天
|
||||||
|
'options': {'expires': 3600}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
celery.conf.timezone = 'Asia/Taipei'
|
||||||
178
backend/tasks_simple.py
Normal file
178
backend/tasks_simple.py
Normal file
@@ -0,0 +1,178 @@
|
|||||||
|
"""
|
||||||
|
Simple Task Definitions
|
||||||
|
簡化的任務定義,避免循環導入
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import datetime, date, timedelta
|
||||||
|
from sqlalchemy import and_, or_
|
||||||
|
from utils.logger import get_logger
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
def send_daily_reminders_task():
|
||||||
|
"""發送每日提醒郵件的實際實作"""
|
||||||
|
from models import db, TodoItem, TodoUserPref
|
||||||
|
from utils.email_service import EmailService
|
||||||
|
from utils.notification_service import NotificationService
|
||||||
|
|
||||||
|
try:
|
||||||
|
today = date.today()
|
||||||
|
tomorrow = today + timedelta(days=1)
|
||||||
|
|
||||||
|
# 查找明日到期的待辦事項
|
||||||
|
due_tomorrow = db.session.query(TodoItem).filter(
|
||||||
|
and_(
|
||||||
|
TodoItem.due_date == tomorrow,
|
||||||
|
TodoItem.status != 'DONE'
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
|
||||||
|
# 查找已逾期的待辦事項
|
||||||
|
overdue = db.session.query(TodoItem).filter(
|
||||||
|
and_(
|
||||||
|
TodoItem.due_date < today,
|
||||||
|
TodoItem.status != 'DONE'
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
|
||||||
|
email_service = EmailService()
|
||||||
|
notification_service = NotificationService()
|
||||||
|
|
||||||
|
sent_count = 0
|
||||||
|
|
||||||
|
# 處理明日到期提醒
|
||||||
|
for todo in due_tomorrow:
|
||||||
|
recipients = notification_service.get_notification_recipients(todo)
|
||||||
|
for recipient in recipients:
|
||||||
|
try:
|
||||||
|
# 檢查用戶是否啟用郵件提醒
|
||||||
|
user_pref = TodoUserPref.query.filter_by(ad_account=recipient).first()
|
||||||
|
if not user_pref or not user_pref.email_reminder_enabled:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if email_service.send_reminder_email(todo, recipient, 'due_tomorrow'):
|
||||||
|
sent_count += 1
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to send due tomorrow reminder to {recipient}: {str(e)}")
|
||||||
|
|
||||||
|
# 處理逾期提醒
|
||||||
|
for todo in overdue:
|
||||||
|
recipients = notification_service.get_notification_recipients(todo)
|
||||||
|
for recipient in recipients:
|
||||||
|
try:
|
||||||
|
# 檢查用戶是否啟用郵件提醒
|
||||||
|
user_pref = TodoUserPref.query.filter_by(ad_account=recipient).first()
|
||||||
|
if not user_pref or not user_pref.email_reminder_enabled:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if email_service.send_reminder_email(todo, recipient, 'overdue'):
|
||||||
|
sent_count += 1
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to send overdue reminder to {recipient}: {str(e)}")
|
||||||
|
|
||||||
|
# 記錄稽核日誌
|
||||||
|
from models import TodoAuditLog
|
||||||
|
audit = TodoAuditLog(
|
||||||
|
actor_ad='system',
|
||||||
|
todo_id=None,
|
||||||
|
action='DAILY_REMINDER',
|
||||||
|
detail={
|
||||||
|
'due_tomorrow_count': len(due_tomorrow),
|
||||||
|
'overdue_count': len(overdue),
|
||||||
|
'emails_sent': sent_count
|
||||||
|
}
|
||||||
|
)
|
||||||
|
db.session.add(audit)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
logger.info(f"Daily reminders sent: {sent_count} emails for {len(due_tomorrow + overdue)} todos")
|
||||||
|
return {
|
||||||
|
'sent_count': sent_count,
|
||||||
|
'due_tomorrow': len(due_tomorrow),
|
||||||
|
'overdue': len(overdue)
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Daily reminders task failed: {str(e)}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
def send_weekly_summary_task():
|
||||||
|
"""發送每週摘要報告的實際實作"""
|
||||||
|
from models import db, TodoUserPref
|
||||||
|
from utils.email_service import EmailService
|
||||||
|
from utils.notification_service import NotificationService
|
||||||
|
|
||||||
|
try:
|
||||||
|
# 取得所有啟用週報的用戶
|
||||||
|
users = TodoUserPref.query.filter_by(weekly_summary_enabled=True).all()
|
||||||
|
|
||||||
|
email_service = EmailService()
|
||||||
|
notification_service = NotificationService()
|
||||||
|
sent_count = 0
|
||||||
|
|
||||||
|
for user in users:
|
||||||
|
try:
|
||||||
|
# 準備週報資料
|
||||||
|
digest_data = notification_service.prepare_digest(user.ad_account, 'weekly')
|
||||||
|
|
||||||
|
if email_service.send_digest_email(user.ad_account, digest_data):
|
||||||
|
sent_count += 1
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to send weekly summary to {user.ad_account}: {str(e)}")
|
||||||
|
|
||||||
|
# 記錄稽核日誌
|
||||||
|
from models import TodoAuditLog
|
||||||
|
audit = TodoAuditLog(
|
||||||
|
actor_ad='system',
|
||||||
|
todo_id=None,
|
||||||
|
action='WEEKLY_SUMMARY',
|
||||||
|
detail={
|
||||||
|
'users_count': len(users),
|
||||||
|
'emails_sent': sent_count
|
||||||
|
}
|
||||||
|
)
|
||||||
|
db.session.add(audit)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
logger.info(f"Weekly summary sent: {sent_count} emails to {len(users)} users")
|
||||||
|
return {
|
||||||
|
'sent_count': sent_count,
|
||||||
|
'total_users': len(users)
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Weekly summary task failed: {str(e)}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
def cleanup_old_logs_task():
|
||||||
|
"""清理舊的日誌記錄的實際實作"""
|
||||||
|
from models import db, TodoAuditLog
|
||||||
|
|
||||||
|
try:
|
||||||
|
# 清理30天前的稽核日誌
|
||||||
|
thirty_days_ago = datetime.utcnow() - timedelta(days=30)
|
||||||
|
deleted_count = TodoAuditLog.query.filter(
|
||||||
|
TodoAuditLog.created_at < thirty_days_ago
|
||||||
|
).delete()
|
||||||
|
|
||||||
|
db.session.commit()
|
||||||
|
logger.info(f"Cleaned up {deleted_count} old audit logs")
|
||||||
|
return {'deleted_count': deleted_count}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Cleanup logs task failed: {str(e)}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
# 為了與現有代碼兼容,提供簡單的包裝函數
|
||||||
|
def send_daily_reminders():
|
||||||
|
"""包裝函數,保持與現有代碼兼容"""
|
||||||
|
return send_daily_reminders_task()
|
||||||
|
|
||||||
|
def send_weekly_summary():
|
||||||
|
"""包裝函數,保持與現有代碼兼容"""
|
||||||
|
return send_weekly_summary_task()
|
||||||
|
|
||||||
|
def cleanup_old_logs():
|
||||||
|
"""包裝函數,保持與現有代碼兼容"""
|
||||||
|
return cleanup_old_logs_task()
|
||||||
230
backend/templates/emails/fire_email.html
Normal file
230
backend/templates/emails/fire_email.html
Normal file
@@ -0,0 +1,230 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-TW">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>緊急通知 - {{ todo.title }}</title>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
font-family: 'Microsoft YaHei', 'PingFang SC', Arial, sans-serif;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: #333;
|
||||||
|
max-width: 600px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 20px;
|
||||||
|
background-color: #f5f5f5;
|
||||||
|
}
|
||||||
|
.container {
|
||||||
|
background: white;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 30px;
|
||||||
|
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
||||||
|
}
|
||||||
|
.header {
|
||||||
|
text-align: center;
|
||||||
|
border-bottom: 3px solid #dc3545;
|
||||||
|
padding-bottom: 20px;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
}
|
||||||
|
.fire-icon {
|
||||||
|
font-size: 48px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
.title {
|
||||||
|
color: #dc3545;
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: bold;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.urgent-badge {
|
||||||
|
background: #dc3545;
|
||||||
|
color: white;
|
||||||
|
padding: 8px 16px;
|
||||||
|
border-radius: 20px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: bold;
|
||||||
|
display: inline-block;
|
||||||
|
margin: 10px 0;
|
||||||
|
}
|
||||||
|
.todo-details {
|
||||||
|
background: #f8f9fa;
|
||||||
|
border-left: 4px solid #dc3545;
|
||||||
|
padding: 20px;
|
||||||
|
margin: 20px 0;
|
||||||
|
border-radius: 0 4px 4px 0;
|
||||||
|
}
|
||||||
|
.detail-row {
|
||||||
|
margin: 10px 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.detail-label {
|
||||||
|
font-weight: bold;
|
||||||
|
min-width: 80px;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
.detail-value {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
.status-badge, .priority-badge {
|
||||||
|
padding: 4px 12px;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: bold;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
.status-new { background: #e3f2fd; color: #1976d2; }
|
||||||
|
.status-in-progress { background: #fff3e0; color: #f57c00; }
|
||||||
|
.status-done { background: #e8f5e8; color: #388e3c; }
|
||||||
|
.priority-high { background: #ffebee; color: #d32f2f; }
|
||||||
|
.priority-medium { background: #fff3e0; color: #f57c00; }
|
||||||
|
.priority-low { background: #e8f5e8; color: #388e3c; }
|
||||||
|
.custom-message {
|
||||||
|
background: #fff3cd;
|
||||||
|
border: 1px solid #ffeaa7;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 15px;
|
||||||
|
margin: 20px 0;
|
||||||
|
}
|
||||||
|
.sender-info {
|
||||||
|
margin: 20px 0;
|
||||||
|
padding: 15px;
|
||||||
|
background: #f8f9fa;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
.action-buttons {
|
||||||
|
text-align: center;
|
||||||
|
margin: 30px 0;
|
||||||
|
}
|
||||||
|
.btn {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 12px 24px;
|
||||||
|
margin: 0 10px;
|
||||||
|
border-radius: 4px;
|
||||||
|
text-decoration: none;
|
||||||
|
font-weight: bold;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.btn-primary {
|
||||||
|
background: #007bff;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
.btn-danger {
|
||||||
|
background: #dc3545;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
.footer {
|
||||||
|
text-align: center;
|
||||||
|
margin-top: 30px;
|
||||||
|
padding-top: 20px;
|
||||||
|
border-top: 1px solid #dee2e6;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
.timestamp {
|
||||||
|
color: #999;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<div class="header">
|
||||||
|
<span class="fire-icon">🚨</span>
|
||||||
|
<h1 class="title">緊急通知</h1>
|
||||||
|
<div class="urgent-badge">URGENT - 立即處理</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sender-info">
|
||||||
|
<strong>{{ sender_name }}</strong> 向您發送了緊急通知
|
||||||
|
<div class="timestamp">{{ timestamp }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if custom_message %}
|
||||||
|
<div class="custom-message">
|
||||||
|
<strong>📝 發送者留言:</strong><br>
|
||||||
|
{{ custom_message }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="todo-details">
|
||||||
|
<h3>📋 待辦事項詳情</h3>
|
||||||
|
|
||||||
|
<div class="detail-row">
|
||||||
|
<div class="detail-label">標題:</div>
|
||||||
|
<div class="detail-value"><strong>{{ todo.title }}</strong></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if todo.description %}
|
||||||
|
<div class="detail-row">
|
||||||
|
<div class="detail-label">描述:</div>
|
||||||
|
<div class="detail-value">{{ todo.description }}</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="detail-row">
|
||||||
|
<div class="detail-label">狀態:</div>
|
||||||
|
<div class="detail-value">
|
||||||
|
<span class="status-badge status-{{ todo.status.lower().replace('_', '-') }}">
|
||||||
|
{% if todo.status == 'NEW' %}新建
|
||||||
|
{% elif todo.status == 'IN_PROGRESS' %}進行中
|
||||||
|
{% elif todo.status == 'DONE' %}完成
|
||||||
|
{% else %}{{ todo.status }}{% endif %}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="detail-row">
|
||||||
|
<div class="detail-label">優先級:</div>
|
||||||
|
<div class="detail-value">
|
||||||
|
<span class="priority-badge priority-{{ todo.priority.lower() }}">
|
||||||
|
{% if todo.priority == 'HIGH' %}高
|
||||||
|
{% elif todo.priority == 'MEDIUM' %}中
|
||||||
|
{% elif todo.priority == 'LOW' %}低
|
||||||
|
{% else %}{{ todo.priority }}{% endif %}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if todo.due_date %}
|
||||||
|
<div class="detail-row">
|
||||||
|
<div class="detail-label">到期日:</div>
|
||||||
|
<div class="detail-value">
|
||||||
|
<strong style="color: #dc3545;">{{ todo.due_date.strftime('%Y年%m月%d日') }}</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="detail-row">
|
||||||
|
<div class="detail-label">建立者:</div>
|
||||||
|
<div class="detail-value">{{ todo.creator_display_name or todo.creator_ad }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="detail-row">
|
||||||
|
<div class="detail-label">建立時間:</div>
|
||||||
|
<div class="detail-value">{{ todo.created_at.strftime('%Y年%m月%d日 %H:%M') }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="action-buttons">
|
||||||
|
<a href="{{ app_url }}/todos/{{ todo.id }}" class="btn btn-primary">
|
||||||
|
📖 查看詳情
|
||||||
|
</a>
|
||||||
|
<a href="{{ app_url }}/todos/{{ todo.id }}/edit" class="btn btn-danger">
|
||||||
|
✏️ 立即處理
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="footer">
|
||||||
|
<p>這是一封系統自動發送的緊急通知郵件</p>
|
||||||
|
<p>如有疑問,請聯繫發送者 {{ sender_name }} ({{ sender }})</p>
|
||||||
|
<div class="timestamp">
|
||||||
|
發送時間:{{ timestamp }}<br>
|
||||||
|
由 {{ app_name }} 系統發送
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
319
backend/utils/email_service.py
Normal file
319
backend/utils/email_service.py
Normal file
@@ -0,0 +1,319 @@
|
|||||||
|
"""
|
||||||
|
Email Service
|
||||||
|
處理所有郵件相關功能,包括通知、提醒和摘要郵件
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import smtplib
|
||||||
|
from email.mime.text import MIMEText
|
||||||
|
from email.mime.multipart import MIMEMultipart
|
||||||
|
from email.mime.application import MIMEApplication
|
||||||
|
from datetime import datetime, date
|
||||||
|
from flask import current_app
|
||||||
|
from jinja2 import Environment, FileSystemLoader, select_autoescape
|
||||||
|
from utils.logger import get_logger
|
||||||
|
from utils.ldap_utils import get_user_info
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
class EmailService:
|
||||||
|
"""郵件服務類別"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.smtp_server = os.getenv('SMTP_SERVER')
|
||||||
|
self.smtp_port = int(os.getenv('SMTP_PORT', 587))
|
||||||
|
self.use_tls = os.getenv('SMTP_USE_TLS', 'false').lower() == 'true'
|
||||||
|
self.use_ssl = os.getenv('SMTP_USE_SSL', 'false').lower() == 'true'
|
||||||
|
self.auth_required = os.getenv('SMTP_AUTH_REQUIRED', 'false').lower() == 'true'
|
||||||
|
self.sender_email = os.getenv('SMTP_SENDER_EMAIL')
|
||||||
|
self.sender_password = os.getenv('SMTP_SENDER_PASSWORD', '')
|
||||||
|
|
||||||
|
# 設定 Jinja2 模板環境
|
||||||
|
template_dir = os.path.join(os.path.dirname(__file__), '..', 'templates', 'emails')
|
||||||
|
self.jinja_env = Environment(
|
||||||
|
loader=FileSystemLoader(template_dir),
|
||||||
|
autoescape=select_autoescape(['html', 'xml'])
|
||||||
|
)
|
||||||
|
|
||||||
|
def _create_smtp_connection(self):
|
||||||
|
"""建立 SMTP 連線"""
|
||||||
|
try:
|
||||||
|
if self.use_ssl:
|
||||||
|
server = smtplib.SMTP_SSL(self.smtp_server, self.smtp_port)
|
||||||
|
else:
|
||||||
|
server = smtplib.SMTP(self.smtp_server, self.smtp_port)
|
||||||
|
if self.use_tls:
|
||||||
|
server.starttls()
|
||||||
|
|
||||||
|
if self.auth_required and self.sender_password:
|
||||||
|
server.login(self.sender_email, self.sender_password)
|
||||||
|
|
||||||
|
return server
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"SMTP connection failed: {str(e)}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _send_email(self, to_email, subject, html_content, text_content=None):
|
||||||
|
"""發送郵件的基礎方法"""
|
||||||
|
try:
|
||||||
|
if not self.smtp_server or not self.sender_email:
|
||||||
|
logger.error("SMTP configuration incomplete")
|
||||||
|
return False
|
||||||
|
|
||||||
|
# 建立郵件
|
||||||
|
msg = MIMEMultipart('alternative')
|
||||||
|
msg['From'] = self.sender_email
|
||||||
|
msg['To'] = to_email
|
||||||
|
msg['Subject'] = subject
|
||||||
|
|
||||||
|
# 添加文本內容
|
||||||
|
if text_content:
|
||||||
|
text_part = MIMEText(text_content, 'plain', 'utf-8')
|
||||||
|
msg.attach(text_part)
|
||||||
|
|
||||||
|
# 添加 HTML 內容
|
||||||
|
html_part = MIMEText(html_content, 'html', 'utf-8')
|
||||||
|
msg.attach(html_part)
|
||||||
|
|
||||||
|
# 發送郵件
|
||||||
|
server = self._create_smtp_connection()
|
||||||
|
if not server:
|
||||||
|
return False
|
||||||
|
|
||||||
|
server.send_message(msg)
|
||||||
|
server.quit()
|
||||||
|
|
||||||
|
logger.info(f"Email sent successfully to {to_email}")
|
||||||
|
return True
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to send email to {to_email}: {str(e)}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _get_user_email(self, ad_account):
|
||||||
|
"""取得使用者郵件地址"""
|
||||||
|
user_info = get_user_info(ad_account)
|
||||||
|
if user_info and user_info.get('email'):
|
||||||
|
return user_info['email']
|
||||||
|
|
||||||
|
# 如果無法從 LDAP 取得,嘗試組合郵件地址
|
||||||
|
domain = os.getenv('LDAP_DOMAIN', 'panjit.com.tw')
|
||||||
|
return f"{ad_account}@{domain}"
|
||||||
|
|
||||||
|
def send_fire_email(self, todo, recipient, sender, custom_message=''):
|
||||||
|
"""發送緊急通知郵件"""
|
||||||
|
try:
|
||||||
|
recipient_email = self._get_user_email(recipient)
|
||||||
|
sender_info = get_user_info(sender)
|
||||||
|
sender_name = sender_info.get('displayName', sender) if sender_info else sender
|
||||||
|
|
||||||
|
# 準備模板資料
|
||||||
|
template_data = {
|
||||||
|
'todo': todo,
|
||||||
|
'recipient': recipient,
|
||||||
|
'sender': sender,
|
||||||
|
'sender_name': sender_name,
|
||||||
|
'custom_message': custom_message,
|
||||||
|
'timestamp': datetime.now().strftime('%Y-%m-%d %H:%M:%S'),
|
||||||
|
'app_name': current_app.config.get('APP_NAME', 'PANJIT Todo List')
|
||||||
|
}
|
||||||
|
|
||||||
|
# 渲染模板
|
||||||
|
template = self.jinja_env.get_template('fire_email.html')
|
||||||
|
html_content = template.render(**template_data)
|
||||||
|
|
||||||
|
# 主題
|
||||||
|
subject = f"🚨 緊急通知 - {todo.title}"
|
||||||
|
|
||||||
|
return self._send_email(recipient_email, subject, html_content)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Fire email failed for {recipient}: {str(e)}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def send_reminder_email(self, todo, recipient, reminder_type):
|
||||||
|
"""發送提醒郵件"""
|
||||||
|
try:
|
||||||
|
recipient_email = self._get_user_email(recipient)
|
||||||
|
|
||||||
|
# 根據提醒類型設定主題和模板
|
||||||
|
if reminder_type == 'due_tomorrow':
|
||||||
|
subject = f"📅 明日到期提醒 - {todo.title}"
|
||||||
|
template_name = 'reminder_due_tomorrow.html'
|
||||||
|
elif reminder_type == 'overdue':
|
||||||
|
subject = f"⚠️ 逾期提醒 - {todo.title}"
|
||||||
|
template_name = 'reminder_overdue.html'
|
||||||
|
else:
|
||||||
|
subject = f"📋 待辦提醒 - {todo.title}"
|
||||||
|
template_name = 'reminder_general.html'
|
||||||
|
|
||||||
|
# 準備模板資料
|
||||||
|
template_data = {
|
||||||
|
'todo': todo,
|
||||||
|
'recipient': recipient,
|
||||||
|
'reminder_type': reminder_type,
|
||||||
|
'timestamp': datetime.now().strftime('%Y-%m-%d %H:%M:%S'),
|
||||||
|
'app_name': current_app.config.get('APP_NAME', 'PANJIT Todo List')
|
||||||
|
}
|
||||||
|
|
||||||
|
# 渲染模板
|
||||||
|
template = self.jinja_env.get_template(template_name)
|
||||||
|
html_content = template.render(**template_data)
|
||||||
|
|
||||||
|
return self._send_email(recipient_email, subject, html_content)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Reminder email failed for {recipient}: {str(e)}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def send_digest_email(self, recipient, digest_data):
|
||||||
|
"""發送摘要郵件"""
|
||||||
|
try:
|
||||||
|
recipient_email = self._get_user_email(recipient)
|
||||||
|
|
||||||
|
# 根據摘要類型設定主題
|
||||||
|
digest_type = digest_data.get('type', 'weekly')
|
||||||
|
type_names = {
|
||||||
|
'daily': '每日',
|
||||||
|
'weekly': '每週',
|
||||||
|
'monthly': '每月'
|
||||||
|
}
|
||||||
|
subject = f"📊 {type_names.get(digest_type, '定期')}摘要報告"
|
||||||
|
|
||||||
|
# 準備模板資料
|
||||||
|
template_data = {
|
||||||
|
'recipient': recipient,
|
||||||
|
'digest_data': digest_data,
|
||||||
|
'timestamp': datetime.now().strftime('%Y-%m-%d %H:%M:%S'),
|
||||||
|
'app_name': current_app.config.get('APP_NAME', 'PANJIT Todo List')
|
||||||
|
}
|
||||||
|
|
||||||
|
# 渲染模板
|
||||||
|
template = self.jinja_env.get_template('digest.html')
|
||||||
|
html_content = template.render(**template_data)
|
||||||
|
|
||||||
|
return self._send_email(recipient_email, subject, html_content)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Digest email failed for {recipient}: {str(e)}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def send_todo_notification(self, todo, recipients, action, actor):
|
||||||
|
"""發送待辦事項變更通知"""
|
||||||
|
try:
|
||||||
|
success_count = 0
|
||||||
|
|
||||||
|
for recipient in recipients:
|
||||||
|
try:
|
||||||
|
recipient_email = self._get_user_email(recipient)
|
||||||
|
actor_info = get_user_info(actor)
|
||||||
|
actor_name = actor_info.get('displayName', actor) if actor_info else actor
|
||||||
|
|
||||||
|
# 根據動作類型設定主題和模板
|
||||||
|
action_names = {
|
||||||
|
'CREATE': '建立',
|
||||||
|
'UPDATE': '更新',
|
||||||
|
'DELETE': '刪除',
|
||||||
|
'ASSIGN': '指派',
|
||||||
|
'COMPLETE': '完成'
|
||||||
|
}
|
||||||
|
|
||||||
|
action_name = action_names.get(action, action)
|
||||||
|
subject = f"📋 待辦事項{action_name} - {todo.title}"
|
||||||
|
|
||||||
|
# 準備模板資料
|
||||||
|
template_data = {
|
||||||
|
'todo': todo,
|
||||||
|
'recipient': recipient,
|
||||||
|
'action': action,
|
||||||
|
'action_name': action_name,
|
||||||
|
'actor': actor,
|
||||||
|
'actor_name': actor_name,
|
||||||
|
'timestamp': datetime.now().strftime('%Y-%m-%d %H:%M:%S'),
|
||||||
|
'app_name': current_app.config.get('APP_NAME', 'PANJIT Todo List')
|
||||||
|
}
|
||||||
|
|
||||||
|
# 渲染模板
|
||||||
|
template = self.jinja_env.get_template('todo_notification.html')
|
||||||
|
html_content = template.render(**template_data)
|
||||||
|
|
||||||
|
if self._send_email(recipient_email, subject, html_content):
|
||||||
|
success_count += 1
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Todo notification failed for {recipient}: {str(e)}")
|
||||||
|
|
||||||
|
return success_count
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Todo notification batch failed: {str(e)}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
def send_test_email(self, recipient):
|
||||||
|
"""發送測試郵件"""
|
||||||
|
try:
|
||||||
|
recipient_email = self._get_user_email(recipient)
|
||||||
|
|
||||||
|
subject = "✅ 郵件服務測試"
|
||||||
|
html_content = f"""
|
||||||
|
<html>
|
||||||
|
<body>
|
||||||
|
<h2>郵件服務測試</h2>
|
||||||
|
<p>您好 {recipient},</p>
|
||||||
|
<p>這是一封測試郵件,用於驗證 PANJIT Todo List 系統的郵件功能是否正常運作。</p>
|
||||||
|
<p>如果您收到這封郵件,表示郵件服務配置正確。</p>
|
||||||
|
<br>
|
||||||
|
<p>測試時間: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}</p>
|
||||||
|
<p>此郵件由系統自動發送,請勿回覆。</p>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
"""
|
||||||
|
|
||||||
|
return self._send_email(recipient_email, subject, html_content)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Test email failed for {recipient}: {str(e)}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def send_test_email_direct(self, recipient_email):
|
||||||
|
"""直接發送測試郵件到指定郵件地址"""
|
||||||
|
try:
|
||||||
|
subject = "✅ PANJIT Todo List 郵件服務測試"
|
||||||
|
html_content = f"""
|
||||||
|
<html>
|
||||||
|
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333;">
|
||||||
|
<div style="max-width: 600px; margin: 0 auto; padding: 20px;">
|
||||||
|
<h2 style="color: #2563eb; border-bottom: 2px solid #e5e7eb; padding-bottom: 10px;">📧 郵件服務測試</h2>
|
||||||
|
<p>您好!</p>
|
||||||
|
<p>這是一封來自 <strong>PANJIT Todo List 系統</strong> 的測試郵件,用於驗證郵件服務功能是否正常運作。</p>
|
||||||
|
|
||||||
|
<div style="background-color: #f0f9ff; border-left: 4px solid #2563eb; padding: 15px; margin: 20px 0;">
|
||||||
|
<p style="margin: 0;"><strong>✅ 如果您收到這封郵件,表示:</strong></p>
|
||||||
|
<ul style="margin: 10px 0; padding-left: 20px;">
|
||||||
|
<li>SMTP 服務器連線正常</li>
|
||||||
|
<li>郵件發送功能運作良好</li>
|
||||||
|
<li>您的郵件地址設定正確</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr style="border: none; border-top: 1px solid #e5e7eb; margin: 30px 0;">
|
||||||
|
<p style="font-size: 14px; color: #6b7280;">
|
||||||
|
<strong>測試詳細資訊:</strong><br>
|
||||||
|
📅 測試時間: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}<br>
|
||||||
|
📧 收件人: {recipient_email}<br>
|
||||||
|
🏢 發件人: PANJIT Todo List 系統
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p style="font-size: 12px; color: #9ca3af; margin-top: 30px;">
|
||||||
|
此郵件由系統自動發送,請勿回覆。如有任何問題,請聯繫系統管理員。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
"""
|
||||||
|
|
||||||
|
return self._send_email(recipient_email, subject, html_content)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Direct test email failed for {recipient_email}: {str(e)}")
|
||||||
|
return False
|
||||||
266
backend/utils/ldap_utils.py
Normal file
266
backend/utils/ldap_utils.py
Normal file
@@ -0,0 +1,266 @@
|
|||||||
|
import time
|
||||||
|
from ldap3 import Server, Connection, SUBTREE, ALL_ATTRIBUTES
|
||||||
|
from flask import current_app
|
||||||
|
from utils.logger import get_logger
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
def create_ldap_connection(retries=3):
|
||||||
|
"""Create LDAP connection with retry mechanism"""
|
||||||
|
config = current_app.config
|
||||||
|
|
||||||
|
for attempt in range(retries):
|
||||||
|
try:
|
||||||
|
server = Server(
|
||||||
|
config['LDAP_SERVER'],
|
||||||
|
port=config['LDAP_PORT'],
|
||||||
|
use_ssl=config['LDAP_USE_SSL'],
|
||||||
|
get_info=ALL_ATTRIBUTES
|
||||||
|
)
|
||||||
|
|
||||||
|
conn = Connection(
|
||||||
|
server,
|
||||||
|
user=config['LDAP_BIND_USER_DN'],
|
||||||
|
password=config['LDAP_BIND_USER_PASSWORD'],
|
||||||
|
auto_bind=True,
|
||||||
|
raise_exceptions=True
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info("LDAP connection established successfully")
|
||||||
|
return conn
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"LDAP connection attempt {attempt + 1} failed: {str(e)}")
|
||||||
|
if attempt == retries - 1:
|
||||||
|
raise
|
||||||
|
time.sleep(1)
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
def authenticate_user(username, password):
|
||||||
|
"""Authenticate user against LDAP/AD"""
|
||||||
|
try:
|
||||||
|
conn = create_ldap_connection()
|
||||||
|
if not conn:
|
||||||
|
return None
|
||||||
|
|
||||||
|
config = current_app.config
|
||||||
|
search_filter = f"(&(objectClass=person)(objectCategory=person)({config['LDAP_USER_LOGIN_ATTR']}={username}))"
|
||||||
|
|
||||||
|
# Search for user
|
||||||
|
conn.search(
|
||||||
|
config['LDAP_SEARCH_BASE'],
|
||||||
|
search_filter,
|
||||||
|
SUBTREE,
|
||||||
|
attributes=['displayName', 'mail', 'sAMAccountName', 'userPrincipalName']
|
||||||
|
)
|
||||||
|
|
||||||
|
if not conn.entries:
|
||||||
|
logger.warning(f"User not found: {username}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
user_entry = conn.entries[0]
|
||||||
|
user_dn = user_entry.entry_dn
|
||||||
|
|
||||||
|
# Try to bind with user credentials
|
||||||
|
try:
|
||||||
|
user_conn = Connection(
|
||||||
|
conn.server,
|
||||||
|
user=user_dn,
|
||||||
|
password=password,
|
||||||
|
auto_bind=True,
|
||||||
|
raise_exceptions=True
|
||||||
|
)
|
||||||
|
user_conn.unbind()
|
||||||
|
|
||||||
|
# Return user info
|
||||||
|
user_info = {
|
||||||
|
'ad_account': str(user_entry.sAMAccountName) if user_entry.sAMAccountName else username,
|
||||||
|
'display_name': str(user_entry.displayName) if user_entry.displayName else username,
|
||||||
|
'email': str(user_entry.mail) if user_entry.mail else '',
|
||||||
|
'user_principal_name': str(user_entry.userPrincipalName) if user_entry.userPrincipalName else username
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(f"User authenticated successfully: {username}")
|
||||||
|
return user_info
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Authentication failed for user {username}: {str(e)}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"LDAP authentication error: {str(e)}")
|
||||||
|
return None
|
||||||
|
finally:
|
||||||
|
if conn:
|
||||||
|
conn.unbind()
|
||||||
|
|
||||||
|
def search_ldap_principals(search_term, limit=20):
|
||||||
|
"""Search for LDAP users and groups"""
|
||||||
|
try:
|
||||||
|
conn = create_ldap_connection()
|
||||||
|
if not conn:
|
||||||
|
return []
|
||||||
|
|
||||||
|
config = current_app.config
|
||||||
|
|
||||||
|
# Build search filter for active users
|
||||||
|
search_filter = f"""(&
|
||||||
|
(objectClass=person)
|
||||||
|
(objectCategory=person)
|
||||||
|
(!(userAccountControl:1.2.840.113556.1.4.803:=2))
|
||||||
|
(|
|
||||||
|
(displayName=*{search_term}*)
|
||||||
|
(mail=*{search_term}*)
|
||||||
|
(sAMAccountName=*{search_term}*)
|
||||||
|
(userPrincipalName=*{search_term}*)
|
||||||
|
)
|
||||||
|
)"""
|
||||||
|
|
||||||
|
# Remove extra whitespace
|
||||||
|
search_filter = ' '.join(search_filter.split())
|
||||||
|
|
||||||
|
conn.search(
|
||||||
|
config['LDAP_SEARCH_BASE'],
|
||||||
|
search_filter,
|
||||||
|
SUBTREE,
|
||||||
|
attributes=['sAMAccountName', 'displayName', 'mail'],
|
||||||
|
size_limit=limit
|
||||||
|
)
|
||||||
|
|
||||||
|
results = []
|
||||||
|
for entry in conn.entries:
|
||||||
|
results.append({
|
||||||
|
'ad_account': str(entry.sAMAccountName) if entry.sAMAccountName else '',
|
||||||
|
'display_name': str(entry.displayName) if entry.displayName else '',
|
||||||
|
'email': str(entry.mail) if entry.mail else ''
|
||||||
|
})
|
||||||
|
|
||||||
|
logger.info(f"LDAP search found {len(results)} results for term: {search_term}")
|
||||||
|
return results
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"LDAP search error: {str(e)}")
|
||||||
|
return []
|
||||||
|
finally:
|
||||||
|
if conn:
|
||||||
|
conn.unbind()
|
||||||
|
|
||||||
|
def get_user_info(ad_account):
|
||||||
|
"""Get user information from LDAP"""
|
||||||
|
try:
|
||||||
|
conn = create_ldap_connection()
|
||||||
|
if not conn:
|
||||||
|
return None
|
||||||
|
|
||||||
|
config = current_app.config
|
||||||
|
|
||||||
|
# 支援 sAMAccountName 和 userPrincipalName 格式
|
||||||
|
if '@' in ad_account:
|
||||||
|
# Email 格式,使用 userPrincipalName 或 mail 搜尋
|
||||||
|
search_filter = f"""(&
|
||||||
|
(objectClass=person)
|
||||||
|
(|
|
||||||
|
(userPrincipalName={ad_account})
|
||||||
|
(mail={ad_account})
|
||||||
|
)
|
||||||
|
)"""
|
||||||
|
else:
|
||||||
|
# 純帳號名稱,使用 sAMAccountName 搜尋
|
||||||
|
search_filter = f"(&(objectClass=person)(sAMAccountName={ad_account}))"
|
||||||
|
|
||||||
|
# 移除多餘的空白
|
||||||
|
search_filter = ' '.join(search_filter.split())
|
||||||
|
|
||||||
|
conn.search(
|
||||||
|
config['LDAP_SEARCH_BASE'],
|
||||||
|
search_filter,
|
||||||
|
SUBTREE,
|
||||||
|
attributes=['displayName', 'mail', 'sAMAccountName', 'userPrincipalName']
|
||||||
|
)
|
||||||
|
|
||||||
|
if not conn.entries:
|
||||||
|
return None
|
||||||
|
|
||||||
|
entry = conn.entries[0]
|
||||||
|
return {
|
||||||
|
'ad_account': str(entry.sAMAccountName) if entry.sAMAccountName else ad_account,
|
||||||
|
'display_name': str(entry.displayName) if entry.displayName else ad_account,
|
||||||
|
'email': str(entry.mail) if entry.mail else '',
|
||||||
|
'user_principal_name': str(entry.userPrincipalName) if entry.userPrincipalName else ''
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting user info for {ad_account}: {str(e)}")
|
||||||
|
return None
|
||||||
|
finally:
|
||||||
|
if conn:
|
||||||
|
conn.unbind()
|
||||||
|
|
||||||
|
def validate_ad_accounts(ad_accounts):
|
||||||
|
"""Validate multiple AD accounts exist"""
|
||||||
|
try:
|
||||||
|
conn = create_ldap_connection()
|
||||||
|
if not conn:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
config = current_app.config
|
||||||
|
valid_accounts = {}
|
||||||
|
|
||||||
|
for account in ad_accounts:
|
||||||
|
# 支援 sAMAccountName 和 userPrincipalName 格式
|
||||||
|
if '@' in account:
|
||||||
|
# Email 格式,使用 userPrincipalName 或 mail 搜尋
|
||||||
|
search_filter = f"""(&
|
||||||
|
(objectClass=person)
|
||||||
|
(|
|
||||||
|
(userPrincipalName={account})
|
||||||
|
(mail={account})
|
||||||
|
)
|
||||||
|
)"""
|
||||||
|
else:
|
||||||
|
# 純帳號名稱,使用 sAMAccountName 搜尋
|
||||||
|
search_filter = f"(&(objectClass=person)(sAMAccountName={account}))"
|
||||||
|
|
||||||
|
# 移除多餘的空白
|
||||||
|
search_filter = ' '.join(search_filter.split())
|
||||||
|
|
||||||
|
conn.search(
|
||||||
|
config['LDAP_SEARCH_BASE'],
|
||||||
|
search_filter,
|
||||||
|
SUBTREE,
|
||||||
|
attributes=['sAMAccountName', 'displayName', 'mail', 'userPrincipalName']
|
||||||
|
)
|
||||||
|
|
||||||
|
if conn.entries:
|
||||||
|
entry = conn.entries[0]
|
||||||
|
valid_accounts[account] = {
|
||||||
|
'ad_account': str(entry.sAMAccountName) if entry.sAMAccountName else account,
|
||||||
|
'display_name': str(entry.displayName) if entry.displayName else account,
|
||||||
|
'email': str(entry.mail) if entry.mail else '',
|
||||||
|
'user_principal_name': str(entry.userPrincipalName) if entry.userPrincipalName else ''
|
||||||
|
}
|
||||||
|
logger.info(f"Validated AD account: {account} -> {entry.sAMAccountName}")
|
||||||
|
else:
|
||||||
|
logger.warning(f"AD account not found: {account}")
|
||||||
|
|
||||||
|
return valid_accounts
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error validating AD accounts: {str(e)}")
|
||||||
|
return {}
|
||||||
|
finally:
|
||||||
|
if conn:
|
||||||
|
conn.unbind()
|
||||||
|
|
||||||
|
def test_ldap_connection():
|
||||||
|
"""Test LDAP connection for health check"""
|
||||||
|
try:
|
||||||
|
conn = create_ldap_connection(retries=1)
|
||||||
|
if conn:
|
||||||
|
conn.unbind()
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"LDAP connection test failed: {str(e)}")
|
||||||
|
return False
|
||||||
59
backend/utils/logger.py
Normal file
59
backend/utils/logger.py
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import os
|
||||||
|
import logging
|
||||||
|
from logging.handlers import RotatingFileHandler
|
||||||
|
from colorlog import ColoredFormatter
|
||||||
|
|
||||||
|
def setup_logger(app):
|
||||||
|
"""Setup application logging"""
|
||||||
|
|
||||||
|
# Create logs directory if it doesn't exist
|
||||||
|
log_dir = 'logs'
|
||||||
|
if not os.path.exists(log_dir):
|
||||||
|
os.makedirs(log_dir)
|
||||||
|
|
||||||
|
log_file = app.config.get('LOG_FILE', 'logs/app.log')
|
||||||
|
log_level = app.config.get('LOG_LEVEL', 'INFO')
|
||||||
|
|
||||||
|
# Set up file handler
|
||||||
|
file_handler = RotatingFileHandler(
|
||||||
|
log_file,
|
||||||
|
maxBytes=10485760, # 10MB
|
||||||
|
backupCount=10
|
||||||
|
)
|
||||||
|
file_handler.setLevel(getattr(logging, log_level))
|
||||||
|
|
||||||
|
# File formatter
|
||||||
|
file_formatter = logging.Formatter(
|
||||||
|
'%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
||||||
|
)
|
||||||
|
file_handler.setFormatter(file_formatter)
|
||||||
|
|
||||||
|
# Console handler with colors
|
||||||
|
console_handler = logging.StreamHandler()
|
||||||
|
console_handler.setLevel(getattr(logging, log_level))
|
||||||
|
|
||||||
|
# Console formatter with colors
|
||||||
|
console_formatter = ColoredFormatter(
|
||||||
|
'%(log_color)s%(asctime)s - %(name)s - %(levelname)s - %(message)s%(reset)s',
|
||||||
|
log_colors={
|
||||||
|
'DEBUG': 'cyan',
|
||||||
|
'INFO': 'green',
|
||||||
|
'WARNING': 'yellow',
|
||||||
|
'ERROR': 'red',
|
||||||
|
'CRITICAL': 'red,bg_white',
|
||||||
|
}
|
||||||
|
)
|
||||||
|
console_handler.setFormatter(console_formatter)
|
||||||
|
|
||||||
|
# Add handlers to app logger
|
||||||
|
app.logger.addHandler(file_handler)
|
||||||
|
app.logger.addHandler(console_handler)
|
||||||
|
app.logger.setLevel(getattr(logging, log_level))
|
||||||
|
|
||||||
|
# Log startup
|
||||||
|
env_mode = os.environ.get('FLASK_ENV', 'development')
|
||||||
|
app.logger.info(f"Application started in {env_mode} mode")
|
||||||
|
|
||||||
|
def get_logger(name):
|
||||||
|
"""Get a logger instance"""
|
||||||
|
return logging.getLogger(name)
|
||||||
140
backend/utils/mock_ldap.py
Normal file
140
backend/utils/mock_ldap.py
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
"""
|
||||||
|
Mock LDAP for development/testing purposes
|
||||||
|
當無法連接到實際LDAP時使用
|
||||||
|
"""
|
||||||
|
|
||||||
|
from utils.logger import get_logger
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
def authenticate_user(username, password):
|
||||||
|
"""Mock authentication for development"""
|
||||||
|
logger.info(f"Mock LDAP: Authenticating user {username}")
|
||||||
|
|
||||||
|
# 簡單的開發用驗證
|
||||||
|
if not username or not password:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# 模擬用戶資料
|
||||||
|
mock_users = {
|
||||||
|
'admin': {
|
||||||
|
'ad_account': 'admin',
|
||||||
|
'display_name': '系統管理員',
|
||||||
|
'email': 'admin@panjit.com.tw'
|
||||||
|
},
|
||||||
|
'test': {
|
||||||
|
'ad_account': 'test',
|
||||||
|
'display_name': '測試使用者',
|
||||||
|
'email': 'test@panjit.com.tw'
|
||||||
|
},
|
||||||
|
'user1': {
|
||||||
|
'ad_account': 'user1',
|
||||||
|
'display_name': '使用者一',
|
||||||
|
'email': 'user1@panjit.com.tw'
|
||||||
|
},
|
||||||
|
'ymirliu@panjit.com.tw': {
|
||||||
|
'ad_account': '92367',
|
||||||
|
'display_name': 'ymirliu 陸一銘',
|
||||||
|
'email': 'ymirliu@panjit.com.tw'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if username.lower() in mock_users:
|
||||||
|
logger.info(f"Mock LDAP: User {username} authenticated successfully")
|
||||||
|
return mock_users[username.lower()]
|
||||||
|
|
||||||
|
logger.warning(f"Mock LDAP: User {username} not found")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def search_ldap_principals(search_term, limit=20):
|
||||||
|
"""Mock LDAP search"""
|
||||||
|
logger.info(f"Mock LDAP: Searching for '{search_term}'")
|
||||||
|
|
||||||
|
mock_results = [
|
||||||
|
{
|
||||||
|
'ad_account': 'admin',
|
||||||
|
'display_name': '系統管理員',
|
||||||
|
'email': 'admin@panjit.com.tw'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'ad_account': 'test',
|
||||||
|
'display_name': '測試使用者',
|
||||||
|
'email': 'test@panjit.com.tw'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'ad_account': 'user1',
|
||||||
|
'display_name': '使用者一',
|
||||||
|
'email': 'user1@panjit.com.tw'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'ad_account': 'user2',
|
||||||
|
'display_name': '使用者二',
|
||||||
|
'email': 'user2@panjit.com.tw'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
# 簡單的搜尋過濾
|
||||||
|
if search_term:
|
||||||
|
results = []
|
||||||
|
for user in mock_results:
|
||||||
|
if (search_term.lower() in user['ad_account'].lower() or
|
||||||
|
search_term.lower() in user['display_name'].lower() or
|
||||||
|
search_term.lower() in user['email'].lower()):
|
||||||
|
results.append(user)
|
||||||
|
return results[:limit]
|
||||||
|
|
||||||
|
return mock_results[:limit]
|
||||||
|
|
||||||
|
def get_user_info(ad_account):
|
||||||
|
"""Mock get user info"""
|
||||||
|
mock_users = {
|
||||||
|
'admin': {
|
||||||
|
'ad_account': 'admin',
|
||||||
|
'display_name': '系統管理員',
|
||||||
|
'email': 'admin@panjit.com.tw'
|
||||||
|
},
|
||||||
|
'test': {
|
||||||
|
'ad_account': 'test',
|
||||||
|
'display_name': '測試使用者',
|
||||||
|
'email': 'test@panjit.com.tw'
|
||||||
|
},
|
||||||
|
'user1': {
|
||||||
|
'ad_account': 'user1',
|
||||||
|
'display_name': '使用者一',
|
||||||
|
'email': 'user1@panjit.com.tw'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return mock_users.get(ad_account.lower())
|
||||||
|
|
||||||
|
def validate_ad_accounts(ad_accounts):
|
||||||
|
"""Mock validate AD accounts"""
|
||||||
|
mock_users = {
|
||||||
|
'admin': {
|
||||||
|
'ad_account': 'admin',
|
||||||
|
'display_name': '系統管理員',
|
||||||
|
'email': 'admin@panjit.com.tw'
|
||||||
|
},
|
||||||
|
'test': {
|
||||||
|
'ad_account': 'test',
|
||||||
|
'display_name': '測試使用者',
|
||||||
|
'email': 'test@panjit.com.tw'
|
||||||
|
},
|
||||||
|
'user1': {
|
||||||
|
'ad_account': 'user1',
|
||||||
|
'display_name': '使用者一',
|
||||||
|
'email': 'user1@panjit.com.tw'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
valid_accounts = {}
|
||||||
|
for account in ad_accounts:
|
||||||
|
if account.lower() in mock_users:
|
||||||
|
valid_accounts[account] = mock_users[account.lower()]
|
||||||
|
|
||||||
|
return valid_accounts
|
||||||
|
|
||||||
|
def test_ldap_connection():
|
||||||
|
"""Mock LDAP connection test"""
|
||||||
|
logger.info("Mock LDAP: Connection test - always returns True")
|
||||||
|
return True
|
||||||
225
backend/utils/notification_service.py
Normal file
225
backend/utils/notification_service.py
Normal file
@@ -0,0 +1,225 @@
|
|||||||
|
"""
|
||||||
|
Notification Service
|
||||||
|
處理通知邏輯和摘要資料準備
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import datetime, date, timedelta
|
||||||
|
from sqlalchemy import and_, or_, func
|
||||||
|
from models import (
|
||||||
|
db, TodoItem, TodoItemResponsible, TodoItemFollower,
|
||||||
|
TodoUserPref, TodoAuditLog
|
||||||
|
)
|
||||||
|
from utils.logger import get_logger
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
class NotificationService:
|
||||||
|
"""通知服務類別"""
|
||||||
|
|
||||||
|
def get_notification_recipients(self, todo):
|
||||||
|
"""取得待辦事項的通知收件人清單"""
|
||||||
|
recipients = set()
|
||||||
|
|
||||||
|
# 加入建立者(如果啟用通知)
|
||||||
|
creator_pref = TodoUserPref.query.filter_by(ad_account=todo.creator_ad).first()
|
||||||
|
if creator_pref and creator_pref.notification_enabled:
|
||||||
|
recipients.add(todo.creator_ad)
|
||||||
|
|
||||||
|
# 加入負責人(如果啟用通知)
|
||||||
|
for responsible in todo.responsible_users:
|
||||||
|
user_pref = TodoUserPref.query.filter_by(ad_account=responsible.ad_account).first()
|
||||||
|
if user_pref and user_pref.notification_enabled:
|
||||||
|
recipients.add(responsible.ad_account)
|
||||||
|
|
||||||
|
# 加入追蹤人(如果啟用通知)
|
||||||
|
for follower in todo.followers:
|
||||||
|
user_pref = TodoUserPref.query.filter_by(ad_account=follower.ad_account).first()
|
||||||
|
if user_pref and user_pref.notification_enabled:
|
||||||
|
recipients.add(follower.ad_account)
|
||||||
|
|
||||||
|
return list(recipients)
|
||||||
|
|
||||||
|
def prepare_digest(self, user_ad, digest_type='weekly'):
|
||||||
|
"""準備摘要資料"""
|
||||||
|
try:
|
||||||
|
# 計算日期範圍
|
||||||
|
today = date.today()
|
||||||
|
|
||||||
|
if digest_type == 'daily':
|
||||||
|
start_date = today
|
||||||
|
end_date = today
|
||||||
|
period_name = '今日'
|
||||||
|
elif digest_type == 'weekly':
|
||||||
|
start_date = today - timedelta(days=today.weekday()) # 週一
|
||||||
|
end_date = start_date + timedelta(days=6) # 週日
|
||||||
|
period_name = '本週'
|
||||||
|
elif digest_type == 'monthly':
|
||||||
|
start_date = today.replace(day=1)
|
||||||
|
next_month = today.replace(day=28) + timedelta(days=4)
|
||||||
|
end_date = next_month - timedelta(days=next_month.day)
|
||||||
|
period_name = '本月'
|
||||||
|
else:
|
||||||
|
raise ValueError(f"Unsupported digest type: {digest_type}")
|
||||||
|
|
||||||
|
# 基礎查詢 - 使用者相關的待辦事項
|
||||||
|
base_query = TodoItem.query.filter(
|
||||||
|
or_(
|
||||||
|
TodoItem.creator_ad == user_ad,
|
||||||
|
TodoItem.responsible_users.any(TodoItemResponsible.ad_account == user_ad),
|
||||||
|
TodoItem.followers.any(TodoItemFollower.ad_account == user_ad)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# 統計資料
|
||||||
|
stats = {
|
||||||
|
'total_todos': base_query.count(),
|
||||||
|
'completed_todos': base_query.filter(TodoItem.status == 'DONE').count(),
|
||||||
|
'doing_todos': base_query.filter(TodoItem.status == 'DOING').count(),
|
||||||
|
'blocked_todos': base_query.filter(TodoItem.status == 'BLOCKED').count(),
|
||||||
|
'new_todos': base_query.filter(TodoItem.status == 'NEW').count()
|
||||||
|
}
|
||||||
|
|
||||||
|
# 期間內完成的待辦事項
|
||||||
|
completed_in_period = base_query.filter(
|
||||||
|
and_(
|
||||||
|
TodoItem.status == 'DONE',
|
||||||
|
func.date(TodoItem.completed_at).between(start_date, end_date)
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
|
||||||
|
# 期間內建立的待辦事項
|
||||||
|
created_in_period = base_query.filter(
|
||||||
|
func.date(TodoItem.created_at).between(start_date, end_date)
|
||||||
|
).all()
|
||||||
|
|
||||||
|
# 即將到期的待辦事項(未來7天)
|
||||||
|
upcoming_due = base_query.filter(
|
||||||
|
and_(
|
||||||
|
TodoItem.due_date.between(today, today + timedelta(days=7)),
|
||||||
|
TodoItem.status != 'DONE'
|
||||||
|
)
|
||||||
|
).order_by(TodoItem.due_date).all()
|
||||||
|
|
||||||
|
# 逾期的待辦事項
|
||||||
|
overdue = base_query.filter(
|
||||||
|
and_(
|
||||||
|
TodoItem.due_date < today,
|
||||||
|
TodoItem.status != 'DONE'
|
||||||
|
)
|
||||||
|
).order_by(TodoItem.due_date).all()
|
||||||
|
|
||||||
|
# 高優先級待辦事項
|
||||||
|
high_priority = base_query.filter(
|
||||||
|
and_(
|
||||||
|
TodoItem.priority == 'HIGH',
|
||||||
|
TodoItem.status != 'DONE'
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
|
||||||
|
# 活動記錄(期間內的操作)
|
||||||
|
activities = TodoAuditLog.query.filter(
|
||||||
|
and_(
|
||||||
|
TodoAuditLog.actor_ad == user_ad,
|
||||||
|
func.date(TodoAuditLog.created_at).between(start_date, end_date)
|
||||||
|
)
|
||||||
|
).order_by(TodoAuditLog.created_at.desc()).limit(10).all()
|
||||||
|
|
||||||
|
# 組織摘要資料
|
||||||
|
digest_data = {
|
||||||
|
'type': digest_type,
|
||||||
|
'period_name': period_name,
|
||||||
|
'start_date': start_date,
|
||||||
|
'end_date': end_date,
|
||||||
|
'user_ad': user_ad,
|
||||||
|
'stats': stats,
|
||||||
|
'completed_in_period': [todo.to_dict() for todo in completed_in_period],
|
||||||
|
'created_in_period': [todo.to_dict() for todo in created_in_period],
|
||||||
|
'upcoming_due': [todo.to_dict() for todo in upcoming_due],
|
||||||
|
'overdue': [todo.to_dict() for todo in overdue],
|
||||||
|
'high_priority': [todo.to_dict() for todo in high_priority],
|
||||||
|
'recent_activities': [
|
||||||
|
{
|
||||||
|
'action': activity.action,
|
||||||
|
'created_at': activity.created_at,
|
||||||
|
'detail': activity.detail,
|
||||||
|
'todo_id': activity.todo_id
|
||||||
|
}
|
||||||
|
for activity in activities
|
||||||
|
],
|
||||||
|
'generated_at': datetime.now()
|
||||||
|
}
|
||||||
|
|
||||||
|
return digest_data
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to prepare digest for {user_ad}: {str(e)}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
def should_send_notification(self, user_ad, notification_type):
|
||||||
|
"""檢查是否應該發送通知"""
|
||||||
|
try:
|
||||||
|
user_pref = TodoUserPref.query.filter_by(ad_account=user_ad).first()
|
||||||
|
if not user_pref:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# 檢查通知開關
|
||||||
|
if notification_type == 'email_reminder':
|
||||||
|
return user_pref.email_reminder_enabled
|
||||||
|
elif notification_type == 'weekly_summary':
|
||||||
|
return user_pref.weekly_summary_enabled
|
||||||
|
elif notification_type == 'general':
|
||||||
|
return user_pref.notification_enabled
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error checking notification settings for {user_ad}: {str(e)}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def get_users_for_batch_notifications(self, notification_type):
|
||||||
|
"""取得需要接收批量通知的使用者清單"""
|
||||||
|
try:
|
||||||
|
if notification_type == 'weekly_summary':
|
||||||
|
users = db.session.query(TodoUserPref.ad_account).filter(
|
||||||
|
TodoUserPref.weekly_summary_enabled == True
|
||||||
|
).all()
|
||||||
|
elif notification_type == 'email_reminder':
|
||||||
|
users = db.session.query(TodoUserPref.ad_account).filter(
|
||||||
|
TodoUserPref.email_reminder_enabled == True
|
||||||
|
).all()
|
||||||
|
else:
|
||||||
|
users = db.session.query(TodoUserPref.ad_account).filter(
|
||||||
|
TodoUserPref.notification_enabled == True
|
||||||
|
).all()
|
||||||
|
|
||||||
|
return [user[0] for user in users]
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting users for batch notifications: {str(e)}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
def create_notification_summary(self, todos, notification_type):
|
||||||
|
"""建立通知摘要"""
|
||||||
|
try:
|
||||||
|
if notification_type == 'due_tomorrow':
|
||||||
|
return {
|
||||||
|
'title': '明日到期提醒',
|
||||||
|
'description': f'您有 {len(todos)} 項待辦事項將於明日到期',
|
||||||
|
'todos': [todo.to_dict() for todo in todos]
|
||||||
|
}
|
||||||
|
elif notification_type == 'overdue':
|
||||||
|
return {
|
||||||
|
'title': '逾期提醒',
|
||||||
|
'description': f'您有 {len(todos)} 項待辦事項已逾期',
|
||||||
|
'todos': [todo.to_dict() for todo in todos]
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
return {
|
||||||
|
'title': '待辦事項提醒',
|
||||||
|
'description': f'您有 {len(todos)} 項待辦事項需要關注',
|
||||||
|
'todos': [todo.to_dict() for todo in todos]
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error creating notification summary: {str(e)}")
|
||||||
|
return None
|
||||||
33
deploy.bat
Normal file
33
deploy.bat
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
@echo off
|
||||||
|
echo ========================================
|
||||||
|
echo TodoList 部署腳本
|
||||||
|
echo ========================================
|
||||||
|
|
||||||
|
echo 正在停止現有容器...
|
||||||
|
docker-compose down
|
||||||
|
|
||||||
|
echo 正在清理舊的映像檔...
|
||||||
|
docker image prune -f
|
||||||
|
|
||||||
|
echo 正在建構新的單一容器映像檔...
|
||||||
|
docker-compose build --no-cache
|
||||||
|
|
||||||
|
echo 正在啟動單一容器服務...
|
||||||
|
docker-compose up -d
|
||||||
|
|
||||||
|
echo 等待服務啟動...
|
||||||
|
timeout /t 10
|
||||||
|
|
||||||
|
echo 檢查服務狀態...
|
||||||
|
docker-compose ps
|
||||||
|
|
||||||
|
echo 檢查健康狀態...
|
||||||
|
docker-compose logs --tail=20 todolist-app
|
||||||
|
|
||||||
|
echo ========================================
|
||||||
|
echo 部署完成!
|
||||||
|
echo 應用程式現在運行在: http://localhost:12011
|
||||||
|
echo 檢查日誌: docker-compose logs -f
|
||||||
|
echo 停止服務: docker-compose down
|
||||||
|
echo ========================================
|
||||||
|
pause
|
||||||
33
deploy.sh
Normal file
33
deploy.sh
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
echo "========================================"
|
||||||
|
echo " TodoList 部署腳本"
|
||||||
|
echo "========================================"
|
||||||
|
|
||||||
|
echo "正在停止現有容器..."
|
||||||
|
docker-compose down
|
||||||
|
|
||||||
|
echo "正在清理舊的映像檔..."
|
||||||
|
docker image prune -f
|
||||||
|
|
||||||
|
echo "正在建構新的單一容器映像檔..."
|
||||||
|
docker-compose build --no-cache
|
||||||
|
|
||||||
|
echo "正在啟動單一容器服務..."
|
||||||
|
docker-compose up -d
|
||||||
|
|
||||||
|
echo "等待服務啟動..."
|
||||||
|
sleep 10
|
||||||
|
|
||||||
|
echo "檢查服務狀態..."
|
||||||
|
docker-compose ps
|
||||||
|
|
||||||
|
echo "檢查健康狀態..."
|
||||||
|
docker-compose logs --tail=20 todolist-app
|
||||||
|
|
||||||
|
echo "========================================"
|
||||||
|
echo "部署完成!"
|
||||||
|
echo "應用程式現在運行在: http://localhost:12011"
|
||||||
|
echo "檢查日誌: docker-compose logs -f"
|
||||||
|
echo "停止服務: docker-compose down"
|
||||||
|
echo "========================================"
|
||||||
67
docker-compose.yml
Normal file
67
docker-compose.yml
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
services:
|
||||||
|
todolist-app:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
container_name: todolist-single-prod
|
||||||
|
ports:
|
||||||
|
- "12011:12011"
|
||||||
|
environment:
|
||||||
|
# MySQL Database Configuration
|
||||||
|
- DATABASE_URL=mysql+pymysql://A060:WLeSCi0yhtc7@mysql.theaken.com:33306/db_A060
|
||||||
|
- MYSQL_HOST=mysql.theaken.com
|
||||||
|
- MYSQL_PORT=33306
|
||||||
|
- MYSQL_USER=A060
|
||||||
|
- MYSQL_PASSWORD=WLeSCi0yhtc7
|
||||||
|
- MYSQL_DATABASE=db_A060
|
||||||
|
- MYSQL_CHARSET=utf8mb4
|
||||||
|
|
||||||
|
# CORS Configuration (allow both localhost and 127.0.0.1)
|
||||||
|
- CORS_ORIGINS=http://localhost:12011,http://127.0.0.1:12011
|
||||||
|
|
||||||
|
# LDAP Configuration (Production)
|
||||||
|
- USE_MOCK_LDAP=false
|
||||||
|
- LDAP_SERVER=panjit.com.tw
|
||||||
|
- LDAP_PORT=389
|
||||||
|
- LDAP_USE_SSL=false
|
||||||
|
- LDAP_BIND_USER_DN=CN=LdapBind,CN=Users,DC=PANJIT,DC=COM,DC=TW
|
||||||
|
- LDAP_BIND_USER_PASSWORD=panjit2481
|
||||||
|
- LDAP_SEARCH_BASE=OU=PANJIT,DC=panjit,DC=com,DC=tw
|
||||||
|
- LDAP_USER_LOGIN_ATTR=userPrincipalName
|
||||||
|
|
||||||
|
# SMTP Configuration
|
||||||
|
- SMTP_SERVER=mail.panjit.com.tw
|
||||||
|
- SMTP_PORT=25
|
||||||
|
- SMTP_USE_TLS=false
|
||||||
|
- SMTP_USE_SSL=false
|
||||||
|
- SMTP_AUTH_REQUIRED=false
|
||||||
|
- SMTP_SENDER_EMAIL=todo-system@panjit.com.tw
|
||||||
|
- SMTP_SENDER_PASSWORD=
|
||||||
|
|
||||||
|
# Flask Configuration
|
||||||
|
- FLASK_ENV=production
|
||||||
|
- SECRET_KEY=your-production-secret-key-change-me
|
||||||
|
- JWT_SECRET_KEY=your-jwt-secret-key-change-me
|
||||||
|
|
||||||
|
# Logging
|
||||||
|
- LOG_LEVEL=INFO
|
||||||
|
|
||||||
|
# Frontend API URL (now pointing to same container)
|
||||||
|
- NEXT_PUBLIC_API_URL=http://localhost:12011
|
||||||
|
restart: unless-stopped
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "curl", "-f", "http://localhost:12011/api/health/"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
start_period: 40s
|
||||||
|
networks:
|
||||||
|
- todolist-network
|
||||||
|
|
||||||
|
networks:
|
||||||
|
todolist-network:
|
||||||
|
driver: bridge
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
app-logs:
|
||||||
|
driver: local
|
||||||
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"]
|
||||||
|
}
|
||||||
66
manage.bat
Normal file
66
manage.bat
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
@echo off
|
||||||
|
echo ========================================
|
||||||
|
echo TodoList 管理腳本
|
||||||
|
echo ========================================
|
||||||
|
|
||||||
|
echo 選擇操作:
|
||||||
|
echo 1. 部署應用程式
|
||||||
|
echo 2. 停止服務
|
||||||
|
echo 3. 檢視服務狀態
|
||||||
|
echo 4. 檢視日誌
|
||||||
|
echo 5. 重啟服務
|
||||||
|
echo 6. 清理舊映像檔
|
||||||
|
echo 0. 退出
|
||||||
|
|
||||||
|
set /p choice="請輸入選項 (0-6): "
|
||||||
|
|
||||||
|
if "%choice%"=="1" goto deploy
|
||||||
|
if "%choice%"=="2" goto stop
|
||||||
|
if "%choice%"=="3" goto status
|
||||||
|
if "%choice%"=="4" goto logs
|
||||||
|
if "%choice%"=="5" goto restart
|
||||||
|
if "%choice%"=="6" goto cleanup
|
||||||
|
if "%choice%"=="0" goto exit
|
||||||
|
goto invalid
|
||||||
|
|
||||||
|
:deploy
|
||||||
|
echo 正在部署應用程式...
|
||||||
|
call deploy.bat
|
||||||
|
goto end
|
||||||
|
|
||||||
|
:stop
|
||||||
|
echo 正在停止服務...
|
||||||
|
docker-compose down
|
||||||
|
goto end
|
||||||
|
|
||||||
|
:status
|
||||||
|
echo 檢視服務狀態...
|
||||||
|
docker-compose ps
|
||||||
|
goto end
|
||||||
|
|
||||||
|
:logs
|
||||||
|
echo 檢視服務日誌...
|
||||||
|
docker-compose logs -f
|
||||||
|
goto end
|
||||||
|
|
||||||
|
:restart
|
||||||
|
echo 正在重啟服務...
|
||||||
|
docker-compose restart
|
||||||
|
goto end
|
||||||
|
|
||||||
|
:cleanup
|
||||||
|
echo 正在清理舊映像檔...
|
||||||
|
docker system prune -f
|
||||||
|
goto end
|
||||||
|
|
||||||
|
:invalid
|
||||||
|
echo 無效選項,請重新選擇
|
||||||
|
pause
|
||||||
|
goto start
|
||||||
|
|
||||||
|
:exit
|
||||||
|
echo 退出管理腳本
|
||||||
|
goto end
|
||||||
|
|
||||||
|
:end
|
||||||
|
pause
|
||||||
131
mysql/init/01-init.sql
Normal file
131
mysql/init/01-init.sql
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
-- Create database if not exists
|
||||||
|
CREATE DATABASE IF NOT EXISTS todo_system DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
USE todo_system;
|
||||||
|
|
||||||
|
-- Table: todo_item
|
||||||
|
CREATE TABLE IF NOT EXISTS todo_item (
|
||||||
|
id CHAR(36) PRIMARY KEY,
|
||||||
|
title VARCHAR(200) NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
status ENUM('NEW', 'DOING', 'BLOCKED', 'DONE') DEFAULT 'NEW',
|
||||||
|
priority ENUM('LOW', 'MEDIUM', 'HIGH', 'URGENT') DEFAULT 'MEDIUM',
|
||||||
|
due_date DATE,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
completed_at DATETIME,
|
||||||
|
creator_ad VARCHAR(128) NOT NULL,
|
||||||
|
creator_display_name VARCHAR(128),
|
||||||
|
creator_email VARCHAR(256),
|
||||||
|
starred TINYINT(1) DEFAULT 0,
|
||||||
|
INDEX idx_status (status),
|
||||||
|
INDEX idx_priority (priority),
|
||||||
|
INDEX idx_due_date (due_date),
|
||||||
|
INDEX idx_creator_ad (creator_ad),
|
||||||
|
INDEX idx_starred (starred),
|
||||||
|
INDEX idx_created_at (created_at)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- Table: todo_item_responsible
|
||||||
|
CREATE TABLE IF NOT EXISTS todo_item_responsible (
|
||||||
|
todo_id CHAR(36) NOT NULL,
|
||||||
|
ad_account VARCHAR(128) NOT NULL,
|
||||||
|
added_by VARCHAR(128),
|
||||||
|
added_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (todo_id, ad_account),
|
||||||
|
FOREIGN KEY (todo_id) REFERENCES todo_item(id) ON DELETE CASCADE,
|
||||||
|
INDEX idx_ad_account (ad_account)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- Table: todo_item_follower
|
||||||
|
CREATE TABLE IF NOT EXISTS todo_item_follower (
|
||||||
|
todo_id CHAR(36) NOT NULL,
|
||||||
|
ad_account VARCHAR(128) NOT NULL,
|
||||||
|
added_by VARCHAR(128),
|
||||||
|
added_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (todo_id, ad_account),
|
||||||
|
FOREIGN KEY (todo_id) REFERENCES todo_item(id) ON DELETE CASCADE,
|
||||||
|
INDEX idx_ad_account (ad_account)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- Table: todo_mail_log
|
||||||
|
CREATE TABLE IF NOT EXISTS todo_mail_log (
|
||||||
|
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
todo_id CHAR(36),
|
||||||
|
type ENUM('SCHEDULED', 'FIRE') NOT NULL,
|
||||||
|
triggered_by_ad VARCHAR(128),
|
||||||
|
recipients TEXT,
|
||||||
|
subject VARCHAR(255),
|
||||||
|
status ENUM('QUEUED', 'SENT', 'FAILED') DEFAULT 'QUEUED',
|
||||||
|
provider_msg_id VARCHAR(128),
|
||||||
|
error_text TEXT,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
sent_at DATETIME,
|
||||||
|
FOREIGN KEY (todo_id) REFERENCES todo_item(id) ON DELETE CASCADE,
|
||||||
|
INDEX idx_todo_id (todo_id),
|
||||||
|
INDEX idx_type (type),
|
||||||
|
INDEX idx_status (status),
|
||||||
|
INDEX idx_triggered_by (triggered_by_ad),
|
||||||
|
INDEX idx_created_at (created_at)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- Table: todo_audit_log
|
||||||
|
CREATE TABLE IF NOT EXISTS todo_audit_log (
|
||||||
|
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
actor_ad VARCHAR(128) NOT NULL,
|
||||||
|
todo_id CHAR(36),
|
||||||
|
action ENUM('CREATE', 'UPDATE', 'DELETE', 'COMPLETE', 'IMPORT', 'MAIL_SENT', 'MAIL_FAIL') NOT NULL,
|
||||||
|
detail JSON,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (todo_id) REFERENCES todo_item(id) ON DELETE SET NULL,
|
||||||
|
INDEX idx_actor_ad (actor_ad),
|
||||||
|
INDEX idx_todo_id (todo_id),
|
||||||
|
INDEX idx_action (action),
|
||||||
|
INDEX idx_created_at (created_at)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- Table: todo_user_pref
|
||||||
|
CREATE TABLE IF NOT EXISTS todo_user_pref (
|
||||||
|
ad_account VARCHAR(128) PRIMARY KEY,
|
||||||
|
email VARCHAR(256),
|
||||||
|
display_name VARCHAR(128),
|
||||||
|
theme ENUM('light', 'dark', 'auto') DEFAULT 'auto',
|
||||||
|
language VARCHAR(10) DEFAULT 'zh-TW',
|
||||||
|
timezone VARCHAR(50) DEFAULT 'Asia/Taipei',
|
||||||
|
notification_enabled TINYINT(1) DEFAULT 1,
|
||||||
|
email_reminder_enabled TINYINT(1) DEFAULT 1,
|
||||||
|
weekly_summary_enabled TINYINT(1) DEFAULT 1,
|
||||||
|
fire_email_today_count INT DEFAULT 0,
|
||||||
|
fire_email_last_reset DATE,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
INDEX idx_email (email),
|
||||||
|
INDEX idx_updated_at (updated_at)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- Table: todo_import_job
|
||||||
|
CREATE TABLE IF NOT EXISTS todo_import_job (
|
||||||
|
id CHAR(36) PRIMARY KEY,
|
||||||
|
actor_ad VARCHAR(128) NOT NULL,
|
||||||
|
filename VARCHAR(255),
|
||||||
|
total_rows INT DEFAULT 0,
|
||||||
|
success_rows INT DEFAULT 0,
|
||||||
|
failed_rows INT DEFAULT 0,
|
||||||
|
status ENUM('PENDING', 'PROCESSING', 'COMPLETED', 'FAILED') DEFAULT 'PENDING',
|
||||||
|
error_file_path VARCHAR(500),
|
||||||
|
error_details JSON,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
completed_at DATETIME,
|
||||||
|
INDEX idx_actor_ad (actor_ad),
|
||||||
|
INDEX idx_status (status),
|
||||||
|
INDEX idx_created_at (created_at)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- Table: todo_fire_email_log
|
||||||
|
CREATE TABLE IF NOT EXISTS todo_fire_email_log (
|
||||||
|
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
todo_id CHAR(36) NOT NULL,
|
||||||
|
sender_ad VARCHAR(128) NOT NULL,
|
||||||
|
sent_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (todo_id) REFERENCES todo_item(id) ON DELETE CASCADE,
|
||||||
|
INDEX idx_todo_sender_time (todo_id, sender_ad, sent_at),
|
||||||
|
INDEX idx_sender_time (sender_ad, sent_at)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
Reference in New Issue
Block a user