From b0c86302ff5695b2e885c44e92651eb98ab62cdd Mon Sep 17 00:00:00 2001 From: beabigegg Date: Fri, 29 Aug 2025 16:25:46 +0800 Subject: [PATCH] 1ST --- .claude/settings.local.json | 28 + .gitignore | 60 + BEST_PRACTICES.md | 558 ++ CONVERSATION_MEMORY.md | 95 + PRD.md | 176 + README.md | 385 ++ backend/.env.example | 92 + backend/Dockerfile | 36 + backend/app.py | 160 + backend/celery_app.py | 9 + backend/config.py | 139 + backend/create_sample_data.py | 141 + backend/debug_ldap.py | 90 + backend/init_db.py | 65 + backend/models.py | 240 + backend/requirements.txt | 37 + backend/routes/admin.py | 191 + backend/routes/auth.py | 175 + backend/routes/excel.py | 527 ++ backend/routes/health.py | 125 + backend/routes/notifications.py | 584 ++ backend/routes/reports.py | 372 ++ backend/routes/scheduler.py | 261 + backend/routes/todos.py | 709 ++ backend/routes/users.py | 128 + backend/tasks.py | 226 + backend/tasks_simple.py | 178 + backend/templates/emails/fire_email.html | 230 + backend/utils/email_service.py | 319 + backend/utils/ldap_utils.py | 230 + backend/utils/logger.py | 58 + backend/utils/mock_ldap.py | 140 + backend/utils/notification_service.py | 225 + docker-compose.yml | 103 + frontend/.env.example | 184 + frontend/.env.local | 184 + frontend/Dockerfile | 43 + frontend/next-env.d.ts | 5 + frontend/next.config.js | 22 + frontend/package-lock.json | 7808 ++++++++++++++++++++++ frontend/package.json | 46 + frontend/public/panjit-logo.png | Bin 0 -> 7006 bytes frontend/src/app/calendar/page.tsx | 182 + frontend/src/app/dashboard/page.tsx | 544 ++ frontend/src/app/globals.css | 207 + frontend/src/app/layout.tsx | 40 + frontend/src/app/login/page.tsx | 358 + frontend/src/app/page.tsx | 41 + frontend/src/app/settings/page.tsx | 764 +++ frontend/src/app/todos/page.tsx | 611 ++ frontend/src/lib/api.ts | 316 + frontend/src/lib/theme.ts | 210 + frontend/src/providers/AuthProvider.tsx | 180 + frontend/src/providers/ThemeProvider.tsx | 98 + frontend/src/providers/index.tsx | 91 + frontend/src/store/index.ts | 21 + frontend/src/store/slices/authSlice.ts | 37 + frontend/src/store/slices/todosSlice.ts | 119 + frontend/src/store/slices/uiSlice.ts | 168 + frontend/src/types/index.ts | 181 + frontend/tailwind.config.js | 69 + frontend/tsconfig.json | 34 + logo/PANJIT06(100x100pixal ).png | Bin 0 -> 7006 bytes mysql/init/01-init.sql | 131 + todo_import_template_v1_formal.xlsx | Bin 0 -> 7309 bytes 65 files changed, 19786 insertions(+) create mode 100644 .claude/settings.local.json create mode 100644 .gitignore create mode 100644 BEST_PRACTICES.md create mode 100644 CONVERSATION_MEMORY.md create mode 100644 PRD.md create mode 100644 README.md create mode 100644 backend/.env.example create mode 100644 backend/Dockerfile create mode 100644 backend/app.py create mode 100644 backend/celery_app.py create mode 100644 backend/config.py create mode 100644 backend/create_sample_data.py create mode 100644 backend/debug_ldap.py create mode 100644 backend/init_db.py create mode 100644 backend/models.py create mode 100644 backend/requirements.txt create mode 100644 backend/routes/admin.py create mode 100644 backend/routes/auth.py create mode 100644 backend/routes/excel.py create mode 100644 backend/routes/health.py create mode 100644 backend/routes/notifications.py create mode 100644 backend/routes/reports.py create mode 100644 backend/routes/scheduler.py create mode 100644 backend/routes/todos.py create mode 100644 backend/routes/users.py create mode 100644 backend/tasks.py create mode 100644 backend/tasks_simple.py create mode 100644 backend/templates/emails/fire_email.html create mode 100644 backend/utils/email_service.py create mode 100644 backend/utils/ldap_utils.py create mode 100644 backend/utils/logger.py create mode 100644 backend/utils/mock_ldap.py create mode 100644 backend/utils/notification_service.py create mode 100644 docker-compose.yml create mode 100644 frontend/.env.example create mode 100644 frontend/.env.local create mode 100644 frontend/Dockerfile create mode 100644 frontend/next-env.d.ts create mode 100644 frontend/next.config.js create mode 100644 frontend/package-lock.json create mode 100644 frontend/package.json create mode 100644 frontend/public/panjit-logo.png create mode 100644 frontend/src/app/calendar/page.tsx create mode 100644 frontend/src/app/dashboard/page.tsx create mode 100644 frontend/src/app/globals.css create mode 100644 frontend/src/app/layout.tsx create mode 100644 frontend/src/app/login/page.tsx create mode 100644 frontend/src/app/page.tsx create mode 100644 frontend/src/app/settings/page.tsx create mode 100644 frontend/src/app/todos/page.tsx create mode 100644 frontend/src/lib/api.ts create mode 100644 frontend/src/lib/theme.ts create mode 100644 frontend/src/providers/AuthProvider.tsx create mode 100644 frontend/src/providers/ThemeProvider.tsx create mode 100644 frontend/src/providers/index.tsx create mode 100644 frontend/src/store/index.ts create mode 100644 frontend/src/store/slices/authSlice.ts create mode 100644 frontend/src/store/slices/todosSlice.ts create mode 100644 frontend/src/store/slices/uiSlice.ts create mode 100644 frontend/src/types/index.ts create mode 100644 frontend/tailwind.config.js create mode 100644 frontend/tsconfig.json create mode 100644 logo/PANJIT06(100x100pixal ).png create mode 100644 mysql/init/01-init.sql create mode 100644 todo_import_template_v1_formal.xlsx diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..be13fea --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,28 @@ +{ + "permissions": { + "allow": [ + "Read(C:\\Users\\EGG\\WORK\\data\\user_scrip\\TOOL\\semiauto-assistant/**)", + "Read(C:\\Users\\EGG\\WORK\\data\\user_scrip\\TOOL\\TEMP_spec_system_V3/**)", + "Read(C:\\Users\\EGG\\.claude/**)", + "Bash(rm:*)", + "Bash(npm test)", + "Bash(npm install)", + "Bash(npm test:*)", + "Bash(python -m pytest --version)", + "Bash(python -m pytest tests/test_models.py -v)", + "Bash(python:*)", + "Bash(npm run build:*)", + "Bash(./venv/Scripts/activate)", + "Bash(npm start)", + "Bash(curl:*)", + "Bash(find:*)", + "Bash(grep:*)", + "Bash(copy:*)", + "Bash(del check_enum.py)", + "Bash(npm run dev:*)", + "Bash(del \"src\\components\\notifications\\EmailNotificationSettings.tsx\")" + ], + "deny": [], + "ask": [] + } +} \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..39dc4fc --- /dev/null +++ b/.gitignore @@ -0,0 +1,60 @@ +# --- 敏感資訊 (Sensitive Information) --- +# 忽略包含所有密鑰和資料庫連線資訊的環境變數檔案。 + +# --- 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/ +components/ + +# --- 作業系統相關 (Operating System) --- +# 忽略 macOS 的系統檔案。 +.DS_Store + +# 忽略 Windows 的縮圖快取。 +Thumbs.db + +# --- Log 檔案 --- +# 忽略所有日誌檔案。 +*.log +logs/ + +# --- 環境設定檔 --- +.env + +# --- 測試相關 (Testing) --- +# 忽略測試檔案 +test_*.py +*_test.py +tests/ + +# --- 開發者專用文件 (Developer Only) --- +# 最佳實踐文件(包含敏感設定資訊) + + +# --- 對話記憶檔案 (Conversation Memory) --- +# 包含開發過程記錄,不需要版本控制 + diff --git a/BEST_PRACTICES.md b/BEST_PRACTICES.md new file mode 100644 index 0000000..1242e31 --- /dev/null +++ b/BEST_PRACTICES.md @@ -0,0 +1,558 @@ +# 暫時規範管理系統 V3 - 開發者最佳實踐指南 + +> **⚠️ 重要提醒**:本文件包含敏感的系統配置和最佳實踐資訊,僅供開發團隊內部使用。 +> 此文件已在 .gitignore 中排除,請勿提交至版本控制系統。 + +## 🎯 文件目的 + +本文件記錄在開發暫時規範管理系統 V3 過程中遇到的技術難點及最佳解決方案,避免後續開發者重複踩坑。 + +--- + +## 🔐 LDAP/Active Directory 整合最佳實踐 + +### 1. LDAP 連接配置 + +**關鍵發現**:LDAP 連接的穩定性很大程度取決於正確的配置參數組合。 + +#### 正確的配置模式 + +```python +# config.py - 推薦配置 +LDAP_SERVER = "ldap://dc.company.com" # 或使用 IP +LDAP_PORT = 389 # 標準 LDAP port +LDAP_USE_SSL = False # 內網環境通常不需要 SSL +LDAP_SEARCH_BASE = "DC=company,DC=com" +LDAP_BIND_USER_DN = "CN=ServiceAccount,OU=ServiceAccounts,DC=company,DC=com" +LDAP_USER_LOGIN_ATTR = "userPrincipalName" # AD 環境必須使用此屬性 +``` + +#### 常見錯誤及解決方案 + +**錯誤 1**:使用 `sAMAccountName` 作為登入屬性 +```python +# ❌ 錯誤做法 +LDAP_USER_LOGIN_ATTR = "sAMAccountName" + +# ✅ 正確做法 +LDAP_USER_LOGIN_ATTR = "userPrincipalName" +``` + +**錯誤 2**:服務帳號權限不足 +```python +# 服務帳號至少需要以下權限: +# - Read permission on the search base +# - List Contents permission +# - Read All Properties permission +``` + +### 2. LDAP 搜尋最佳化 + +**關鍵發現**:正確的搜尋篩選器可以大幅提升效能並避免權限問題。 + +#### 用戶搜尋最佳實踐 + +```python +# ldap_utils.py - 優化後的搜尋篩選器 +def search_ldap_principals(search_term): + # 多屬性搜尋,提高命中率 + 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}*) + ) + ) + """ +``` + +**關鍵技巧**: +1. 使用 `objectCategory=person` 而不是只用 `objectClass=user` +2. 排除停用帳號避免無效結果 +3. 多屬性搜尋提高使用者體驗 +4. 限制搜尋結果數量避免效能問題 + +#### 群組搜尋最佳實踐 + +```python +# 同時支援 AD 群組和 OU +def get_ldap_group_members(group_name): + # 先嘗試搜尋 AD 群組 + group_filter = f"(&(objectClass=group)(cn={group_name}))" + + # 如果找不到群組,嘗試搜尋 OU + if not found: + ou_filter = f"(&(objectClass=organizationalUnit)(name=*{group_name}*))" +``` + +### 3. LDAP 連接穩定性 + +**關鍵發現**:連接池和重試機制對生產環境至關重要。 + +```python +# ldap_utils.py - 連接重試機制 +def create_ldap_connection(retries=3): + for attempt in range(retries): + try: + server = Server(ldap_server, port=ldap_port, use_ssl=use_ssl) + conn = Connection(server, user=bind_dn, password=bind_password, auto_bind=True) + return conn + except Exception as e: + if attempt == retries - 1: + raise e + time.sleep(1) # 短暫等待後重試 +``` + +--- + +## 📧 SMTP 郵件系統最佳實踐 + +### 1. 多種 SMTP 配置支援 + +**關鍵發現**:企業環境中可能遇到多種 SMTP 配置需求,系統必須具備彈性。 + +#### 配置架構設計 + +```python +# config.py - 彈性 SMTP 配置 +class Config: + SMTP_SERVER = os.getenv('SMTP_SERVER', 'mail.company.com') + SMTP_PORT = int(os.getenv('SMTP_PORT', 25)) + SMTP_USE_TLS = os.getenv('SMTP_USE_TLS', 'false').lower() in ['true', '1', 't'] + SMTP_USE_SSL = os.getenv('SMTP_USE_SSL', 'false').lower() in ['true', '1', 't'] + SMTP_AUTH_REQUIRED = os.getenv('SMTP_AUTH_REQUIRED', 'false').lower() in ['true', '1', 't'] + SMTP_SENDER_EMAIL = os.getenv('SMTP_SENDER_EMAIL', 'temp-spec-system@company.com') + SMTP_SENDER_PASSWORD = os.getenv('SMTP_SENDER_PASSWORD', '') +``` + +#### 智能連接邏輯 + +```python +# utils.py - 智能 SMTP 連接 +def send_email(to_addrs, subject, body): + # 根據 port 和配置自動選擇連接方式 + if use_ssl and smtp_port == 465: + server = smtplib.SMTP_SSL(smtp_server, smtp_port) + else: + server = smtplib.SMTP(smtp_server, smtp_port) + if use_tls and smtp_port == 587: + server.starttls() + + # 只在需要認證時才登入 + if auth_required and sender_password: + server.login(sender_email, sender_password) +``` + +### 2. 郵件發送可靠性 + +**關鍵發現**:詳細的日誌和錯誤處理對於診斷郵件問題至關重要。 + +```python +# utils.py - 完整的錯誤處理 +def send_email(to_addrs, subject, body): + try: + # ... 發送邏輯 ... + result = server.sendmail(sender_email, to_addrs, msg.as_string()) + + # 檢查發送結果 + if result: + # 某些收件者失敗 + print(f"[EMAIL WARNING] 部分收件者發送失敗: {result}") + else: + print(f"[EMAIL SUCCESS] 郵件成功發送至: {', '.join(to_addrs)}") + + return True + + except smtplib.SMTPAuthenticationError as e: + print(f"[EMAIL ERROR] SMTP 認證失敗: {e}") + return False + except smtplib.SMTPConnectError as e: + print(f"[EMAIL ERROR] SMTP 連接失敗: {e}") + return False + # ... 其他異常處理 ... +``` + +### 3. 郵件內容最佳化 + +**關鍵發現**:HTML 格式郵件必須考慮各種郵件客戶端的相容性。 + +```python +# 推薦的 HTML 郵件格式 +def create_email_body(spec, action): + body = f""" + + + + + + +
+

[暫規通知] 規範 '{spec.spec_code}' 已{action}

+
+
+

您好,

