commit 9f3c96ce738b43628922abfce35940579261e219 Author: violet75630 Date: Fri Jan 9 19:14:41 2026 +0800 first commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c37a5b6 --- /dev/null +++ b/.gitignore @@ -0,0 +1,41 @@ +# Dependencies +node_modules/ +__pycache__/ +*.pyc +.venv/ +venv/ +env/ + +# Build outputs +dist/ +build/ +*.egg-info/ +backend/static/ + +# IDE +.vscode/ +.idea/ + +# Environment +.env +.env.local +*.local + +# Database +*.db +data/uploads/* +!data/uploads/.gitkeep + +# OS +.DS_Store +Thumbs.db + +# Logs +*.log +logs/ + +# Testing +coverage/ +.pytest_cache/ +htmlcov/ +.coverage diff --git a/README.md b/README.md new file mode 100644 index 0000000..b442124 --- /dev/null +++ b/README.md @@ -0,0 +1,202 @@ +# SalesPipeline - 銷售管線管理系統 + +一個完整的銷售管線追蹤系統,用於管理 DIT (Design-In Tracking) 案件、樣品紀錄、訂單明細,並提供智慧模糊比對與分析儀表板。 + +## 系統架構 + +``` +Frontend (React + TypeScript + i18n) + │ + │ REST API (Single Port) + ▼ +Backend (FastAPI + SQLAlchemy) + │ + ▼ +Database (MySQL) +``` + +## 功能特色 + +- **使用者驗證**:JWT 登入/註冊、角色權限管理 +- **多語言支援**:繁體中文、英文 (i18next) +- **資料匯入**:自動偵測 Excel 表頭位置,支援中文欄位對應 +- **智慧模糊比對**:使用 rapidfuzz 計算客戶名稱相似度 + - ≥95%:自動匹配 + - 80-95%:需人工審核 + - <80%:不匹配 +- **分析儀表板**:KPI 統計、轉換漏斗圖、歸因明細表 +- **報表匯出**:支援 Excel 和 PDF 格式 +- **單一 Port 部署**:前後端整合於同一服務 + +## 快速開始 + +### 前置需求 + +- Node.js 18+ +- Python 3.10+ +- MySQL 5.7+ / 8.0+ + +### 安裝步驟 + +1. **Clone 專案** + ```bash + git clone + cd SampleOrderAssistant + ``` + +2. **設定後端** + ```bash + cd backend + python -m venv venv + + # Windows + venv\Scripts\activate + + # Linux/Mac + source venv/bin/activate + + pip install -r requirements.txt + ``` + +3. **設定環境變數** + ```bash + cp .env.example .env + # 編輯 .env 檔案設定資料庫連線 + ``` + +4. **設定前端** + ```bash + cd frontend + npm install + ``` + +### 開發模式 + +1. **啟動後端** (Port 8000) + ```bash + cd backend + python run.py + ``` + +2. **啟動前端** (Port 3000) + ```bash + cd frontend + npm run dev + ``` + +3. 打開瀏覽器前往 http://localhost:3000 + +### 生產環境部署 + +1. **建置前端** + ```bash + cd frontend + npm run build + ``` + +2. **啟動服務** + ```bash + cd backend + python run.py + ``` + +3. 打開瀏覽器前往 http://localhost:8000 + +詳細部署說明請參考 [deploy/1panel-setup.md](deploy/1panel-setup.md) + +## API 文件 + +後端啟動後 (DEBUG=True),可前往以下網址查看 API 文件: +- Swagger UI: http://localhost:8000/api/docs +- ReDoc: http://localhost:8000/api/redoc + +## 專案結構 + +``` +SampleOrderAssistant/ +├── frontend/ # React 前端 +│ ├── src/ +│ │ ├── components/ # UI 元件 +│ │ ├── services/ # API 服務 +│ │ ├── hooks/ # Custom Hooks +│ │ ├── i18n/ # 多語言設定 +│ │ │ └── locales/ # 語言檔案 (zh-TW, en) +│ │ └── types/ # TypeScript 類型 +│ └── package.json +│ +├── backend/ # FastAPI 後端 +│ ├── app/ +│ │ ├── models/ # 資料模型 +│ │ ├── routers/ # API 路由 +│ │ ├── services/ # 業務邏輯 +│ │ └── utils/ # 工具函數 +│ ├── static/ # 前端建置輸出 +│ ├── .env # 環境變數 (不進版控) +│ ├── .env.example # 環境變數範本 +│ └── requirements.txt +│ +├── data/ # 資料目錄 +│ └── uploads/ # 上傳檔案 +│ +├── deploy/ # 部署設定 +│ ├── 1panel-setup.md # 1Panel 部署指南 +│ ├── salespipeline.service # Systemd 服務檔 +│ └── deploy.sh # 自動部署腳本 +│ +└── README.md +``` + +## 資料庫表格 + +| 表格名稱 | 說明 | +|---------|------| +| PJ_SOA_Users | 使用者帳戶 | +| PJ_SOA_DIT_Records | DIT 案件資料 | +| PJ_SOA_Sample_Records | 樣品記錄 | +| PJ_SOA_Order_Records | 訂單明細 | +| PJ_SOA_Match_Results | 配對結果 | +| PJ_SOA_Review_Logs | 審核日誌 | + +## 技術棧 + +### 前端 +- React 18 + TypeScript +- Vite +- TailwindCSS +- Recharts +- React Query +- React Router DOM +- react-i18next +- Axios + +### 後端 +- FastAPI +- SQLAlchemy 2.0 +- MySQL (PyMySQL) +- rapidfuzz +- openpyxl + pandas +- reportlab +- python-jose (JWT) + +## 環境變數 + +| 變數名稱 | 說明 | 預設值 | +|---------|------|--------| +| DB_HOST | 資料庫主機 | localhost | +| DB_PORT | 資料庫端口 | 3306 | +| DB_USER | 資料庫使用者 | root | +| DB_PASSWORD | 資料庫密碼 | - | +| DB_DATABASE | 資料庫名稱 | sales_pipeline | +| SECRET_KEY | JWT 密鑰 | - | +| ALGORITHM | JWT 演算法 | HS256 | +| ACCESS_TOKEN_EXPIRE_MINUTES | Token 過期時間(分鐘) | 1440 | +| APP_HOST | 應用監聽地址 | 0.0.0.0 | +| APP_PORT | 應用監聽端口 | 8000 | +| WORKERS | 工作進程數 | 1 | +| DEBUG | 開發模式 | False | +| TABLE_PREFIX | 資料表前綴 | PJ_SOA_ | +| CORS_ORIGINS | 允許的跨域來源 (逗號分隔) | - | + +## License + +MIT diff --git a/SampleOrderAssistant.txt b/SampleOrderAssistant.txt new file mode 100644 index 0000000..1c68d21 --- /dev/null +++ b/SampleOrderAssistant.txt @@ -0,0 +1,654 @@ +import React, { useState, useEffect } from 'react'; +import { + Upload, FileText, Database, CheckCircle, XCircle, + AlertTriangle, BarChart2, PieChart, Activity, + ArrowRight, Search, Filter, Download, RefreshCw, FileSpreadsheet, + Info +} from 'lucide-react'; +import { + BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip as RechartsTooltip, Legend, ResponsiveContainer, + PieChart as RePieChart, Pie, Cell +} from 'recharts'; + +// --- 真實模擬資料 (源自使用者上傳檔案) --- + +// 1. DIT Report (模擬解析後的結構) +// 來源: DIT report (by Focus Item)_app_樣本.xlsx +const PARSED_DIT_DATA = [ + { id: 'OP0000012868', customer: '光寶科技股份有限公司', pn: 'GBU610', eau: 20000, stage: 'Design Win', date: '2025-02-04' }, + { id: 'OP0000012869', customer: 'LITEON (PMS)', pn: 'PMSM808LL_R2', eau: 55000, stage: 'Design-In', date: '2025-07-28' }, + { id: 'OP0000012870', customer: '台達電子', pn: 'PEC3205ES-AU', eau: 100000, stage: 'Sample Provide', date: '2025-08-15' }, // 故意用簡稱測試模糊比對 + { id: 'OP0000012871', customer: '合美企業', pn: 'PJT7828', eau: 5000, stage: 'Negotiation', date: '2025-08-10' }, + { id: 'OP0000012872', customer: '申浦电子', pn: 'ER1604FCT', eau: 30000, stage: 'Design Win', date: '2025-07-30' }, +]; + +// 2. Sample Data (模擬解析後的結構) +// 來源: 樣品申請紀錄_樣本.xlsx +const PARSED_SAMPLE_DATA = [ + { id: 'S202512490-02', order_no: 'S202512490', customer: '台達電子工業股份有限公司', pn: 'PEC3205ES-AU', qty: 20, date: '2025-12-19' }, + { id: 'S202512490-03', order_no: 'S202512490', customer: '台達電子工業股份有限公司', pn: 'PDZ9.1B-AU', qty: 20, date: '2025-12-19' }, +]; + +// 3. Order Data (模擬解析後的結構) +// 來源: 訂單樣本_20251217.xlsx +const PARSED_ORDER_DATA = [ + { id: '1125025312-1', order_no: '1125025312', customer: '合美企業有限公司', pn: 'PJT7828', qty: 1200, status: 'Shipped', amount: 10800 }, + { id: '1125062105-1', order_no: '1125062105', customer: '申浦电子', pn: 'ER1604FCT', qty: 800, status: 'Backlog', amount: 3200 }, +]; + +// --- 模糊比對模擬結果 --- +const SIMULATED_REVIEWS = [ + { + id: 101, + score: 88, + dit: { id: 'OP0000012870', cust: '台達電子', pn: 'PEC3205ES-AU' }, + match_target: { id: 'S202512490', cust: '台達電子工業股份有限公司', pn: 'PEC3205ES-AU', type: 'SAMPLE' }, + type: 'SAMPLE', + reason: 'Customer Name Partial Match (88%)' + }, + { + id: 102, + score: 92, + dit: { id: 'OP0000012871', cust: '合美企業', pn: 'PJT7828' }, + match_target: { id: '1125025312', cust: '合美企業有限公司', pn: 'PJT7828', type: 'ORDER' }, + type: 'ORDER', + reason: 'Corporate Suffix Mismatch' + } +]; + +// --- 輔助元件 --- + +const Card = ({ children, className = "" }) => ( +
+ {children} +
+); + +const Badge = ({ children, type = "neutral" }) => { + const styles = { + neutral: "bg-slate-100 text-slate-600", + success: "bg-emerald-100 text-emerald-700", + warning: "bg-amber-100 text-amber-700", + danger: "bg-rose-100 text-rose-700", + info: "bg-blue-100 text-blue-700" + }; + return ( + + {children} + + ); +}; + +// --- 主應用程式 --- + +export default function App() { + const [activeTab, setActiveTab] = useState('import'); + const [isProcessing, setIsProcessing] = useState(false); + const [processingStep, setProcessingStep] = useState(''); + const [filesLoaded, setFilesLoaded] = useState(false); + + // 狀態管理 + const [reviews, setReviews] = useState([]); + const [processedCount, setProcessedCount] = useState(0); + + // Dashboard 數據 + const [dashboardData, setDashboardData] = useState({ + totalDit: 5, + matchedSample: 0, + matchedOrder: 1, // '申浦电子' 是 Exact Match + conversionRate: 20.0 + }); + + // 模擬載入使用者的真實檔案 + const loadUserFiles = () => { + setFilesLoaded(true); + }; + + // 模擬 ETL 執行 + const runEtl = () => { + if (!filesLoaded) { + alert("請先載入檔案!"); + return; + } + setIsProcessing(true); + + // 模擬後端處理步驟 + const steps = [ + "正在讀取 DIT Report... 偵測到表頭在第 16 行", + "正在讀取 樣品紀錄... 偵測到表頭在第 9 行", + "正在讀取 訂單明細... 編碼識別為 CP950", + "執行資料標準化 (Normalization)...", + "執行模糊比對 (Fuzzy Matching)...", + "運算完成!" + ]; + + let stepIndex = 0; + const interval = setInterval(() => { + if (stepIndex >= steps.length) { + clearInterval(interval); + setIsProcessing(false); + setReviews(SIMULATED_REVIEWS); // 載入模擬的比對結果 + setActiveTab('review'); + } else { + setProcessingStep(steps[stepIndex]); + stepIndex++; + } + }, 800); + }; + + // 處理審核動作 + const handleReviewAction = (id, action) => { + const reviewItem = reviews.find(r => r.id === id); + setReviews(prev => prev.filter(r => r.id !== id)); + + if (action === 'accept') { + setProcessedCount(prev => prev + 1); + + // 更新 Dashboard 數據 (模擬) + if (reviewItem.type === 'ORDER') { + setDashboardData(prev => ({ + ...prev, + matchedOrder: prev.matchedOrder + 1, + conversionRate: ((prev.matchedOrder + 1) / prev.totalDit * 100).toFixed(1) + })); + } else if (reviewItem.type === 'SAMPLE') { + setDashboardData(prev => ({ + ...prev, + matchedSample: prev.matchedSample + 1 + })); + } + } + }; + + // 漏斗圖資料 + const funnelData = [ + { name: 'DIT 案件', value: dashboardData.totalDit, fill: '#6366f1' }, + { name: '成功送樣', value: dashboardData.matchedSample, fill: '#8b5cf6' }, + { name: '取得訂單', value: dashboardData.matchedOrder, fill: '#10b981' }, + ]; + + return ( +
+ {/* Top Navigation */} +
+
+
+
+ S +
+
+ SalesPipeline + On-Premise Simulator +
+
+
+ {[ + { id: 'import', icon: Database, label: '資料匯入' }, + { id: 'review', icon: CheckCircle, label: '比對審核', badge: reviews.length }, + { id: 'dashboard', icon: BarChart2, label: '分析儀表板' } + ].map(tab => ( + + ))} +
+
+
+ 系統運作中 +
+
+
+ +
+ + {/* --- View 1: Import --- */} + {activeTab === 'import' && ( +
+
+
+

原始資料匯入中心

+

系統將自動偵測 Excel/CSV 檔頭位置並進行智慧欄位對應。

+
+
+ + +
+
+ + {/* Progress Log */} + {isProcessing && ( +
+

+ {processingStep} +

+
+ )} + +
+ {[ + { type: 'dit', title: '1. DIT Report', file: 'DIT report_app_樣本.xlsx', desc: '含 Metadata (前16行)', count: '5 筆 (測試)' }, + { type: 'sample', title: '2. 樣品紀錄', file: '樣品申請紀錄_樣本.xlsx', desc: '含檔頭 (前9行)', count: '2 筆 (測試)' }, + { type: 'order', title: '3. 訂單明細', file: '訂單樣本_20251217.xlsx', desc: '標準格式', count: '2 筆 (測試)' }, + ].map(file => ( + +
+
+ {filesLoaded ? : } +
+ {filesLoaded && Ready} +
+

{file.title}

+

{filesLoaded ? file.file : "等待上傳..."}

+

{file.desc}

+ + {filesLoaded && ( +
+ 預覽筆數 + {file.count} +
+ )} +
+ ))} +
+ + {/* Data Preview (Mocked from User Files) */} + {filesLoaded && ( +
+ +
+

+ + DIT 解析結果 (預覽) +

+ Auto-Skipped Header +
+ + + + + + + + + + + {PARSED_DIT_DATA.map((row, i) => ( + + + + + + + ))} + +
CustomerPart NoStageEAU
{row.customer}{row.pn}{row.stage}{row.eau.toLocaleString()}
+
+ + +
+

+ + 訂單解析結果 (預覽) +

+ Detected CP950 +
+ + + + + + + + + + + {PARSED_ORDER_DATA.map((row, i) => ( + + + + + + + ))} + +
CustomerPart NoStatusQty
{row.customer}{row.pn} + + {row.status} + + {row.qty.toLocaleString()}
+
+
+ )} +
+ )} + + {/* --- View 2: Review --- */} + {activeTab === 'review' && ( +
+
+
+

+ 模糊比對審核工作台 + 待審核: {reviews.length} +

+

系統發現以下案件名稱相似,請人工確認關聯性。

+
+
+ + {reviews.length === 0 ? ( +
+
+ +
+

所有案件已審核完畢!

+

您的資料比對已完成,請查看分析儀表板。

+ +
+ ) : ( +
+ {reviews.map(item => ( + +
+ {/* Left: DIT */} +
+
+ DIT (設計導入) + OP編號: {item.dit.id} +
+
+
Customer Name
+
{item.dit.cust}
+
Part Number
+
+ {item.dit.pn} +
+
+
+ + {/* Middle: Score & Reason */} +
+
+
{item.score}%
+
+ 相似度 +
+
+ {item.reason} +
+
+
+ + {/* Right: Target (Sample/Order) */} +
+
+ + {item.type === 'ORDER' ? 'Order (訂單)' : 'Sample (樣品)'} + + 來源單號: {item.match_target.id} +
+
+
Customer Name
+
{item.match_target.cust}
+
Part Number
+
+ {item.match_target.pn} +
+
+
+ + {/* Actions */} +
+ + +
+
+
+ ))} +
+ )} +
+ )} + + {/* --- View 3: Dashboard --- */} + {activeTab === 'dashboard' && ( +
+
+
+

業務轉換率戰情室

+

數據來源:DIT Report, 樣品紀錄, 訂單明細 (2025-12-17)

+
+ +
+ + {/* KPI Cards */} +
+ +
DIT 總案件數
+
{dashboardData.totalDit}
+
Raw Data Count
+
+ +
成功送樣數
+
{dashboardData.matchedSample}
+
+ {processedCount > 0 ? `(含人工審核 +1)` : ''} +
+
+ +
取得訂單 (Backlog)
+
{dashboardData.matchedOrder}
+
+ Match Rate: {dashboardData.conversionRate}% +
+
+ +
預估營收 (Revenue)
+
$14,000
+
Based on matched orders
+
+
+ +
+ {/* Funnel Chart */} + +

+ + DIT 轉換漏斗 (Funnel Analysis) +

+
+ + + + + + + + {funnelData.map((entry, index) => ( + + ))} + + + +
+
+ + {/* Status Pie Chart */} + +

+ + 訂單狀態佔比 +

+
+ + + + + + + + + + +
+
+
+ + {/* Attribution Table Preview */} + +
+

+ + DIT 歸因明細表 (LIFO 邏輯) +

+
+ + + Hover to see order details + +
+
+ + + + + + + + + + + + + {PARSED_DIT_DATA.map((row, i) => { + // 簡單模擬關聯邏輯 (Find exact matches or partial matches) + const matchedOrder = PARSED_ORDER_DATA.find(o => o.customer.includes(row.customer) || row.customer.includes(o.customer.substring(0, 2))); + const matchedSample = PARSED_SAMPLE_DATA.find(s => s.customer.includes(row.customer) || row.customer.includes(s.customer.substring(0, 2))); + + // 人工審核修正 (台達電子 & 合美) + const isReviewedSample = (row.customer === '台達電子' && processedCount > 0) ? {order_no: 'S202512490'} : null; + const isReviewedOrder = (row.customer === '合美企業' && processedCount > 1) ? {order_no: '1125025312'} : null; + + // 決定顯示內容 + const finalSample = matchedSample || isReviewedSample; + const finalOrder = matchedOrder || isReviewedOrder; + + return ( + + + + + + + {/* Sample Column */} + + + {/* Order Column */} + + + ) + })} + +
OP編號CustomerPart No.EAUSample OrderOrder Status
+ {row.id} + + {row.customer} +
{row.stage}
+
{row.pn}{row.eau.toLocaleString()} + {finalSample ? ( +
+ + {finalSample.order_no} +
+ ) : ( + - + )} +
+ {finalOrder ? ( +
+ + {finalOrder.order_no} +
+ ) : ( + - + )} +
+
+ +
+ )} + +
+
+ ); +} \ No newline at end of file diff --git a/_nul b/_nul new file mode 100644 index 0000000..e69de29 diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000..2e9b139 --- /dev/null +++ b/backend/.env.example @@ -0,0 +1,24 @@ +# Database Configuration +DB_HOST=localhost +DB_PORT=3306 +DB_USER=your_user +DB_PASSWORD=your_password +DB_DATABASE=your_database + +# JWT Configuration +SECRET_KEY=your-super-secret-key-change-in-production +ALGORITHM=HS256 +ACCESS_TOKEN_EXPIRE_MINUTES=1440 + +# Application Settings +APP_HOST=0.0.0.0 +APP_PORT=8000 +WORKERS=1 +DEBUG=False + +# Default Admin Account (created on first startup) +ADMIN_EMAIL=admin@example.com +ADMIN_PASSWORD=admin123 + +# CORS Settings (comma separated, for development) +CORS_ORIGINS=http://localhost:3000,http://127.0.0.1:3000,http://localhost:5173,http://127.0.0.1:5173 diff --git a/backend/app/__init__.py b/backend/app/__init__.py new file mode 100644 index 0000000..575047f --- /dev/null +++ b/backend/app/__init__.py @@ -0,0 +1 @@ +# SalesPipeline Backend diff --git a/backend/app/config.py b/backend/app/config.py new file mode 100644 index 0000000..0192a65 --- /dev/null +++ b/backend/app/config.py @@ -0,0 +1,58 @@ +import os +from pathlib import Path +from dotenv import load_dotenv + +# 載入環境變數 +load_dotenv() + +# 專案路徑 +BASE_DIR = Path(__file__).resolve().parent.parent.parent +DATA_DIR = BASE_DIR / "data" +UPLOAD_DIR = DATA_DIR / "uploads" +STATIC_DIR = BASE_DIR / "backend" / "static" + +# 確保目錄存在 +DATA_DIR.mkdir(exist_ok=True) +UPLOAD_DIR.mkdir(exist_ok=True) +STATIC_DIR.mkdir(exist_ok=True) + +# MySQL 資料庫設定 +DB_HOST = os.getenv("DB_HOST", "localhost") +DB_PORT = os.getenv("DB_PORT", "3306") +DB_USER = os.getenv("DB_USER", "root") +DB_PASSWORD = os.getenv("DB_PASSWORD", "") +DB_DATABASE = os.getenv("DB_DATABASE", "sales_pipeline") + +# MySQL 連線字串 +DATABASE_URL = f"mysql+pymysql://{DB_USER}:{DB_PASSWORD}@{DB_HOST}:{DB_PORT}/{DB_DATABASE}?charset=utf8mb4" + +# 資料表前綴 +TABLE_PREFIX = os.getenv("TABLE_PREFIX", "PJ_SOA_") + +# JWT 設定 +SECRET_KEY = os.getenv("SECRET_KEY", "your-secret-key-change-in-production-12345678") +ALGORITHM = os.getenv("ALGORITHM", "HS256") +ACCESS_TOKEN_EXPIRE_MINUTES = int(os.getenv("ACCESS_TOKEN_EXPIRE_MINUTES", "1440")) + +# 模糊比對閾值 +MATCH_THRESHOLD_AUTO = int(os.getenv("MATCH_THRESHOLD_AUTO", "95")) +MATCH_THRESHOLD_REVIEW = int(os.getenv("MATCH_THRESHOLD_REVIEW", "80")) + +# Excel 解析設定 +MAX_HEADER_SCAN_ROWS = int(os.getenv("MAX_HEADER_SCAN_ROWS", "20")) + +# 應用設定 +APP_HOST = os.getenv("APP_HOST", "0.0.0.0") +APP_PORT = int(os.getenv("APP_PORT", "8000")) +WORKERS = int(os.getenv("WORKERS", "1")) +DEBUG = os.getenv("DEBUG", "False").lower() == "true" + +# CORS 設定 +CORS_ORIGINS = [ + origin.strip() + for origin in os.getenv( + "CORS_ORIGINS", + "http://localhost:3000,http://127.0.0.1:3000,http://localhost:5173,http://127.0.0.1:5173" + ).split(",") + if origin.strip() +] diff --git a/backend/app/init_admin.py b/backend/app/init_admin.py new file mode 100644 index 0000000..91c96f2 --- /dev/null +++ b/backend/app/init_admin.py @@ -0,0 +1,54 @@ +""" +初始化管理員帳號腳本 +""" +from sqlalchemy.orm import Session +from app.models import engine, Base +from app.models.user import User, UserRole +from app.utils.security import get_password_hash +import os + +def create_admin_user(db: Session): + """建立預設管理員帳號""" + admin_email = os.getenv("ADMIN_EMAIL", "admin@example.com") + admin_password = os.getenv("ADMIN_PASSWORD", "admin123") + + # 檢查是否已存在 + existing = db.query(User).filter(User.email == admin_email).first() + if existing: + print(f"Admin user already exists: {admin_email}") + return existing + + # 建立管理員 + admin = User( + email=admin_email, + password_hash=get_password_hash(admin_password), + display_name="Administrator", + language="zh-TW", + role=UserRole.admin + ) + db.add(admin) + db.commit() + db.refresh(admin) + + print(f"Admin user created: {admin_email}") + return admin + +def init_database(): + """初始化資料庫並建立預設帳號""" + from sqlalchemy.orm import sessionmaker + + # 建立所有資料表 + Base.metadata.create_all(bind=engine) + print("Database tables created.") + + # 建立 session + SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) + db = SessionLocal() + + try: + create_admin_user(db) + finally: + db.close() + +if __name__ == "__main__": + init_database() diff --git a/backend/app/main.py b/backend/app/main.py new file mode 100644 index 0000000..bec4220 --- /dev/null +++ b/backend/app/main.py @@ -0,0 +1,71 @@ +from fastapi import FastAPI, Request +from fastapi.middleware.cors import CORSMiddleware +from fastapi.staticfiles import StaticFiles +from fastapi.responses import FileResponse +from app.models import init_db +from app.routers import auth, etl, match, dashboard, report, lab +from app.config import STATIC_DIR, DEBUG, CORS_ORIGINS, APP_HOST, APP_PORT + +# 初始化資料庫 +init_db() + +app = FastAPI( + title="SalesPipeline API", + description="銷售管線管理系統 API", + version="1.0.0", + docs_url="/api/docs" if DEBUG else None, + redoc_url="/api/redoc" if DEBUG else None, +) + +# CORS 設定 (開發模式需要) +if DEBUG and CORS_ORIGINS: + app.add_middleware( + CORSMiddleware, + allow_origins=CORS_ORIGINS, + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], + ) + +# 註冊 API 路由 +app.include_router(auth.router, prefix="/api") +app.include_router(etl.router, prefix="/api") +app.include_router(match.router, prefix="/api") +app.include_router(dashboard.router, prefix="/api") +app.include_router(report.router, prefix="/api") +app.include_router(lab.router, prefix="/api") + +@app.get("/api/health") +def health_check(): + return {"status": "healthy", "version": "1.0.0"} + +# 靜態檔案服務 (前端 build 後的檔案) +static_path = STATIC_DIR +if static_path.exists(): + assets_dir = static_path / "assets" + if assets_dir.exists(): + app.mount("/assets", StaticFiles(directory=assets_dir), name="assets") + +# SPA 路由處理 - 所有非 API 路由都返回 index.html +@app.get("/{full_path:path}") +async def serve_spa(request: Request, full_path: str): + if full_path.startswith("api/"): + return {"error": "Not Found"}, 404 + + static_file = static_path / full_path + if static_file.exists() and static_file.is_file(): + return FileResponse(static_file) + + index_file = static_path / "index.html" + if index_file.exists(): + return FileResponse(index_file) + + return { + "message": "SalesPipeline API is running", + "docs": "/api/docs" if DEBUG else "Disabled in production", + "health": "/api/health" + } + +if __name__ == "__main__": + import uvicorn + uvicorn.run(app, host=APP_HOST, port=APP_PORT) diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py new file mode 100644 index 0000000..e0e5439 --- /dev/null +++ b/backend/app/models/__init__.py @@ -0,0 +1,64 @@ +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker, declarative_base +from app.config import DATABASE_URL +import os + +# MySQL 連線引擎設定 +engine = create_engine( + DATABASE_URL, + pool_size=10, + max_overflow=20, + pool_pre_ping=True, + pool_recycle=3600, + echo=False +) + +SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) +Base = declarative_base() + +def get_db(): + db = SessionLocal() + try: + yield db + finally: + db.close() + +# Import models to register them +from app.models.user import User, UserRole +from app.models.dit import DitRecord +from app.models.sample import SampleRecord +from app.models.order import OrderRecord +from app.models.match import MatchResult, ReviewLog + +def init_db(): + """初始化資料庫並建立預設管理員""" + from app.utils.security import get_password_hash + + # 建立所有資料表 + Base.metadata.create_all(bind=engine) + + # 建立預設管理員帳號 + db = SessionLocal() + try: + admin_email = os.getenv("ADMIN_EMAIL", "admin@example.com") + admin_password = os.getenv("ADMIN_PASSWORD", "admin123") + + existing = db.query(User).filter(User.email == admin_email).first() + if not existing: + admin = User( + email=admin_email, + password_hash=get_password_hash(admin_password), + display_name="Administrator", + language="zh-TW", + role=UserRole.admin + ) + db.add(admin) + db.commit() + print(f"[Init] Admin user created: {admin_email}") + else: + print(f"[Init] Admin user exists: {admin_email}") + except Exception as e: + print(f"[Init] Error creating admin: {e}") + db.rollback() + finally: + db.close() diff --git a/backend/app/models/dit.py b/backend/app/models/dit.py new file mode 100644 index 0000000..ee9513b --- /dev/null +++ b/backend/app/models/dit.py @@ -0,0 +1,22 @@ +from sqlalchemy import Column, Integer, String, DateTime, Float, UniqueConstraint +from sqlalchemy.sql import func +from app.models import Base +from app.config import TABLE_PREFIX + +class DitRecord(Base): + __tablename__ = f"{TABLE_PREFIX}DIT_Records" + __table_args__ = ( + UniqueConstraint('op_id', 'pn', name='uix_dit_op_pn'), + ) + + id = Column(Integer, primary_key=True, index=True) + op_id = Column(String(255), index=True, nullable=False) # 移除 unique,因為同一 op_id 可有多個 pn + erp_account = Column(String(100), index=True) # AQ 欄 + customer = Column(String(255), nullable=False, index=True) + customer_normalized = Column(String(255), index=True) + pn = Column(String(100), nullable=False, index=True) + eau = Column(Integer, default=0) + stage = Column(String(50)) + date = Column(String(20)) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + updated_at = Column(DateTime(timezone=True), onupdate=func.now()) diff --git a/backend/app/models/match.py b/backend/app/models/match.py new file mode 100644 index 0000000..61ba558 --- /dev/null +++ b/backend/app/models/match.py @@ -0,0 +1,49 @@ +from sqlalchemy import Column, Integer, String, DateTime, Float, Enum, ForeignKey +from sqlalchemy.orm import relationship +from sqlalchemy.sql import func +from app.models import Base +from app.config import TABLE_PREFIX +import enum + +class TargetType(str, enum.Enum): + SAMPLE = "SAMPLE" + ORDER = "ORDER" + +class MatchStatus(str, enum.Enum): + pending = "pending" + accepted = "accepted" + rejected = "rejected" + auto_matched = "auto_matched" + +class ReviewAction(str, enum.Enum): + accept = "accept" + reject = "reject" + +class MatchResult(Base): + __tablename__ = f"{TABLE_PREFIX}Match_Results" + + id = Column(Integer, primary_key=True, index=True) + dit_id = Column(Integer, ForeignKey(f"{TABLE_PREFIX}DIT_Records.id"), nullable=False) + target_type = Column(Enum(TargetType), nullable=False) + target_id = Column(Integer, nullable=False) + score = Column(Float, nullable=False) + match_priority = Column(Integer, default=3) # 1: Oppy ID, 2: Account, 3: Name + match_source = Column(String(255)) # e.g., "Matched via Opportunity ID: OP12345" + reason = Column(String(255)) + status = Column(Enum(MatchStatus), default=MatchStatus.pending) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + updated_at = Column(DateTime(timezone=True), onupdate=func.now()) + + dit = relationship("DitRecord", backref="matches") + +class ReviewLog(Base): + __tablename__ = f"{TABLE_PREFIX}Review_Logs" + + id = Column(Integer, primary_key=True, index=True) + match_id = Column(Integer, ForeignKey(f"{TABLE_PREFIX}Match_Results.id"), nullable=False) + user_id = Column(Integer, ForeignKey(f"{TABLE_PREFIX}users.id"), nullable=False) + action = Column(Enum(ReviewAction), nullable=False) + timestamp = Column(DateTime(timezone=True), server_default=func.now()) + + match_result = relationship("MatchResult", backref="review_logs") + user = relationship("User", backref="review_logs") diff --git a/backend/app/models/order.py b/backend/app/models/order.py new file mode 100644 index 0000000..ddfe389 --- /dev/null +++ b/backend/app/models/order.py @@ -0,0 +1,20 @@ +from sqlalchemy import Column, Integer, String, DateTime, Float +from sqlalchemy.sql import func +from app.models import Base +from app.config import TABLE_PREFIX + +class OrderRecord(Base): + __tablename__ = f"{TABLE_PREFIX}Order_Records" + + id = Column(Integer, primary_key=True, index=True) + order_id = Column(String(50), index=True, nullable=False) # 移除 unique,訂單可能有多個項次 + order_no = Column(String(50), index=True) + cust_id = Column(String(100), index=True) + customer = Column(String(255), nullable=False, index=True) + customer_normalized = Column(String(255), index=True) + pn = Column(String(100), nullable=False, index=True) + qty = Column(Integer, default=0) + status = Column(String(50), default='Backlog') # 改為 String 以支援中文狀態 + amount = Column(Float, default=0.0) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + updated_at = Column(DateTime(timezone=True), onupdate=func.now()) diff --git a/backend/app/models/sample.py b/backend/app/models/sample.py new file mode 100644 index 0000000..87635a7 --- /dev/null +++ b/backend/app/models/sample.py @@ -0,0 +1,20 @@ +from sqlalchemy import Column, Integer, String, DateTime +from sqlalchemy.sql import func +from app.models import Base +from app.config import TABLE_PREFIX + +class SampleRecord(Base): + __tablename__ = f"{TABLE_PREFIX}Sample_Records" + + id = Column(Integer, primary_key=True, index=True) + sample_id = Column(String(50), unique=True, index=True, nullable=False) + order_no = Column(String(50), index=True) + oppy_no = Column(String(100), index=True) # AU 欄 + cust_id = Column(String(100), index=True) # G 欄 + customer = Column(String(255), nullable=False, index=True) + customer_normalized = Column(String(255), index=True) + pn = Column(String(100), nullable=False, index=True) + qty = Column(Integer, default=0) + date = Column(String(20)) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + updated_at = Column(DateTime(timezone=True), onupdate=func.now()) diff --git a/backend/app/models/user.py b/backend/app/models/user.py new file mode 100644 index 0000000..1fbd355 --- /dev/null +++ b/backend/app/models/user.py @@ -0,0 +1,23 @@ +from sqlalchemy import Column, Integer, String, DateTime, Enum +from sqlalchemy.sql import func +from app.models import Base +from app.config import TABLE_PREFIX +import enum + +class UserRole(str, enum.Enum): + admin = "admin" + user = "user" + +class User(Base): + __tablename__ = f"{TABLE_PREFIX}users" + + id = Column(Integer, primary_key=True, index=True) + email = Column(String(200), unique=True, index=True, nullable=False) + ad_username = Column(String(100), nullable=True) # Added to satisfy DB constraint + department = Column(String(100), nullable=True) # Added to satisfy DB constraint + password_hash = Column("local_password", String(255), nullable=True) + display_name = Column(String(100), nullable=True) + # language = Column(String(10), default="zh-TW") # Not in DB + role = Column(String(20), default="user") # Simplified from Enum + created_at = Column(DateTime(timezone=True), server_default=func.now()) + updated_at = Column(DateTime(timezone=True), onupdate=func.now()) diff --git a/backend/app/routers/__init__.py b/backend/app/routers/__init__.py new file mode 100644 index 0000000..873f7bb --- /dev/null +++ b/backend/app/routers/__init__.py @@ -0,0 +1 @@ +# Routers package diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py new file mode 100644 index 0000000..1335bb4 --- /dev/null +++ b/backend/app/routers/auth.py @@ -0,0 +1,84 @@ +from fastapi import APIRouter, Depends, HTTPException, status +from fastapi.security import OAuth2PasswordRequestForm +from sqlalchemy.orm import Session +from pydantic import BaseModel, EmailStr +from app.models import get_db +from app.models.user import User, UserRole +from app.utils.security import ( + get_password_hash, verify_password, + create_access_token, get_current_user +) + +router = APIRouter(prefix="/auth", tags=["Authentication"]) + +class UserCreate(BaseModel): + email: EmailStr + password: str + +class UserResponse(BaseModel): + id: int + email: str + role: str + + class Config: + from_attributes = True + +class TokenResponse(BaseModel): + access_token: str + token_type: str + user: UserResponse + +def get_role_value(role) -> str: + """取得 role 的字串值,相容 Enum 和字串""" + if hasattr(role, 'value'): + return role.value + return str(role) if role else 'user' + +@router.post("/register", response_model=UserResponse) +def register(user_data: UserCreate, db: Session = Depends(get_db)): + """註冊新使用者""" + existing_user = db.query(User).filter(User.email == user_data.email).first() + if existing_user: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Email already registered" + ) + + user = User( + email=user_data.email, + password_hash=get_password_hash(user_data.password), + role=UserRole.user + ) + db.add(user) + db.commit() + db.refresh(user) + + return UserResponse(id=user.id, email=user.email, role=get_role_value(user.role)) + +@router.post("/login", response_model=TokenResponse) +def login(form_data: OAuth2PasswordRequestForm = Depends(), db: Session = Depends(get_db)): + """登入取得 JWT Token""" + user = db.query(User).filter(User.email == form_data.username).first() + if not user or not verify_password(form_data.password, user.password_hash): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Incorrect email or password", + headers={"WWW-Authenticate": "Bearer"}, + ) + + access_token = create_access_token(data={"sub": str(user.id)}) + + return TokenResponse( + access_token=access_token, + token_type="bearer", + user=UserResponse(id=user.id, email=user.email, role=get_role_value(user.role)) + ) + +@router.get("/me", response_model=UserResponse) +def get_me(current_user: User = Depends(get_current_user)): + """取得當前使用者資訊""" + return UserResponse( + id=current_user.id, + email=current_user.email, + role=get_role_value(current_user.role) + ) diff --git a/backend/app/routers/dashboard.py b/backend/app/routers/dashboard.py new file mode 100644 index 0000000..b8b0815 --- /dev/null +++ b/backend/app/routers/dashboard.py @@ -0,0 +1,225 @@ +from typing import List +from fastapi import APIRouter, Depends +from sqlalchemy.orm import Session +from sqlalchemy import func +from pydantic import BaseModel +from app.models import get_db +from app.models.dit import DitRecord +from app.models.sample import SampleRecord +from app.models.order import OrderRecord +from app.models.match import MatchResult, MatchStatus, TargetType + +router = APIRouter(prefix="/dashboard", tags=["Dashboard"]) + +class KPIResponse(BaseModel): + total_dit: int + sample_rate: float # 送樣轉換率 + hit_rate: float # 訂單命中率 + fulfillment_rate: float # EAU 達成率 + orphan_sample_rate: float # 無效送樣率 + total_revenue: float + +class FunnelItem(BaseModel): + name: str + value: int + fill: str + +class AttributionDit(BaseModel): + op_id: str + customer: str + pn: str + eau: int + stage: str + date: str + +class AttributionSample(BaseModel): + order_no: str + date: str + +class AttributionOrder(BaseModel): + order_no: str + status: str + qty: int + amount: float + +class AttributionRow(BaseModel): + dit: AttributionDit + sample: AttributionSample | None + order: AttributionOrder | None + match_source: str | None + attributed_qty: int + fulfillment_rate: float + +def get_lifo_attribution(db: Session): + """執行 LIFO 業績分配邏輯""" + # 1. 取得所有 DIT,按日期由新到舊排序 (LIFO) + dits = db.query(DitRecord).order_by(DitRecord.date.desc()).all() + + # 2. 取得所有已匹配且接受的訂單 + matched_orders = db.query(MatchResult, OrderRecord).join( + OrderRecord, MatchResult.target_id == OrderRecord.id + ).filter( + MatchResult.target_type == TargetType.ORDER, + MatchResult.status.in_([MatchStatus.accepted, MatchStatus.auto_matched]) + ).all() + + # 3. 建立業績池 (Revenue Pool) - 按 (客戶, 料號) 分組 + order_pools = {} + for match, order in matched_orders: + key = (order.customer_normalized, order.pn) + if key not in order_pools: + order_pools[key] = 0 + order_pools[key] += (order.qty or 0) + + # 4. 進行分配 + attribution_map = {} # dit_id -> {qty, total_eau} + for dit in dits: + key = (dit.customer_normalized, dit.pn) + eau = dit.eau or 0 + allocated = 0 + + if key in order_pools and order_pools[key] > 0: + allocated = min(eau, order_pools[key]) + order_pools[key] -= allocated + + attribution_map[dit.id] = { + "qty": allocated, + "eau": eau + } + + return attribution_map + +@router.get("/kpi", response_model=KPIResponse) +def get_kpi(db: Session = Depends(get_db)): + """取得 KPI 統計 (符合規格書 v1.0)""" + total_dit = db.query(DitRecord).count() + if total_dit == 0: + return KPIResponse(total_dit=0, sample_rate=0, hit_rate=0, fulfillment_rate=0, orphan_sample_rate=0, total_revenue=0) + + # 1. 送樣轉換率 (Sample Rate): (有匹配到樣品的 DIT 數) / (總 DIT 數) + dits_with_sample = db.query(func.count(func.distinct(MatchResult.dit_id))).filter( + MatchResult.target_type == TargetType.SAMPLE, + MatchResult.status.in_([MatchStatus.accepted, MatchStatus.auto_matched]) + ).scalar() or 0 + sample_rate = (dits_with_sample / total_dit * 100) + + # 2. 訂單命中率 (Hit Rate): (有匹配到訂單的 DIT 數) / (總 DIT 數) + dits_with_order = db.query(func.count(func.distinct(MatchResult.dit_id))).filter( + MatchResult.target_type == TargetType.ORDER, + MatchResult.status.in_([MatchStatus.accepted, MatchStatus.auto_matched]) + ).scalar() or 0 + hit_rate = (dits_with_order / total_dit * 100) + + # 3. EAU 達成率 (Fulfillment Rate): (歸因之訂單總量) / (DIT 預估 EAU) + attribution_map = get_lifo_attribution(db) + total_attributed_qty = sum(item['qty'] for item in attribution_map.values()) + total_eau = sum(item['eau'] for item in attribution_map.values()) + fulfillment_rate = (total_attributed_qty / total_eau * 100) if total_eau > 0 else 0 + + # 4. 無效送樣率 (Orphan Sample Rate): (未匹配到 DIT 的送樣數) / (總送樣數) + total_samples = db.query(SampleRecord).count() + matched_sample_ids = db.query(func.distinct(MatchResult.target_id)).filter( + MatchResult.target_type == TargetType.SAMPLE + ).all() + matched_sample_count = len(matched_sample_ids) + orphan_sample_rate = ((total_samples - matched_sample_count) / total_samples * 100) if total_samples > 0 else 0 + + # 5. 總營收 + total_revenue = db.query(func.sum(OrderRecord.amount)).join( + MatchResult, MatchResult.target_id == OrderRecord.id + ).filter( + MatchResult.target_type == TargetType.ORDER, + MatchResult.status.in_([MatchStatus.accepted, MatchStatus.auto_matched]) + ).scalar() or 0 + + return KPIResponse( + total_dit=total_dit, + sample_rate=round(sample_rate, 1), + hit_rate=round(hit_rate, 1), + fulfillment_rate=round(fulfillment_rate, 1), + orphan_sample_rate=round(orphan_sample_rate, 1), + total_revenue=total_revenue + ) + +@router.get("/funnel", response_model=List[FunnelItem]) +def get_funnel(db: Session = Depends(get_db)): + """取得漏斗數據""" + total_dit = db.query(DitRecord).count() + + dits_with_sample = db.query(func.count(func.distinct(MatchResult.dit_id))).filter( + MatchResult.target_type == TargetType.SAMPLE, + MatchResult.status.in_([MatchStatus.accepted, MatchStatus.auto_matched]) + ).scalar() or 0 + + dits_with_order = db.query(func.count(func.distinct(MatchResult.dit_id))).filter( + MatchResult.target_type == TargetType.ORDER, + MatchResult.status.in_([MatchStatus.accepted, MatchStatus.auto_matched]) + ).scalar() or 0 + + return [ + FunnelItem(name='DIT 案件', value=total_dit, fill='#6366f1'), + FunnelItem(name='成功送樣', value=dits_with_sample, fill='#8b5cf6'), + FunnelItem(name='取得訂單', value=dits_with_order, fill='#10b981'), + ] + +@router.get("/attribution", response_model=List[AttributionRow]) +def get_attribution(db: Session = Depends(get_db)): + """取得歸因明細 (含 LIFO 分配與追溯資訊)""" + dit_records = db.query(DitRecord).order_by(DitRecord.date.desc()).all() + attribution_map = get_lifo_attribution(db) + result = [] + + for dit in dit_records: + # 找到樣品匹配 (取分數最高的一個) + sample_match = db.query(MatchResult).filter( + MatchResult.dit_id == dit.id, + MatchResult.target_type == TargetType.SAMPLE, + MatchResult.status.in_([MatchStatus.accepted, MatchStatus.auto_matched]) + ).order_by(MatchResult.score.desc()).first() + + sample_info = None + if sample_match: + sample = db.query(SampleRecord).filter(SampleRecord.id == sample_match.target_id).first() + if sample: + sample_info = AttributionSample(order_no=sample.order_no, date=sample.date or '') + + # 找到訂單匹配 (取分數最高的一個) + order_match = db.query(MatchResult).filter( + MatchResult.dit_id == dit.id, + MatchResult.target_type == TargetType.ORDER, + MatchResult.status.in_([MatchStatus.accepted, MatchStatus.auto_matched]) + ).order_by(MatchResult.score.desc()).first() + + order_info = None + match_source = None + if order_match: + order = db.query(OrderRecord).filter(OrderRecord.id == order_match.target_id).first() + if order: + order_info = AttributionOrder( + order_no=order.order_no, + status=order.status or 'Unknown', + qty=order.qty or 0, + amount=order.amount or 0 + ) + match_source = order_match.match_source + + attr_data = attribution_map.get(dit.id, {"qty": 0, "eau": dit.eau or 0}) + fulfillment = (attr_data['qty'] / attr_data['eau'] * 100) if attr_data['eau'] > 0 else 0 + + result.append(AttributionRow( + dit=AttributionDit( + op_id=dit.op_id, + customer=dit.customer, + pn=dit.pn, + eau=dit.eau, + stage=dit.stage or '', + date=dit.date or '' + ), + sample=sample_info, + order=order_info, + match_source=match_source, + attributed_qty=attr_data['qty'], + fulfillment_rate=round(fulfillment, 1) + )) + + return result diff --git a/backend/app/routers/etl.py b/backend/app/routers/etl.py new file mode 100644 index 0000000..1d60940 --- /dev/null +++ b/backend/app/routers/etl.py @@ -0,0 +1,246 @@ +import shutil +from pathlib import Path +from typing import List +import pandas as pd +from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form +from sqlalchemy.orm import Session +from pydantic import BaseModel +from app.models import get_db +from app.models.dit import DitRecord +from app.models.sample import SampleRecord +from app.models.order import OrderRecord +from app.models.match import MatchResult, TargetType, ReviewLog +from app.config import UPLOAD_DIR +from app.services.excel_parser import excel_parser +from app.services.fuzzy_matcher import normalize_customer_name, sanitize_pn + +router = APIRouter(prefix="/etl", tags=["ETL"]) + +class ParsedFileResponse(BaseModel): + file_id: str + file_type: str + filename: str + header_row: int + row_count: int + preview: List[dict] + +class ImportRequest(BaseModel): + file_id: str + +class ImportResponse(BaseModel): + imported_count: int + +@router.post("/upload", response_model=ParsedFileResponse) +async def upload_file( + file: UploadFile = File(...), + file_type: str = Form(...), + db: Session = Depends(get_db) +): + """上傳並解析 Excel 檔案""" + if file_type not in ['dit', 'sample', 'order']: + raise HTTPException(status_code=400, detail="Invalid file type") + + # 儲存檔案 + file_path = UPLOAD_DIR / file.filename + with open(file_path, "wb") as buffer: + shutil.copyfileobj(file.file, buffer) + + try: + # 解析檔案 + file_id, file_info = excel_parser.parse_file(file_path, file_type) + + return ParsedFileResponse( + file_id=file_id, + file_type=file_info['file_type'], + filename=file_info['filename'], + header_row=file_info['header_row'], + row_count=file_info['row_count'], + preview=file_info['preview'] + ) + except Exception as e: + raise HTTPException(status_code=400, detail=f"Failed to parse file: {str(e)}") + +@router.get("/preview/{file_id}", response_model=ParsedFileResponse) +def get_preview(file_id: str): + """取得檔案預覽""" + file_info = excel_parser.get_file_info(file_id) + if not file_info: + raise HTTPException(status_code=404, detail="File not found") + + return ParsedFileResponse( + file_id=file_info['file_id'], + file_type=file_info['file_type'], + filename=file_info['filename'], + header_row=file_info['header_row'], + row_count=file_info['row_count'], + preview=file_info['preview'] + ) + +def clean_value(val, default=''): + """清理欄位值,處理 nan 和空值""" + if val is None or (isinstance(val, float) and pd.isna(val)): + return default + str_val = str(val).strip() + if str_val.lower() in ('nan', 'none', 'null', ''): + return default + return str_val + + +@router.post("/import", response_model=ImportResponse) +def import_data(request: ImportRequest, db: Session = Depends(get_db)): + """匯入資料到資料庫""" + import traceback + + try: + file_info = excel_parser.get_file_info(request.file_id) + if not file_info: + print(f"[ETL Import] Error: File not found for file_id={request.file_id}") + raise HTTPException(status_code=404, detail="File not found") + + df = excel_parser.get_parsed_data(request.file_id) + if df is None: + print(f"[ETL Import] Error: Parsed data not found for file_id={request.file_id}") + raise HTTPException(status_code=404, detail="Parsed data not found") + + print(f"[ETL Import] Starting import: file_type={file_info['file_type']}, rows={len(df)}") + + file_type = file_info['file_type'] + imported_count = 0 + seen_ids = set() # 追蹤已處理的 ID,避免檔案內重複 + + # 清除該類型的舊資料,避免重複鍵衝突 + try: + if file_type == 'dit': + print("[ETL Import] Clearing old DIT records and dependent matches/logs...") + # 先清除與 DIT 相關的審核日誌與比對結果 + db.query(ReviewLog).delete() + db.query(MatchResult).delete() + db.query(DitRecord).delete() + elif file_type == 'sample': + print("[ETL Import] Clearing old Sample records and dependent matches/logs...") + # 先清除與 Sample 相關的比對結果 (及其日誌) + # 這裡比較複雜,因為 ReviewLog 是透過 MatchResult 關聯的 + # 但既然我們是清空整個類別,直接清空所有 ReviewLog 和對應的 MatchResult 是最安全的 + db.query(ReviewLog).delete() + db.query(MatchResult).filter(MatchResult.target_type == TargetType.SAMPLE).delete() + db.query(SampleRecord).delete() + elif file_type == 'order': + print("[ETL Import] Clearing old Order records and dependent matches/logs...") + db.query(ReviewLog).delete() + db.query(MatchResult).filter(MatchResult.target_type == TargetType.ORDER).delete() + db.query(OrderRecord).delete() + db.flush() # 使用 flush 而非 commit,保持在同一個事務中 + print("[ETL Import] Old data cleared successfully.") + except Exception as e: + db.rollback() + print(f"[ETL Import] Error clearing old data: {traceback.format_exc()}") + raise HTTPException(status_code=500, detail=f"Failed to clear old data: {str(e)}") + + for idx, row in df.iterrows(): + try: + if file_type == 'dit': + op_id = clean_value(row.get('op_id'), '') + erp_account = clean_value(row.get('erp_account'), '') + customer = clean_value(row.get('customer')) + pn = clean_value(row.get('pn')) + # 跳過無效資料列或重複的 op_id + pn 組合 + unique_key = f"{op_id}|{pn}" + if not op_id or unique_key in seen_ids: + continue + seen_ids.add(unique_key) + record = DitRecord( + op_id=op_id, + erp_account=erp_account, + customer=customer, + customer_normalized=normalize_customer_name(customer), + pn=sanitize_pn(pn), + eau=int(row.get('eau', 0)) if row.get('eau') and not pd.isna(row.get('eau')) else 0, + stage=clean_value(row.get('stage')), + date=clean_value(row.get('date')) + ) + elif file_type == 'sample': + sample_id = clean_value(row.get('sample_id'), f'S{idx}') + oppy_no = clean_value(row.get('oppy_no'), '') + cust_id = clean_value(row.get('cust_id'), '') + customer = clean_value(row.get('customer')) + pn = clean_value(row.get('pn')) + # 跳過重複的 sample_id + if sample_id in seen_ids: + continue + seen_ids.add(sample_id) + record = SampleRecord( + sample_id=sample_id, + order_no=clean_value(row.get('order_no')), + oppy_no=oppy_no, + cust_id=cust_id, + customer=customer, + customer_normalized=normalize_customer_name(customer), + pn=sanitize_pn(pn), + qty=int(row.get('qty', 0)) if row.get('qty') and not pd.isna(row.get('qty')) else 0, + date=clean_value(row.get('date')) + ) + elif file_type == 'order': + order_id = clean_value(row.get('order_id'), f'O{idx}') + cust_id = clean_value(row.get('cust_id'), '') + customer = clean_value(row.get('customer')) + pn = clean_value(row.get('pn')) + # 跳過重複的 order_id + if order_id in seen_ids: + continue + seen_ids.add(order_id) + record = OrderRecord( + order_id=order_id, + order_no=clean_value(row.get('order_no')), + cust_id=cust_id, + customer=customer, + customer_normalized=normalize_customer_name(customer), + pn=sanitize_pn(pn), + qty=int(row.get('qty', 0)) if row.get('qty') and not pd.isna(row.get('qty')) else 0, + status=clean_value(row.get('status'), 'Backlog'), + amount=float(row.get('amount', 0)) if row.get('amount') and not pd.isna(row.get('amount')) else 0 + ) + else: + continue + + db.add(record) + imported_count += 1 + if imported_count % 500 == 0: + print(f"[ETL Import] Processed {imported_count} rows...") + except Exception as e: + print(f"[ETL Import] Error importing row {idx}: {e}") + continue + + try: + print(f"[ETL Import] Committing {imported_count} records...") + db.commit() + print(f"[ETL Import] Import successful: {imported_count} records.") + except Exception as e: + db.rollback() + print(f"[ETL Import] Commit Error: {traceback.format_exc()}") + raise HTTPException(status_code=500, detail=f"Failed to commit data: {str(e)}") + + return ImportResponse(imported_count=imported_count) + except HTTPException: + raise + except Exception as e: + print(f"[ETL Import] Unhandled Exception: {traceback.format_exc()}") + raise HTTPException(status_code=500, detail=f"Internal Server Error: {str(e)}") + +@router.get("/data/{data_type}") +def get_data(data_type: str, db: Session = Depends(get_db)): + """取得已匯入的資料""" + if data_type == 'dit': + records = db.query(DitRecord).all() + elif data_type == 'sample': + records = db.query(SampleRecord).all() + elif data_type == 'order': + records = db.query(OrderRecord).all() + else: + raise HTTPException(status_code=400, detail="Invalid data type") + + return [ + { + **{c.name: getattr(record, c.name) for c in record.__table__.columns} + } + for record in records + ] diff --git a/backend/app/routers/lab.py b/backend/app/routers/lab.py new file mode 100644 index 0000000..ee90459 --- /dev/null +++ b/backend/app/routers/lab.py @@ -0,0 +1,181 @@ +from typing import List, Optional +from datetime import datetime, timedelta +from fastapi import APIRouter, Depends, Query +from sqlalchemy.orm import Session +from sqlalchemy import func, and_ +from pydantic import BaseModel +from app.models import get_db +from app.models.sample import SampleRecord +from app.models.order import OrderRecord + +router = APIRouter(prefix="/lab", tags=["Lab"]) + +class LabKPI(BaseModel): + avg_velocity: float # 平均轉換時間 (天) + conversion_rate: float # 轉換比例 (%) + orphan_count: int # 孤兒樣品總數 + +class ScatterPoint(BaseModel): + customer: str + pn: str + sample_qty: int + order_qty: int + +class OrphanSample(BaseModel): + customer: str + pn: str + days_since_sent: int + order_no: str + date: str + +def parse_date(date_str: str) -> Optional[datetime]: + try: + return datetime.strptime(date_str, "%Y-%m-%d") + except: + return None + +@router.get("/kpi", response_model=LabKPI) +def get_lab_kpi( + start_date: Optional[str] = Query(None), + end_date: Optional[str] = Query(None), + db: Session = Depends(get_db) +): + # 1. 取得所有樣品與訂單 + samples_query = db.query(SampleRecord) + orders_query = db.query(OrderRecord) + + if start_date: + samples_query = samples_query.filter(SampleRecord.date >= start_date) + orders_query = orders_query.filter(OrderRecord.created_at >= start_date) # 訂單使用 created_at or date? OrderRecord 只有 created_at 欄位是 DateTime + + if end_date: + samples_query = samples_query.filter(SampleRecord.date <= end_date) + # Note: OrderRecord 只有 created_at + + samples = samples_query.all() + orders = orders_query.all() + + # 建立群組 (ERP Code + PN) + # ERP Code correspond to cust_id + sample_groups = {} + for s in samples: + key = (s.cust_id, s.pn) + if key not in sample_groups: + sample_groups[key] = [] + sample_groups[key].append(s) + + order_groups = {} + for o in orders: + key = (o.cust_id, o.pn) + if key not in order_groups: + order_groups[key] = [] + order_groups[key].append(o) + + # 計算 Velocity 與 轉換率 + velocities = [] + converted_samples_count = 0 + total_samples_count = len(samples) + + for key, group_samples in sample_groups.items(): + if key in order_groups: + # 轉換成功 + converted_samples_count += len(group_samples) + + # 計算 Velocity: First Order Date - Earliest Sample Date + earliest_sample_date = min([parse_date(s.date) for s in group_samples if s.date] or [datetime.max]) + first_order_date = min([o.created_at for o in order_groups[key] if o.created_at] or [datetime.max]) + + if earliest_sample_date != datetime.max and first_order_date != datetime.max: + diff = (first_order_date - earliest_sample_date).days + if diff >= 0: + velocities.append(diff) + + avg_velocity = sum(velocities) / len(velocities) if velocities else 0 + conversion_rate = (converted_samples_count / total_samples_count * 100) if total_samples_count > 0 else 0 + + # 孤兒樣品: > 90天且無訂單 + now = datetime.now() + orphan_count = 0 + for key, group_samples in sample_groups.items(): + if key not in order_groups: + for s in group_samples: + s_date = parse_date(s.date) + if s_date and (now - s_date).days > 90: + orphan_count += 1 + + return LabKPI( + avg_velocity=round(avg_velocity, 1), + conversion_rate=round(conversion_rate, 1), + orphan_count=orphan_count + ) + +@router.get("/scatter", response_model=List[ScatterPoint]) +def get_scatter_data( + start_date: Optional[str] = Query(None), + end_date: Optional[str] = Query(None), + db: Session = Depends(get_db) +): + samples_query = db.query(SampleRecord) + orders_query = db.query(OrderRecord) + + if start_date: + samples_query = samples_query.filter(SampleRecord.date >= start_date) + if end_date: + samples_query = samples_query.filter(SampleRecord.date <= end_date) + + samples = samples_query.all() + orders = orders_query.all() + + # 聚合資料 + data_map = {} # (cust_id, pn) -> {sample_qty, order_qty, customer_name} + + for s in samples: + key = (s.cust_id, s.pn) + if key not in data_map: + data_map[key] = {"sample_qty": 0, "order_qty": 0, "customer": s.customer} + data_map[key]["sample_qty"] += (s.qty or 0) + + for o in orders: + key = (o.cust_id, o.pn) + if key in data_map: + data_map[key]["order_qty"] += (o.qty or 0) + # 如果有訂單但沒樣品,我們在 ROI 分析中可能不顯示,或者顯示在 Y 軸上 X=0。 + # 根據需求:分析「樣品寄送」與「訂單接收」的關聯,通常以有送樣的為基底。 + + return [ + ScatterPoint( + customer=v["customer"], + pn=key[1], + sample_qty=v["sample_qty"], + order_qty=v["order_qty"] + ) + for key, v in data_map.items() + ] + +@router.get("/orphans", response_model=List[OrphanSample]) +def get_orphans(db: Session = Depends(get_db)): + now = datetime.now() + threshold_date = now - timedelta(days=90) + + # 找出所有樣品 + samples = db.query(SampleRecord).all() + + # 找出有訂單的人 (cust_id, pn) + orders_keys = set(db.query(OrderRecord.cust_id, OrderRecord.pn).distinct().all()) + + orphans = [] + for s in samples: + key = (s.cust_id, s.pn) + s_date = parse_date(s.date) + + if key not in orders_keys: + if s_date and s_date < threshold_date: + orphans.append(OrphanSample( + customer=s.customer, + pn=s.pn, + days_since_sent=(now - s_date).days, + order_no=s.order_no, + date=s.date + )) + + return sorted(orphans, key=lambda x: x.days_since_sent, reverse=True) diff --git a/backend/app/routers/match.py b/backend/app/routers/match.py new file mode 100644 index 0000000..951fda9 --- /dev/null +++ b/backend/app/routers/match.py @@ -0,0 +1,171 @@ +from typing import List, Optional +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.orm import Session +from pydantic import BaseModel +from app.models import get_db +from app.models.dit import DitRecord +from app.models.sample import SampleRecord +from app.models.order import OrderRecord +from app.models.match import MatchResult, MatchStatus, TargetType +from app.services.fuzzy_matcher import FuzzyMatcher + +router = APIRouter(prefix="/match", tags=["Matching"]) + +class MatchRunResponse(BaseModel): + match_count: int + auto_matched: int + pending_review: int + +class DitInfo(BaseModel): + id: int + op_id: str + customer: str + pn: str + eau: int + stage: Optional[str] + + class Config: + from_attributes = True + +class TargetInfo(BaseModel): + id: int + customer: str + pn: str + order_no: Optional[str] + qty: Optional[int] + +class MatchResultResponse(BaseModel): + id: int + dit_id: int + target_type: str + target_id: int + score: float + reason: str + status: str + dit: Optional[DitInfo] + target: Optional[TargetInfo] + + class Config: + from_attributes = True + +class ReviewRequest(BaseModel): + action: str # 'accept' or 'reject' + +@router.post("/run", response_model=MatchRunResponse) +def run_matching(db: Session = Depends(get_db)): + """執行模糊比對""" + matcher = FuzzyMatcher(db) + result = matcher.run_matching() + return MatchRunResponse(**result) + +@router.get("/results", response_model=List[MatchResultResponse]) +def get_results(db: Session = Depends(get_db)): + """取得所有比對結果""" + matches = db.query(MatchResult).all() + + results = [] + for match in matches: + # 取得 DIT 資訊 + dit = db.query(DitRecord).filter(DitRecord.id == match.dit_id).first() + dit_info = DitInfo( + id=dit.id, + op_id=dit.op_id, + customer=dit.customer, + pn=dit.pn, + eau=dit.eau, + stage=dit.stage + ) if dit else None + + # 取得目標資訊 + target_info = None + if match.target_type == TargetType.SAMPLE: + sample = db.query(SampleRecord).filter(SampleRecord.id == match.target_id).first() + if sample: + target_info = TargetInfo( + id=sample.id, + customer=sample.customer, + pn=sample.pn, + order_no=sample.order_no, + qty=sample.qty + ) + elif match.target_type == TargetType.ORDER: + order = db.query(OrderRecord).filter(OrderRecord.id == match.target_id).first() + if order: + target_info = TargetInfo( + id=order.id, + customer=order.customer, + pn=order.pn, + order_no=order.order_no, + qty=order.qty + ) + + results.append(MatchResultResponse( + id=match.id, + dit_id=match.dit_id, + target_type=match.target_type.value, + target_id=match.target_id, + score=match.score, + reason=match.reason, + status=match.status.value, + dit=dit_info, + target=target_info + )) + + return results + +@router.put("/{match_id}/review", response_model=MatchResultResponse) +def review_match(match_id: int, request: ReviewRequest, db: Session = Depends(get_db)): + """審核比對結果""" + if request.action not in ['accept', 'reject']: + raise HTTPException(status_code=400, detail="Invalid action") + + matcher = FuzzyMatcher(db) + match = matcher.review_match(match_id, request.action) + + if not match: + raise HTTPException(status_code=404, detail="Match not found") + + # 取得相關資訊 + dit = db.query(DitRecord).filter(DitRecord.id == match.dit_id).first() + dit_info = DitInfo( + id=dit.id, + op_id=dit.op_id, + customer=dit.customer, + pn=dit.pn, + eau=dit.eau, + stage=dit.stage + ) if dit else None + + target_info = None + if match.target_type == TargetType.SAMPLE: + sample = db.query(SampleRecord).filter(SampleRecord.id == match.target_id).first() + if sample: + target_info = TargetInfo( + id=sample.id, + customer=sample.customer, + pn=sample.pn, + order_no=sample.order_no, + qty=sample.qty + ) + elif match.target_type == TargetType.ORDER: + order = db.query(OrderRecord).filter(OrderRecord.id == match.target_id).first() + if order: + target_info = TargetInfo( + id=order.id, + customer=order.customer, + pn=order.pn, + order_no=order.order_no, + qty=order.qty + ) + + return MatchResultResponse( + id=match.id, + dit_id=match.dit_id, + target_type=match.target_type.value, + target_id=match.target_id, + score=match.score, + reason=match.reason, + status=match.status.value, + dit=dit_info, + target=target_info + ) diff --git a/backend/app/routers/report.py b/backend/app/routers/report.py new file mode 100644 index 0000000..5f1f669 --- /dev/null +++ b/backend/app/routers/report.py @@ -0,0 +1,32 @@ +from fastapi import APIRouter, Depends, HTTPException +from fastapi.responses import StreamingResponse +from sqlalchemy.orm import Session +from app.models import get_db +from app.services.report_generator import ReportGenerator + +router = APIRouter(prefix="/report", tags=["Report"]) + +@router.get("/export") +def export_report(format: str = "xlsx", db: Session = Depends(get_db)): + """匯出報表""" + if format not in ['xlsx', 'pdf']: + raise HTTPException(status_code=400, detail="Invalid format. Use 'xlsx' or 'pdf'") + + generator = ReportGenerator(db) + + if format == 'xlsx': + output = generator.generate_excel() + media_type = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" + filename = "dit_attribution_report.xlsx" + else: + output = generator.generate_pdf() + media_type = "application/pdf" + filename = "dit_attribution_report.pdf" + + return StreamingResponse( + output, + media_type=media_type, + headers={ + "Content-Disposition": f"attachment; filename={filename}" + } + ) diff --git a/backend/app/services/__init__.py b/backend/app/services/__init__.py new file mode 100644 index 0000000..a70b302 --- /dev/null +++ b/backend/app/services/__init__.py @@ -0,0 +1 @@ +# Services package diff --git a/backend/app/services/excel_parser.py b/backend/app/services/excel_parser.py new file mode 100644 index 0000000..4091e53 --- /dev/null +++ b/backend/app/services/excel_parser.py @@ -0,0 +1,175 @@ +import re +import uuid +import math +from pathlib import Path +from typing import List, Dict, Any, Optional, Tuple +import pandas as pd +import chardet +from openpyxl import load_workbook +from app.config import MAX_HEADER_SCAN_ROWS, UPLOAD_DIR + + +def clean_value(val): + """清理單一值,將 NaN/Inf 轉換為 None 以便 JSON 序列化""" + if val is None: + return None + if isinstance(val, float): + if math.isnan(val) or math.isinf(val): + return None + return val + + +def clean_dict(d: Dict) -> Dict: + """清理字典中的所有 NaN/Inf 值""" + return {k: clean_value(v) for k, v in d.items()} + + +def clean_records(records: List[Dict]) -> List[Dict]: + """清理記錄列表中的所有 NaN/Inf 值""" + return [clean_dict(r) for r in records] + +# 欄位名稱對應表 +COLUMN_MAPPING = { + 'dit': { + 'op_id': ['opportunity name', 'opportunity no', 'opportunity', 'op編號', 'op 編號', 'op_id', 'opid', '案件編號', '案號', 'opportunity id'], + 'erp_account': ['erp account', 'account no', 'erp account no', '客戶代碼', '客戶編號', 'erp_account'], + 'customer': ['account name', 'branding customer', '客戶', '客戶名稱', 'customer', 'customer name', '公司名稱'], + 'pn': ['product name', '料號', 'part number', 'pn', 'part no', 'part_number', '產品料號', 'stage/part'], + 'eau': ['eau quantity', 'eau quantity (pcs)', 'eau', '年預估量', 'annual usage', '預估用量'], + 'stage': ['stage', 'oppty product stage', '階段', 'status', '狀態', '專案階段'], + 'date': ['created date', '日期', 'date', '建立日期', 'create date'] + }, + 'sample': { + 'sample_id': ['樣品訂單號碼', 'item', '樣品編號', 'sample_id', 'sample id', '編號'], + 'order_no': ['樣品訂單號碼', '單號', 'order_no', 'order no', '樣品單號', '申請單號'], + 'oppy_no': ['oppy no', 'oppy_no', '案號', '案件編號', 'opportunity no'], + 'cust_id': ['cust id', 'cust_id', '客戶編號', '客戶代碼', '客戶代號'], + 'customer': ['客戶名稱', '客戶簡稱', '客戶', 'customer', 'customer name'], + 'pn': ['item', 'type', '料號', 'part number', 'pn', 'part no', '產品料號', '索樣數量'], + 'qty': ['索樣數量pcs', '索樣數量 k', '數量', 'qty', 'quantity', '申請數量'], + 'date': ['需求日', '日期', 'date', '申請日期'] + }, + 'order': { + 'order_id': ['項次', '訂單編號', 'order_id', 'order id'], + 'order_no': ['訂單單號', '訂單號', 'order_no', 'order no', '銷貨單號'], + 'cust_id': ['客戶編號', '客戶代碼', '客戶代號', 'cust_id', 'cust id'], + 'customer': ['客戶', '客戶名稱', 'customer', 'customer name'], + 'pn': ['type', '內部料號', '料號', 'part number', 'pn', 'part no', '產品料號'], + 'qty': ['訂單量', '數量', 'qty', 'quantity', '訂購數量', '出貨數量'], + 'status': ['狀態', 'status', '訂單狀態'], + 'amount': ['原幣金額(含稅)', '台幣金額(未稅)', '金額', 'amount', 'total', '訂單金額'] + } +} + +class ExcelParser: + def __init__(self): + self.parsed_files: Dict[str, Dict] = {} + + def detect_encoding(self, file_path: Path) -> str: + """偵測檔案編碼""" + with open(file_path, 'rb') as f: + result = chardet.detect(f.read(10000)) + return result.get('encoding', 'utf-8') + + def find_header_row(self, df: pd.DataFrame, file_type: str) -> int: + """自動偵測表頭位置""" + expected_columns = set() + for variants in COLUMN_MAPPING[file_type].values(): + expected_columns.update([v.lower() for v in variants]) + + for idx in range(min(MAX_HEADER_SCAN_ROWS, len(df))): + row = df.iloc[idx] + row_values = [str(v).lower().strip() for v in row.values if pd.notna(v)] + + # 檢查是否有匹配的欄位名稱 + matches = sum(1 for v in row_values if any(exp in v for exp in expected_columns)) + if matches >= 2: # 至少匹配 2 個欄位 + return idx + + return 0 # 預設第一行為表頭 + + def map_columns(self, df: pd.DataFrame, file_type: str) -> Dict[str, str]: + """將 DataFrame 欄位對應到標準欄位名稱""" + mapping = {} + column_map = COLUMN_MAPPING[file_type] + + df_columns = [str(c).lower().strip() for c in df.columns] + + for standard_name, variants in column_map.items(): + for variant in variants: + variant_lower = variant.lower() + for idx, col in enumerate(df_columns): + if variant_lower in col or col in variant_lower: + mapping[df.columns[idx]] = standard_name + break + if standard_name in mapping.values(): + break + + return mapping + + def parse_file(self, file_path: Path, file_type: str) -> Tuple[str, Dict[str, Any]]: + """解析 Excel/CSV 檔案""" + file_id = str(uuid.uuid4()) + + # 讀取檔案 + if file_path.suffix.lower() == '.csv': + encoding = self.detect_encoding(file_path) + df = pd.read_csv(file_path, encoding=encoding, header=None) + else: + df = pd.read_excel(file_path, header=None) + + # 找到表頭 + header_row = self.find_header_row(df, file_type) + + # 重新讀取,以正確的表頭 + if file_path.suffix.lower() == '.csv': + df = pd.read_csv(file_path, encoding=encoding, header=header_row) + else: + df = pd.read_excel(file_path, header=header_row) + + # 欄位對應 + column_mapping = self.map_columns(df, file_type) + df = df.rename(columns=column_mapping) + + # 只保留需要的欄位 + required_columns = list(COLUMN_MAPPING[file_type].keys()) + available_columns = [c for c in required_columns if c in df.columns] + df = df[available_columns] + + # 清理資料 + df = df.dropna(how='all') + + # 產生預覽資料(清理 NaN 值以便 JSON 序列化) + preview = clean_records(df.head(10).to_dict(orient='records')) + + # 儲存解析結果 + parsed_data = { + 'file_id': file_id, + 'file_type': file_type, + 'filename': file_path.name, + 'header_row': header_row, + 'row_count': len(df), + 'columns': list(df.columns), + 'preview': preview, + 'dataframe': df + } + + self.parsed_files[file_id] = parsed_data + + return file_id, {k: v for k, v in parsed_data.items() if k != 'dataframe'} + + def get_parsed_data(self, file_id: str) -> Optional[pd.DataFrame]: + """取得解析後的 DataFrame""" + if file_id in self.parsed_files: + return self.parsed_files[file_id].get('dataframe') + return None + + def get_file_info(self, file_id: str) -> Optional[Dict]: + """取得檔案資訊""" + if file_id in self.parsed_files: + data = self.parsed_files[file_id] + return {k: v for k, v in data.items() if k != 'dataframe'} + return None + +# 全域實例 +excel_parser = ExcelParser() diff --git a/backend/app/services/fuzzy_matcher.py b/backend/app/services/fuzzy_matcher.py new file mode 100644 index 0000000..2bca38c --- /dev/null +++ b/backend/app/services/fuzzy_matcher.py @@ -0,0 +1,277 @@ +import re +from typing import List, Tuple, Optional +from rapidfuzz import fuzz, process +from sqlalchemy.orm import Session +from app.config import MATCH_THRESHOLD_AUTO, MATCH_THRESHOLD_REVIEW +from app.models.dit import DitRecord +from app.models.sample import SampleRecord +from app.models.order import OrderRecord +from app.models.match import MatchResult, MatchStatus, TargetType, ReviewLog +import pandas as pd +from datetime import timedelta + +# 公司後綴清單(用於正規化) +COMPANY_SUFFIXES = [ + '股份有限公司', '有限公司', '公司', + '株式会社', '株式會社', + 'Co., Ltd.', 'Co.,Ltd.', 'Co. Ltd.', 'Co.Ltd.', + 'Corporation', 'Corp.', 'Corp', + 'Inc.', 'Inc', + 'Limited', 'Ltd.', 'Ltd', + 'LLC', 'L.L.C.', +] + +def sanitize_pn(pn: str) -> str: + """去除非字母數字字元並轉大寫 (PMSM-808-LL -> PMSM808LL)""" + if not pn: + return "" + return re.sub(r'[^a-zA-Z0-9]', '', str(pn)).upper() + +def normalize_customer_name(name: str) -> str: + """正規化客戶名稱 (轉大寫)""" + if not name: + return "" + + # 轉換為大寫 + normalized = name.strip() + + # 移除公司後綴 + for suffix in COMPANY_SUFFIXES: + normalized = re.sub(re.escape(suffix), '', normalized, flags=re.IGNORECASE) + + # 移除括號及其內容 + normalized = re.sub(r'\([^)]*\)', '', normalized) + normalized = re.sub(r'([^)]*)', '', normalized) + + # 全形轉半形 + normalized = normalized.replace(' ', ' ') + + # 移除多餘空白 + normalized = re.sub(r'\s+', ' ', normalized).strip() + + return normalized.upper() + +def calculate_similarity(name1: str, name2: str) -> Tuple[float, str]: + """計算兩個名稱的相似度""" + # 正規化 + norm1 = normalize_customer_name(name1) + norm2 = normalize_customer_name(name2) + + if not norm1 or not norm2: + return 0.0, "Empty name" + + # 完全匹配 + if norm1 == norm2: + return 100.0, "Exact Match" + + # 使用多種比對方法 + ratio = fuzz.ratio(norm1, norm2) + partial_ratio = fuzz.partial_ratio(norm1, norm2) + token_sort_ratio = fuzz.token_sort_ratio(norm1, norm2) + token_set_ratio = fuzz.token_set_ratio(norm1, norm2) + + # 取最高分 + best_score = max(ratio, partial_ratio, token_sort_ratio, token_set_ratio) + + # 決定原因 + if ratio == best_score: + reason = "Character Similarity" + elif partial_ratio == best_score: + reason = "Partial Match" + elif token_sort_ratio == best_score: + reason = "Token Order Match" + else: + reason = "Token Set Match" + + # 檢查是否為後綴差異 + if best_score >= 80: + for suffix in COMPANY_SUFFIXES[:3]: # 只檢查常見後綴 + if (suffix in name1 and suffix not in name2) or \ + (suffix not in name1 and suffix in name2): + reason = "Corporate Suffix Mismatch" + break + + return best_score, reason + +class FuzzyMatcher: + def __init__(self, db: Session): + self.db = db + + def run_matching(self) -> dict: + """執行瀑布式模糊比對 (Waterfall Matching)""" + + # 1. 取得所有 DIT 記錄 + dit_records = self.db.query(DitRecord).all() + + # 2. 取得所有樣品和訂單記錄並按 PN 分組 + sample_records = self.db.query(SampleRecord).all() + order_records = self.db.query(OrderRecord).all() + + samples_by_pn = {} + samples_by_oppy = {} + for s in sample_records: + if s.pn: + if s.pn not in samples_by_pn: + samples_by_pn[s.pn] = [] + samples_by_pn[s.pn].append(s) + if s.oppy_no: + if s.oppy_no not in samples_by_oppy: + samples_by_oppy[s.oppy_no] = [] + samples_by_oppy[s.oppy_no].append(s) + + orders_by_pn = {} + for o in order_records: + if o.pn not in orders_by_pn: + orders_by_pn[o.pn] = [] + orders_by_pn[o.pn].append(o) + + # 3. 清除舊的比對結果 + self.db.query(ReviewLog).delete() + self.db.query(MatchResult).delete() + + match_count = 0 + auto_matched = 0 + pending_review = 0 + + for dit in dit_records: + dit_date = pd.to_datetime(dit.date, errors='coerce') + + # --- 比對樣品 (DIT -> Sample) --- + # 收集所有可能的樣品 (Priority 1: Oppy ID, Priority 2/3: PN) + potential_samples = [] + if dit.op_id: + potential_samples.extend(samples_by_oppy.get(dit.op_id, [])) + if dit.pn: + potential_samples.extend(samples_by_pn.get(dit.pn, [])) + + # 去重 + seen_sample_ids = set() + unique_potential_samples = [] + for s in potential_samples: + if s.id not in seen_sample_ids: + seen_sample_ids.add(s.id) + unique_potential_samples.append(s) + + for sample in unique_potential_samples: + sample_date = pd.to_datetime(sample.date, errors='coerce') + + # 時間窗檢查: Sample Date 必須在 DIT Date 的 前 30 天 至 今日 之間 + if pd.notna(dit_date) and pd.notna(sample_date): + if sample_date < (dit_date - timedelta(days=30)): + continue + + match_priority = 0 + match_source = "" + score = 0.0 + reason = "" + + # Priority 1: 案號精準比對 (Golden Key) + if dit.op_id and sample.oppy_no and dit.op_id == sample.oppy_no: + match_priority = 1 + match_source = f"Matched via Opportunity ID: {dit.op_id}" + score = 100.0 + reason = "Golden Key Match" + + # Priority 2 & 3 則限制在相同 PN + elif dit.pn == sample.pn: + # Priority 2: 客戶代碼比對 (Silver Key) + if dit.erp_account and sample.cust_id and dit.erp_account == sample.cust_id: + match_priority = 2 + match_source = f"Matched via ERP Account: {dit.erp_account}" + score = 99.0 + reason = "Silver Key Match" + + # Priority 3: 名稱模糊比對 (Fallback) + else: + score, reason = calculate_similarity(dit.customer, sample.customer) + if score >= MATCH_THRESHOLD_REVIEW: + match_priority = 3 + match_source = f"Matched via Name Similarity ({reason})" + + if match_priority > 0: + status = MatchStatus.auto_matched if score >= MATCH_THRESHOLD_AUTO else MatchStatus.pending + match = MatchResult( + dit_id=dit.id, + target_type=TargetType.SAMPLE, + target_id=sample.id, + score=score, + match_priority=match_priority, + match_source=match_source, + reason=reason, + status=status + ) + self.db.add(match) + match_count += 1 + if status == MatchStatus.auto_matched: + auto_matched += 1 + else: + pending_review += 1 + + # --- 比對訂單 (DIT -> Order) --- + # 訂單比對通常基於 PN + for order in orders_by_pn.get(dit.pn, []): + match_priority = 0 + match_source = "" + score = 0.0 + reason = "" + + # Priority 2: 客戶代碼比對 (Silver Key) + if dit.erp_account and order.cust_id and dit.erp_account == order.cust_id: + match_priority = 2 + match_source = f"Matched via ERP Account: {dit.erp_account}" + score = 99.0 + reason = "Silver Key Match" + + # Priority 3: 名稱模糊比對 (Fallback) + else: + score, reason = calculate_similarity(dit.customer, order.customer) + if score >= MATCH_THRESHOLD_REVIEW: + match_priority = 3 + match_source = f"Matched via Name Similarity ({reason})" + + if match_priority > 0: + status = MatchStatus.auto_matched if score >= MATCH_THRESHOLD_AUTO else MatchStatus.pending + match = MatchResult( + dit_id=dit.id, + target_type=TargetType.ORDER, + target_id=order.id, + score=score, + match_priority=match_priority, + match_source=match_source, + reason=reason, + status=status + ) + self.db.add(match) + match_count += 1 + if status == MatchStatus.auto_matched: + auto_matched += 1 + else: + pending_review += 1 + + self.db.commit() + + return { + 'match_count': match_count, + 'auto_matched': auto_matched, + 'pending_review': pending_review + } + + def get_pending_reviews(self) -> List[MatchResult]: + """取得待審核的比對結果""" + return self.db.query(MatchResult).filter( + MatchResult.status == MatchStatus.pending + ).all() + + def review_match(self, match_id: int, action: str) -> Optional[MatchResult]: + """審核比對結果""" + match = self.db.query(MatchResult).filter(MatchResult.id == match_id).first() + if not match: + return None + + if action == 'accept': + match.status = MatchStatus.accepted + elif action == 'reject': + match.status = MatchStatus.rejected + + self.db.commit() + return match diff --git a/backend/app/services/report_generator.py b/backend/app/services/report_generator.py new file mode 100644 index 0000000..1b8cf47 --- /dev/null +++ b/backend/app/services/report_generator.py @@ -0,0 +1,171 @@ +import io +from typing import List, Dict, Any +from datetime import datetime +from openpyxl import Workbook +from openpyxl.styles import Font, Alignment, PatternFill, Border, Side +from reportlab.lib import colors +from reportlab.lib.pagesizes import A4, landscape +from reportlab.platypus import SimpleDocTemplate, Table, TableStyle, Paragraph, Spacer +from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle +from reportlab.pdfbase import pdfmetrics +from reportlab.pdfbase.ttfonts import TTFont +from sqlalchemy.orm import Session +from app.models.dit import DitRecord +from app.models.sample import SampleRecord +from app.models.order import OrderRecord +from app.models.match import MatchResult, MatchStatus + +class ReportGenerator: + def __init__(self, db: Session): + self.db = db + + def get_attribution_data(self) -> List[Dict[str, Any]]: + """取得歸因明細資料""" + dit_records = self.db.query(DitRecord).all() + result = [] + + for dit in dit_records: + row = { + 'op_id': dit.op_id, + 'customer': dit.customer, + 'pn': dit.pn, + 'eau': dit.eau, + 'stage': dit.stage, + 'sample_order': None, + 'order_no': None, + 'order_status': None, + 'order_amount': None + } + + # 找到已接受的樣品匹配 + sample_match = self.db.query(MatchResult).filter( + MatchResult.dit_id == dit.id, + MatchResult.target_type == 'SAMPLE', + MatchResult.status.in_([MatchStatus.accepted, MatchStatus.auto_matched]) + ).first() + + if sample_match: + sample = self.db.query(SampleRecord).filter( + SampleRecord.id == sample_match.target_id + ).first() + if sample: + row['sample_order'] = sample.order_no + + # 找到已接受的訂單匹配 + order_match = self.db.query(MatchResult).filter( + MatchResult.dit_id == dit.id, + MatchResult.target_type == 'ORDER', + MatchResult.status.in_([MatchStatus.accepted, MatchStatus.auto_matched]) + ).first() + + if order_match: + order = self.db.query(OrderRecord).filter( + OrderRecord.id == order_match.target_id + ).first() + if order: + row['order_no'] = order.order_no + row['order_status'] = order.status.value if order.status else None + row['order_amount'] = order.amount + + result.append(row) + + return result + + def generate_excel(self) -> io.BytesIO: + """產生 Excel 報表""" + wb = Workbook() + ws = wb.active + ws.title = "DIT Attribution Report" + + # 標題樣式 + header_font = Font(bold=True, color="FFFFFF") + header_fill = PatternFill(start_color="4F46E5", end_color="4F46E5", fill_type="solid") + header_alignment = Alignment(horizontal="center", vertical="center") + + # 表頭 + headers = ['OP編號', '客戶名稱', '料號', 'EAU', '階段', '樣品單號', '訂單單號', '訂單狀態', '訂單金額'] + for col, header in enumerate(headers, 1): + cell = ws.cell(row=1, column=col, value=header) + cell.font = header_font + cell.fill = header_fill + cell.alignment = header_alignment + + # 資料 + data = self.get_attribution_data() + for row_idx, row_data in enumerate(data, 2): + ws.cell(row=row_idx, column=1, value=row_data['op_id']) + ws.cell(row=row_idx, column=2, value=row_data['customer']) + ws.cell(row=row_idx, column=3, value=row_data['pn']) + ws.cell(row=row_idx, column=4, value=row_data['eau']) + ws.cell(row=row_idx, column=5, value=row_data['stage']) + ws.cell(row=row_idx, column=6, value=row_data['sample_order'] or '-') + ws.cell(row=row_idx, column=7, value=row_data['order_no'] or '-') + ws.cell(row=row_idx, column=8, value=row_data['order_status'] or '-') + ws.cell(row=row_idx, column=9, value=row_data['order_amount'] or 0) + + # 調整欄寬 + column_widths = [15, 30, 20, 12, 15, 15, 15, 12, 12] + for col, width in enumerate(column_widths, 1): + ws.column_dimensions[chr(64 + col)].width = width + + # 儲存到 BytesIO + output = io.BytesIO() + wb.save(output) + output.seek(0) + return output + + def generate_pdf(self) -> io.BytesIO: + """產生 PDF 報表""" + output = io.BytesIO() + doc = SimpleDocTemplate(output, pagesize=landscape(A4)) + elements = [] + + styles = getSampleStyleSheet() + + # 標題 + title = Paragraph("DIT Attribution Report", styles['Title']) + elements.append(title) + elements.append(Spacer(1, 20)) + + # 日期 + date_text = Paragraph(f"Generated: {datetime.now().strftime('%Y-%m-%d %H:%M')}", styles['Normal']) + elements.append(date_text) + elements.append(Spacer(1, 20)) + + # 表格資料 + data = self.get_attribution_data() + + table_data = [['OP No.', 'Customer', 'P/N', 'EAU', 'Stage', 'Sample', 'Order', 'Status', 'Amount']] + + for row in data: + table_data.append([ + row['op_id'], + row['customer'][:20] + '...' if len(row['customer']) > 20 else row['customer'], + row['pn'], + str(row['eau']), + row['stage'] or '-', + row['sample_order'] or '-', + row['order_no'] or '-', + row['order_status'] or '-', + f"${row['order_amount']:,.0f}" if row['order_amount'] else '-' + ]) + + # 建立表格 + table = Table(table_data) + table.setStyle(TableStyle([ + ('BACKGROUND', (0, 0), (-1, 0), colors.HexColor('#4F46E5')), + ('TEXTCOLOR', (0, 0), (-1, 0), colors.whitesmoke), + ('ALIGN', (0, 0), (-1, -1), 'CENTER'), + ('FONTSIZE', (0, 0), (-1, 0), 10), + ('FONTSIZE', (0, 1), (-1, -1), 8), + ('BOTTOMPADDING', (0, 0), (-1, 0), 12), + ('BACKGROUND', (0, 1), (-1, -1), colors.beige), + ('GRID', (0, 0), (-1, -1), 1, colors.black), + ('ROWBACKGROUNDS', (0, 1), (-1, -1), [colors.white, colors.HexColor('#F8FAFC')]), + ])) + + elements.append(table) + + doc.build(elements) + output.seek(0) + return output diff --git a/backend/app/utils/__init__.py b/backend/app/utils/__init__.py new file mode 100644 index 0000000..dd7ee44 --- /dev/null +++ b/backend/app/utils/__init__.py @@ -0,0 +1 @@ +# Utils package diff --git a/backend/app/utils/security.py b/backend/app/utils/security.py new file mode 100644 index 0000000..5c1a798 --- /dev/null +++ b/backend/app/utils/security.py @@ -0,0 +1,64 @@ +from datetime import datetime, timedelta +from typing import Optional +from jose import JWTError, jwt +from passlib.context import CryptContext +from fastapi import Depends, HTTPException, status +from fastapi.security import OAuth2PasswordBearer +from sqlalchemy.orm import Session +from app.config import SECRET_KEY, ALGORITHM, ACCESS_TOKEN_EXPIRE_MINUTES +from app.models import get_db +from app.models.user import User + +pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") +oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/login") + +def verify_password(plain_password: str, hashed_password: str) -> bool: + return pwd_context.verify(plain_password, hashed_password) + +def get_password_hash(password: str) -> str: + return pwd_context.hash(password) + +def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str: + to_encode = data.copy() + if expires_delta: + expire = datetime.utcnow() + expires_delta + else: + expire = datetime.utcnow() + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES) + to_encode.update({"exp": expire}) + encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM) + return encoded_jwt + +def decode_token(token: str) -> Optional[dict]: + try: + payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM]) + return payload + except JWTError: + return None + +async def get_current_user( + token: str = Depends(oauth2_scheme), + db: Session = Depends(get_db) +) -> User: + credentials_exception = HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Could not validate credentials", + headers={"WWW-Authenticate": "Bearer"}, + ) + + payload = decode_token(token) + if payload is None: + raise credentials_exception + + user_id_raw = payload.get("sub") + if user_id_raw is None: + raise credentials_exception + try: + user_id = int(user_id_raw) + except (ValueError, TypeError): + raise credentials_exception + + user = db.query(User).filter(User.id == user_id).first() + if user is None: + raise credentials_exception + + return user diff --git a/backend/create_admin.py b/backend/create_admin.py new file mode 100644 index 0000000..3d9edce --- /dev/null +++ b/backend/create_admin.py @@ -0,0 +1,34 @@ +from app.models import init_db, SessionLocal +from app.models.user import User, UserRole +from app.utils.security import get_password_hash + +def create_admin_user(): + init_db() + db = SessionLocal() + + email = "admin@example.com" + password = "admin" + + # Check if user exists + user = db.query(User).filter(User.email == email).first() + if user: + print(f"User {email} already exists.") + return + + # Create new admin user + new_user = User( + email=email, + password_hash=get_password_hash(password), + role="admin", # String type now + display_name="Administrator", + ad_username="admin_local", + department="IT" + ) + + db.add(new_user) + db.commit() + print(f"Admin user created successfully.\nEmail: {email}\nPassword: {password}") + db.close() + +if __name__ == "__main__": + create_admin_user() diff --git a/backend/drop_tables.py b/backend/drop_tables.py new file mode 100644 index 0000000..c5e3bf7 --- /dev/null +++ b/backend/drop_tables.py @@ -0,0 +1,17 @@ +from sqlalchemy import create_engine, text +from app.config import DATABASE_URL, TABLE_PREFIX + +def drop_user_table(): + engine = create_engine(DATABASE_URL) + table_name = f"{TABLE_PREFIX}Users" + lower_table_name = f"{TABLE_PREFIX}users" + + with engine.connect() as conn: + # Try dropping both case variants to be sure + conn.execute(text(f"DROP TABLE IF EXISTS {table_name}")) + conn.execute(text(f"DROP TABLE IF EXISTS {lower_table_name}")) + conn.commit() + print(f"Dropped table {table_name} (and lowercase variant if existed).") + +if __name__ == "__main__": + drop_user_table() diff --git a/backend/inspect_db.py b/backend/inspect_db.py new file mode 100644 index 0000000..2b9abc4 --- /dev/null +++ b/backend/inspect_db.py @@ -0,0 +1,25 @@ +from sqlalchemy import create_engine, inspect +from app.config import DATABASE_URL, TABLE_PREFIX + +def inspect_schema(): + engine = create_engine(DATABASE_URL) + inspector = inspect(engine) + tables = [ + f"{TABLE_PREFIX}DIT_Records", + f"{TABLE_PREFIX}Sample_Records", + f"{TABLE_PREFIX}Order_Records", + f"{TABLE_PREFIX}Match_Results" + ] + + print("All tables:", inspector.get_table_names()) + for table_name in tables: + if table_name in inspector.get_table_names(): + print(f"\nTable {table_name} exists. Columns:") + columns = inspector.get_columns(table_name) + for column in columns: + print(f"- {column['name']} ({column['type']})") + else: + print(f"\nTable {table_name} does not exist.") + +if __name__ == "__main__": + inspect_schema() diff --git a/backend/read_spec.py b/backend/read_spec.py new file mode 100644 index 0000000..b704e55 --- /dev/null +++ b/backend/read_spec.py @@ -0,0 +1,27 @@ +import docx +import sys + +def read_docx(file_path): + doc = docx.Document(file_path) + content = [] + + # Iterate through all elements in the document in order + for element in doc.element.body: + if element.tag.endswith('p'): # Paragraph + para = docx.text.paragraph.Paragraph(element, doc) + if para.text.strip(): + content.append(para.text) + elif element.tag.endswith('tbl'): # Table + table = docx.table.Table(element, doc) + for row in table.rows: + row_text = [cell.text.strip() for cell in row.cells] + content.append(" | ".join(row_text)) + + return '\n'.join(content) + +if __name__ == "__main__": + path = r"c:\Users\USER\Desktop\SampleOrderAssistant\data\業務資料比對與轉換率分析系統 - 邏輯規格書 (v1.0).docx" + content = read_docx(path) + with open("spec_content.txt", "w", encoding="utf-8") as f: + f.write(content) + print("Content written to spec_content.txt") diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..1c3d79e --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,16 @@ +fastapi>=0.115.0 +uvicorn[standard]==0.24.0 +sqlalchemy==2.0.23 +python-multipart==0.0.6 +python-jose[cryptography]==3.3.0 +passlib[bcrypt]==1.7.4 +openpyxl==3.1.2 +pandas==2.1.3 +rapidfuzz==3.5.2 +reportlab==4.0.7 +chardet==5.2.0 +opencc-python-reimplemented==0.1.7 +pymysql==1.1.2 +cryptography==41.0.7 +python-dotenv==1.0.0 +email-validator==2.3.0 diff --git a/backend/run.py b/backend/run.py new file mode 100644 index 0000000..2120d65 --- /dev/null +++ b/backend/run.py @@ -0,0 +1,30 @@ +#!/usr/bin/env python3 +""" +SalesPipeline 應用程式啟動腳本 +用於開發與生產環境 +""" +import sys +import os + +# 確保可以匯入 app 模組 +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +import uvicorn +from app.config import APP_HOST, APP_PORT, WORKERS, DEBUG + +def main(): + """啟動應用程式""" + print(f"Starting SalesPipeline on {APP_HOST}:{APP_PORT}") + print(f"Workers: {WORKERS}, Debug: {DEBUG}") + + uvicorn.run( + "app.main:app", + host=APP_HOST, + port=APP_PORT, + workers=WORKERS if not DEBUG else 1, + reload=DEBUG, + access_log=True, + ) + +if __name__ == "__main__": + main() diff --git a/backend/schema_check.txt b/backend/schema_check.txt new file mode 100644 index 0000000..337f8d8 --- /dev/null +++ b/backend/schema_check.txt @@ -0,0 +1,55 @@ +All tables: ['dit_records', 'match_results', 'order_records', 'pj_abc', 'PJ_SOA_DIT_Records', 'PJ_SOA_Match_Results', 'PJ_SOA_Order_Records', 'PJ_SOA_orders', 'PJ_SOA_Review_Logs', 'PJ_SOA_Sample_Records', 'PJ_SOA_samples', 'PJ_SOA_users', 'review_logs', 'sample_records', 'users'] + +Table PJ_SOA_DIT_Records exists. Columns: +- id (INTEGER) +- op_id (VARCHAR(255)) +- customer (VARCHAR(255)) +- customer_normalized (VARCHAR(255)) +- pn (VARCHAR(100)) +- eau (INTEGER) +- stage (VARCHAR(50)) +- date (VARCHAR(20)) +- created_at (DATETIME) +- updated_at (DATETIME) +- erp_account (VARCHAR(100)) + +Table PJ_SOA_Sample_Records exists. Columns: +- id (INTEGER) +- sample_id (VARCHAR(50)) +- order_no (VARCHAR(50)) +- customer (VARCHAR(255)) +- customer_normalized (VARCHAR(255)) +- pn (VARCHAR(100)) +- qty (INTEGER) +- date (VARCHAR(20)) +- created_at (DATETIME) +- updated_at (DATETIME) +- oppy_no (VARCHAR(100)) +- cust_id (VARCHAR(100)) + +Table PJ_SOA_Order_Records exists. Columns: +- id (INTEGER) +- order_id (VARCHAR(50)) +- order_no (VARCHAR(50)) +- customer (VARCHAR(255)) +- customer_normalized (VARCHAR(255)) +- pn (VARCHAR(100)) +- qty (INTEGER) +- status (VARCHAR(50)) +- amount (FLOAT) +- created_at (DATETIME) +- updated_at (DATETIME) +- cust_id (VARCHAR(100)) + +Table PJ_SOA_Match_Results exists. Columns: +- id (INTEGER) +- dit_id (INTEGER) +- target_type (ENUM) +- target_id (INTEGER) +- score (FLOAT) +- reason (VARCHAR(255)) +- status (ENUM) +- created_at (DATETIME) +- updated_at (DATETIME) +- match_priority (INTEGER) +- match_source (VARCHAR(255)) diff --git a/backend/spec_content.txt b/backend/spec_content.txt new file mode 100644 index 0000000..5e50789 --- /dev/null +++ b/backend/spec_content.txt @@ -0,0 +1,63 @@ +業務資料比對與轉換率分析系統 - 邏輯規格書 (v1.0) +文件日期:2026-01-09 +適用範圍:半導體製造業銷售漏斗分析 (Sales Pipeline Analysis) +部署環境:On-Premise (地端) +1. 資料源與 ETL 前處理策略 (Data Ingestion) +系統針對三份異質資料來源進行標準化清洗,具備「動態表頭偵測」能力以適應 ERP 匯出的非結構化報表。 +1.1 資料來源定義 +資料類型 | 檔案特徵 | 關鍵欄位識別 (Key Columns) | 處理邏輯 +DIT Report | 含 Metadata (前 ~15 行) | R欄 (Opportunity ID), AQ欄 (ERP Account), Customer, Part No, EAU, Stage | 自動跳過 Metadata,定位至 "Stage/Part" 所在行作為表頭。 +樣品紀錄 | 含 Metadata (前 ~8 行) | AU欄 (Oppy No), G欄 (Cust ID), Customer, Part No, Qty | 自動跳過 Metadata,定位至 "索樣數量" 所在行。 +訂單明細 | 標準格式 (第 1 行) | Order No, Customer, Part No, Qty, Status (Backlog/Shipped) | 識別 Big5/CP950 編碼,標準化讀取。 +1.2 資料清洗規則 (Sanitization) +Part Number (PN): 去除所有分隔符 (-, _, ),統一轉大寫。例:PMSM-808-LL ➔ PMSM808LL。 +Customer Name: 移除法律實體後綴 (如 "Inc.", "Co., Ltd"),全形轉半形,統一轉大寫。 +日期格式: 統一轉換為 YYYY-MM-DD,無效日期視為 Null。 +2. 核心比對引擎 (Matching Engine) - 瀑布式邏輯 +為解決客戶名稱不一致(如別名、子公司)問題,系統採用 三層級瀑布式比對 (Waterfall Matching)。優先級由高至低,一旦上層匹配成功,即鎖定關聯,不再向下尋找。 +優先級 1:案號精準比對 (Golden Key) 🥇 +邏輯:直接透過 CRM/ERP 系統生成的唯一案號進行勾稽。 +對應欄位: +DIT Report: R 欄 (Opportunity ID / 案號) +Sample Log: AU 欄 (Oppy No) +信心水準:100% (絕對準確) +適用情境:業務在申請樣品時已正確填寫案號。 +優先級 2:客戶代碼比對 (Silver Key) 🥈 +邏輯:若無案號,則比對 ERP 客戶代碼 (Account Number)。 +對應欄位: +DIT Report: AQ 欄 (ERP Account No) +Sample Log: G 欄 (客戶編號) +信心水準:99% (解決同名異字問題,如 "Liteon" vs "光寶")。 +限制:需同時滿足 Account Match AND Normalized Part Number Match。 +優先級 3:名稱模糊比對 (Fallback Mechanism) 🥉 +邏輯:前兩者皆空值時,使用 Levenshtein Distance 演算法計算名稱相似度。 +對應欄位:Customer Name vs 客戶名稱 +信心水準:80% ~ 90% (不確定性高) +處理機制:系統標記為 Pending Review,強制進入 Human-in-the-Loop (人工審核) 流程,需人工確認後才計入績效。 +註:訂單 (Order) 資料通常無 Oppy ID,故訂單比對主要依賴 Priority 2 (Account + PN) 與 Priority 3 (Name + PN)。 +3. 歸因與時間窗邏輯 (Attribution & Time Window) +定義「何時」發生的送樣與訂單可以算在該 DIT 的績效上。 +3.1 時間窗 (Time Window) +DIT → Sample: +Sample Date 必須在 DIT Date 的 前 30 天 (容許先跑流程後補單) 至 今日 之間。 +DIT → Order: +Order Date 必須在 DIT Date (或 First Sample Date) 的 前 30 天 之後。 +目的:排除 DIT 建立很久之前的舊訂單(那些屬於舊案子維護,非新開發案)。 +3.2 多對多歸因法則 (LIFO Logic) +針對「同一客戶、同一料號」有多筆 DIT 的情況,採用 LIFO (Last-In-First-Out) 庫存扣抵法 進行業績分配: +將同料號的 DIT 按建立日期 由新到舊 排序。 +將該料號的總訂單量 (Backlog + Shipped) 放入「業績池 (Revenue Pool)」。 +優先滿足 最新 的 DIT EAU 額度。 +若有剩餘業績,再分配給次新的 DIT,依此類推。 +目的:確保業績優先反映在最新的開發專案上,避免舊案子無限期佔用新訂單的功勞。 +4. 關鍵績效指標定義 (KPI Definitions) +系統最終產出的量化指標,用於衡量業務轉換效率。 +指標名稱 | 計算公式 | 業務意涵 +送樣轉換率 (Sample Rate) | (有匹配到樣品的 DIT 數) / (總 DIT 數) | 衡量前端 Design-In 開案後,成功推進到送樣階段的能力。 +訂單命中率 (Hit Rate) | (有匹配到訂單的 DIT 數) / (總 DIT 數) | 衡量開發案最終轉化為實際營收的成功率 (Binary)。 +EAU 達成率 (Fulfillment Rate) | (歸因之訂單總量) / (DIT 預估 EAU) | 衡量客戶預估量 (Forecast) 的準確度與實際拉貨力道。 +無效送樣率 (Orphan Sample) | (未匹配到 DIT 的送樣數) / (總送樣數) | 監控是否有「偷跑」或「未立案」即送樣的資源浪費行為。 +5. 系統輸出與審核 +DIT 歸因明細表:每一列 DIT 清楚標示匹配到的 Sample No 與 Order No。 +可追溯性 (Traceability):滑鼠懸停 (Hover) 可顯示匹配邏輯來源 (如 "Matched via Opportunity ID: OP12345")。 +人工介入:對於模糊比對的案件,提供 UI 介面供使用者點選 Accept / Reject,並將結果回寫至資料庫,作為日後自動比對的訓練樣本。 \ No newline at end of file diff --git a/backend/test_etl.py b/backend/test_etl.py new file mode 100644 index 0000000..0a0d6bf --- /dev/null +++ b/backend/test_etl.py @@ -0,0 +1,68 @@ +import sys +import os +from pathlib import Path +import pandas as pd + +# Add backend to path +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +from app.models import SessionLocal, init_db +from app.routers.etl import import_data, ImportRequest +from app.services.excel_parser import excel_parser + +def test_import(): + db = SessionLocal() + try: + # 1. Upload/Parse DIT + base_dir = Path(__file__).resolve().parent.parent + dit_path = base_dir / "data" / "uploads" / "DIT report (by Focus Item)_app_樣本 2.xlsx" + print(f"Parsing {dit_path}...") + file_id, info = excel_parser.parse_file(dit_path, "dit") + print(f"File ID: {file_id}, Rows: {info['row_count']}") + + # 2. Import DIT + print("Importing DIT...") + req = ImportRequest(file_id=file_id) + res = import_data(req, db) + print(f"Imported {res.imported_count} DIT records.") + + # 3. Upload/Parse Sample + sample_path = base_dir / "data" / "uploads" / "樣品申請紀錄_樣本_9月.xlsx" + print(f"Parsing {sample_path}...") + file_id_s, info_s = excel_parser.parse_file(sample_path, "sample") + print(f"File ID: {file_id_s}, Rows: {info_s['row_count']}") + + # 4. Import Sample + print("Importing Sample...") + req_s = ImportRequest(file_id=file_id_s) + res_s = import_data(req_s, db) + print(f"Imported {res_s.imported_count} Sample records.") + + # 5. Upload/Parse Order + order_path = base_dir / "data" / "uploads" / "訂單樣本_20251217_調整.xlsx" + print(f"Parsing {order_path}...") + file_id_o, info_o = excel_parser.parse_file(order_path, "order") + print(f"File ID: {file_id_o}, Rows: {info_o['row_count']}") + + # 6. Import Order + print("Importing Order...") + req_o = ImportRequest(file_id=file_id_o) + res_o = import_data(req_o, db) + print(f"Imported {res_o.imported_count} Order records.") + + # 7. Run Matching + from app.services.fuzzy_matcher import FuzzyMatcher + print("Running Matching...") + matcher = FuzzyMatcher(db) + match_res = matcher.run_matching() + print(f"Matching completed: {match_res}") + + except Exception as e: + import traceback + print("Error during test:") + print(traceback.format_exc()) + finally: + db.close() + +if __name__ == "__main__": + test_import() diff --git a/backend/update_db.py b/backend/update_db.py new file mode 100644 index 0000000..366a9fc --- /dev/null +++ b/backend/update_db.py @@ -0,0 +1,53 @@ +from sqlalchemy import create_engine, text +from app.config import DATABASE_URL, TABLE_PREFIX + +def update_schema(): + engine = create_engine(DATABASE_URL) + with engine.connect() as conn: + print("Updating schema...") + + # Add erp_account to DitRecord + try: + conn.execute(text(f"ALTER TABLE {TABLE_PREFIX}DIT_Records ADD COLUMN erp_account VARCHAR(100)")) + print("Added erp_account to DIT_Records") + except Exception as e: + print(f"erp_account might already exist: {e}") + + # Add oppy_no and cust_id to SampleRecord + try: + conn.execute(text(f"ALTER TABLE {TABLE_PREFIX}Sample_Records ADD COLUMN oppy_no VARCHAR(100)")) + print("Added oppy_no to Sample_Records") + except Exception as e: + print(f"oppy_no might already exist: {e}") + + try: + conn.execute(text(f"ALTER TABLE {TABLE_PREFIX}Sample_Records ADD COLUMN cust_id VARCHAR(100)")) + print("Added cust_id to Sample_Records") + except Exception as e: + print(f"cust_id might already exist: {e}") + + # Add cust_id to OrderRecord + try: + conn.execute(text(f"ALTER TABLE {TABLE_PREFIX}Order_Records ADD COLUMN cust_id VARCHAR(100)")) + print("Added cust_id to Order_Records") + except Exception as e: + print(f"cust_id might already exist: {e}") + + # Add match_priority and match_source to MatchResult + try: + conn.execute(text(f"ALTER TABLE {TABLE_PREFIX}Match_Results ADD COLUMN match_priority INTEGER DEFAULT 3")) + print("Added match_priority to Match_Results") + except Exception as e: + print(f"match_priority might already exist: {e}") + + try: + conn.execute(text(f"ALTER TABLE {TABLE_PREFIX}Match_Results ADD COLUMN match_source VARCHAR(255)")) + print("Added match_source to Match_Results") + except Exception as e: + print(f"match_source might already exist: {e}") + + conn.commit() + print("Schema update completed.") + +if __name__ == "__main__": + update_schema() diff --git a/data/uploads/.gitkeep b/data/uploads/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/data/業務資料比對與轉換率分析系統 - 邏輯規格書 (v1.0).docx b/data/業務資料比對與轉換率分析系統 - 邏輯規格書 (v1.0).docx new file mode 100644 index 0000000..9e4bc3f Binary files /dev/null and b/data/業務資料比對與轉換率分析系統 - 邏輯規格書 (v1.0).docx differ diff --git a/deploy/1panel-setup.md b/deploy/1panel-setup.md new file mode 100644 index 0000000..a372f03 --- /dev/null +++ b/deploy/1panel-setup.md @@ -0,0 +1,229 @@ +# 1Panel 部署指南 - SalesPipeline + +## 系統需求 +- Python 3.10+ +- Node.js 18+ (僅用於建置前端) +- MySQL 5.7+ / 8.0+ + +--- + +## 部署步驟 + +### 1. 上傳專案 +將專案上傳至伺服器,例如:`/opt/salespipeline` + +```bash +# 建立專案目錄 +mkdir -p /opt/salespipeline +cd /opt/salespipeline + +# 上傳或 clone 專案 +# git clone . +``` + +### 2. 設定 Python 虛擬環境 + +```bash +cd /opt/salespipeline/backend + +# 建立虛擬環境 +python3 -m venv venv + +# 啟動虛擬環境 +source venv/bin/activate + +# 安裝依賴 +pip install -r requirements.txt +``` + +### 3. 設定環境變數 + +```bash +# 複製範本 +cp .env.example .env + +# 編輯環境變數 +nano .env +``` + +編輯 `.env` 檔案: +```env +# Database Configuration +DB_HOST=your_mysql_host +DB_PORT=3306 +DB_USER=your_user +DB_PASSWORD=your_password +DB_DATABASE=your_database + +# JWT Configuration +SECRET_KEY=<更換為強密碼> +ALGORITHM=HS256 +ACCESS_TOKEN_EXPIRE_MINUTES=1440 + +# Application Settings +APP_HOST=0.0.0.0 +APP_PORT=8000 +WORKERS=2 +DEBUG=False + +# CORS Settings (開發時使用) +CORS_ORIGINS=http://localhost:3000,http://127.0.0.1:3000 +``` + +### 4. 建置前端 + +```bash +cd /opt/salespipeline/frontend + +# 安裝依賴 +npm install + +# 建置 (輸出至 backend/static) +npm run build +``` + +### 5. 初始化資料庫 +資料庫表會在應用程式首次啟動時自動建立。 + +### 6. 設定 Systemd 服務 + +建立服務檔案 `/etc/systemd/system/salespipeline.service`: + +```ini +[Unit] +Description=SalesPipeline Application +After=network.target + +[Service] +Type=simple +User=www-data +Group=www-data +WorkingDirectory=/opt/salespipeline/backend +Environment="PATH=/opt/salespipeline/backend/venv/bin" +EnvironmentFile=/opt/salespipeline/backend/.env +ExecStart=/opt/salespipeline/backend/venv/bin/python run.py +Restart=always +RestartSec=10 + +[Install] +WantedBy=multi-user.target +``` + +啟動服務: +```bash +# 重新載入 systemd +sudo systemctl daemon-reload + +# 啟動服務 +sudo systemctl start salespipeline + +# 設定開機自啟 +sudo systemctl enable salespipeline + +# 查看狀態 +sudo systemctl status salespipeline + +# 查看日誌 +sudo journalctl -u salespipeline -f +``` + +--- + +## 1Panel 設定 + +### 方案 A:直接使用 Python 網站 (推薦) + +1. 進入 1Panel 控制台 +2. 網站 → 建立網站 → Python 專案 +3. 設定: + - 專案路徑:`/opt/salespipeline/backend` + - Python 版本:3.10 + - 啟動命令:`/opt/salespipeline/backend/venv/bin/python run.py` + - Port:依 `.env` 中的 `APP_PORT` 設定 + +### 方案 B:使用反向代理 + +1. 建立 Systemd 服務 (如上述步驟 6) +2. 1Panel → 網站 → 建立網站 → 反向代理 +3. 設定: + - 網站域名:your-domain.com + - 代理地址:`http://127.0.0.1:` (依 .env 設定) + +--- + +## 目錄結構 + +``` +/opt/salespipeline/ +├── backend/ +│ ├── app/ # 應用程式碼 +│ ├── data/ # 資料目錄 (上傳檔案) +│ ├── static/ # 前端建置檔案 +│ ├── venv/ # Python 虛擬環境 +│ ├── .env # 環境變數 +│ ├── requirements.txt +│ └── run.py # 啟動腳本 +└── frontend/ + ├── src/ + ├── package.json + └── ... +``` + +--- + +## 環境變數說明 + +| 變數名稱 | 說明 | 預設值 | +|---------|------|--------| +| DB_HOST | 資料庫主機 | localhost | +| DB_PORT | 資料庫端口 | 3306 | +| DB_USER | 資料庫使用者 | root | +| DB_PASSWORD | 資料庫密碼 | - | +| DB_DATABASE | 資料庫名稱 | sales_pipeline | +| SECRET_KEY | JWT 密鑰 | - | +| ALGORITHM | JWT 演算法 | HS256 | +| ACCESS_TOKEN_EXPIRE_MINUTES | Token 過期時間(分鐘) | 1440 | +| APP_HOST | 應用監聽地址 | 0.0.0.0 | +| APP_PORT | 應用監聽端口 | 8000 | +| WORKERS | 工作進程數 | 1 | +| DEBUG | 除錯模式 | False | +| TABLE_PREFIX | 資料表前綴 | PJ_SOA_ | +| CORS_ORIGINS | 允許的跨域來源 | - | + +--- + +## 常用指令 + +```bash +# 啟動服務 +sudo systemctl start salespipeline + +# 停止服務 +sudo systemctl stop salespipeline + +# 重啟服務 +sudo systemctl restart salespipeline + +# 查看日誌 +sudo journalctl -u salespipeline -f + +# 更新程式碼後重建 +cd /opt/salespipeline/frontend && npm run build +sudo systemctl restart salespipeline +``` + +--- + +## 故障排除 + +### 連線資料庫失敗 +1. 確認 `.env` 中的資料庫連線資訊正確 +2. 確認防火牆允許連接資料庫 Port + +### 無法存取網站 +1. 確認服務正在運行:`systemctl status salespipeline` +2. 確認 APP_PORT 未被佔用 +3. 檢查防火牆設定 + +### 靜態檔案 404 +確認前端已建置:`ls /opt/salespipeline/backend/static/` diff --git a/deploy/deploy.sh b/deploy/deploy.sh new file mode 100644 index 0000000..4911f5e --- /dev/null +++ b/deploy/deploy.sh @@ -0,0 +1,122 @@ +#!/bin/bash +# SalesPipeline 部署腳本 +# 使用方式: ./deploy.sh + +set -e + +echo "==========================================" +echo " SalesPipeline Deployment Script" +echo "==========================================" + +# 設定變數 +APP_DIR="/opt/salespipeline" +BACKEND_DIR="$APP_DIR/backend" +FRONTEND_DIR="$APP_DIR/frontend" +SERVICE_NAME="salespipeline" +LOG_DIR="/var/log/salespipeline" + +# 顏色輸出 +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' + +print_status() { + echo -e "${GREEN}[✓]${NC} $1" +} + +print_warning() { + echo -e "${YELLOW}[!]${NC} $1" +} + +print_error() { + echo -e "${RED}[✗]${NC} $1" +} + +# 1. 建立目錄 +echo "" +echo "Step 1: Creating directories..." +sudo mkdir -p $APP_DIR +sudo mkdir -p $LOG_DIR +sudo chown -R $USER:$USER $APP_DIR +print_status "Directories created" + +# 2. 設定 Python 虛擬環境 +echo "" +echo "Step 2: Setting up Python environment..." +cd $BACKEND_DIR +python3 -m venv venv +source venv/bin/activate +pip install --upgrade pip +pip install -r requirements.txt +print_status "Python environment ready" + +# 3. 設定環境變數 +echo "" +echo "Step 3: Configuring environment..." +if [ ! -f "$BACKEND_DIR/.env" ]; then + if [ -f "$BACKEND_DIR/.env.example" ]; then + cp $BACKEND_DIR/.env.example $BACKEND_DIR/.env + print_warning "Created .env from .env.example - please update with production values" + else + print_error ".env.example not found" + exit 1 + fi +else + print_status ".env file exists" +fi + +# 4. 建置前端 +echo "" +echo "Step 4: Building frontend..." +cd $FRONTEND_DIR +if command -v npm &> /dev/null; then + npm install + npm run build + print_status "Frontend built successfully" +else + print_warning "npm not found - skipping frontend build" +fi + +# 5. 設定 Systemd 服務 +echo "" +echo "Step 5: Configuring systemd service..." +sudo cp $APP_DIR/deploy/salespipeline.service /etc/systemd/system/ +sudo systemctl daemon-reload +print_status "Systemd service configured" + +# 6. 設定目錄權限 +echo "" +echo "Step 6: Setting permissions..." +sudo chown -R www-data:www-data $BACKEND_DIR/data +sudo chown -R www-data:www-data $BACKEND_DIR/static +sudo chown -R www-data:www-data $LOG_DIR +print_status "Permissions set" + +# 7. 啟動服務 +echo "" +echo "Step 7: Starting service..." +sudo systemctl enable $SERVICE_NAME +sudo systemctl restart $SERVICE_NAME +print_status "Service started" + +# 8. 檢查狀態 +echo "" +echo "Step 8: Checking status..." +sleep 2 +if sudo systemctl is-active --quiet $SERVICE_NAME; then + print_status "Service is running!" +else + print_error "Service failed to start" + sudo journalctl -u $SERVICE_NAME -n 20 + exit 1 +fi + +echo "" +echo "==========================================" +echo " Deployment Complete!" +echo "==========================================" +echo "" +echo "Application URL: http://localhost:8000" +echo "Logs: sudo journalctl -u $SERVICE_NAME -f" +echo "" diff --git a/deploy/salespipeline.service b/deploy/salespipeline.service new file mode 100644 index 0000000..332bcdf --- /dev/null +++ b/deploy/salespipeline.service @@ -0,0 +1,19 @@ +[Unit] +Description=SalesPipeline - Sales Pipeline Management System +After=network.target mysql.service + +[Service] +Type=simple +User=www-data +Group=www-data +WorkingDirectory=/opt/salespipeline/backend +Environment="PATH=/opt/salespipeline/backend/venv/bin" +EnvironmentFile=/opt/salespipeline/backend/.env +ExecStart=/opt/salespipeline/backend/venv/bin/python run.py +Restart=always +RestartSec=10 +StandardOutput=append:/var/log/salespipeline/app.log +StandardError=append:/var/log/salespipeline/error.log + +[Install] +WantedBy=multi-user.target diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..f82c779 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,13 @@ + + + + + + + SalesPipeline - 銷售管線管理系統 + + +
+ + + diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..628397d --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,3508 @@ +{ + "name": "sales-pipeline-frontend", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "sales-pipeline-frontend", + "version": "1.0.0", + "dependencies": { + "@tanstack/react-query": "^5.8.0", + "axios": "^1.6.2", + "i18next": "^23.7.6", + "i18next-browser-languagedetector": "^7.2.0", + "lucide-react": "^0.294.0", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "react-i18next": "^13.5.0", + "react-router-dom": "^6.20.0", + "recharts": "^2.10.3" + }, + "devDependencies": { + "@types/node": "^20.10.0", + "@types/react": "^18.2.37", + "@types/react-dom": "^18.2.15", + "@vitejs/plugin-react": "^4.2.0", + "autoprefixer": "^10.4.16", + "postcss": "^8.4.31", + "tailwindcss": "^3.3.5", + "typescript": "^5.2.2", + "vite": "^7.3.0" + } + }, + "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==", + "dev": true, + "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/compat-data": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.5.tgz", + "integrity": "sha512-6uFXyCayocRbqhZOB+6XcuZbkMNimwfVGFji8CTZnCzOHVGvDqzvitu1re2AU5LROliz7eQPhB8CpAMvnx9EjA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.5.tgz", + "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.28.5", + "@babel/helper-compilation-targets": "^7.27.2", + "@babel/helper-module-transforms": "^7.28.3", + "@babel/helpers": "^7.28.4", + "@babel/parser": "^7.28.5", + "@babel/template": "^7.27.2", + "@babel/traverse": "^7.28.5", + "@babel/types": "^7.28.5", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.5.tgz", + "integrity": "sha512-3EwLFhZ38J4VyIP6WNtt2kUdW9dokXA9Cr4IVIFHuCpZ3H8/YFOl5JjZHisrn1fATPBmKKqXzDFvh9fUwHz6CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.28.5", + "@babel/types": "^7.28.5", + "@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-compilation-targets": { + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.2.tgz", + "integrity": "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.27.2", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "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==", + "dev": true, + "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==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.27.1", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.3", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.3.tgz", + "integrity": "sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.27.1", + "@babel/helper-validator-identifier": "^7.27.1", + "@babel/traverse": "^7.28.3" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.27.1.tgz", + "integrity": "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==", + "dev": true, + "license": "MIT", + "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==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.4.tgz", + "integrity": "sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.27.2", + "@babel/types": "^7.28.4" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.5.tgz", + "integrity": "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.5" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", + "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", + "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz", + "integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==", + "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==", + "dev": true, + "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.5", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.5.tgz", + "integrity": "sha512-TCCj4t55U90khlYkVV/0TfkJkAkUg3jZFA3Neb7unZT8CPok7iiRfaX0F+WnqWqt7OxhOn0uBKXCw4lbL8W0aQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.28.5", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.28.5", + "@babel/template": "^7.27.2", + "@babel/types": "^7.28.5", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.5.tgz", + "integrity": "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz", + "integrity": "sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.2.tgz", + "integrity": "sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.2.tgz", + "integrity": "sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.2.tgz", + "integrity": "sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.2.tgz", + "integrity": "sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.2.tgz", + "integrity": "sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.2.tgz", + "integrity": "sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.2.tgz", + "integrity": "sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.2.tgz", + "integrity": "sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.2.tgz", + "integrity": "sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.2.tgz", + "integrity": "sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.2.tgz", + "integrity": "sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.2.tgz", + "integrity": "sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.2.tgz", + "integrity": "sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.2.tgz", + "integrity": "sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.2.tgz", + "integrity": "sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.2.tgz", + "integrity": "sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.2.tgz", + "integrity": "sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.2.tgz", + "integrity": "sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.2.tgz", + "integrity": "sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.2.tgz", + "integrity": "sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.2.tgz", + "integrity": "sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.2.tgz", + "integrity": "sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.2.tgz", + "integrity": "sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.2.tgz", + "integrity": "sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.2.tgz", + "integrity": "sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "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==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@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==", + "dev": true, + "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==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "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/@remix-run/router": { + "version": "1.23.1", + "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.1.tgz", + "integrity": "sha512-vDbaOzF7yT2Qs4vO6XV1MHcJv+3dgR1sT+l3B8xxOVhUC336prMvqrvsLL/9Dnw2xr6Qhz4J0dmS0llNAbnUmQ==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.53.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.53.5.tgz", + "integrity": "sha512-iDGS/h7D8t7tvZ1t6+WPK04KD0MwzLZrG0se1hzBjSi5fyxlsiggoJHwh18PCFNn7tG43OWb6pdZ6Y+rMlmyNQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.53.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.53.5.tgz", + "integrity": "sha512-wrSAViWvZHBMMlWk6EJhvg8/rjxzyEhEdgfMMjREHEq11EtJ6IP6yfcCH57YAEca2Oe3FNCE9DSTgU70EIGmVw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.53.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.53.5.tgz", + "integrity": "sha512-S87zZPBmRO6u1YXQLwpveZm4JfPpAa6oHBX7/ghSiGH3rz/KDgAu1rKdGutV+WUI6tKDMbaBJomhnT30Y2t4VQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.53.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.53.5.tgz", + "integrity": "sha512-YTbnsAaHo6VrAczISxgpTva8EkfQus0VPEVJCEaboHtZRIb6h6j0BNxRBOwnDciFTZLDPW5r+ZBmhL/+YpTZgA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.53.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.53.5.tgz", + "integrity": "sha512-1T8eY2J8rKJWzaznV7zedfdhD1BqVs1iqILhmHDq/bqCUZsrMt+j8VCTHhP0vdfbHK3e1IQ7VYx3jlKqwlf+vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.53.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.53.5.tgz", + "integrity": "sha512-sHTiuXyBJApxRn+VFMaw1U+Qsz4kcNlxQ742snICYPrY+DDL8/ZbaC4DVIB7vgZmp3jiDaKA0WpBdP0aqPJoBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.53.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.53.5.tgz", + "integrity": "sha512-dV3T9MyAf0w8zPVLVBptVlzaXxka6xg1f16VAQmjg+4KMSTWDvhimI/Y6mp8oHwNrmnmVl9XxJ/w/mO4uIQONA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.53.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.53.5.tgz", + "integrity": "sha512-wIGYC1x/hyjP+KAu9+ewDI+fi5XSNiUi9Bvg6KGAh2TsNMA3tSEs+Sh6jJ/r4BV/bx/CyWu2ue9kDnIdRyafcQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.53.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.53.5.tgz", + "integrity": "sha512-Y+qVA0D9d0y2FRNiG9oM3Hut/DgODZbU9I8pLLPwAsU0tUKZ49cyV1tzmB/qRbSzGvY8lpgGkJuMyuhH7Ma+Vg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.53.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.53.5.tgz", + "integrity": "sha512-juaC4bEgJsyFVfqhtGLz8mbopaWD+WeSOYr5E16y+1of6KQjc0BpwZLuxkClqY1i8sco+MdyoXPNiCkQou09+g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.53.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.53.5.tgz", + "integrity": "sha512-rIEC0hZ17A42iXtHX+EPJVL/CakHo+tT7W0pbzdAGuWOt2jxDFh7A/lRhsNHBcqL4T36+UiAgwO8pbmn3dE8wA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.53.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.53.5.tgz", + "integrity": "sha512-T7l409NhUE552RcAOcmJHj3xyZ2h7vMWzcwQI0hvn5tqHh3oSoclf9WgTl+0QqffWFG8MEVZZP1/OBglKZx52Q==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.53.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.53.5.tgz", + "integrity": "sha512-7OK5/GhxbnrMcxIFoYfhV/TkknarkYC1hqUw1wU2xUN3TVRLNT5FmBv4KkheSG2xZ6IEbRAhTooTV2+R5Tk0lQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.53.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.53.5.tgz", + "integrity": "sha512-GwuDBE/PsXaTa76lO5eLJTyr2k8QkPipAyOrs4V/KJufHCZBJ495VCGJol35grx9xryk4V+2zd3Ri+3v7NPh+w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.53.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.53.5.tgz", + "integrity": "sha512-IAE1Ziyr1qNfnmiQLHBURAD+eh/zH1pIeJjeShleII7Vj8kyEm2PF77o+lf3WTHDpNJcu4IXJxNO0Zluro8bOw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.53.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.53.5.tgz", + "integrity": "sha512-Pg6E+oP7GvZ4XwgRJBuSXZjcqpIW3yCBhK4BcsANvb47qMvAbCjR6E+1a/U2WXz1JJxp9/4Dno3/iSJLcm5auw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.53.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.53.5.tgz", + "integrity": "sha512-txGtluxDKTxaMDzUduGP0wdfng24y1rygUMnmlUJ88fzCCULCLn7oE5kb2+tRB+MWq1QDZT6ObT5RrR8HFRKqg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.53.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.53.5.tgz", + "integrity": "sha512-3DFiLPnTxiOQV993fMc+KO8zXHTcIjgaInrqlG8zDp1TlhYl6WgrOHuJkJQ6M8zHEcntSJsUp1XFZSY8C1DYbg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.53.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.53.5.tgz", + "integrity": "sha512-nggc/wPpNTgjGg75hu+Q/3i32R00Lq1B6N1DO7MCU340MRKL3WZJMjA9U4K4gzy3dkZPXm9E1Nc81FItBVGRlA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.53.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.53.5.tgz", + "integrity": "sha512-U/54pTbdQpPLBdEzCT6NBCFAfSZMvmjr0twhnD9f4EIvlm9wy3jjQ38yQj1AGznrNO65EWQMgm/QUjuIVrYF9w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.53.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.53.5.tgz", + "integrity": "sha512-2NqKgZSuLH9SXBBV2dWNRCZmocgSOx8OJSdpRaEcRlIfX8YrKxUT6z0F1NpvDVhOsl190UFTRh2F2WDWWCYp3A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.53.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.53.5.tgz", + "integrity": "sha512-JRpZUhCfhZ4keB5v0fe02gQJy05GqboPOaxvjugW04RLSYYoB/9t2lx2u/tMs/Na/1NXfY8QYjgRljRpN+MjTQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@tanstack/query-core": { + "version": "5.90.12", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.90.12.tgz", + "integrity": "sha512-T1/8t5DhV/SisWjDnaiU2drl6ySvsHj1bHBCWNXd+/T+Hh1cf6JodyEYMd5sgwm+b/mETT4EV3H+zCVczCU5hg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "5.90.12", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.12.tgz", + "integrity": "sha512-graRZspg7EoEaw0a8faiUASCyJrqjKPdqJ9EwuDRUF9mEYJ1YPczI9H+/agJ0mOJkPCJDk0lsz5QTrLZ/jQ2rg==", + "license": "MIT", + "dependencies": { + "@tanstack/query-core": "5.90.12" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18 || ^19" + } + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/d3-array": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", + "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/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "20.19.27", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.27.tgz", + "integrity": "sha512-N2clP5pJhB2YnZJ3PIHFk5RkygRX5WO/5f0WC08tp0wd+sv0rsJk3MqWn3CbNmT2J505a5336jaQj4ph1AdMug==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "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==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.27", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.27.tgz", + "integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.2.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/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "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/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.23", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.23.tgz", + "integrity": "sha512-YYTXSFulfwytnjAPlw8QHncHJmlvFKtczb8InXaAx9Q0LbfDnfEYDE55omerIJKihhmU61Ft+cAOSzQVaBUmeA==", + "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.28.1", + "caniuse-lite": "^1.0.30001760", + "fraction.js": "^5.3.4", + "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/axios": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.2.tgz", + "integrity": "sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.6", + "form-data": "^4.0.4", + "proxy-from-env": "^1.1.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.9.9", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.9.tgz", + "integrity": "sha512-V8fbOCSeOFvlDj7LLChUcqbZrdKD9RU/VR260piF1790vT0mfLSwGc/Qzxv3IqiTukOpNtItePa0HBpMAj7MDg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.js" + } + }, + "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/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.28.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", + "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "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": { + "baseline-browser-mapping": "^2.9.0", + "caniuse-lite": "^1.0.30001759", + "electron-to-chromium": "^1.5.263", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.2.0" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "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/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.30001760", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001760.tgz", + "integrity": "sha512-7AAMPcueWELt1p3mi13HR/LHH0TJLT11cnwDJEs3xA4+CK/PLKeO9Kl1oru24htkyUKtkGCvAx4ohB0Ttry8Dw==", + "dev": true, + "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/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/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/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/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "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.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "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/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "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/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/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/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/electron-to-chromium": { + "version": "1.5.267", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.267.tgz", + "integrity": "sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==", + "dev": true, + "license": "ISC" + }, + "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-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/esbuild": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz", + "integrity": "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.2", + "@esbuild/android-arm": "0.27.2", + "@esbuild/android-arm64": "0.27.2", + "@esbuild/android-x64": "0.27.2", + "@esbuild/darwin-arm64": "0.27.2", + "@esbuild/darwin-x64": "0.27.2", + "@esbuild/freebsd-arm64": "0.27.2", + "@esbuild/freebsd-x64": "0.27.2", + "@esbuild/linux-arm": "0.27.2", + "@esbuild/linux-arm64": "0.27.2", + "@esbuild/linux-ia32": "0.27.2", + "@esbuild/linux-loong64": "0.27.2", + "@esbuild/linux-mips64el": "0.27.2", + "@esbuild/linux-ppc64": "0.27.2", + "@esbuild/linux-riscv64": "0.27.2", + "@esbuild/linux-s390x": "0.27.2", + "@esbuild/linux-x64": "0.27.2", + "@esbuild/netbsd-arm64": "0.27.2", + "@esbuild/netbsd-x64": "0.27.2", + "@esbuild/openbsd-arm64": "0.27.2", + "@esbuild/openbsd-x64": "0.27.2", + "@esbuild/openharmony-arm64": "0.27.2", + "@esbuild/sunos-x64": "0.27.2", + "@esbuild/win32-arm64": "0.27.2", + "@esbuild/win32-ia32": "0.27.2", + "@esbuild/win32-x64": "0.27.2" + } + }, + "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/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-equals": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.4.0.tgz", + "integrity": "sha512-jt2DW/aNFNwke7AUd+Z+e6pz39KO5rzdbbFCg2sGafS4mk13MI7Z8O5z9cADNn5lhGODIgLwug6TZO2ctf7kcw==", + "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/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/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/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/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "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/fraction.js": { + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", + "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/rawify" + } + }, + "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/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "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/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/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/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/html-parse-stringify": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz", + "integrity": "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==", + "license": "MIT", + "dependencies": { + "void-elements": "3.1.0" + } + }, + "node_modules/i18next": { + "version": "23.16.8", + "resolved": "https://registry.npmjs.org/i18next/-/i18next-23.16.8.tgz", + "integrity": "sha512-06r/TitrM88Mg5FdUXAKL96dJMzgqLE5dv3ryBAra4KCwD9mJ4ndOTS95ZuymIGoE+2hzfdaMak2X11/es7ZWg==", + "funding": [ + { + "type": "individual", + "url": "https://locize.com" + }, + { + "type": "individual", + "url": "https://locize.com/i18next.html" + }, + { + "type": "individual", + "url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project" + } + ], + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.23.2" + } + }, + "node_modules/i18next-browser-languagedetector": { + "version": "7.2.2", + "resolved": "https://registry.npmjs.org/i18next-browser-languagedetector/-/i18next-browser-languagedetector-7.2.2.tgz", + "integrity": "sha512-6b7r75uIJDWCcCflmbof+sJ94k9UQO4X0YR62oUfqGI/GjCLVzlCwu8TFdRZIqVLzWbzNcmkmhfqKEr4TLz4HQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.23.2" + } + }, + "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-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-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==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.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-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-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/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/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "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==", + "dev": true, + "license": "MIT" + }, + "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/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/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/lucide-react": { + "version": "0.294.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.294.0.tgz", + "integrity": "sha512-V7o0/VECSGbLHn3/1O67FUgBwWB+hmzshrgDVRJQhMh8uj5D3HBuIvhuAmQTtlupILSplwIZg5FTc4tTKMA2SA==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0" + } + }, + "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/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "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==", + "dev": true, + "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/node-releases": { + "version": "2.0.27", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", + "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", + "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/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/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==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "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/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.1.0", + "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.1.0.tgz", + "integrity": "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "camelcase-css": "^2.0.1" + }, + "engines": { + "node": "^12 || ^14 || >= 16" + }, + "peerDependencies": { + "postcss": "^8.4.21" + } + }, + "node_modules/postcss-load-config": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz", + "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "lilconfig": "^3.1.1" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "jiti": ">=1.21.0", + "postcss": ">=8.0.9", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + }, + "postcss": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "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/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/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-i18next": { + "version": "13.5.0", + "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-13.5.0.tgz", + "integrity": "sha512-CFJ5NDGJ2MUyBohEHxljOq/39NQ972rh1ajnadG9BjTk+UXbHLq4z5DKEbEQBDoIhUmmbuS/fIMJKo6VOax1HA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.22.5", + "html-parse-stringify": "^3.0.1" + }, + "peerDependencies": { + "i18next": ">= 23.2.3", + "react": ">= 16.8.0" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + }, + "react-native": { + "optional": true + } + } + }, + "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/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-router": { + "version": "6.30.2", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.2.tgz", + "integrity": "sha512-H2Bm38Zu1bm8KUE5NVWRMzuIyAV8p/JrOaBJAwVmp37AXG72+CZJlEBw6pdn9i5TBgLMhNDgijS4ZlblpHyWTA==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8" + } + }, + "node_modules/react-router-dom": { + "version": "6.30.2", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.2.tgz", + "integrity": "sha512-l2OwHn3UUnEVUqc6/1VMmR1cvZryZ3j3NzapC2eUXO1dB0sYp5mvwdjiXhpUbRb21eFow3qSxpP8Yv6oAU824Q==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.1", + "react-router": "6.30.2" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, + "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/resolve": { + "version": "1.22.11", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", + "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-core-module": "^2.16.1", + "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/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/rollup": { + "version": "4.53.5", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.53.5.tgz", + "integrity": "sha512-iTNAbFSlRpcHeeWu73ywU/8KuU/LZmNCSxp6fjQkJBD3ivUb8tpDrXhIxEzA05HlYMEwmtaUnb3RP+YNv162OQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.53.5", + "@rollup/rollup-android-arm64": "4.53.5", + "@rollup/rollup-darwin-arm64": "4.53.5", + "@rollup/rollup-darwin-x64": "4.53.5", + "@rollup/rollup-freebsd-arm64": "4.53.5", + "@rollup/rollup-freebsd-x64": "4.53.5", + "@rollup/rollup-linux-arm-gnueabihf": "4.53.5", + "@rollup/rollup-linux-arm-musleabihf": "4.53.5", + "@rollup/rollup-linux-arm64-gnu": "4.53.5", + "@rollup/rollup-linux-arm64-musl": "4.53.5", + "@rollup/rollup-linux-loong64-gnu": "4.53.5", + "@rollup/rollup-linux-ppc64-gnu": "4.53.5", + "@rollup/rollup-linux-riscv64-gnu": "4.53.5", + "@rollup/rollup-linux-riscv64-musl": "4.53.5", + "@rollup/rollup-linux-s390x-gnu": "4.53.5", + "@rollup/rollup-linux-x64-gnu": "4.53.5", + "@rollup/rollup-linux-x64-musl": "4.53.5", + "@rollup/rollup-openharmony-arm64": "4.53.5", + "@rollup/rollup-win32-arm64-msvc": "4.53.5", + "@rollup/rollup-win32-ia32-msvc": "4.53.5", + "@rollup/rollup-win32-x64-gnu": "4.53.5", + "@rollup/rollup-win32-x64-msvc": "4.53.5", + "fsevents": "~2.3.2" + } + }, + "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/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": "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/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==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/sucrase": { + "version": "3.35.1", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", + "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "tinyglobby": "^0.2.11", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "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==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/tailwindcss": { + "version": "3.4.19", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz", + "integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==", + "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.7", + "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 || ^5.0 || ^6.0", + "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/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.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "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-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/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "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/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "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/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/vite": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.0.tgz", + "integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite/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/vite/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/void-elements": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz", + "integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..f7e35b9 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,34 @@ +{ + "name": "sales-pipeline-frontend", + "private": true, + "version": "1.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "preview": "vite preview" + }, + "dependencies": { + "@tanstack/react-query": "^5.8.0", + "axios": "^1.6.2", + "i18next": "^23.7.6", + "i18next-browser-languagedetector": "^7.2.0", + "lucide-react": "^0.294.0", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "react-i18next": "^13.5.0", + "react-router-dom": "^6.20.0", + "recharts": "^2.10.3" + }, + "devDependencies": { + "@types/node": "^20.10.0", + "@types/react": "^18.2.37", + "@types/react-dom": "^18.2.15", + "@vitejs/plugin-react": "^4.2.0", + "autoprefixer": "^10.4.16", + "postcss": "^8.4.31", + "tailwindcss": "^3.3.5", + "typescript": "^5.2.2", + "vite": "^7.3.0" + } +} diff --git a/frontend/postcss.config.js b/frontend/postcss.config.js new file mode 100644 index 0000000..2e7af2b --- /dev/null +++ b/frontend/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000..9577a4d --- /dev/null +++ b/frontend/src/App.tsx @@ -0,0 +1,202 @@ +import React, { useState, useEffect } from 'react'; +import { Routes, Route, Navigate } from 'react-router-dom'; +import { useTranslation } from 'react-i18next'; +import { Database, CheckCircle, BarChart2, LogOut, User, FlaskConical } from 'lucide-react'; +import { ImportView } from './components/ImportView'; +import { ReviewView } from './components/ReviewView'; +import { DashboardView } from './components/DashboardView'; +import { LabView } from './components/LabView'; +import { LoginPage } from './components/LoginPage'; +import { LanguageSwitch } from './components/common/LanguageSwitch'; +import { authApi } from './services/api'; + +type TabId = 'import' | 'review' | 'dashboard' | 'lab'; + +interface Tab { + id: TabId; + icon: React.ElementType; + labelKey: string; + badge?: number; +} + +const MainLayout: React.FC<{ onLogout: () => void }> = ({ onLogout }) => { + const { t } = useTranslation(); + const [activeTab, setActiveTab] = useState('import'); + const [reviewCount, setReviewCount] = useState(0); + const [userEmail, setUserEmail] = useState(''); + + useEffect(() => { + authApi.me().then(user => { + setUserEmail(user.email); + }).catch(() => { }); + }, []); + + const tabs: Tab[] = [ + { id: 'import', icon: Database, labelKey: 'nav.import' }, + { id: 'review', icon: CheckCircle, labelKey: 'nav.review', badge: reviewCount }, + { id: 'dashboard', icon: BarChart2, labelKey: 'nav.dashboard' }, + { id: 'lab', icon: FlaskConical, labelKey: 'nav.lab' }, + ]; + + const handleEtlComplete = () => { + setReviewCount(2); + setActiveTab('review'); + }; + + const handleReviewComplete = () => { + setReviewCount(0); + setActiveTab('dashboard'); + }; + + const handleLogout = () => { + localStorage.removeItem('token'); + onLogout(); + }; + + return ( +
+
+
+
+
+ S +
+
+ + {t('common.appName')} + + + {t('common.appSubtitle')} + +
+
+ +
+ {tabs.map(tab => ( + + ))} +
+ +
+
+
+ {t('common.systemRunning')} +
+ + + +
+ + {userEmail} +
+ + +
+
+
+ +
+ {activeTab === 'import' && ( + + )} + + {activeTab === 'review' && ( + + )} + + {activeTab === 'dashboard' && ( + + )} + + {activeTab === 'lab' && ( + + )} +
+
+ ); +}; + +function App() { + const [isAuthenticated, setIsAuthenticated] = useState(null); + + useEffect(() => { + const token = localStorage.getItem('token'); + if (token) { + authApi.me() + .then(() => setIsAuthenticated(true)) + .catch(() => { + localStorage.removeItem('token'); + setIsAuthenticated(false); + }); + } else { + setIsAuthenticated(false); + } + }, []); + + const handleLogin = () => { + setIsAuthenticated(true); + }; + + const handleLogout = () => { + setIsAuthenticated(false); + }; + + if (isAuthenticated === null) { + return ( +
+
+
+ ); + } + + return ( + + + ) : ( + + ) + } + /> + + ) : ( + + ) + } + /> + + ); +} + +export default App; diff --git a/frontend/src/components/DashboardView.tsx b/frontend/src/components/DashboardView.tsx new file mode 100644 index 0000000..6a63a9e --- /dev/null +++ b/frontend/src/components/DashboardView.tsx @@ -0,0 +1,279 @@ +import React, { useState, useEffect } from 'react'; +import { + BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip as RechartsTooltip, ResponsiveContainer, + Cell +} from 'recharts'; +import { Filter, Activity, Download, Info, CheckCircle, HelpCircle, XCircle } from 'lucide-react'; +import { Card } from './common/Card'; +import { dashboardApi, reportApi } from '../services/api'; +import type { DashboardKPI, FunnelData, AttributionRow } from '../types'; + +export const DashboardView: React.FC = () => { + const [kpi, setKpi] = useState({ + total_dit: 0, + sample_rate: 0, + hit_rate: 0, + fulfillment_rate: 0, + orphan_sample_rate: 0, + total_revenue: 0, + }); + const [funnelData, setFunnelData] = useState([]); + const [attribution, setAttribution] = useState([]); + const [loading, setLoading] = useState(true); + const [filterType, setFilterType] = useState<'all' | 'sample' | 'order'>('all'); + + useEffect(() => { + loadDashboardData(); + }, []); + + const loadDashboardData = async () => { + try { + const [kpiData, funnelRes, attrRes] = await Promise.all([ + dashboardApi.getKPI(), + dashboardApi.getFunnel(), + dashboardApi.getAttribution(), + ]); + setKpi(kpiData); + setFunnelData(funnelRes); + setAttribution(attrRes); + } catch (error) { + console.error('Error loading dashboard:', error); + } finally { + setLoading(false); + } + }; + + const handleExport = async (format: 'xlsx' | 'pdf') => { + try { + const blob = format === 'xlsx' + ? await reportApi.exportExcel() + : await reportApi.exportPdf(); + + const url = window.URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `report.${format}`; + document.body.appendChild(a); + a.click(); + window.URL.revokeObjectURL(url); + document.body.removeChild(a); + } catch (error) { + console.error('Export error:', error); + alert('匯出失敗,請稍後再試'); + } + }; + + if (loading) { + return ( +
+
+
+ ); + } + + const filteredAttribution = attribution.filter(row => { + if (filterType === 'sample') return !!row.sample; + if (filterType === 'order') return !!row.order; + return true; + }); + + return ( +
+
+
+

業務轉換率戰情室 (v1.0)

+

+ 數據來源:DIT Report, 樣品紀錄, 訂單明細 | 邏輯:瀑布式比對 + LIFO 歸因 +

+
+ +
+ + {/* KPI Cards */} +
+ +
DIT 總案件數
+
{kpi.total_dit}
+
Total Pipeline
+
+ +
送樣轉換率
+
{kpi.sample_rate}%
+
Sample Rate
+
+ +
訂單命中率
+
{kpi.hit_rate}%
+
Hit Rate (Binary)
+
+ +
EAU 達成率
+
{kpi.fulfillment_rate}%
+
Fulfillment (LIFO)
+
+ +
無效送樣率
+
{kpi.orphan_sample_rate}%
+
Orphan Sample
+
+
+ +
+ {/* Funnel Chart */} + +

+ + DIT 轉換漏斗 (Funnel Analysis) +

+
+ + + + + + + { + if (data.name === '成功送樣') setFilterType('sample'); + else if (data.name === '取得訂單') setFilterType('order'); + else setFilterType('all'); + }} + style={{ cursor: 'pointer' }} + > + {funnelData.map((entry, index) => ( + + ))} + + + +
+
+ + {/* Total Revenue Card */} + + +

歸因總營收

+
+ ${kpi.total_revenue.toLocaleString()} +
+

+ 基於 LIFO 邏輯分配至各 DIT 案件之實際訂單金額總和 +

+
+
+ + {/* Attribution Table */} + +
+
+

+ + DIT 歸因明細表 (LIFO 業績分配) +

+ {filterType !== 'all' && ( + + 篩選:{filterType === 'sample' ? '成功送樣' : '取得訂單'} + + + )} +
+
+ + + Hover to see matching logic + +
+
+
+ + + + + + + + + + + + + {filteredAttribution.map((row, i) => ( + + + + + + + + + ))} + +
OP編號 / 日期Customer / StagePart No.EAU / 歸因量達成率送樣 / 訂單
+
{row.dit.op_id}
+
{row.dit.date}
+
+
{row.dit.customer}
+
{row.dit.stage}
+
{row.dit.pn} +
{row.dit.eau.toLocaleString()}
+
+{row.attributed_qty.toLocaleString()}
+
+
= 100 ? 'text-emerald-600' : 'text-slate-500'}`}> + {row.fulfillment_rate}% +
+
+
= 100 ? 'bg-emerald-500' : 'bg-indigo-500'}`} + style={{ width: `${Math.min(row.fulfillment_rate, 100)}%` }} + >
+
+
+
+ {row.sample ? ( +
+ +
+ ) : ( +
+ )} + {row.order ? ( +
+ + +
+ ) : ( +
+ )} +
+
+
+
+
+ ); +}; diff --git a/frontend/src/components/ImportView.tsx b/frontend/src/components/ImportView.tsx new file mode 100644 index 0000000..64e8bc6 --- /dev/null +++ b/frontend/src/components/ImportView.tsx @@ -0,0 +1,286 @@ +import React, { useState, useRef } from 'react'; +import { + FileText, Database, CheckCircle, RefreshCw, Activity, +} from 'lucide-react'; +import { Card, Badge } from './common/Card'; +import { etlApi, matchApi } from '../services/api'; +import type { ParsedFile, DitRecord, OrderRecord } from '../types'; + +interface ImportViewProps { + onEtlComplete: () => void; +} + +type FileType = 'dit' | 'sample' | 'order'; + +interface FileState { + file: File | null; + parsed: ParsedFile | null; + loading: boolean; +} + +export const ImportView: React.FC = ({ onEtlComplete }) => { + const [files, setFiles] = useState>({ + dit: { file: null, parsed: null, loading: false }, + sample: { file: null, parsed: null, loading: false }, + order: { file: null, parsed: null, loading: false }, + }); + + const [isProcessing, setIsProcessing] = useState(false); + const [processingStep, setProcessingStep] = useState(''); + const [error, setError] = useState(null); + + const fileInputRefs = { + dit: useRef(null), + sample: useRef(null), + order: useRef(null), + }; + + const handleFileSelect = async (type: FileType, file: File) => { + setFiles(prev => ({ + ...prev, + [type]: { ...prev[type], file, loading: true } + })); + + try { + const parsed = await etlApi.upload(file, type); + setFiles(prev => ({ + ...prev, + [type]: { file, parsed, loading: false } + })); + } catch (error) { + console.error(`Error uploading ${type} file:`, error); + setFiles(prev => ({ + ...prev, + [type]: { file: null, parsed: null, loading: false } + })); + } + }; + + const allFilesReady = files.dit.parsed && files.sample.parsed && files.order.parsed; + + const runEtl = async () => { + if (!allFilesReady) { + alert("請先上傳所有檔案!"); + return; + } + + setError(null); + setIsProcessing(true); + const steps = [ + "正在匯入 DIT Report...", + "正在匯入 樣品紀錄...", + "正在匯入 訂單明細...", + "執行資料標準化 (Normalization)...", + "執行模糊比對 (Fuzzy Matching)...", + "運算完成!" + ]; + + try { + for (let i = 0; i < steps.length - 2; i++) { + setProcessingStep(steps[i]); + if (i === 0 && files.dit.parsed) { + await etlApi.import(files.dit.parsed.file_id); + } else if (i === 1 && files.sample.parsed) { + await etlApi.import(files.sample.parsed.file_id); + } else if (i === 2 && files.order.parsed) { + await etlApi.import(files.order.parsed.file_id); + } + await new Promise(resolve => setTimeout(resolve, 500)); + } + + setProcessingStep(steps[4]); + await matchApi.run(); + + setProcessingStep(steps[5]); + await new Promise(resolve => setTimeout(resolve, 500)); + + onEtlComplete(); + } catch (error: any) { + console.error('ETL error:', error); + const msg = error.response?.data?.detail || error.message || 'ETL 處理失敗,請檢查檔案格式'; + setError(msg); + } finally { + setIsProcessing(false); + } + }; + + + const fileConfigs = [ + { type: 'dit' as FileType, title: '1. DIT Report', desc: '含 Metadata (前16行)' }, + { type: 'sample' as FileType, title: '2. 樣品紀錄', desc: '含檔頭 (前9行)' }, + { type: 'order' as FileType, title: '3. 訂單明細', desc: '標準格式' }, + ]; + + return ( +
+
+
+

原始資料匯入中心

+

系統將自動偵測 Excel/CSV 檔頭位置並進行智慧欄位對應。

+
+
+ +
+
+ + {isProcessing && ( +
+

+ {processingStep} +

+
+ )} + + {error && ( +
+

+ 錯誤:{error} +

+

請檢查檔案格式或連線狀態,並重新嘗試。

+
+ )} + +
+ {fileConfigs.map(({ type, title, desc }) => { + const fileState = files[type]; + const isLoaded = !!fileState.parsed; + + return ( + fileInputRefs[type].current?.click()} + > + { + const file = e.target.files?.[0]; + if (file) handleFileSelect(type, file); + }} + /> +
+
+ {fileState.loading ? ( + + ) : isLoaded ? ( + + ) : ( + + )} +
+ {isLoaded && Ready} +
+

{title}

+

+ {fileState.file?.name || "點擊上傳..."} +

+

{desc}

+ + {fileState.parsed && ( +
+ 預覽筆數 + + {fileState.parsed.row_count} 筆 + +
+ )} +
+ ); + })} +
+ + {allFilesReady && ( +
+ +
+

+ + DIT 解析結果 (預覽) +

+ Auto-Skipped Header +
+ + + + + + + + + + + {(files.dit.parsed?.preview as unknown as DitRecord[] || []).slice(0, 5).map((row, i) => ( + + + + + + + ))} + +
CustomerPart NoStageEAU
{row.customer}{row.pn}{row.stage}{row.eau?.toLocaleString()}
+
+ + +
+

+ + 訂單解析結果 (預覽) +

+ Detected CP950 +
+ + + + + + + + + + + {(files.order.parsed?.preview as unknown as OrderRecord[] || []).slice(0, 5).map((row, i) => ( + + + + + + + ))} + +
CustomerPart NoStatusQty
{row.customer}{row.pn} + + {row.status} + + {row.qty?.toLocaleString()}
+
+
+ )} +
+ ); +}; diff --git a/frontend/src/components/LabView.tsx b/frontend/src/components/LabView.tsx new file mode 100644 index 0000000..fa089cd --- /dev/null +++ b/frontend/src/components/LabView.tsx @@ -0,0 +1,337 @@ +import React, { useState, useEffect } from 'react'; +import { + ScatterChart, Scatter, XAxis, YAxis, ZAxis, CartesianGrid, Tooltip as RechartsTooltip, + ResponsiveContainer, Label +} from 'recharts'; +import { + FlaskConical, Calendar, Clock, Target, AlertTriangle, Copy, + Check, TrendingUp, Info, HelpCircle +} from 'lucide-react'; +import { Card } from './common/Card'; +import { labApi } from '../services/api'; +import type { LabKPI, ScatterPoint, OrphanSample } from '../types'; + +export const LabView: React.FC = () => { + const [kpi, setKpi] = useState({ + avg_velocity: 0, + conversion_rate: 0, + orphan_count: 0 + }); + const [scatterData, setScatterData] = useState([]); + const [orphans, setOrphans] = useState([]); + const [loading, setLoading] = useState(true); + const [dateRange, setDateRange] = useState<'all' | '12m' | '6m' | '3m'>('all'); + const [useLogScale, setUseLogScale] = useState(false); + const [copiedId, setCopiedId] = useState(null); + + useEffect(() => { + loadLabData(); + }, [dateRange]); + + const loadLabData = async () => { + setLoading(true); + try { + let start_date = ''; + const now = new Date(); + if (dateRange === '12m') { + start_date = new Date(now.setFullYear(now.getFullYear() - 1)).toISOString().split('T')[0]; + } else if (dateRange === '6m') { + start_date = new Date(now.setMonth(now.getMonth() - 6)).toISOString().split('T')[0]; + } else if (dateRange === '3m') { + start_date = new Date(now.setMonth(now.getMonth() - 3)).toISOString().split('T')[0]; + } + + const params = start_date ? { start_date } : {}; + + const [kpiData, scatterRes, orphanRes] = await Promise.all([ + labApi.getKPI(params), + labApi.getScatter(params), + labApi.getOrphans() + ]); + + setKpi(kpiData); + setScatterData(scatterRes); + setOrphans(orphanRes); + } catch (error) { + console.error('Error loading lab data:', error); + } finally { + setLoading(false); + } + }; + + const handleCopy = (orphan: OrphanSample, index: number) => { + const text = `Customer: ${orphan.customer}\nPart No: ${orphan.pn}\nSent Date: ${orphan.date}\nDays Ago: ${orphan.days_since_sent}`; + navigator.clipboard.writeText(text); + setCopiedId(index); + setTimeout(() => setCopiedId(null), 2000); + }; + + if (loading) { + return ( +
+
+
+ ); + } + + return ( +
+
+
+

+ + 送樣成效分析戰情室 (Sample Conversion Lab) +

+

+ 焦點分析:樣品投資報酬率 (ROI) 與 轉換速度 | 邏輯:ERP Code + PN 直接比對 +

+
+ +
+ {(['all', '12m', '6m', '3m'] as const).map((r) => ( + + ))} +
+
+ + {/* KPI Cards */} +
+ +
+
+
平均轉換速度
+
{kpi.avg_velocity} 天
+
+ + Conversion Velocity +
+
+
+ +
+
+
+ + +
+
+
整體轉換倍率 (ROI)
+
{kpi.conversion_rate}%
+
+ + Sample to Order Ratio +
+
+
+ +
+
+
+ + +
+
+
孤兒樣品總數
+
{kpi.orphan_count} 筆
+
+ + Wait-time > 90 Days +
+
+
+ +
+
+
+
+ +
+ {/* Scatter Matrix */} + +
+

+ + 送樣量與訂單量分佈矩陣 (Sample ROI Matrix) +

+
+ + +
+
+ +
+ + + + + + + + + { + if (active && payload && payload.length) { + const data = payload[0].payload as ScatterPoint; + return ( +
+

{data.customer}

+

{data.pn}

+
+

+ 送樣量: + {data.sample_qty.toLocaleString()} +

+

+ 訂單量: + {data.order_qty.toLocaleString()} +

+

+ {data.order_qty > data.sample_qty ? '✨ 高效轉換 (High ROI)' : data.order_qty > 0 ? '穩定轉換' : '尚無訂單 (Orphan?)'} +

+
+
+ ); + } + return null; + }} + /> + +
+
+
+ +
+
+
+ 案件資料 (Customer/PN Group) +
+
+
+ + {/* Insight Card */} + +
+

+ + 戰情室洞察 (Lab Insights) +

+
+
+

高效轉換客戶

+

識別散佈圖中「左上角」點位,代表投入少量樣品即獲得大量訂單。

+
+
+

風險警示

+

右下角點位代表送樣頻繁但轉換效率低,需檢視應用場景或產品適配度。

+
+
+
+
+

+ "本模組直接比對 ERP 編號,確保不因專案名稱模糊而漏失任何實際營收數據。" +

+
+
+
+ + {/* Orphan Samples Table */} + +
+

+ + Orphan Alert Table - > 90 Days +

+
+ 共 {orphans.length} 筆待追蹤案件 +
+
+
+ + + + + + + + + + + + + {orphans.map((row, i) => ( + + + + + + + + + ))} + {orphans.length === 0 && ( + + + + )} + +
客戶名稱產品料號 (Part No)送樣日期滯留天數狀態操作
{row.customer}{row.pn}{row.date} + 180 ? 'text-rose-600' : 'text-amber-600'}`}> + {row.days_since_sent} 天 + + + 180 ? 'bg-rose-100 text-rose-700' : 'bg-amber-100 text-amber-700' + }`}> + {row.days_since_sent > 180 ? '呆滯庫存 (Dead Stock)' : '需採取行動'} + + + +
+ 目前沒有孤兒樣品,所有送樣皆在有效時限內或已轉化。 +
+
+
+
+ ); +}; diff --git a/frontend/src/components/LoginPage.tsx b/frontend/src/components/LoginPage.tsx new file mode 100644 index 0000000..f650caa --- /dev/null +++ b/frontend/src/components/LoginPage.tsx @@ -0,0 +1,193 @@ +import React, { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useNavigate } from 'react-router-dom'; +import { LogIn, UserPlus, Mail, Lock, Eye, EyeOff } from 'lucide-react'; +import { authApi } from '../services/api'; +import { LanguageSwitch } from './common/LanguageSwitch'; + +interface LoginPageProps { + onLogin: (token: string) => void; +} + +export const LoginPage: React.FC = ({ onLogin }) => { + const { t } = useTranslation(); + const navigate = useNavigate(); + const [isLogin, setIsLogin] = useState(true); + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [confirmPassword, setConfirmPassword] = useState(''); + const [showPassword, setShowPassword] = useState(false); + const [error, setError] = useState(''); + const [loading, setLoading] = useState(false); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(''); + + if (!email) { + setError(t('auth.emailRequired')); + return; + } + if (!password) { + setError(t('auth.passwordRequired')); + return; + } + if (!isLogin && password !== confirmPassword) { + setError(t('auth.passwordMismatch')); + return; + } + + setLoading(true); + try { + if (isLogin) { + const response = await authApi.login({ email, password }); + localStorage.setItem('token', response.access_token); + onLogin(response.access_token); + navigate('/'); + } else { + await authApi.register({ email, password }); + setIsLogin(true); + setError(''); + setPassword(''); + setConfirmPassword(''); + } + } catch (err: any) { + if (isLogin) { + setError(t('auth.invalidCredentials')); + } else { + setError(t('auth.registerFailed')); + } + } finally { + setLoading(false); + } + }; + + return ( +
+
+ +
+ +
+
+
+
+
+ S +
+

{t('common.appName')}

+

{t('common.appSubtitle')}

+
+ +
+ + +
+ +
+
+ +
+ + setEmail(e.target.value)} + className="w-full pl-10 pr-4 py-2.5 border border-slate-200 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 outline-none transition-all" + placeholder="your@email.com" + /> +
+
+ +
+ +
+ + setPassword(e.target.value)} + className="w-full pl-10 pr-10 py-2.5 border border-slate-200 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 outline-none transition-all" + placeholder="••••••••" + /> + +
+
+ + {!isLogin && ( +
+ +
+ + setConfirmPassword(e.target.value)} + className="w-full pl-10 pr-4 py-2.5 border border-slate-200 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 outline-none transition-all" + placeholder="••••••••" + /> +
+
+ )} + + {error && ( +
+ {error} +
+ )} + + +
+ +

