commit a2f024704cdac09d07a5f3cf497ff6fce296aee5 Author: beabigegg Date: Fri Sep 12 07:37:26 2025 +0800 backup 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 0000000..b12aa06 Binary files /dev/null and b/frontend/public/panjit-logo.png differ 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