From a2f024704cdac09d07a5f3cf497ff6fce296aee5 Mon Sep 17 00:00:00 2001 From: beabigegg Date: Fri, 12 Sep 2025 07:37:26 +0800 Subject: [PATCH] backup --- .claude/settings.local.json | 13 + .dockerignore | 35 + .env.production | 113 + .gitignore | 69 + DEPLOYMENT.md | 380 + Dockerfile | 56 + README.md | 356 + USERMANUAL.md | 360 + backend/.dockerignore | 99 + backend/.env | 81 + backend/.env.example | 92 + backend/app.py | 185 + backend/celery_app.py | 9 + backend/config.py | 139 + backend/init_db.py | 65 + backend/migrations/add_public_feature.sql | 19 + backend/models.py | 257 + backend/requirements.txt | 40 + backend/routes/admin.py | 191 + backend/routes/auth.py | 175 + backend/routes/excel.py | 516 ++ backend/routes/health.py | 126 + backend/routes/notifications.py | 584 ++ backend/routes/reports.py | 372 + backend/routes/scheduler.py | 261 + backend/routes/todos.py | 939 ++ backend/routes/users.py | 128 + backend/run_migration.py | 60 + backend/tasks.py | 226 + backend/tasks_simple.py | 178 + backend/templates/emails/fire_email.html | 230 + backend/utils/email_service.py | 319 + backend/utils/ldap_utils.py | 266 + backend/utils/logger.py | 59 + backend/utils/mock_ldap.py | 140 + backend/utils/notification_service.py | 225 + deploy.bat | 33 + deploy.sh | 33 + docker-compose.yml | 67 + frontend/.dockerignore | 60 + frontend/.env.example | 184 + frontend/.env.local | 184 + frontend/next-env.d.ts | 5 + frontend/next.config.js | 58 + frontend/package-lock.json | 7808 +++++++++++++++++ frontend/package.json | 46 + frontend/public/panjit-logo.png | Bin 0 -> 7006 bytes frontend/src/app/calendar/page.tsx | 182 + frontend/src/app/dashboard/page.tsx | 579 ++ frontend/src/app/globals.css | 207 + frontend/src/app/layout.tsx | 43 + frontend/src/app/login/page.tsx | 358 + frontend/src/app/page.tsx | 41 + frontend/src/app/public/page.tsx | 372 + frontend/src/app/settings/page.tsx | 264 + frontend/src/app/todos/page.tsx | 806 ++ .../src/components/EnvironmentWrapper.tsx | 77 + .../src/components/layout/DashboardLayout.tsx | 465 + .../components/layout/NotificationPanel.tsx | 451 + frontend/src/components/layout/Sidebar.tsx | 637 ++ .../EnhancedEmailNotificationSettings.tsx | 591 ++ .../src/components/todos/BatchActions.tsx | 285 + .../src/components/todos/CalendarView.tsx | 935 ++ frontend/src/components/todos/ExcelImport.tsx | 446 + frontend/src/components/todos/SearchBar.tsx | 213 + frontend/src/components/todos/TodoDialog.tsx | 715 ++ frontend/src/components/todos/TodoFilters.tsx | 472 + frontend/src/components/todos/TodoList.tsx | 550 ++ frontend/src/lib/api.ts | 365 + frontend/src/lib/theme.ts | 210 + frontend/src/providers/AuthProvider.tsx | 180 + frontend/src/providers/ThemeProvider.tsx | 98 + frontend/src/providers/index.tsx | 91 + frontend/src/store/index.ts | 21 + frontend/src/store/slices/authSlice.ts | 37 + frontend/src/store/slices/todosSlice.ts | 119 + frontend/src/store/slices/uiSlice.ts | 168 + frontend/src/types/index.ts | 185 + frontend/tailwind.config.js | 69 + frontend/tsconfig.json | 34 + manage.bat | 66 + mysql/init/01-init.sql | 131 + 82 files changed, 26304 insertions(+) create mode 100644 .claude/settings.local.json create mode 100644 .dockerignore create mode 100644 .env.production create mode 100644 .gitignore create mode 100644 DEPLOYMENT.md create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 USERMANUAL.md create mode 100644 backend/.dockerignore create mode 100644 backend/.env create mode 100644 backend/.env.example create mode 100644 backend/app.py create mode 100644 backend/celery_app.py create mode 100644 backend/config.py create mode 100644 backend/init_db.py create mode 100644 backend/migrations/add_public_feature.sql create mode 100644 backend/models.py create mode 100644 backend/requirements.txt create mode 100644 backend/routes/admin.py create mode 100644 backend/routes/auth.py create mode 100644 backend/routes/excel.py create mode 100644 backend/routes/health.py create mode 100644 backend/routes/notifications.py create mode 100644 backend/routes/reports.py create mode 100644 backend/routes/scheduler.py create mode 100644 backend/routes/todos.py create mode 100644 backend/routes/users.py create mode 100644 backend/run_migration.py create mode 100644 backend/tasks.py create mode 100644 backend/tasks_simple.py create mode 100644 backend/templates/emails/fire_email.html create mode 100644 backend/utils/email_service.py create mode 100644 backend/utils/ldap_utils.py create mode 100644 backend/utils/logger.py create mode 100644 backend/utils/mock_ldap.py create mode 100644 backend/utils/notification_service.py create mode 100644 deploy.bat create mode 100644 deploy.sh create mode 100644 docker-compose.yml create mode 100644 frontend/.dockerignore create mode 100644 frontend/.env.example create mode 100644 frontend/.env.local create mode 100644 frontend/next-env.d.ts create mode 100644 frontend/next.config.js create mode 100644 frontend/package-lock.json create mode 100644 frontend/package.json create mode 100644 frontend/public/panjit-logo.png create mode 100644 frontend/src/app/calendar/page.tsx create mode 100644 frontend/src/app/dashboard/page.tsx create mode 100644 frontend/src/app/globals.css create mode 100644 frontend/src/app/layout.tsx create mode 100644 frontend/src/app/login/page.tsx create mode 100644 frontend/src/app/page.tsx create mode 100644 frontend/src/app/public/page.tsx create mode 100644 frontend/src/app/settings/page.tsx create mode 100644 frontend/src/app/todos/page.tsx create mode 100644 frontend/src/components/EnvironmentWrapper.tsx create mode 100644 frontend/src/components/layout/DashboardLayout.tsx create mode 100644 frontend/src/components/layout/NotificationPanel.tsx create mode 100644 frontend/src/components/layout/Sidebar.tsx create mode 100644 frontend/src/components/notifications/EnhancedEmailNotificationSettings.tsx create mode 100644 frontend/src/components/todos/BatchActions.tsx create mode 100644 frontend/src/components/todos/CalendarView.tsx create mode 100644 frontend/src/components/todos/ExcelImport.tsx create mode 100644 frontend/src/components/todos/SearchBar.tsx create mode 100644 frontend/src/components/todos/TodoDialog.tsx create mode 100644 frontend/src/components/todos/TodoFilters.tsx create mode 100644 frontend/src/components/todos/TodoList.tsx create mode 100644 frontend/src/lib/api.ts create mode 100644 frontend/src/lib/theme.ts create mode 100644 frontend/src/providers/AuthProvider.tsx create mode 100644 frontend/src/providers/ThemeProvider.tsx create mode 100644 frontend/src/providers/index.tsx create mode 100644 frontend/src/store/index.ts create mode 100644 frontend/src/store/slices/authSlice.ts create mode 100644 frontend/src/store/slices/todosSlice.ts create mode 100644 frontend/src/store/slices/uiSlice.ts create mode 100644 frontend/src/types/index.ts create mode 100644 frontend/tailwind.config.js create mode 100644 frontend/tsconfig.json create mode 100644 manage.bat create mode 100644 mysql/init/01-init.sql diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..9ec12ad --- /dev/null +++ b/.claude/settings.local.json @@ -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": [] + } +} \ No newline at end of file diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..4eaca59 --- /dev/null +++ b/.dockerignore @@ -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 \ No newline at end of file diff --git a/.env.production b/.env.production new file mode 100644 index 0000000..d73755c --- /dev/null +++ b/.env.production @@ -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 + diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..722177b --- /dev/null +++ b/.gitignore @@ -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 \ No newline at end of file diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md new file mode 100644 index 0000000..35033e1 --- /dev/null +++ b/DEPLOYMENT.md @@ -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 +cd TODOLIST + +# 2. 執行部署腳本 +deploy.bat + +# 或使用管理腳本 +manage.bat +``` + +### Linux/Mac 環境 + +```bash +# 1. 克隆專案(如果尚未克隆) +git clone +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 +``` + +--- + +**注意**: 本文件包含敏感配置資訊,請妥善保管,僅限授權人員查看。 \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..da20c13 --- /dev/null +++ b/Dockerfile @@ -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"] \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..08661b6 --- /dev/null +++ b/README.md @@ -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 +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: 檢查文件格式和欄位映射,參考匯入模板 + diff --git a/USERMANUAL.md b/USERMANUAL.md new file mode 100644 index 0000000..cb81607 --- /dev/null +++ b/USERMANUAL.md @@ -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**:低優先級(綠色) + diff --git a/backend/.dockerignore b/backend/.dockerignore new file mode 100644 index 0000000..b82b7d4 --- /dev/null +++ b/backend/.dockerignore @@ -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/ \ No newline at end of file diff --git a/backend/.env b/backend/.env new file mode 100644 index 0000000..c4176cc --- /dev/null +++ b/backend/.env @@ -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 \ No newline at end of file diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000..696d9eb --- /dev/null +++ b/backend/.env.example @@ -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 \ No newline at end of file diff --git a/backend/app.py b/backend/app.py new file mode 100644 index 0000000..2af3252 --- /dev/null +++ b/backend/app.py @@ -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('/') + 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) \ No newline at end of file diff --git a/backend/celery_app.py b/backend/celery_app.py new file mode 100644 index 0000000..2863ccc --- /dev/null +++ b/backend/celery_app.py @@ -0,0 +1,9 @@ +""" +Celery Application Entry Point +用於啟動 Celery worker 和 beat scheduler +""" + +from tasks import celery + +if __name__ == '__main__': + celery.start() \ No newline at end of file diff --git a/backend/config.py b/backend/config.py new file mode 100644 index 0000000..91b7180 --- /dev/null +++ b/backend/config.py @@ -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 +} \ No newline at end of file diff --git a/backend/init_db.py b/backend/init_db.py new file mode 100644 index 0000000..d7fd5d6 --- /dev/null +++ b/backend/init_db.py @@ -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) \ No newline at end of file diff --git a/backend/migrations/add_public_feature.sql b/backend/migrations/add_public_feature.sql new file mode 100644 index 0000000..786adf2 --- /dev/null +++ b/backend/migrations/add_public_feature.sql @@ -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; \ No newline at end of file diff --git a/backend/models.py b/backend/models.py new file mode 100644 index 0000000..cf23885 --- /dev/null +++ b/backend/models.py @@ -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') \ No newline at end of file diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..f7fc16a --- /dev/null +++ b/backend/requirements.txt @@ -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 \ No newline at end of file diff --git a/backend/routes/admin.py b/backend/routes/admin.py new file mode 100644 index 0000000..9873647 --- /dev/null +++ b/backend/routes/admin.py @@ -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 \ No newline at end of file diff --git a/backend/routes/auth.py b/backend/routes/auth.py new file mode 100644 index 0000000..bf34ab0 --- /dev/null +++ b/backend/routes/auth.py @@ -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 \ No newline at end of file diff --git a/backend/routes/excel.py b/backend/routes/excel.py new file mode 100644 index 0000000..3d83b3d --- /dev/null +++ b/backend/routes/excel.py @@ -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 \ No newline at end of file diff --git a/backend/routes/health.py b/backend/routes/health.py new file mode 100644 index 0000000..7d14d88 --- /dev/null +++ b/backend/routes/health.py @@ -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 \ No newline at end of file diff --git a/backend/routes/notifications.py b/backend/routes/notifications.py new file mode 100644 index 0000000..5719ee4 --- /dev/null +++ b/backend/routes/notifications.py @@ -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/', 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 \ No newline at end of file diff --git a/backend/routes/reports.py b/backend/routes/reports.py new file mode 100644 index 0000000..54c120d --- /dev/null +++ b/backend/routes/reports.py @@ -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 \ No newline at end of file diff --git a/backend/routes/scheduler.py b/backend/routes/scheduler.py new file mode 100644 index 0000000..957b3aa --- /dev/null +++ b/backend/routes/scheduler.py @@ -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/', 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 \ No newline at end of file diff --git a/backend/routes/todos.py b/backend/routes/todos.py new file mode 100644 index 0000000..a0d76ac --- /dev/null +++ b/backend/routes/todos.py @@ -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('/', 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('/', 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('/', 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('//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('//responsible/', 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('//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('//followers/', 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('//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('//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('//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('//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 \ No newline at end of file diff --git a/backend/routes/users.py b/backend/routes/users.py new file mode 100644 index 0000000..469fcb2 --- /dev/null +++ b/backend/routes/users.py @@ -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 \ No newline at end of file diff --git a/backend/run_migration.py b/backend/run_migration.py new file mode 100644 index 0000000..b6c2fe3 --- /dev/null +++ b/backend/run_migration.py @@ -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() \ No newline at end of file diff --git a/backend/tasks.py b/backend/tasks.py new file mode 100644 index 0000000..a828b84 --- /dev/null +++ b/backend/tasks.py @@ -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' \ No newline at end of file diff --git a/backend/tasks_simple.py b/backend/tasks_simple.py new file mode 100644 index 0000000..8f62034 --- /dev/null +++ b/backend/tasks_simple.py @@ -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() \ No newline at end of file diff --git a/backend/templates/emails/fire_email.html b/backend/templates/emails/fire_email.html new file mode 100644 index 0000000..3da5e1e --- /dev/null +++ b/backend/templates/emails/fire_email.html @@ -0,0 +1,230 @@ + + + + + + 緊急通知 - {{ todo.title }} + + + +
+
+ 🚨 +

緊急通知

+
URGENT - 立即處理
+
+ +
+ {{ sender_name }} 向您發送了緊急通知 +
{{ timestamp }}
+
+ + {% if custom_message %} +
+ 📝 發送者留言:
+ {{ custom_message }} +
+ {% endif %} + +
+

📋 待辦事項詳情