+ {isLogin ? t('auth.noAccount') : t('auth.hasAccount')}{' '} + +

+
+
+
+
+ ); +}; diff --git a/frontend/src/components/ReviewView.tsx b/frontend/src/components/ReviewView.tsx new file mode 100644 index 0000000..a58fd81 --- /dev/null +++ b/frontend/src/components/ReviewView.tsx @@ -0,0 +1,157 @@ +import React, { useState, useEffect } from 'react'; +import { CheckCircle, XCircle } from 'lucide-react'; +import { Card, Badge } from './common/Card'; +import { matchApi } from '../services/api'; +import type { MatchResult, ReviewAction } from '../types'; + +interface ReviewViewProps { + onReviewComplete: () => void; +} + +export const ReviewView: React.FC = ({ onReviewComplete }) => { + const [reviews, setReviews] = useState([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + loadReviews(); + }, []); + + const loadReviews = async () => { + try { + const results = await matchApi.getResults(); + setReviews(results.filter(r => r.status === 'pending')); + } catch (error) { + console.error('Error loading reviews:', error); + } finally { + setLoading(false); + } + }; + + const handleReviewAction = async (id: number, action: ReviewAction) => { + try { + await matchApi.review(id, action); + setReviews(prev => prev.filter(r => r.id !== id)); + + if (reviews.length === 1) { + onReviewComplete(); + } + } catch (error) { + console.error('Error submitting review:', error); + } + }; + + if (loading) { + return ( +
+
+
+ ); + } + + return ( +
+
+
+

+ 模糊比對審核工作台 + + 待審核: {reviews.length} + +

+

系統發現以下案件名稱相似,請人工確認關聯性。

+
+
+ + {reviews.length === 0 ? ( +
+
+ +
+

所有案件已審核完畢!

+

您的資料比對已完成,請查看分析儀表板。

+ +
+ ) : ( +
+ {reviews.map(item => ( + +
+ {/* Left: DIT */} +
+
+ DIT (設計導入) + OP編號: {item.dit?.op_id} +
+
+
Customer Name
+
{item.dit?.customer}
+
Part Number
+
+ {item.dit?.pn} +
+
+
+ + {/* Middle: Score & Reason */} +
+
+
{item.score}%
+
+ 相似度 +
+
+ {item.reason} +
+
+
+ + {/* Right: Target (Sample/Order) */} +
+
+ + {item.target_type === 'ORDER' ? 'Order (訂單)' : 'Sample (樣品)'} + + + 來源單號: {(item.target as { order_no?: string })?.order_no || 'N/A'} + +
+
+
Customer Name
+
+ {(item.target as { customer?: string })?.customer} +
+
Part Number
+
+ {(item.target as { pn?: string })?.pn} +
+
+
+ + {/* Actions */} +
+ + +
+
+
+ ))} +
+ )} +
+ ); +}; diff --git a/frontend/src/components/common/Card.tsx b/frontend/src/components/common/Card.tsx new file mode 100644 index 0000000..0d8cf86 --- /dev/null +++ b/frontend/src/components/common/Card.tsx @@ -0,0 +1,37 @@ +import React from 'react'; + +interface CardProps { + children: React.ReactNode; + className?: string; + onClick?: () => void; +} + +export const Card: React.FC = ({ children, className = "", onClick }) => ( +
+ {children} +
+); + +interface BadgeProps { + children: React.ReactNode; + type?: 'neutral' | 'success' | 'warning' | 'danger' | 'info'; +} + +export const Badge: React.FC = ({ children, type = "neutral" }) => { + const styles: Record = { + neutral: "bg-slate-100 text-slate-600", + success: "bg-emerald-100 text-emerald-700", + warning: "bg-amber-100 text-amber-700", + danger: "bg-rose-100 text-rose-700", + info: "bg-blue-100 text-blue-700" + }; + + return ( + + {children} + + ); +}; diff --git a/frontend/src/components/common/LanguageSwitch.tsx b/frontend/src/components/common/LanguageSwitch.tsx new file mode 100644 index 0000000..1dedb5d --- /dev/null +++ b/frontend/src/components/common/LanguageSwitch.tsx @@ -0,0 +1,41 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { Globe } from 'lucide-react'; + +const languages = [ + { code: 'zh-TW', label: '繁體中文' }, + { code: 'en', label: 'English' }, +]; + +export const LanguageSwitch: React.FC = () => { + const { i18n } = useTranslation(); + + const handleLanguageChange = (langCode: string) => { + i18n.changeLanguage(langCode); + localStorage.setItem('language', langCode); + }; + + return ( +
+ +
+ {languages.map((lang) => ( + + ))} +
+
+ ); +}; diff --git a/frontend/src/hooks/useAuth.ts b/frontend/src/hooks/useAuth.ts new file mode 100644 index 0000000..e7fc5a9 --- /dev/null +++ b/frontend/src/hooks/useAuth.ts @@ -0,0 +1,69 @@ +import { useState, useEffect, useCallback } from 'react'; +import { authApi } from '../services/api'; +import type { User, LoginRequest } from '../types'; + +export function useAuth() { + const [user, setUser] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const checkAuth = useCallback(async () => { + const token = localStorage.getItem('token'); + if (!token) { + setLoading(false); + return; + } + + try { + const userData = await authApi.me(); + setUser(userData); + } catch { + localStorage.removeItem('token'); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + checkAuth(); + }, [checkAuth]); + + const login = async (data: LoginRequest) => { + setError(null); + try { + const response = await authApi.login(data); + localStorage.setItem('token', response.access_token); + setUser(response.user); + return true; + } catch (err) { + setError('登入失敗,請檢查帳號密碼'); + return false; + } + }; + + const register = async (data: LoginRequest) => { + setError(null); + try { + await authApi.register(data); + return await login(data); + } catch (err) { + setError('註冊失敗,該 Email 可能已被使用'); + return false; + } + }; + + const logout = () => { + localStorage.removeItem('token'); + setUser(null); + }; + + return { + user, + loading, + error, + isAuthenticated: !!user, + login, + register, + logout, + }; +} diff --git a/frontend/src/i18n/index.ts b/frontend/src/i18n/index.ts new file mode 100644 index 0000000..3d9952a --- /dev/null +++ b/frontend/src/i18n/index.ts @@ -0,0 +1,30 @@ +import i18n from 'i18next'; +import { initReactI18next } from 'react-i18next'; +import LanguageDetector from 'i18next-browser-languagedetector'; + +import zhTW from './locales/zh-TW.json'; +import en from './locales/en.json'; + +const resources = { + 'zh-TW': { translation: zhTW }, + 'en': { translation: en }, +}; + +i18n + .use(LanguageDetector) + .use(initReactI18next) + .init({ + resources, + fallbackLng: 'zh-TW', + defaultNS: 'translation', + detection: { + order: ['localStorage', 'navigator', 'htmlTag'], + caches: ['localStorage'], + lookupLocalStorage: 'language', + }, + interpolation: { + escapeValue: false, + }, + }); + +export default i18n; diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json new file mode 100644 index 0000000..466fcc5 --- /dev/null +++ b/frontend/src/i18n/locales/en.json @@ -0,0 +1,140 @@ +{ + "common": { + "appName": "SalesPipeline", + "appSubtitle": "Sales Pipeline Management System", + "systemRunning": "System Running", + "loading": "Loading...", + "save": "Save", + "cancel": "Cancel", + "confirm": "Confirm", + "delete": "Delete", + "edit": "Edit", + "search": "Search", + "filter": "Filter", + "export": "Export", + "import": "Import", + "refresh": "Refresh", + "back": "Back", + "next": "Next", + "previous": "Previous", + "submit": "Submit", + "reset": "Reset", + "close": "Close", + "yes": "Yes", + "no": "No", + "success": "Success", + "error": "Error", + "warning": "Warning", + "info": "Info" + }, + "nav": { + "import": "Data Import", + "review": "Match Review", + "dashboard": "Dashboard", + "lab": "Sample Conversion Lab", + "settings": "Settings", + "logout": "Logout" + }, + "auth": { + "login": "Login", + "register": "Register", + "email": "Email", + "password": "Password", + "confirmPassword": "Confirm Password", + "rememberMe": "Remember me", + "forgotPassword": "Forgot password?", + "noAccount": "Don't have an account?", + "hasAccount": "Already have an account?", + "loginSuccess": "Login successful", + "loginFailed": "Login failed", + "registerSuccess": "Registration successful", + "registerFailed": "Registration failed", + "invalidCredentials": "Invalid email or password", + "emailRequired": "Email is required", + "passwordRequired": "Password is required", + "passwordMismatch": "Passwords do not match" + }, + "import": { + "title": "Data Import", + "subtitle": "Upload Excel files to import DIT, Sample, and Order data", + "ditFile": "DIT File", + "sampleFile": "Sample File", + "orderFile": "Order File", + "selectFile": "Select File", + "dragDrop": "or drag and drop here", + "supportedFormats": "Supported formats: xlsx, xls", + "uploading": "Uploading...", + "parsing": "Parsing...", + "importing": "Importing...", + "uploadSuccess": "Upload successful", + "uploadFailed": "Upload failed", + "importSuccess": "Import successful", + "importFailed": "Import failed", + "recordsImported": "{{count}} records imported", + "preview": "Preview", + "startImport": "Start Import", + "clearAll": "Clear All" + }, + "review": { + "title": "Match Review", + "subtitle": "Review automatically matched results", + "pendingReview": "Pending Review", + "accepted": "Accepted", + "rejected": "Rejected", + "autoMatched": "Auto Matched", + "similarity": "Similarity", + "matchReason": "Match Reason", + "ditRecord": "DIT Record", + "matchedRecord": "Matched Record", + "accept": "Accept", + "reject": "Reject", + "acceptAll": "Accept All", + "rejectAll": "Reject All", + "noRecords": "No records pending review", + "reviewComplete": "Review Complete", + "reviewSuccess": "Review successful", + "reviewFailed": "Review failed" + }, + "dashboard": { + "title": "Analytics Dashboard", + "subtitle": "Sales Pipeline Overview & Analysis", + "totalDit": "Total DIT", + "matchedSamples": "Matched Samples", + "matchedOrders": "Matched Orders", + "conversionRate": "Conversion Rate", + "totalRevenue": "Total Revenue", + "conversionFunnel": "Conversion Funnel", + "attribution": "DIT Attribution", + "exportExcel": "Export Excel", + "exportPdf": "Export PDF", + "customer": "Customer", + "partNumber": "Part Number", + "stage": "Stage", + "eau": "EAU", + "sample": "Sample", + "order": "Order", + "amount": "Amount" + }, + "settings": { + "title": "Settings", + "language": "Language", + "theme": "Theme", + "darkMode": "Dark Mode", + "lightMode": "Light Mode", + "profile": "Profile", + "displayName": "Display Name", + "changePassword": "Change Password", + "currentPassword": "Current Password", + "newPassword": "New Password", + "saveChanges": "Save Changes" + }, + "errors": { + "networkError": "Network connection error", + "serverError": "Server error", + "unauthorized": "Unauthorized, please login again", + "forbidden": "No permission to perform this action", + "notFound": "Resource not found", + "validationError": "Validation error", + "unknownError": "Unknown error occurred" + } +} diff --git a/frontend/src/i18n/locales/zh-TW.json b/frontend/src/i18n/locales/zh-TW.json new file mode 100644 index 0000000..dc32bc8 --- /dev/null +++ b/frontend/src/i18n/locales/zh-TW.json @@ -0,0 +1,140 @@ +{ + "common": { + "appName": "SalesPipeline", + "appSubtitle": "銷售管線管理系統", + "systemRunning": "系統運作中", + "loading": "載入中...", + "save": "儲存", + "cancel": "取消", + "confirm": "確認", + "delete": "刪除", + "edit": "編輯", + "search": "搜尋", + "filter": "篩選", + "export": "匯出", + "import": "匯入", + "refresh": "重新整理", + "back": "返回", + "next": "下一步", + "previous": "上一步", + "submit": "提交", + "reset": "重置", + "close": "關閉", + "yes": "是", + "no": "否", + "success": "成功", + "error": "錯誤", + "warning": "警告", + "info": "資訊" + }, + "nav": { + "import": "資料匯入", + "review": "比對審核", + "dashboard": "分析儀表板", + "lab": "送樣成效分析", + "settings": "設定", + "logout": "登出" + }, + "auth": { + "login": "登入", + "register": "註冊", + "email": "電子郵件", + "password": "密碼", + "confirmPassword": "確認密碼", + "rememberMe": "記住我", + "forgotPassword": "忘記密碼?", + "noAccount": "還沒有帳號?", + "hasAccount": "已有帳號?", + "loginSuccess": "登入成功", + "loginFailed": "登入失敗", + "registerSuccess": "註冊成功", + "registerFailed": "註冊失敗", + "invalidCredentials": "帳號或密碼錯誤", + "emailRequired": "請輸入電子郵件", + "passwordRequired": "請輸入密碼", + "passwordMismatch": "密碼不一致" + }, + "import": { + "title": "資料匯入", + "subtitle": "上傳 Excel 檔案以匯入 DIT、樣品和訂單資料", + "ditFile": "DIT 檔案", + "sampleFile": "樣品檔案", + "orderFile": "訂單檔案", + "selectFile": "選擇檔案", + "dragDrop": "或拖放檔案至此", + "supportedFormats": "支援格式:xlsx, xls", + "uploading": "上傳中...", + "parsing": "解析中...", + "importing": "匯入中...", + "uploadSuccess": "上傳成功", + "uploadFailed": "上傳失敗", + "importSuccess": "匯入成功", + "importFailed": "匯入失敗", + "recordsImported": "已匯入 {{count}} 筆資料", + "preview": "預覽", + "startImport": "開始匯入", + "clearAll": "清除全部" + }, + "review": { + "title": "比對審核", + "subtitle": "審核系統自動比對的結果", + "pendingReview": "待審核", + "accepted": "已接受", + "rejected": "已拒絕", + "autoMatched": "自動配對", + "similarity": "相似度", + "matchReason": "配對原因", + "ditRecord": "DIT 記錄", + "matchedRecord": "配對記錄", + "accept": "接受", + "reject": "拒絕", + "acceptAll": "全部接受", + "rejectAll": "全部拒絕", + "noRecords": "沒有待審核的記錄", + "reviewComplete": "審核完成", + "reviewSuccess": "審核成功", + "reviewFailed": "審核失敗" + }, + "dashboard": { + "title": "分析儀表板", + "subtitle": "銷售管線總覽與分析", + "totalDit": "DIT 總數", + "matchedSamples": "配對樣品數", + "matchedOrders": "配對訂單數", + "conversionRate": "轉換率", + "totalRevenue": "總營收", + "conversionFunnel": "轉換漏斗", + "attribution": "DIT 歸因表", + "exportExcel": "匯出 Excel", + "exportPdf": "匯出 PDF", + "customer": "客戶", + "partNumber": "料號", + "stage": "階段", + "eau": "EAU", + "sample": "樣品", + "order": "訂單", + "amount": "金額" + }, + "settings": { + "title": "設定", + "language": "語言", + "theme": "主題", + "darkMode": "深色模式", + "lightMode": "淺色模式", + "profile": "個人資料", + "displayName": "顯示名稱", + "changePassword": "變更密碼", + "currentPassword": "目前密碼", + "newPassword": "新密碼", + "saveChanges": "儲存變更" + }, + "errors": { + "networkError": "網路連線錯誤", + "serverError": "伺服器錯誤", + "unauthorized": "未經授權,請重新登入", + "forbidden": "無權限執行此操作", + "notFound": "找不到資源", + "validationError": "資料驗證錯誤", + "unknownError": "發生未知錯誤" + } +} diff --git a/frontend/src/index.css b/frontend/src/index.css new file mode 100644 index 0000000..a42ba0f --- /dev/null +++ b/frontend/src/index.css @@ -0,0 +1,57 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +@keyframes fade-in { + from { opacity: 0; } + to { opacity: 1; } +} + +@keyframes slide-in-from-bottom-4 { + from { transform: translateY(1rem); opacity: 0; } + to { transform: translateY(0); opacity: 1; } +} + +@keyframes slide-in-from-right-4 { + from { transform: translateX(1rem); opacity: 0; } + to { transform: translateX(0); opacity: 1; } +} + +@keyframes zoom-in-95 { + from { transform: scale(0.95); opacity: 0; } + to { transform: scale(1); opacity: 1; } +} + +.animate-in { + animation-duration: 0.5s; + animation-fill-mode: both; +} + +.fade-in { + animation-name: fade-in; +} + +.slide-in-from-bottom-4 { + animation-name: slide-in-from-bottom-4; +} + +.slide-in-from-bottom-2 { + animation-name: slide-in-from-bottom-4; + animation-duration: 0.3s; +} + +.slide-in-from-right-4 { + animation-name: slide-in-from-right-4; +} + +.zoom-in-95 { + animation-name: zoom-in-95; +} + +.duration-500 { + animation-duration: 0.5s; +} + +.duration-300 { + animation-duration: 0.3s; +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx new file mode 100644 index 0000000..fc69a3d --- /dev/null +++ b/frontend/src/main.tsx @@ -0,0 +1,26 @@ +import React from 'react' +import ReactDOM from 'react-dom/client' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { BrowserRouter } from 'react-router-dom' +import App from './App' +import './i18n' +import './index.css' + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + staleTime: 1000 * 60 * 5, + retry: 1, + }, + }, +}) + +ReactDOM.createRoot(document.getElementById('root')!).render( + + + + + + + , +) diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts new file mode 100644 index 0000000..2b337e4 --- /dev/null +++ b/frontend/src/services/api.ts @@ -0,0 +1,173 @@ +import axios from 'axios'; +import type { + LoginRequest, + LoginResponse, + User, + MatchResult, + DashboardKPI, + ParsedFile, + ReviewAction, + DitRecord, + SampleRecord, + OrderRecord, + LabKPI, + ScatterPoint, + OrphanSample +} from '../types'; + +const api = axios.create({ + baseURL: '/api', + headers: { + 'Content-Type': 'application/json', + }, +}); + +// 請求攔截器:加入 JWT Token +api.interceptors.request.use((config) => { + const token = localStorage.getItem('token'); + if (token) { + config.headers.Authorization = `Bearer ${token}`; + } + return config; +}); + +// 響應攔截器:處理 401 錯誤 +api.interceptors.response.use( + (response) => response, + (error) => { + // 只有在非登入/驗證相關的 API 返回 401 時才跳轉 + const isAuthEndpoint = error.config?.url?.includes('/auth/'); + if (error.response?.status === 401 && !isAuthEndpoint) { + localStorage.removeItem('token'); + window.location.href = '/login'; + } + return Promise.reject(error); + } +); + +// Auth API +export const authApi = { + login: async (data: LoginRequest): Promise => { + const formData = new URLSearchParams(); + formData.append('username', data.email); + formData.append('password', data.password); + + const response = await api.post('/auth/login', formData, { + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + }); + return response.data; + }, + + register: async (data: LoginRequest): Promise => { + const response = await api.post('/auth/register', data); + return response.data; + }, + + me: async (): Promise => { + const response = await api.get('/auth/me'); + return response.data; + }, +}; + +// ETL API +export const etlApi = { + upload: async (file: File, fileType: 'dit' | 'sample' | 'order'): Promise => { + const formData = new FormData(); + formData.append('file', file); + formData.append('file_type', fileType); + const response = await api.post('/etl/upload', formData, { + headers: { 'Content-Type': 'multipart/form-data' }, + }); + return response.data; + }, + + preview: async (fileId: string): Promise => { + const response = await api.get(`/etl/preview/${fileId}`); + return response.data; + }, + + import: async (fileId: string): Promise<{ imported_count: number }> => { + const response = await api.post<{ imported_count: number }>('/etl/import', { file_id: fileId }); + return response.data; + }, + + getData: async (type: 'dit' | 'sample' | 'order'): Promise<(DitRecord | SampleRecord | OrderRecord)[]> => { + const response = await api.get(`/etl/data/${type}`); + return response.data; + }, +}; + +// Match API +export const matchApi = { + run: async (): Promise<{ match_count: number; auto_matched: number; pending_review: number }> => { + const response = await api.post('/match/run'); + return response.data; + }, + + getResults: async (): Promise => { + const response = await api.get('/match/results'); + return response.data; + }, + + review: async (id: number, action: ReviewAction): Promise => { + const response = await api.put(`/match/${id}/review`, { action }); + return response.data; + }, +}; + +// Dashboard API +export const dashboardApi = { + getKPI: async (): Promise => { + const response = await api.get('/dashboard/kpi'); + return response.data; + }, + + getFunnel: async () => { + const response = await api.get('/dashboard/funnel'); + return response.data; + }, + + getAttribution: async () => { + const response = await api.get('/dashboard/attribution'); + return response.data; + }, +}; + +// Report API +export const reportApi = { + exportExcel: async (): Promise => { + const response = await api.get('/report/export', { + params: { format: 'xlsx' }, + responseType: 'blob', + }); + return response.data; + }, + + exportPdf: async (): Promise => { + const response = await api.get('/report/export', { + params: { format: 'pdf' }, + responseType: 'blob', + }); + return response.data; + }, +}; + +// Lab API +export const labApi = { + getKPI: async (params?: { start_date?: string; end_date?: string }): Promise => { + const response = await api.get('/lab/kpi', { params }); + return response.data; + }, + + getScatter: async (params?: { start_date?: string; end_date?: string }): Promise => { + const response = await api.get('/lab/scatter', { params }); + return response.data; + }, + + getOrphans: async (): Promise => { + const response = await api.get('/lab/orphans'); + return response.data; + }, +}; + +export default api; diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts new file mode 100644 index 0000000..ab5fa86 --- /dev/null +++ b/frontend/src/types/index.ts @@ -0,0 +1,146 @@ +// 使用者相關類型 +export interface User { + id: number; + email: string; + display_name?: string; + language: string; + role: 'admin' | 'user'; + created_at: string; +} + +export interface LoginRequest { + email: string; + password: string; +} + +export interface LoginResponse { + access_token: string; + token_type: string; + user: User; +} + +// DIT 資料類型 +export interface DitRecord { + id: number; + op_id: string; + erp_account?: string; + customer: string; + pn: string; + eau: number; + stage: string; + date: string; + created_at: string; +} + +// 樣品資料類型 +export interface SampleRecord { + id: number; + sample_id: string; + order_no: string; + oppy_no?: string; + cust_id?: string; + customer: string; + pn: string; + qty: number; + date: string; + created_at: string; +} + +// 訂單資料類型 +export interface OrderRecord { + id: number; + order_id: string; + order_no: string; + cust_id?: string; + customer: string; + pn: string; + qty: number; + status: string; + amount: number; + created_at: string; +} + +// 比對結果類型 +export interface MatchResult { + id: number; + dit_id: number; + target_type: 'SAMPLE' | 'ORDER'; + target_id: number; + score: number; + match_priority: number; + match_source: string; + reason: string; + status: 'pending' | 'accepted' | 'rejected' | 'auto_matched'; + dit: DitRecord; + target: SampleRecord | OrderRecord; +} + +// 審核動作 +export type ReviewAction = 'accept' | 'reject'; + +// Dashboard KPI +export interface DashboardKPI { + total_dit: number; + sample_rate: number; + hit_rate: number; + fulfillment_rate: number; + orphan_sample_rate: number; + total_revenue: number; +} + +// 漏斗數據 +export interface FunnelData { + name: string; + value: number; + fill: string; +} + +// 歸因明細 +export interface AttributionRow { + dit: DitRecord; + sample?: SampleRecord; + order?: OrderRecord; + match_source?: string; + attributed_qty: number; + fulfillment_rate: number; +} + +// Excel 上傳預覽 +export interface ParsedFile { + file_id: string; + file_type: 'dit' | 'sample' | 'order'; + filename: string; + row_count: number; + header_row: number; + preview: Record[]; +} + +// Lab 分析相關類型 +export interface LabKPI { + avg_velocity: number; + conversion_rate: number; + orphan_count: number; +} + +export interface ScatterPoint { + customer: string; + pn: string; + sample_qty: number; + order_qty: number; +} + +export interface OrphanSample { + customer: string; + pn: string; + days_since_sent: number; + order_no: string; + date: string; +} + +// API 響應包裝 +export interface ApiResponse { + success: boolean; + data?: T; + message?: string; + error?: string; +} diff --git a/frontend/tailwind.config.js b/frontend/tailwind.config.js new file mode 100644 index 0000000..dca8ba0 --- /dev/null +++ b/frontend/tailwind.config.js @@ -0,0 +1,11 @@ +/** @type {import('tailwindcss').Config} */ +export default { + content: [ + "./index.html", + "./src/**/*.{js,ts,jsx,tsx}", + ], + theme: { + extend: {}, + }, + plugins: [], +} diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..5413626 --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "baseUrl": ".", + "paths": { + "@/*": ["src/*"] + } + }, + "include": ["src"], + "references": [{ "path": "./tsconfig.node.json" }] +} diff --git a/frontend/tsconfig.node.json b/frontend/tsconfig.node.json new file mode 100644 index 0000000..42872c5 --- /dev/null +++ b/frontend/tsconfig.node.json @@ -0,0 +1,10 @@ +{ + "compilerOptions": { + "composite": true, + "skipLibCheck": true, + "module": "ESNext", + "moduleResolution": "bundler", + "allowSyntheticDefaultImports": true + }, + "include": ["vite.config.ts"] +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 0000000..33d174f --- /dev/null +++ b/frontend/vite.config.ts @@ -0,0 +1,26 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' +import path from 'path' + +export default defineConfig({ + plugins: [react()], + build: { + outDir: '../backend/static', + emptyOutDir: true, + }, + server: { + port: 3000, + strictPort: true, + proxy: { + '/api': { + target: 'http://localhost:8000', + changeOrigin: true, + } + } + }, + resolve: { + alias: { + '@': path.resolve(__dirname, './src'), + }, + }, +})