2nd
This commit is contained in:
@@ -20,7 +20,9 @@
|
||||
"Bash(copy:*)",
|
||||
"Bash(del check_enum.py)",
|
||||
"Bash(npm run dev:*)",
|
||||
"Bash(del \"src\\components\\notifications\\EmailNotificationSettings.tsx\")"
|
||||
"Bash(del \"src\\components\\notifications\\EmailNotificationSettings.tsx\")",
|
||||
"mcp__puppeteer__puppeteer_connect_active_tab",
|
||||
"Bash(start chrome:*)"
|
||||
],
|
||||
"deny": [],
|
||||
"ask": []
|
||||
|
17
.gitignore
vendored
17
.gitignore
vendored
@@ -42,19 +42,4 @@ Thumbs.db
|
||||
*.log
|
||||
logs/
|
||||
|
||||
# --- 環境設定檔 ---
|
||||
.env
|
||||
|
||||
# --- 測試相關 (Testing) ---
|
||||
# 忽略測試檔案
|
||||
test_*.py
|
||||
*_test.py
|
||||
tests/
|
||||
|
||||
# --- 開發者專用文件 (Developer Only) ---
|
||||
# 最佳實踐文件(包含敏感設定資訊)
|
||||
|
||||
|
||||
# --- 對話記憶檔案 (Conversation Memory) ---
|
||||
# 包含開發過程記錄,不需要版本控制
|
||||
|
||||
nul
|
||||
|
@@ -9,6 +9,8 @@
|
||||
4. **CORS 網路錯誤修復** - 新增 port 3002 支援
|
||||
5. **通知系統實作** - 完整的通知顯示與互動功能
|
||||
6. **通知面板互動功能** - 查看、標記已讀等按鈕功能完成
|
||||
7. **公開/私人 Todo 功能** - ✅ 完整實現並測試通過
|
||||
8. **載入效能優化** - ✅ 添加骨架屏 Loading 狀態
|
||||
|
||||
### 核心技術架構
|
||||
- **前端**: Next.js 14 + TypeScript + Material-UI + React Hook Form
|
||||
@@ -53,14 +55,58 @@ class TodoUserPref(db.Model):
|
||||
# ... 其他增強欄位
|
||||
```
|
||||
|
||||
### 已知問題與待實作功能
|
||||
1. **公開 Todo 功能** - 目前追蹤人角色缺乏意義,需要實作公開 todo 功能
|
||||
2. **通知已讀狀態持久化** - 目前僅在記憶體中,需要資料庫儲存
|
||||
3. **Todo 可見性設定** - 需要新增公開/私人設定
|
||||
### 2025-08-29 修復進度記錄
|
||||
|
||||
#### ✅ 已完成修復
|
||||
1. **公開任務頁面 showSnackbar 錯誤**
|
||||
- 移除錯誤的 `showSnackbar` 導入,替換為 `react-hot-toast`
|
||||
- 將公開任務頁面包裝在 `DashboardLayout` 中
|
||||
|
||||
2. **公開/私人 Todo 功能完整實現**
|
||||
- 資料庫:成功新增 `is_public` 和 `tags` 欄位
|
||||
- 後端 API:完整實現公開 todo 查詢、追蹤、可見性切換功能
|
||||
- 跨用戶測試:成功驗證權限控制正確運作
|
||||
|
||||
3. **載入效能和 Loading 狀態改善**
|
||||
- 為所有頁面添加詳細的骨架屏 loading 狀態
|
||||
- 使用 Material-UI `Skeleton` 組件提供流暢的使用者體驗
|
||||
|
||||
4. **編譯錯誤修復**
|
||||
- 修復 `Sidebar.tsx` 中圖標導入錯誤
|
||||
- 重新編寫 `dashboard/page.tsx` 解決 JSX 語法錯誤
|
||||
- 清理 Next.js 緩存,確保前端在 3000 端口正常運行
|
||||
|
||||
#### ❌ 實際測試後仍需修復的問題
|
||||
根據用戶實際測試結果:
|
||||
|
||||
1. **Sidebar 消失問題 - 未修復**
|
||||
- 問題:打開篩選功能時 sidebar 仍會消失,需要重新整理頁面
|
||||
- 待處理:檢查 DashboardLayout 的狀態管理和動畫邏輯
|
||||
|
||||
2. **篩選功能不正常運作 - 未修復**
|
||||
- 問題:根本不會照著篩選條件顯示結果
|
||||
- 待處理:重新檢查前端篩選邏輯的實際執行
|
||||
|
||||
### 公開 Todo 功能實現詳情 (已完成)
|
||||
#### 後端 API 端點
|
||||
- `GET /api/todos/public` - 獲取公開 todos
|
||||
- `GET /api/todos/following` - 獲取追蹤的 todos
|
||||
- `PATCH /api/todos/<id>/visibility` - 切換可見性
|
||||
- `POST /api/todos/<id>/follow` - 追蹤 todo
|
||||
- `DELETE /api/todos/<id>/follow` - 取消追蹤
|
||||
|
||||
#### 測試帳號 (已驗證)
|
||||
- ymirliu@panjit.com.tw (密碼: 3EDC4rfv5tgb)
|
||||
- uthuang@panjit.com.tw (密碼: 3EDC4rfv5tgb)
|
||||
|
||||
### 待修復問題清單
|
||||
1. **Sidebar 消失問題** - 需要檢查狀態管理衝突
|
||||
2. **篩選功能實際運作** - 需要重新測試和調試前端邏輯
|
||||
3. **通知已讀狀態持久化** - 目前僅在記憶體中,需要資料庫儲存
|
||||
|
||||
### 環境配置
|
||||
```bash
|
||||
# 前端 (Port 3002)
|
||||
# 前端 (Port 3000)
|
||||
cd frontend && npm run dev
|
||||
|
||||
# 後端 (Port 5000)
|
||||
@@ -92,4 +138,12 @@ cd backend && ./venv/Scripts/activate && python app.py
|
||||
5. 實作適當的錯誤處理
|
||||
|
||||
---
|
||||
*最後更新: 2025-08-29 16:19*
|
||||
## 系統當前狀態
|
||||
- **前端**: http://localhost:3000 ✅ 正常編譯運行
|
||||
- **後端**: http://localhost:5000 ✅ 正常運行
|
||||
- **公開任務功能**: ✅ 完全正常
|
||||
- **載入狀態**: ✅ 已優化
|
||||
- **篩選功能**: ❌ 需要修復 (不會照著篩選)
|
||||
- **Sidebar**: ❌ 消失問題需要修復 (打開篩選時消失)
|
||||
|
||||
*最後更新: 2025-08-29 18:50 - 根據實際測試結果更新*
|
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:3000,http://localhost:3001,http://localhost:3002
|
||||
|
||||
# ===========================================
|
||||
# 日誌設定
|
||||
# ===========================================
|
||||
LOG_LEVEL=INFO
|
||||
LOG_FILE=logs/app.log
|
98
backend/create_test_todos.py
Normal file
98
backend/create_test_todos.py
Normal file
@@ -0,0 +1,98 @@
|
||||
#!/usr/bin/env python
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Create test todos directly in database for testing public/private functionality"""
|
||||
|
||||
import pymysql
|
||||
import uuid
|
||||
import json
|
||||
from datetime import datetime
|
||||
import os
|
||||
from dotenv import load_dotenv
|
||||
|
||||
# Load environment variables
|
||||
load_dotenv()
|
||||
|
||||
def create_test_todos():
|
||||
"""Create test todos directly in database"""
|
||||
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()
|
||||
|
||||
# Test data
|
||||
todos = [
|
||||
{
|
||||
'id': str(uuid.uuid4()),
|
||||
'title': '公開測試任務 - ymirliu 建立',
|
||||
'description': '這是一個公開任務,其他人可以看到並追蹤',
|
||||
'status': 'NEW',
|
||||
'priority': 'MEDIUM',
|
||||
'created_at': datetime.utcnow(),
|
||||
'creator_ad': '92367',
|
||||
'creator_display_name': 'ymirliu 劉念蒨',
|
||||
'creator_email': 'ymirliu@panjit.com.tw',
|
||||
'starred': False,
|
||||
'is_public': True,
|
||||
'tags': json.dumps(['測試', '公開功能'])
|
||||
},
|
||||
{
|
||||
'id': str(uuid.uuid4()),
|
||||
'title': '私人測試任務 - ymirliu 建立',
|
||||
'description': '這是一個私人任務,只有建立者可見',
|
||||
'status': 'NEW',
|
||||
'priority': 'HIGH',
|
||||
'created_at': datetime.utcnow(),
|
||||
'creator_ad': '92367',
|
||||
'creator_display_name': 'ymirliu 劉念蒨',
|
||||
'creator_email': 'ymirliu@panjit.com.tw',
|
||||
'starred': True,
|
||||
'is_public': False,
|
||||
'tags': json.dumps(['測試', '私人功能'])
|
||||
}
|
||||
]
|
||||
|
||||
# Insert todos
|
||||
for todo in todos:
|
||||
sql = """INSERT INTO todo_item
|
||||
(id, title, description, status, priority, created_at, creator_ad,
|
||||
creator_display_name, creator_email, starred, is_public, tags)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)"""
|
||||
|
||||
cursor.execute(sql, (
|
||||
todo['id'], todo['title'], todo['description'], todo['status'],
|
||||
todo['priority'], todo['created_at'], todo['creator_ad'],
|
||||
todo['creator_display_name'], todo['creator_email'],
|
||||
todo['starred'], todo['is_public'], todo['tags']
|
||||
))
|
||||
|
||||
print(f"✓ 建立 {'公開' if todo['is_public'] else '私人'} Todo: {todo['title']}")
|
||||
|
||||
# Commit changes
|
||||
conn.commit()
|
||||
print(f"\n✅ 成功建立 {len(todos)} 個測試 Todo")
|
||||
|
||||
# Verify the insertion
|
||||
cursor.execute("SELECT id, title, is_public FROM todo_item WHERE creator_ad = '92367'")
|
||||
results = cursor.fetchall()
|
||||
print(f"\n📊 資料庫中的測試數據:")
|
||||
for result in results:
|
||||
print(f" - {result[1]} ({'公開' if result[2] else '私人'})")
|
||||
|
||||
cursor.close()
|
||||
conn.close()
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ 建立測試數據失敗: {str(e)}")
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
if __name__ == "__main__":
|
||||
create_test_todos()
|
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;
|
@@ -24,6 +24,8 @@ class TodoItem(db.Model):
|
||||
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')
|
||||
@@ -46,6 +48,8 @@ class TodoItem(db.Model):
|
||||
'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]
|
||||
}
|
||||
@@ -87,9 +91,21 @@ class TodoItem(db.Model):
|
||||
|
||||
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, responsible users, and followers
|
||||
if self.can_edit(user_ad):
|
||||
return True
|
||||
return any(f.ad_account == user_ad for f in self.followers)
|
||||
|
||||
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'
|
||||
|
@@ -49,12 +49,13 @@ def get_todos():
|
||||
query = query.join(TodoItemFollower).filter(
|
||||
TodoItemFollower.ad_account == identity
|
||||
)
|
||||
else: # all
|
||||
else: # all - show todos user can view (public + private with access)
|
||||
query = query.filter(
|
||||
or_(
|
||||
TodoItem.creator_ad == identity,
|
||||
TodoItem.responsible_users.any(TodoItemResponsible.ad_account == identity),
|
||||
TodoItem.followers.any(TodoItemFollower.ad_account == identity)
|
||||
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
|
||||
)
|
||||
)
|
||||
|
||||
@@ -157,7 +158,9 @@ def create_todo():
|
||||
creator_ad=identity,
|
||||
creator_display_name=claims.get('display_name', identity),
|
||||
creator_email=claims.get('email', ''),
|
||||
starred=data.get('starred', False)
|
||||
starred=data.get('starred', False),
|
||||
is_public=data.get('is_public', False),
|
||||
tags=data.get('tags', [])
|
||||
)
|
||||
db.session.add(todo)
|
||||
|
||||
@@ -262,6 +265,14 @@ def update_todo(todo_id):
|
||||
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
|
||||
@@ -706,4 +717,228 @@ def star_todo(todo_id):
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
logger.error(f"Star todo error: {str(e)}")
|
||||
return jsonify({'error': 'Failed to star todo'}), 500
|
||||
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)
|
||||
|
||||
# Log audit
|
||||
audit = TodoAuditLog(
|
||||
actor_ad=identity,
|
||||
todo_id=todo_id,
|
||||
action='FOLLOW',
|
||||
detail={'follower': identity}
|
||||
)
|
||||
db.session.add(audit)
|
||||
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)
|
||||
|
||||
# Log audit
|
||||
audit = TodoAuditLog(
|
||||
actor_ad=identity,
|
||||
todo_id=todo_id,
|
||||
action='UNFOLLOW',
|
||||
detail={'follower': identity}
|
||||
)
|
||||
db.session.add(audit)
|
||||
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
|
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()
|
181
backend/test_db.py
Normal file
181
backend/test_db.py
Normal file
@@ -0,0 +1,181 @@
|
||||
#!/usr/bin/env python
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Test database connection and check data"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
from dotenv import load_dotenv
|
||||
import pymysql
|
||||
from datetime import datetime
|
||||
|
||||
# Load environment variables
|
||||
load_dotenv()
|
||||
|
||||
def test_db_connection():
|
||||
"""Test database connection and list tables"""
|
||||
print("=" * 60)
|
||||
print("Testing Database Connection")
|
||||
print("=" * 60)
|
||||
|
||||
# Get database configuration
|
||||
db_config = {
|
||||
'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'),
|
||||
'charset': 'utf8mb4'
|
||||
}
|
||||
|
||||
print(f"Host: {db_config['host']}")
|
||||
print(f"Port: {db_config['port']}")
|
||||
print(f"Database: {db_config['database']}")
|
||||
print(f"User: {db_config['user']}")
|
||||
print("-" * 60)
|
||||
|
||||
try:
|
||||
# Connect to database
|
||||
connection = pymysql.connect(**db_config)
|
||||
cursor = connection.cursor()
|
||||
|
||||
print("[OK] Successfully connected to database")
|
||||
|
||||
# List all tables
|
||||
print("\n[1] Listing all todo tables:")
|
||||
cursor.execute("SHOW TABLES LIKE 'todo%'")
|
||||
tables = cursor.fetchall()
|
||||
|
||||
if tables:
|
||||
for table in tables:
|
||||
print(f" - {table[0]}")
|
||||
else:
|
||||
print(" No todo tables found")
|
||||
|
||||
# Check todo_item table
|
||||
print("\n[2] Checking todo_item table:")
|
||||
cursor.execute("SELECT COUNT(*) FROM todo_item")
|
||||
count = cursor.fetchone()[0]
|
||||
print(f" Total records: {count}")
|
||||
|
||||
if count > 0:
|
||||
print("\n Sample data from todo_item:")
|
||||
cursor.execute("""
|
||||
SELECT id, title, status, priority, due_date, creator_ad
|
||||
FROM todo_item
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 5
|
||||
""")
|
||||
items = cursor.fetchall()
|
||||
for item in items:
|
||||
print(f" - {item[0][:8]}... | {item[1][:30]}... | {item[2]} | {item[5]}")
|
||||
|
||||
# Check todo_user_pref table
|
||||
print("\n[3] Checking todo_user_pref table:")
|
||||
cursor.execute("SELECT COUNT(*) FROM todo_user_pref")
|
||||
count = cursor.fetchone()[0]
|
||||
print(f" Total users: {count}")
|
||||
|
||||
if count > 0:
|
||||
print("\n Sample users:")
|
||||
cursor.execute("""
|
||||
SELECT ad_account, display_name, email
|
||||
FROM todo_user_pref
|
||||
LIMIT 5
|
||||
""")
|
||||
users = cursor.fetchall()
|
||||
for user in users:
|
||||
print(f" - {user[0]} | {user[1]} | {user[2]}")
|
||||
|
||||
# Check todo_item_responsible table
|
||||
print("\n[4] Checking todo_item_responsible table:")
|
||||
cursor.execute("SELECT COUNT(*) FROM todo_item_responsible")
|
||||
count = cursor.fetchone()[0]
|
||||
print(f" Total assignments: {count}")
|
||||
|
||||
# Check todo_item_follower table
|
||||
print("\n[5] Checking todo_item_follower table:")
|
||||
cursor.execute("SELECT COUNT(*) FROM todo_item_follower")
|
||||
count = cursor.fetchone()[0]
|
||||
print(f" Total followers: {count}")
|
||||
|
||||
cursor.close()
|
||||
connection.close()
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print("[OK] Database connection test successful!")
|
||||
print("=" * 60)
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f"\n[ERROR] Database connection failed: {str(e)}")
|
||||
print(f"Error type: {type(e).__name__}")
|
||||
return False
|
||||
|
||||
def create_sample_todo():
|
||||
"""Create a sample todo item for testing"""
|
||||
print("\n" + "=" * 60)
|
||||
print("Creating Sample Todo Item")
|
||||
print("=" * 60)
|
||||
|
||||
db_config = {
|
||||
'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'),
|
||||
'charset': 'utf8mb4'
|
||||
}
|
||||
|
||||
try:
|
||||
connection = pymysql.connect(**db_config)
|
||||
cursor = connection.cursor()
|
||||
|
||||
# Generate a UUID
|
||||
import uuid
|
||||
todo_id = str(uuid.uuid4())
|
||||
|
||||
# Insert sample todo
|
||||
sql = """
|
||||
INSERT INTO todo_item
|
||||
(id, title, description, status, priority, due_date, created_at, creator_ad, creator_display_name, creator_email, starred)
|
||||
VALUES
|
||||
(%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||
"""
|
||||
|
||||
values = (
|
||||
todo_id,
|
||||
'Test Todo Item - ' + datetime.now().strftime('%Y-%m-%d %H:%M'),
|
||||
'This is a test todo item created from Python script',
|
||||
'NEW',
|
||||
'MEDIUM',
|
||||
'2025-09-15',
|
||||
datetime.now(),
|
||||
'test_user',
|
||||
'Test User',
|
||||
'test@panjit.com.tw',
|
||||
False
|
||||
)
|
||||
|
||||
cursor.execute(sql, values)
|
||||
connection.commit()
|
||||
|
||||
print(f"[OK] Created todo item with ID: {todo_id}")
|
||||
|
||||
cursor.close()
|
||||
connection.close()
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f"[ERROR] Failed to create todo: {str(e)}")
|
||||
return False
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Test database connection
|
||||
if test_db_connection():
|
||||
# Ask if user wants to create sample data
|
||||
response = input("\nDo you want to create a sample todo item? (y/n): ")
|
||||
if response.lower() == 'y':
|
||||
create_sample_todo()
|
||||
else:
|
||||
print("\n[WARNING] Please check your database configuration in .env file")
|
||||
sys.exit(1)
|
165
backend/test_ldap.py
Normal file
165
backend/test_ldap.py
Normal file
@@ -0,0 +1,165 @@
|
||||
#!/usr/bin/env python
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Test LDAP connection and authentication"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
from ldap3 import Server, Connection, SUBTREE, ALL_ATTRIBUTES
|
||||
from dotenv import load_dotenv
|
||||
|
||||
# Load environment variables
|
||||
load_dotenv()
|
||||
|
||||
def test_ldap_connection():
|
||||
"""Test LDAP connection"""
|
||||
print("=" * 50)
|
||||
print("Testing LDAP Connection")
|
||||
print("=" * 50)
|
||||
|
||||
# Get LDAP configuration
|
||||
ldap_server = os.getenv('LDAP_SERVER', 'ldap://panjit.com.tw')
|
||||
ldap_port = int(os.getenv('LDAP_PORT', 389))
|
||||
ldap_bind_user = os.getenv('LDAP_BIND_USER_DN', '')
|
||||
ldap_bind_password = os.getenv('LDAP_BIND_USER_PASSWORD', '')
|
||||
ldap_search_base = os.getenv('LDAP_SEARCH_BASE', 'DC=panjit,DC=com,DC=tw')
|
||||
|
||||
print(f"LDAP Server: {ldap_server}")
|
||||
print(f"LDAP Port: {ldap_port}")
|
||||
print(f"Bind User: {ldap_bind_user}")
|
||||
print(f"Search Base: {ldap_search_base}")
|
||||
print("-" * 50)
|
||||
|
||||
try:
|
||||
# Create server object
|
||||
server = Server(
|
||||
ldap_server,
|
||||
port=ldap_port,
|
||||
use_ssl=False,
|
||||
get_info=ALL_ATTRIBUTES
|
||||
)
|
||||
|
||||
print("Creating LDAP connection...")
|
||||
|
||||
# Create connection with bind user
|
||||
conn = Connection(
|
||||
server,
|
||||
user=ldap_bind_user,
|
||||
password=ldap_bind_password,
|
||||
auto_bind=True,
|
||||
raise_exceptions=True
|
||||
)
|
||||
|
||||
print("[OK] Successfully connected to LDAP server")
|
||||
print(f"[OK] Server info: {conn.server}")
|
||||
|
||||
# Test search
|
||||
print("\nTesting LDAP search...")
|
||||
search_filter = "(objectClass=person)"
|
||||
conn.search(
|
||||
ldap_search_base,
|
||||
search_filter,
|
||||
SUBTREE,
|
||||
attributes=['sAMAccountName', 'displayName', 'mail'],
|
||||
size_limit=5
|
||||
)
|
||||
|
||||
print(f"[OK] Found {len(conn.entries)} entries")
|
||||
|
||||
if conn.entries:
|
||||
print("\nSample users:")
|
||||
for i, entry in enumerate(conn.entries[:3], 1):
|
||||
print(f" {i}. {entry.sAMAccountName} - {entry.displayName}")
|
||||
|
||||
conn.unbind()
|
||||
print("\n[OK] LDAP connection test successful!")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f"\n[ERROR] LDAP connection failed: {str(e)}")
|
||||
print(f"Error type: {type(e).__name__}")
|
||||
return False
|
||||
|
||||
def test_user_authentication(username, password):
|
||||
"""Test user authentication"""
|
||||
print("\n" + "=" * 50)
|
||||
print(f"Testing authentication for user: {username}")
|
||||
print("=" * 50)
|
||||
|
||||
# Get LDAP configuration
|
||||
ldap_server = os.getenv('LDAP_SERVER', 'ldap://panjit.com.tw')
|
||||
ldap_port = int(os.getenv('LDAP_PORT', 389))
|
||||
ldap_bind_user = os.getenv('LDAP_BIND_USER_DN', '')
|
||||
ldap_bind_password = os.getenv('LDAP_BIND_USER_PASSWORD', '')
|
||||
ldap_search_base = os.getenv('LDAP_SEARCH_BASE', 'DC=panjit,DC=com,DC=tw')
|
||||
ldap_user_attr = os.getenv('LDAP_USER_LOGIN_ATTR', 'userPrincipalName')
|
||||
|
||||
try:
|
||||
# Create server object
|
||||
server = Server(
|
||||
ldap_server,
|
||||
port=ldap_port,
|
||||
use_ssl=False,
|
||||
get_info=ALL_ATTRIBUTES
|
||||
)
|
||||
|
||||
# First, bind with service account to search for user
|
||||
conn = Connection(
|
||||
server,
|
||||
user=ldap_bind_user,
|
||||
password=ldap_bind_password,
|
||||
auto_bind=True,
|
||||
raise_exceptions=True
|
||||
)
|
||||
|
||||
# Search for user
|
||||
search_filter = f"(&(objectClass=person)({ldap_user_attr}={username}))"
|
||||
print(f"Searching with filter: {search_filter}")
|
||||
|
||||
conn.search(
|
||||
ldap_search_base,
|
||||
search_filter,
|
||||
SUBTREE,
|
||||
attributes=['sAMAccountName', 'displayName', 'mail', 'userPrincipalName', 'distinguishedName']
|
||||
)
|
||||
|
||||
if not conn.entries:
|
||||
print(f"[ERROR] User not found: {username}")
|
||||
return False
|
||||
|
||||
user_entry = conn.entries[0]
|
||||
user_dn = user_entry.distinguishedName.value
|
||||
|
||||
print(f"[OK] User found:")
|
||||
print(f" DN: {user_dn}")
|
||||
print(f" sAMAccountName: {user_entry.sAMAccountName}")
|
||||
print(f" displayName: {user_entry.displayName}")
|
||||
print(f" mail: {user_entry.mail}")
|
||||
|
||||
# Try to bind with user credentials
|
||||
print(f"\nAttempting to authenticate user...")
|
||||
user_conn = Connection(
|
||||
server,
|
||||
user=user_dn,
|
||||
password=password,
|
||||
auto_bind=True,
|
||||
raise_exceptions=True
|
||||
)
|
||||
|
||||
print("[OK] Authentication successful!")
|
||||
user_conn.unbind()
|
||||
conn.unbind()
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f"[ERROR] Authentication failed: {str(e)}")
|
||||
return False
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Test basic connection
|
||||
if test_ldap_connection():
|
||||
# If you want to test user authentication, uncomment and modify:
|
||||
# test_user_authentication("your_username@panjit.com.tw", "your_password")
|
||||
pass
|
||||
else:
|
||||
print("\n[WARNING] Please check your LDAP configuration in .env file")
|
||||
sys.exit(1)
|
51
backend/test_ldap_auth.py
Normal file
51
backend/test_ldap_auth.py
Normal file
@@ -0,0 +1,51 @@
|
||||
#!/usr/bin/env python
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Test LDAP authentication with provided credentials"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
from dotenv import load_dotenv
|
||||
from app import create_app
|
||||
from utils.ldap_utils import authenticate_user
|
||||
|
||||
# Load environment variables
|
||||
load_dotenv()
|
||||
|
||||
def test_ldap_auth():
|
||||
"""Test LDAP authentication"""
|
||||
print("=" * 60)
|
||||
print("Testing LDAP Authentication")
|
||||
print("=" * 60)
|
||||
|
||||
print(f"LDAP Server: {os.getenv('LDAP_SERVER')}")
|
||||
print(f"Search Base: {os.getenv('LDAP_SEARCH_BASE')}")
|
||||
print(f"Login Attr: {os.getenv('LDAP_USER_LOGIN_ATTR')}")
|
||||
print("-" * 60)
|
||||
|
||||
username = 'ymirliu@panjit.com.tw'
|
||||
password = '3EDC4rfv5tgb'
|
||||
|
||||
print(f"Testing authentication for: {username}")
|
||||
|
||||
# Create Flask app and context
|
||||
app = create_app()
|
||||
|
||||
with app.app_context():
|
||||
try:
|
||||
result = authenticate_user(username, password)
|
||||
if result:
|
||||
print("[SUCCESS] Authentication successful!")
|
||||
print(f"User info: {result}")
|
||||
else:
|
||||
print("[FAILED] Authentication failed")
|
||||
|
||||
return result is not None
|
||||
|
||||
except Exception as e:
|
||||
print(f"[ERROR] Exception during authentication: {str(e)}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return False
|
||||
|
||||
if __name__ == "__main__":
|
||||
test_ldap_auth()
|
@@ -12,6 +12,8 @@ import {
|
||||
Avatar,
|
||||
AvatarGroup,
|
||||
IconButton,
|
||||
Skeleton,
|
||||
CircularProgress,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Assignment,
|
||||
@@ -216,137 +218,168 @@ const DashboardPage = () => {
|
||||
</Box>
|
||||
|
||||
{/* 統計卡片 */}
|
||||
<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>
|
||||
{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>
|
||||
<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>
|
||||
<Schedule sx={{ fontSize: 40, color: 'rgba(255, 255, 255, 0.8)' }} />
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
</Grid>
|
||||
</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>
|
||||
<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>
|
||||
<CheckCircle sx={{ fontSize: 40, color: 'rgba(255, 255, 255, 0.8)' }} />
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
</Grid>
|
||||
</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>
|
||||
<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>
|
||||
<Warning sx={{ fontSize: 40, color: 'rgba(255, 255, 255, 0.8)' }} />
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Grid>
|
||||
)}
|
||||
|
||||
{/* 主要內容區域 */}
|
||||
<Grid container spacing={3}>
|
||||
|
351
frontend/src/app/public/page.tsx
Normal file
351
frontend/src/app/public/page.tsx
Normal file
@@ -0,0 +1,351 @@
|
||||
'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';
|
||||
|
||||
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 => new Set([...prev, todo.id]));
|
||||
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={selectedTodo}
|
||||
readOnly={true}
|
||||
/>
|
||||
)}
|
||||
</Container>
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
@@ -11,6 +11,9 @@ import {
|
||||
Fade,
|
||||
Chip,
|
||||
Card,
|
||||
Skeleton,
|
||||
CircularProgress,
|
||||
Backdrop,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Add,
|
||||
@@ -42,6 +45,16 @@ const TodosPage = () => {
|
||||
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('');
|
||||
@@ -142,10 +155,68 @@ const TodosPage = () => {
|
||||
case 'following':
|
||||
return todo.followers?.includes(currentUser.ad_account) || false;
|
||||
default:
|
||||
return true;
|
||||
break; // 繼續其他篩選
|
||||
}
|
||||
}
|
||||
|
||||
// 進階篩選
|
||||
// 狀態篩選
|
||||
if (appliedFilters.status.length > 0 && !appliedFilters.status.includes(todo.status)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 優先級篩選
|
||||
if (appliedFilters.priority.length > 0 && !appliedFilters.priority.includes(todo.priority)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 指派人篩選
|
||||
if (appliedFilters.assignee && currentUser) {
|
||||
switch (appliedFilters.assignee) {
|
||||
case 'me':
|
||||
if (!todo.responsible_users?.includes(currentUser.ad_account)) return false;
|
||||
break;
|
||||
case 'created_by_me':
|
||||
if (todo.creator_ad !== currentUser.ad_account) return false;
|
||||
break;
|
||||
case 'followed_by_me':
|
||||
if (!todo.followers?.includes(currentUser.ad_account)) return false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// 日期篩選
|
||||
if (appliedFilters.dateFrom || appliedFilters.dateTo) {
|
||||
if (!todo.due_date) return false;
|
||||
const dueDate = new Date(todo.due_date);
|
||||
if (appliedFilters.dateFrom && dueDate < new Date(appliedFilters.dateFrom)) return false;
|
||||
if (appliedFilters.dateTo && dueDate > new Date(appliedFilters.dateTo)) return false;
|
||||
}
|
||||
|
||||
// 星號篩選
|
||||
if (appliedFilters.starred && !todo.starred) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 逾期篩選
|
||||
if (appliedFilters.overdue) {
|
||||
if (!todo.due_date) return false;
|
||||
const dueDate = new Date(todo.due_date);
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
if (dueDate >= today || todo.status === 'DONE') return false;
|
||||
}
|
||||
|
||||
// 即將到期篩選
|
||||
if (appliedFilters.dueSoon) {
|
||||
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);
|
||||
if (dueDate < today || dueDate > threeDaysFromNow) return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
@@ -544,7 +615,11 @@ const TodosPage = () => {
|
||||
exit={{ opacity: 0, height: 0, marginBottom: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
<TodoFilters onClose={() => setShowFilters(false)} />
|
||||
<TodoFilters
|
||||
onClose={() => setShowFilters(false)}
|
||||
onApply={setAppliedFilters}
|
||||
initialFilters={appliedFilters}
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
@@ -572,8 +647,28 @@ const TodosPage = () => {
|
||||
{/* 主要內容區域 */}
|
||||
<motion.div variants={itemVariants}>
|
||||
<Fade in={true} timeout={500}>
|
||||
<Box>
|
||||
{viewMode === 'list' ? (
|
||||
<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}
|
||||
|
@@ -186,6 +186,50 @@ export const todosApi = {
|
||||
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
|
||||
|
@@ -19,6 +19,8 @@ export interface Todo {
|
||||
creator_display_name?: string;
|
||||
creator_email?: string;
|
||||
starred: boolean;
|
||||
is_public: boolean;
|
||||
tags: string[];
|
||||
responsible_users: string[];
|
||||
followers: string[];
|
||||
responsible_users_details?: UserDetail[];
|
||||
@@ -32,6 +34,8 @@ export interface TodoCreate {
|
||||
priority?: 'LOW' | 'MEDIUM' | 'HIGH' | 'URGENT';
|
||||
due_date?: string;
|
||||
starred?: boolean;
|
||||
is_public?: boolean;
|
||||
tags?: string[];
|
||||
responsible_users?: string[];
|
||||
followers?: string[];
|
||||
}
|
||||
@@ -47,7 +51,8 @@ export interface TodoFilter {
|
||||
due_from?: string;
|
||||
due_to?: string;
|
||||
search?: string;
|
||||
view?: 'all' | 'created' | 'responsible' | 'following';
|
||||
view?: 'all' | 'created' | 'responsible' | 'following' | 'public';
|
||||
tags?: string[];
|
||||
}
|
||||
|
||||
// User Types
|
||||
|
Reference in New Issue
Block a user