+ +
+
標題:
+
{{ todo.title }}
+
+ + {% if todo.description %} +
+
描述:
+
{{ todo.description }}
+
+ {% endif %} + +
+
狀態:
+
+ + {% if todo.status == 'NEW' %}新建 + {% elif todo.status == 'IN_PROGRESS' %}進行中 + {% elif todo.status == 'DONE' %}完成 + {% else %}{{ todo.status }}{% endif %} + +
+
+ +
+
優先級:
+
+ + {% if todo.priority == 'HIGH' %}高 + {% elif todo.priority == 'MEDIUM' %}中 + {% elif todo.priority == 'LOW' %}低 + {% else %}{{ todo.priority }}{% endif %} + +
+
+ + {% if todo.due_date %} +
+
到期日:
+
+ {{ todo.due_date.strftime('%Y年%m月%d日') }} +
+
+ {% endif %} + +
+
建立者:
+
{{ todo.creator_display_name or todo.creator_ad }}
+
+ +
+
建立時間:
+
{{ todo.created_at.strftime('%Y年%m月%d日 %H:%M') }}
+
+
+ + + + +
+ + \ No newline at end of file diff --git a/backend/utils/email_service.py b/backend/utils/email_service.py new file mode 100644 index 0000000..ddca0be --- /dev/null +++ b/backend/utils/email_service.py @@ -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""" + + +

郵件服務測試

+

您好 {recipient},

+

這是一封測試郵件,用於驗證 PANJIT Todo List 系統的郵件功能是否正常運作。

+

如果您收到這封郵件,表示郵件服務配置正確。

+
+

測試時間: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}

+

此郵件由系統自動發送,請勿回覆。

+ + + """ + + 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""" + + +
+

📧 郵件服務測試

+

您好!

+

這是一封來自 PANJIT Todo List 系統 的測試郵件,用於驗證郵件服務功能是否正常運作。

+ +
+

✅ 如果您收到這封郵件,表示:

+
    +
  • SMTP 服務器連線正常
  • +
  • 郵件發送功能運作良好
  • +
  • 您的郵件地址設定正確
  • +
+
+ +
+

+ 測試詳細資訊:
+ 📅 測試時間: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}
+ 📧 收件人: {recipient_email}
+ 🏢 發件人: PANJIT Todo List 系統 +

+ +

+ 此郵件由系統自動發送,請勿回覆。如有任何問題,請聯繫系統管理員。 +

+
+ + + """ + + 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 \ No newline at end of file diff --git a/backend/utils/ldap_utils.py b/backend/utils/ldap_utils.py new file mode 100644 index 0000000..54873f2 --- /dev/null +++ b/backend/utils/ldap_utils.py @@ -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 \ No newline at end of file diff --git a/backend/utils/logger.py b/backend/utils/logger.py new file mode 100644 index 0000000..01ef245 --- /dev/null +++ b/backend/utils/logger.py @@ -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) \ No newline at end of file diff --git a/backend/utils/mock_ldap.py b/backend/utils/mock_ldap.py new file mode 100644 index 0000000..ef75b05 --- /dev/null +++ b/backend/utils/mock_ldap.py @@ -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 \ No newline at end of file diff --git a/backend/utils/notification_service.py b/backend/utils/notification_service.py new file mode 100644 index 0000000..6eb27b7 --- /dev/null +++ b/backend/utils/notification_service.py @@ -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 \ No newline at end of file diff --git a/deploy.bat b/deploy.bat new file mode 100644 index 0000000..5d58b44 --- /dev/null +++ b/deploy.bat @@ -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 \ No newline at end of file diff --git a/deploy.sh b/deploy.sh new file mode 100644 index 0000000..8ade357 --- /dev/null +++ b/deploy.sh @@ -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 "========================================" \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..aa32fe3 --- /dev/null +++ b/docker-compose.yml @@ -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 \ No newline at end of file diff --git a/frontend/.dockerignore b/frontend/.dockerignore new file mode 100644 index 0000000..55fbeca --- /dev/null +++ b/frontend/.dockerignore @@ -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 \ No newline at end of file diff --git a/frontend/.env.example b/frontend/.env.example new file mode 100644 index 0000000..b206d7b --- /dev/null +++ b/frontend/.env.example @@ -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 +# - 根據需求調整快取設定 +# - 監控和分析工具可選擇性啟用 \ No newline at end of file diff --git a/frontend/.env.local b/frontend/.env.local new file mode 100644 index 0000000..b206d7b --- /dev/null +++ b/frontend/.env.local @@ -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 +# - 根據需求調整快取設定 +# - 監控和分析工具可選擇性啟用 \ No newline at end of file diff --git a/frontend/next-env.d.ts b/frontend/next-env.d.ts new file mode 100644 index 0000000..4f11a03 --- /dev/null +++ b/frontend/next-env.d.ts @@ -0,0 +1,5 @@ +/// +/// + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/basic-features/typescript for more information. diff --git a/frontend/next.config.js b/frontend/next.config.js new file mode 100644 index 0000000..e35f7a0 --- /dev/null +++ b/frontend/next.config.js @@ -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: /(? 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 \ No newline at end of file diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..4c324e9 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,7808 @@ +{ + "name": "todo-system-frontend", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "todo-system-frontend", + "version": "1.0.0", + "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" + } + }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", + "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==", + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.27.1", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/generator": { + "version": "7.28.3", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.3.tgz", + "integrity": "sha512-3lSpxGgvnmZznmBkCRnVREPUFJv2wrv9iAoFDvADJc0ypmdOxdUtcLeBgBJ6zE0PMeTKnxeQzyk0xTBq4Ep7zw==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.28.3", + "@babel/types": "^7.28.2", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz", + "integrity": "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==", + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.27.1", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz", + "integrity": "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.28.3", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.3.tgz", + "integrity": "sha512-7+Ey1mAgYqFAx2h0RuoxcQT5+MlG3GTV0TQrgr7/ZliKsm/MNDxVVutlWaziMq7wJNAz8MTqz55XLpWvva6StA==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.28.3", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.3.tgz", + "integrity": "sha512-9uIQ10o0WGdpP6GDhXcdOJPJuDgFtIDtN/9+ArJQ2NAfAmiuhTQdzkaTGR33v43GYS2UrSA0eX2pPPHoFVvpxA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template": { + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz", + "integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/parser": "^7.27.2", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.28.3", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.3.tgz", + "integrity": "sha512-7w4kZYHneL3A6NP2nxzHvT3HCZ7puDZZjFMqDpBPECub79sTtSO5CGXDkKrTQq8ksAwfD/XI2MRFX23njdDaIQ==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.28.3", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.28.3", + "@babel/template": "^7.27.2", + "@babel/types": "^7.28.2", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.28.2", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.2.tgz", + "integrity": "sha512-ruv7Ae4J5dUYULmeXw1gmb7rYRz57OWCPM57pHojnLq/3Z1CK2lNSLTCVjxVk1F/TZHwOZZrOWi0ur95BbLxNQ==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@emnapi/core": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.4.5.tgz", + "integrity": "sha512-XsLw1dEOpkSX/WucdqUhPWP7hDxSvZiY+fsUC14h+FtQ2Ifni4znbBt8punRX+Uj2JG/uDb8nEHVKvrVlvdZ5Q==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.0.4", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.4.5.tgz", + "integrity": "sha512-++LApOtY0pEEz1zrd9vy1/zXVaVJJ/EbAF3u0fXIzPJEDtnITsBGbbK0EkM72amhl/R5b+5xx0Y/QhcVOpuulg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.0.4.tgz", + "integrity": "sha512-PJR+bOmMOPH8AtcTGAyYNiuJ3/Fcoj2XN/gBEWzDIKh254XO+mM9XoXHk5GNEhodxeMznbg7BlRojVbKN+gC6g==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emotion/babel-plugin": { + "version": "11.13.5", + "resolved": "https://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.13.5.tgz", + "integrity": "sha512-pxHCpT2ex+0q+HH91/zsdHkw/lXd468DIN2zvfvLtPKLLMo6gQj7oLObq8PhkrxOZb/gGCq03S3Z7PDhS8pduQ==", + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.16.7", + "@babel/runtime": "^7.18.3", + "@emotion/hash": "^0.9.2", + "@emotion/memoize": "^0.9.0", + "@emotion/serialize": "^1.3.3", + "babel-plugin-macros": "^3.1.0", + "convert-source-map": "^1.5.0", + "escape-string-regexp": "^4.0.0", + "find-root": "^1.1.0", + "source-map": "^0.5.7", + "stylis": "4.2.0" + } + }, + "node_modules/@emotion/cache": { + "version": "11.14.0", + "resolved": "https://registry.npmjs.org/@emotion/cache/-/cache-11.14.0.tgz", + "integrity": "sha512-L/B1lc/TViYk4DcpGxtAVbx0ZyiKM5ktoIyafGkH6zg/tj+mA+NE//aPYKG0k8kCHSHVJrpLpcAlOBEXQ3SavA==", + "license": "MIT", + "dependencies": { + "@emotion/memoize": "^0.9.0", + "@emotion/sheet": "^1.4.0", + "@emotion/utils": "^1.4.2", + "@emotion/weak-memoize": "^0.4.0", + "stylis": "4.2.0" + } + }, + "node_modules/@emotion/hash": { + "version": "0.9.2", + "resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.9.2.tgz", + "integrity": "sha512-MyqliTZGuOm3+5ZRSaaBGP3USLw6+EGykkwZns2EPC5g8jJ4z9OrdZY9apkl3+UP9+sdz76YYkwCKP5gh8iY3g==", + "license": "MIT" + }, + "node_modules/@emotion/is-prop-valid": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@emotion/is-prop-valid/-/is-prop-valid-1.3.1.tgz", + "integrity": "sha512-/ACwoqx7XQi9knQs/G0qKvv5teDMhD7bXYns9N/wM8ah8iNb8jZ2uNO0YOgiq2o2poIvVtJS2YALasQuMSQ7Kw==", + "license": "MIT", + "dependencies": { + "@emotion/memoize": "^0.9.0" + } + }, + "node_modules/@emotion/memoize": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.9.0.tgz", + "integrity": "sha512-30FAj7/EoJ5mwVPOWhAyCX+FPfMDrVecJAM+Iw9NRoSl4BBAQeqj4cApHHUXOVvIPgLVDsCFoz/hGD+5QQD1GQ==", + "license": "MIT" + }, + "node_modules/@emotion/react": { + "version": "11.14.0", + "resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.14.0.tgz", + "integrity": "sha512-O000MLDBDdk/EohJPFUqvnp4qnHeYkVP5B0xEG0D/L7cOKP9kefu2DXn8dj74cQfsEzUqh+sr1RzFqiL1o+PpA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.3", + "@emotion/babel-plugin": "^11.13.5", + "@emotion/cache": "^11.14.0", + "@emotion/serialize": "^1.3.3", + "@emotion/use-insertion-effect-with-fallbacks": "^1.2.0", + "@emotion/utils": "^1.4.2", + "@emotion/weak-memoize": "^0.4.0", + "hoist-non-react-statics": "^3.3.1" + }, + "peerDependencies": { + "react": ">=16.8.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@emotion/serialize": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@emotion/serialize/-/serialize-1.3.3.tgz", + "integrity": "sha512-EISGqt7sSNWHGI76hC7x1CksiXPahbxEOrC5RjmFRJTqLyEK9/9hZvBbiYn70dw4wuwMKiEMCUlR6ZXTSWQqxA==", + "license": "MIT", + "dependencies": { + "@emotion/hash": "^0.9.2", + "@emotion/memoize": "^0.9.0", + "@emotion/unitless": "^0.10.0", + "@emotion/utils": "^1.4.2", + "csstype": "^3.0.2" + } + }, + "node_modules/@emotion/sheet": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@emotion/sheet/-/sheet-1.4.0.tgz", + "integrity": "sha512-fTBW9/8r2w3dXWYM4HCB1Rdp8NLibOw2+XELH5m5+AkWiL/KqYX6dc0kKYlaYyKjrQ6ds33MCdMPEwgs2z1rqg==", + "license": "MIT" + }, + "node_modules/@emotion/styled": { + "version": "11.14.1", + "resolved": "https://registry.npmjs.org/@emotion/styled/-/styled-11.14.1.tgz", + "integrity": "sha512-qEEJt42DuToa3gurlH4Qqc1kVpNq8wO8cJtDzU46TjlzWjDlsVyevtYCRijVq3SrHsROS+gVQ8Fnea108GnKzw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.3", + "@emotion/babel-plugin": "^11.13.5", + "@emotion/is-prop-valid": "^1.3.0", + "@emotion/serialize": "^1.3.3", + "@emotion/use-insertion-effect-with-fallbacks": "^1.2.0", + "@emotion/utils": "^1.4.2" + }, + "peerDependencies": { + "@emotion/react": "^11.0.0-rc.0", + "react": ">=16.8.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@emotion/unitless": { + "version": "0.10.0", + "resolved": "https://registry.npmjs.org/@emotion/unitless/-/unitless-0.10.0.tgz", + "integrity": "sha512-dFoMUuQA20zvtVTuxZww6OHoJYgrzfKM1t52mVySDJnMSEa08ruEvdYQbhvyu6soU+NeLVd3yKfTfT0NeV6qGg==", + "license": "MIT" + }, + "node_modules/@emotion/use-insertion-effect-with-fallbacks": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@emotion/use-insertion-effect-with-fallbacks/-/use-insertion-effect-with-fallbacks-1.2.0.tgz", + "integrity": "sha512-yJMtVdH59sxi/aVJBpk9FQq+OR8ll5GT8oWd57UpeaKEVGab41JWaCFA7FRLoMLloOZF/c/wsPoe+bfGmRKgDg==", + "license": "MIT", + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@emotion/utils": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/@emotion/utils/-/utils-1.4.2.tgz", + "integrity": "sha512-3vLclRofFziIa3J2wDh9jjbkUz9qk5Vi3IZ/FSTKViB0k+ef0fPV7dYrUIugbgupYDx7v9ud/SjrtEP8Y4xLoA==", + "license": "MIT" + }, + "node_modules/@emotion/weak-memoize": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@emotion/weak-memoize/-/weak-memoize-0.4.0.tgz", + "integrity": "sha512-snKqtPW01tN0ui7yu9rGv69aJXr/a/Ywvl11sUjNtEcRc+ng/mQriFL0wLXMef74iHa/EkftbDzU9F8iFbH+zg==", + "license": "MIT" + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.7.0.tgz", + "integrity": "sha512-dyybb3AcajC7uha6CvhdVRJqaKyn7w2YKqKyAN37NKYgZT36w+iRb0Dymmc5qEJ549c/S31cMMSFd75bteCpCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.1", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.1.tgz", + "integrity": "sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.1.4.tgz", + "integrity": "sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.12.4", + "debug": "^4.3.2", + "espree": "^9.6.0", + "globals": "^13.19.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.0", + "minimatch": "^3.1.2", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@eslint/eslintrc/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@eslint/js": { + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.57.1.tgz", + "integrity": "sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, + "node_modules/@floating-ui/core": { + "version": "1.7.3", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.3.tgz", + "integrity": "sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.10" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.7.4", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.4.tgz", + "integrity": "sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.7.3", + "@floating-ui/utils": "^0.2.10" + } + }, + "node_modules/@floating-ui/react-dom": { + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.6.tgz", + "integrity": "sha512-4JX6rEatQEvlmgU80wZyq9RT96HZJa88q8hp0pBd+LrczeDI4o6uA2M+uvxngVHo4Ihr8uibXxH6+70zhAFrVw==", + "license": "MIT", + "dependencies": { + "@floating-ui/dom": "^1.7.4" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.10", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.10.tgz", + "integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==", + "license": "MIT" + }, + "node_modules/@humanwhocodes/config-array": { + "version": "0.13.0", + "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz", + "integrity": "sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw==", + "deprecated": "Use @eslint/config-array instead", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanwhocodes/object-schema": "^2.0.3", + "debug": "^4.3.1", + "minimatch": "^3.0.5" + }, + "engines": { + "node": ">=10.10.0" + } + }, + "node_modules/@humanwhocodes/config-array/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@humanwhocodes/config-array/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/object-schema": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-2.0.3.tgz", + "integrity": "sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==", + "deprecated": "Use @eslint/object-schema instead", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@isaacs/cliui/node_modules/ansi-regex": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.0.tgz", + "integrity": "sha512-TKY5pyBkHyADOPYlRT9Lx6F544mPl0vS5Ew7BJ45hA08Q+t3GjbueLliBWN3sMICk6+y7HdyxSzC4bWS8baBdg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/ansi-styles": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", + "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@isaacs/cliui/node_modules/strip-ansi": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", + "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.30", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.30.tgz", + "integrity": "sha512-GQ7Nw5G2lTu/BtHTKfXhKHok2WGetd4XYcVKGx00SjAk8GMwgJM3zr6zORiPGuOE+/vkc90KtTosSSvaCjKb2Q==", + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@mui/base": { + "version": "5.0.0-dev.20240529-082515-213b5e33ab", + "resolved": "https://registry.npmjs.org/@mui/base/-/base-5.0.0-dev.20240529-082515-213b5e33ab.tgz", + "integrity": "sha512-3ic6fc6BHstgM+MGqJEVx3zt9g5THxVXm3VVFUfdeplPqAWWgW2QoKfZDLT10s+pi+MAkpgEBP0kgRidf81Rsw==", + "deprecated": "This package has been replaced by @base-ui-components/react", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.24.6", + "@floating-ui/react-dom": "^2.0.8", + "@mui/types": "^7.2.14-dev.20240529-082515-213b5e33ab", + "@mui/utils": "^6.0.0-dev.20240529-082515-213b5e33ab", + "@popperjs/core": "^2.11.8", + "clsx": "^2.1.1", + "prop-types": "^15.8.1" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@types/react": "^17.0.0 || ^18.0.0", + "react": "^17.0.0 || ^18.0.0", + "react-dom": "^17.0.0 || ^18.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/base/node_modules/@mui/utils": { + "version": "6.4.9", + "resolved": "https://registry.npmjs.org/@mui/utils/-/utils-6.4.9.tgz", + "integrity": "sha512-Y12Q9hbK9g+ZY0T3Rxrx9m2m10gaphDuUMgWxyV5kNJevVxXYCLclYUCC9vXaIk1/NdNDTcW2Yfr2OGvNFNmHg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.26.0", + "@mui/types": "~7.2.24", + "@types/prop-types": "^15.7.14", + "clsx": "^2.1.1", + "prop-types": "^15.8.1", + "react-is": "^19.0.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/core-downloads-tracker": { + "version": "5.18.0", + "resolved": "https://registry.npmjs.org/@mui/core-downloads-tracker/-/core-downloads-tracker-5.18.0.tgz", + "integrity": "sha512-jbhwoQ1AY200PSSOrNXmrFCaSDSJWP7qk6urkTmIirvRXDROkqe+QwcLlUiw/PrREwsIF/vm3/dAXvjlMHF0RA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + } + }, + "node_modules/@mui/icons-material": { + "version": "5.18.0", + "resolved": "https://registry.npmjs.org/@mui/icons-material/-/icons-material-5.18.0.tgz", + "integrity": "sha512-1s0vEZj5XFXDMmz3Arl/R7IncFqJ+WQ95LDp1roHWGDE2oCO3IS4/hmiOv1/8SD9r6B7tv9GLiqVZYHo+6PkTg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.23.9" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@mui/material": "^5.0.0", + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/material": { + "version": "5.18.0", + "resolved": "https://registry.npmjs.org/@mui/material/-/material-5.18.0.tgz", + "integrity": "sha512-bbH/HaJZpFtXGvWg3TsBWG4eyt3gah3E7nCNU8GLyRjVoWcA91Vm/T+sjHfUcwgJSw9iLtucfHBoq+qW/T30aA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.23.9", + "@mui/core-downloads-tracker": "^5.18.0", + "@mui/system": "^5.18.0", + "@mui/types": "~7.2.15", + "@mui/utils": "^5.17.1", + "@popperjs/core": "^2.11.8", + "@types/react-transition-group": "^4.4.10", + "clsx": "^2.1.0", + "csstype": "^3.1.3", + "prop-types": "^15.8.1", + "react-is": "^19.0.0", + "react-transition-group": "^4.4.5" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@emotion/react": "^11.5.0", + "@emotion/styled": "^11.3.0", + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/react": { + "optional": true + }, + "@emotion/styled": { + "optional": true + }, + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/private-theming": { + "version": "5.17.1", + "resolved": "https://registry.npmjs.org/@mui/private-theming/-/private-theming-5.17.1.tgz", + "integrity": "sha512-XMxU0NTYcKqdsG8LRmSoxERPXwMbp16sIXPcLVgLGII/bVNagX0xaheWAwFv8+zDK7tI3ajllkuD3GZZE++ICQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.23.9", + "@mui/utils": "^5.17.1", + "prop-types": "^15.8.1" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/styled-engine": { + "version": "5.18.0", + "resolved": "https://registry.npmjs.org/@mui/styled-engine/-/styled-engine-5.18.0.tgz", + "integrity": "sha512-BN/vKV/O6uaQh2z5rXV+MBlVrEkwoS/TK75rFQ2mjxA7+NBo8qtTAOA4UaM0XeJfn7kh2wZ+xQw2HAx0u+TiBg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.23.9", + "@emotion/cache": "^11.13.5", + "@emotion/serialize": "^1.3.3", + "csstype": "^3.1.3", + "prop-types": "^15.8.1" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@emotion/react": "^11.4.1", + "@emotion/styled": "^11.3.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/react": { + "optional": true + }, + "@emotion/styled": { + "optional": true + } + } + }, + "node_modules/@mui/system": { + "version": "5.18.0", + "resolved": "https://registry.npmjs.org/@mui/system/-/system-5.18.0.tgz", + "integrity": "sha512-ojZGVcRWqWhu557cdO3pWHloIGJdzVtxs3rk0F9L+x55LsUjcMUVkEhiF7E4TMxZoF9MmIHGGs0ZX3FDLAf0Xw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.23.9", + "@mui/private-theming": "^5.17.1", + "@mui/styled-engine": "^5.18.0", + "@mui/types": "~7.2.15", + "@mui/utils": "^5.17.1", + "clsx": "^2.1.0", + "csstype": "^3.1.3", + "prop-types": "^15.8.1" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@emotion/react": "^11.5.0", + "@emotion/styled": "^11.3.0", + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/react": { + "optional": true + }, + "@emotion/styled": { + "optional": true + }, + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/types": { + "version": "7.2.24", + "resolved": "https://registry.npmjs.org/@mui/types/-/types-7.2.24.tgz", + "integrity": "sha512-3c8tRt/CbWZ+pEg7QpSwbdxOk36EfmhbKf6AGZsD1EcLDLTSZoxxJ86FVtcjxvjuhdyBiWKSTGZFaXCnidO2kw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/utils": { + "version": "5.17.1", + "resolved": "https://registry.npmjs.org/@mui/utils/-/utils-5.17.1.tgz", + "integrity": "sha512-jEZ8FTqInt2WzxDV8bhImWBqeQRD99c/id/fq83H0ER9tFl+sfZlaAoCdznGvbSQQ9ividMxqSV2c7cC1vBcQg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.23.9", + "@mui/types": "~7.2.15", + "@types/prop-types": "^15.7.12", + "clsx": "^2.1.1", + "prop-types": "^15.8.1", + "react-is": "^19.0.0" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/x-date-pickers": { + "version": "6.20.2", + "resolved": "https://registry.npmjs.org/@mui/x-date-pickers/-/x-date-pickers-6.20.2.tgz", + "integrity": "sha512-x1jLg8R+WhvkmUETRfX2wC+xJreMii78EXKLl6r3G+ggcAZlPyt0myID1Amf6hvJb9CtR7CgUo8BwR+1Vx9Ggw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.23.2", + "@mui/base": "^5.0.0-beta.22", + "@mui/utils": "^5.14.16", + "@types/react-transition-group": "^4.4.8", + "clsx": "^2.0.0", + "prop-types": "^15.8.1", + "react-transition-group": "^4.4.5" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui" + }, + "peerDependencies": { + "@emotion/react": "^11.9.0", + "@emotion/styled": "^11.8.1", + "@mui/material": "^5.8.6", + "@mui/system": "^5.8.0", + "date-fns": "^2.25.0 || ^3.2.0", + "date-fns-jalali": "^2.13.0-0", + "dayjs": "^1.10.7", + "luxon": "^3.0.2", + "moment": "^2.29.4", + "moment-hijri": "^2.1.2", + "moment-jalaali": "^0.7.4 || ^0.8.0 || ^0.9.0 || ^0.10.0", + "react": "^17.0.0 || ^18.0.0", + "react-dom": "^17.0.0 || ^18.0.0" + }, + "peerDependenciesMeta": { + "@emotion/react": { + "optional": true + }, + "@emotion/styled": { + "optional": true + }, + "date-fns": { + "optional": true + }, + "date-fns-jalali": { + "optional": true + }, + "dayjs": { + "optional": true + }, + "luxon": { + "optional": true + }, + "moment": { + "optional": true + }, + "moment-hijri": { + "optional": true + }, + "moment-jalaali": { + "optional": true + } + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "0.2.12", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz", + "integrity": "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.4.3", + "@emnapi/runtime": "^1.4.3", + "@tybys/wasm-util": "^0.10.0" + } + }, + "node_modules/@next/env": { + "version": "14.0.4", + "resolved": "https://registry.npmjs.org/@next/env/-/env-14.0.4.tgz", + "integrity": "sha512-irQnbMLbUNQpP1wcE5NstJtbuA/69kRfzBrpAD7Gsn8zm/CY6YQYc3HQBz8QPxwISG26tIm5afvvVbu508oBeQ==", + "license": "MIT" + }, + "node_modules/@next/eslint-plugin-next": { + "version": "14.0.4", + "resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-14.0.4.tgz", + "integrity": "sha512-U3qMNHmEZoVmHA0j/57nRfi3AscXNvkOnxDmle/69Jz/G0o/gWjXTDdlgILZdrxQ0Lw/jv2mPW8PGy0EGIHXhQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "glob": "7.1.7" + } + }, + "node_modules/@next/swc-darwin-arm64": { + "version": "14.0.4", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-14.0.4.tgz", + "integrity": "sha512-mF05E/5uPthWzyYDyptcwHptucf/jj09i2SXBPwNzbgBNc+XnwzrL0U6BmPjQeOL+FiB+iG1gwBeq7mlDjSRPg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-darwin-x64": { + "version": "14.0.4", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-14.0.4.tgz", + "integrity": "sha512-IZQ3C7Bx0k2rYtrZZxKKiusMTM9WWcK5ajyhOZkYYTCc8xytmwSzR1skU7qLgVT/EY9xtXDG0WhY6fyujnI3rw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-gnu": { + "version": "14.0.4", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-14.0.4.tgz", + "integrity": "sha512-VwwZKrBQo/MGb1VOrxJ6LrKvbpo7UbROuyMRvQKTFKhNaXjUmKTu7wxVkIuCARAfiI8JpaWAnKR+D6tzpCcM4w==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-musl": { + "version": "14.0.4", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-14.0.4.tgz", + "integrity": "sha512-8QftwPEW37XxXoAwsn+nXlodKWHfpMaSvt81W43Wh8dv0gkheD+30ezWMcFGHLI71KiWmHK5PSQbTQGUiidvLQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-gnu": { + "version": "14.0.4", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-14.0.4.tgz", + "integrity": "sha512-/s/Pme3VKfZAfISlYVq2hzFS8AcAIOTnoKupc/j4WlvF6GQ0VouS2Q2KEgPuO1eMBwakWPB1aYFIA4VNVh667A==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-musl": { + "version": "14.0.4", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-14.0.4.tgz", + "integrity": "sha512-m8z/6Fyal4L9Bnlxde5g2Mfa1Z7dasMQyhEhskDATpqr+Y0mjOBZcXQ7G5U+vgL22cI4T7MfvgtrM2jdopqWaw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-arm64-msvc": { + "version": "14.0.4", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-14.0.4.tgz", + "integrity": "sha512-7Wv4PRiWIAWbm5XrGz3D8HUkCVDMMz9igffZG4NB1p4u1KoItwx9qjATHz88kwCEal/HXmbShucaslXCQXUM5w==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-ia32-msvc": { + "version": "14.0.4", + "resolved": "https://registry.npmjs.org/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-14.0.4.tgz", + "integrity": "sha512-zLeNEAPULsl0phfGb4kdzF/cAVIfaC7hY+kt0/d+y9mzcZHsMS3hAS829WbJ31DkSlVKQeHEjZHIdhN+Pg7Gyg==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-x64-msvc": { + "version": "14.0.4", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-14.0.4.tgz", + "integrity": "sha512-yEh2+R8qDlDCjxVpzOTEpBLQTEFAcP2A8fUFLaWNap9GitYKkKv1//y2S6XY6zsR4rCOPRpU7plYDR+az2n30A==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nolyfill/is-core-module": { + "version": "1.0.39", + "resolved": "https://registry.npmjs.org/@nolyfill/is-core-module/-/is-core-module-1.0.39.tgz", + "integrity": "sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.4.0" + } + }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/@popperjs/core": { + "version": "2.11.8", + "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz", + "integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/popperjs" + } + }, + "node_modules/@reduxjs/toolkit": { + "version": "2.8.2", + "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.8.2.tgz", + "integrity": "sha512-MYlOhQ0sLdw4ud48FoC5w0dH9VfWQjtCjreKwYTT3l+r427qYC5Y8PihNutepr8XrNaBUDQo9khWUwQxZaqt5A==", + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@standard-schema/utils": "^0.3.0", + "immer": "^10.0.3", + "redux": "^5.0.1", + "redux-thunk": "^3.1.0", + "reselect": "^5.1.0" + }, + "peerDependencies": { + "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", + "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-redux": { + "optional": true + } + } + }, + "node_modules/@rtsao/scc": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", + "integrity": "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rushstack/eslint-patch": { + "version": "1.12.0", + "resolved": "https://registry.npmjs.org/@rushstack/eslint-patch/-/eslint-patch-1.12.0.tgz", + "integrity": "sha512-5EwMtOqvJMMa3HbmxLlF74e+3/HhwBTMcvt3nqVJgGCozO6hzIPOBlwm8mGVNR9SN2IJpxSnlxczyDjcn7qIyw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@standard-schema/spec": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.0.0.tgz", + "integrity": "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==", + "license": "MIT" + }, + "node_modules/@standard-schema/utils": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", + "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", + "license": "MIT" + }, + "node_modules/@swc/helpers": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.2.tgz", + "integrity": "sha512-E4KcWTpoLHqwPHLxidpOqQbcrZVgi0rsmmZXUle1jXmJfuIf/UWpczUJ7MZZ5tlxytgJXyp0w4PGkkeLiuIdZw==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tanstack/query-core": { + "version": "5.85.5", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.85.5.tgz", + "integrity": "sha512-KO0WTob4JEApv69iYp1eGvfMSUkgw//IpMnq+//cORBzXf0smyRwPLrUvEe5qtAEGjwZTXrjxg+oJNP/C00t6w==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "5.85.5", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.85.5.tgz", + "integrity": "sha512-/X4EFNcnPiSs8wM2v+b6DqS5mmGeuJQvxBglmDxl6ZQb5V26ouD2SJYAcC3VjbNwqhY2zjxVD15rDA5nGbMn3A==", + "license": "MIT", + "dependencies": { + "@tanstack/query-core": "5.85.5" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18 || ^19" + } + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.0", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.0.tgz", + "integrity": "sha512-VyyPYFlOMNylG45GoAe0xDoLwWuowvf92F9kySqzYh8vmYm7D2u4iUJKa1tOUpS70Ku13ASrOkS4ScXFsTaCNQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/d3-array": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.1.tgz", + "integrity": "sha512-Y2Jn2idRrLzUfAKV2LyRImR+y4oa2AntrgID95SHJxuMUrkNXmanDSed71sRNZysveJVt1hLLemQZIady0FpEg==", + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-shape": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.7.tgz", + "integrity": "sha512-VLvUQ33C+3J+8p+Daf+nYSOsjB4GXp19/S/aGo60m9h1v6XaxjiT82lKVWJCfzhtuZ3yD7i/TPeC/fuKLLOSmg==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json5": { + "version": "0.0.29", + "resolved": "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz", + "integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "20.19.11", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.11.tgz", + "integrity": "sha512-uug3FEEGv0r+jrecvUUpbY8lLisvIjg6AAic6a2bSP5OEOLeJsDSnvhCDov7ipFFMXS3orMpzlmi0ZcuGkBbow==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/parse-json": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.2.tgz", + "integrity": "sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==", + "license": "MIT" + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.24", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.24.tgz", + "integrity": "sha512-0dLEBsA1kI3OezMBF8nSsb7Nk19ZnsyE1LLhB8r27KbgU5H4pvuqZLdtE+aUkJVoXgTVuA+iLIwmZ0TuK4tx6A==", + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.0.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "node_modules/@types/react-transition-group": { + "version": "4.4.12", + "resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.12.tgz", + "integrity": "sha512-8TV6R3h2j7a91c+1DXdJi3Syo69zzIZbz7Lg5tORM5LEJG7X/E6a1V3drRyBRZq7/utz7A+c4OgYLiLcYGHG6w==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*" + } + }, + "node_modules/@types/semver": { + "version": "7.7.0", + "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.7.0.tgz", + "integrity": "sha512-k107IF4+Xr7UHjwDc7Cfd6PRQfbdkiRabXGRjo07b4WyPahFBZCZ1sE+BNxYIJPPg73UkfOsVOLwqVc/6ETrIA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/use-sync-external-store": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", + "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", + "license": "MIT" + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-6.21.0.tgz", + "integrity": "sha512-oy9+hTPCUFpngkEZUSzbf9MxI65wbKFoQYsgPdILTfbUldp5ovUuphZVe4i30emU9M/kP+T64Di0mxl7dSw3MA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.5.1", + "@typescript-eslint/scope-manager": "6.21.0", + "@typescript-eslint/type-utils": "6.21.0", + "@typescript-eslint/utils": "6.21.0", + "@typescript-eslint/visitor-keys": "6.21.0", + "debug": "^4.3.4", + "graphemer": "^1.4.0", + "ignore": "^5.2.4", + "natural-compare": "^1.4.0", + "semver": "^7.5.4", + "ts-api-utils": "^1.0.1" + }, + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^6.0.0 || ^6.0.0-alpha", + "eslint": "^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-6.21.0.tgz", + "integrity": "sha512-tbsV1jPne5CkFQCgPBcDOt30ItF7aJoZL997JSF7MhGQqOeT3svWRYxiqlfA5RUdlHN6Fi+EI9bxqbdyAUZjYQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@typescript-eslint/scope-manager": "6.21.0", + "@typescript-eslint/types": "6.21.0", + "@typescript-eslint/typescript-estree": "6.21.0", + "@typescript-eslint/visitor-keys": "6.21.0", + "debug": "^4.3.4" + }, + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-6.21.0.tgz", + "integrity": "sha512-OwLUIWZJry80O99zvqXVEioyniJMa+d2GrqpUTqi5/v5D5rOrppJVBPa0yKCblcigC0/aYAzxxqQ1B+DS2RYsg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "6.21.0", + "@typescript-eslint/visitor-keys": "6.21.0" + }, + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-6.21.0.tgz", + "integrity": "sha512-rZQI7wHfao8qMX3Rd3xqeYSMCL3SoiSQLBATSiVKARdFGCYSRvmViieZjqc58jKgs8Y8i9YvVVhRbHSTA4VBag==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/typescript-estree": "6.21.0", + "@typescript-eslint/utils": "6.21.0", + "debug": "^4.3.4", + "ts-api-utils": "^1.0.1" + }, + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-6.21.0.tgz", + "integrity": "sha512-1kFmZ1rOm5epu9NZEZm1kckCDGj5UJEf7P1kliH4LKu/RkwpsfqqGmY2OOcUs18lSlQBKLDYBOGxRVtrMN5lpg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-6.21.0.tgz", + "integrity": "sha512-6npJTkZcO+y2/kr+z0hc4HwNfrrP4kNYh57ek7yCNlrBjWQ1Y0OS7jiZTkgumrvkX5HkEKXFZkkdFNkaW2wmUQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@typescript-eslint/types": "6.21.0", + "@typescript-eslint/visitor-keys": "6.21.0", + "debug": "^4.3.4", + "globby": "^11.1.0", + "is-glob": "^4.0.3", + "minimatch": "9.0.3", + "semver": "^7.5.4", + "ts-api-utils": "^1.0.1" + }, + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-6.21.0.tgz", + "integrity": "sha512-NfWVaC8HP9T8cbKQxHcsJBY5YE1O33+jpMwN45qzWWaPDZgLIbo12toGMWnmhvCpd3sIxkpDw3Wv1B3dYrbDQQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.4.0", + "@types/json-schema": "^7.0.12", + "@types/semver": "^7.5.0", + "@typescript-eslint/scope-manager": "6.21.0", + "@typescript-eslint/types": "6.21.0", + "@typescript-eslint/typescript-estree": "6.21.0", + "semver": "^7.5.4" + }, + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^7.0.0 || ^8.0.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-6.21.0.tgz", + "integrity": "sha512-JJtkDduxLi9bivAB+cYOVMtbkqdPOhZ+ZI5LC47MIRrDV4Yn2o+ZnW10Nkmr28xRpSpdJ6Sm42Hjf2+REYXm0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "6.21.0", + "eslint-visitor-keys": "^3.4.1" + }, + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@ungap/structured-clone": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", + "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", + "dev": true, + "license": "ISC" + }, + "node_modules/@unrs/resolver-binding-android-arm-eabi": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm-eabi/-/resolver-binding-android-arm-eabi-1.11.1.tgz", + "integrity": "sha512-ppLRUgHVaGRWUx0R0Ut06Mjo9gBaBkg3v/8AxusGLhsIotbBLuRk51rAzqLC8gq6NyyAojEXglNjzf6R948DNw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@unrs/resolver-binding-android-arm64": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm64/-/resolver-binding-android-arm64-1.11.1.tgz", + "integrity": "sha512-lCxkVtb4wp1v+EoN+HjIG9cIIzPkX5OtM03pQYkG+U5O/wL53LC4QbIeazgiKqluGeVEeBlZahHalCaBvU1a2g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@unrs/resolver-binding-darwin-arm64": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-arm64/-/resolver-binding-darwin-arm64-1.11.1.tgz", + "integrity": "sha512-gPVA1UjRu1Y/IsB/dQEsp2V1pm44Of6+LWvbLc9SDk1c2KhhDRDBUkQCYVWe6f26uJb3fOK8saWMgtX8IrMk3g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@unrs/resolver-binding-darwin-x64": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-x64/-/resolver-binding-darwin-x64-1.11.1.tgz", + "integrity": "sha512-cFzP7rWKd3lZaCsDze07QX1SC24lO8mPty9vdP+YVa3MGdVgPmFc59317b2ioXtgCMKGiCLxJ4HQs62oz6GfRQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@unrs/resolver-binding-freebsd-x64": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-freebsd-x64/-/resolver-binding-freebsd-x64-1.11.1.tgz", + "integrity": "sha512-fqtGgak3zX4DCB6PFpsH5+Kmt/8CIi4Bry4rb1ho6Av2QHTREM+47y282Uqiu3ZRF5IQioJQ5qWRV6jduA+iGw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm-gnueabihf": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-gnueabihf/-/resolver-binding-linux-arm-gnueabihf-1.11.1.tgz", + "integrity": "sha512-u92mvlcYtp9MRKmP+ZvMmtPN34+/3lMHlyMj7wXJDeXxuM0Vgzz0+PPJNsro1m3IZPYChIkn944wW8TYgGKFHw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm-musleabihf": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-musleabihf/-/resolver-binding-linux-arm-musleabihf-1.11.1.tgz", + "integrity": "sha512-cINaoY2z7LVCrfHkIcmvj7osTOtm6VVT16b5oQdS4beibX2SYBwgYLmqhBjA1t51CarSaBuX5YNsWLjsqfW5Cw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm64-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-gnu/-/resolver-binding-linux-arm64-gnu-1.11.1.tgz", + "integrity": "sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm64-musl": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-musl/-/resolver-binding-linux-arm64-musl-1.11.1.tgz", + "integrity": "sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-ppc64-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-ppc64-gnu/-/resolver-binding-linux-ppc64-gnu-1.11.1.tgz", + "integrity": "sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-riscv64-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-gnu/-/resolver-binding-linux-riscv64-gnu-1.11.1.tgz", + "integrity": "sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-riscv64-musl": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-musl/-/resolver-binding-linux-riscv64-musl-1.11.1.tgz", + "integrity": "sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-s390x-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-s390x-gnu/-/resolver-binding-linux-s390x-gnu-1.11.1.tgz", + "integrity": "sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-x64-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-gnu/-/resolver-binding-linux-x64-gnu-1.11.1.tgz", + "integrity": "sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-x64-musl": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-musl/-/resolver-binding-linux-x64-musl-1.11.1.tgz", + "integrity": "sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-wasm32-wasi": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-wasm32-wasi/-/resolver-binding-wasm32-wasi-1.11.1.tgz", + "integrity": "sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@napi-rs/wasm-runtime": "^0.2.11" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@unrs/resolver-binding-win32-arm64-msvc": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-arm64-msvc/-/resolver-binding-win32-arm64-msvc-1.11.1.tgz", + "integrity": "sha512-nRcz5Il4ln0kMhfL8S3hLkxI85BXs3o8EYoattsJNdsX4YUU89iOkVn7g0VHSRxFuVMdM4Q1jEpIId1Ihim/Uw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@unrs/resolver-binding-win32-ia32-msvc": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-ia32-msvc/-/resolver-binding-win32-ia32-msvc-1.11.1.tgz", + "integrity": "sha512-DCEI6t5i1NmAZp6pFonpD5m7i6aFrpofcp4LA2i8IIq60Jyo28hamKBxNrZcyOwVOZkgsRp9O2sXWBWP8MnvIQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@unrs/resolver-binding-win32-x64-msvc": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-x64-msvc/-/resolver-binding-win32-x64-msvc-1.11.1.tgz", + "integrity": "sha512-lrW200hZdbfRtztbygyaq/6jP6AKE8qQN2KvPcJ+x7wiD038YtnYtZ82IMNJ69GJibV7bwL3y9FgK+5w/pYt6g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/acorn": { + "version": "8.15.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", + "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/adler-32": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/adler-32/-/adler-32-1.3.1.tgz", + "integrity": "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "dev": true, + "license": "MIT" + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/arg": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", + "dev": true, + "license": "MIT" + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/array-buffer-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz", + "integrity": "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "is-array-buffer": "^3.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array-includes": { + "version": "3.1.9", + "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.9.tgz", + "integrity": "sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.24.0", + "es-object-atoms": "^1.1.1", + "get-intrinsic": "^1.3.0", + "is-string": "^1.1.1", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array-union": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz", + "integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/array.prototype.findlast": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/array.prototype.findlast/-/array.prototype.findlast-1.2.5.tgz", + "integrity": "sha512-CVvd6FHg1Z3POpBLxO6E6zr+rSKEQ9L6rZHAaY7lLfhKsWYUBBOuMs0e9o24oopj6H+geRCX0YJ+TJLBK2eHyQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.findlastindex": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/array.prototype.findlastindex/-/array.prototype.findlastindex-1.2.6.tgz", + "integrity": "sha512-F/TKATkzseUExPlfvmwQKGITM3DGTK+vkAsCZoDc5daVygbJBnjEUCbgkAvVFsgfXfX4YIqZ/27G3k3tdXrTxQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.9", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "es-shim-unscopables": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.flat": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/array.prototype.flat/-/array.prototype.flat-1.3.3.tgz", + "integrity": "sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.flatmap": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/array.prototype.flatmap/-/array.prototype.flatmap-1.3.3.tgz", + "integrity": "sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.tosorted": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/array.prototype.tosorted/-/array.prototype.tosorted-1.1.4.tgz", + "integrity": "sha512-p6Fx8B7b7ZhL/gmUsAy0D15WhvDccw3mnGNbZpi3pmeJdxtWsj2jEaI4Y6oo3XiHfzuSgPwKc04MYt6KgvC/wA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.3", + "es-errors": "^1.3.0", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/arraybuffer.prototype.slice": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.4.tgz", + "integrity": "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.1", + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "is-array-buffer": "^3.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/ast-types-flow": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.8.tgz", + "integrity": "sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/async-function": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz", + "integrity": "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/autoprefixer": { + "version": "10.4.21", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.21.tgz", + "integrity": "sha512-O+A6LWV5LDHSJD3LjHYoNi4VLsj/Whi7k6zG12xTYaU4cQ8oxQGckXNX8cRHK5yOZ/ppVHe0ZBXGzSV9jXdVbQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/autoprefixer" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "browserslist": "^4.24.4", + "caniuse-lite": "^1.0.30001702", + "fraction.js": "^4.3.7", + "normalize-range": "^0.1.2", + "picocolors": "^1.1.1", + "postcss-value-parser": "^4.2.0" + }, + "bin": { + "autoprefixer": "bin/autoprefixer" + }, + "engines": { + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/available-typed-arrays": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", + "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "possible-typed-array-names": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/axe-core": { + "version": "4.10.3", + "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.10.3.tgz", + "integrity": "sha512-Xm7bpRXnDSX2YE2YFfBk2FnF0ep6tmG7xPh8iHee8MIcrgq762Nkce856dYtJYLkuIoYZvGfTs/PbZhideTcEg==", + "dev": true, + "license": "MPL-2.0", + "engines": { + "node": ">=4" + } + }, + "node_modules/axios": { + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.11.0.tgz", + "integrity": "sha512-1Lx3WLFQWm3ooKDYZD1eXmoGO9fxYQjrycfHFC8P0sCfQVXyROp0p9PFWBehewBOdCwHc+f/b8I0fMto5eSfwA==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.6", + "form-data": "^4.0.4", + "proxy-from-env": "^1.1.0" + } + }, + "node_modules/axobject-query": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", + "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/babel-plugin-macros": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/babel-plugin-macros/-/babel-plugin-macros-3.1.0.tgz", + "integrity": "sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5", + "cosmiconfig": "^7.0.0", + "resolve": "^1.19.0" + }, + "engines": { + "node": ">=10", + "npm": ">=6" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.25.3", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.25.3.tgz", + "integrity": "sha512-cDGv1kkDI4/0e5yON9yM5G/0A5u8sf5TnmdX5C9qHzI9PPu++sQ9zjm1k9NiOrf3riY4OkK0zSGqfvJyJsgCBQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "caniuse-lite": "^1.0.30001735", + "electron-to-chromium": "^1.5.204", + "node-releases": "^2.0.19", + "update-browserslist-db": "^1.1.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/busboy": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", + "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==", + "dependencies": { + "streamsearch": "^1.1.0" + }, + "engines": { + "node": ">=10.16.0" + } + }, + "node_modules/call-bind": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", + "integrity": "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.0", + "es-define-property": "^1.0.0", + "get-intrinsic": "^1.2.4", + "set-function-length": "^1.2.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/camelcase-css": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", + "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001737", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001737.tgz", + "integrity": "sha512-BiloLiXtQNrY5UyF0+1nSJLXUENuhka2pzy2Fx5pGxqavdrxSCW4U6Pn/PoG3Efspi2frRbHpBV2XsrPE6EDlw==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/cfb": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cfb/-/cfb-1.2.2.tgz", + "integrity": "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==", + "license": "Apache-2.0", + "dependencies": { + "adler-32": "~1.3.0", + "crc-32": "~1.2.0" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chokidar/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/client-only": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", + "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==", + "license": "MIT" + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/codepage": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/codepage/-/codepage-1.15.0.tgz", + "integrity": "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/convert-source-map": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz", + "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==", + "license": "MIT" + }, + "node_modules/cosmiconfig": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.1.0.tgz", + "integrity": "sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA==", + "license": "MIT", + "dependencies": { + "@types/parse-json": "^4.0.0", + "import-fresh": "^3.2.1", + "parse-json": "^5.0.0", + "path-type": "^4.0.0", + "yaml": "^1.10.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/crc-32": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz", + "integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==", + "license": "Apache-2.0", + "bin": { + "crc32": "bin/crc32.njs" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true, + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/csstype": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", + "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", + "license": "MIT" + }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.0.tgz", + "integrity": "sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/damerau-levenshtein": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz", + "integrity": "sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/data-view-buffer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", + "integrity": "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/data-view-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-byte-length/-/data-view-byte-length-1.0.2.tgz", + "integrity": "sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/inspect-js" + } + }, + "node_modules/data-view-byte-offset": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/data-view-byte-offset/-/data-view-byte-offset-1.0.1.tgz", + "integrity": "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/dayjs": { + "version": "1.11.15", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.15.tgz", + "integrity": "sha512-MC+DfnSWiM9APs7fpiurHGCoeIx0Gdl6QZBy+5lu8MbYKN5FZEXqOgrundfibdfhGZ15o9hzmZ2xJjZnbvgKXQ==", + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", + "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decimal.js-light": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", + "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", + "license": "MIT" + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/define-properties": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/didyoumean": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", + "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/dir-glob": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", + "integrity": "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-type": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/dlv": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", + "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", + "dev": true, + "license": "MIT" + }, + "node_modules/doctrine": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", + "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "esutils": "^2.0.2" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/dom-helpers": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", + "integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.8.7", + "csstype": "^3.0.2" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "dev": true, + "license": "MIT" + }, + "node_modules/electron-to-chromium": { + "version": "1.5.211", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.211.tgz", + "integrity": "sha512-IGBvimJkotaLzFnwIVgW9/UD/AOJ2tByUmeOrtqBfACSbAw5b1G0XpvdaieKyc7ULmbwXVx+4e4Be8pOPBrYkw==", + "dev": true, + "license": "ISC" + }, + "node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true, + "license": "MIT" + }, + "node_modules/error-ex": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", + "integrity": "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==", + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.2.1" + } + }, + "node_modules/es-abstract": { + "version": "1.24.0", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.0.tgz", + "integrity": "sha512-WSzPgsdLtTcQwm4CROfS5ju2Wa1QQcVeT37jFjYzdFz1r9ahadC8B8/a4qxJxM+09F18iumCdRmlr96ZYkQvEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.2", + "arraybuffer.prototype.slice": "^1.0.4", + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "data-view-buffer": "^1.0.2", + "data-view-byte-length": "^1.0.2", + "data-view-byte-offset": "^1.0.1", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "es-set-tostringtag": "^2.1.0", + "es-to-primitive": "^1.3.0", + "function.prototype.name": "^1.1.8", + "get-intrinsic": "^1.3.0", + "get-proto": "^1.0.1", + "get-symbol-description": "^1.1.0", + "globalthis": "^1.0.4", + "gopd": "^1.2.0", + "has-property-descriptors": "^1.0.2", + "has-proto": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "internal-slot": "^1.1.0", + "is-array-buffer": "^3.0.5", + "is-callable": "^1.2.7", + "is-data-view": "^1.0.2", + "is-negative-zero": "^2.0.3", + "is-regex": "^1.2.1", + "is-set": "^2.0.3", + "is-shared-array-buffer": "^1.0.4", + "is-string": "^1.1.1", + "is-typed-array": "^1.1.15", + "is-weakref": "^1.1.1", + "math-intrinsics": "^1.1.0", + "object-inspect": "^1.13.4", + "object-keys": "^1.1.1", + "object.assign": "^4.1.7", + "own-keys": "^1.0.1", + "regexp.prototype.flags": "^1.5.4", + "safe-array-concat": "^1.1.3", + "safe-push-apply": "^1.0.0", + "safe-regex-test": "^1.1.0", + "set-proto": "^1.0.0", + "stop-iteration-iterator": "^1.1.0", + "string.prototype.trim": "^1.2.10", + "string.prototype.trimend": "^1.0.9", + "string.prototype.trimstart": "^1.0.8", + "typed-array-buffer": "^1.0.3", + "typed-array-byte-length": "^1.0.3", + "typed-array-byte-offset": "^1.0.4", + "typed-array-length": "^1.0.7", + "unbox-primitive": "^1.1.0", + "which-typed-array": "^1.1.19" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-iterator-helpers": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/es-iterator-helpers/-/es-iterator-helpers-1.2.1.tgz", + "integrity": "sha512-uDn+FE1yrDzyC0pCo961B2IHbdM8y/ACZsKD4dG6WqrjV53BADjwa7D+1aom2rsNVfLyDgU/eigvlJGJ08OQ4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.6", + "es-errors": "^1.3.0", + "es-set-tostringtag": "^2.0.3", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.6", + "globalthis": "^1.0.4", + "gopd": "^1.2.0", + "has-property-descriptors": "^1.0.2", + "has-proto": "^1.2.0", + "has-symbols": "^1.1.0", + "internal-slot": "^1.1.0", + "iterator.prototype": "^1.1.4", + "safe-array-concat": "^1.1.3" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-shim-unscopables": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/es-shim-unscopables/-/es-shim-unscopables-1.1.0.tgz", + "integrity": "sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-to-primitive": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.3.0.tgz", + "integrity": "sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7", + "is-date-object": "^1.0.5", + "is-symbol": "^1.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.1.tgz", + "integrity": "sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==", + "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.2.0", + "@eslint-community/regexpp": "^4.6.1", + "@eslint/eslintrc": "^2.1.4", + "@eslint/js": "8.57.1", + "@humanwhocodes/config-array": "^0.13.0", + "@humanwhocodes/module-importer": "^1.0.1", + "@nodelib/fs.walk": "^1.2.8", + "@ungap/structured-clone": "^1.2.0", + "ajv": "^6.12.4", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.2", + "debug": "^4.3.2", + "doctrine": "^3.0.0", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^7.2.2", + "eslint-visitor-keys": "^3.4.3", + "espree": "^9.6.1", + "esquery": "^1.4.2", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^6.0.1", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "globals": "^13.19.0", + "graphemer": "^1.4.0", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "is-path-inside": "^3.0.3", + "js-yaml": "^4.1.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "levn": "^0.4.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.2", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3", + "strip-ansi": "^6.0.1", + "text-table": "^0.2.0" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-config-next": { + "version": "14.0.4", + "resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-14.0.4.tgz", + "integrity": "sha512-9/xbOHEQOmQtqvQ1UsTQZpnA7SlDMBtuKJ//S4JnoyK3oGLhILKXdBgu/UO7lQo/2xOykQULS1qQ6p2+EpHgAQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@next/eslint-plugin-next": "14.0.4", + "@rushstack/eslint-patch": "^1.3.3", + "@typescript-eslint/parser": "^5.4.2 || ^6.0.0", + "eslint-import-resolver-node": "^0.3.6", + "eslint-import-resolver-typescript": "^3.5.2", + "eslint-plugin-import": "^2.28.1", + "eslint-plugin-jsx-a11y": "^6.7.1", + "eslint-plugin-react": "^7.33.2", + "eslint-plugin-react-hooks": "^4.5.0 || 5.0.0-canary-7118f5dd7-20230705" + }, + "peerDependencies": { + "eslint": "^7.23.0 || ^8.0.0", + "typescript": ">=3.3.1" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/eslint-config-next/node_modules/eslint-import-resolver-typescript": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/eslint-import-resolver-typescript/-/eslint-import-resolver-typescript-3.10.1.tgz", + "integrity": "sha512-A1rHYb06zjMGAxdLSkN2fXPBwuSaQ0iO5M/hdyS0Ajj1VBaRp0sPD3dn1FhME3c/JluGFbwSxyCfqdSbtQLAHQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "@nolyfill/is-core-module": "1.0.39", + "debug": "^4.4.0", + "get-tsconfig": "^4.10.0", + "is-bun-module": "^2.0.0", + "stable-hash": "^0.0.5", + "tinyglobby": "^0.2.13", + "unrs-resolver": "^1.6.2" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint-import-resolver-typescript" + }, + "peerDependencies": { + "eslint": "*", + "eslint-plugin-import": "*", + "eslint-plugin-import-x": "*" + }, + "peerDependenciesMeta": { + "eslint-plugin-import": { + "optional": true + }, + "eslint-plugin-import-x": { + "optional": true + } + } + }, + "node_modules/eslint-import-resolver-node": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.9.tgz", + "integrity": "sha512-WFj2isz22JahUv+B788TlO3N6zL3nNJGU8CcZbPZvVEkBPaJdCV4vy5wyghty5ROFbCRnm132v8BScu5/1BQ8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^3.2.7", + "is-core-module": "^2.13.0", + "resolve": "^1.22.4" + } + }, + "node_modules/eslint-import-resolver-node/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/eslint-module-utils": { + "version": "2.12.1", + "resolved": "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.12.1.tgz", + "integrity": "sha512-L8jSWTze7K2mTg0vos/RuLRS5soomksDPoJLXIslC7c8Wmut3bx7CPpJijDcBZtxQ5lrbUdM+s0OlNbz0DCDNw==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^3.2.7" + }, + "engines": { + "node": ">=4" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/eslint-module-utils/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/eslint-plugin-import": { + "version": "2.32.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.32.0.tgz", + "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rtsao/scc": "^1.1.0", + "array-includes": "^3.1.9", + "array.prototype.findlastindex": "^1.2.6", + "array.prototype.flat": "^1.3.3", + "array.prototype.flatmap": "^1.3.3", + "debug": "^3.2.7", + "doctrine": "^2.1.0", + "eslint-import-resolver-node": "^0.3.9", + "eslint-module-utils": "^2.12.1", + "hasown": "^2.0.2", + "is-core-module": "^2.16.1", + "is-glob": "^4.0.3", + "minimatch": "^3.1.2", + "object.fromentries": "^2.0.8", + "object.groupby": "^1.0.3", + "object.values": "^1.2.1", + "semver": "^6.3.1", + "string.prototype.trimend": "^1.0.9", + "tsconfig-paths": "^3.15.0" + }, + "engines": { + "node": ">=4" + }, + "peerDependencies": { + "eslint": "^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 || ^9" + } + }, + "node_modules/eslint-plugin-import/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/eslint-plugin-import/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/eslint-plugin-import/node_modules/doctrine": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", + "integrity": "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "esutils": "^2.0.2" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/eslint-plugin-import/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/eslint-plugin-import/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/eslint-plugin-jsx-a11y": { + "version": "6.10.2", + "resolved": "https://registry.npmjs.org/eslint-plugin-jsx-a11y/-/eslint-plugin-jsx-a11y-6.10.2.tgz", + "integrity": "sha512-scB3nz4WmG75pV8+3eRUQOHZlNSUhFNq37xnpgRkCCELU3XMvXAxLk1eqWWyE22Ki4Q01Fnsw9BA3cJHDPgn2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "aria-query": "^5.3.2", + "array-includes": "^3.1.8", + "array.prototype.flatmap": "^1.3.2", + "ast-types-flow": "^0.0.8", + "axe-core": "^4.10.0", + "axobject-query": "^4.1.0", + "damerau-levenshtein": "^1.0.8", + "emoji-regex": "^9.2.2", + "hasown": "^2.0.2", + "jsx-ast-utils": "^3.3.5", + "language-tags": "^1.0.9", + "minimatch": "^3.1.2", + "object.fromentries": "^2.0.8", + "safe-regex-test": "^1.0.3", + "string.prototype.includes": "^2.0.1" + }, + "engines": { + "node": ">=4.0" + }, + "peerDependencies": { + "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9" + } + }, + "node_modules/eslint-plugin-jsx-a11y/node_modules/aria-query": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz", + "integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/eslint-plugin-jsx-a11y/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/eslint-plugin-jsx-a11y/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/eslint-plugin-react": { + "version": "7.37.5", + "resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.37.5.tgz", + "integrity": "sha512-Qteup0SqU15kdocexFNAJMvCJEfa2xUKNV4CC1xsVMrIIqEy3SQ/rqyxCWNzfrd3/ldy6HMlD2e0JDVpDg2qIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-includes": "^3.1.8", + "array.prototype.findlast": "^1.2.5", + "array.prototype.flatmap": "^1.3.3", + "array.prototype.tosorted": "^1.1.4", + "doctrine": "^2.1.0", + "es-iterator-helpers": "^1.2.1", + "estraverse": "^5.3.0", + "hasown": "^2.0.2", + "jsx-ast-utils": "^2.4.1 || ^3.0.0", + "minimatch": "^3.1.2", + "object.entries": "^1.1.9", + "object.fromentries": "^2.0.8", + "object.values": "^1.2.1", + "prop-types": "^15.8.1", + "resolve": "^2.0.0-next.5", + "semver": "^6.3.1", + "string.prototype.matchall": "^4.0.12", + "string.prototype.repeat": "^1.0.0" + }, + "engines": { + "node": ">=4" + }, + "peerDependencies": { + "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7" + } + }, + "node_modules/eslint-plugin-react-hooks": { + "version": "5.0.0-canary-7118f5dd7-20230705", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-5.0.0-canary-7118f5dd7-20230705.tgz", + "integrity": "sha512-AZYbMo/NW9chdL7vk6HQzQhT+PvTAEVqWk9ziruUoW2kAOcN5qNyelv70e0F1VNQAbvutOC9oc+xfWycI9FxDw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0" + } + }, + "node_modules/eslint-plugin-react/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/eslint-plugin-react/node_modules/doctrine": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", + "integrity": "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "esutils": "^2.0.2" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/eslint-plugin-react/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/eslint-plugin-react/node_modules/resolve": { + "version": "2.0.0-next.5", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.5.tgz", + "integrity": "sha512-U7WjGVG9sH8tvjW5SmGbQuui75FiyjAX72HX15DwBBwF9dNiQZRQAg9nnPhYy+TUnE0+VcrttuvNI8oSxZcocA==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-core-module": "^2.13.0", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/eslint-plugin-react/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/eslint-scope": { + "version": "7.2.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz", + "integrity": "sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/eslint/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/espree": { + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz", + "integrity": "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.9.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^3.4.1" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz", + "integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/eventemitter3": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", + "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", + "license": "MIT" + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-equals": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.2.2.tgz", + "integrity": "sha512-V7/RktU11J3I36Nwq2JnZEM7tNm17eBJz+u25qdxBZeCKiX6BkVSZQjwWIr+IobgnZy+ag73tTZgZi7tr0LrBw==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fastq": { + "version": "1.19.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz", + "integrity": "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/file-entry-cache": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", + "integrity": "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^3.0.4" + }, + "engines": { + "node": "^10.12.0 || >=12.0.0" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/find-root": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/find-root/-/find-root-1.1.0.tgz", + "integrity": "sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng==", + "license": "MIT" + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.2.0.tgz", + "integrity": "sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.3", + "rimraf": "^3.0.2" + }, + "engines": { + "node": "^10.12.0 || >=12.0.0" + } + }, + "node_modules/flatted": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", + "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", + "dev": true, + "license": "ISC" + }, + "node_modules/follow-redirects": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/for-each": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", + "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "dev": true, + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/foreground-child/node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/form-data": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz", + "integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/frac": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/frac/-/frac-1.1.2.tgz", + "integrity": "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/fraction.js": { + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.7.tgz", + "integrity": "sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + }, + "funding": { + "type": "patreon", + "url": "https://github.com/sponsors/rawify" + } + }, + "node_modules/framer-motion": { + "version": "10.18.0", + "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-10.18.0.tgz", + "integrity": "sha512-oGlDh1Q1XqYPksuTD/usb0I70hq95OUzmL9+6Zd+Hs4XV0oaISBa/UUMSjYiq6m8EUF32132mOJ8xVZS+I0S6w==", + "license": "MIT", + "dependencies": { + "tslib": "^2.4.0" + }, + "optionalDependencies": { + "@emotion/is-prop-valid": "^0.8.2" + }, + "peerDependencies": { + "react": "^18.0.0", + "react-dom": "^18.0.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-dom": { + "optional": true + } + } + }, + "node_modules/framer-motion/node_modules/@emotion/is-prop-valid": { + "version": "0.8.8", + "resolved": "https://registry.npmjs.org/@emotion/is-prop-valid/-/is-prop-valid-0.8.8.tgz", + "integrity": "sha512-u5WtneEAr5IDG2Wv65yhunPSMLIpuKsbuOktRojfrEiEvRyC85LgPMZI63cr7NUqT8ZIGdSVg8ZKGxIug4lXcA==", + "license": "MIT", + "optional": true, + "dependencies": { + "@emotion/memoize": "0.7.4" + } + }, + "node_modules/framer-motion/node_modules/@emotion/memoize": { + "version": "0.7.4", + "resolved": "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.7.4.tgz", + "integrity": "sha512-Ja/Vfqe3HpuzRsG1oBtWTHk2PGZ7GR+2Vz5iYGelAw8dx32K0y7PjVuxK6z1nMpZOqAFsRUPCkK1YjJ56qJlgw==", + "license": "MIT", + "optional": true + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "dev": true, + "license": "ISC" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/function.prototype.name": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.1.8.tgz", + "integrity": "sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "functions-have-names": "^1.2.3", + "hasown": "^2.0.2", + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/functions-have-names": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", + "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-symbol-description": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.1.0.tgz", + "integrity": "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-tsconfig": { + "version": "4.10.1", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.10.1.tgz", + "integrity": "sha512-auHyJ4AgMz7vgS8Hp3N6HXSmlMdUyhSUrfBF16w153rxtLIEOE+HGqaBppczZvnHLqQJfiHotCYpNhl0lUROFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, + "node_modules/glob": { + "version": "7.1.7", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.7.tgz", + "integrity": "sha512-OvD9ENzPLbegENnYP5UUfJIirTg4+XwMWGaQfQTY0JenxNvvIKP3U3/tAQSPIu/lHxXYSZmpXlUHeqAIdKzBLQ==", + "deprecated": "Glob versions prior to v9 are no longer supported", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.0.4", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/glob-to-regexp": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz", + "integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==", + "license": "BSD-2-Clause" + }, + "node_modules/glob/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/glob/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/globals": { + "version": "13.24.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz", + "integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "type-fest": "^0.20.2" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/globalthis": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", + "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-properties": "^1.2.1", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/globby": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz", + "integrity": "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-union": "^2.1.0", + "dir-glob": "^3.0.1", + "fast-glob": "^3.2.9", + "ignore": "^5.2.0", + "merge2": "^1.4.1", + "slash": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/goober": { + "version": "2.1.16", + "resolved": "https://registry.npmjs.org/goober/-/goober-2.1.16.tgz", + "integrity": "sha512-erjk19y1U33+XAMe1VTvIONHYoSqE4iS7BYUZfHaqeohLmnC0FdxEh7rQU+6MZ4OajItzjZFSRtVANrQwNq6/g==", + "license": "MIT", + "peerDependencies": { + "csstype": "^3.0.10" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC" + }, + "node_modules/graphemer": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", + "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", + "dev": true, + "license": "MIT" + }, + "node_modules/has-bigints": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", + "integrity": "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-proto": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.2.0.tgz", + "integrity": "sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hoist-non-react-statics": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", + "integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==", + "license": "BSD-3-Clause", + "dependencies": { + "react-is": "^16.7.0" + } + }, + "node_modules/hoist-non-react-statics/node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "license": "MIT" + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/immer": { + "version": "10.1.1", + "resolved": "https://registry.npmjs.org/immer/-/immer-10.1.1.tgz", + "integrity": "sha512-s2MPrmjovJcoMaHtx6K11Ra7oD05NT97w1IC5zpMkT6Atjr7H8LjaDd81iIxUYpMKSRRNMJE703M1Fhr/TctHw==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "dev": true, + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/internal-slot": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", + "integrity": "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "hasown": "^2.0.2", + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/is-array-buffer": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", + "integrity": "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", + "license": "MIT" + }, + "node_modules/is-async-function": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-async-function/-/is-async-function-2.1.1.tgz", + "integrity": "sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "async-function": "^1.0.0", + "call-bound": "^1.0.3", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-bigint": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.1.0.tgz", + "integrity": "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-bigints": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-boolean-object": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.2.tgz", + "integrity": "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-bun-module": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-bun-module/-/is-bun-module-2.0.0.tgz", + "integrity": "sha512-gNCGbnnnnFAUGKeZ9PdbyeGYJqewpmc2aKHUEMO5nQPWU9lOmv7jcmQIv+qHD8fXW6W7qfuCwX4rY9LNRjXrkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.7.1" + } + }, + "node_modules/is-callable": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", + "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-core-module": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-data-view": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-data-view/-/is-data-view-1.0.2.tgz", + "integrity": "sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "get-intrinsic": "^1.2.6", + "is-typed-array": "^1.1.13" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-date-object": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.1.0.tgz", + "integrity": "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-finalizationregistry": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-finalizationregistry/-/is-finalizationregistry-1.1.1.tgz", + "integrity": "sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-generator-function": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.0.tgz", + "integrity": "sha512-nPUB5km40q9e8UfN/Zc24eLlzdSf9OfKByBw9CIdw4H1giPMeA0OIJvbchsCu4npfI2QcMVBsGEBHKZ7wLTWmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "get-proto": "^1.0.0", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-map": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz", + "integrity": "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-negative-zero": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.3.tgz", + "integrity": "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-number-object": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.1.1.tgz", + "integrity": "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-path-inside": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz", + "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-regex": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", + "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-set": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.3.tgz", + "integrity": "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-shared-array-buffer": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.4.tgz", + "integrity": "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-string": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.1.1.tgz", + "integrity": "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-symbol": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.1.1.tgz", + "integrity": "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-symbols": "^1.1.0", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-typed-array": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", + "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakmap": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz", + "integrity": "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakref": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.1.1.tgz", + "integrity": "sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakset": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.4.tgz", + "integrity": "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "dev": true, + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/iterator.prototype": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/iterator.prototype/-/iterator.prototype-1.1.5.tgz", + "integrity": "sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.6", + "get-proto": "^1.0.0", + "has-symbols": "^1.1.0", + "set-function-name": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, + "node_modules/jiti": { + "version": "1.21.7", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", + "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "bin/jiti.js" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", + "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-parse-even-better-errors": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsx-ast-utils": { + "version": "3.3.5", + "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz", + "integrity": "sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-includes": "^3.1.6", + "array.prototype.flat": "^1.3.1", + "object.assign": "^4.1.4", + "object.values": "^1.1.6" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/language-subtag-registry": { + "version": "0.3.23", + "resolved": "https://registry.npmjs.org/language-subtag-registry/-/language-subtag-registry-0.3.23.tgz", + "integrity": "sha512-0K65Lea881pHotoGEa5gDlMxt3pctLi2RplBb7Ezh4rRdLEOtgi7n4EwK9lamnUCkKBqaeKRVebTq6BAxSkpXQ==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/language-tags": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/language-tags/-/language-tags-1.0.9.tgz", + "integrity": "sha512-MbjN408fEndfiQXbFQ1vnd+1NoLDsnQW41410oQBXiyXDMYH5z505juWa4KUE1LqxRC7DgOgZDbKLxHIwm27hA==", + "dev": true, + "license": "MIT", + "dependencies": { + "language-subtag-registry": "^0.3.20" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "license": "MIT" + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", + "license": "MIT" + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/minimatch": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.3.tgz", + "integrity": "sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/minipass": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", + "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/napi-postinstall": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/napi-postinstall/-/napi-postinstall-0.3.3.tgz", + "integrity": "sha512-uTp172LLXSxuSYHv/kou+f6KW3SMppU9ivthaVTXian9sOt3XM/zHYHpRZiLgQoxeWfYUnslNWQHF1+G71xcow==", + "dev": true, + "license": "MIT", + "bin": { + "napi-postinstall": "lib/cli.js" + }, + "engines": { + "node": "^12.20.0 || ^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/napi-postinstall" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/next": { + "version": "14.0.4", + "resolved": "https://registry.npmjs.org/next/-/next-14.0.4.tgz", + "integrity": "sha512-qbwypnM7327SadwFtxXnQdGiKpkuhaRLE2uq62/nRul9cj9KhQ5LhHmlziTNqUidZotw/Q1I9OjirBROdUJNgA==", + "license": "MIT", + "dependencies": { + "@next/env": "14.0.4", + "@swc/helpers": "0.5.2", + "busboy": "1.6.0", + "caniuse-lite": "^1.0.30001406", + "graceful-fs": "^4.2.11", + "postcss": "8.4.31", + "styled-jsx": "5.1.1", + "watchpack": "2.4.0" + }, + "bin": { + "next": "dist/bin/next" + }, + "engines": { + "node": ">=18.17.0" + }, + "optionalDependencies": { + "@next/swc-darwin-arm64": "14.0.4", + "@next/swc-darwin-x64": "14.0.4", + "@next/swc-linux-arm64-gnu": "14.0.4", + "@next/swc-linux-arm64-musl": "14.0.4", + "@next/swc-linux-x64-gnu": "14.0.4", + "@next/swc-linux-x64-musl": "14.0.4", + "@next/swc-win32-arm64-msvc": "14.0.4", + "@next/swc-win32-ia32-msvc": "14.0.4", + "@next/swc-win32-x64-msvc": "14.0.4" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.1.0", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "sass": "^1.3.0" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + }, + "sass": { + "optional": true + } + } + }, + "node_modules/next-themes": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/next-themes/-/next-themes-0.2.1.tgz", + "integrity": "sha512-B+AKNfYNIzh0vqQQKqQItTS8evEouKD7H5Hj3kmuPERwddR2TxvDSFZuTj6T7Jfn1oyeUyJMydPl1Bkxkh0W7A==", + "license": "MIT", + "peerDependencies": { + "next": "*", + "react": "*", + "react-dom": "*" + } + }, + "node_modules/next/node_modules/postcss": { + "version": "8.4.31", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", + "integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.6", + "picocolors": "^1.0.0", + "source-map-js": "^1.0.2" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/node-releases": { + "version": "2.0.19", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.19.tgz", + "integrity": "sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==", + "dev": true, + "license": "MIT" + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/normalize-range": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/normalize-range/-/normalize-range-0.1.2.tgz", + "integrity": "sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.assign": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.7.tgz", + "integrity": "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0", + "has-symbols": "^1.1.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object.entries": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/object.entries/-/object.entries-1.1.9.tgz", + "integrity": "sha512-8u/hfXFRBD1O0hPUjioLhoWFHRmt6tKA4/vZPyckBr18l1KE9uHrFaFaUi8MDRTpi4uak2goyPTSNJLXX2k2Hw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.fromentries": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/object.fromentries/-/object.fromentries-2.0.8.tgz", + "integrity": "sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object.groupby": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/object.groupby/-/object.groupby-1.0.3.tgz", + "integrity": "sha512-+Lhy3TQTuzXI5hevh8sBGqbmurHbbIjAi0Z4S63nthVLmLxfbj4T54a4CfZrXIrt9iP4mVAPYMo/v99taj3wjQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.values": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/object.values/-/object.values-1.2.1.tgz", + "integrity": "sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/own-keys": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz", + "integrity": "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-intrinsic": "^1.2.6", + "object-keys": "^1.1.1", + "safe-push-apply": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "dev": true, + "license": "BlueOak-1.0.0" + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/parse-json": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", + "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.0.0", + "error-ex": "^1.3.1", + "json-parse-even-better-errors": "^2.3.0", + "lines-and-columns": "^1.1.6" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "license": "MIT" + }, + "node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-scurry/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/path-type": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", + "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/possible-typed-array-names": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", + "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-import": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", + "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.0.0", + "read-cache": "^1.0.0", + "resolve": "^1.1.7" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "postcss": "^8.0.0" + } + }, + "node_modules/postcss-js": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.0.1.tgz", + "integrity": "sha512-dDLF8pEO191hJMtlHFPRa8xsizHaM82MLfNkUHdUtVEV3tgTp5oj+8qbEqYM57SLfc74KSbw//4SeJma2LRVIw==", + "dev": true, + "license": "MIT", + "dependencies": { + "camelcase-css": "^2.0.1" + }, + "engines": { + "node": "^12 || ^14 || >= 16" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + "peerDependencies": { + "postcss": "^8.4.21" + } + }, + "node_modules/postcss-nested": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz", + "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "^6.1.1" + }, + "engines": { + "node": ">=12.0" + }, + "peerDependencies": { + "postcss": "^8.2.14" + } + }, + "node_modules/postcss-selector-parser": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", + "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, + "node_modules/prop-types/node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "license": "MIT" + }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "license": "MIT" + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-hook-form": { + "version": "7.62.0", + "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.62.0.tgz", + "integrity": "sha512-7KWFejc98xqG/F4bAxpL41NB3o1nnvQO1RWZT3TqRZYL8RryQETGfEdVnJN2fy1crCiBLLjkRBVK05j24FxJGA==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/react-hook-form" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17 || ^18 || ^19" + } + }, + "node_modules/react-hot-toast": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/react-hot-toast/-/react-hot-toast-2.6.0.tgz", + "integrity": "sha512-bH+2EBMZ4sdyou/DPrfgIouFpcRLCJ+HoCA32UoAYHn6T3Ur5yfcDCeSr5mwldl6pFOsiocmrXMuoCJ1vV8bWg==", + "license": "MIT", + "dependencies": { + "csstype": "^3.1.3", + "goober": "^2.1.16" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "react": ">=16", + "react-dom": ">=16" + } + }, + "node_modules/react-is": { + "version": "19.1.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.1.1.tgz", + "integrity": "sha512-tr41fA15Vn8p4X9ntI+yCyeGSf1TlYaY5vlTZfQmeLBrFo3psOPX6HhTDnFNL9uj3EhP0KAQ80cugCl4b4BERA==", + "license": "MIT" + }, + "node_modules/react-redux": { + "version": "9.2.0", + "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", + "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", + "license": "MIT", + "dependencies": { + "@types/use-sync-external-store": "^0.0.6", + "use-sync-external-store": "^1.4.0" + }, + "peerDependencies": { + "@types/react": "^18.2.25 || ^19", + "react": "^18.0 || ^19", + "redux": "^5.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "redux": { + "optional": true + } + } + }, + "node_modules/react-smooth": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/react-smooth/-/react-smooth-4.0.4.tgz", + "integrity": "sha512-gnGKTpYwqL0Iii09gHobNolvX4Kiq4PKx6eWBCYYix+8cdw+cGo3do906l1NBPKkSWx1DghC1dlWG9L2uGd61Q==", + "license": "MIT", + "dependencies": { + "fast-equals": "^5.0.1", + "prop-types": "^15.8.1", + "react-transition-group": "^4.4.5" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/react-transition-group": { + "version": "4.4.5", + "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz", + "integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==", + "license": "BSD-3-Clause", + "dependencies": { + "@babel/runtime": "^7.5.5", + "dom-helpers": "^5.0.1", + "loose-envify": "^1.4.0", + "prop-types": "^15.6.2" + }, + "peerDependencies": { + "react": ">=16.6.0", + "react-dom": ">=16.6.0" + } + }, + "node_modules/read-cache": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", + "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pify": "^2.3.0" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/recharts": { + "version": "2.15.4", + "resolved": "https://registry.npmjs.org/recharts/-/recharts-2.15.4.tgz", + "integrity": "sha512-UT/q6fwS3c1dHbXv2uFgYJ9BMFHu3fwnd7AYZaEQhXuYQ4hgsxLvsUXzGdKeZrW5xopzDCvuA2N41WJ88I7zIw==", + "license": "MIT", + "dependencies": { + "clsx": "^2.0.0", + "eventemitter3": "^4.0.1", + "lodash": "^4.17.21", + "react-is": "^18.3.1", + "react-smooth": "^4.0.4", + "recharts-scale": "^0.4.4", + "tiny-invariant": "^1.3.1", + "victory-vendor": "^36.6.8" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "react": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/recharts-scale": { + "version": "0.4.5", + "resolved": "https://registry.npmjs.org/recharts-scale/-/recharts-scale-0.4.5.tgz", + "integrity": "sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w==", + "license": "MIT", + "dependencies": { + "decimal.js-light": "^2.4.1" + } + }, + "node_modules/recharts/node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "license": "MIT" + }, + "node_modules/redux": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", + "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", + "license": "MIT" + }, + "node_modules/redux-thunk": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz", + "integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==", + "license": "MIT", + "peerDependencies": { + "redux": "^5.0.0" + } + }, + "node_modules/reflect.getprototypeof": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", + "integrity": "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.9", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.7", + "get-proto": "^1.0.1", + "which-builtin-type": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/regexp.prototype.flags": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", + "integrity": "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-errors": "^1.3.0", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "set-function-name": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/reselect": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz", + "integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==", + "license": "MIT" + }, + "node_modules/resolve": { + "version": "1.22.10", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz", + "integrity": "sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w==", + "license": "MIT", + "dependencies": { + "is-core-module": "^2.16.0", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "dev": true, + "license": "ISC", + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/safe-array-concat": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.3.tgz", + "integrity": "sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "get-intrinsic": "^1.2.6", + "has-symbols": "^1.1.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">=0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-push-apply": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz", + "integrity": "sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-regex-test": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", + "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-regex": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "7.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", + "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/set-function-length": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-function-name": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz", + "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "functions-have-names": "^1.2.3", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-proto": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/set-proto/-/set-proto-1.0.0.tgz", + "integrity": "sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/slash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", + "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/source-map": { + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", + "integrity": "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ssf": { + "version": "0.11.2", + "resolved": "https://registry.npmjs.org/ssf/-/ssf-0.11.2.tgz", + "integrity": "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==", + "license": "Apache-2.0", + "dependencies": { + "frac": "~1.1.2" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/stable-hash": { + "version": "0.0.5", + "resolved": "https://registry.npmjs.org/stable-hash/-/stable-hash-0.0.5.tgz", + "integrity": "sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA==", + "dev": true, + "license": "MIT" + }, + "node_modules/stop-iteration-iterator": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", + "integrity": "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "internal-slot": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/streamsearch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", + "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/string-width/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/string.prototype.includes": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/string.prototype.includes/-/string.prototype.includes-2.0.1.tgz", + "integrity": "sha512-o7+c9bW6zpAdJHTtujeePODAhkuicdAryFsfVKwA+wGw89wJ4GTY484WTucM9hLtDEOpOvI+aHnzqnC5lHp4Rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.3" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/string.prototype.matchall": { + "version": "4.0.12", + "resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.0.12.tgz", + "integrity": "sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.6", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.6", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "internal-slot": "^1.1.0", + "regexp.prototype.flags": "^1.5.3", + "set-function-name": "^2.0.2", + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.repeat": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/string.prototype.repeat/-/string.prototype.repeat-1.0.0.tgz", + "integrity": "sha512-0u/TldDbKD8bFCQ/4f5+mNRrXwZ8hg2w7ZR8wa16e8z9XpePWl3eGEcUD0OXpEH/VJH/2G3gjUtR3ZOiBe2S/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-properties": "^1.1.3", + "es-abstract": "^1.17.5" + } + }, + "node_modules/string.prototype.trim": { + "version": "1.2.10", + "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.10.tgz", + "integrity": "sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "define-data-property": "^1.1.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-object-atoms": "^1.0.0", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimend": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.9.tgz", + "integrity": "sha512-G7Ok5C6E/j4SGfyLCloXTrngQIQU3PWtXGst3yM7Bea9FRURf1S42ZHlZZtsNque2FN2PoUhfZXYLNWwEr4dLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimstart": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.8.tgz", + "integrity": "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/styled-jsx": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.1.tgz", + "integrity": "sha512-pW7uC1l4mBZ8ugbiZrcIsiIvVx1UmTfw7UkC3Um2tmfUq9Bhk8IiyEIPl6F8agHgjzku6j0xQEZbfA5uSgSaCw==", + "license": "MIT", + "dependencies": { + "client-only": "0.0.1" + }, + "engines": { + "node": ">= 12.0.0" + }, + "peerDependencies": { + "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0" + }, + "peerDependenciesMeta": { + "@babel/core": { + "optional": true + }, + "babel-plugin-macros": { + "optional": true + } + } + }, + "node_modules/stylis": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.2.0.tgz", + "integrity": "sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw==", + "license": "MIT" + }, + "node_modules/sucrase": { + "version": "3.35.0", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.0.tgz", + "integrity": "sha512-8EbVDiu9iN/nESwxeSxDKe0dunta1GOlHufmSSXxMD2z2/tMZpDMpvXQGsc+ajGo8y2uYUmixaSRUc/QPoQ0GA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "glob": "^10.3.10", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/sucrase/node_modules/glob": { + "version": "10.4.5", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", + "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", + "dev": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/sucrase/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/tailwindcss": { + "version": "3.4.17", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.17.tgz", + "integrity": "sha512-w33E2aCvSDP0tW9RZuNXadXlkHXqFzSkQew/aIa2i/Sj8fThxwovwlXHSPXTbAHwEIhBFXAedUhP2tueAKP8Og==", + "dev": true, + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "arg": "^5.0.2", + "chokidar": "^3.6.0", + "didyoumean": "^1.2.2", + "dlv": "^1.1.3", + "fast-glob": "^3.3.2", + "glob-parent": "^6.0.2", + "is-glob": "^4.0.3", + "jiti": "^1.21.6", + "lilconfig": "^3.1.3", + "micromatch": "^4.0.8", + "normalize-path": "^3.0.0", + "object-hash": "^3.0.0", + "picocolors": "^1.1.1", + "postcss": "^8.4.47", + "postcss-import": "^15.1.0", + "postcss-js": "^4.0.1", + "postcss-load-config": "^4.0.2", + "postcss-nested": "^6.2.0", + "postcss-selector-parser": "^6.1.2", + "resolve": "^1.22.8", + "sucrase": "^3.35.0" + }, + "bin": { + "tailwind": "lib/cli.js", + "tailwindcss": "lib/cli.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tailwindcss/node_modules/postcss-load-config": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-4.0.2.tgz", + "integrity": "sha512-bSVhyJGL00wMVoPUzAVAnbEoWyqRxkjv64tUl427SKnPrENtq6hJwUojroMz2VB+Q1edmi4IfrAPpami5VVgMQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "lilconfig": "^3.0.0", + "yaml": "^2.3.4" + }, + "engines": { + "node": ">= 14" + }, + "peerDependencies": { + "postcss": ">=8.0.9", + "ts-node": ">=9.0.0" + }, + "peerDependenciesMeta": { + "postcss": { + "optional": true + }, + "ts-node": { + "optional": true + } + } + }, + "node_modules/tailwindcss/node_modules/yaml": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.1.tgz", + "integrity": "sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw==", + "dev": true, + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + } + }, + "node_modules/text-table": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", + "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", + "dev": true, + "license": "MIT" + }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/tiny-invariant": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.14", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.14.tgz", + "integrity": "sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.4.4", + "picomatch": "^4.0.2" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/ts-api-utils": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.4.3.tgz", + "integrity": "sha512-i3eMG77UTMD0hZhgRS562pv83RC6ukSAC2GMNWc+9dieh/+jDM5u5YG+NHX6VNDRHQcHwmsTHctP9LhbC3WxVw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16" + }, + "peerDependencies": { + "typescript": ">=4.2.0" + } + }, + "node_modules/ts-interface-checker": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/tsconfig-paths": { + "version": "3.15.0", + "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz", + "integrity": "sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/json5": "^0.0.29", + "json5": "^1.0.2", + "minimist": "^1.2.6", + "strip-bom": "^3.0.0" + } + }, + "node_modules/tsconfig-paths/node_modules/json5": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/json5/-/json5-1.0.2.tgz", + "integrity": "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==", + "dev": true, + "license": "MIT", + "dependencies": { + "minimist": "^1.2.0" + }, + "bin": { + "json5": "lib/cli.js" + } + }, + "node_modules/tsconfig-paths/node_modules/strip-bom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", + "integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/type-fest": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", + "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/typed-array-buffer": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", + "integrity": "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/typed-array-byte-length": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-byte-length/-/typed-array-byte-length-1.0.3.tgz", + "integrity": "sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "for-each": "^0.3.3", + "gopd": "^1.2.0", + "has-proto": "^1.2.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-byte-offset": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/typed-array-byte-offset/-/typed-array-byte-offset-1.0.4.tgz", + "integrity": "sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "for-each": "^0.3.3", + "gopd": "^1.2.0", + "has-proto": "^1.2.0", + "is-typed-array": "^1.1.15", + "reflect.getprototypeof": "^1.0.9" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-length": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.7.tgz", + "integrity": "sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "for-each": "^0.3.3", + "gopd": "^1.0.1", + "is-typed-array": "^1.1.13", + "possible-typed-array-names": "^1.0.0", + "reflect.getprototypeof": "^1.0.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typescript": { + "version": "5.9.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.2.tgz", + "integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/unbox-primitive": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", + "integrity": "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-bigints": "^1.0.2", + "has-symbols": "^1.1.0", + "which-boxed-primitive": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/unrs-resolver": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/unrs-resolver/-/unrs-resolver-1.11.1.tgz", + "integrity": "sha512-bSjt9pjaEBnNiGgc9rUiHGKv5l4/TGzDmYw3RhnkJGtLhbnnA/5qJj7x3dNDCRx/PJxu774LlH8lCOlB4hEfKg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "napi-postinstall": "^0.3.0" + }, + "funding": { + "url": "https://opencollective.com/unrs-resolver" + }, + "optionalDependencies": { + "@unrs/resolver-binding-android-arm-eabi": "1.11.1", + "@unrs/resolver-binding-android-arm64": "1.11.1", + "@unrs/resolver-binding-darwin-arm64": "1.11.1", + "@unrs/resolver-binding-darwin-x64": "1.11.1", + "@unrs/resolver-binding-freebsd-x64": "1.11.1", + "@unrs/resolver-binding-linux-arm-gnueabihf": "1.11.1", + "@unrs/resolver-binding-linux-arm-musleabihf": "1.11.1", + "@unrs/resolver-binding-linux-arm64-gnu": "1.11.1", + "@unrs/resolver-binding-linux-arm64-musl": "1.11.1", + "@unrs/resolver-binding-linux-ppc64-gnu": "1.11.1", + "@unrs/resolver-binding-linux-riscv64-gnu": "1.11.1", + "@unrs/resolver-binding-linux-riscv64-musl": "1.11.1", + "@unrs/resolver-binding-linux-s390x-gnu": "1.11.1", + "@unrs/resolver-binding-linux-x64-gnu": "1.11.1", + "@unrs/resolver-binding-linux-x64-musl": "1.11.1", + "@unrs/resolver-binding-wasm32-wasi": "1.11.1", + "@unrs/resolver-binding-win32-arm64-msvc": "1.11.1", + "@unrs/resolver-binding-win32-ia32-msvc": "1.11.1", + "@unrs/resolver-binding-win32-x64-msvc": "1.11.1" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz", + "integrity": "sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/use-sync-external-store": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.5.0.tgz", + "integrity": "sha512-Rb46I4cGGVBmjamjphe8L/UnvJD+uPPtTkNvX5mZgqdbavhI4EbgIWJiIHXJ8bc/i9EQGPRh4DwEURJ552Do0A==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, + "node_modules/victory-vendor": { + "version": "36.9.2", + "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-36.9.2.tgz", + "integrity": "sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==", + "license": "MIT AND ISC", + "dependencies": { + "@types/d3-array": "^3.0.3", + "@types/d3-ease": "^3.0.0", + "@types/d3-interpolate": "^3.0.1", + "@types/d3-scale": "^4.0.2", + "@types/d3-shape": "^3.1.0", + "@types/d3-time": "^3.0.0", + "@types/d3-timer": "^3.0.0", + "d3-array": "^3.1.6", + "d3-ease": "^3.0.1", + "d3-interpolate": "^3.0.1", + "d3-scale": "^4.0.2", + "d3-shape": "^3.1.0", + "d3-time": "^3.0.0", + "d3-timer": "^3.0.1" + } + }, + "node_modules/watchpack": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.0.tgz", + "integrity": "sha512-Lcvm7MGST/4fup+ifyKi2hjyIAwcdI4HRgtvTpIUxBRhB+RFtUh8XtDOxUfctVCnhVi+QQj49i91OyvzkJl6cg==", + "license": "MIT", + "dependencies": { + "glob-to-regexp": "^0.4.1", + "graceful-fs": "^4.1.2" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/which-boxed-primitive": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz", + "integrity": "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-bigint": "^1.1.0", + "is-boolean-object": "^1.2.1", + "is-number-object": "^1.1.1", + "is-string": "^1.1.1", + "is-symbol": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-builtin-type": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/which-builtin-type/-/which-builtin-type-1.2.1.tgz", + "integrity": "sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "function.prototype.name": "^1.1.6", + "has-tostringtag": "^1.0.2", + "is-async-function": "^2.0.0", + "is-date-object": "^1.1.0", + "is-finalizationregistry": "^1.1.0", + "is-generator-function": "^1.0.10", + "is-regex": "^1.2.1", + "is-weakref": "^1.0.2", + "isarray": "^2.0.5", + "which-boxed-primitive": "^1.1.0", + "which-collection": "^1.0.2", + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-collection": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/which-collection/-/which-collection-1.0.2.tgz", + "integrity": "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-map": "^2.0.3", + "is-set": "^2.0.3", + "is-weakmap": "^2.0.2", + "is-weakset": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-typed-array": { + "version": "1.1.19", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.19.tgz", + "integrity": "sha512-rEvr90Bck4WZt9HHFC4DJMsjvu7x+r6bImz0/BrbWb7A2djJ8hnZMrWnHo9F8ssv0OMErasDhftrfROTyqSDrw==", + "dev": true, + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "for-each": "^0.3.5", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/wmf": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wmf/-/wmf-1.0.2.tgz", + "integrity": "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/word": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/word/-/word-0.3.0.tgz", + "integrity": "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/xlsx": { + "version": "0.18.5", + "resolved": "https://registry.npmjs.org/xlsx/-/xlsx-0.18.5.tgz", + "integrity": "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==", + "license": "Apache-2.0", + "dependencies": { + "adler-32": "~1.3.0", + "cfb": "~1.2.1", + "codepage": "~1.15.0", + "crc-32": "~1.2.1", + "ssf": "~0.11.2", + "wmf": "~1.0.1", + "word": "~0.3.0" + }, + "bin": { + "xlsx": "bin/xlsx.njs" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/yaml": { + "version": "1.10.2", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz", + "integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==", + "license": "ISC", + "engines": { + "node": ">= 6" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..c074130 --- /dev/null +++ b/frontend/package.json @@ -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" + } +} \ No newline at end of file diff --git a/frontend/public/panjit-logo.png b/frontend/public/panjit-logo.png new file mode 100644 index 0000000000000000000000000000000000000000..b12aa06778a9c3939cb80f9ad17b0b43ca7dd501 GIT binary patch literal 7006 zcmeHMc{JPYmq!<>wY+U|&3l_Ps?Yu~n6BXkWFjDT*MrkXlpq z5~W0KQA@OlB`HB9mXJ)|-#Ig9&iT!pGxP7v{Bh57&U5bfKKFa?=ehTDzu%V@W`=yc zBD@?N9DK$`238y#obbPe=OlZlzw-1K_HycxkwX{T9 z_ge3}Cfo%7Uiy;3iJAC_0Lf9q02sZ7D}e{o+s?0ZUi!Gs#s8Q8^)bj1 zctU=9t1xP@&foP*p-SdHrzYKBL{ku$f64=N7vz)H*H77-1sCc1W zg&;i!gAKvpxtU@!$@}WQR`@EQ4@`TV&J2$ZD3hhBRguTD@)aePEy3Vydd1i-my4cq zMw?kY=z^B^$~IdIC2b0_1hMcK^g(}hUAQ(eSDoPSfEstmJq!jNHc%vvI&j)s-*s^_ zYM}7VA0)XrXhD2dMZ;Ome693x_pW>{DQHPX+XhqFD^(U68>GW=RsF*>-~)B-b$42) ztQ%*~V+ywpZy9!f=FnI)dbsnv0q45-Os_FSa^okN%Ue(nS8jH+T!xf1BOQD(E_n1@ zp|auigheP=uzO7^$t5c}Db4KK3)|X`h-MK)s9?}HRb@9ZJ>Qnjm`-1ml%u}T_|$}d zI z^G?Bu!|9KL9?oHBX#Uq)BdyNePsor-YBSF4YlVvWCb1nMPmB;wnO)>G>%W*kJx8b) zP=-3LY}cUSc_tp>6Mgn~y6D>tk*$}f0~9_4*@K_X?S4gLRHg%dH!bLI($GlA51TN`7DjBo$gMzKcR{=Cmp!OYsLZ=G$wxyp#Zqg z$|rro?xBPR8TB)9OFn&!ib?e(CNchC!1n?9&D!`GYS7F6V-(uZg{MCes-{VuqXYILP1-A-r5Y68Lo*sCI-+Ca1+{zc6rpCUQKR`Q~l`N zTO(p%3r@HZnI|WErpn(oS9TVy7u5(Vl;E!kY4pxt%Kv6=PpWQ(rg zZChLgU*7D^dR|TbKgA5R9QeREP|5?vh^gX7%O|brkz(LdKQj5g$5OV9H4A`jt~p!ac7?sSaCaPJNBv_H=ud!{l+as zNuM7g8L{h1X%y zu@f|P)8n^Xc$gt*38{N4Ldd3Qg_*Dt=w(@U z2#wzj!Y3nWH64X6tnR%06E!+KH+I91RhRTteF~{WC1;}RoWPuFDT0n0%}QENiadqJ z`>WM=3N>0LCM=qZGSN3E$3*pbZqTD8=%j_Rzn{yK$J_S|%tTJsTPxLHmR6Z3L-a3y z;PqcZ{2T}CPej`iv{4AIzs~9@!=~jD8Y|ft*iX^+S*-cA-9n6&$`OpV%nOQuo9h^` zFXNaqADHa4IX+Y)sKe8#{2WI-TH6^-$k!;XE`hIKWXJ5v3pcI8WkHi}YdbIvq7f4v zLIc+GiV(Fk$Jp3mqlEss!im7m^`_zB7a|`^N-`ohbk1_#6uHT7X`HwfVs0P7hqr%HLQh}eRgemb*U^Kc>k3#uqpfQ$Y^^NF)q3NqpI;Hm z0QXs{EyvaY-62r$=9U6{?4glR_}K@2VoRCl4}NE&tl2S&77U_khQ(6_j*3XTv1D#7 z5YsTuyMrQ$Im(CLy1uOJnP6IzC=vz7J!3oUCI3dbrvB&JqDqIl$>9B+6&!1Wv@OYi z6q;{RFgq@^bC@)arh-Wz7xO5`Xad@VIOJv@kg0#H& zbWu?9xAK9H>7cPvf6^j1hN2Y>WP;*;;qUu#=bQ4-&}-~FWTc9^v&)zHO3Klzu(j3! z45y4tQ;u3fG`rGJX7ThH?ePGkn^Bt&k{8Wnq{n;IY;;qfPK@+IYS?Hy{@wNlC5$!4 z`a#}`r*OLhyONufSbKGc-7ba)A+AT!v=Gceb8}(wx^&XPk1T~sxQGH99dz5;JG&tg zN9iuZSw(IjiQKn!End0`4%HM&iHnnmuHiCK( zysV#Ltj>e!kVEgfm6)XoyR>7TnXj$^SswGO_0HnmtTb)>Z_<>P3ZP=A5^Nj17=@9c9 zzKg(!`h8M*yj#sy_XYxA{IPksJ&x)S~8!> zNN0U+B~jyd&FMuiM)p!z2M_)T5fJ?{gqRET5*jxW4{p1p8vmL?nnc#Yv*xpRScI1$ zLkCBA79$@!F(MHEyPaEH3#_`fa@v*YNNXx)HDB0R+!voebgHJnXim|t48I5GuJwJg z%vy(^b$rN;C`tDBO%}2v5T5uM7pYJKh2(viq+;gg;~Z7cT6~>|PL&C4ab0*H;*SiIVs5l?~Pzrfy!jz8KTsI7Ko*Y6=%sBHT^=)GI~>ePj4lSh-tC= zoQ?kLIeYiQ*3>Lb=BkuQhL|=T$E5aQ>ZlOIar!;)zq%z_+ZL2)RMu*MMyVN#L%>{}7u<;Q8>{_kk5eanAN(vE=3ns!|PLBe0 zst)$)k_>_1`(AJ_MOgrWD7r7a%E&_p+l|`rnUWTXTn9BzTUHwkfoWqa4e(LTFpU^j zC%CvMMNuDWRCynBrz~6?Cc&EN;+ROsY*QPb!E~PA(pI( z_cKK2_-{=L(weDYHMUQJg#;clrOBVE$*M^FQvjgmPT`UWRmK)6ZrELJ16p zBB38k5eWX>iZN`Hpw~lQRw#DblJ$kYbn<6Xy+x9*z=3gn#aCbVuHKrCm+!;PZMsZbt*;yOC@svytSo z`mee>^$0}MJ|4df{op`w610fz03Tl(ee`w0%6bT>pci(7?MBV~kA)Y?kdctz<`tA? zWIjqx;EnWiLPpH2l71*L9%LU0v(e)Scwe1A`sG&xpeHccBJ~PiHw2gBqILzJWPwQZ z9$s*IZo=c1>U|~`+L{(NIzS2tzB(@QO4Ms1Ns!AP!`=JG*!kb#M=Oq9KG$ZM;nPqf z~sWY9I(vI0EkfR)Tu2*GX9KjRHrljcgJ2>I5@ZD=5&yc=m`1h0r1?l5A zk*K(7W_X_FP6!(#i|qL%7;?wo+4c5p7T02EN^uI%W#q#t-XT_8_2MQ$if!FYM}pL5 zXDh^R!tW}4)O%dih5rTbf*(SZhZ=2|+_{2h$D)=O!H(l&cKLobd45C1di{&BYNhHG zJD~~OOSX^fl+y-gWK{-t00m8&boH`dZN`v!`8eq4*e3y}rX_wS{l~GBAIPRxkJr1@ zoxk%lYvTlpbC*98{~Wm_2z&?sWWvK)1JStU7SeZHJIKodDE_Mrr`I|v??cJNmn8{) zR{JWwjq=^@bkKdTPy!GQcGOl|EzepB+6ZXVqFXo<^8`O%8+c@_@3boiJt~8q^Mh54 zc`;5v3dU(oN~v?jX1JXgOZlXYZ+mmAeFw|F*@q-zlPkv^@G|=%*Etdb5tEM$sHuyyUL-mwST)p(9NEJicjpuAr?3 zNQ|e1KO^V*;55OJ9Q6_Gpz_Eh{m;Zhk$Xxns$%#w7~J`%Q!)Kn)LqYjRbz;2KUUN3 zQ1ixG{P||gP|F}?t$cx6wJppmAVU~Ky$%S*Jzv15hruM2o{&}pi5yOzR_rT-Ec97H zX$C3AzNI%h&=Hzq)dQl1bB^PdM4gWL0~*omX%4!jOM|kN(Waf(u<#{ZCe-ta#jb-6#bOJlC^El#zkVtTS=uFlD- z#C{Nz30aIG$WyPjhFwdiRYGfasNPK5nNMR)&p=vLWYK^apW>w(^)nzZQETqKD$b{^ zjfaF;`828uCS{1R9~~U|V=|QS+o5ht+ed!{l!7*=e1`c0>UD zfgAqJc1S<(Ck0zmU%lcAED_-{51XcvH@g z<412!u_?0OaKoT`eF{}Vy%b3F$xF7W8}c=$s)xzg{Rw8~YdET?pex1KC^9JlO_}@6 z9i98m3er2F9j?moQm{CxI=fK@1C&chBbt#NE#23fi$H_AIBebZ$ee7)I@p@} zrfQqz@t91QSAwASf#&9}J1TtGZf`}cQ8@_nsq#e=d95rMB<1Vq z*7g+Bg*p^rsl~1_Fm^Yxa4rD_cBWY_MPxM{xeV%?m44-PGTX3^Hoc08EJZ1DQ`8ZS zS9|=u@(KClw9bc>^wE)of?Lwwzv?SZc+{LUp7~FtRo56DNL2qeYCCRX1Gb82lY2b8 zO^vd0i9c=CwcLgOYXDn!JFM>Tx8w~*W9iw%p~l_BS^ z%i4&w=smh_*xPBiV*WFRd>AtcKMb@z1;01;%{7-=zK{t}ap0F!E|ji1%hh%bGZb-@ z6;ll@bl)-N?b!VBrulZyKER{yh+1_yVRVJLrPPCEf79xHW6Y;6Jg3txn{Sc2$xM1- zhJT+Vcry=hgl>KLm~#B1S@BBxs-Q?G^=h;B*-FP&+2i4;fwE-LUcPK>z zzuq7$awDCg=XU1D@@2(3kLF{a09br&3|0 z2$wNAo>;sc%xnz%G+Nqo9k=h-9}(G%&1L)KI8|;IhW-n7@2$9rR6bUsFID(QvFZ! z*R+doeYDJ8*I2dZ%Pih1Q5Jc=-KxhEq4UF`$XwAHN3wq<^hUHPYhNBy<(eB=LyE%2 zjXRzR+f?7ZWZkkW4Cu(%$)g?_Qd)%nD71U`UVLYEw=ueF&80PZzXR`rY>();*Ha^9 zvhuaMVApZkCxL#s(U3}4Qkb@})g{mJdaOMvCwON`v5NRD zSq!b~EAW^LXn_r5+eSz^c@uEggXG!iV7Fhxr4xtZ*4gVd2cCUEoptDpa7)wOU>K-; zxih9fF(a&1Mk&28t2lQCK0Jk~7#PcvT=q~aHjnzI&L&pRC)~)Lu_IoJDWv@iFCv{V zaf;{__l80kBgES~|6s#pW?G)!Eg!`0MVJ-hN)sX}q{?PNMUW{oC!%u~7ZBn%&!cL$!^UBf5si zCAwgrK0>^?zv_o8JF^^#A>NQAK@N8|z5I9T{%;ii|JVN;F=0W(G(-x5R%1VSlDI literal 0 HcmV?d00001 diff --git a/frontend/src/app/calendar/page.tsx b/frontend/src/app/calendar/page.tsx new file mode 100644 index 0000000..51df390 --- /dev/null +++ b/frontend/src/app/calendar/page.tsx @@ -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([]); + const [selectedTodos, setSelectedTodos] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(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 ( + + + + + + 日曆視圖 + + + 以日曆方式檢視您的待辦事項 + + + + {/* Loading Skeleton */} + + + + + + {Array.from({ length: 35 }).map((_, index) => ( + + ))} + + + + + ); + } + + return ( + + + + + + 日曆視圖 + + + 以日曆方式檢視您的待辦事項,支援月、週、日三種檢視模式 + + + + {error && ( + setError(null)} + > + {error} + + )} + + {selectedTodos.length > 0 && ( + setSelectedTodos([])} + > + 已選擇 {selectedTodos.length} 個待辦事項 + + )} + + + + + + ); +}; + +export default CalendarPage; \ No newline at end of file diff --git a/frontend/src/app/dashboard/page.tsx b/frontend/src/app/dashboard/page.tsx new file mode 100644 index 0000000..2341c5e --- /dev/null +++ b/frontend/src/app/dashboard/page.tsx @@ -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([]); + 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 ( + + + + + 儀表板 + + + 歡迎回來!這裡是您的待辦事項概覽 + + + + {/* 統計卡片 */} + {loading ? ( + + {Array.from({ length: 4 }).map((_, index) => ( + + + + + + + + + + + + + + + + ))} + + ) : ( + + + + + + + + + 總待辦 + + + {stats.total} + + + + + + + + + + + + + + + + + 進行中 + + + {stats.doing} + + + + + + + + + + + + + + + + + 已完成 + + + {stats.completed} + + + + + + + + + + + + + + + + + 已逾期 + + + {stats.overdue} + + + + + + + + + + )} + + {/* 主要內容區域 */} + + {/* 最近待辦 */} + + + + + + + 最近待辦 + + + + + + {recentTodos.map((todo, index) => ( + + + + + {todo.title} + + + + + + + + + + + + {todo.dueDate} + + + + + {todo.assignees.map((assignee, idx) => ( + + {assignee} + + ))} + + + + + ))} + + + + + + + {/* 右側面板 */} + + + + {/* 即將到期 */} + + + + + 即將到期 + + + + {upcomingDeadlines.map((item, index) => ( + + + + {item.title} + + + {item.date} + + + + {item.urgent && ( + + )} + + ))} + + + + + + + + + + {/* 新增待辦對話框 */} + setTodoDialogOpen(false)} + onTodoCreated={handleTodoCreated} + /> + + ); +}; + +export default DashboardPage; \ No newline at end of file diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css new file mode 100644 index 0000000..7ab8085 --- /dev/null +++ b/frontend/src/app/globals.css @@ -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; + } +} \ No newline at end of file diff --git a/frontend/src/app/layout.tsx b/frontend/src/app/layout.tsx new file mode 100644 index 0000000..7c22b4a --- /dev/null +++ b/frontend/src/app/layout.tsx @@ -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 ( + + + + + {children} + + + + + ); +} \ No newline at end of file diff --git a/frontend/src/app/login/page.tsx b/frontend/src/app/login/page.tsx new file mode 100644 index 0000000..35169f8 --- /dev/null +++ b/frontend/src/app/login/page.tsx @@ -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 ( + + {/* 背景裝飾 */} + + + + + + + {/* Logo 區域 */} + + + + + To-Do + + + 專業待辦事項管理系統 + + + + + {/* 登入表單 */} + + + + {error && ( + + {error} + + )} + + + + setUsername(e.target.value)} + margin="normal" + disabled={isLoading} + autoComplete="username" + InputProps={{ + startAdornment: ( + + + + ), + }} + 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)', + } + } + }} + /> + + setPassword(e.target.value)} + margin="normal" + disabled={isLoading} + autoComplete="current-password" + InputProps={{ + startAdornment: ( + + + + ), + endAdornment: ( + + setShowPassword(!showPassword)} + edge="end" + disabled={isLoading} + size="small" + > + {showPassword ? : } + + + ), + }} + 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)', + } + } + }} + /> + + + + + {/* 底部資訊 */} + + + 使用您的 AD 帳號登入 • 支援企業單一登入 + + + + + + + + + + ); +}; + +export default LoginPage; \ No newline at end of file diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx new file mode 100644 index 0000000..9aa5e33 --- /dev/null +++ b/frontend/src/app/page.tsx @@ -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 ( + + + + 正在載入 PANJIT Todo List... + + + ); +} \ No newline at end of file diff --git a/frontend/src/app/public/page.tsx b/frontend/src/app/public/page.tsx new file mode 100644 index 0000000..fe0d0ba --- /dev/null +++ b/frontend/src/app/public/page.tsx @@ -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([]); + const [loading, setLoading] = useState(true); + const [searchTerm, setSearchTerm] = useState(''); + const [filters, setFilters] = useState({}); + const [showFilters, setShowFilters] = useState(false); + const [selectedTodo, setSelectedTodo] = useState(null); + const [showTodoDialog, setShowTodoDialog] = useState(false); + const [followingTodos, setFollowingTodos] = useState>(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 ( + + + {/* Header */} + + + + 公開任務 + + + + + {/* Search and Filters */} + + + + setSearchTerm(e.target.value)} + InputProps={{ + startAdornment: ( + + + + ), + }} + /> + + + + {showFilters && ( + setShowFilters(false)} + onApply={setFilters} + initialFilters={filters} + /> + )} + + + + {/* Todos List */} + {loading ? ( + + {[1, 2, 3].map((i) => ( + + + + ))} + + ) : todos.length === 0 ? ( + 目前沒有公開任務 + ) : ( + + {todos.map((todo) => ( + + + + {/* Title and Status */} + + + handleViewTodo(todo)} + > + {todo.starred && } + {todo.title} + + + 建立者: {todo.creator_display_name || todo.creator_ad} + + + + handleToggleFollow(todo)} + color={followingTodos.has(todo.id) ? 'primary' : 'default'} + > + {followingTodos.has(todo.id) ? : } + + + + + {/* Description */} + {todo.description && ( + + {todo.description} + + )} + + {/* Chips */} + + + + {todo.due_date && ( + + )} + + + {/* Tags */} + {todo.tags && todo.tags.length > 0 && ( + + {todo.tags.map((tag, index) => ( + + ))} + + )} + + {/* Followers */} + + + 追蹤者: + + {todo.followers.length > 0 ? ( + + {todo.followers_details?.map((follower) => ( + + + {follower.display_name.charAt(0)} + + + ))} + + ) : ( + + 無 + + )} + + + + {/* Actions */} + + + + + + ))} + + )} + + {/* Todo Dialog */} + {selectedTodo && ( + { + 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" + /> + )} + + + ); +} \ No newline at end of file diff --git a/frontend/src/app/settings/page.tsx b/frontend/src/app/settings/page.tsx new file mode 100644 index 0000000..75f50c1 --- /dev/null +++ b/frontend/src/app/settings/page.tsx @@ -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 = () => ( + + + + + + 郵件通知設定 + + + + + + + 通知方式 + + + setNotificationSettings(prev => ({ ...prev, emailNotifications: e.target.checked }))} + color="primary" + /> + } + label={ + + + 電子信箱通知 + + } + /> + + + + + + + 通知內容 + + + setNotificationSettings(prev => ({ ...prev, todoReminders: e.target.checked }))} + color="primary" + /> + } + label="待辦事項提醒" + /> + setNotificationSettings(prev => ({ ...prev, deadlineAlerts: e.target.checked }))} + color="primary" + /> + } + label="截止日期警告" + /> + setNotificationSettings(prev => ({ ...prev, weeklyReports: e.target.checked }))} + color="primary" + /> + } + label="每週報告" + /> + + + + + + + 聲音設定 + + + setNotificationSettings(prev => ({ ...prev, soundEnabled: e.target.checked }))} + color="primary" + /> + } + label={ + + + 啟用通知聲音 + + } + /> + + {notificationSettings.soundEnabled && ( + + + 音量大小: {notificationSettings.soundVolume}% + + setNotificationSettings(prev => ({ ...prev, soundVolume: value as number }))} + min={0} + max={100} + step={10} + marks + sx={{ color: 'primary.main' }} + /> + + )} + + + + + ); + + return ( + + + {/* 標題區域 */} + + + 郵件通知設定 + + + 管理您的郵件通知偏好設定 + + + + {/* 設定內容 */} + {renderNotificationSettings()} + + {/* 儲存按鈕 */} + + + + + + {/* 成功通知 */} + setShowSuccess(false)} + anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }} + > + setShowSuccess(false)} + sx={{ + borderRadius: 2, + fontWeight: 600, + }} + > + 郵件通知設定已成功儲存! + + + + + ); +}; + +export default SettingsPage; \ No newline at end of file diff --git a/frontend/src/app/todos/page.tsx b/frontend/src/app/todos/page.tsx new file mode 100644 index 0000000..4d924c2 --- /dev/null +++ b/frontend/src/app/todos/page.tsx @@ -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('list'); + const [filterMode, setFilterMode] = useState('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([]); + const [searchQuery, setSearchQuery] = useState(''); + const [showTodoDialog, setShowTodoDialog] = useState(false); + const [editingTodo, setEditingTodo] = useState(null); + const [showExcelImport, setShowExcelImport] = useState(false); + const [todos, setTodos] = useState([]); + const [loading, setLoading] = useState(true); + const [currentUser, setCurrentUser] = useState(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 ( + + + {/* 標題區域 */} + + + + + + 待辦清單 + + + + {getFilterModeLabel(filterMode)} · {filteredTodos.length} 項目 + + {selectedTodos.length > 0 && ( + + )} + + + + + + + + + + + + {/* 工具列 */} + + + + {/* 左側工具 */} + + {/* 視圖切換 */} + + + setViewMode('list')} + sx={{ + backgroundColor: viewMode === 'list' ? 'primary.main' : 'transparent', + color: viewMode === 'list' ? 'white' : 'text.secondary', + '&:hover': { + backgroundColor: viewMode === 'list' ? 'primary.dark' : 'action.hover', + }, + }} + > + + + + + setViewMode('calendar')} + sx={{ + backgroundColor: viewMode === 'calendar' ? 'primary.main' : 'transparent', + color: viewMode === 'calendar' ? 'white' : 'text.secondary', + '&:hover': { + backgroundColor: viewMode === 'calendar' ? 'primary.dark' : 'action.hover', + }, + }} + > + + + + + + {/* 篩選器切換 */} + + {(['all', 'created', 'responsible', 'following'] as FilterMode[]).map((mode) => ( + setFilterMode(mode)} + sx={{ + fontSize: '0.75rem', + fontWeight: filterMode === mode ? 600 : 400, + '&:hover': { + transform: 'translateY(-1px)', + }, + }} + /> + ))} + + + + {/* 右側工具 */} + + + 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', + }} + > + + + + + + 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', + }} + > + + + + + + 0 ? 'primary.main' : 'text.secondary', + }} + > + + + + + + + + + {/* 搜尋列 */} + + {showSearch && ( + + setShowSearch(false)} + /> + + )} + + + {/* 進階篩選 */} + + {showFilters && ( + + setShowFilters(false)} + onApply={setAppliedFilters} + initialFilters={appliedFilters} + /> + + )} + + + {/* 批次操作工具列 */} + + {selectedTodos.length > 0 && ( + + setSelectedTodos([])} + onBulkStatusChange={handleBulkStatusChange} + onBulkComplete={handleBulkComplete} + onBulkDelete={handleBulkDelete} + /> + + )} + + + {/* 主要內容區域 */} + + + + {loading ? ( + + {Array.from({ length: 3 }).map((_, index) => ( + + + + + + + + + + + + + + + + ))} + + ) : viewMode === 'list' ? ( + + ) : ( + + )} + + + + + {/* 新增/編輯待辦對話框 */} + + + {/* Excel 匯入對話框 */} + setShowExcelImport(false)} + onImportComplete={handleTodoCreated} + /> + + + ); +}; + +export default TodosPage; \ No newline at end of file diff --git a/frontend/src/components/EnvironmentWrapper.tsx b/frontend/src/components/EnvironmentWrapper.tsx new file mode 100644 index 0000000..c44ff65 --- /dev/null +++ b/frontend/src/components/EnvironmentWrapper.tsx @@ -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} +} \ No newline at end of file diff --git a/frontend/src/components/layout/DashboardLayout.tsx b/frontend/src/components/layout/DashboardLayout.tsx new file mode 100644 index 0000000..cec2224 --- /dev/null +++ b/frontend/src/components/layout/DashboardLayout.tsx @@ -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 = ({ children }) => { + const [sidebarOpen, setSidebarOpen] = useState(true); + const [sidebarCollapsed, setSidebarCollapsed] = useState(false); + const [userMenuAnchor, setUserMenuAnchor] = useState(null); + const [notificationAnchor, setNotificationAnchor] = useState(null); + const [themeMenuAnchor, setThemeMenuAnchor] = useState(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) => { + setUserMenuAnchor(event.currentTarget); + }; + + const handleUserMenuClose = () => { + setUserMenuAnchor(null); + }; + + const handleNotificationOpen = (event: React.MouseEvent) => { + setNotificationAnchor(event.currentTarget); + }; + + const handleNotificationClose = () => { + setNotificationAnchor(null); + }; + + const handleThemeMenuOpen = (event: React.MouseEvent) => { + 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: , + dark: , + auto: , + }; + + return ( + + {/* App Bar */} + + + + + + + {/* 標題區域 */} + + + 待辦管理 + + + + {/* 右側工具列 */} + + {/* 主題切換 */} + + + {themeIcons[themeMode]} + + + + {/* 通知 */} + + + + + + + + + {/* 用戶菜單 */} + + + + {user?.display_name?.charAt(0) || user?.ad_account?.charAt(0) || 'U'} + + + + + + + + {/* Sidebar */} + 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 不會遮擋按鈕 + }, + }} + > + setSidebarCollapsed(!sidebarCollapsed)} + onClose={() => isMobile && setSidebarOpen(false)} + /> + + + {/* 主內容區域 */} + + {/* 為 AppBar 預留空間 */} + + + + {children} + + + + + {/* 用戶選單 */} + + + + {user?.display_name || user?.ad_account} + + + {user?.email} + + + + + + 登出 + + + + {/* 主題選單 */} + + { setThemeMode('light'); handleThemeMenuClose(); }} + selected={themeMode === 'light'} + > + + 亮色 + + { setThemeMode('dark'); handleThemeMenuClose(); }} + selected={themeMode === 'dark'} + > + + 暗色 + + { setThemeMode('auto'); handleThemeMenuClose(); }} + selected={themeMode === 'auto'} + > + + 跟隨系統 + + + + {/* 通知面板 */} + + + + ); +}; + +export default DashboardLayout; \ No newline at end of file diff --git a/frontend/src/components/layout/NotificationPanel.tsx b/frontend/src/components/layout/NotificationPanel.tsx new file mode 100644 index 0000000..15c98f5 --- /dev/null +++ b/frontend/src/components/layout/NotificationPanel.tsx @@ -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 = ({ + anchor, + open, + onClose, +}) => { + const { actualTheme } = useTheme(); + const router = useRouter(); + const [notifications, setNotifications] = useState([]); + 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 ; + case 'assignment': + return ; + case 'completion': + return ; + case 'overdue': + return ; + default: + return ; + } + }; + + 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 ( + + + {/* 標題區域 */} + + + + 通知 + + {unreadCount > 0 && ( + + )} + + + + setSettingsOpen(true)} + > + + + + + + + + + {/* 通知清單 */} + + {loading ? ( + + + 載入中... + + + ) : notifications.length === 0 ? ( + + + + 目前沒有新的通知 + + + 當有新的待辦事項更新時,您會在這裡看到通知 + + + ) : ( + + + {notifications.map((notification, index) => ( + + + + + {notification.avatar || getNotificationIcon(notification.type)} + + + + + + {notification.title} + + + + } + secondary={ + + + {notification.message} + + + {notification.actionable && ( + + + {!notification.read && ( + + )} + + )} + + } + /> + + {index < notifications.length - 1 && ( + + )} + + ))} + + + )} + + + {/* 底部操作 */} + + + + + + + + {/* 郵件通知設定對話框 */} + setSettingsOpen(false)} + /> + + ); +}; + +export default NotificationPanel; \ No newline at end of file diff --git a/frontend/src/components/layout/Sidebar.tsx b/frontend/src/components/layout/Sidebar.tsx new file mode 100644 index 0000000..c77db77 --- /dev/null +++ b/frontend/src/components/layout/Sidebar.tsx @@ -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 = ({ collapsed, onToggleCollapse, onClose }) => { + const router = useRouter(); + const pathname = usePathname(); + const { actualTheme } = useTheme(); + const [expandedGroups, setExpandedGroups] = React.useState>({ + views: true, + status: true, + }); + const [todos, setTodos] = useState([]); + 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: , + path: '/dashboard', + }, + { + id: 'todos', + label: '待辦清單', + icon: , + path: '/todos', + badge: stats.total || undefined, + }, + { + id: 'public', + label: '公開任務', + icon: , + path: '/public', + }, + { + id: 'calendar', + label: '日曆視圖', + icon: , + path: '/calendar', + }, + ], + }, + { + id: 'views', + label: '視圖篩選', + items: [ + { + id: 'starred', + label: '已加星', + icon: , + path: '/todos?starred=true', + badge: stats.starred || undefined, + color: '#fbbf24', + }, + { + id: 'my-todos', + label: '我建立的', + icon: , + path: '/todos?view=created', + badge: stats.created || undefined, + }, + { + id: 'assigned', + label: '指派給我', + icon: , + path: '/todos?view=responsible', + badge: stats.assigned || undefined, + }, + { + id: 'following', + label: '我追蹤的', + icon: , + path: '/todos?view=following', + badge: stats.following || undefined, + }, + ], + }, + { + id: 'status', + label: '狀態分類', + items: [ + { + id: 'new', + label: '新建立', + icon: , + path: '/todos?status=NEW', + badge: stats.new || undefined, + color: '#6b7280', + }, + { + id: 'doing', + label: '進行中', + icon: , + path: '/todos?status=DOING', + badge: stats.doing || undefined, + color: '#3b82f6', + }, + { + id: 'blocked', + label: '已阻塞', + icon: , + path: '/todos?status=BLOCKED', + badge: stats.blocked || undefined, + color: '#ef4444', + }, + { + id: 'done', + label: '已完成', + icon: , + 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 ( + + + + 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', + }} + > + + {item.icon} + + + + {!collapsed && ( + + + {item.badge && ( + + )} + + )} + + + + + + ); + }; + + return ( + + {/* Logo 區域 */} + + + + + + {!collapsed && ( + + + + )} + + + {/* 導航列表 */} + + + {navGroups.map((group) => ( + + {/* 群組標題 */} + + {!collapsed && group.label && ( + + + 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'} + > + + {group.id !== 'main' && ( + expandedGroups[group.id] ? : + )} + + + + )} + + + {/* 群組項目 */} + + {(collapsed || expandedGroups[group.id] || group.id === 'main') && ( + + {group.items.map(renderNavItem)} + + )} + + + {/* 分隔線 */} + {group.id === 'main' && !collapsed && ( + + )} + + ))} + + + + {/* 底部快速狀態 */} + + {!collapsed && ( + + + + {stats.doing > 0 && ( + + )} + {(() => { + 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 ? ( + + ) : null; + })()} + {stats.blocked > 0 && ( + + )} + {stats.total === 0 && !loading && ( + + )} + + + + )} + + + ); +}; + +export default Sidebar; \ No newline at end of file diff --git a/frontend/src/components/notifications/EnhancedEmailNotificationSettings.tsx b/frontend/src/components/notifications/EnhancedEmailNotificationSettings.tsx new file mode 100644 index 0000000..093b297 --- /dev/null +++ b/frontend/src/components/notifications/EnhancedEmailNotificationSettings.tsx @@ -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 = ({ + 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({ + 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 ( + + + + 載入中... + + + + ); + } + + return ( + + + + + + 增強郵件提醒設定 + + + + + + + + + + {/* 總開關 */} + + + + + + 啟用郵件通知 + + + 接收待辦事項相關的郵件提醒通知 + + + setSettings(prev => ({ ...prev, emailEnabled: e.target.checked }))} + size="medium" + /> + + + + + {settings.emailEnabled && ( + + {/* 基本設定 */} + + }> + + + 基本設定 + + + + setSettings(prev => ({ ...prev, emailAddress: e.target.value }))} + helperText="通知將發送至此郵件地址" + sx={{ mb: 2 }} + disabled + /> + + + + + + {/* 到期提醒設定 */} + + }> + + + 到期提醒設定 + + + + + 選擇在到期日前幾天發送提醒郵件(可多選) + + + + + {reminderDayOptions.map(day => ( + + handleReminderDayToggle(day)} + size="small" + /> + } + label={`前 ${day} 天`} + /> + + ))} + + + + {settings.reminderDays.length > 0 && ( + + + 已選擇: + + + {settings.reminderDays.sort((a, b) => a - b).map(day => ( + + ))} + + + )} + + + + {/* 摘要郵件設定 */} + + }> + + + 摘要郵件設定 + + + + + {/* 週報設定 */} + + + + + + 週報摘要 + + + + setSettings(prev => ({ ...prev, weeklyDigestEnabled: e.target.checked }))} + /> + } + label="啟用週報" + sx={{ mb: 2 }} + /> + + {settings.weeklyDigestEnabled && ( + + + 發送時間 + + + + + 發送日期 + + + + )} + + + + {/* 月報設定 */} + + + + + + 月報摘要 + + + + setSettings(prev => ({ ...prev, monthlyDigestEnabled: e.target.checked }))} + /> + } + label="啟用月報" + sx={{ mb: 2 }} + /> + + {settings.monthlyDigestEnabled && ( + + + 發送時間 + + + + setSettings(prev => ({ ...prev, monthlyDigestDay: Math.max(1, Math.min(28, parseInt(e.target.value) || 1)) }))} + inputProps={{ min: 1, max: 28 }} + helperText="1-28日" + /> + + )} + + + + + + + {/* 其他通知設定 */} + + }> + + + 其他通知 + + + + setSettings(prev => ({ ...prev, assignmentNotifications: e.target.checked }))} + /> + } + label={ + + 指派通知 + + 有新的待辦事項指派給您時發送通知 + + + } + sx={{ mb: 2, alignItems: 'flex-start', ml: 0 }} + /> + + setSettings(prev => ({ ...prev, completionNotifications: e.target.checked }))} + /> + } + label={ + + 完成通知 + + 您指派的待辦事項被完成時發送通知 + + + } + sx={{ alignItems: 'flex-start', ml: 0 }} + /> + + + + {/* 設定預覽 */} + + + 當前設定預覽: + + + {settings.reminderDays.length > 0 && ( + + )} + {settings.weeklyDigestEnabled && ( + d.value === settings.weeklyDigestDay)?.label} ${settings.weeklyDigestTime}`} /> + )} + {settings.monthlyDigestEnabled && ( + + )} + {settings.assignmentNotifications && ( + + )} + {settings.completionNotifications && ( + + )} + + + + )} + + + + + + + + + ); +}; + +export default EnhancedEmailNotificationSettings; \ No newline at end of file diff --git a/frontend/src/components/todos/BatchActions.tsx b/frontend/src/components/todos/BatchActions.tsx new file mode 100644 index 0000000..f4b409c --- /dev/null +++ b/frontend/src/components/todos/BatchActions.tsx @@ -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 = ({ + selectedCount, + onClearSelection, + onBulkDelete, + onBulkComplete, + onBulkAssign, + onBulkStar, + onBulkEmail, + onBulkArchive, + onBulkStatusChange, +}) => { + const { actualTheme } = useTheme(); + + return ( + + + + {/* 左側資訊 */} + + + + 批次操作工具列 + + + + {/* 操作按鈕 */} + + {/* 完成 */} + {onBulkComplete && ( + + + + + + )} + + {/* 狀態變更按鈕 */} + {onBulkStatusChange && ( + <> + + onBulkStatusChange('NEW')} + sx={{ + color: 'text.secondary', + '&:hover': { + backgroundColor: 'rgba(107, 114, 128, 0.1)', + color: 'text.primary', + }, + }} + > + + + + + + onBulkStatusChange('DOING')} + sx={{ + color: 'primary.main', + '&:hover': { + backgroundColor: 'primary.main', + color: 'white', + }, + }} + > + + + + + + onBulkStatusChange('BLOCKED')} + sx={{ + color: 'error.main', + '&:hover': { + backgroundColor: 'error.main', + color: 'white', + }, + }} + > + + + + + )} + + {/* 加星號 */} + {onBulkStar && ( + + + + + + )} + + {/* 指派 */} + {onBulkAssign && ( + + + + + + )} + + {/* 發送提醒 */} + {onBulkEmail && ( + + + + + + )} + + {/* 封存 */} + {onBulkArchive && ( + + + + + + )} + + {/* 刪除 */} + {onBulkDelete && ( + + + + + + )} + + {/* 清除選擇 */} + + + + + + + + + + ); +}; + +export default BatchActions; \ No newline at end of file diff --git a/frontend/src/components/todos/CalendarView.tsx b/frontend/src/components/todos/CalendarView.tsx new file mode 100644 index 0000000..2b4010b --- /dev/null +++ b/frontend/src/components/todos/CalendarView.tsx @@ -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 = ({ + todos, + selectedTodos, + onSelectionChange, + onEditTodo +}) => { + const { actualTheme } = useTheme(); + const [currentDate, setCurrentDate] = useState(dayjs()); + const [viewType, setViewType] = useState('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 ( + + + {/* 星期標題行 */} + + {['日', '一', '二', '三', '四', '五', '六'].map((day, index) => ( + + + {day} + + + ))} + + + {/* 日期網格 */} + + {weeks.map((week, weekIndex) => ( + + {week.map((date, dayIndex) => { + const todosForDate = getTodosForDate(date); + const isCurrentMonth = date.month() === currentDate.month(); + const isToday = date.isSame(dayjs(), 'day'); + + return ( + + {/* 日期數字和徽章 */} + + + {date.date()} + + {todosForDate.length > 0 && ( + + )} + + + {/* 待辦事項列表 */} + + {todosForDate.slice(0, 2).map((todo) => ( + + 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`, + }, + }} + > + + {todo.starred && } + {todo.title} + + + + ))} + + {todosForDate.length > 2 && ( + + +{todosForDate.length - 2} 項 + + )} + + + ); + })} + + ))} + + + + ); + }; + + const renderWeekView = () => { + return ( + + + {getViewDates.map((date) => { + const todosForDate = getTodosForDate(date); + const isToday = date.isSame(dayjs(), 'day'); + + return ( + + + + {/* 日期標題 */} + + + {date.format('MM/DD')} + + + {date.format('dddd')} + + + + + + + {/* 待辦事項列表 */} + + + {todosForDate.map((todo, index) => ( + + 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)', + }, + }} + > + + + {todo.starred && } + {todo.title} + + + + + + + + {todo.priority === 'URGENT' ? '緊急' : + todo.priority === 'HIGH' ? '高' : + todo.priority === 'MEDIUM' ? '中' : '低'} + + + + {(() => { + 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 ( + + + + ); + })()} + + + + ))} + + + {todosForDate.length === 0 && ( + + 無待辦事項 + + )} + + + + + ); + })} + + + ); + }; + + const renderDayView = () => { + const todosForDate = getTodosForDate(currentDate); + + return ( + + + {/* 日期標題 */} + + + + + {currentDate.format('YYYY年 MM月 DD日')} + + + {currentDate.format('dddd')} + + + + } + label={`${todosForDate.length} 個待辦事項`} + color="primary" + variant="outlined" + /> + + + + {/* 待辦事項列表 */} + + {todosForDate.length > 0 ? ( + + + {todosForDate.map((todo, index) => ( + + + 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)', + }, + }} + > + + + {todo.starred && } + {todo.title} + + + + + {todo.description && ( + + {todo.description} + + )} + + + + + + + {(() => { + 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 ( + + + + ); + })()} + + + + + + ))} + + + ) : ( + + + + 今天沒有待辦事項 + + + 好好休息,或者開始規劃新的任務吧! + + + )} + + + + ); + }; + + return ( + + {/* 控制列 */} + + + {/* 導航控制 */} + + + + + + + + + + + {/* 標題 */} + + {getTitleText()} + + + {/* 視圖切換 */} + + + setViewType('month')} + sx={{ + backgroundColor: viewType === 'month' ? 'primary.main' : 'transparent', + color: viewType === 'month' ? 'white' : 'text.secondary', + '&:hover': { + backgroundColor: viewType === 'month' ? 'primary.dark' : 'action.hover', + }, + }} + > + + + + + setViewType('week')} + sx={{ + backgroundColor: viewType === 'week' ? 'primary.main' : 'transparent', + color: viewType === 'week' ? 'white' : 'text.secondary', + '&:hover': { + backgroundColor: viewType === 'week' ? 'primary.dark' : 'action.hover', + }, + }} + > + + + + + setViewType('day')} + sx={{ + backgroundColor: viewType === 'day' ? 'primary.main' : 'transparent', + color: viewType === 'day' ? 'white' : 'text.secondary', + '&:hover': { + backgroundColor: viewType === 'day' ? 'primary.dark' : 'action.hover', + }, + }} + > + + + + + + + + {/* 日曆內容 */} + + + {viewType === 'month' && renderMonthView()} + {viewType === 'week' && renderWeekView()} + {viewType === 'day' && renderDayView()} + + + + ); +}; + +export default CalendarView; \ No newline at end of file diff --git a/frontend/src/components/todos/ExcelImport.tsx b/frontend/src/components/todos/ExcelImport.tsx new file mode 100644 index 0000000..d9343e9 --- /dev/null +++ b/frontend/src/components/todos/ExcelImport.tsx @@ -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 = ({ open, onClose, onImportComplete }) => { + const { actualTheme } = useTheme(); + const [activeStep, setActiveStep] = useState(0); + const [loading, setLoading] = useState(false); + const [file, setFile] = useState(null); + const [parsedData, setParsedData] = useState([]); + const [parseErrors, setParseErrors] = useState([]); + const [importErrors, setImportErrors] = useState([]); + + 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) => { + 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 ( + + + Excel 檔案匯入 + + + 請下載模板,填入待辦事項資料後上傳 + + + + + + + document.getElementById('file-input')?.click()} + > + + + + 點擊上傳檔案 + + + 支援 .xlsx, .xls, .csv 格式 + + {file && ( + + 已選擇: {file.name} + + )} + + + ); + + case 1: + return ( + + + 預覽資料 ({parsedData.length} 筆) + + + {parseErrors.length > 0 && ( + + 解析警告: +
    + {parseErrors.map((error, index) => ( +
  • {error}
  • + ))} +