+ +
+ + + """ + return body +``` + +--- + +## 🗄️ 資料庫設計最佳實踐 + +### 1. 資料庫遷移策略 + +**關鍵發現**:平滑的資料庫升級對於生產環境至關重要。 + +#### 遷移腳本模板 + +```python +# migrate_*.py - 標準遷移腳本結構 +def migrate_database(): + engine = create_engine(Config.SQLALCHEMY_DATABASE_URI) + + try: + with engine.connect() as conn: + # 檢查是否已經遷移 + result = conn.execute(text(""" + SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_NAME = 'table_name' AND COLUMN_NAME = 'new_column' + AND TABLE_SCHEMA = DATABASE() + """)) + + if result.fetchone(): + print("✓ 遷移已完成,無需重複執行") + return True + + # 執行遷移 + conn.execute(text("ALTER TABLE table_name ADD COLUMN new_column TYPE")) + conn.commit() + + # 驗證遷移結果 + # ... + + except Exception as e: + print(f"✗ 遷移失敗:{str(e)}") + return False +``` + +### 2. 資料模型設計 + +**關鍵發現**:適當的索引和關聯設計可以大幅提升查詢效能。 + +```python +# models.py - 最佳實踐 +class TempSpec(db.Model): + __tablename__ = 'ts_temp_spec' + + # 主鍵 + id = db.Column(db.Integer, primary_key=True) + + # 業務鍵,建立索引 + spec_code = db.Column(db.String(20), nullable=False, index=True) + + # 常用查詢欄位,建立索引 + status = db.Column(db.Enum(...), nullable=False, index=True) + end_date = db.Column(db.Date, index=True) # 用於到期查詢 + + # 新功能擴展欄位 + notification_emails = db.Column(db.Text, nullable=True) + + # 正確的關聯設置 + uploads = db.relationship('Upload', back_populates='spec', + cascade='all, delete-orphan') +``` + +--- + +## 🔄 Flask 應用架構最佳實踐 + +### 1. 藍圖(Blueprint)組織 + +**關鍵發現**:良好的模組分離有助於維護和擴展。 + +```python +# 推薦的路由組織結構 +routes/ +├── __init__.py # 藍圖註冊 +├── auth.py # 認證相關 +├── api.py # API 介面 +├── temp_spec.py # 核心業務邏輯 +├── admin.py # 管理功能 +└── upload.py # 檔案處理 +``` + +### 2. 錯誤處理策略 + +```python +# app.py - 全局錯誤處理 +@app.errorhandler(403) +def forbidden(error): + return render_template('403.html'), 403 + +@app.errorhandler(404) +def not_found(error): + return render_template('404.html'), 404 + +@app.errorhandler(500) +def internal_error(error): + db.session.rollback() + return render_template('500.html'), 500 +``` + +### 3. 配置管理 + +```python +# config.py - 環境配置分離 +class DevelopmentConfig(Config): + DEBUG = True + TESTING = False + +class ProductionConfig(Config): + DEBUG = False + TESTING = False + # 生產環境特定設定 + +class TestingConfig(Config): + TESTING = True + # 測試環境設定 +``` + +--- + +## 🏗️ 前端整合最佳實踐 + +### 1. ONLYOFFICE 整合要點 + +**關鍵發現**:Docker 環境下的網路配置是最大的挑戰。 + +```python +# routes/temp_spec.py - URL 修正邏輯 +def edit_spec(spec_id): + doc_url = get_file_uri(doc_filename) + callback_url = url_for('temp_spec.onlyoffice_callback', spec_id=spec_id, _external=True) + + # Docker 環境 URL 修正 + if '127.0.0.1' in doc_url or 'localhost' in doc_url: + doc_url = doc_url.replace('127.0.0.1', 'host.docker.internal') + doc_url = doc_url.replace('localhost', 'host.docker.internal') + callback_url = callback_url.replace('127.0.0.1', 'host.docker.internal') + callback_url = callback_url.replace('localhost', 'host.docker.internal') +``` + +### 2. 前端元件最佳化 + +**關鍵發現**:Tom Select 元件需要正確配置才能提供良好的使用者體驗。 + +```javascript +// 推薦的 Tom Select 配置 +const recipientSelect = new TomSelect('#recipients', { + valueField: 'value', + labelField: 'text', + searchField: 'text', + placeholder: '請輸入姓名或 Email 來搜尋...', + plugins: ['remove_button'], + maxItems: null, + create: false, + load: function(query, callback) { + if (!query || query.length < 2) { + callback(); + return; + } + + // 實作搜尋邏輯... + } +}); +``` + +--- + +## 🚀 部署最佳實踐 + +### 1. Docker 配置優化 + +```yaml +# docker-compose.yml - 生產環境配置 +version: '3.8' + +services: + app: + build: . + environment: + - FLASK_ENV=production + - PYTHONUNBUFFERED=1 + volumes: + - ./uploads:/app/uploads + - ./logs:/app/logs + restart: unless-stopped + + mysql: + image: mysql:8.0 + environment: + MYSQL_ROOT_PASSWORD: ${DB_ROOT_PASSWORD} + MYSQL_DATABASE: ${DB_NAME} + volumes: + - mysql_data:/var/lib/mysql + restart: unless-stopped + +volumes: + mysql_data: +``` + +### 2. 日誌管理 + +```python +# app.py - 生產環境日誌配置 +if not app.debug: + if not os.path.exists('logs'): + os.mkdir('logs') + + file_handler = RotatingFileHandler('logs/tempspec.log', + maxBytes=10240000, backupCount=10) + file_handler.setFormatter(logging.Formatter( + '%(asctime)s %(levelname)s: %(message)s [in %(pathname)s:%(lineno)d]' + )) + file_handler.setLevel(logging.INFO) + app.logger.addHandler(file_handler) +``` + +### 3. 安全性配置 + +```python +# 推薦的安全標頭設置 +@app.after_request +def after_request(response): + response.headers['X-Content-Type-Options'] = 'nosniff' + response.headers['X-Frame-Options'] = 'DENY' + response.headers['X-XSS-Protection'] = '1; mode=block' + return response +``` + +--- + +## 🐛 除錯與監控 + +### 1. 開發階段除錯 + +```python +# 推薦的除錯配置 +DEBUG_LDAP = os.getenv('DEBUG_LDAP', 'false').lower() == 'true' +DEBUG_EMAIL = os.getenv('DEBUG_EMAIL', 'false').lower() == 'true' +DEBUG_DATABASE = os.getenv('DEBUG_DATABASE', 'false').lower() == 'true' + +def debug_log(category, message): + if category == 'ldap' and DEBUG_LDAP: + print(f"[LDAP DEBUG] {message}") + elif category == 'email' and DEBUG_EMAIL: + print(f"[EMAIL DEBUG] {message}") + # ... +``` + +### 2. 生產環境監控 + +```python +# tasks.py - 健康檢查任務 +@scheduler.task('cron', id='health_check', hour='*/1') +def health_check(): + try: + # 檢查資料庫連接 + db.session.execute(text('SELECT 1')) + + # 檢查 LDAP 連接 + test_ldap_connection() + + # 檢查 SMTP 連接 + test_smtp_connection() + + app.logger.info("Health check passed") + except Exception as e: + app.logger.error(f"Health check failed: {e}") +``` + +--- + +## 📊 效能優化要點 + +### 1. 資料庫查詢優化 + +```python +# 推薦的查詢模式 +def get_active_specs_expiring_soon(): + return TempSpec.query.filter( + TempSpec.status == 'active', + TempSpec.end_date <= datetime.now().date() + timedelta(days=7) + ).options( + joinedload(TempSpec.uploads) # 預載關聯資料 + ).all() +``` + +### 2. 快取策略 + +```python +# 推薦使用 Flask-Caching +from flask_caching import Cache + +cache = Cache(app, config={'CACHE_TYPE': 'simple'}) + +@cache.memoize(timeout=300) # 5分鐘快取 +def get_ldap_group_members(group_name): + # LDAP 查詢邏輯... +``` + +--- + +## 🔧 維護與升級 + +### 1. 版本控制策略 + +```bash +# 推薦的版本標籤格式 +git tag v3.2.0-rc1 # 發布候選版本 +git tag v3.2.0 # 正式版本 +git tag v3.2.1 # 修正版本 +``` + +### 2. 備份策略 + +```bash +#!/bin/bash +# backup.sh - 定期備份腳本 +DATE=$(date +%Y%m%d_%H%M%S) + +# 資料庫備份 +mysqldump -u $DB_USER -p$DB_PASSWORD $DB_NAME > backup_${DATE}.sql + +# 檔案備份 +tar -czf uploads_${DATE}.tar.gz uploads/ +``` + +--- + +## 📝 總結 + +本文件記錄了開發暫時規範管理系統 V3 過程中的關鍵技術決策和最佳實踐。這些經驗可以幫助後續開發者: + +1. **避免常見陷阱**:特別是 LDAP 配置和 SMTP 設定 +2. **提升開發效率**:使用經過驗證的架構模式 +3. **確保系統穩定性**:採用完整的錯誤處理和監控機制 +4. **簡化部署流程**:使用 Docker 和自動化腳本 + +**重要提醒**:本文件包含敏感資訊,請勿外洩或提交至公開版本控制系統。 + +--- + +*最後更新:2025年1月* +*文件版本:V1.0* \ No newline at end of file diff --git a/CONVERSATION_MEMORY.md b/CONVERSATION_MEMORY.md new file mode 100644 index 0000000..d5aa42c --- /dev/null +++ b/CONVERSATION_MEMORY.md @@ -0,0 +1,95 @@ +# 對話記憶 - PANJIT To-Do List System V1 + +## 最新狀態 (2025-08-29) + +### 已完成的主要功能 +1. **Fire Email 功能修復** - 修正 AD 帳號轉換郵件地址的問題 +2. **重複錯誤提示修復** - 解決 axios 攔截器重複顯示錯誤的問題 +3. **增強型郵件通知設定** - 支援彈性提醒天數、週/月摘要 +4. **CORS 網路錯誤修復** - 新增 port 3002 支援 +5. **通知系統實作** - 完整的通知顯示與互動功能 +6. **通知面板互動功能** - 查看、標記已讀等按鈕功能完成 + +### 核心技術架構 +- **前端**: Next.js 14 + TypeScript + Material-UI + React Hook Form +- **後端**: Flask + SQLAlchemy + JWT + MySQL +- **認證**: LDAP 整合 + JWT Token 刷新機制 +- **郵件**: SMTP 服務整合 (mail.panjit.com.tw:25) +- **資料庫**: MySQL with enhanced notification models + +### 重要修復記錄 + +#### 1. Fire Email 核心問題修復 +**檔案**: `backend/utils/email_service.py:_get_user_email` +```python +# 修正前 +user_info.get('mail') # 錯誤的欄位名稱 + +# 修正後 +user_info.get('email') # 正確的欄位名稱 +``` + +#### 2. 通知系統 API 實作 +**檔案**: `backend/routes/notifications.py` +- 新增 `/api/notifications/` - 獲取用戶通知 +- 新增 `/api/notifications/mark-read` - 標記單個通知已讀 +- 新增 `/api/notifications/mark-all-read` - 標記全部通知已讀 + +#### 3. 前端通知面板功能 +**檔案**: `frontend/src/components/layout/NotificationPanel.tsx` +- 實作查看按鈕 - 導航到對應 todo +- 實作標記已讀按鈕 - 更新通知狀態 +- 實作全部標記已讀 - 批量更新 +- 實作查看全部 - 導航到主頁 + +### 資料庫模型增強 +**檔案**: `backend/models.py` +```python +class TodoUserPref(db.Model): + monthly_summary_enabled = db.Column(db.Boolean, default=False) + reminder_days_before = db.Column(JSON, default=lambda: [1, 3]) + weekly_summary_time = db.Column(db.String(5), default='09:00') + monthly_summary_time = db.Column(db.String(5), default='09:00') + # ... 其他增強欄位 +``` + +### 已知問題與待實作功能 +1. **公開 Todo 功能** - 目前追蹤人角色缺乏意義,需要實作公開 todo 功能 +2. **通知已讀狀態持久化** - 目前僅在記憶體中,需要資料庫儲存 +3. **Todo 可見性設定** - 需要新增公開/私人設定 + +### 環境配置 +```bash +# 前端 (Port 3002) +cd frontend && npm run dev + +# 後端 (Port 5000) +cd backend && ./venv/Scripts/activate && python app.py +``` + +### 測試用戶帳號 +- **uthuang** (92509) - uthuang@panjit.com.tw +- **ymirliu** - ymirliu@panjit.com.tw +- **minjiesyu** (92453) - minjiesyu@panjit.com.tw + +### API 端點清單 +- `GET /api/notifications/` - 獲取通知清單 +- `POST /api/notifications/mark-read` - 標記單個通知已讀 +- `POST /api/notifications/mark-all-read` - 標記全部通知已讀 +- `POST /api/notifications/fire-email` - 發送緊急郵件 +- `GET/PATCH /api/notifications/settings` - 通知設定管理 + +### 重要設定檔案 +- **後端 .env**: 包含 CORS_ORIGINS 設定 (支援 3000,3001,3002) +- **前端 api.ts**: 包含完整的 notificationsApi 客戶端 +- **資料庫連線**: mysql.theaken.com:33306/db_A060 + +## 開發原則提醒 +1. 必須提供完整可執行的程式碼 +2. 所有功能都需要單元測試 +3. 確保 Windows 環境相容性 +4. 遵循 MUI 設計規範 +5. 實作適當的錯誤處理 + +--- +*最後更新: 2025-08-29 16:19* \ No newline at end of file diff --git a/PRD.md b/PRD.md new file mode 100644 index 0000000..712c3e8 --- /dev/null +++ b/PRD.md @@ -0,0 +1,176 @@ +# PANJIT|To-Do List(Web UI)V1 產品需求文件(PRD) +**版本**:V1(定版) +**最後更新**:2025-08-28 +**資料庫**:MySQL(所有資料表皆採前綴 `todo_`) +**登入**:AD/LDAP(不限僅內網) +**DueDate 精度**:到 **「日」** +**Email 寄件者**:以**建立者的 AD Mail**發送(SMTP 需允許代寄或以 Envelope From 配合) + +**Excel 匯入**:提供正式模板(含下拉驗證、README) + +--- + +## 1. 背景與目標 +- 以 AD 帳號辨識使用者,提供個人/協作化的待辦管理。 +- 除固定排程提醒外,提供「**Fire 一鍵提醒**」降低漏辦風險。 +- 提供**正式 Excel 模板**以降低匯入錯誤率。 + +### 成功指標(示例) +- 90% 使用者在 2 週內完成首次建立/匯入待辦。 +- 逾期數較導入前下降 30%。 +- 主要操作 P95 < 1s;匯入任務成功率 99%。 + +--- + +## 2. 使用者與場景 +- **建立者**:建立待辦者,預設可見/可編輯。 +- **負責人(多人)**:可見/可編輯(與建立者同等)。 +- **追蹤者(多人)**:可見但預設不可編輯;可收信。 + +主要場景: +1) 新增/編輯待辦(可設定多負責人、多追蹤者)。 +2) 清單/日曆檢視、篩選、批次操作。 +3) Fire 一鍵提醒:立即寄發、可寫附註、冷卻與限額管控。 +4) 排程提醒:到期前/當天/逾期 + 週摘要(可開關)。 +5) 匯入:下載模板→填寫→上傳驗證→導入。 + +--- + +## 3. 功能需求 + +### 3.1 待辦 CRUD 與視圖 +- 欄位: + - 必填:`title` + - 選填:`description`, `priority(LOW|MEDIUM|HIGH|URGENT)`, `status(NEW|DOING|BLOCKED|DONE)`, `due_date(YYYY-MM-DD)`, `starred` + - 系統:`id(uuid)`, `created_at`, `completed_at`, `creator_ad`, `creator_display_name`, `creator_email` +- 視圖:列表 + 日曆;支援篩選(狀態、到期區間、加星)與批次操作(狀態、到期日)。 +- 可見性:建立者/負責人/追蹤者可見;可編輯者為建立者與負責人。 + +### 3.2 多負責人/多追蹤者 +- 以 AD 帳號多選加入;維護表 `todo_item_responsible`, `todo_item_follower`。 + +### 3.3 Email 通知 +- **排程提醒**(可開關):到期前 X 天、到期日、逾期 Y 天;週一 09:00 摘要(建議)。 +- **Fire 一鍵提醒**: + - 受信人預設:負責人 + 追蹤者 + 建立者(可調整/去重)。 + - 冷卻/限額:**同一待辦 2 分鐘冷卻**,**每人每日 20 封**。 + - 可附註;所有寄送寫入 `todo_mail_log`。 + +### 3.4 Excel 匯入 +- 下載正式模板(含 README 與下拉驗證)。 +- 驗證:必填、日期格式、AD 帳號存在性、重複(同標題 + 近日期)。 +- 逐列錯誤報告與「問題列下載」。 + + +--- + +## 4. 非功能需求 +- 效能:一般操作 P95 < 1s;匯入以背景 Job 執行,提供進度查詢。 +- 可用性:SMTP 故障不影響 CRUD;提供降級邏輯。 +- 監控:`/healthz`(DB/SMTP);寄送/匯入皆留 `todo_audit_log`。 +- 備份:每日快照;保留 7/30 天。 + +--- + +## 5. 資料庫(MySQL,前綴 `todo_`) + +- `todo_item`:主表(待辦) +- `todo_item_responsible`:多負責人 +- `todo_item_follower`:多追蹤者 +- `todo_mail_log`:排程/Fire 寄信紀錄 +- `todo_audit_log`:稽核日誌 +- `todo_user_pref`:使用者偏好 + +```mermaid +erDiagram + todo_item ||--o{ todo_item_responsible : has + todo_item ||--o{ todo_item_follower : has + todo_item ||--o{ todo_mail_log : logs + todo_item ||--o{ todo_audit_log : audits + + todo_item {{ + char(36) id PK + varchar title + text description + enum status + enum priority + date due_date + datetime created_at + datetime completed_at + varchar creator_ad + varchar creator_display_name + varchar creator_email + tinyint starred + }} + + todo_item_responsible {{ + char(36) todo_id FK + varchar ad_account + datetime added_at + }} + + todo_item_follower {{ + char(36) todo_id FK + varchar ad_account + datetime added_at + }} + + todo_mail_log {{ + bigint id PK + char(36) todo_id FK + enum type + varchar triggered_by_ad + text recipients + varchar subject + enum status + varchar provider_msg_id + text error_text + datetime created_at + datetime sent_at + }} + + todo_audit_log {{ + bigint id PK + varchar actor_ad + char(36) todo_id FK nullable + enum action + json detail + datetime created_at + }} + + todo_user_pref {{ + varchar ad_account PK + varchar email + datetime updated_at + }} +``` + +--- + +## 6. API(摘要) +- `GET /api/todos`、`POST /api/todos`、`PATCH /api/todos/{{id}}`、`DELETE /api/todos/{{id}}` +- `POST /api/todos/{{id}}/responsibles`、`POST /api/todos/{{id}}/followers` +- `POST /api/todos/{{id}}/fire-email` +- `GET /api/imports/template`、`POST /api/imports`、`GET /api/imports/{{job_id}}` + +--- + +## 7. 驗收標準 +1) AD 登入成功;首登寫入個資。 +2) 多負責人/多追蹤者運作;可見/可編輯權限如預期。 +3) Fire 寄信:預設收件人、冷卻/限額、附註、寄送紀錄。 +4) 排程提醒、週摘要可開關且寄送正確。 +5) 匯入:模板下載、驗證逐列錯誤、成功導入。 +6) 稽核:CRUD/匯入/寄信皆可查。 + +--- + +## 8. 風險與對策 +- SMTP 代寄受限 → 以系統 Envelope From + `Reply-To:` 建立者。 + +- 匯入錯誤率高 → 強制模板、下拉驗證、逐列錯誤檔回饋。 + +--- + +## 9. 附件 +- 正式 Excel 模板:`todo_import_template_v1_formal.xlsx` diff --git a/README.md b/README.md new file mode 100644 index 0000000..d87347e --- /dev/null +++ b/README.md @@ -0,0 +1,385 @@ +# PANJIT To-Do List System V1 + +一個基於 Next.js + Flask 的企業級待辦事項管理系統,支援 AD/LDAP 登入、多人協作、Email 提醒等功能。 + +## 🚀 系統特色 + +- ✅ **AD/LDAP 登入** - 企業級身份驗證 +- ✅ **多人協作** - 支援多負責人/多追蹤者 +- ✅ **Fire 一鍵提醒** - 立即郵件提醒功能 (2分鐘冷卻 + 每日20封限額) +- ✅ **排程提醒** - 到期前/當天/逾期自動提醒 + 週摘要 +- ✅ **Excel 匯入** - 完整模板驗證與錯誤處理 +- ✅ **完整稽核** - 所有操作記錄追蹤 +- ✅ **響應式設計** - 支援桌面與行動裝置 + +## 🏗️ 技術架構 + +### 前端 (Frontend) +- **Next.js 14** - React 全端框架 +- **TypeScript** - 類型安全開發 +- **Material-UI** - 企業級 UI 組件 +- **Redux Toolkit** - 狀態管理 +- **TanStack Query** - 服務端狀態管理 + +### 後端 (Backend) +- **Flask** - Python Web 框架 +- **SQLAlchemy** - ORM 資料庫管理 +- **MySQL** - 主要資料庫 +- **Celery + Redis** - 背景任務處理 +- **JWT** - 身份驗證 +- **python-ldap** - AD/LDAP 整合 + +### 部署 (Deployment) +- **Docker** - 容器化部署 +- **Nginx** - 反向代理 +- **MySQL 8.0** - 資料庫服務 +- **Redis** - 快取與任務佇列 + +## 📋 系統需求 + +- **Node.js** >= 18.0 +- **Python** >= 3.11 +- **MySQL** >= 8.0 +- **Redis** >= 6.0 + +## 🛠️ 本地開發安裝 + +### 1. 克隆專案 + +```bash +git clone +cd TODOLIST +``` + +### 2. 資料庫準備 + +#### 方式一:使用 Docker (推薦) +```bash +# 啟動 MySQL 和 Redis +docker-compose up mysql redis -d + +# 等待資料庫啟動完成 (大約30秒) +docker-compose logs mysql +``` + +#### 方式二:本地 MySQL +```bash +# 建立資料庫 +mysql -u root -p +CREATE DATABASE todo_system CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; +CREATE USER 'todouser'@'localhost' IDENTIFIED BY 'todopass'; +GRANT ALL PRIVILEGES ON todo_system.* TO 'todouser'@'localhost'; +FLUSH PRIVILEGES; +EXIT; + +# 初始化資料庫結構 +mysql -u todouser -p todo_system < mysql/init/01-init.sql +``` + +### 3. 後端設定 + +```bash +cd backend + +# 建立虛擬環境 +python -m venv venv +venv\Scripts\activate # Windows +# source venv/bin/activate # Linux/macOS + +# 安裝依賴 +pip install -r requirements.txt + +# 複製環境設定檔並修改 +copy .env.example .env # Windows +# cp .env.example .env # Linux/macOS + +# 編輯 .env 檔案,設定資料庫連線資訊 +``` + +#### 重要環境變數設定 + +編輯 `backend/.env` 檔案: + +```env +# MySQL 連線 (如使用Docker,保持預設值即可) +MYSQL_HOST=localhost +MYSQL_PORT=3306 +MYSQL_USER=todouser +MYSQL_PASSWORD=todopass +MYSQL_DATABASE=todo_system + +# SMTP 設定 (依照公司環境設定) +SMTP_SERVER=mail.your-company.com +SMTP_PORT=25 +SMTP_SENDER_EMAIL=todo-system@your-company.com + +# AD/LDAP 設定 (依照公司環境設定) +LDAP_SERVER=ldap://dc.your-company.com +LDAP_SEARCH_BASE=DC=your-company,DC=com +``` + +### 4. 前端設定 + +```bash +cd frontend + +# 安裝依賴 +npm install + +# 複製環境設定檔 +copy .env.example .env.local # Windows +# cp .env.example .env.local # Linux/macOS + +# 編輯 .env.local 設定 API URL +``` + +編輯 `frontend/.env.local`: +```env +NEXT_PUBLIC_API_URL=http://localhost:5000 +NEXT_PUBLIC_AD_DOMAIN=your-company.com.tw +NEXT_PUBLIC_EMAIL_DOMAIN=your-company.com.tw +``` + +### 5. 啟動應用程式 + +#### 後端啟動 +```bash +cd backend + +# 啟動 Flask 應用程式 +python app.py + +# 後端將在 http://localhost:5000 啟動 +``` + +#### 前端啟動 (另開終端) +```bash +cd frontend + +# 啟動開發服務器 +npm run dev + +# 前端將在 http://localhost:3000 啟動 +``` + +#### Celery 背景任務 (另開終端) +```bash +cd backend + +# 啟動 Celery Worker +celery -A celery_app.celery worker --loglevel=info + +# 啟動 Celery Beat (排程任務) +celery -A celery_app.celery beat --loglevel=info +``` + +## 🌐 訪問應用程式 + +- **前端界面**: http://localhost:3000 +- **後端 API**: http://localhost:5000 +- **API 文檔**: http://localhost:5000/api (Swagger UI) +- **健康檢查**: http://localhost:5000/api/health/healthz + +## 📁 專案結構 + +``` +TODOLIST/ +├── backend/ # Flask 後端 +│ ├── routes/ # API 路由 +│ ├── models.py # 資料模型 +│ ├── config.py # 設定檔 +│ ├── app.py # Flask 應用程式入口 +│ ├── tasks.py # Celery 背景任務 +│ └── utils/ # 工具函數 +├── frontend/ # Next.js 前端 +│ ├── src/ +│ │ ├── app/ # Next.js 應用程式路由 +│ │ ├── components/ # React 組件 +│ │ ├── store/ # Redux 狀態管理 +│ │ ├── lib/ # API 客戶端 +│ │ └── types/ # TypeScript 類型定義 +├── mysql/ +│ └── init/ # 資料庫初始化 SQL +├── nginx/ # Nginx 設定 +├── docker-compose.yml # Docker 編排設定 +└── PRD.md # 產品需求文件 +``` + +## 🔧 開發指令 + +### 後端開發 +```bash +cd backend + +# 啟動開發服務器 (自動重載) +flask run --debug + +# 資料庫遷移 +flask db upgrade + +# 執行 Python 腳本 +python -m scripts.init_admin_user +``` + +### 前端開發 +```bash +cd frontend + +# 開發模式 +npm run dev + +# 類型檢查 +npm run type-check + +# 程式碼檢查 +npm run lint + +# 建置生產版本 +npm run build + +# 啟動生產服務器 +npm start +``` + +## 📊 功能說明 + +### 1. 使用者登入 +- 使用 AD/LDAP 帳號登入 +- 首次登入自動建立使用者偏好設定 +- JWT Token 驗證機制 + +### 2. 待辦管理 +- 建立/編輯/刪除待辦事項 +- 支援多負責人與多追蹤者 +- 狀態管理:NEW/DOING/BLOCKED/DONE +- 優先級:LOW/MEDIUM/HIGH/URGENT +- 到期日設定與星號標記 + +### 3. 通知系統 +- **Fire 一鍵提醒**:立即發送郵件,2分鐘冷卻,每日20封限制 +- **排程提醒**:到期前3天、當天、逾期1天自動提醒 +- **週摘要**:每週一早上9點發送個人待辦摘要 + +### 4. Excel 匯入 +- 下載正式模板檔案 +- 逐列錯誤驗證與報告 +- AD 帳號存在性檢查 +- 重複項目檢測 + +### 5. 權限控制 +- **建立者**:完整編輯權限 +- **負責人**:完整編輯權限 +- **追蹤者**:僅檢視權限,可接收通知 + +## 🛡️ 安全性 + +- JWT Token 身份驗證 +- CORS 跨域保護 +- SQL Injection 防護 (SQLAlchemy) +- XSS 防護 (React) +- LDAP 注入防護 +- API Rate Limiting (建議生產環境啟用) + +## 📝 API 文檔 + +### 主要 API 端點 + +- `POST /api/auth/login` - 使用者登入 +- `GET /api/todos` - 取得待辦清單 +- `POST /api/todos` - 建立待辦事項 +- `PATCH /api/todos/{id}` - 更新待辦事項 +- `DELETE /api/todos/{id}` - 刪除待辦事項 +- `POST /api/todos/{id}/fire-email` - Fire 一鍵提醒 +- `GET /api/imports/template` - 下載 Excel 模板 +- `POST /api/imports` - 上傳 Excel 檔案 + +完整 API 文檔請查看:http://localhost:5000/api/docs + +## 🚀 生產部署 + +### 使用 Docker Compose + +```bash +# 建置並啟動所有服務 +docker-compose up -d + +# 檢查服務狀態 +docker-compose ps + +# 查看日誌 +docker-compose logs -f backend frontend +``` + +### 環境變數設定 + +生產環境請務必修改: +- `SECRET_KEY` - Flask 密鑰 +- `JWT_SECRET_KEY` - JWT 密鑰 +- 資料庫密碼 +- SMTP 設定 +- LDAP 設定 + +## 🔍 故障排除 + +### 常見問題 + +1. **資料庫連線失敗** + ```bash + # 檢查 MySQL 是否啟動 + docker-compose ps mysql + + # 檢查連線設定 + mysql -h localhost -u todouser -p todo_system + ``` + +2. **LDAP 連線失敗** + - 檢查 LDAP 服務器設定 + - 確認網路連線 + - 驗證搜尋基底 DN 設定 + +3. **郵件發送失敗** + - 檢查 SMTP 服務器設定 + - 確認防火牆設定 + - 驗證寄件者郵箱權限 + +4. **前端無法連接後端** + - 檢查 `NEXT_PUBLIC_API_URL` 設定 + - 確認 CORS 設定 + - 檢查後端服務是否正常啟動 + +### 日誌檢查 + +```bash +# 後端日誌 +tail -f backend/logs/app.log + +# Docker 日誌 +docker-compose logs -f backend +docker-compose logs -f frontend + +# Celery 任務日誌 +docker-compose logs -f celery-worker +``` + +## 👥 開發團隊 + +- **產品設計**: PANJIT IT Team +- **後端開發**: Flask + SQLAlchemy +- **前端開發**: Next.js + TypeScript +- **系統架構**: Docker + Nginx + +## 📄 授權 + +本專案為 PANJIT 內部使用,請勿外傳。 + +--- + +## 🔖 版本資訊 + +- **版本**: V1.0 +- **更新日期**: 2025-08-28 +- **Python**: 3.11+ +- **Node.js**: 18.0+ +- **資料庫**: MySQL 8.0 + +如有問題請聯繫 IT 部門或查看相關文檔。 \ No newline at end of file diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000..c1223f2 --- /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:3000 + +# =========================================== +# 日誌設定 +# =========================================== +LOG_LEVEL=INFO +LOG_FILE=logs/app.log \ No newline at end of file diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..08e4b3e --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,36 @@ +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 \ + && rm -rf /var/lib/apt/lists/* + +# Copy requirements +COPY requirements.txt . + +# Install Python dependencies +RUN pip install --no-cache-dir -r requirements.txt + +# Copy application code +COPY . . + +# Create necessary directories +RUN mkdir -p logs uploads + +# Set environment variables +ENV FLASK_APP=app.py +ENV PYTHONUNBUFFERED=1 + +# Expose port +EXPOSE 5000 + +# Run the application +CMD ["python", "-m", "flask", "run", "--host=0.0.0.0", "--port=5000"] \ No newline at end of file diff --git a/backend/app.py b/backend/app.py new file mode 100644 index 0000000..3742384 --- /dev/null +++ b/backend/app.py @@ -0,0 +1,160 @@ +import os +import logging +from datetime import datetime +from flask import Flask, jsonify +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__) + 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') + + # 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): + return jsonify({'error': 'Not Found', 'message': 'Resource not found'}), 404 + + @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 + +if __name__ == '__main__': + app = create_app() + app.run(host='0.0.0.0', port=5000, debug=True) \ 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/create_sample_data.py b/backend/create_sample_data.py new file mode 100644 index 0000000..de18e2f --- /dev/null +++ b/backend/create_sample_data.py @@ -0,0 +1,141 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +"""Create sample todo data for testing""" + +import os +import sys +from dotenv import load_dotenv +import pymysql +from datetime import datetime, timedelta +import uuid + +# Load environment variables +load_dotenv() + +def create_sample_todos(): + """Create sample todo items for testing""" + print("=" * 60) + print("Creating Sample Todo Data") + print("=" * 60) + + db_config = { + 'host': os.getenv('MYSQL_HOST', 'mysql.theaken.com'), + 'port': int(os.getenv('MYSQL_PORT', 33306)), + 'user': os.getenv('MYSQL_USER', 'A060'), + 'password': os.getenv('MYSQL_PASSWORD', 'WLeSCi0yhtc7'), + 'database': os.getenv('MYSQL_DATABASE', 'db_A060'), + 'charset': 'utf8mb4' + } + + try: + connection = pymysql.connect(**db_config) + cursor = connection.cursor() + + # Sample todos data + sample_todos = [ + { + 'title': '完成網站改版設計稿', + 'description': '設計新版網站的主要頁面布局,包含首頁、產品頁面和聯絡頁面', + 'status': 'DOING', + 'priority': 'HIGH', + 'due_date': (datetime.now() + timedelta(days=7)).date(), + 'creator_ad': '92367', + 'creator_display_name': 'ymirliu 陸一銘', + 'creator_email': 'ymirliu@panjit.com.tw', + 'starred': True + }, + { + 'title': '資料庫效能優化', + 'description': '優化主要查詢語句,提升系統響應速度', + 'status': 'NEW', + 'priority': 'URGENT', + 'due_date': (datetime.now() + timedelta(days=3)).date(), + 'creator_ad': '92367', + 'creator_display_name': 'ymirliu 陸一銘', + 'creator_email': 'ymirliu@panjit.com.tw', + 'starred': False + }, + { + 'title': 'API 文檔更新', + 'description': '更新所有 API 介面文檔,補充新增的端點說明', + 'status': 'DOING', + 'priority': 'MEDIUM', + 'due_date': (datetime.now() + timedelta(days=10)).date(), + 'creator_ad': 'test', + 'creator_display_name': '測試使用者', + 'creator_email': 'test@panjit.com.tw', + 'starred': False + }, + { + 'title': '使用者測試回饋整理', + 'description': '整理上週使用者測試的所有回饋意見,並分類處理', + 'status': 'BLOCKED', + 'priority': 'LOW', + 'due_date': (datetime.now() + timedelta(days=15)).date(), + 'creator_ad': 'test', + 'creator_display_name': '測試使用者', + 'creator_email': 'test@panjit.com.tw', + 'starred': True + }, + { + 'title': '系統安全性檢查', + 'description': '對系統進行全面的安全性檢查,確保沒有漏洞', + 'status': 'NEW', + 'priority': 'URGENT', + 'due_date': (datetime.now() + timedelta(days=2)).date(), + 'creator_ad': '92367', + 'creator_display_name': 'ymirliu 陸一銘', + 'creator_email': 'ymirliu@panjit.com.tw', + 'starred': False + } + ] + + created_count = 0 + + for todo in sample_todos: + todo_id = str(uuid.uuid4()) + + sql = """ + INSERT INTO todo_item + (id, title, description, status, priority, due_date, created_at, creator_ad, creator_display_name, creator_email, starred) + VALUES + (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) + """ + + values = ( + todo_id, + todo['title'], + todo['description'], + todo['status'], + todo['priority'], + todo['due_date'], + datetime.now(), + todo['creator_ad'], + todo['creator_display_name'], + todo['creator_email'], + todo['starred'] + ) + + cursor.execute(sql, values) + created_count += 1 + print(f"[OK] Created todo: {todo['title']} (ID: {todo_id[:8]}...)") + + connection.commit() + + print(f"\n[SUCCESS] Created {created_count} sample todos successfully!") + + # Show summary + cursor.execute("SELECT COUNT(*) FROM todo_item") + total_count = cursor.fetchone()[0] + print(f"[INFO] Total todos in database: {total_count}") + + cursor.close() + connection.close() + return True + + except Exception as e: + print(f"[ERROR] Failed to create sample data: {str(e)}") + return False + +if __name__ == "__main__": + create_sample_todos() \ No newline at end of file diff --git a/backend/debug_ldap.py b/backend/debug_ldap.py new file mode 100644 index 0000000..b9eb8c3 --- /dev/null +++ b/backend/debug_ldap.py @@ -0,0 +1,90 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +"""Debug LDAP search to find the correct format""" + +import os +import sys +from dotenv import load_dotenv +from ldap3 import Server, Connection, SUBTREE, ALL_ATTRIBUTES + +# Load environment variables +load_dotenv() + +def debug_ldap(): + """Debug LDAP search""" + print("=" * 60) + print("Debug LDAP Search") + print("=" * 60) + + # Get LDAP configuration + ldap_server = os.getenv('LDAP_SERVER', 'ldap://panjit.com.tw') + ldap_port = int(os.getenv('LDAP_PORT', 389)) + ldap_bind_user = os.getenv('LDAP_BIND_USER_DN', '') + ldap_bind_password = os.getenv('LDAP_BIND_USER_PASSWORD', '') + ldap_search_base = os.getenv('LDAP_SEARCH_BASE', 'DC=panjit,DC=com,DC=tw') + + print(f"LDAP Server: {ldap_server}") + print(f"LDAP Port: {ldap_port}") + print(f"Search Base: {ldap_search_base}") + print("-" * 60) + + try: + # Create server object + server = Server( + ldap_server, + port=ldap_port, + use_ssl=False, + get_info=ALL_ATTRIBUTES + ) + + # Create connection with bind user + conn = Connection( + server, + user=ldap_bind_user, + password=ldap_bind_password, + auto_bind=True, + raise_exceptions=True + ) + + print("[OK] Successfully connected to LDAP server") + + # Test different search filters + test_searches = [ + "(&(objectClass=person)(sAMAccountName=ymirliu))", + "(&(objectClass=person)(userPrincipalName=ymirliu@panjit.com.tw))", + "(&(objectClass=person)(mail=ymirliu@panjit.com.tw))", + "(&(objectClass=person)(cn=*ymirliu*))", + "(&(objectClass=person)(displayName=*ymirliu*))", + ] + + for i, search_filter in enumerate(test_searches, 1): + print(f"\n[{i}] Testing filter: {search_filter}") + + conn.search( + ldap_search_base, + search_filter, + SUBTREE, + attributes=['sAMAccountName', 'displayName', 'mail', 'userPrincipalName', 'cn'] + ) + + if conn.entries: + print(f" Found {len(conn.entries)} entries:") + for entry in conn.entries: + print(f" sAMAccountName: {entry.sAMAccountName}") + print(f" userPrincipalName: {entry.userPrincipalName}") + print(f" displayName: {entry.displayName}") + print(f" mail: {entry.mail}") + print(f" cn: {entry.cn}") + print() + else: + print(" No entries found") + + conn.unbind() + + except Exception as e: + print(f"[ERROR] LDAP connection failed: {str(e)}") + import traceback + traceback.print_exc() + +if __name__ == "__main__": + debug_ldap() \ 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/models.py b/backend/models.py new file mode 100644 index 0000000..2f44256 --- /dev/null +++ b/backend/models.py @@ -0,0 +1,240 @@ +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) + + # 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, + '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""" + if self.creator_ad == user_ad: + return True + return any(r.ad_account == user_ad for r in self.responsible_users) + + def can_view(self, user_ad): + """Check if user can view this todo""" + if self.can_edit(user_ad): + return True + return any(f.ad_account == user_ad for f in self.followers) + +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'), 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..e33555b --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,37 @@ +# 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 + +# 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..5e56bd3 --- /dev/null +++ b/backend/routes/excel.py @@ -0,0 +1,527 @@ +""" +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', '進行中': 'IN_PROGRESS', '完成': 'DONE', + 'NEW': 'NEW', 'IN_PROGRESS': 'IN_PROGRESS', 'DONE': 'DONE', + '新': 'NEW', '進行': 'IN_PROGRESS', '完': 'DONE' + } + status_str = str(row.get('狀態', row.get('status', 'NEW'))).strip() + status = status_mapping.get(status_str, 'NEW') + + # 優先級 + priority_mapping = { + '高': 'HIGH', '中': 'MEDIUM', '低': 'LOW', + 'HIGH': 'HIGH', 'MEDIUM': 'MEDIUM', 'LOW': 'LOW', + '高優先級': '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()] + + # 追蹤人 + followers_str = str(row.get('追蹤人', row.get('followers', ''))).strip() + followers = [] + if followers_str and followers_str != 'nan': + followers = [user.strip() for user in followers_str.replace(',', ';').split(';') if user.strip()] + + 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': followers + }) + + 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', []) + followers = todo_data.get('followers', []) + + 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 + + if followers: + valid_followers = validate_ad_accounts(followers) + invalid_followers = set(followers) - set(valid_followers.keys()) + if invalid_followers: + errors.append({ + 'row': todo_data.get('row', '?'), + 'error': f'無效的追蹤人帳號: {", ".join(invalid_followers)}' + }) + continue + + # 建立待辦事項 + due_date = None + if todo_data.get('due_date'): + due_date = datetime.strptime(todo_data['due_date'], '%Y-%m-%d').date() + + 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 + ) + db.session.add(todo) + + # 新增負責人 + if responsible_users: + for account in responsible_users: + responsible = TodoItemResponsible( + todo_id=todo.id, + ad_account=account, + added_by=identity + ) + db.session.add(responsible) + + # 新增追蹤人 + if followers: + for account in followers: + follower = TodoItemFollower( + todo_id=todo.id, + ad_account=account, + added_by=identity + ) + db.session.add(follower) + + # 新增稽核記錄 + 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': '新建', 'IN_PROGRESS': '進行中', 'DONE': '完成'} + priority_mapping = {'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'], + '描述': ['這是第一個範例的詳細描述', '這是第二個範例的詳細描述'], + '狀態': ['新建', '進行中'], + '優先級': ['高', '中'], + '到期日': ['2024-12-31', '2025-01-15'], + '負責人': ['user1@panjit.com.tw', 'user2@panjit.com.tw'], + '追蹤人': ['user3@panjit.com.tw;user4@panjit.com.tw', 'user5@panjit.com.tw'] + } + + # 說明資料 + instructions = { + '欄位說明': [ + '標題 (必填)', + '描述 (選填)', + '狀態: 新建/進行中/完成', + '優先級: 高/中/低', + '到期日: YYYY-MM-DD 格式', + '負責人: AD帳號,多人用分號分隔', + '追蹤人: AD帳號,多人用分號分隔' + ], + '說明': [ + '請填入待辦事項的標題', + '可選填詳細描述', + '可選填 NEW/IN_PROGRESS/DONE', + '可選填 HIGH/MEDIUM/LOW', + '例如: 2024-12-31', + '例如: john@panjit.com.tw', + '例如: mary@panjit.com.tw;tom@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..48a67cf --- /dev/null +++ b/backend/routes/health.py @@ -0,0 +1,125 @@ +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('/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..b3cd53b --- /dev/null +++ b/backend/routes/todos.py @@ -0,0 +1,709 @@ +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 + + # 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 + ) + 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) + ) + ) + + # 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) + ) + 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'] + + # 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 \ 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/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..d0ce5f1 --- /dev/null +++ b/backend/utils/ldap_utils.py @@ -0,0 +1,230 @@ +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 + search_filter = f"(&(objectClass=person)(sAMAccountName={ad_account}))" + + 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 '' + } + + 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: + search_filter = f"(&(objectClass=person)(sAMAccountName={account}))" + + conn.search( + config['LDAP_SEARCH_BASE'], + search_filter, + SUBTREE, + attributes=['sAMAccountName', 'displayName', 'mail'] + ) + + 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 '' + } + + 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..9cd2993 --- /dev/null +++ b/backend/utils/logger.py @@ -0,0 +1,58 @@ +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 + app.logger.info(f"Application started in {app.config.get('ENV', 'development')} 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/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..027736e --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,103 @@ +version: '3.8' + +services: + mysql: + image: mysql:8.0 + container_name: todo_mysql + restart: unless-stopped + environment: + MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD:-rootpassword} + MYSQL_DATABASE: ${MYSQL_DATABASE:-todo_system} + MYSQL_USER: ${MYSQL_USER:-todouser} + MYSQL_PASSWORD: ${MYSQL_PASSWORD:-todopass} + TZ: Asia/Taipei + ports: + - "${MYSQL_PORT:-3306}:3306" + volumes: + - ./mysql/data:/var/lib/mysql + - ./mysql/init:/docker-entrypoint-initdb.d + command: --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci + networks: + - todo_network + + redis: + image: redis:7-alpine + container_name: todo_redis + restart: unless-stopped + ports: + - "${REDIS_PORT:-6379}:6379" + volumes: + - ./redis/data:/data + networks: + - todo_network + + backend: + build: + context: ./backend + dockerfile: Dockerfile + container_name: todo_backend + restart: unless-stopped + depends_on: + - mysql + - redis + environment: + - FLASK_ENV=${FLASK_ENV:-development} + - MYSQL_HOST=mysql + - MYSQL_PORT=3306 + - MYSQL_DATABASE=${MYSQL_DATABASE:-todo_system} + - MYSQL_USER=${MYSQL_USER:-todouser} + - MYSQL_PASSWORD=${MYSQL_PASSWORD:-todopass} + - REDIS_URL=redis://redis:6379/0 + ports: + - "${BACKEND_PORT:-5000}:5000" + volumes: + - ./backend:/app + - ./uploads:/app/uploads + - ./logs:/app/logs + networks: + - todo_network + + frontend: + build: + context: ./frontend + dockerfile: Dockerfile + container_name: todo_frontend + restart: unless-stopped + depends_on: + - backend + environment: + - NODE_ENV=${NODE_ENV:-development} + - NEXT_PUBLIC_API_URL=${API_URL:-http://localhost:5000} + ports: + - "${FRONTEND_PORT:-3000}:3000" + volumes: + - ./frontend:/app + - /app/node_modules + - /app/.next + networks: + - todo_network + + nginx: + image: nginx:alpine + container_name: todo_nginx + restart: unless-stopped + depends_on: + - backend + - frontend + ports: + - "${NGINX_PORT:-80}:80" + - "${NGINX_SSL_PORT:-443}:443" + volumes: + - ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro + - ./nginx/conf.d:/etc/nginx/conf.d:ro + - ./nginx/ssl:/etc/nginx/ssl:ro + networks: + - todo_network + +networks: + todo_network: + driver: bridge + +volumes: + mysql_data: + redis_data: \ No newline at end of file diff --git a/frontend/.env.example b/frontend/.env.example new file mode 100644 index 0000000..0a88aa5 --- /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:5000 +NEXT_PUBLIC_BACKEND_URL=http://localhost:5000 + +# 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:3000,http://localhost:5000 + +# 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..0a88aa5 --- /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:5000 +NEXT_PUBLIC_BACKEND_URL=http://localhost:5000 + +# 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:3000,http://localhost:5000 + +# 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/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..d964c24 --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,43 @@ +FROM node:18-alpine AS base + +# Install dependencies only when needed +FROM base AS deps +RUN apk add --no-cache libc6-compat +WORKDIR /app + +# Install dependencies based on the preferred package manager +COPY package.json package-lock.json* ./ +RUN npm ci --only=production + +# Rebuild the source code only when needed +FROM base AS builder +WORKDIR /app +COPY --from=deps /app/node_modules ./node_modules +COPY . . + +# Build the application +RUN npm run build + +# Production image, copy all the files and run next +FROM base AS runner +WORKDIR /app + +ENV NODE_ENV production + +RUN addgroup --system --gid 1001 nodejs +RUN adduser --system --uid 1001 nextjs + +COPY --from=builder /app/public ./public + +# Automatically leverage output traces to reduce image size +COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ +COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static + +USER nextjs + +EXPOSE 3000 + +ENV PORT 3000 +ENV HOSTNAME "0.0.0.0" + +CMD ["node", "server.js"] \ 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..194e9c0 --- /dev/null +++ b/frontend/next.config.js @@ -0,0 +1,22 @@ +/** @type {import('next').NextConfig} */ +const nextConfig = { + reactStrictMode: true, + swcMinify: true, + output: 'standalone', + env: { + NEXT_PUBLIC_API_URL: process.env.NEXT_PUBLIC_API_URL || 'http://localhost:5000', + }, + async rewrites() { + return [ + { + source: '/api/:path*', + destination: `${process.env.NEXT_PUBLIC_API_URL || 'http://localhost:5000'}/api/:path*`, + }, + ] + }, + images: { + domains: ['localhost'], + }, +} + +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..e47ca22 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,46 @@ +{ + "name": "todo-system-frontend", + "version": "1.0.0", + "private": true, + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start", + "lint": "next lint", + "type-check": "tsc --noEmit" + }, + "dependencies": { + "@emotion/react": "^11.11.3", + "@emotion/styled": "^11.11.0", + "@mui/icons-material": "^5.15.3", + "@mui/material": "^5.15.3", + "@mui/x-date-pickers": "^6.19.0", + "@reduxjs/toolkit": "^2.0.1", + "@tanstack/react-query": "^5.17.9", + "axios": "^1.6.5", + "dayjs": "^1.11.10", + "framer-motion": "^10.18.0", + "next": "14.0.4", + "next-themes": "^0.2.1", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "react-hook-form": "^7.48.2", + "react-hot-toast": "^2.4.1", + "react-redux": "^9.0.4", + "recharts": "^2.10.3", + "xlsx": "^0.18.5" + }, + "devDependencies": { + "@types/node": "^20.10.6", + "@types/react": "^18.2.46", + "@types/react-dom": "^18.2.18", + "@typescript-eslint/eslint-plugin": "^6.17.0", + "@typescript-eslint/parser": "^6.17.0", + "autoprefixer": "^10.4.16", + "eslint": "^8.56.0", + "eslint-config-next": "14.0.4", + "postcss": "^8.4.33", + "tailwindcss": "^3.4.0", + "typescript": "^5.3.3" + } +} \ No newline at end of file diff --git a/frontend/public/panjit-logo.png b/frontend/public/panjit-logo.png new file mode 100644 index 0000000000000000000000000000000000000000..b12aa06778a9c3939cb80f9ad17b0b43ca7dd501 GIT binary patch literal 7006 zcmeHMc{JPYmq!<>wY+U|&3l_Ps?Yu~n6BXkWFjDT*MrkXlpq z5~W0KQA@OlB`HB9mXJ)|-#Ig9&iT!pGxP7v{Bh57&U5bfKKFa?=ehTDzu%V@W`=yc zBD@?N9DK$`238y#obbPe=OlZlzw-1K_HycxkwX{T9 z_ge3}Cfo%7Uiy;3iJAC_0Lf9q02sZ7D}e{o+s?0ZUi!Gs#s8Q8^)bj1 zctU=9t1xP@&foP*p-SdHrzYKBL{ku$f64=N7vz)H*H77-1sCc1W zg&;i!gAKvpxtU@!$@}WQR`@EQ4@`TV&J2$ZD3hhBRguTD@)aePEy3Vydd1i-my4cq zMw?kY=z^B^$~IdIC2b0_1hMcK^g(}hUAQ(eSDoPSfEstmJq!jNHc%vvI&j)s-*s^_ zYM}7VA0)XrXhD2dMZ;Ome693x_pW>{DQHPX+XhqFD^(U68>GW=RsF*>-~)B-b$42) ztQ%*~V+ywpZy9!f=FnI)dbsnv0q45-Os_FSa^okN%Ue(nS8jH+T!xf1BOQD(E_n1@ zp|auigheP=uzO7^$t5c}Db4KK3)|X`h-MK)s9?}HRb@9ZJ>Qnjm`-1ml%u}T_|$}d zI z^G?Bu!|9KL9?oHBX#Uq)BdyNePsor-YBSF4YlVvWCb1nMPmB;wnO)>G>%W*kJx8b) zP=-3LY}cUSc_tp>6Mgn~y6D>tk*$}f0~9_4*@K_X?S4gLRHg%dH!bLI($GlA51TN`7DjBo$gMzKcR{=Cmp!OYsLZ=G$wxyp#Zqg z$|rro?xBPR8TB)9OFn&!ib?e(CNchC!1n?9&D!`GYS7F6V-(uZg{MCes-{VuqXYILP1-A-r5Y68Lo*sCI-+Ca1+{zc6rpCUQKR`Q~l`N zTO(p%3r@HZnI|WErpn(oS9TVy7u5(Vl;E!kY4pxt%Kv6=PpWQ(rg zZChLgU*7D^dR|TbKgA5R9QeREP|5?vh^gX7%O|brkz(LdKQj5g$5OV9H4A`jt~p!ac7?sSaCaPJNBv_H=ud!{l+as zNuM7g8L{h1X%y zu@f|P)8n^Xc$gt*38{N4Ldd3Qg_*Dt=w(@U z2#wzj!Y3nWH64X6tnR%06E!+KH+I91RhRTteF~{WC1;}RoWPuFDT0n0%}QENiadqJ z`>WM=3N>0LCM=qZGSN3E$3*pbZqTD8=%j_Rzn{yK$J_S|%tTJsTPxLHmR6Z3L-a3y z;PqcZ{2T}CPej`iv{4AIzs~9@!=~jD8Y|ft*iX^+S*-cA-9n6&$`OpV%nOQuo9h^` zFXNaqADHa4IX+Y)sKe8#{2WI-TH6^-$k!;XE`hIKWXJ5v3pcI8WkHi}YdbIvq7f4v zLIc+GiV(Fk$Jp3mqlEss!im7m^`_zB7a|`^N-`ohbk1_#6uHT7X`HwfVs0P7hqr%HLQh}eRgemb*U^Kc>k3#uqpfQ$Y^^NF)q3NqpI;Hm z0QXs{EyvaY-62r$=9U6{?4glR_}K@2VoRCl4}NE&tl2S&77U_khQ(6_j*3XTv1D#7 z5YsTuyMrQ$Im(CLy1uOJnP6IzC=vz7J!3oUCI3dbrvB&JqDqIl$>9B+6&!1Wv@OYi z6q;{RFgq@^bC@)arh-Wz7xO5`Xad@VIOJv@kg0#H& zbWu?9xAK9H>7cPvf6^j1hN2Y>WP;*;;qUu#=bQ4-&}-~FWTc9^v&)zHO3Klzu(j3! z45y4tQ;u3fG`rGJX7ThH?ePGkn^Bt&k{8Wnq{n;IY;;qfPK@+IYS?Hy{@wNlC5$!4 z`a#}`r*OLhyONufSbKGc-7ba)A+AT!v=Gceb8}(wx^&XPk1T~sxQGH99dz5;JG&tg zN9iuZSw(IjiQKn!End0`4%HM&iHnnmuHiCK( zysV#Ltj>e!kVEgfm6)XoyR>7TnXj$^SswGO_0HnmtTb)>Z_<>P3ZP=A5^Nj17=@9c9 zzKg(!`h8M*yj#sy_XYxA{IPksJ&x)S~8!> zNN0U+B~jyd&FMuiM)p!z2M_)T5fJ?{gqRET5*jxW4{p1p8vmL?nnc#Yv*xpRScI1$ zLkCBA79$@!F(MHEyPaEH3#_`fa@v*YNNXx)HDB0R+!voebgHJnXim|t48I5GuJwJg z%vy(^b$rN;C`tDBO%}2v5T5uM7pYJKh2(viq+;gg;~Z7cT6~>|PL&C4ab0*H;*SiIVs5l?~Pzrfy!jz8KTsI7Ko*Y6=%sBHT^=)GI~>ePj4lSh-tC= zoQ?kLIeYiQ*3>Lb=BkuQhL|=T$E5aQ>ZlOIar!;)zq%z_+ZL2)RMu*MMyVN#L%>{}7u<;Q8>{_kk5eanAN(vE=3ns!|PLBe0 zst)$)k_>_1`(AJ_MOgrWD7r7a%E&_p+l|`rnUWTXTn9BzTUHwkfoWqa4e(LTFpU^j zC%CvMMNuDWRCynBrz~6?Cc&EN;+ROsY*QPb!E~PA(pI( z_cKK2_-{=L(weDYHMUQJg#;clrOBVE$*M^FQvjgmPT`UWRmK)6ZrELJ16p zBB38k5eWX>iZN`Hpw~lQRw#DblJ$kYbn<6Xy+x9*z=3gn#aCbVuHKrCm+!;PZMsZbt*;yOC@svytSo z`mee>^$0}MJ|4df{op`w610fz03Tl(ee`w0%6bT>pci(7?MBV~kA)Y?kdctz<`tA? zWIjqx;EnWiLPpH2l71*L9%LU0v(e)Scwe1A`sG&xpeHccBJ~PiHw2gBqILzJWPwQZ z9$s*IZo=c1>U|~`+L{(NIzS2tzB(@QO4Ms1Ns!AP!`=JG*!kb#M=Oq9KG$ZM;nPqf z~sWY9I(vI0EkfR)Tu2*GX9KjRHrljcgJ2>I5@ZD=5&yc=m`1h0r1?l5A zk*K(7W_X_FP6!(#i|qL%7;?wo+4c5p7T02EN^uI%W#q#t-XT_8_2MQ$if!FYM}pL5 zXDh^R!tW}4)O%dih5rTbf*(SZhZ=2|+_{2h$D)=O!H(l&cKLobd45C1di{&BYNhHG zJD~~OOSX^fl+y-gWK{-t00m8&boH`dZN`v!`8eq4*e3y}rX_wS{l~GBAIPRxkJr1@ zoxk%lYvTlpbC*98{~Wm_2z&?sWWvK)1JStU7SeZHJIKodDE_Mrr`I|v??cJNmn8{) zR{JWwjq=^@bkKdTPy!GQcGOl|EzepB+6ZXVqFXo<^8`O%8+c@_@3boiJt~8q^Mh54 zc`;5v3dU(oN~v?jX1JXgOZlXYZ+mmAeFw|F*@q-zlPkv^@G|=%*Etdb5tEM$sHuyyUL-mwST)p(9NEJicjpuAr?3 zNQ|e1KO^V*;55OJ9Q6_Gpz_Eh{m;Zhk$Xxns$%#w7~J`%Q!)Kn)LqYjRbz;2KUUN3 zQ1ixG{P||gP|F}?t$cx6wJppmAVU~Ky$%S*Jzv15hruM2o{&}pi5yOzR_rT-Ec97H zX$C3AzNI%h&=Hzq)dQl1bB^PdM4gWL0~*omX%4!jOM|kN(Waf(u<#{ZCe-ta#jb-6#bOJlC^El#zkVtTS=uFlD- z#C{Nz30aIG$WyPjhFwdiRYGfasNPK5nNMR)&p=vLWYK^apW>w(^)nzZQETqKD$b{^ zjfaF;`828uCS{1R9~~U|V=|QS+o5ht+ed!{l!7*=e1`c0>UD zfgAqJc1S<(Ck0zmU%lcAED_-{51XcvH@g z<412!u_?0OaKoT`eF{}Vy%b3F$xF7W8}c=$s)xzg{Rw8~YdET?pex1KC^9JlO_}@6 z9i98m3er2F9j?moQm{CxI=fK@1C&chBbt#NE#23fi$H_AIBebZ$ee7)I@p@} zrfQqz@t91QSAwASf#&9}J1TtGZf`}cQ8@_nsq#e=d95rMB<1Vq z*7g+Bg*p^rsl~1_Fm^Yxa4rD_cBWY_MPxM{xeV%?m44-PGTX3^Hoc08EJZ1DQ`8ZS zS9|=u@(KClw9bc>^wE)of?Lwwzv?SZc+{LUp7~FtRo56DNL2qeYCCRX1Gb82lY2b8 zO^vd0i9c=CwcLgOYXDn!JFM>Tx8w~*W9iw%p~l_BS^ z%i4&w=smh_*xPBiV*WFRd>AtcKMb@z1;01;%{7-=zK{t}ap0F!E|ji1%hh%bGZb-@ z6;ll@bl)-N?b!VBrulZyKER{yh+1_yVRVJLrPPCEf79xHW6Y;6Jg3txn{Sc2$xM1- zhJT+Vcry=hgl>KLm~#B1S@BBxs-Q?G^=h;B*-FP&+2i4;fwE-LUcPK>z zzuq7$awDCg=XU1D@@2(3kLF{a09br&3|0 z2$wNAo>;sc%xnz%G+Nqo9k=h-9}(G%&1L)KI8|;IhW-n7@2$9rR6bUsFID(QvFZ! z*R+doeYDJ8*I2dZ%Pih1Q5Jc=-KxhEq4UF`$XwAHN3wq<^hUHPYhNBy<(eB=LyE%2 zjXRzR+f?7ZWZkkW4Cu(%$)g?_Qd)%nD71U`UVLYEw=ueF&80PZzXR`rY>();*Ha^9 zvhuaMVApZkCxL#s(U3}4Qkb@})g{mJdaOMvCwON`v5NRD zSq!b~EAW^LXn_r5+eSz^c@uEggXG!iV7Fhxr4xtZ*4gVd2cCUEoptDpa7)wOU>K-; zxih9fF(a&1Mk&28t2lQCK0Jk~7#PcvT=q~aHjnzI&L&pRC)~)Lu_IoJDWv@iFCv{V zaf;{__l80kBgES~|6s#pW?G)!Eg!`0MVJ-hN)sX}q{?PNMUW{oC!%u~7ZBn%&!cL$!^UBf5si zCAwgrK0>^?zv_o8JF^^#A>NQAK@N8|z5I9T{%;ii|JVN;F=0W(G(-x5R%1VSlDI literal 0 HcmV?d00001 diff --git a/frontend/src/app/calendar/page.tsx b/frontend/src/app/calendar/page.tsx new file mode 100644 index 0000000..51df390 --- /dev/null +++ b/frontend/src/app/calendar/page.tsx @@ -0,0 +1,182 @@ +'use client'; + +import React, { useState, useEffect } from 'react'; +import { + Box, + Typography, + Paper, + Skeleton, + Alert, +} from '@mui/material'; +import { motion } from 'framer-motion'; +import { useTheme } from '@/providers/ThemeProvider'; +import DashboardLayout from '@/components/layout/DashboardLayout'; +import CalendarView from '@/components/todos/CalendarView'; +import { Todo } from '@/types'; +import { todosApi } from '@/lib/api'; + +const CalendarPage: React.FC = () => { + const { actualTheme } = useTheme(); + const [todos, setTodos] = useState([]); + const [selectedTodos, setSelectedTodos] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + const fetchTodos = async () => { + try { + setLoading(true); + setError(null); + + const token = localStorage.getItem('access_token'); + if (!token) { + setTodos([]); + setLoading(false); + return; + } + + const response = await todosApi.getTodos({ view: 'all' }); + setTodos(response.todos || []); + } catch (error) { + console.error('Failed to fetch todos:', error); + setError('無法載入待辦事項,請重新整理頁面'); + setTodos([]); + } finally { + setLoading(false); + } + }; + + fetchTodos(); + }, []); + + const handleSelectionChange = (selected: string[]) => { + setSelectedTodos(selected); + }; + + const handleEditTodo = (todo: Todo) => { + // TODO: 實作編輯功能,可以開啟編輯對話框或導航到編輯頁面 + console.log('Edit todo:', todo); + }; + + if (loading) { + return ( + + + + + + 日曆視圖 + + + 以日曆方式檢視您的待辦事項 + + + + {/* Loading Skeleton */} + + + + + + {Array.from({ length: 35 }).map((_, index) => ( + + ))} + + + + + ); + } + + return ( + + + + + + 日曆視圖 + + + 以日曆方式檢視您的待辦事項,支援月、週、日三種檢視模式 + + + + {error && ( + setError(null)} + > + {error} + + )} + + {selectedTodos.length > 0 && ( + setSelectedTodos([])} + > + 已選擇 {selectedTodos.length} 個待辦事項 + + )} + + + + + + ); +}; + +export default CalendarPage; \ No newline at end of file diff --git a/frontend/src/app/dashboard/page.tsx b/frontend/src/app/dashboard/page.tsx new file mode 100644 index 0000000..f1494c6 --- /dev/null +++ b/frontend/src/app/dashboard/page.tsx @@ -0,0 +1,544 @@ +'use client'; + +import React, { useState, useEffect } from 'react'; +import { + Box, + Grid, + Card, + CardContent, + Typography, + Chip, + Button, + Avatar, + AvatarGroup, + IconButton, +} 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'); + if (!token) { + console.log('No access token found, skipping API call'); + setTodos([]); + return; + } + + const response = await todosApi.getTodos({ view: 'all' }); + setTodos(response.todos || []); + } catch (error) { + 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: 'all' }); + 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 ( + + + + + 儀表板 + + + 歡迎回來!這裡是您的待辦事項概覽 + + + + {/* 統計卡片 */} + + + + + + + + + 總待辦 + + + {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..e353a74 --- /dev/null +++ b/frontend/src/app/layout.tsx @@ -0,0 +1,40 @@ +import type { Metadata, Viewport } from 'next'; +import { Inter } from 'next/font/google'; +import { Providers } from '@/providers'; +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' }], + themeColor: [ + { media: '(prefers-color-scheme: light)', color: '#ffffff' }, + { media: '(prefers-color-scheme: dark)', color: '#111827' }, + ], +}; + +export const viewport: Viewport = { + width: 'device-width', + initialScale: 1, + maximumScale: 1, + userScalable: false, +}; + +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/settings/page.tsx b/frontend/src/app/settings/page.tsx new file mode 100644 index 0000000..ca115b6 --- /dev/null +++ b/frontend/src/app/settings/page.tsx @@ -0,0 +1,764 @@ +'use client'; + +import React, { useState } from 'react'; +import { + Box, + Typography, + Card, + CardContent, + Switch, + FormControlLabel, + Button, + Divider, + Avatar, + TextField, + IconButton, + Chip, + Grid, + Paper, + Alert, + Snackbar, + FormControl, + InputLabel, + Select, + MenuItem, + Slider, +} from '@mui/material'; +import { + Person, + Palette, + Notifications, + Security, + Language, + Save, + Edit, + PhotoCamera, + DarkMode, + LightMode, + SettingsBrightness, + VolumeUp, + Email, + Sms, + Phone, + Schedule, + Visibility, + Lock, + Key, + Shield, + Refresh, +} from '@mui/icons-material'; +import { motion, AnimatePresence } from 'framer-motion'; +import { useTheme } from '@/providers/ThemeProvider'; +import { useSearchParams } from 'next/navigation'; +import DashboardLayout from '@/components/layout/DashboardLayout'; + +const SettingsPage = () => { + const { themeMode, setThemeMode, actualTheme } = useTheme(); + const searchParams = useSearchParams(); + const [showSuccess, setShowSuccess] = useState(false); + const [activeTab, setActiveTab] = useState(() => { + const tabParam = searchParams.get('tab'); + return tabParam || 'profile'; + }); + + // 用戶設定 + const [userSettings, setUserSettings] = useState(() => { + try { + const userStr = localStorage.getItem('user'); + if (userStr) { + const user = JSON.parse(userStr); + return { + name: user.display_name || user.ad_account || '', + email: user.email || '', + department: '資訊部', + position: '員工', + phone: '', + bio: '', + avatar: (user.display_name || user.ad_account || 'U').charAt(0).toUpperCase(), + }; + } + } catch (error) { + console.error('Failed to parse user from localStorage:', error); + } + + return { + name: '', + email: '', + department: '資訊部', + position: '員工', + phone: '', + bio: '', + avatar: 'U', + }; + }); + + // 通知設定 + const [notificationSettings, setNotificationSettings] = useState({ + emailNotifications: true, + pushNotifications: true, + smsNotifications: false, + todoReminders: true, + deadlineAlerts: true, + weeklyReports: true, + soundEnabled: true, + soundVolume: 70, + }); + + // 隱私設定 + const [privacySettings, setPrivacySettings] = useState({ + profileVisibility: 'team', + todoVisibility: 'responsible', + showOnlineStatus: true, + allowDirectMessages: true, + dataSharing: false, + }); + + // 工作設定 + const [workSettings, setWorkSettings] = useState({ + timeZone: 'Asia/Taipei', + dateFormat: 'YYYY-MM-DD', + timeFormat: '24h', + workingHours: { + start: '09:00', + end: '18:00', + }, + autoRefresh: 30, + defaultView: 'list', + }); + + 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 handleSave = () => { + console.log('Settings saved:', { + user: userSettings, + notifications: notificationSettings, + privacy: privacySettings, + work: workSettings, + }); + setShowSuccess(true); + }; + + const themeOptions = [ + { value: 'light', label: '亮色模式', icon: }, + { value: 'dark', label: '深色模式', icon: }, + { value: 'system', label: '跟隨系統', icon: }, + ]; + + const tabs = [ + { id: 'profile', label: '個人資料', icon: }, + { id: 'appearance', label: '外觀主題', icon: }, + { id: 'notifications', label: '通知設定', icon: }, + { id: 'privacy', label: '隱私安全', icon: }, + { id: 'work', label: '工作偏好', icon: }, + ]; + + const renderProfileSettings = () => ( + + + + + + + 個人資料設定 + + + + {/* 頭像區域 */} + + + + {userSettings.avatar} + + + + + + + + {userSettings.name} + + + {userSettings.position} · {userSettings.department} + + } + /> + + + + + + setUserSettings(prev => ({ ...prev, name: e.target.value }))} + sx={{ + '& .MuiOutlinedInput-root': { + borderRadius: 2, + backgroundColor: actualTheme === 'dark' + ? 'rgba(255, 255, 255, 0.05)' + : 'rgba(0, 0, 0, 0.02)', + } + }} + /> + + + setUserSettings(prev => ({ ...prev, email: e.target.value }))} + sx={{ + '& .MuiOutlinedInput-root': { + borderRadius: 2, + backgroundColor: actualTheme === 'dark' + ? 'rgba(255, 255, 255, 0.05)' + : 'rgba(0, 0, 0, 0.02)', + } + }} + /> + + + setUserSettings(prev => ({ ...prev, department: e.target.value }))} + sx={{ + '& .MuiOutlinedInput-root': { + borderRadius: 2, + backgroundColor: actualTheme === 'dark' + ? 'rgba(255, 255, 255, 0.05)' + : 'rgba(0, 0, 0, 0.02)', + } + }} + /> + + + setUserSettings(prev => ({ ...prev, position: e.target.value }))} + sx={{ + '& .MuiOutlinedInput-root': { + borderRadius: 2, + backgroundColor: actualTheme === 'dark' + ? 'rgba(255, 255, 255, 0.05)' + : 'rgba(0, 0, 0, 0.02)', + } + }} + /> + + + setUserSettings(prev => ({ ...prev, phone: e.target.value }))} + sx={{ + '& .MuiOutlinedInput-root': { + borderRadius: 2, + backgroundColor: actualTheme === 'dark' + ? 'rgba(255, 255, 255, 0.05)' + : 'rgba(0, 0, 0, 0.02)', + } + }} + /> + + + setUserSettings(prev => ({ ...prev, bio: e.target.value }))} + sx={{ + '& .MuiOutlinedInput-root': { + borderRadius: 2, + backgroundColor: actualTheme === 'dark' + ? 'rgba(255, 255, 255, 0.05)' + : 'rgba(0, 0, 0, 0.02)', + } + }} + /> + + + + + + ); + + const renderAppearanceSettings = () => ( + + + + + + + 外觀主題設定 + + + + + 選擇您喜歡的介面主題,讓工作更舒適 + + + + {themeOptions.map((option) => ( + + + setThemeMode(option.value as 'light' | 'dark' | 'auto')} + sx={{ + p: 3, + cursor: 'pointer', + textAlign: 'center', + backgroundColor: themeMode === option.value + ? (actualTheme === 'dark' ? 'rgba(59, 130, 246, 0.2)' : 'rgba(59, 130, 246, 0.1)') + : (actualTheme === 'dark' ? '#374151' : '#f9fafb'), + border: themeMode === option.value + ? `2px solid ${actualTheme === 'dark' ? '#60a5fa' : '#3b82f6'}` + : `1px solid ${actualTheme === 'dark' ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)'}`, + borderRadius: 3, + transition: 'all 0.3s ease', + '&:hover': { + backgroundColor: actualTheme === 'dark' + ? 'rgba(59, 130, 246, 0.1)' + : 'rgba(59, 130, 246, 0.05)', + transform: 'translateY(-2px)', + }, + }} + > + + {option.icon} + + + {option.label} + + {themeMode === option.value && ( + + )} + + + + ))} + + + {/* 預覽區域 */} + + + 主題預覽 + + + + 待辦事項預覽 + + + + + + 2024-01-15 到期 + + + + 這是在 {themeMode === 'light' ? '亮色' : themeMode === 'dark' ? '深色' : '系統'} 模式下的預覽效果 + + + + + + + ); + + const renderNotificationSettings = () => ( + + + + + + + 通知設定 + + + + + + + 通知方式 + + + setNotificationSettings(prev => ({ ...prev, emailNotifications: e.target.checked }))} + color="primary" + /> + } + label={ + + + 電子信箱通知 + + } + /> + setNotificationSettings(prev => ({ ...prev, pushNotifications: e.target.checked }))} + color="primary" + /> + } + label={ + + + 推送通知 + + } + /> + setNotificationSettings(prev => ({ ...prev, smsNotifications: 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 ( + + + {/* 標題區域 */} + + + + 設定 + + + 管理您的個人資料和應用程式偏好設定 + + + + + + {/* 側邊欄 */} + + + + + {tabs.map((tab) => ( + + + + ))} + + + + + + {/* 主要內容 */} + + + + {activeTab === 'profile' && renderProfileSettings()} + {activeTab === 'appearance' && renderAppearanceSettings()} + {activeTab === 'notifications' && renderNotificationSettings()} + {/* 其他 tab 內容可以在這裡添加 */} + + + + {/* 儲存按鈕 */} + + + + + + + + + + {/* 成功通知 */} + 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..e681563 --- /dev/null +++ b/frontend/src/app/todos/page.tsx @@ -0,0 +1,611 @@ +'use client'; + +import React, { useState, useEffect } from 'react'; +import { + Box, + Typography, + Button, + IconButton, + Toolbar, + Tooltip, + Fade, + Chip, + Card, +} from '@mui/material'; +import { + Add, + ViewList, + CalendarViewMonth, + FilterList, + Sort, + Search, + SelectAll, + MoreVert, +} 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 { Todo } from '@/types'; +import { todosApi } from '@/lib/api'; + +type ViewMode = 'list' | 'calendar'; +type FilterMode = 'all' | 'created' | 'responsible' | 'following'; + +const TodosPage = () => { + const { actualTheme } = useTheme(); + const [viewMode, setViewMode] = useState('list'); + const [filterMode, setFilterMode] = useState('all'); + const [showFilters, setShowFilters] = useState(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 [todos, setTodos] = useState([]); + const [loading, setLoading] = useState(true); + const [currentUser, setCurrentUser] = useState(null); + + // 從 API 獲取資料 + useEffect(() => { + const fetchTodos = async () => { + try { + setLoading(true); + + // 檢查是否有有效的 token + const token = localStorage.getItem('access_token'); + if (!token) { + console.log('No access token found, skipping API call'); + setTodos([]); + return; + } + + // 獲取當前用戶信息 + try { + const userResponse = await fetch('http://localhost:5000/api/auth/me', { + headers: { + 'Authorization': `Bearer ${token}`, + }, + }); + if (userResponse.ok) { + const userData = await userResponse.json(); + setCurrentUser(userData); + } + } catch (userError) { + console.warn('Failed to fetch user data:', userError); + } + + // 獲取待辦事項 + const response = await todosApi.getTodos({ + view: filterMode === 'all' ? 'all' : filterMode + }); + setTodos(response.todos || []); + } catch (error) { + 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': + return todo.creator_ad === currentUser.ad_account; + case 'responsible': + return todo.responsible_users?.includes(currentUser.ad_account) || false; + case 'following': + return todo.followers?.includes(currentUser.ad_account) || false; + default: + return true; + } + } + + return true; + }); + + 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', 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 { + // 使用 API 更新單個待辦事項的狀態 + await todosApi.updateTodo(todoId, { status }); + + // 更新本地狀態 + setTodos(prevTodos => + prevTodos.map(todo => + todo.id === todoId + ? { + ...todo, + status, + completed_at: status === 'DONE' ? new Date().toISOString() : null + } + : 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)} /> + + )} + + + {/* 批次操作工具列 */} + + {selectedTodos.length > 0 && ( + + setSelectedTodos([])} + onBulkStatusChange={handleBulkStatusChange} + onBulkComplete={handleBulkComplete} + onBulkDelete={handleBulkDelete} + /> + + )} + + + {/* 主要內容區域 */} + + + + {viewMode === 'list' ? ( + + ) : ( + + )} + + + + + {/* 新增/編輯待辦對話框 */} + + + + ); +}; + +export default TodosPage; \ 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..dad7395 --- /dev/null +++ b/frontend/src/lib/api.ts @@ -0,0 +1,316 @@ +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 || 'http://localhost:5000'; + +// 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, + }); + }, +}; + +// 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; + }, + + 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..087dd8a --- /dev/null +++ b/frontend/src/types/index.ts @@ -0,0 +1,181 @@ +// 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; + responsible_users: string[]; + followers: string[]; + responsible_users_details?: UserDetail[]; + followers_details?: UserDetail[]; +} + +export interface TodoCreate { + title: string; + description?: string; + status?: 'NEW' | 'DOING' | 'BLOCKED' | 'DONE'; + priority?: 'LOW' | 'MEDIUM' | 'HIGH' | 'URGENT'; + due_date?: string; + starred?: boolean; + responsible_users?: string[]; + followers?: 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'; +} + +// 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/logo/PANJIT06(100x100pixal ).png b/logo/PANJIT06(100x100pixal ).png new file mode 100644 index 0000000000000000000000000000000000000000..b12aa06778a9c3939cb80f9ad17b0b43ca7dd501 GIT binary patch literal 7006 zcmeHMc{JPYmq!<>wY+U|&3l_Ps?Yu~n6BXkWFjDT*MrkXlpq z5~W0KQA@OlB`HB9mXJ)|-#Ig9&iT!pGxP7v{Bh57&U5bfKKFa?=ehTDzu%V@W`=yc zBD@?N9DK$`238y#obbPe=OlZlzw-1K_HycxkwX{T9 z_ge3}Cfo%7Uiy;3iJAC_0Lf9q02sZ7D}e{o+s?0ZUi!Gs#s8Q8^)bj1 zctU=9t1xP@&foP*p-SdHrzYKBL{ku$f64=N7vz)H*H77-1sCc1W zg&;i!gAKvpxtU@!$@}WQR`@EQ4@`TV&J2$ZD3hhBRguTD@)aePEy3Vydd1i-my4cq zMw?kY=z^B^$~IdIC2b0_1hMcK^g(}hUAQ(eSDoPSfEstmJq!jNHc%vvI&j)s-*s^_ zYM}7VA0)XrXhD2dMZ;Ome693x_pW>{DQHPX+XhqFD^(U68>GW=RsF*>-~)B-b$42) ztQ%*~V+ywpZy9!f=FnI)dbsnv0q45-Os_FSa^okN%Ue(nS8jH+T!xf1BOQD(E_n1@ zp|auigheP=uzO7^$t5c}Db4KK3)|X`h-MK)s9?}HRb@9ZJ>Qnjm`-1ml%u}T_|$}d zI z^G?Bu!|9KL9?oHBX#Uq)BdyNePsor-YBSF4YlVvWCb1nMPmB;wnO)>G>%W*kJx8b) zP=-3LY}cUSc_tp>6Mgn~y6D>tk*$}f0~9_4*@K_X?S4gLRHg%dH!bLI($GlA51TN`7DjBo$gMzKcR{=Cmp!OYsLZ=G$wxyp#Zqg z$|rro?xBPR8TB)9OFn&!ib?e(CNchC!1n?9&D!`GYS7F6V-(uZg{MCes-{VuqXYILP1-A-r5Y68Lo*sCI-+Ca1+{zc6rpCUQKR`Q~l`N zTO(p%3r@HZnI|WErpn(oS9TVy7u5(Vl;E!kY4pxt%Kv6=PpWQ(rg zZChLgU*7D^dR|TbKgA5R9QeREP|5?vh^gX7%O|brkz(LdKQj5g$5OV9H4A`jt~p!ac7?sSaCaPJNBv_H=ud!{l+as zNuM7g8L{h1X%y zu@f|P)8n^Xc$gt*38{N4Ldd3Qg_*Dt=w(@U z2#wzj!Y3nWH64X6tnR%06E!+KH+I91RhRTteF~{WC1;}RoWPuFDT0n0%}QENiadqJ z`>WM=3N>0LCM=qZGSN3E$3*pbZqTD8=%j_Rzn{yK$J_S|%tTJsTPxLHmR6Z3L-a3y z;PqcZ{2T}CPej`iv{4AIzs~9@!=~jD8Y|ft*iX^+S*-cA-9n6&$`OpV%nOQuo9h^` zFXNaqADHa4IX+Y)sKe8#{2WI-TH6^-$k!;XE`hIKWXJ5v3pcI8WkHi}YdbIvq7f4v zLIc+GiV(Fk$Jp3mqlEss!im7m^`_zB7a|`^N-`ohbk1_#6uHT7X`HwfVs0P7hqr%HLQh}eRgemb*U^Kc>k3#uqpfQ$Y^^NF)q3NqpI;Hm z0QXs{EyvaY-62r$=9U6{?4glR_}K@2VoRCl4}NE&tl2S&77U_khQ(6_j*3XTv1D#7 z5YsTuyMrQ$Im(CLy1uOJnP6IzC=vz7J!3oUCI3dbrvB&JqDqIl$>9B+6&!1Wv@OYi z6q;{RFgq@^bC@)arh-Wz7xO5`Xad@VIOJv@kg0#H& zbWu?9xAK9H>7cPvf6^j1hN2Y>WP;*;;qUu#=bQ4-&}-~FWTc9^v&)zHO3Klzu(j3! z45y4tQ;u3fG`rGJX7ThH?ePGkn^Bt&k{8Wnq{n;IY;;qfPK@+IYS?Hy{@wNlC5$!4 z`a#}`r*OLhyONufSbKGc-7ba)A+AT!v=Gceb8}(wx^&XPk1T~sxQGH99dz5;JG&tg zN9iuZSw(IjiQKn!End0`4%HM&iHnnmuHiCK( zysV#Ltj>e!kVEgfm6)XoyR>7TnXj$^SswGO_0HnmtTb)>Z_<>P3ZP=A5^Nj17=@9c9 zzKg(!`h8M*yj#sy_XYxA{IPksJ&x)S~8!> zNN0U+B~jyd&FMuiM)p!z2M_)T5fJ?{gqRET5*jxW4{p1p8vmL?nnc#Yv*xpRScI1$ zLkCBA79$@!F(MHEyPaEH3#_`fa@v*YNNXx)HDB0R+!voebgHJnXim|t48I5GuJwJg z%vy(^b$rN;C`tDBO%}2v5T5uM7pYJKh2(viq+;gg;~Z7cT6~>|PL&C4ab0*H;*SiIVs5l?~Pzrfy!jz8KTsI7Ko*Y6=%sBHT^=)GI~>ePj4lSh-tC= zoQ?kLIeYiQ*3>Lb=BkuQhL|=T$E5aQ>ZlOIar!;)zq%z_+ZL2)RMu*MMyVN#L%>{}7u<;Q8>{_kk5eanAN(vE=3ns!|PLBe0 zst)$)k_>_1`(AJ_MOgrWD7r7a%E&_p+l|`rnUWTXTn9BzTUHwkfoWqa4e(LTFpU^j zC%CvMMNuDWRCynBrz~6?Cc&EN;+ROsY*QPb!E~PA(pI( z_cKK2_-{=L(weDYHMUQJg#;clrOBVE$*M^FQvjgmPT`UWRmK)6ZrELJ16p zBB38k5eWX>iZN`Hpw~lQRw#DblJ$kYbn<6Xy+x9*z=3gn#aCbVuHKrCm+!;PZMsZbt*;yOC@svytSo z`mee>^$0}MJ|4df{op`w610fz03Tl(ee`w0%6bT>pci(7?MBV~kA)Y?kdctz<`tA? zWIjqx;EnWiLPpH2l71*L9%LU0v(e)Scwe1A`sG&xpeHccBJ~PiHw2gBqILzJWPwQZ z9$s*IZo=c1>U|~`+L{(NIzS2tzB(@QO4Ms1Ns!AP!`=JG*!kb#M=Oq9KG$ZM;nPqf z~sWY9I(vI0EkfR)Tu2*GX9KjRHrljcgJ2>I5@ZD=5&yc=m`1h0r1?l5A zk*K(7W_X_FP6!(#i|qL%7;?wo+4c5p7T02EN^uI%W#q#t-XT_8_2MQ$if!FYM}pL5 zXDh^R!tW}4)O%dih5rTbf*(SZhZ=2|+_{2h$D)=O!H(l&cKLobd45C1di{&BYNhHG zJD~~OOSX^fl+y-gWK{-t00m8&boH`dZN`v!`8eq4*e3y}rX_wS{l~GBAIPRxkJr1@ zoxk%lYvTlpbC*98{~Wm_2z&?sWWvK)1JStU7SeZHJIKodDE_Mrr`I|v??cJNmn8{) zR{JWwjq=^@bkKdTPy!GQcGOl|EzepB+6ZXVqFXo<^8`O%8+c@_@3boiJt~8q^Mh54 zc`;5v3dU(oN~v?jX1JXgOZlXYZ+mmAeFw|F*@q-zlPkv^@G|=%*Etdb5tEM$sHuyyUL-mwST)p(9NEJicjpuAr?3 zNQ|e1KO^V*;55OJ9Q6_Gpz_Eh{m;Zhk$Xxns$%#w7~J`%Q!)Kn)LqYjRbz;2KUUN3 zQ1ixG{P||gP|F}?t$cx6wJppmAVU~Ky$%S*Jzv15hruM2o{&}pi5yOzR_rT-Ec97H zX$C3AzNI%h&=Hzq)dQl1bB^PdM4gWL0~*omX%4!jOM|kN(Waf(u<#{ZCe-ta#jb-6#bOJlC^El#zkVtTS=uFlD- z#C{Nz30aIG$WyPjhFwdiRYGfasNPK5nNMR)&p=vLWYK^apW>w(^)nzZQETqKD$b{^ zjfaF;`828uCS{1R9~~U|V=|QS+o5ht+ed!{l!7*=e1`c0>UD zfgAqJc1S<(Ck0zmU%lcAED_-{51XcvH@g z<412!u_?0OaKoT`eF{}Vy%b3F$xF7W8}c=$s)xzg{Rw8~YdET?pex1KC^9JlO_}@6 z9i98m3er2F9j?moQm{CxI=fK@1C&chBbt#NE#23fi$H_AIBebZ$ee7)I@p@} zrfQqz@t91QSAwASf#&9}J1TtGZf`}cQ8@_nsq#e=d95rMB<1Vq z*7g+Bg*p^rsl~1_Fm^Yxa4rD_cBWY_MPxM{xeV%?m44-PGTX3^Hoc08EJZ1DQ`8ZS zS9|=u@(KClw9bc>^wE)of?Lwwzv?SZc+{LUp7~FtRo56DNL2qeYCCRX1Gb82lY2b8 zO^vd0i9c=CwcLgOYXDn!JFM>Tx8w~*W9iw%p~l_BS^ z%i4&w=smh_*xPBiV*WFRd>AtcKMb@z1;01;%{7-=zK{t}ap0F!E|ji1%hh%bGZb-@ z6;ll@bl)-N?b!VBrulZyKER{yh+1_yVRVJLrPPCEf79xHW6Y;6Jg3txn{Sc2$xM1- zhJT+Vcry=hgl>KLm~#B1S@BBxs-Q?G^=h;B*-FP&+2i4;fwE-LUcPK>z zzuq7$awDCg=XU1D@@2(3kLF{a09br&3|0 z2$wNAo>;sc%xnz%G+Nqo9k=h-9}(G%&1L)KI8|;IhW-n7@2$9rR6bUsFID(QvFZ! z*R+doeYDJ8*I2dZ%Pih1Q5Jc=-KxhEq4UF`$XwAHN3wq<^hUHPYhNBy<(eB=LyE%2 zjXRzR+f?7ZWZkkW4Cu(%$)g?_Qd)%nD71U`UVLYEw=ueF&80PZzXR`rY>();*Ha^9 zvhuaMVApZkCxL#s(U3}4Qkb@})g{mJdaOMvCwON`v5NRD zSq!b~EAW^LXn_r5+eSz^c@uEggXG!iV7Fhxr4xtZ*4gVd2cCUEoptDpa7)wOU>K-; zxih9fF(a&1Mk&28t2lQCK0Jk~7#PcvT=q~aHjnzI&L&pRC)~)Lu_IoJDWv@iFCv{V zaf;{__l80kBgES~|6s#pW?G)!Eg!`0MVJ-hN)sX}q{?PNMUW{oC!%u~7ZBn%&!cL$!^UBf5si zCAwgrK0>^?zv_o8JF^^#A>NQAK@N8|z5I9T{%;ii|JVN;F=0W(G(-x5R%1VSlDI literal 0 HcmV?d00001 diff --git a/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 diff --git a/todo_import_template_v1_formal.xlsx b/todo_import_template_v1_formal.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..92e98f604c80e08fe2f3501b0410932e607cfa17 GIT binary patch literal 7309 zcmZ{J1yEes)^+0=+}&Lp2u{!@xNBoUf;%L*2X}V~7PNy~@L(ah27V3~XYs=Znaxkzs0000HkReI}Mi5gl_J4Y+db%*4E>n9GWhZ+FXZAM^ z4s33=HVV4_z(4s*(PH1Em zZi<_`RSstK-G!8JFCLw27fU92j_A&l$)}G6Ee(|aFjvOApX}(#OlD{R0Q0}iHL-Ux z`)%(~yoy{m2S#6e_;O zZZ_;L7G}0)f8RNOug`%l*m;f*$L9z#>Slgni_-hjzrGgR+49|dZCcQQBKZeQ?I;^} z)3_ktcIlTGgUK%r;uV@RgkV{AZil6$x3>A*e;u+JZwH4ap1K+RV4t6EX<%Pm)&kOC zig6GYLykL;3*`;!bS(kheBJ}tSlvGZA`s==a0aLKmcc~q)YH4Q^9!0mmNt0=(xyh< z1}qk$qv|TU_J$O-8{}M9xP$6ZX8BOqT4Hofp|z*iHa&N9iy~$tI|8IYe>)ChZ3^FE z`{H#)-Ov^FuCEEXa@2J8Vy-f-s5DAVY2wm*X)nVf6FCa3 zG-3UIVIn%Lw_?=oMcau|NOxseZ`r8Jo+TaqeWi854xWWLXl>5}3%71Vbbu!FwBSE9@uHial&M7kW*GH92$W4>zKT<7euT+e7P%r zTrYNdgt1PU2r<3i|6DqNvgaq{{ye2+pn%IG%UfXBI?>m%4668`C1WJv&Ut%1r#N9o zL44TZ3k%Z8Ej9Q}2)&&kwvzyfxwzNJ#YJ2pEpX)M8hKAq$edg=`Lq7W$a;396LrhN zg|_#FlC&N5Oi4!#)l4FYMk=SDwRs(ruTJI#5c?HkR&c77G6{465@&K=@KtSZIp#99 zcS9Ru|58-wWtn}tuvVV7%v{hF28d`i(|OZ*%;pxM5Hxn7@#SZJiZ(7(3c(V0DGy7} zv|CcgNkyg*DK^$nA_}}%a0(3_Rtdk4!fWgY=EAv$%n8xsM0T1R*|-?X8tP&fx}j|@ zqD~Nm*fqprT*%`A?mCkPnIw*w6xy7)21FTT*>X$ zHay&rd6Rp;C|3q}LOqVn@phG61$6NHvU!gb9yOf_=Z!b;QhS?sPDFqgL3ZG;x0Rf8 zGu&(pCR$EI{e9|?1jbqZoR0l_3z6)TgPR{TL6CGER%*OhIsY0ln#Q|`Z^ukuk+x02 zv`B;M6K1^OSlQylb~|e4vPm-=kS2ykA@KSRB+o^JW#n4OaI1C*g-fErNp813v8|4d zPxh?pyMrRxAgZ9bP&{e<`&?3<+Y9}7>(qrDYAF(pMB;5Qw#`{T1^BX45sRAcSC zuXlRfh*tT3z&3fOYT0hhj*Z=Jb*g;MKN3nN?F&8qNO_nSoI27i5#sJ?m};KXtIUmK zYg10c0%|a7oH=2^SvECd%|!@=s6d~pY8Y{XdtsPkKYGbWN^LuArRW*1jm59c7n#-B zK^Wf?+ES_VcUh)lo0olCG^^^=ARW;FpMGw$60XHn9C^^G=+8mLRK!EUdTo-3UnKkS z7ol3)7PHJ(4Ze0Mu6nE#f-h^EeU6H1jacvM(V>A&E&L@OW}N0lr%SA;NG#w`9jsLa z{e!8kjLb?8s&_Xj?>mi7?;QrD(KiIQ4A1l>Dt#^%)^6+)1YgT%y7yp0xky1%Cy~J8 z;RDx|594Fuv7v?%2PBW715ArZ{8DdFT2E1%7;%RyMEHHBSOS9;4aRA)aKb;M!cpQo ztsNaDSiO&&xnc3Q4N?c1PT_x(2u!+qgQQ*_*9Ukt7S!UeU z3{kqK%@nNlJn^TdyoMmP*EMg8=}DPyhgezZ1xNdnapW3o|npXZF9o{>mb)nzr_9YR zDw?{kO5k`t`yk#DTG6p2ymPa%+wB0TGMP;0OcVZ-HT(S_mV;mxfvjls+3Ym)%_`wF zdHcaDa6P4@#nKwXz|9RPwAO^+hp~ilW}UVy)@V6FOqCz4A+gRkBm*X862g9NVd`G) zF$7`Sj9+3g8&n^W(wtcb*9d+s@Py6QL%=s;$D%XDlyJSdDXTp0G^_ks&f0`YDYQ{C z@hZs{ndLV(NmFUF;|7FY`KB49Lp)6hTD^N~wo#WtYED0>B zW%L~)1H>Erv_Kt0Uo-K~+PnUR9ng}@!w+;};WAXv^&PbL1C0)<0!iSzn)NcR`M84U zErd-I*GdOP2p_csUh8P#?AdvJCF=n4R-BvBJh5ta67xN)c4CRIEqIoS!(g58Gh=55 zMUf2W>d^)vITXnjEFcMR2IT+@(1+f4hEhaw7=~-4?GVOaP68b1`58icx$5-nl$Ie? zbp(=`gQMiEkLl=leIoRB_qgM99vJv67?-~;hlPjUO>@Cs#bcSMtX5_?QV!r{76Ci0 zI=T)0oS{v_-kl)av6Fqx(I3ughwd0N7!6oIu`i{(y(>AV-42uG@5?p9l7UDg%U z(|TP@AvHERHNXg~4${XZkRI8`dT3t>?Lt&CDq_{n4y8(4EJGuo;oOa6#r9FoAg(8M z9KMP)C9K7g%7E&Mt>HGEFTj#d;KuLM)E0R)gQ>U}ezRCe5o19|QEB6TiU0K-iR-%k6JE?%JB@&3)(RvvyAS0KOFwWi&ZLuqXL^(qxhsc4A!a97 zODP|PnPUhtt+gjdy@no8zD;DF3SlgegY_nG@nR*vfirNC>wzW`kt(y8nRI~}VqO^s zoH6%)eM8y)s39nHh4825H_-EF3!gN2#P8I`rno8N9CY;lAwk6YH7h7htu_gK>_*^^^;F>a}iuRf!cvW^DhErM zQk4BrOn2#{=m_Y{23puVP?H|}jECWE9BnRr>RLHkZoGDwkP?H1CRYu->)6rC`#J-| z3QZ+8s76?*Pjp|zanu|?oa5rLi;hU2c|i+D%uP^G=6;ntmKi#f_pM!lwb7l(hxYz# z%O}-)zMUf_UKLFBCeuW{QQV*Lrq_~5bM|5jigBg%3!>0? zf)El}nNV~dn#&v@D_qw)_5&7!PZ2lV4lJ`yJaf+sZ4qOoD)ng3&He7$)_E*jp8nm< zgPhak6bepaet?s>7?h_h1E5g5%FAldW!K^mOLawMpm>5G1Dy{m&PyafXYUKzyMP*$ zc3)P)Z`w|UdAJ=|GHenEJq%YUdb=uRlcE4s85$WP1i>3dyE1j84K8(dO$f;pGyP;j zE}3}{40qZvxTH`~II0F^D!AHM^{pPsCt$qBH)mSz$F|bkCR67~=&w~l#>8KKX?_Tn zq5OmwM57FC#kp^5^(Nr7*bLRW1Md(fP4@m)_R277@>5*;=%*mhH_NGlU89^ZKLOa@ zz=f)z)|55%z|_uOU?n4b5^O$hV3Ck|FKq4VYGvdS)ZU!%%Mh}~Ep6|_ z1-n%aj1Pvir!bR=$qS{jRJifbx(~2}PPY9)+I4H?!rTY*Gm4ZEwVYaMgd_Y*@;LP6 zNj_X~0C;lML^>)s<8%2EejJLlCNhs^2q_|Ka{CNt!UbR<3OC@>sEw>tLMNI?`qT-7Fb!k+PXJ3%uiJ@<7# z77nY;;X$(dZoyKL*{@^8h83Y_O0zrMb9`awP<2SGG!W(v2tyF?`RWhW3O!#DPu35! zBCaKE$~xa=ItEfo35J$UbnEb*?Y!(iHhs`ik+VCw$6LCb{hr*yRtoWsJG5IS)BPDJ z{Yoie=iQ>z8Dsp03ujxWmrYs>S;&Bk&<@qjO-RkgwpRL2BQ)L3j;dXBB$f9&`x_3Ew`30&M&2}vT{q)Q5v*DWnaE|bHr*fV>IGicpTp- zIQoFJ%KoQs$Bt>wm!3{RkWm4E7k`&(&MxjYX3oDIx}dpWm%)MKgZmXG5|$gM2*XlB zD!o=AX)a=ch{I^W=ⅈ&DpOiq`V5Hqyr~Oq>Jm}_YYn+TYRNl#ptYs-!cRjV@j5o zc)XAUs%$K!d(n+^TR~Qof*;S+Uv~1nmMz76O^V*Nz=kH!R$T*IecbODY++Lug9UPAl#evHLonfmxbX zP)Til9#5_OAO-*luV=rgv1<@k<{2i04}@_pnnaRh3p&sf_Af!;}!A0g31hmD4d z{Ci9W3__mZ1WnjSzFwwuvtdMFxt$}bVFM4#OCuwnj)|oyB179R5{pMLHVR977%XaE zu&d-xbYWI2i8=OrF5lI0zUt`EgxVuLKDvMr-q@7HEN8+at`Bsik>P;#7+c79j8vx& zj`8&VtdAunvkC$@OJhh8K(}B@e@3K$M-8KLMsI752^bW{)qA|pc>w&Wgp}|Wl8z?@ zU_Ythsc!)oI+@uxv$OraXT>+!b#tJK`u2=wb0CaThH{ym;K5!)X?{eX#exf$%4&zq z6*|VE5V{KsVf`et6g}W8sE;TixW`bn)SYUCdRuirUOCA^#uG9wDmiZ=@mZHe zM3jy2Z9}UmUa@n5R;b=gR-y1byONl_A4EXjQQY#i92eU0(6}$~@)s(P)P^i|5}3$s zV&mc6vdmZ>l{Q|w)WF0?(n4R-zKht29f%24tp|Af+R;KCIr^swZKR`d?E_E10Q$bq zvls2E%+QE_3g-h))f)QWlKPo4AllCTuNrl}uj#9u$Q^P}cM5%v6s4C;tS9mx`oeUa6Rx_1uoFrYj9Q&D> z#p06_rG4uAd3|ZHdY!X&z$@^FZ(xavwF7nXy`58cBe(jtsG(za%(V5chUg-XhpsUV zr*G?a-|&E<$VT6ae6;ujUi;hdmp>@uLoI{t8vm0??ZE$48860S&$CS=?SEYgnN32la5%G;&+n_`d4PiQm{0 zfqt7+LpJz@GW=52c*;+WK^%4@PJpI3)OP{|(x|h7apKHb8enpRR)RYxy0*GTteeyQ zmMyRd!uCp||D0-$Zel#b>*DaSuiQij zs)-Kk6=TGa$~o{KTF4mcOX3AczP$;hr;;=D7TI2y&h_ozJZTG=MDxidN!~NR`>~er z@WGqqL594+ceW3VcwleXiw&oaK2H_8%6grl;}L6U#p}*>mXgwDk|P z`^n$`mBgL{pQnL;faSRVvHbs%!gKKRK=cpT9RDxy{|HOZd7jJrA0C~jgTsFjyRsbI U(`4#*hDHH=d&<-mgukEu9}oJ>dH?_b literal 0 HcmV?d00001