+
+ )} + + + + + + + 標題 + 狀態 + 優先級 + 到期日 + 負責人 + 公開設定 + + + + {parsedData.map((todo, index) => ( + + {todo.row} + + + {todo.title} + + {todo.description && ( + + {todo.description.substring(0, 50)}{todo.description.length > 50 ? '...' : ''} + + )} + + + + + + + + + + {todo.due_date || '-'} + + + + + {(todo.responsible_users && todo.responsible_users.length > 0) ? todo.responsible_users.join(', ') : '-'} + + + + + {todo.is_public ? '是' : '否'} + + + + ))} + +
+
+
+ ); + + case 2: + return ( + + + + 匯入完成! + + + {importErrors.length > 0 && ( + + 部分資料匯入失敗: +
    + {importErrors.map((error, index) => ( +
  • 第 {error.row} 行: {error.error}
  • + ))} +
+
+ )} +
+ ); + + default: + return null; + } + }; + + return ( + + + + Excel 匯入 + + {steps.map((label) => ( + + {label} + + ))} + + + + + + {loading && } + + + {renderStepContent()} + + + + + + + {activeStep === 1 && ( + + )} + {activeStep === 2 && ( + + )} + + + ); +}; + +export default ExcelImport; \ No newline at end of file diff --git a/frontend/src/components/todos/SearchBar.tsx b/frontend/src/components/todos/SearchBar.tsx new file mode 100644 index 0000000..75aa572 --- /dev/null +++ b/frontend/src/components/todos/SearchBar.tsx @@ -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 = ({ value, onChange, onClose }) => { + const { actualTheme } = useTheme(); + const inputRef = useRef(null); + const [recentSearches, setRecentSearches] = useState([]); + + // 自動 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 ( + + + + onChange(e.target.value)} + onKeyDown={handleKeyDown} + InputProps={{ + startAdornment: ( + + + + ), + endAdornment: value && ( + + onChange('')} + sx={{ color: 'text.secondary' }} + > + + + + ), + }} + 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 && ( + + {/* 最近搜尋 */} + {recentSearches.length > 0 && ( + + + + + 最近搜尋 + + + + {recentSearches.map((search, index) => ( + + 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', + }} + /> + + ))} + + + )} + + {/* 如果沒有搜索歷史,顯示提示 */} + {recentSearches.length === 0 && ( + + + + 開始輸入以搜尋待辦事項 + + + 您的搜索歷史將會顯示在這裡 + + + )} + + )} + + {value && ( + + + 按 Enter 搜尋 "{value}" 或 Esc 取消 + + + )} + + + + ); +}; + +export default SearchBar; \ No newline at end of file diff --git a/frontend/src/components/todos/TodoDialog.tsx b/frontend/src/components/todos/TodoDialog.tsx new file mode 100644 index 0000000..67daecd --- /dev/null +++ b/frontend/src/components/todos/TodoDialog.tsx @@ -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 = ({ + 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({ + title: '', + description: '', + status: 'NEW', + priority: 'MEDIUM', + dueDate: null, + starred: false, + responsible: [], + isPublic: false, // 預設為非公開 + }); + + const [assignToMyself, setAssignToMyself] = useState(false); + + // 用戶資料 + const [availableUsers, setAvailableUsers] = useState([]); + const [loadingUsers, setLoadingUsers] = useState(false); + const [searchTimeout, setSearchTimeout] = useState(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 ( + + {open && ( + + + {/* 對話框標題 */} + + + + + + + + {mode === 'create' ? '新增待辦事項' : '編輯待辦事項'} + + + {mode === 'create' ? '建立新的待辦任務' : '修改現有的待辦任務'} + + + + + + + + + + + + {error && ( + + + {error} + + + )} + + + {/* 基本資訊 */} + + + + + 基本資訊 + + + + + + + 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)', + } + }} + /> + handleInputChange('starred', !formData.starred)} + sx={{ + color: formData.starred ? '#fbbf24' : 'text.disabled', + '&:hover': { + color: '#fbbf24', + backgroundColor: 'rgba(251, 191, 36, 0.1)', + }, + }} + > + {formData.starred ? : } + + + + + + 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)', + } + }} + /> + + + + + 狀態 + + + + + + + 優先級 + + + + + + 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)', + } + } + } + }} + /> + + + + {/* 人員指派 */} + + + + + + 人員指派 + + + + + + { + setAssignToMyself(e.target.checked); + if (e.target.checked) { + // 清空已選的負責人 + handleInputChange('responsible', []); + } + }} + color="primary" + /> + } + label="指派給自己" + sx={{ mb: 2 }} + /> + + + {!assignToMyself && ( + + 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 ( +
  • + + {option.avatar} + + + {option.name} + + {option.department} + + +
  • + ); + }} + renderTags={(value, getTagProps) => + value.map((option, index) => ( + {option.avatar}} + label={option.name} + size="small" + sx={{ borderRadius: 2 }} + /> + )) + } + renderInput={(params) => ( + + )} + /> +
    + )} + + {/* 設定 */} + + + + 設定 + + + + + handleInputChange('isPublic', e.target.checked)} + color="primary" + /> + } + label={ + + + + 公開此待辦事項 + + 其他用戶可以查看此待辦事項 + + + + } + /> + +
    +
    + + + + + + + +
    +
    + )} +
    + ); +}; + +export default TodoDialog; \ No newline at end of file diff --git a/frontend/src/components/todos/TodoFilters.tsx b/frontend/src/components/todos/TodoFilters.tsx new file mode 100644 index 0000000..6ae114a --- /dev/null +++ b/frontend/src/components/todos/TodoFilters.tsx @@ -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 = ({ 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 ( + + + {/* 標題區域 */} + + + + + 進階篩選 + + {getActiveFilterCount() > 0 && ( + + )} + + + + + + + + + + + + + + + {/* 狀態篩選 */} + + + }> + + + + 狀態 + + + + + + {statusOptions.map((option) => ( + 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', + }} + /> + ))} + + + + + + {/* 優先級篩選 */} + + + }> + + + + 優先級 + + + + + + {priorityOptions.map((option) => ( + 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', + }} + /> + ))} + + + + + + {/* 指派人篩選 */} + + + }> + + + + 指派人 + + + + + + + + + + + + {/* 日期範圍 */} + + + }> + + + + 到期日期 + + + + + + setFilters(prev => ({ ...prev, dateFrom: date }))} + slotProps={{ + textField: { + size: 'small', + fullWidth: true, + }, + }} + /> + setFilters(prev => ({ ...prev, dateTo: date }))} + slotProps={{ + textField: { + size: 'small', + fullWidth: true, + }, + }} + /> + + + + + + {/* 特殊篩選 */} + + + }> + + 特殊篩選 + + + + + setFilters(prev => ({ ...prev, starred: e.target.checked }))} + color="primary" + /> + } + label="僅顯示已加星項目" + /> + setFilters(prev => ({ ...prev, overdue: e.target.checked }))} + color="error" + /> + } + label="僅顯示逾期項目" + /> + setFilters(prev => ({ ...prev, dueSoon: e.target.checked }))} + color="warning" + /> + } + label="僅顯示即將到期項目" + /> + + + + + + + {/* 底部操作按鈕 */} + + + {getActiveFilterCount() > 0 + ? `${getActiveFilterCount()} 個篩選器已套用` + : '沒有套用篩選器'} + + + + + + + + + + + ); +}; + +export default TodoFilters; \ No newline at end of file diff --git a/frontend/src/components/todos/TodoList.tsx b/frontend/src/components/todos/TodoList.tsx new file mode 100644 index 0000000..70a0981 --- /dev/null +++ b/frontend/src/components/todos/TodoList.tsx @@ -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 = ({ + 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 ; + case 'DOING': return ; + case 'BLOCKED': return ; + case 'DONE': return ; + default: return ; + } + }; + + 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) => { + 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 ( + + + + + 沒有找到待辦事項 + + + 嘗試調整篩選條件或建立新的待辦事項 + + + + + ); + } + + return ( + + + + {todos.map((todo) => { + const isSelected = selectedTodos.includes(todo.id); + const overdue = todo.due_date ? isOverdue(todo.due_date) : false; + + return ( + + + {/* 逾期標示 */} + {overdue && ( + + 逾期 + + )} + + + {/* 頂部區域 */} + + {/* 選擇框 */} + handleTodoSelect(todo.id)} + sx={{ + p: 0, + '&.Mui-checked': { + color: 'primary.main', + }, + }} + /> + + {/* 主要內容 */} + + {/* 標題和星標 */} + + + {todo.title} + + + + + + {todo.is_public ? ( + + ) : ( + + )} + + + + {todo.starred ? : } + + + + + {/* 描述 */} + {todo.description && ( + + {todo.description} + + )} + + {/* 標籤區域 */} + + {/* 狀態 */} + { + 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', + } : {}, + }} + /> + + {/* 優先級 */} + + + {/* 到期時間 */} + + + + {todo.due_date ? getDaysUntilDue(todo.due_date) : ''} + + + + + {/* 底部資訊 */} + + {/* 人員資訊 */} + + {/* 建立者 */} + + + 建立者: {todo.creator_display_name || todo.creator_ad} + + + + {/* 負責人 */} + + + + {(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 ( + + + + ); + })} + {(todo.responsible_users_details || todo.responsible_users || []).length > 3 && ( + + )} + + + + {/* 追蹤者 */} + {todo.followers.length > 0 && ( + + + 追蹤者 + + + )} + + + {/* 操作按鈕 */} + { + 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)', + }, + }} + > + + + + + + + + + {/* 右鍵菜單 */} + 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, + }, + }} + > + { + handleMenuClose(todo.id); + onEditTodo?.(todo); + }}> + + 編輯 + + handleFireEmail(todo.id)} sx={{ color: 'warning.main' }}> + + 緊急提醒 + + handleMenuClose(todo.id)} sx={{ color: 'error.main' }}> + + 刪除 + + + + + ); + })} + + + + ); +}; + +export default TodoList; \ No newline at end of file diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts new file mode 100644 index 0000000..c88eb9e --- /dev/null +++ b/frontend/src/lib/api.ts @@ -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 => { + const response = await api.post('/api/auth/login', credentials); + return response.data; + }, + + logout: async (): Promise => { + await api.post('/api/auth/logout'); + localStorage.removeItem('access_token'); + localStorage.removeItem('refresh_token'); + localStorage.removeItem('user'); + }, + + getCurrentUser: async (): Promise => { + const response = await api.get('/api/auth/me'); + return response.data; + }, + + validateToken: async (): Promise => { + 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 => { + 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 => { + const response = await api.get(`/api/todos/${id}`); + return response.data; + }, + + createTodo: async (todo: TodoCreate): Promise => { + const response = await api.post('/api/todos', todo); + return response.data; + }, + + updateTodo: async (id: string, updates: Partial): Promise => { + const response = await api.patch(`/api/todos/${id}`, updates); + return response.data; + }, + + deleteTodo: async (id: string): Promise => { + await api.delete(`/api/todos/${id}`); + }, + + batchUpdateTodos: async (todoIds: string[], updates: Partial): 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 => { + await api.post('/api/notifications/fire-email', { + todo_id: request.todo_id, + message: request.note, + }); + }, + + getPublicTodos: async (filters?: TodoFilter): Promise => { + 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 => { + 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 => { + const response = await api.get(`/api/users/search?q=${encodeURIComponent(query)}`); + return response.data.users; + }, + + getPreferences: async (): Promise => { + const response = await api.get('/api/users/preferences'); + return response.data; + }, + + updatePreferences: async (preferences: Partial): Promise => { + const response = await api.patch('/api/users/preferences', preferences); + return response.data; + }, + + getFireEmailQuota: async (): Promise => { + const response = await api.get('/api/users/fire-email-quota'); + return response.data; + }, +}; + +// Import API +export const importApi = { + downloadTemplate: async (): Promise => { + const response = await api.get('/api/imports/template', { + responseType: 'blob', + }); + return response.data; + }, + + uploadFile: async (file: File): Promise => { + 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 => { + const response = await api.post('/api/excel/import', { todos }); + return response.data; + }, + + getImportJob: async (jobId: string): Promise => { + const response = await api.get(`/api/imports/${jobId}`); + return response.data; + }, + + downloadErrors: async (jobId: string): Promise => { + 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 => { + const response = await api.get(`/api/admin/stats?days=${days}`); + return response.data; + }, + + getAuditLogs: async (params: any): Promise => { + const queryParams = new URLSearchParams(params).toString(); + const response = await api.get(`/api/admin/audit-logs?${queryParams}`); + return response.data; + }, + + getMailLogs: async (params: any): Promise => { + 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 => { + const response = await api.get('/api/notifications/settings'); + return response.data; + }, + + updateSettings: async (settings: any): Promise => { + const response = await api.patch('/api/notifications/settings', settings); + return response.data; + }, + + sendTestEmail: async (recipientEmail?: string): Promise => { + await api.post('/api/notifications/test', recipientEmail ? { recipient_email: recipientEmail } : {}); + }, + + sendDigest: async (type: 'weekly' | 'monthly' = 'weekly'): Promise => { + await api.post('/api/notifications/digest', { type }); + }, + + markNotificationRead: async (notificationId: string): Promise => { + await api.post('/api/notifications/mark-read', { notification_id: notificationId }); + }, + + markAllNotificationsRead: async (): Promise => { + await api.post('/api/notifications/mark-all-read'); + }, + + getNotifications: async (): Promise => { + const response = await api.get('/api/notifications/'); + return response.data; + }, +}; + +// Health API +export const healthApi = { + check: async (): Promise => { + const response = await api.get('/api/health/healthz'); + return response.data; + }, + + readiness: async (): Promise => { + const response = await api.get('/api/health/readiness'); + return response.data; + }, +}; + +export default api; \ No newline at end of file diff --git a/frontend/src/lib/theme.ts b/frontend/src/lib/theme.ts new file mode 100644 index 0000000..1f50115 --- /dev/null +++ b/frontend/src/lib/theme.ts @@ -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'); \ No newline at end of file diff --git a/frontend/src/providers/AuthProvider.tsx b/frontend/src/providers/AuthProvider.tsx new file mode 100644 index 0000000..bfa0568 --- /dev/null +++ b/frontend/src/providers/AuthProvider.tsx @@ -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; + logout: () => Promise; + refreshAuth: () => Promise; + isLoading: boolean; +} + +const AuthContext = createContext(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 = ({ children }) => { + const [authState, setAuthState] = useState({ + 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 => { + 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 => { + try { + await authApi.logout(); + } catch (error) { + console.error('Logout error:', error); + } finally { + clearAuthData(); + toast.success('已登出'); + } + }; + + const refreshAuth = async (): Promise => { + 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 ( + + {children} + + ); +}; \ No newline at end of file diff --git a/frontend/src/providers/ThemeProvider.tsx b/frontend/src/providers/ThemeProvider.tsx new file mode 100644 index 0000000..3130de1 --- /dev/null +++ b/frontend/src/providers/ThemeProvider.tsx @@ -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(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 = ({ children }) => { + const [themeMode, setThemeMode] = useState('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 ( + + + + {children} + + + ); +}; \ No newline at end of file diff --git a/frontend/src/providers/index.tsx b/frontend/src/providers/index.tsx new file mode 100644 index 0000000..878f3f1 --- /dev/null +++ b/frontend/src/providers/index.tsx @@ -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 = ({ children }) => { + return ( + + + + + + {children} + + + + + + + ); +}; \ No newline at end of file diff --git a/frontend/src/store/index.ts b/frontend/src/store/index.ts new file mode 100644 index 0000000..96c99bd --- /dev/null +++ b/frontend/src/store/index.ts @@ -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; +export type AppDispatch = typeof store.dispatch; \ No newline at end of file diff --git a/frontend/src/store/slices/authSlice.ts b/frontend/src/store/slices/authSlice.ts new file mode 100644 index 0000000..94095c8 --- /dev/null +++ b/frontend/src/store/slices/authSlice.ts @@ -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) => { + 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; \ No newline at end of file diff --git a/frontend/src/store/slices/todosSlice.ts b/frontend/src/store/slices/todosSlice.ts new file mode 100644 index 0000000..e18b96a --- /dev/null +++ b/frontend/src/store/slices/todosSlice.ts @@ -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) => { + state.todos = action.payload; + }, + addTodo: (state, action: PayloadAction) => { + state.todos.unshift(action.payload); + }, + updateTodo: (state, action: PayloadAction) => { + const index = state.todos.findIndex(todo => todo.id === action.payload.id); + if (index !== -1) { + state.todos[index] = action.payload; + } + }, + removeTodo: (state, action: PayloadAction) => { + state.todos = state.todos.filter(todo => todo.id !== action.payload); + }, + setSelectedTodos: (state, action: PayloadAction) => { + state.selectedTodos = action.payload; + }, + toggleTodoSelection: (state, action: PayloadAction) => { + 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) => { + 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) => { + 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) => { + state.isLoading = action.payload; + }, + setPagination: (state, action: PayloadAction>) => { + state.pagination = { ...state.pagination, ...action.payload }; + }, + setPage: (state, action: PayloadAction) => { + 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; \ No newline at end of file diff --git a/frontend/src/store/slices/uiSlice.ts b/frontend/src/store/slices/uiSlice.ts new file mode 100644 index 0000000..d892d6e --- /dev/null +++ b/frontend/src/store/slices/uiSlice.ts @@ -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) => { + state.sidebarOpen = action.payload; + }, + toggleSidebarCollapsed: (state) => { + state.sidebarCollapsed = !state.sidebarCollapsed; + }, + setSidebarCollapsed: (state, action: PayloadAction) => { + state.sidebarCollapsed = action.payload; + }, + setSearchOpen: (state, action: PayloadAction) => { + state.searchOpen = action.payload; + }, + setFilterPanelOpen: (state, action: PayloadAction) => { + state.filterPanelOpen = action.payload; + }, + setCreateTodoDialogOpen: (state, action: PayloadAction) => { + state.createTodoDialogOpen = action.payload; + }, + setEditTodoDialogOpen: (state, action: PayloadAction) => { + state.editTodoDialogOpen = action.payload; + }, + setDeleteTodoDialogOpen: (state, action: PayloadAction) => { + state.deleteTodoDialogOpen = action.payload; + }, + setBatchActionsOpen: (state, action: PayloadAction) => { + state.batchActionsOpen = action.payload; + }, + setSettingsDialogOpen: (state, action: PayloadAction) => { + state.settingsDialogOpen = action.payload; + }, + setAiChatOpen: (state, action: PayloadAction) => { + state.aiChatOpen = action.payload; + }, + setImportDialogOpen: (state, action: PayloadAction) => { + state.importDialogOpen = action.payload; + }, + setCurrentEditingTodo: (state, action: PayloadAction) => { + state.currentEditingTodo = action.payload; + }, + setCurrentDeletingTodos: (state, action: PayloadAction) => { + state.currentDeletingTodos = action.payload; + }, + addNotification: (state, action: PayloadAction>) => { + const notification = { + ...action.payload, + id: Date.now().toString(), + timestamp: Date.now(), + read: false, + }; + state.notifications.unshift(notification); + }, + markNotificationAsRead: (state, action: PayloadAction) => { + 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) => { + state.notifications = state.notifications.filter(n => n.id !== action.payload); + }, + clearNotifications: (state) => { + state.notifications = []; + }, + setOnlineStatus: (state, action: PayloadAction) => { + state.isOnline = action.payload; + }, + setLastSync: (state, action: PayloadAction) => { + 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; \ No newline at end of file diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts new file mode 100644 index 0000000..10146d1 --- /dev/null +++ b/frontend/src/types/index.ts @@ -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 { + 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 { + data?: T; + error?: string; + message?: string; +} + +export interface PaginatedResponse { + 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; \ No newline at end of file diff --git a/frontend/tailwind.config.js b/frontend/tailwind.config.js new file mode 100644 index 0000000..9225690 --- /dev/null +++ b/frontend/tailwind.config.js @@ -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: [], +} \ No newline at end of file diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..db231b8 --- /dev/null +++ b/frontend/tsconfig.json @@ -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"] +} \ No newline at end of file diff --git a/manage.bat b/manage.bat new file mode 100644 index 0000000..2e5551b --- /dev/null +++ b/manage.bat @@ -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 \ No newline at end of file diff --git a/mysql/init/01-init.sql b/mysql/init/01-init.sql new file mode 100644 index 0000000..aa7fd38 --- /dev/null +++ b/mysql/init/01-init.sql @@ -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; \ No newline at end of file