diff --git a/.env b/.env
index d1fdeed..d972166 100644
--- a/.env
+++ b/.env
@@ -1,6 +1,6 @@
-PM_USER_ID=b21364
-PM_PASSWORD=b21364!.
-DB_HOST=localhost
-DB_USER=root
-DB_PASSWORD=45278434
-DB_NAME=PM_proto
+PM_USER_ID=b21364
+PM_PASSWORD=b21364!.
+DB_HOST=localhost
+DB_USER=root
+DB_PASSWORD=45278434
+DB_NAME=PM_proto
diff --git a/.gitignore b/.gitignore
index b86406d..bc60a93 100644
Binary files a/.gitignore and b/.gitignore differ
diff --git a/ANALYSIS_REPORT.md b/ANALYSIS_REPORT.md
index 594edec..9588e19 100644
--- a/ANALYSIS_REPORT.md
+++ b/ANALYSIS_REPORT.md
@@ -1,81 +1,60 @@
-# ๐ ์์คํ
์ด์ ์์ฐ ๊ฐ์น ๋ถ์ ๋ณด๊ณ ์ (Sabermetrics Edition)
-
-๋ณธ ๋ณด๊ณ ์๋ ์ผ๊ตฌ์ ํต๊ณ ๋ถ์ ๊ธฐ๋ฒ์ธ **์ธ์ด๋ฒ๋ฉํธ๋ฆญ์ค(Sabermetrics)**๋ฅผ ํ๋ก์ ํธ ๊ด๋ฆฌ ์์คํ
์ ์ด์ํ์ฌ, ๋จ์ ํ๋๋ ์ธก์ ์ ๋์ด **'์ค์ง์ ์์ฐ ๊ฐ์น'**์ **'๋ฏธ๋ ์ด์ ์ํ'**์ ์ ๋ฐ ๋ถ์ํ ๊ฒฐ๊ณผ์
๋๋ค.
-
----
-
-## 1. ํต์ฌ ๋ถ์ ์งํ ์ ์ (Core Metrics)
-
-### 1.1 ์ด์ ํ๋ ฅ ์ง์ (AVI, Activity Vitality Index)
-ํ๋ก์ ํธ๊ฐ ํ์ฌ ์ผ๋ง๋ '์ด์์ ์จ ์ฌ๊ณ ์๋๊ฐ'๋ฅผ ๋ํ๋ด๋ ์์กด ์ง์์
๋๋ค.
-
-* **์ฐ์ถ ๊ณต์**: $AVI = exp(-\lambda \times days) \times Quality \times ECV \times 100$
-* **ํต์ฌ ๋ฐ์ดํฐ**:
- * **์ ์ฒด ์ผ์(days)**: ๋ง์ง๋ง ์ ์๋ฏธํ ํ์ผ ์
๋ฐ์ดํธ ์ดํ ๊ฒฝ๊ณผ ์๊ฐ.
- * **๊ฐ์ ๊ณ์($\lambda$)**: ๊ธฐ๋ณธ $0.04$์์ ์์ํ์ฌ, ์์ฐ ๊ท๋ชจ(์ต๋ $+0.04$)์ ๋ถ์ ์ ์ฒด์จ(์ต๋ $+0.03$)์ ๋์ ์ผ๋ก ๊ฒฐํฉํฉ๋๋ค.
- * **ํ๋ ํ์ง(Quality)**: ํ์ผ ์ฆ๋ถ ํ๋($1.0$), ๊ตฌ์กฐ์ ๊ด๋ฆฌ($0.7$), ๋จ์ ํ์ ๋ก๊ทธ($0.4$)๋ก ์ฐจ๋ฑ ๋ฐฐ์ ํฉ๋๋ค.
- * **์กด์ฌ ์ ๋ขฐ๋(ECV)**: ํ์ผ ์ $0$๊ฐ($0.05$), $10$๊ฐ ๋ฏธ๋ง($0.4$) ๋ฑ ์ ๋ น ํ๋ก์ ํธ์ ํจ๋ํฐ๋ฅผ ๋ถ์ฌํฉ๋๋ค.
-* **์๋ฏธ**: 100%์ ๊ฐ๊น์ธ์๋ก ์ค์๊ฐ ๊ฐ๋ ์ํ์ด๋ฉฐ, 0%์ ๊ฐ๊น์ธ์๋ก ๋ฐ์ดํฐ ๋
ธํํ๊ฐ ์๋ฃ๋ '์ฌ๋ง' ์ํ๋ฅผ ๋ปํฉ๋๋ค.
-
-### 1.2 ์์ฐ ๊ฐ์น ๊ธฐ์ฌ๋ (VCI, Value Contribution Index)
-์์คํ
์ ์ฒด์ ์ด์ ํ์ค ๋๋น, ํด๋น ํ๋ก์ ํธ๊ฐ ๊ธฐ์ฌํ๊ณ ์๋ ๊ฐ์น์ ์๋์ ํ์ค์ ์ธก์ ํฉ๋๋ค.
-
-* **์ฐ์ถ ๊ณต์**: $VCI = (AVI - 70.0) \times (\frac{Files}{200} + 0.5)$
-* **ํต์ฌ ๋ก์ง**:
- * **๊ฑด๊ฐ ๊ธฐ์ค์ (70.0%)**: ์์คํ
์์ฐ ๊ฐ์น๋ฅผ ์ ์งํ๊ธฐ ์ํ ์ต์ ๋ง์ง๋
ธ์ (Replacement Level)์
๋๋ค.
- * **๊ท๋ชจ ๊ฐ์ค์น**: ํ์ผ $200$๊ฐ๋ฅผ $1.0$ ๊ฐ์ค์น ๊ธฐ์ค์ผ๋ก ์ผ์, ๋ํ ํ๋ก์ ํธ์ผ์๋ก ์์คํ
์ ์ฃผ๋ ์ถฉ๊ฒฉ์ ๊ธฐํ๊ธ์์ ์ผ๋ก ๋ฐ์ํฉ๋๋ค.
-* **์๋ฏธ**: ์์(+)๋ ๊ฐ์น ์ฐฝ์ถ, ์์(-)๋ ์์คํ
๊ธฐํ๋น์ฉ์ ๊ฐ์๋จน๋ '๊ฐ์น ํ๊ดด' ์ํ์์ ๋ํ๋
๋๋ค.
-
-### 1.3 ์
๋ฌด ์ง์ค๋ (Job Focus)
-๋จ์ ๊ด๋ฆฌ ํ์๋ฅผ ์ ์ธํ๊ณ , ์ค์ ์ฑ๊ณผ๋ฌผ(ํ์ผ)์ ์์ฐํ๋ ๋ฐ ์ผ๋ง๋ ๋ชฐ์
ํ๋์ง๋ฅผ ํ๋ณํฉ๋๋ค.
-
-* **์ฐ์ถ ๊ณต์**: $Job Focus = \frac{\text{์ต๊ทผ ํ์คํ ๋ฆฌ ์ค ์ค์ ํ์ผ ๋ณ๋ ๋ฐ์ ํ์}}{\text{์ ์ฒด ๋ฐ์ดํฐ ์์ง ํ์}} \times 100$
-* **์๋ฏธ**: ๋ก๊ทธ๋ง ๋จ๊ธฐ๋ '๋ณด์ฌ์ฃผ๊ธฐ์ ํ๋'์ ํํฐ๋งํ์ฌ ์ด์์ ์ง์ ์ฑ์ ํ์ธํฉ๋๋ค.
-
-### 1.4 ์ด์ ์ผ๊ด์ฑ ์ง์ (OCI, Operational Consistency Index)
-ํ๋ก์ ํธ ๊ด๋ฆฌ์ '๋ฆฌ๋ฌ'๊ณผ '์ฑ์ค๋'๋ฅผ ์ธก์ ํ๋ ์งํ์
๋๋ค.
-
-* **์ฐ์ถ ๊ณต์**: ์ต๊ทผ 30์ผ ๋ฐ์ดํฐ๋ฅผ 4๊ฐ ์ฃผ์ฐจ๋ก ๋ถํ ํ์ฌ ํ๋ ์ฌ๋ถ ๋ถ์ (์ฃผ์ฐจ๋ณ ์ฑ์ค๋ 70% + ํ๋ ๋ฐ๋ 30%)
-* **์๋ฏธ**: ํน์ ์์ ์ ๋ชฐ์์น๊ธฐ์ ์์
์ ํ๋ ํ๋ก์ ํธ๋ณด๋ค, ๋งค์ฃผ ๊พธ์คํ ๊ด๋ฆฌ๋๋ ํ๋ก์ ํธ์ ๋ ๋์ ์ ๋ขฐ ์ ์๋ฅผ ๋ถ์ฌํฉ๋๋ค.
-
----
-
-## 2. ๋ฑ๊ธ ์ฒด๊ณ ๋ฐ ๊ด๋ฆฌ ๊ฐ์ด๋ (Grade System)
-
-### 2.1 VCI ๋ฑ๊ธ (ํ๋ก์ ํธ ์์)
-| ๋ฑ๊ธ (Grade) | ์ ์ ๊ธฐ์ค | ์ด์ ์๋ฏธ ๋ฐ ๊ด๋ฆฌ ์ ๋ต |
-| :--- | :--- | :--- |
-| **Masterpiece** | +10.0 ์ด์ | **์ต์ฐ๋ ์์ฐ**: ์์คํ
๊ฐ์น๋ฅผ ๊ฒฌ์ธํ๋ ํต์ฌ ํ๋ก์ ํธ |
-| **Blue Chip** | +2.0 ~ +10.0 | **์ฐ๋ ์์ฐ**: ๊พธ์คํ ํ๋ ฅ์ผ๋ก ๊ฐ์น๋ฅผ ์ฐฝ์ถํ๋ ํต์ฌ๊ตฐ |
-| **Steady** | -2.0 ~ +2.0 | **์์ ์์ฐ**: ํ์ค ์์ค์ ์ด์์ ์ ์ง ์ค์ธ ํ์ ์ ์ง๊ตฐ |
-| **Underperform** | -10.0 ~ -2.0 | **์ ์ฑ๊ณผ ์์ฐ**: ๊ท๋ชจ ๋๋น ํ๋ ฅ์ด ๋ถ์กฑํ์ฌ ๊ฐ์น ํ๋ฝ ์ค์ธ ๊ทธ๋ฃน |
-| **Liability** | -10.0 ์ดํ | **๊ณ ์ํ ์์ฐ**: ์์คํ
๊ฐ์น๋ฅผ ํผ์ ์ค์ธ ๋ฐฉ์น ํ๋ก์ ํธ. ์ฆ์ ์กฐ์น ํ์ |
-
-### 2.2 ์ด์ ์ผ๊ด์ฑ (OCI) ํ์
-* **์ ๊ธฐ์ (80%โ)**: ์ฃผ ๋จ์์ ์ ๊ธฐ์ ๊ด๋ฆฌ๊ฐ ์๋ฒฝํ ์ด๋ค์ง๋ ์ต์ฐ๋ ๊ด๋ฆฌ ์ํ.
-* **์์ ์ (50~80%)**: ๊ฐํ์ ์ ์ฒด๋ ์์ผ๋ ์ ๋ฐ์ ์ธ ๊ด๋ฆฌ ๋ฆฌ๋ฌ์ ์ ์งํ๋ ์ํ.
-* **๊ฐํ์ (20~50%)**: ๊ด๋ฆฌ ํ๋์ด ๋ถ๊ท์นํ๋ฉฐ, ํ์์ ์ํ ์ผํ์ฑ ์์
์ค์ฌ์ธ ์ํ.
-* **๋ถ๊ท์น (20%โ)**: ์ฅ๊ธฐ ์ ์ฒด ์ค์ด๊ฑฐ๋ ๊ด๋ฆฌ์ ์์์ฑ์ ํ์ธํ๊ธฐ ์ด๋ ค์ด ์ํ ์ํ.
-
----
-
-## 3. ๋ฐ์ดํฐ ๋ถ์ ํ๋ก์ธ์ค (Analysis Process)
-
-1. **๋ฐ์ดํฐ ์์ง**: `projects_history` ํ
์ด๋ธ๋ก๋ถํฐ ์ผ๋ณ ํ์ผ ์ ๋ฐ ๋ก๊ทธ ํ
์คํธ๋ฅผ ์ถ์ถํฉ๋๋ค.
-2. **ํผ์ฒ ์ถ์ถ**:
- * **Velocity**: ํ์ผ ์์ ๋ณํ ์๋ ๊ณ์ฐ.
- * **Acceleration**: ํ๋์ ๊ฐ์/๊ฐ์ ์ฌ๋ถ ํ๋ณ.
- * **Stagnation**: ๋ง์ง๋ง ํ๋ ์ดํ์ ๊ณต๋ฐฑ ๊ธฐ๊ฐ ์ธก์ .
-3. **AI ์๋ฎฌ๋ ์ด์
**: ์ถ์ถ๋ ํผ์ฒ๋ฅผ AI ์ํ ์ ์ํ ๋ชจ๋ธ (AAS)์ ์
๋ ฅํ์ฌ ๊ฐ๋ณ ํ๋ก์ ํธ๋ง์ **'์ํ ๊ณก์ '**์ ์์ฑํฉ๋๋ค.
-4. **์ต์ข
ํ์ **: AVI์ VCI๋ฅผ ๊ฒฐํฉํ์ฌ ๋ฆฌ๋๋ณด๋์ ๋ฑ๊ธ๊ณผ ๊ด๋ฆฌ ๊ฐ์ด๋๋ผ์ธ์ ์ก์ถํฉ๋๋ค.
-
----
-
-## 4. ๊ด๋ฆฌ์ ์ ์ธ (Action Plan)
-
-* **VCI ์์ ํ๋ก์ ํธ ์ง์ค ๊ด๋ฆฌ**: ๋จ์ ํ๋๋์ด ์๋ VCI๊ฐ ๋ฎ์ ๋ํ ํ๋ก์ ํธ๋ถํฐ ์ฐ์ ์ ์ผ๋ก ์ธ๋ ฅ์ ๋ฐฐ์นํ๊ฑฐ๋ ์ด์ ์ ์ฑ
์ ์ฌ์ ๊ฒํด์ผ ํฉ๋๋ค.
-* **AI Forecast ํ์ฉ**: 'ํ๋ ฅ ์ ํ' ์๋ณด๊ฐ ๋ฌ ํ๋ก์ ํธ๋ ์ค์ AVI๊ฐ ๊ธ๋ฝํ๊ธฐ ์ ์ ์ ์ ์ธ ์กฐ์น(์
๋ฌด ๋
๋ ค, ํ์ผ ํํํ)๋ฅผ ์ทจํ ์ ์์ต๋๋ค.
-* **ํ์ผ ์์ ํ๋ ฅ์ ๊ท ํ**: ํ์ผ ์๊ฐ ๋ง์๋ฐ ํ๋ ฅ(AVI)์ด ๋ฎ์ ๊ฒฝ์ฐ, ์์คํ
์ ์ฒด์ ๋ฐ์ดํฐ ๋ฌด๊ฒฐ์ฑ์ ํด์น ์ ์์ผ๋ฏ๋ก ๋ฐ์ดํฐ ํด๋ ์ง์ด๋ ์์นด์ด๋น์ ๊ถ๊ณ ํฉ๋๋ค.
-
----
-*๋ณธ ๋ถ์ ์์ง์ Project Master Sabermetrics ์๊ณ ๋ฆฌ์ฆ์ ์ํด ์๋ ์์ฑ๋์์ต๋๋ค.*
+# ๐ ์์คํ
์ด์ ์์ฐ ๊ฐ์น ๋ถ์ ๋ณด๊ณ (Sabermetrics Report)
+
+๋ณธ ๋ณด๊ณ ์๋ ํ๋ก์ ํธ ๊ด๋ฆฌ ์์คํ
๋ด์์ ์์ง๋ ํ๋ ๋ก๊ทธ ๋ฐ ์์ฐ ๋ฐ์ดํฐ๋ฅผ ํต๊ณ์ /AI ๊ธฐ๋ฒ์ผ๋ก ๋ถ์ํ์ฌ, ๊ฐ ํ๋ก์ ํธ์ ์ด์ ํ๋ ฅ๊ณผ ์กฐ์ง ๊ธฐ์ฌ๋๋ฅผ ์ ๋ํํ ์งํ๋ฅผ ์ ์ํฉ๋๋ค.
+
+---
+
+## 1. ์ด์ ํ๋ ฅ ์ง์ (AVI, Activity Vitality Index)
+ํ๋ก์ ํธ๊ฐ ํ์ฌ ์ผ๋ง๋ ๊ฑด๊ฐํ๊ฒ ๊ฐ๋๋๊ณ ์๋์ง๋ฅผ ๋ํ๋ด๋ **'๋์งํธ ์์ฐ ์์กด ์งํ'**์
๋๋ค.
+
+### 1.1 ์ฐ์ถ ๊ณต์
+$$AVI = e^{-\lambda \times Stagnant\_Days} \times Quality \times 100$$
+
+### 1.2 3๋ ํต์ฌ ๋ณ์ ์์ธ ์ค๋ช
+
+#### โ ์ง์ ๊ฐ์ ๋ชจ๋ธ ($e^{-\lambda \times t}$) : "๊ฐ์น์ ์ํํญํ"
+์์ฐ์ ๊ด๋ฆฌํ์ง ์์ผ๋ฉด ์๊ฐ์ด ํ๋ฅผ์๋ก ๊ฐ์น๊ฐ ๊ธฐํ๊ธ์์ ์ผ๋ก ์๋ฉธํ๋ค๋ **'์ ๋ณด ํ๋ฐ์ฑ'** ์๋ฆฌ๋ฅผ ๋ฐ์ํฉ๋๋ค.
+* **Stagnant Days (์ ์ฒด ์ผ์)**: ๋ง์ง๋ง ์ ํจ ํ๋ ๋ก๊ทธ ๊ธฐ๋ก์ผ๋ก๋ถํฐ ์ค๋๊น์ง ๊ฒฝ๊ณผ๋ ๋ ์ง์
๋๋ค.
+* **ํน์ง**: ์ ์ฒด ์ด๊ธฐ์๋ ์ ์๊ฐ ๋น ๋ฅด๊ฒ ํ๋ฝํ๋ค๊ฐ, ์๊ฐ์ด ์ง๋ ์๋ก ํ๋ฝ ํญ์ด ๋ํ๋๋ฉฐ 0์ ์๋ ดํฉ๋๋ค. ์ด๋ ๊ด๋ฆฌ๊ฐ ์ค๋จ๋ ์งํ์ ์ ๋ณด ๋ง์ค ์ํ์ด ๊ฐ์ฅ ํฌ๋ค๋ ์ค๋ฌด์ ๊ฒฝํ์ ๋ฐ์ํ ๊ฒ์
๋๋ค.
+
+#### โก ์ํ ๊ฐ์ ๊ณ์ ($\lambda$) : "๋ํ ์์ฐ์ ๋์ ๊ด๋ฆฌ ๋น์ฉ"
+๋ชจ๋ ํ๋ก์ ํธ๋ ์์ฐ ๊ท๋ชจ์ ๋ฐ๋ผ '๋์ด๊ฐ๋ ์๋'๊ฐ ๋ค๋ฆ
๋๋ค.
+* **๊ณต์**: $\lambda = 0.04 + \log_{10}(Files + 1) \times 0.008$
+* **๋น์ฆ๋์ค ๋ก์ง**: ํ์ผ์ด ๋ง์ ๋ํ ํ๋ก์ ํธ์ผ์๋ก ๊ด๋ฆฌ ๋ถ์ฌ ์ ์กฐ์ง์ ๋ฏธ์น๋ ํ๊ฒฉ์ด ํฝ๋๋ค. ๋ฐ๋ผ์ ๋ํ ํ๋ก์ ํธ์ผ์๋ก $\lambda$ ๊ฐ์ด ์ปค์ง๋ฉฐ, ์ํ ํ๋ก์ ํธ๋ณด๋ค **ํจ์ฌ ๋น ๋ฅธ ์๋๋ก AVI๊ฐ ํ๋ฝ**ํ๋๋ก ์ค๊ณ๋์์ต๋๋ค. (๋ํ ํ๋ก์ ํธ๋ ๋ ์์ฃผ ๊ด๋ฆฌํด์ผ ์ ์๊ฐ ์ ์ง๋จ)
+
+#### โข ํ๋ ํ์ง ๊ฐ์ค์น ($Quality$) : "ํ์ ๊ณผ ์ค๋ฌด์ ๊ตฌ๋ถ"
+๋จ์ํ ์ ์ํ๊ฑฐ๋ ๋ก๊ทธ๊ฐ ์ฐํ๋ค๊ณ ํด์ ํ๋ ฅ์ด 100% ํ๋ณต๋์ง ์์ต๋๋ค. AI๊ฐ ๋ก๊ทธ ํค์๋๋ฅผ ๋ถ์ํ์ฌ ํ๋์ **'์ง์ ์ฑ'**์ ํ๊ฐํฉ๋๋ค.
+* **High (1.0)**: **์ฑ๊ณผ๋ฌผ ์ค์ฌ ํ๋** (ํ์ผ ์
๋ก๋, ์์ , ๋ฑ๋ก, ์
๋ฐ์ดํธ ๋ฑ)
+* **Medium (0.7)**: **๊ตฌ์กฐ์ ์ ์ง ํ๋** (ํด๋ ์์ฑ, ์ญ์ , ์ด๋ ๋ฑ)
+* **Low (0.4)**: **๋จ์ ํ์ ํ๋** (๊ถํ ๋ณ๊ฒฝ, ๋ฉ์ผ ํ์ธ, ์ฐธ๊ฐ์ ์ถ๊ฐ ๋ฑ)
+
+---
+
+## 2. ์์ฐ ๊ฐ์น ๊ธฐ์ฌ๋ (VCI, Value Contribution Index)
+์ผ๊ตฌ์ **WAR(Wins Above Replacement)** ๊ฐ๋
์ ๋์
ํ์ฌ, ์ ์ฒด ํฌํธํด๋ฆฌ์ค ํ๊ท ๋๋น ๊ฐ๋ณ ํ๋ก์ ํธ๊ฐ ์กฐ์ง ๊ฐ์น์ ์ผ๋ง๋ ๊ธฐ์ฌํ๋์ง ์ฐ์ถํฉ๋๋ค.
+
+### 2.1 ์ฐ์ถ ๊ณต์
+$$VCI = (Individual\_AVI - Portfolio\_Avg\_AVI) \times Asset\_Weight$$
+* **Asset Weight (ํ์ผ ๊ท๋ชจ ๊ฐ์ค์น)**: $max(0.2, \frac{Individual\_Files}{Portfolio\_Avg\_Files})$
+
+### 2.2 ์งํ์ ์๋ฏธ: "ํ๊ท (0.0)์ ๊ธฐ์ค์ผ๋ก ํ ์๋ ํ๊ฐ"
+* **0.0 (ํ๊ท )**: ์กฐ์ง ๋ด ํ๊ท ์ ์ธ ๊ด๋ฆฌ ์์ค๊ณผ ๊ท๋ชจ๋ฅผ ๊ฐ์ง ํ์ค ํ๋ก์ ํธ.
+* **(+) ์ ์**: ํ๊ท ์ด์์ ํ๋ ฅ์ผ๋ก ์กฐ์ง์ ๋์งํธ ์์ฐ ๊ฐ์น๋ฅผ ์ฆ๋์ํค๋ ํ๋ก์ ํธ.
+* **(-) ์ ์**: ํ๊ท ์ดํ์ ๋ฐฉ์น๋ก ์ธํด ์กฐ์ง์ ์ ์ฌ์ ๊ธฐํ๋น์ฉ ์์ค์ ์
ํ๋ ๋ฆฌ์คํฌ ํ๋ก์ ํธ.
+* **์๋ ๊ฐ์ค์น**: ์กฐ์ง์ ํ๊ท ํ์ผ ์๋ณด๋ค ํฐ ํ๋ก์ ํธ๊ฐ ๋ฐฉ์น๋ ๋ ๋ง์ด๋์ค ์ ์๊ฐ ๋ ๊ฐํ๋ฅด๊ฒ ํ๋ฝํ์ฌ **'์ฐ์ ๊ด๋ฆฌ ๋์'**์ ๋ช
ํํ ์๋ณํฉ๋๋ค.
+
+---
+
+## 3. ๊ฐ๋ ฅํ ์์ธ ์ฒ๋ฆฌ (Hard Rules)
+
+๋ฐ์ดํฐ์ ์ ๋ขฐ๋๋ฅผ ํ๋ณดํ๊ธฐ ์ํด ๋ค์๊ณผ ๊ฐ์ **'์ฌ๋ง ํ์ '** ๊ท์น์ด ์ ์ฉ๋ฉ๋๋ค.
+1. **์๋ ์ญ์ ํจ๋ํฐ**: ์ต๊ทผ ๋ก๊ทธ๊ฐ ์์คํ
์ ์ํ 'ํด๋์๋์ญ์ '์ธ ๊ฒฝ์ฐ, AVI๋ ์ฆ์ **0.1%**๋ก ๊ณ ์ ๋ฉ๋๋ค. (๊ด๋ฆฌ ํฌ๊ธฐ ์ํ)
+2. **์์ฐ ๋ถ์ฌ ํจ๋ํฐ (ECV)**: ํ์ผ ๊ฐ์๊ฐ 0๊ฐ์ธ ๊ฒฝ์ฐ ์ด์ ์ผ๊ด์ฑ(OCI)์ **0.0์ **์ด๋ฉฐ, ํ์ผ 10๊ฐ ๋ฏธ๋ง์ ์ต์ข
๊ฐ์ค์น์ **50% ํจ๋ํฐ**๋ฅผ ์ ์ฉํ์ฌ '๊ป๋ฐ๊ธฐ ํ๋ก์ ํธ'๋ฅผ ๊ฑธ๋ฌ๋
๋๋ค.
+
+---
+
+## 4. ๋ฐํ ๋ฐ ๋ถ์ ๊ฐ์ด๋ (Executive Summary)
+
+* **AVI๊ฐ ๋ฎ์ ํ๋ก์ ํธ**: "๋ฐ์ดํฐ๊ฐ ๋ก์๊ฐ๊ณ ์์ผ๋ ์ฆ์ ์ต์ ์ฑ๊ณผ๋ฌผ์ ์
๋ฐ์ดํธํ์ญ์์ค."
+* **VCI๊ฐ ์์(-)์ธ ๋ํ ํ๋ก์ ํธ**: "์กฐ์ง์์ ๊ฐ์ฅ ์ค์ํ ์์ฐ์์๋ ๋ถ๊ตฌํ๊ณ ํ๊ท ์ดํ๋ก ๋ฐฉ์น๋๊ณ ์์ต๋๋ค. **์ต์ฐ์ ๊ด๋ฆฌ ๋์**์
๋๋ค."
+* **OCI๊ฐ ๋ฎ์ ํ๋ก์ ํธ**: "ํ๋์ ์์ผ๋ ๋ถ๊ท์นํฉ๋๋ค. ๊ด๋ฆฌ์ ์ง์์ฑ์ ํ๋ณดํ์ฌ ์ด์ ๋ฆฌ๋ฌ์ ์ฐพ์ผ์ญ์์ค."
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..994f8f5
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,40 @@
+# 1. ๋ฒ ์ด์ค ์ด๋ฏธ์ง ์ค์ (์์ ์ ์ธ bookworm ๋ฒ์ ์ฌ์ฉ)
+FROM python:3.9-slim-bookworm
+
+# 2. ์์คํ
์์กด์ฑ ์ค์น (OCR, PDF, Playwright ๊ด๋ จ ํต์ฌ ๋ผ์ด๋ธ๋ฌ๋ฆฌ)
+RUN apt-get update && apt-get install -y \
+ tesseract-ocr \
+ libtesseract-dev \
+ poppler-utils \
+ libgl1 \
+ libnss3 \
+ libnspr4 \
+ libatk1.0-0 \
+ libatk-bridge2.0-0 \
+ libcups2 \
+ libdrm2 \
+ libxkbcommon0 \
+ libxcomposite1 \
+ libxdamage1 \
+ libxrandr2 \
+ libgbm1 \
+ libpango-1.0-0 \
+ libcairo2 \
+ libasound2 \
+ fonts-liberation \
+ && rm -rf /var/lib/apt/lists/*
+
+# 3. ์์
๋๋ ํ ๋ฆฌ ์ค์
+WORKDIR /app
+
+# 4. ํ์ํ ํจํค์ง ์ค์น
+COPY requirements.txt .
+RUN pip install --no-cache-dir -r requirements.txt
+# ๋ธ๋ผ์ฐ์ ๋ง ์ค์น (์์กด์ฑ์ ์์์ ์ค์นํจ)
+RUN playwright install chromium
+
+# 5. ํ๋ก์ ํธ ์ ์ฒด ํ์ผ ๋ณต์ฌ
+COPY . .
+
+# 6. ์๋ฒ ๊ตฌ๋
+CMD ["uvicorn", "server:app", "--host", "0.0.0.0", "--port", "8000"]
diff --git a/PLAN.md b/PLAN.md
index 79dc477..2fa84fa 100644
--- a/PLAN.md
+++ b/PLAN.md
@@ -1,34 +1,34 @@
-# ๋ฐ์ดํฐ ๋ถ์ ๊ด๋ฆฌ์ ํ์ด์ง ๊ธฐํ์
-
-## 1. ํ๋ก์ ํธ ๊ฐ์
-๋ณธ ํ๋ก์ ํธ๋ ๋ฐ์ดํฐ ๋ถ์ ํ๋ก์ธ์ค ๋ฐ ํ๋ก์ ํธ ๋ฆฌ์์ค๋ฅผ ํตํฉ ๊ด๋ฆฌํ๊ธฐ ์ํ ๊ด๋ฆฌ์ ๋์๋ณด๋์
๋๋ค. ์ฌ์ฉ์ ์ธํฐ๋์
๊ด๋ฆฌ๋ถํฐ ์์คํ
๋ก๊ทธ, ๋ฆฌ์์ค ํํฉ์ ํ๋์ ํ์
ํ๋ ๊ฒ์ ๋ชฉํ๋ก ํฉ๋๋ค.
-
-## 2. ์ฃผ์ ๊ธฐ๋ฅ ์์ธ
-
-### โ ๋ฉ์ผ ๊ด๋ฆฌ ๋ฐ ์๊ตฌ์ฌํญ ์์คํ
(Mail & Inquiry Management) - [์๋ฃ]
-- **UI/UX ๊ณ ๋ํ**: ๋ฆฌ์คํธ ์์ญ ๋๋น ํ์ฅ(400px) ๋ฐ ์๊ฐ์ ๊ฐ๋
์ฑ ๊ฐ์
-- **๊ฒ์ ๋ฐ ํํฐ๋ง**: ํค์๋ ๋ฐ ๊ธฐ๊ฐ๋ณ ๋ฉ์ผ ๊ฒ์ ๊ธฐ๋ฅ ๊ตฌํ
-- **๋์ ์ฐ๋**: ๋ฆฌ์คํธ ํด๋ฆญ ์ ๋ฉ์ผ ๋ณธ๋ฌธ ์ค์๊ฐ ์
๋ฐ์ดํธ ๊ตฌํ
-- **๋ฉ์ผ ๊ด๋ฆฌ**: ๊ฐ๋ณ ์ญ์ ๋ฐ ์ฒดํฌ๋ฐ์ค๋ฅผ ํ์ฉํ ๋๋ ์ญ์ ๊ธฐ๋ฅ ์ถ๊ฐ
-- **ํญ ์์คํ
**: ์์ /๋ฐ์ /์์/ํด์งํต๋ณ ๋ฐ์ดํฐ ๋ถ๋ฅ ๋ฐ ๋์ ๋ ๋๋ง ์ ์ฉ
-
-### โก ๋ก๊ทธ ๊ด๋ฆฌ (Log Management)
-- **์ต๊ทผ ๋ก๊ทธ**: ์ค์๊ฐ์ผ๋ก ๋ฐ์ํ๋ ์์คํ
๋ฐ ๋ถ์ ์์
๋ก๊ทธ ์ถ๋ ฅ
-- **์ ์ฒด ๋ก๊ทธ**: ๋ ์ง๋ณ, ํ๋ก์ ํธ๋ณ ํํฐ๋ง์ ํตํ ๋ก๊ทธ ๊ธฐ๋ก ์กฐํ ๋ฐ ๋ด๋ณด๋ด๊ธฐ
-
-### โข ํ์ผ ๊ด๋ฆฌ (File Management)
-- ํ๋ก์ ํธ๋ณ ๋ฐ์ดํฐ์
, ๋ถ์ ๊ฒฐ๊ณผ๋ฌผ ํ์ผ ๊ฐ์ ๋ฐ ์ฉ๋ ํต๊ณ
-- ํ์ผ ํ์ฅ์๋ณ ๊ตฌ์ฑ ๋น์จ(CSV, JSON, Python ๋ฑ) ์๊ฐํ ์งํ ์ ๊ณต
-
-### โฃ ์ธ์ ๊ด๋ฆฌ (Personnel Management)
-- ํ๋ก์ ํธ ์ฐธ์ฌ ์ธ์ ํํฉ ์กฐํ
-- ์ฌ์ฉ์๋ณ ๊ถํ(๊ด๋ฆฌ์, ๋ถ์๊ฐ, ๋ทฐ์ด) ๋ถ์ฌ ๋ฐ ์์ ๊ธฐ๋ฅ
-
-### โข ๊ณต์ง์ฌํญ (Notice & Patch Notes)
-- ๋ถ์ ๋ชจ๋ธ ์
๋ฐ์ดํธ, ์์คํ
์ ๊ฒ, ํจ์น ๋ด์ญ ๊ณต์
-- ์ฌ์ฉ์ ๋์ ๊ณต์ง์ฌํญ ์์ฑ ๋ฐ ๊ฒ์ํ ๊ด๋ฆฌ
-
-## 3. UI/UX ๊ฐ์ด๋๋ผ์ธ
-- **Layout**: ์ข์ธก ๋ด๋น๊ฒ์ด์
๋ฐ(Sidebar) + ์๋จ ํค๋(Header) + ์ค์ ์ปจํ
์ธ ์์ญ
-- **Theme**: ์ ๋ขฐ๊ฐ์ ์ฃผ๋ Dark Blue / White ํค์ ๊นจ๋ํ ๋์์ธ
-- **Responsiveness**: ๋ค์ํ ํด์๋์ ๋์ํ๋ ๋ฐ์ํ ๋ ์ด์์ ๊ตฌ์ฑ
+# ๋ฐ์ดํฐ ๋ถ์ ๊ด๋ฆฌ์ ํ์ด์ง ๊ธฐํ์
+
+## 1. ํ๋ก์ ํธ ๊ฐ์
+๋ณธ ํ๋ก์ ํธ๋ ๋ฐ์ดํฐ ๋ถ์ ํ๋ก์ธ์ค ๋ฐ ํ๋ก์ ํธ ๋ฆฌ์์ค๋ฅผ ํตํฉ ๊ด๋ฆฌํ๊ธฐ ์ํ ๊ด๋ฆฌ์ ๋์๋ณด๋์
๋๋ค. ์ฌ์ฉ์ ์ธํฐ๋์
๊ด๋ฆฌ๋ถํฐ ์์คํ
๋ก๊ทธ, ๋ฆฌ์์ค ํํฉ์ ํ๋์ ํ์
ํ๋ ๊ฒ์ ๋ชฉํ๋ก ํฉ๋๋ค.
+
+## 2. ์ฃผ์ ๊ธฐ๋ฅ ์์ธ
+
+### โ ๋ฉ์ผ ๊ด๋ฆฌ ๋ฐ ์๊ตฌ์ฌํญ ์์คํ
(Mail & Inquiry Management) - [์๋ฃ]
+- **UI/UX ๊ณ ๋ํ**: ๋ฆฌ์คํธ ์์ญ ๋๋น ํ์ฅ(400px) ๋ฐ ์๊ฐ์ ๊ฐ๋
์ฑ ๊ฐ์
+- **๊ฒ์ ๋ฐ ํํฐ๋ง**: ํค์๋ ๋ฐ ๊ธฐ๊ฐ๋ณ ๋ฉ์ผ ๊ฒ์ ๊ธฐ๋ฅ ๊ตฌํ
+- **๋์ ์ฐ๋**: ๋ฆฌ์คํธ ํด๋ฆญ ์ ๋ฉ์ผ ๋ณธ๋ฌธ ์ค์๊ฐ ์
๋ฐ์ดํธ ๊ตฌํ
+- **๋ฉ์ผ ๊ด๋ฆฌ**: ๊ฐ๋ณ ์ญ์ ๋ฐ ์ฒดํฌ๋ฐ์ค๋ฅผ ํ์ฉํ ๋๋ ์ญ์ ๊ธฐ๋ฅ ์ถ๊ฐ
+- **ํญ ์์คํ
**: ์์ /๋ฐ์ /์์/ํด์งํต๋ณ ๋ฐ์ดํฐ ๋ถ๋ฅ ๋ฐ ๋์ ๋ ๋๋ง ์ ์ฉ
+
+### โก ๋ก๊ทธ ๊ด๋ฆฌ (Log Management)
+- **์ต๊ทผ ๋ก๊ทธ**: ์ค์๊ฐ์ผ๋ก ๋ฐ์ํ๋ ์์คํ
๋ฐ ๋ถ์ ์์
๋ก๊ทธ ์ถ๋ ฅ
+- **์ ์ฒด ๋ก๊ทธ**: ๋ ์ง๋ณ, ํ๋ก์ ํธ๋ณ ํํฐ๋ง์ ํตํ ๋ก๊ทธ ๊ธฐ๋ก ์กฐํ ๋ฐ ๋ด๋ณด๋ด๊ธฐ
+
+### โข ํ์ผ ๊ด๋ฆฌ (File Management)
+- ํ๋ก์ ํธ๋ณ ๋ฐ์ดํฐ์
, ๋ถ์ ๊ฒฐ๊ณผ๋ฌผ ํ์ผ ๊ฐ์ ๋ฐ ์ฉ๋ ํต๊ณ
+- ํ์ผ ํ์ฅ์๋ณ ๊ตฌ์ฑ ๋น์จ(CSV, JSON, Python ๋ฑ) ์๊ฐํ ์งํ ์ ๊ณต
+
+### โฃ ์ธ์ ๊ด๋ฆฌ (Personnel Management)
+- ํ๋ก์ ํธ ์ฐธ์ฌ ์ธ์ ํํฉ ์กฐํ
+- ์ฌ์ฉ์๋ณ ๊ถํ(๊ด๋ฆฌ์, ๋ถ์๊ฐ, ๋ทฐ์ด) ๋ถ์ฌ ๋ฐ ์์ ๊ธฐ๋ฅ
+
+### โข ๊ณต์ง์ฌํญ (Notice & Patch Notes)
+- ๋ถ์ ๋ชจ๋ธ ์
๋ฐ์ดํธ, ์์คํ
์ ๊ฒ, ํจ์น ๋ด์ญ ๊ณต์
+- ์ฌ์ฉ์ ๋์ ๊ณต์ง์ฌํญ ์์ฑ ๋ฐ ๊ฒ์ํ ๊ด๋ฆฌ
+
+## 3. UI/UX ๊ฐ์ด๋๋ผ์ธ
+- **Layout**: ์ข์ธก ๋ด๋น๊ฒ์ด์
๋ฐ(Sidebar) + ์๋จ ํค๋(Header) + ์ค์ ์ปจํ
์ธ ์์ญ
+- **Theme**: ์ ๋ขฐ๊ฐ์ ์ฃผ๋ Dark Blue / White ํค์ ๊นจ๋ํ ๋์์ธ
+- **Responsiveness**: ๋ค์ํ ํด์๋์ ๋์ํ๋ ๋ฐ์ํ ๋ ์ด์์ ๊ตฌ์ฑ
diff --git a/README.md b/README.md
index afa860c..bf6f40b 100644
--- a/README.md
+++ b/README.md
@@ -1,86 +1,86 @@
-# ๐ ์๋ฒ ์ ์ฑ
(Server Policy)
-
-**์๋ฒ ๊ตฌ๋ ์ ๋ฐ๋์ ์๋ ๋ช
๋ น์ด๋ฅผ ์ฌ์ฉํ๋ค:**
-```bash
-uvicorn server:app --host 0.0.0.0 --port 8000 --reload
-```
-- **Host**: `0.0.0.0` (์ธ๋ถ ์ ์ ํ์ฉ)
-- **Port**: `8000`
-- **Reload**: ์ฝ๋ ์์ ์ ์๋ ์ฌ์์ ํ์ฑํ
-
----
-
-# ๐ค ๋ฉ์ผ์์คํ
AIํ๋จ๊ฐ์ด๋ (AI Reasoning Guide)
-
-AI๋ ํ์ผ์ ๋ถ๋ฅํ ๋ ๋จ์ํ ํค์๋ ๋งค์นญ์ด ์๋, ์๋์ **5๋จ๊ณ ํตํฉ ์ถ๋ก ๋ชจ๋ธ**์ ์ฌ์ฉํ์ฌ '์ค๋ฌด์์ฒ๋ผ' ์๊ฐํ๊ณ ํ๋จํ๋ค.
-
-### 1๋จ๊ณ: ์ ์ ๋ฐ์ดํฐ ์์ง (Holistic Reading)
-- **๋ฌด์ ํ ์ค์บ**: ํ์ด์ง ์์ ๊ด๊ณ์์ด ๋ฌธ์ ์ ์ฒด๋ฅผ ์ ์ ์กฐ์ฌํ๋ค.
-- **๋ฌด์กฐ๊ฑด์ OCR**: ๋์งํธ ํ
์คํธ ์ ๋ฌด์ ์๊ด์์ด ๋ชจ๋ ํ์ด์ง์ ๊ณ ํด์๋(300 DPI) OCR์ ์คํํ์ฌ ์ด๋ฏธ์ง ์ ๋์ฅ, ์๊ธฐ, ํ ๋ฐ์ดํฐ๊น์ง ์๋ฒฝํ ์์งํ๋ค.
-
-### 2๋จ๊ณ: ํ์ผ๋ช
๊ฐ์ค์น ์ ์ฉ (Title Steering)
-- **ํ์ผ๋ช
= ๋ณด๊ด ์๋**: ์ฌ์ฉ์๊ฐ ์ง์ ํ์ผ๋ช
์ ๋ถ๋ฅ์ ๊ฐ์ฅ ๊ฐ๋ ฅํ '๋ฐฉํฅํ'์ด๋ค.
-- **์ต์ข
์กฐ์จ**: ๋ณธ๋ฌธ์ ๋ฐ์ดํฐ๊ฐ ๋ค๋ฅธ ๋๋ฉ์ธ์ ์ ๋ ค ์๋๋ผ๋, ํ์ผ๋ช
์ ๋ช
ํํ ์
๋ฌด ์ฉ์ด(`์ค์ ๋ณด๊ณ `, `ํ๋๊ธ` ๋ฑ)๊ฐ ์๋ค๋ฉด ์ด๋ฅผ ์ต์ข
๋ถ๋ฅ์ ๊ฐ์ฅ ํฐ ๋ฌด๊ฒ์ถ๋ก ์ผ๋๋ค.
-
-### 3๋จ๊ณ: ๋ฌธ์์ ๋ฌผ๋ฆฌ์ ํ(Format) ๋ถ์
-- **๊ณต๋ฌธ ๊ณจ๊ฒฉ ํ์ธ**: ๋ฌธ์์ ์์(`์์ /๋ฐ์ `)๊ณผ ๋(`์ง์ธ/๋.`)์ ๊ตฌ์กฐ๋ฅผ ํ์ธํ๋ค.
-- **๊ป๋ฐ๊ธฐ vs ์๋งน์ด**:
- - **๊ณต๋ฌธ ๋ณธ์ฒด**: ๊ณจ๊ฒฉ์ด ์๋ฒฝํ๊ณ ๋ค๋ฐ๋ฅด๋ ๊ธฐ์ ๋ฐ์ดํฐ๊ฐ ์ ์ ๊ฒฝ์ฐ โ **[๊ณต์ฌ๊ด๋ฆฌ > ๊ณต๋ฌธ]**
- - **์ฒจ๋ถ ๋ณธ์ฒด**: ๊ณต๋ฌธ ๋ค์ ๋๋์ ์ฐ์ถ์, ๊ณ์ฝ์, ๋๋ฉด์ด ๋ถ์ด ์๋ ๊ฒฝ์ฐ โ **[ํด๋น ๊ธฐ์ ์นดํ
๊ณ ๋ฆฌ]** (๊ณต๋ฌธ์ ์ ๋ฌ ์๋จ์ผ๋ก๋ง ๊ฐ์ฃผ)
-
-### 4๋จ๊ณ: ๋น์ฆ๋์ค ๋๋ฉ์ธ ์์ ๊ฒฐํฉ (Common Sense)
-- **์ง๋ช
๊ต์ฐจ ๊ฒ์ฆ**: ํ์ผ๋ช
๊ณผ ๋ณธ๋ฌธ์ ์ง๋ช
(์ด์ฒ, ๊ณต์ฃผ, ๋์ , ์ ์ ๋ฑ)์ ๋์กฐํ์ฌ ์ ํํ ํ๋ก์ ํธ๋ฅผ ์ ํํ๋ค. (์์ ๊ธฐ๋ณธ๊ฐ ์ง์ ๊ธ์ง)
-- **์ค๋ฌด ๋งฅ๋ฝ ๋งค์นญ**: '์๋๋ฃ/์ฐ์ฅ'์ ์ฌ์
๋น ์ฑ๊ฒฉ์ '๊ธฐํ'๋ก, '๋น๊ณ'๋ '๊ตฌ์กฐ๋ฌผ'๋ก ์ฐ๊ฒฐํ๋ ๋ฑ ๊ฑด์ค ์ค๋ฌด ์์์ ์ถ๋ก ์ ๋ฐ์ํ๋ค.
-
-### 5๋จ๊ณ: ์ต์ข
์ง๋ ๋งค์นญ (Hierarchy Mapping)
-- ์์ง๋ ๋ชจ๋ ์ ๋ณด๋ฅผ ์ข
ํฉํ์ฌ ์ฌ์ฉ์๊ฐ ์ ์ํ **ํ์ค ๋ถ๋ฅ ์ฒด๊ณ(Tab > Category > Sub)** ์ง๋ ์์์ ๊ฐ์ฅ ๋
ผ๋ฆฌ์ ์ด๊ณ ์ค๋ฌด์ ์ธ ์์น๋ฅผ ์ต์ข
ํ์ ํ๋ค.
-
----
-
-# ๐ ๏ธ ๊ฐ๋ฐ ๋ฐ ๊ด๋ฆฌ ๊ท์น (Strict Development Rules)
-
-1. **์ธ์ด ์ค์ **: ์์ด๋ก ์๊ฐํ๋, ๋ชจ๋ ๋ต๋ณ์ **ํ๊ตญ์ด**๋ก ์์ฑํ๋ค.
-2. **์์ ์์ ์ ๋ ๊ธ์ง (Zero-Arbitrary Change)**:
- - ์ฌ์ฉ์๊ฐ ๋ช
์์ ์ผ๋ก ์ง์ํ ๋ถ๋ถ ์ธ์๋ **๋จ ํ ์ค์ ์ฝ๋๋, ๊ทธ ์ด๋ค ํ์ผ๋ ์์๋ก ์์ , ์ ๋ฆฌ, ๋ฆฌํฉํ ๋งํ์ง ์๋๋ค.**
- - ์ง์๋ฐ์ง ์์ ๋ค๋ฅธ ํํธ์ ์ฝ๋๋ ์ ๋ ๊ฑด๋๋ฆฌ์ง ์์ผ๋ฉฐ, ์ํฅ ๋ฒ์๊ฐ ์์ฒญ ๋ฒ์๋ฅผ ๋ฒ์ด๋์ง ์๋๋ก '์ธ๊ณผ ์์ ์(Surgical) ์์ '์ ์์น์ผ๋ก ํ๋ค.
-3. **๊ฐ์ ์์
์ ์ฐจ (Test-First Approach)**:
- - ์ฌ์ฉ์๊ฐ ๊ฐ์ (Refactoring, Optimization ๋ฑ)์ ์ง์ํ ๊ฒฝ์ฐ, **์์ ์ ํ์ฌ ์์คํ
์ด ์ ์์ ์ผ๋ก ์ ์๋ํ๋์ง ๋จผ์ ์ ์ ํ์ธ**ํ๋ค.
- - ๊ธฐ์กด ๋์ ๋ฐฉ์๊ณผ ์ฑ๋ฅ์ ๊ธฐ์ค(Baseline)์ผ๋ก ์ผ๊ณ , ์์ ํ์๋ **๊ธฐ์กด์ ๋ชจ๋ ๊ธฐ๋ฅ์ด ๋ฌด๊ฒฐํ๊ฒ ์ ์ง๋๋์ง ๋ฐ๋์ ํ
์คํธํ์ฌ ์
์ฆ**ํ๋ค.
- - ๊ฒ์ฆ ๊ฒฐ๊ณผ๋ฅผ ๋ฐํ์ผ๋ก "๋ฌด์์, ์, ์ด๋ป๊ฒ" ๋ฐ๊ฟ์ง ์์ธ ๋ณด๊ณ ํ, ์ฌ์ฉ์๋ก๋ถํฐ **'์งํ์์ผ'** ์น์ธ์ ์ป์ ๋ค์๋ง ์งํํ๋ค.
-4. **์ ๋ณด๊ณ ํ์น์ธ**: ๋ชจ๋ ๊ธฐ๋ฅ ์์ ๋ฐ ์ฝ๋ ๋ณ๊ฒฝ ์ ์๋ ์์ ๋ฐฉ์์ ๋จผ์ ๋ณด๊ณ ํ๊ณ ์น์ธ ์ ์ฐจ๋ฅผ ๊ฑฐ์น๋ค.
-5. **๋ก๊ทธ ๊ธฐ๋ก ์ฒ ์ **: ์งํ ์ํฉ(๋ก๊ทธ์ธ, ์์ง, ์ค๋ฅ ๋ฑ)์ ์ค์๊ฐ ๋ก๊ทธ์ ์์ธํ ํ์ํ์ฌ ํฌ๋ช
์ฑ์ ํ๋ณดํ๋ค.
-
----
-
-## ๐จ ๋์์ธ ๊ฐ์ด๋ (Design System)
-
-์ด ํ๋ก์ ํธ๋ `tokens.json`์ ์ ์๋ ๋์์ธ ์์คํ
์ ์ค์ํฉ๋๋ค.
-
-### 1. ์ปฌ๋ฌ ์์คํ
(Colors)
-- **Primary**: `#1E5149` (primary-lv-6) - ๋ธ๋๋ ํต์ฌ ์ปฌ๋ฌ
-- **Background**: `#FFFFFF` (Light Default) / `#F9FAFB` (Light Muted)
-- **Point Colors**:
- - Blue: `#0D8DF2` (Info)
- - Green: `#4DB251` (Success)
- - Red: `#F21D0D` (Error)
- - Yellow: `#FFBF00` (Warning)
-- **Special**: `ai_color` (Purple-Blue Gradient) - AI ๊ด๋ จ ์์ ์ ์ฉ
-
-### 2. ํ์ดํฌ๊ทธ๋ํผ (Typography)
-- **Font Family**: `Pretendard`, `sans-serif`
-- **Scale**:
- - **H1**: 20px / ExtraBold (pretendard-0)
- - **H2**: 16px / SemiBold (pretendard-1)
- - **H3/H4**: 14px / SemiBold or Regular
- - **Body/P**: 12px / Regular (pretendard-2)
-
-### 3. ๋ ์ด์์ ๋ฐ ๊ฐ๊ฒฉ (Dimensions)
-- **Spacing Unit**: Base 4px (xs: 4px, sm: 8px, md: 16px, lg: 32px, xl: 64px)
-- **Border Radius**: sm: 4px, lg: 8px, xl: 16px
-- **Shadow**: `0 8px 24px rgba(0,0,0,0.16)` (box__drop-shadow)
-
-### 4. ์ปดํฌ๋ํธ ๊ท์น
-- **Buttons**: `borderRadius.lg (8px)` ์ ์ฉ, Primary ๋ฐฐ๊ฒฝ์ ์ฌ์ฉ
-- **Cards**: `borderRadius.lg (8px)` ์ ์ฉ, Subtle Shadow ํ์ฉ
-- **Topbar**: Height 36px, `headercolor` ๊ทธ๋ผ๋ฐ์ด์
์ ์ฉ ๊ฐ๋ฅ
-
+# ๐ ์๋ฒ ์ ์ฑ
(Server Policy)
+
+**์๋ฒ ๊ตฌ๋ ์ ๋ฐ๋์ ์๋ ๋ช
๋ น์ด๋ฅผ ์ฌ์ฉํ๋ค:**
+```bash
+uvicorn server:app --host 0.0.0.0 --port 8000 --reload
+```
+- **Host**: `0.0.0.0` (์ธ๋ถ ์ ์ ํ์ฉ)
+- **Port**: `8000`
+- **Reload**: ์ฝ๋ ์์ ์ ์๋ ์ฌ์์ ํ์ฑํ
+
+---
+
+# ๐ค ๋ฉ์ผ์์คํ
AIํ๋จ๊ฐ์ด๋ (AI Reasoning Guide)
+
+AI๋ ํ์ผ์ ๋ถ๋ฅํ ๋ ๋จ์ํ ํค์๋ ๋งค์นญ์ด ์๋, ์๋์ **5๋จ๊ณ ํตํฉ ์ถ๋ก ๋ชจ๋ธ**์ ์ฌ์ฉํ์ฌ '์ค๋ฌด์์ฒ๋ผ' ์๊ฐํ๊ณ ํ๋จํ๋ค.
+
+### 1๋จ๊ณ: ์ ์ ๋ฐ์ดํฐ ์์ง (Holistic Reading)
+- **๋ฌด์ ํ ์ค์บ**: ํ์ด์ง ์์ ๊ด๊ณ์์ด ๋ฌธ์ ์ ์ฒด๋ฅผ ์ ์ ์กฐ์ฌํ๋ค.
+- **๋ฌด์กฐ๊ฑด์ OCR**: ๋์งํธ ํ
์คํธ ์ ๋ฌด์ ์๊ด์์ด ๋ชจ๋ ํ์ด์ง์ ๊ณ ํด์๋(300 DPI) OCR์ ์คํํ์ฌ ์ด๋ฏธ์ง ์ ๋์ฅ, ์๊ธฐ, ํ ๋ฐ์ดํฐ๊น์ง ์๋ฒฝํ ์์งํ๋ค.
+
+### 2๋จ๊ณ: ํ์ผ๋ช
๊ฐ์ค์น ์ ์ฉ (Title Steering)
+- **ํ์ผ๋ช
= ๋ณด๊ด ์๋**: ์ฌ์ฉ์๊ฐ ์ง์ ํ์ผ๋ช
์ ๋ถ๋ฅ์ ๊ฐ์ฅ ๊ฐ๋ ฅํ '๋ฐฉํฅํ'์ด๋ค.
+- **์ต์ข
์กฐ์จ**: ๋ณธ๋ฌธ์ ๋ฐ์ดํฐ๊ฐ ๋ค๋ฅธ ๋๋ฉ์ธ์ ์ ๋ ค ์๋๋ผ๋, ํ์ผ๋ช
์ ๋ช
ํํ ์
๋ฌด ์ฉ์ด(`์ค์ ๋ณด๊ณ `, `ํ๋๊ธ` ๋ฑ)๊ฐ ์๋ค๋ฉด ์ด๋ฅผ ์ต์ข
๋ถ๋ฅ์ ๊ฐ์ฅ ํฐ ๋ฌด๊ฒ์ถ๋ก ์ผ๋๋ค.
+
+### 3๋จ๊ณ: ๋ฌธ์์ ๋ฌผ๋ฆฌ์ ํ(Format) ๋ถ์
+- **๊ณต๋ฌธ ๊ณจ๊ฒฉ ํ์ธ**: ๋ฌธ์์ ์์(`์์ /๋ฐ์ `)๊ณผ ๋(`์ง์ธ/๋.`)์ ๊ตฌ์กฐ๋ฅผ ํ์ธํ๋ค.
+- **๊ป๋ฐ๊ธฐ vs ์๋งน์ด**:
+ - **๊ณต๋ฌธ ๋ณธ์ฒด**: ๊ณจ๊ฒฉ์ด ์๋ฒฝํ๊ณ ๋ค๋ฐ๋ฅด๋ ๊ธฐ์ ๋ฐ์ดํฐ๊ฐ ์ ์ ๊ฒฝ์ฐ โ **[๊ณต์ฌ๊ด๋ฆฌ > ๊ณต๋ฌธ]**
+ - **์ฒจ๋ถ ๋ณธ์ฒด**: ๊ณต๋ฌธ ๋ค์ ๋๋์ ์ฐ์ถ์, ๊ณ์ฝ์, ๋๋ฉด์ด ๋ถ์ด ์๋ ๊ฒฝ์ฐ โ **[ํด๋น ๊ธฐ์ ์นดํ
๊ณ ๋ฆฌ]** (๊ณต๋ฌธ์ ์ ๋ฌ ์๋จ์ผ๋ก๋ง ๊ฐ์ฃผ)
+
+### 4๋จ๊ณ: ๋น์ฆ๋์ค ๋๋ฉ์ธ ์์ ๊ฒฐํฉ (Common Sense)
+- **์ง๋ช
๊ต์ฐจ ๊ฒ์ฆ**: ํ์ผ๋ช
๊ณผ ๋ณธ๋ฌธ์ ์ง๋ช
(์ด์ฒ, ๊ณต์ฃผ, ๋์ , ์ ์ ๋ฑ)์ ๋์กฐํ์ฌ ์ ํํ ํ๋ก์ ํธ๋ฅผ ์ ํํ๋ค. (์์ ๊ธฐ๋ณธ๊ฐ ์ง์ ๊ธ์ง)
+- **์ค๋ฌด ๋งฅ๋ฝ ๋งค์นญ**: '์๋๋ฃ/์ฐ์ฅ'์ ์ฌ์
๋น ์ฑ๊ฒฉ์ '๊ธฐํ'๋ก, '๋น๊ณ'๋ '๊ตฌ์กฐ๋ฌผ'๋ก ์ฐ๊ฒฐํ๋ ๋ฑ ๊ฑด์ค ์ค๋ฌด ์์์ ์ถ๋ก ์ ๋ฐ์ํ๋ค.
+
+### 5๋จ๊ณ: ์ต์ข
์ง๋ ๋งค์นญ (Hierarchy Mapping)
+- ์์ง๋ ๋ชจ๋ ์ ๋ณด๋ฅผ ์ข
ํฉํ์ฌ ์ฌ์ฉ์๊ฐ ์ ์ํ **ํ์ค ๋ถ๋ฅ ์ฒด๊ณ(Tab > Category > Sub)** ์ง๋ ์์์ ๊ฐ์ฅ ๋
ผ๋ฆฌ์ ์ด๊ณ ์ค๋ฌด์ ์ธ ์์น๋ฅผ ์ต์ข
ํ์ ํ๋ค.
+
+---
+
+# ๐ ๏ธ ๊ฐ๋ฐ ๋ฐ ๊ด๋ฆฌ ๊ท์น (Strict Development Rules)
+
+1. **์ธ์ด ์ค์ **: ์์ด๋ก ์๊ฐํ๋, ๋ชจ๋ ๋ต๋ณ์ **ํ๊ตญ์ด**๋ก ์์ฑํ๋ค.
+2. **์์ ์์ ์ ๋ ๊ธ์ง (Zero-Arbitrary Change)**:
+ - ์ฌ์ฉ์๊ฐ ๋ช
์์ ์ผ๋ก ์ง์ํ ๋ถ๋ถ ์ธ์๋ **๋จ ํ ์ค์ ์ฝ๋๋, ๊ทธ ์ด๋ค ํ์ผ๋ ์์๋ก ์์ , ์ ๋ฆฌ, ๋ฆฌํฉํ ๋งํ์ง ์๋๋ค.**
+ - ์ง์๋ฐ์ง ์์ ๋ค๋ฅธ ํํธ์ ์ฝ๋๋ ์ ๋ ๊ฑด๋๋ฆฌ์ง ์์ผ๋ฉฐ, ์ํฅ ๋ฒ์๊ฐ ์์ฒญ ๋ฒ์๋ฅผ ๋ฒ์ด๋์ง ์๋๋ก '์ธ๊ณผ ์์ ์(Surgical) ์์ '์ ์์น์ผ๋ก ํ๋ค.
+3. **๊ฐ์ ์์
์ ์ฐจ (Test-First Approach)**:
+ - ์ฌ์ฉ์๊ฐ ๊ฐ์ (Refactoring, Optimization ๋ฑ)์ ์ง์ํ ๊ฒฝ์ฐ, **์์ ์ ํ์ฌ ์์คํ
์ด ์ ์์ ์ผ๋ก ์ ์๋ํ๋์ง ๋จผ์ ์ ์ ํ์ธ**ํ๋ค.
+ - ๊ธฐ์กด ๋์ ๋ฐฉ์๊ณผ ์ฑ๋ฅ์ ๊ธฐ์ค(Baseline)์ผ๋ก ์ผ๊ณ , ์์ ํ์๋ **๊ธฐ์กด์ ๋ชจ๋ ๊ธฐ๋ฅ์ด ๋ฌด๊ฒฐํ๊ฒ ์ ์ง๋๋์ง ๋ฐ๋์ ํ
์คํธํ์ฌ ์
์ฆ**ํ๋ค.
+ - ๊ฒ์ฆ ๊ฒฐ๊ณผ๋ฅผ ๋ฐํ์ผ๋ก "๋ฌด์์, ์, ์ด๋ป๊ฒ" ๋ฐ๊ฟ์ง ์์ธ ๋ณด๊ณ ํ, ์ฌ์ฉ์๋ก๋ถํฐ **'์งํ์์ผ'** ์น์ธ์ ์ป์ ๋ค์๋ง ์งํํ๋ค.
+4. **์ ๋ณด๊ณ ํ์น์ธ**: ๋ชจ๋ ๊ธฐ๋ฅ ์์ ๋ฐ ์ฝ๋ ๋ณ๊ฒฝ ์ ์๋ ์์ ๋ฐฉ์์ ๋จผ์ ๋ณด๊ณ ํ๊ณ ์น์ธ ์ ์ฐจ๋ฅผ ๊ฑฐ์น๋ค.
+5. **๋ก๊ทธ ๊ธฐ๋ก ์ฒ ์ **: ์งํ ์ํฉ(๋ก๊ทธ์ธ, ์์ง, ์ค๋ฅ ๋ฑ)์ ์ค์๊ฐ ๋ก๊ทธ์ ์์ธํ ํ์ํ์ฌ ํฌ๋ช
์ฑ์ ํ๋ณดํ๋ค.
+
+---
+
+## ๐จ ๋์์ธ ๊ฐ์ด๋ (Design System)
+
+์ด ํ๋ก์ ํธ๋ `tokens.json`์ ์ ์๋ ๋์์ธ ์์คํ
์ ์ค์ํฉ๋๋ค.
+
+### 1. ์ปฌ๋ฌ ์์คํ
(Colors)
+- **Primary**: `#1E5149` (primary-lv-6) - ๋ธ๋๋ ํต์ฌ ์ปฌ๋ฌ
+- **Background**: `#FFFFFF` (Light Default) / `#F9FAFB` (Light Muted)
+- **Point Colors**:
+ - Blue: `#0D8DF2` (Info)
+ - Green: `#4DB251` (Success)
+ - Red: `#F21D0D` (Error)
+ - Yellow: `#FFBF00` (Warning)
+- **Special**: `ai_color` (Purple-Blue Gradient) - AI ๊ด๋ จ ์์ ์ ์ฉ
+
+### 2. ํ์ดํฌ๊ทธ๋ํผ (Typography)
+- **Font Family**: `Pretendard`, `sans-serif`
+- **Scale**:
+ - **H1**: 20px / ExtraBold (pretendard-0)
+ - **H2**: 16px / SemiBold (pretendard-1)
+ - **H3/H4**: 14px / SemiBold or Regular
+ - **Body/P**: 12px / Regular (pretendard-2)
+
+### 3. ๋ ์ด์์ ๋ฐ ๊ฐ๊ฒฉ (Dimensions)
+- **Spacing Unit**: Base 4px (xs: 4px, sm: 8px, md: 16px, lg: 32px, xl: 64px)
+- **Border Radius**: sm: 4px, lg: 8px, xl: 16px
+- **Shadow**: `0 8px 24px rgba(0,0,0,0.16)` (box__drop-shadow)
+
+### 4. ์ปดํฌ๋ํธ ๊ท์น
+- **Buttons**: `borderRadius.lg (8px)` ์ ์ฉ, Primary ๋ฐฐ๊ฒฝ์ ์ฌ์ฉ
+- **Cards**: `borderRadius.lg (8px)` ์ ์ฉ, Subtle Shadow ํ์ฉ
+- **Topbar**: Height 36px, `headercolor` ๊ทธ๋ผ๋ฐ์ด์
์ ์ฉ ๊ฐ๋ฅ
+
diff --git a/__pycache__/analysis_service.cpython-312.pyc b/__pycache__/analysis_service.cpython-312.pyc
deleted file mode 100644
index d34751f..0000000
Binary files a/__pycache__/analysis_service.cpython-312.pyc and /dev/null differ
diff --git a/__pycache__/analyze.cpython-312.pyc b/__pycache__/analyze.cpython-312.pyc
deleted file mode 100644
index f44c613..0000000
Binary files a/__pycache__/analyze.cpython-312.pyc and /dev/null differ
diff --git a/__pycache__/crawler_api.cpython-312.pyc b/__pycache__/crawler_api.cpython-312.pyc
deleted file mode 100644
index 4ffa45d..0000000
Binary files a/__pycache__/crawler_api.cpython-312.pyc and /dev/null differ
diff --git a/__pycache__/crawler_service.cpython-312.pyc b/__pycache__/crawler_service.cpython-312.pyc
deleted file mode 100644
index df7e01a..0000000
Binary files a/__pycache__/crawler_service.cpython-312.pyc and /dev/null differ
diff --git a/__pycache__/inquiry_service.cpython-312.pyc b/__pycache__/inquiry_service.cpython-312.pyc
deleted file mode 100644
index 8b39214..0000000
Binary files a/__pycache__/inquiry_service.cpython-312.pyc and /dev/null differ
diff --git a/__pycache__/prediction_service.cpython-312.pyc b/__pycache__/prediction_service.cpython-312.pyc
deleted file mode 100644
index d80a302..0000000
Binary files a/__pycache__/prediction_service.cpython-312.pyc and /dev/null differ
diff --git a/__pycache__/project_service.cpython-312.pyc b/__pycache__/project_service.cpython-312.pyc
deleted file mode 100644
index 76db698..0000000
Binary files a/__pycache__/project_service.cpython-312.pyc and /dev/null differ
diff --git a/__pycache__/schemas.cpython-312.pyc b/__pycache__/schemas.cpython-312.pyc
deleted file mode 100644
index 0590f30..0000000
Binary files a/__pycache__/schemas.cpython-312.pyc and /dev/null differ
diff --git a/__pycache__/server.cpython-312.pyc b/__pycache__/server.cpython-312.pyc
deleted file mode 100644
index 17d489e..0000000
Binary files a/__pycache__/server.cpython-312.pyc and /dev/null differ
diff --git a/__pycache__/sql_queries.cpython-312.pyc b/__pycache__/sql_queries.cpython-312.pyc
deleted file mode 100644
index b7d27ec..0000000
Binary files a/__pycache__/sql_queries.cpython-312.pyc and /dev/null differ
diff --git a/analysis_service.py b/analysis_service.py
index 2f8f1f0..d543681 100644
--- a/analysis_service.py
+++ b/analysis_service.py
@@ -1,198 +1,270 @@
-import re
-import math
-import statistics
-from datetime import datetime, timedelta
-from sql_queries import DashboardQueries
-from prediction_service import SOIPredictionService
-
-class AnalysisService:
- """ํ๋ก์ ํธ ํต๊ณ ๋ฐ ํ๋์ฑ ๋ถ์ ์ ๋ฌธ ์๋น์ค"""
-
- @staticmethod
- def calculate_operational_consistency(history_rows, days_stagnant):
- """์ด์ ์ผ๊ด์ฑ ์ง์(OCI) ์ฐ์ถ ๋ก์ง (์ฅ๊ธฐ ์ ์ฒด ํจ๋ํฐ ํฌํจ)
- ์ต๊ทผ 30์ผ๊ฐ ํ๋ ๋ฆฌ๋ฌ ๋ถ์ + ํ์ฌ ๋ฐฉ์น ๊ธฐ๊ฐ์ ๋ฐ๋ฅธ ๊ฐ๋ ฅํ ๊ฐ์
- """
- if not history_rows or len(history_rows) < 2:
- return 0.0
-
- # 1. ์ต๊ทผ 30์ผ ์ด๋ ฅ ๊ธฐ๋ฐ Base Score ์ฐ์ถ
- now = datetime.now().date()
- recent_30 = [h for h in history_rows if (now - h['crawl_date']).days <= 30]
-
- # ์ฃผ์ฐจ๋ณ ํ๋ ์ฌ๋ถ (4์ฃผ)
- weeks_active = [False, False, False, False]
- for h in recent_30:
- days_ago = (now - h['crawl_date']).days
- week_idx = min(3, days_ago // 7)
- weeks_active[week_idx] = True
-
- base_consistency = (sum(weeks_active) / 4) * 70
-
- # ํ๋ ๋ฐ๋ (๋ณํ ๋ฐ์์ผ ๋น์จ)
- effort_days = 0
- for i in range(1, len(recent_30)):
- if recent_30[i]['file_count'] != recent_30[i-1]['file_count']:
- effort_days += 1
-
- density_score = (effort_days / max(1, len(recent_30))) * 30
- base_oci = base_consistency + density_score
-
- # 2. [ํต์ฌ] ์ฅ๊ธฐ ์ ์ฒด ํจ๋ํฐ ์ ์ฉ
- # ๋ฐฉ์น์ผ์ด 100์ผ ์ด์์ด๋ฉด OCI๋ 0์ ์ผ๋ก ์๋ ด (์ฑ์ค๋ ๋ฌดํจํ)
- stagnation_factor = max(0, (100 - days_stagnant) / 100.0)
-
- final_oci = base_oci * stagnation_factor
-
- return round(final_oci, 1)
-
- @staticmethod
- def calculate_activity_status(target_date_dt, log, file_count):
- """๊ฐ๋ณ ํ๋ก์ ํธ์ ํ๋ ์ํ ๋ฐ ๋ฐฉ์น์ผ ์ฐ์ถ"""
- status, days = "unknown", 999
- file_val = int(file_count) if file_count else 0
- has_log = log and log != "๋ฐ์ดํฐ ์์" and log != "X"
-
- if file_val == 0:
- status = "unknown"
- elif has_log:
- if "ํด๋์๋์ญ์ " in log.replace(" ", ""):
- status = "stale"
- days = 999
- else:
- match = re.search(r'(\d{4})\.(\d{2})\.(\d{2})', log)
- if match:
- log_date = datetime.strptime(match.group(0), "%Y.%m.%d")
- diff = (target_date_dt - log_date).days
- status = "active" if diff <= 7 else "warning" if diff <= 14 else "stale"
- days = diff
- else:
- status = "stale"
- else:
- status = "stale"
-
- return status, days
-
- @staticmethod
- def get_project_activity_logic(cursor, date_str):
- """ํ๋๋ ๋ถ์ ๋ฆฌํฌํธ ์์ฑ ๋ก์ง"""
- if not date_str or date_str == "-":
- cursor.execute(DashboardQueries.GET_LAST_CRAWL_DATE)
- res = cursor.fetchone()
- target_date_val = res['last_date'] if res['last_date'] else datetime.now().date()
- else:
- target_date_val = datetime.strptime(date_str.replace(".", "-"), "%Y-%m-%d").date()
-
- target_date_dt = datetime.combine(target_date_val, datetime.min.time())
- cursor.execute(DashboardQueries.GET_PROJECT_LIST_FOR_ANALYSIS, (target_date_val,))
- rows = cursor.fetchall()
-
- analysis = {"summary": {"active": 0, "warning": 0, "stale": 0, "unknown": 0}, "details": []}
- for r in rows:
- status, days = AnalysisService.calculate_activity_status(target_date_dt, r['recent_log'], r['file_count'])
- analysis["summary"][status] += 1
- analysis["details"].append({"name": r['short_nm'] or r['project_nm'], "status": status, "days_ago": days})
-
- return analysis
-
- @staticmethod
- def get_p_zsr_analysis_logic(cursor):
- """์ ๋์ ๋ฐฉ์น ์คํ ๊ณ ๋ฐ ๋ฐ ์ด์ ์ผ๊ด์ฑ(OCI) ๋ถ์ ๋ก์ง"""
- cursor.execute(DashboardQueries.GET_LAST_CRAWL_DATE)
- res_date = cursor.fetchone()
- if not res_date or not res_date['last_date']:
- return []
- last_date = res_date['last_date']
-
- cursor.execute("""
- SELECT m.project_id, m.project_nm, m.short_nm, m.department, m.master,
- h.recent_log, h.file_count, m.continent, m.country
- FROM projects_master m
- LEFT JOIN projects_history h ON m.project_id = h.project_id AND h.crawl_date = %s
- ORDER BY m.project_id ASC
- """, (last_date,))
- projects = cursor.fetchall()
-
- if not projects: return []
-
- results = []
- total_soi = 0
-
- for p in projects:
- file_count = int(p['file_count']) if p['file_count'] else 0
- log = p['recent_log']
-
- # ๋ฐฉ์น์ผ ๊ณ์ฐ
- days_stagnant = 14
- if log and log != "๋ฐ์ดํฐ ์์":
- match = re.search(r'(\d{4})\.(\d{2})\.(\d{2})', log)
- if match:
- log_date = datetime.strptime(match.group(0), "%Y.%m.%d").date()
- days_stagnant = (last_date - log_date).days
-
- is_auto_delete = log and "ํด๋์๋์ญ์ " in log.replace(" ", "")
-
- # AI-Hazard ์ถ๋ก ๋ก์ง (Dynamic Lambda)
- scale_impact = min(0.04, math.log10(file_count + 1) * 0.008) if file_count > 0 else 0
- ai_lambda = 0.04 + scale_impact
-
- # ์ง์ ๊ฐ์ ์ ์ฉ
- soi_score = math.exp(-ai_lambda * days_stagnant) * 100
-
- # ECV ํจ๋ํฐ
- existence_confidence = 1.0
- if file_count == 0: existence_confidence = 0.05
- elif file_count < 10: existence_confidence = 0.4
-
- # Log Quality Scoring
- log_quality_factor = 1.0
- if log and log != "๋ฐ์ดํฐ ์์":
- if any(k in log for k in ["์
๋ก๋", "์์ ", "๋ฑ๋ก", "๋ณํ", "ํ์ผ", "์
๋ฐ์ดํธ"]): log_quality_factor = 1.0
- elif any(k in log for k in ["ํด๋", "์์ฑ", "์ญ์ ", "์ด๋"]): log_quality_factor = 0.7
- elif any(k in log for k in ["์ฐธ๊ฐ์", "๊ถํ", "์ถ๊ฐ", "๋ณ๊ฒฝ", "๋ฉ์ผ"]): log_quality_factor = 0.4
- else: log_quality_factor = 0.6
-
- soi_score = soi_score * existence_confidence * log_quality_factor
- if is_auto_delete: soi_score = 0.1
-
- # [์ด์ ์ผ๊ด์ฑ ๋ถ์ (OCI)]
- history_rows = SOIPredictionService.get_historical_soi(cursor, p['project_id'])
- oci_score = AnalysisService.calculate_operational_consistency(history_rows, days_stagnant)
-
- # ์ค๋ฌด ํฌ์
์๋์ง ๊ณ์ฐ
- effort_days = 0
- if len(history_rows) > 1:
- for i in range(1, len(history_rows)):
- if history_rows[i]['file_count'] != history_rows[i-1]['file_count']:
- effort_days += 1
-
- work_effort_rate = round((effort_days / max(1, len(history_rows))) * 100, 1)
- total_soi += soi_score
-
- # VCI ์ฐ์ถ
- REPLACEMENT_LEVEL = 70.0
- asset_weight = (file_count / 200.0) + 0.5
- p_war_score = (soi_score - REPLACEMENT_LEVEL) * asset_weight
-
- results.append({
- "project_nm": p['short_nm'] or p['project_nm'],
- "file_count": file_count,
- "days_stagnant": days_stagnant,
- "risk_count": round(p_war_score, 2),
- "p_war": round(soi_score, 1),
- "oci_score": oci_score, # ์ด์ ์ผ๊ด์ฑ ์ง์ ์ถ๊ฐ
- "is_auto_delete": is_auto_delete,
- "master": p['master'],
- "dept": p['department'],
- "ai_lambda": round(ai_lambda, 4),
- "log_quality": log_quality_factor,
- "work_effort": work_effort_rate,
- "avg_info": {
- "avg_files": 0,
- "avg_stagnant": 0,
- "avg_risk": round(total_soi / len(projects), 1)
- }
- })
-
- results.sort(key=lambda x: x['p_war'])
- return results
+import re
+import math
+import statistics
+from datetime import datetime, timedelta
+from sql_queries import DashboardQueries
+from prediction_service import SOIPredictionService
+
+class AnalysisService:
+ """ํ๋ก์ ํธ ํต๊ณ ๋ฐ ํ๋์ฑ ๋ถ์ ์ ๋ฌธ ์๋น์ค"""
+
+ @staticmethod
+ def calculate_operational_consistency(history_rows, days_stagnant):
+ """์ด์ ์ผ๊ด์ฑ ์ง์(OCI) ์ฐ์ถ ๋ก์ง (์์ฐ ๊ท๋ชจ ๋ฐ ์ฅ๊ธฐ ์ ์ฒด ํจ๋ํฐ ํฌํจ)
+ ์ต๊ทผ 30์ผ๊ฐ ํ๋ ๋ฆฌ๋ฌ ๋ถ์ + ํ์ฌ ๋ฐฉ์น ๊ธฐ๊ฐ์ ๋ฐ๋ฅธ ๊ฐ๋ ฅํ ๊ฐ์
+ """
+ if not history_rows or len(history_rows) < 2:
+ return 0.0
+
+ # [์ถ๊ฐ] ์ต์ ์ํ ํ์ธ: ํ์ฌ ๋ก๊ทธ๊ฐ 'ํด๋์๋์ญ์ '๋ฉด ์ ์ ์ฆ์ 0์ (์ผ์๋ ์ค์ ์ผ์ ์ ์ง)
+ latest_log = history_rows[-1].get('recent_log', '') or ''
+ if latest_log and "ํด๋์๋์ญ์ " in latest_log.replace(" ", ""):
+ return 0.0
+
+ # 1. ์ต๊ทผ 30์ผ ์ด๋ ฅ ๊ธฐ๋ฐ Base Score ์ฐ์ถ
+ now = datetime.now().date()
+ recent_30 = [h for h in history_rows if (now - h['crawl_date']).days <= 30]
+
+ if not recent_30:
+ return 0.0
+
+ # [์ถ๊ฐ] ์์ฐ ๊ท๋ชจ ํ์ธ: ํ์ผ์ด 0๊ฐ๋ฉด ์ด์ ์ผ๊ด์ฑ ์ฐ์ถ ์์ฒด๊ฐ ๋ฌด์๋ฏธํจ
+ max_files = max([int(h['file_count'] or 0) for h in recent_30])
+ if max_files == 0:
+ return 0.0
+
+ # ์ฃผ์ฐจ๋ณ ํ๋ ์ฌ๋ถ (4์ฃผ) - ํ์ผ์ด 1๊ฐ ์ด์ ์กด์ฌํ ๋๋ง ์ ํจ ํ๋์ผ๋ก ์ธ์
+ weeks_active = [False, False, False, False]
+ for h in recent_30:
+ if int(h['file_count'] or 0) > 0:
+ days_ago = (now - h['crawl_date']).days
+ week_idx = min(3, days_ago // 7)
+ weeks_active[week_idx] = True
+
+ base_consistency = (sum(weeks_active) / 4) * 70
+
+ # ํ๋ ๋ฐ๋ (๋ณํ ๋ฐ์์ผ ๋น์จ)
+ effort_days = 0
+ for i in range(1, len(recent_30)):
+ # 'ํด๋์๋์ญ์ ' ๋ก๊ทธ๊ฐ ํฌํจ๋ ๋ ์ ๋ณํ๋ ๊ด๋ฆฌ ๋
ธ๋ ฅ์ผ๋ก ์ธ์ ํ์ง ์์
+ log_content = recent_30[i].get('recent_log', '') or ''
+ if "ํด๋์๋์ญ์ " in log_content.replace(" ", ""):
+ continue
+
+ if recent_30[i]['file_count'] != recent_30[i-1]['file_count']:
+ effort_days += 1
+
+ density_score = (effort_days / max(1, len(recent_30))) * 30
+ base_oci = base_consistency + density_score
+
+ # 2. [ํต์ฌ] ํจ๋ํฐ ์์ง ์ ์ฉ
+ # A. ์ฅ๊ธฐ ์ ์ฒด ํจ๋ํฐ: ๋ฐฉ์น์ผ์ด 100์ผ ์ด์์ด๋ฉด 0์ ์ผ๋ก ์๋ ด
+ stagnation_factor = max(0, (100 - days_stagnant) / 100.0)
+
+ # B. ์์ฐ ๋ถ์กฑ ํจ๋ํฐ (Existence Confidence): ํ์ผ์ด ๋๋ฌด ์ ์ผ๋ฉด ๊ด๋ฆฌ ์ ๋ขฐ๋ ํ๋ฝ
+ # 10๊ฐ ๋ฏธ๋ง์ 50%๋ง ์ธ์ , ๊ทธ ์ด์์ ์ ์ง์ ์ผ๋ก 100%๊น์ง ํ๋ณต
+ asset_confidence = 1.0
+ if max_files < 10:
+ asset_confidence = 0.5
+ elif max_files < 30:
+ asset_confidence = 0.8
+
+ final_oci = base_oci * stagnation_factor * asset_confidence
+
+ return round(final_oci, 1)
+
+ @staticmethod
+ def calculate_activity_status(target_date_dt, log, file_count):
+ """๊ฐ๋ณ ํ๋ก์ ํธ์ ํ๋ ์ํ ๋ฐ ๋ฐฉ์น์ผ ์ฐ์ถ (ํ์ฌ ์๊ฐ ๊ธฐ์ค ์ค์ง ๋ฐฉ์น์ผ ์ฐ์ถ)"""
+ status, days = "unknown", 999
+ file_val = int(file_count) if file_count else 0
+ has_log = log and log != "๋ฐ์ดํฐ ์์" and log != "X"
+
+ # ์ค์ง์ ์ธ ์ค๋ ๋ ์ง๋ฅผ ๊ธฐ์ค์ผ๋ก ์ ์ฒด์ผ ์ฐ์ถ (์ฌ์ฉ์ ์ง๊ด์ฑ ๊ฐํ)
+ now_dt = datetime.now()
+
+ if file_val == 0:
+ status = "unknown"
+ elif has_log:
+ is_auto = "ํด๋์๋์ญ์ " in log.replace(" ", "")
+ # 2์๋ฆฌ ๋๋ 4์๋ฆฌ ์ฐ๋ ์ง์ ์ ๊ท์
+ match = re.search(r'(\d{2,4})\.(\d{2})\.(\d{2})', log)
+ if match:
+ y, m, d = match.groups()
+ # 2์๋ฆฌ ์ฐ๋ ๋ณด์
+ if len(y) == 2: y = "20" + y
+ log_date = datetime.strptime(f"{y}.{m}.{d}", "%Y.%m.%d")
+
+ # ์์ง์ผ(target_date_dt)์ด ์๋ ํ์ฌ ์์ (now_dt) ๊ธฐ์ค์ผ๋ก ์ฐจ์ด ๊ณ์ฐ
+ diff = (now_dt - log_date).days
+ days = diff
+ # ์ํ ํ์ ์ ์์ง ์์ ์ target_date_dt๋ฅผ ๊ธฐ์ค์ผ๋ก ํ ์ง ๊ฒํ ํ์ํ๋,
+ # ์ฌ์ฉ์ ์์ฒญ์ ๋ฐ๋ผ '์ด์ํ ๊ณ์ฐ'์ ๋ฐ๋ก์ก๊ธฐ ์ํด ํ์ฌ ์์ ๊ธฐ์ค ํ์ ์ ์ฉ
+ status = "stale" if is_auto or diff > 14 else "warning" if diff > 7 else "active"
+ else:
+ status = "stale"
+ days = 999
+ else:
+ status = "stale"
+
+ return status, days
+
+ @staticmethod
+ def get_project_activity_logic(cursor, date_str):
+ """ํ๋๋ ๋ถ์ ๋ฆฌํฌํธ ์์ฑ ๋ก์ง"""
+ if not date_str or date_str == "-":
+ cursor.execute(DashboardQueries.GET_LAST_CRAWL_DATE)
+ res = cursor.fetchone()
+ target_date_val = res['last_date'] if res['last_date'] else datetime.now().date()
+ else:
+ target_date_val = datetime.strptime(date_str.replace(".", "-"), "%Y-%m-%d").date()
+
+ target_date_dt = datetime.combine(target_date_val, datetime.min.time())
+ cursor.execute(DashboardQueries.GET_PROJECT_LIST_FOR_ANALYSIS, (target_date_val,))
+ rows = cursor.fetchall()
+
+ analysis = {"summary": {"active": 0, "warning": 0, "stale": 0, "unknown": 0}, "details": []}
+ for r in rows:
+ status, days = AnalysisService.calculate_activity_status(target_date_dt, r['recent_log'], r['file_count'])
+ analysis["summary"][status] += 1
+ analysis["details"].append({"name": r['short_nm'] or r['project_nm'], "status": status, "days_ago": days})
+
+ return analysis
+
+ @staticmethod
+ def get_p_zsr_analysis_logic(cursor):
+ """์ ๋์ ๋ฐฉ์น ์คํ ๊ณ ๋ฐ ๋ฐ ์ด์ ์ผ๊ด์ฑ(OCI) ๋ถ์ ๋ก์ง"""
+ cursor.execute(DashboardQueries.GET_LAST_CRAWL_DATE)
+ res_date = cursor.fetchone()
+ if not res_date or not res_date['last_date']:
+ return []
+ last_date = res_date['last_date']
+
+ # ํน์ ๋ ์ง(last_date) ์ดํ์ ๊ฐ ํ๋ก์ ํธ๋ณ ์ต์ ๋ฐ์ดํฐ๋ฅผ ์กฐ์ธํ๋๋ก ์์
+ cursor.execute("""
+ SELECT m.project_id, m.project_nm, m.short_nm, m.department, m.master,
+ h.recent_log, h.file_count, m.continent, m.country
+ FROM projects_master m
+ LEFT JOIN projects_history h ON h.project_id = m.project_id AND h.crawl_date = (
+ SELECT MAX(crawl_date)
+ FROM projects_history
+ WHERE project_id = m.project_id AND crawl_date <= %s
+ )
+ ORDER BY m.project_id ASC
+ """, (last_date,))
+ projects = cursor.fetchall()
+
+ if not projects: return []
+
+ results = []
+ total_avi = 0
+ total_files = 0
+ project_data_list = []
+
+ # 1์ฐจ Pass: ๊ฐ๋ณ AVI ์ฐ์ถ ๋ฐ ์ ์ฒด ํฉ๊ณ ์ง๊ณ
+ now_dt = datetime.now()
+ for p in projects:
+ file_count = int(p['file_count']) if p['file_count'] else 0
+ log = p['recent_log']
+
+ # ๋ฐฉ์น์ผ ๊ณ์ฐ (ํ์ฌ ์๊ฐ ๊ธฐ์ค ๋๊ธฐํ)
+ days_stagnant = 14
+ is_auto_delete = log and "ํด๋์๋์ญ์ " in log.replace(" ", "")
+
+ if log and log != "๋ฐ์ดํฐ ์์":
+ match = re.search(r'(\d{2,4})\.(\d{2})\.(\d{2})', log)
+ if match:
+ y, m, d = match.groups()
+ if len(y) == 2: y = "20" + y
+ log_date = datetime.strptime(f"{y}.{m}.{d}", "%Y.%m.%d")
+ days_stagnant = (now_dt - log_date).days
+ elif is_auto_delete:
+ days_stagnant = 999
+
+ # AI-Hazard ์ถ๋ก ๋ก์ง (Dynamic Lambda)
+ scale_impact = min(0.04, math.log10(file_count + 1) * 0.008) if file_count > 0 else 0
+ ai_lambda = 0.04 + scale_impact
+
+ # ์ง์ ๊ฐ์ ์ ์ฉ
+ avi_score = math.exp(-ai_lambda * days_stagnant) * 100
+
+ # ECV ํจ๋ํฐ
+ existence_confidence = 1.0
+ if file_count == 0: existence_confidence = 0.05
+ elif file_count < 10: existence_confidence = 0.4
+
+ # Log Quality Scoring (SWVW ๋ชจ๋ธ ์ ์ฉ)
+ from log_scorer import LogScorer
+ log_quality_factor = LogScorer.get_score(log)
+
+ avi_score = avi_score * existence_confidence * log_quality_factor
+ if is_auto_delete: avi_score = 0.1
+
+ total_avi += avi_score
+ total_files += file_count
+ project_data_list.append({
+ "p": p,
+ "avi_score": avi_score,
+ "file_count": file_count,
+ "days_stagnant": days_stagnant,
+ "is_auto_delete": is_auto_delete,
+ "log_quality": log_quality_factor,
+ "ai_lambda": ai_lambda
+ })
+
+ # 2์ฐจ Pass: ํ๊ท ๊ธฐ๋ฐ ๊ฐ์น๊ธฐ์ฌ๋(WAR) ์ฐ์ถ
+ num_projects = len(projects) if projects else 1
+ avg_avi = total_avi / num_projects
+ avg_files = total_files / num_projects
+
+ for item in project_data_list:
+ p = item['p']
+ avi_score = item['avi_score']
+ file_count = item['file_count']
+
+ # [์ด์ ์ผ๊ด์ฑ ๋ถ์ (OCI)]
+ history_rows = SOIPredictionService.get_historical_avi(cursor, p['project_id'])
+ oci_score = AnalysisService.calculate_operational_consistency(history_rows, item['days_stagnant'])
+
+ # ์ค๋ฌด ํฌ์
์๋์ง ๊ณ์ฐ
+ effort_days = 0
+ if len(history_rows) > 1:
+ for i in range(1, len(history_rows)):
+ if history_rows[i]['file_count'] != history_rows[i-1]['file_count']:
+ effort_days += 1
+
+ work_effort_rate = round((effort_days / max(1, len(history_rows))) * 100, 1)
+
+ # [VCI ์ฐ์ถ - ๋ก๊ทธ ๊ธฐ๋ฐ ์๋ ๊ฐ์ค์น ๋ชจ๋ธ (์์ )]
+ # 1. ํ์ผ ๊ท๋ชจ ๊ฐ์ค์น๋ฅผ ๋ก๊ทธ(log10) ๊ธฐ๋ฐ์ผ๋ก ๋ณ๊ฒฝํ์ฌ ์ ํ ํญ์ฃผ ๋ฐฉ์ง
+ # 2. ํ๊ท ํ์ผ ์ ๋๋น ์๋์ ๊ท๋ชจ๋ฅผ ๋ฐ์ํ๋, ์ต๋ ๊ฐ์ค์น๋ฅผ 2.5๋ก ์บกํ(Capping)
+ if avg_files > 0:
+ relative_size = math.log10(file_count + 1) / math.log10(avg_files + 1)
+ else:
+ relative_size = 1.0
+
+ asset_weight = min(2.5, max(0.2, relative_size))
+ p_war_score = (avi_score - avg_avi) * asset_weight
+
+ results.append({
+ "project_nm": p['short_nm'] or p['project_nm'],
+ "file_count": file_count,
+ "days_stagnant": item['days_stagnant'],
+ "risk_count": round(p_war_score, 2), # WAR ๊ธฐ๋ฐ ๊ฐ์น๊ธฐ์ฌ๋ (ํ๊ท 0)
+ "p_war": round(avi_score, 1),
+ "oci_score": oci_score,
+ "is_auto_delete": item['is_auto_delete'],
+ "master": p['master'],
+ "dept": p['department'],
+ "ai_lambda": round(item['ai_lambda'], 4),
+ "log_quality": item['log_quality'],
+ "work_effort": work_effort_rate,
+ "avg_info": {
+ "avg_files": round(avg_files, 1),
+ "avg_stagnant": 0,
+ "avg_risk": round(avg_avi, 1)
+ }
+ })
+
+ results.sort(key=lambda x: x['p_war'])
+ return results
diff --git a/analyze.py b/analyze.py
index 70108e4..34d20d4 100644
--- a/analyze.py
+++ b/analyze.py
@@ -1,167 +1,167 @@
-import os
-import re
-import unicodedata
-from pypdf import PdfReader
-import pytesseract
-from pdf2image import convert_from_path
-
-# 1. ์์คํ
์ค์
-TESSERACT_EXE = r'C:\Users\User\AppData\Local\Programs\Tesseract-OCR\tesseract.exe'
-TESSDATA_DIR = r'C:\Users\User\AppData\Local\Programs\Tesseract-OCR\tessdata'
-POPPLER_BIN = r'D:\์ดํํ\00ํฌ๋กฌ๋ค์ด๋ก๋\poppler-25.12.0\Library\bin'
-
-pytesseract.pytesseract.tesseract_cmd = TESSERACT_EXE
-os.environ["TESSDATA_PREFIX"] = TESSDATA_DIR
-OCR_AVAILABLE = os.path.exists(TESSERACT_EXE)
-
-SYSTEM_HIERARCHY = {
- "ํ์ ": {
- "๊ณ์ฝ": ["๊ณ์ฝ๊ด๋ฆฌ", "๊ธฐ์ฑ๊ด๋ฆฌ", "์
๋ฌด์ง์์", "์ธ์๊ด๋ฆฌ"],
- "์
๋ฌด๊ด๋ฆฌ": ["์
๋ฌด์ผ์ง(2025)", "์
๋ฌด์ผ์ง(2025๋
์ด์ )", "๋ฐ์ฃผ์ฒ ์ ๊ธฐ๋ณด๊ณ ", "๋ณธ์ฌ์
๋ฌด๋ณด๊ณ ", "๊ณต์ฌ๊ฐ๋
์ผ์ง", "์์์๋ฅ"]
- },
- "์ค๊ณ์ฑ๊ณผํ": {
- "์๋ฐฉ์": ["๊ณต์ฌ์๋ฐฉ์", "์ฅ๋น ๋ฐ์
ํ๊ฐ ๊ฒํ ์"],
- "์ค๊ณ๋๋ฉด": ["๊ณตํต", "ํ ๊ณต", "๋นํ๋ฉด์์ ๊ณต", "๋ฐฐ์๊ณต", "๊ต๋๊ณต", "ํฌ์ฅ๊ณต", "๊ตํต์์ ์์ค๊ณต", "๋ถ๋๊ณต", "์ฉ์ง๊ณต & ๊ธฐํ๊ณต"],
- "์๋์ฐ์ถ์": ["ํ ๊ณต", "๋นํ๋ฉด์์ ๊ณต", "๋ฐฐ์๊ณต", "๊ต๋๊ณต", "ํฌ์ฅ๊ณต", "๊ตํต์์ ์์ค๊ณต", "๋ถ๋๊ณต", "์ฉ์ง๊ณต & ๊ธฐํ๊ณต"],
- "๋ด์ญ์": ["๋จ๊ฐ์ฐ์ถ์"],
- "๋ณด๊ณ ์": ["์ค์์ค๊ณ๋ณด๊ณ ์", "์ง๋ฐ์กฐ์ฌ๋ณด๊ณ ์", "๊ตฌ์กฐ๊ณ์ฐ์", "์๋ฆฌ ๋ฐ ์ ๊ธฐ๊ณ์ฐ์", "๊ธฐํ๋ณด๊ณ ์", "๊ธฐ์ ์๋ฌธ ๋ฐ ์ฌ์"],
- "์ธก๋๊ณ์ฐ๋ถ": ["์ธก๋๊ณ์ฐ๋ถ"],
- "์ค๊ณ๋จ๊ณ ์ํํ์": ["ํ์ยทํ์"]
- },
- "์๊ณต์ฑ๊ณผํ": {
- "์ค๊ณ๋๋ฉด": ["๊ณตํต", "ํ ๊ณต", "๋นํ๋ฉด์์ ๊ณต", "๋ฐฐ์๊ณต", "๊ต๋๊ณต", "ํฌ์ฅ๊ณต", "๊ตํต์์ ์์ค๊ณต", "๋ถ๋๊ณต", "์ฉ์ง๊ณต & ๊ธฐํ๊ณต"]
- },
- "์๊ณต๊ฒ์ธก": {
- "ํ ๊ณต": ["๊ฒ์ธก (๊นจ๊ธฐ)", "๊ฒ์ธก (์ฐ์ฝ์ง๋ฐ)", "๊ฒ์ธก (๋ฐํ)", "๊ฒ์ธก (๋
ธ์ฒด)", "๊ฒ์ธก (๋
ธ์)", "๊ฒ์ธก (ํ ์ทจ์ฅ)"],
- "๋ฐฐ์๊ณต": ["๊ฒ์ธก (Vํ์ธก๊ตฌ)", "๊ฒ์ธก (์ฐ๋ง๋ฃจ์ธก๊ตฌ)", "๊ฒ์ธก (Uํ์ธก๊ตฌ)", "๊ฒ์ธก (Uํ์ธก๊ตฌ)(์)", "๊ฒ์ธก (Lํ์ธก๊ตฌ, Jํ์ธก๊ตฌ)", "๊ฒ์ธก (๋์๋ก)", "๊ฒ์ธก (๋์๋ก)(์)", "๊ฒ์ธก (ํก๋ฐฐ์๊ด)", "๊ฒ์ธก (์ข
๋ฐฐ์๊ด)", "๊ฒ์ธก (๋งน์๊ฑฐ)", "๊ฒ์ธก (ํต๋ก์๊ฑฐ)", "๊ฒ์ธก (์๋ก์๊ฑฐ)", "๊ฒ์ธก (ํธ์๊ณต)", "๊ฒ์ธก (์น๋ฒฝ๊ณต)", "๊ฒ์ธก (์ฉ์๊ฐ๊ฑฐ)"],
- "๊ตฌ์กฐ๋ฌผ๊ณต": ["๊ฒ์ธก (ํ๋ชฉ๊ต-๊ฑฐ๋, ๋ถ๋๊ณต)", "๊ฒ์ธก (ํ๋ชฉ๊ต)(์)", "๊ฒ์ธก (๊ฐ์ฐฉํฐ๋, ์ํํต๋ก)"],
- "ํฌ์ฅ๊ณต": ["๊ฒ์ธก (๊ธฐ์ธต, ๋ณด์กฐ๊ธฐ์ธต)"],
- "๋ถ๋๊ณต": ["๊ฒ์ธก (ํ๊ฒฝ)", "๊ฒ์ธก (์ง์ฅ๊ฐ์ฅ,๊ฑด๋ฌผ ์ฒ ๊ฑฐ)", "๊ฒ์ธก (๋ฐฉ์๋ฒฝ ๋ฑ)"],
- "๋นํ๋ฉด์์ ๊ณต": ["๊ฒ์ธก (์์๋ณดํธ๊ณต)", "๊ฒ์ธก (๊ตฌ์กฐ๋ฌผ๋ณดํธ๊ณต)"],
- "๊ตํต์์ ์์ค๊ณต": ["๊ฒ์ธก (๋์๋ฐฉ์ง์ฑ
)"],
- "๊ฒ์ธก ์์์๋ฅ": ["๊ฒ์ธก ์์์๋ฅ"]
- },
- "์ค๊ณ๋ณ๊ฒฝ": {
- "์ค์ ๋ณด๊ณ (์ด์ฒ~๊ณต์ฃผ)": ["ํ ๊ณต", "๋ฐฐ์๊ณต", "๊ต๋๊ณต(ํ๋ชฉ๊ต)", "๊ตฌ์กฐ๋ฌผ๊ณต", "ํฌ์ฅ๊ณต", "๊ตํต์์ ๊ณต", "๋ถ๋๊ณต", "์ ๊ธฐ๊ณต์ฌ", "๋ฏธํ์ ๊ณต", "์์ ๊ด๋ฆฌ", "ํ๊ฒฝ๊ด๋ฆฌ", "ํ์ง๊ด๋ฆฌ", "์์ฌ๊ด๋ฆฌ", "์ง์ฅ๋ฌผ", "๊ธฐํ"],
- "์ค์ ๋ณด๊ณ (๋์ ~์ ์)": ["ํ ๊ณต", "๋ฐฐ์๊ณต", "๋นํ๋ฉด์์ ๊ณต", "ํฌ์ฅ๊ณต", "๋ถ๋๊ณต", "์์ ๊ด๋ฆฌ", "ํ๊ฒฝ๊ด๋ฆฌ", "์์ฌ๊ด๋ฆฌ", "๊ธฐํ"],
- "๊ธฐ์ ์ง์ ๊ฒํ ": ["ํ ๊ณต", "๋ฐฐ์๊ณต", "๊ต๋๊ณต(ํ๋ชฉ๊ต)", "๊ตฌ์กฐ๋ฌผ&๋ถ๋๊ณต", "๊ธฐํ"],
- "์๊ณต๊ณํ(์ด์ฒ~๊ณต์ฃผ)": ["ํ ๊ณต", "๋ฐฐ์๊ณต", "๊ต๋๊ณต(ํ๋ชฉ๊ต)", "๊ตฌ์กฐ๋ฌผ&๋ถ๋&ํฌ์ฅ&๊ตํต์์ ๊ณต", "ํ๊ฒฝ ๋ฐ ํ์ง๊ด๋ฆฌ"]
- },
- "๊ณต์ฌ๊ด๋ฆฌ": {
- "๊ณต์ ยท์ผ์ ": ["๊ณต์ ํ", "์๊ฐ ๊ณต์ ๋ณด๊ณ ", "์์
์ผ๋ณด"],
- "ํ์ง ๊ด๋ฆฌ": ["ํ์ง์ํ๊ณํ์", "ํ์ง์ํ ์ค์ ๋ณด๊ณ ", "์ฝํฌ๋ฆฌํธ ํ์คํํฉ[์ด์ฒ~๊ณต์ฃผ(4์ฐจ)]", "ํ์ง๊ด๋ฆฌ๋น ์ฌ์ฉ๋ด์ญ", "๊ท ์ด๊ด๋ฆฌ", "ํ์ง๊ด๋ฆฌ ์์์๋ฅ"],
- "์์ ๊ด๋ฆฌ": ["์์ ๊ด๋ฆฌ๊ณํ์", "์์ ๊ด๋ฆฌ ์ค์ ๋ณด๊ณ ", "์ํ์ฑ ํ๊ฐ", "์ฌ์ ์์
ํ๊ฐ์", "์์ ๊ด๋ฆฌ๋น ์ฌ์ฉ๋ด์ญ", "์์ ๊ด๋ฆฌ์์คํ๊ฐ", "์์ ๊ด๋ฆฌ ์์์๋ฅ"],
- "ํ๊ฒฝ ๊ด๋ฆฌ": ["ํ๊ฒฝ์ํฅํ๊ฐ", "์ฌ์ ์ฌํด์ํฅ์ฑ๊ฒํ ", "์ ์ง๊ด๋ฆฌ ๋ฐ ๋ณด์์ ๊ฒ", "ํ๊ฒฝ๋ณด์ ๋น ์ฌ์ฉ๋ด์ญ", "๊ฑด์คํ๊ธฐ๋ฌผ ๊ด๋ฆฌ"],
- "์์ฌ ๊ด๋ฆฌ (๊ด๊ธ)": ["์์ฌ๊ตฌ๋งค์์ฒญ (๋ ๋ฏธ์ฝ, ์ฒ ๊ทผ)", "์์ฌ๊ตฌ๋งค์์ฒญ (๊ทธ ์ธ)", "๋ฉํ๊ธฐํ", "๊ณ์ฝ ๋ณ๊ฒฝ", "์์ฌ ๋ฐ์
ยท์๋ถ ๊ด๋ฆฌ", "์์ฌ๊ด๋ฆฌ ์์์๋ฅ"],
- "์์ฌ ๊ด๋ฆฌ (์ฌ๊ธ)": ["์์ฌ๊ณต๊ธ์ ์น์ธ", "์์ฌ ๋ฐ์
ยท์๋ถ ๊ด๋ฆฌ", "์์ฌ ๊ฒ์ยทํ์ธ"],
- "์ ๊ฒ (์ ๋ฆฌ์ค)": ["๋ด๋ถ์ ๊ฒ", "์ธ๋ถ์ ๊ฒ"],
- "๊ณต๋ฌธ": ["์ ์(์์ )", "๋ฐ์ก(๋ฐ์ )", "ํ๋๊ธ", "์ธ๋ ฅ", "๋ฐฉ์นจ"]
- },
- "๋ฏผ์๊ด๋ฆฌ": {
- "๋ฏผ์(์ด์ฒ~๊ณต์ฃผ)": ["์ฒ๋ฆฌ๋์ฅ", "๋ณด์", "๊ณต์ฌ์ผ๋ฐ", "ํ๊ฒฝ๋ถ์"],
- "์ค์ ๋ณด๊ณ (์ด์ฒ~๊ณต์ฃผ)": ["๋ฏผ์"],
- "์ค์ ๋ณด๊ณ (๋์ ~์ ์)": ["๋ฏผ์"]
- }
-}
-
-def analyze_flow_reasoning(filename, all_text_list):
- """
- ๋ณธ๋ฌธ์ ์ ์ ์กฐ์ฌ ๊ฒฐ๊ณผ์ ํ์ผ๋ช
์ '์๋ ๊ฐ์ค์น'๋ฅผ ๋ํด ์ต์ข
์ถ๋ก
- """
- full_text = " ".join(all_text_list)
- clean_ctx = full_text.replace(" ", "").replace("\n", "").lower()
- fn_clean = filename.replace(" ", "").lower()
-
- # 1. ๋๋ฉ์ธ๋ณ ๊ธฐ๋ณธ ์ ์ (๋ณธ๋ฌธ ์ ์ ์กฐ์ฌ - ํ๋ฑํ๊ฒ)
- scores = {
- "official": sum(clean_ctx.count(k) for k in ["์์ :", "๋ฐ์ :", "๊ฒฝ์ :", "์ํ์ผ์", "๊ทํ", "๋๋ฆฝ๋๋ค", "๋ฐ๋๋๋ค"]),
- "contract": sum(clean_ctx.count(k) for k in ["๊ณ์ฝ์", "ํ๋๊ธ", "์ธ์ฃผ", "๋๊ธ", "์ธ๊ฐ", "์ฌ์
์"]),
- "hr": sum(clean_ctx.count(k) for k in ["์ดํ๊ณ", "์ธ๋ ฅ", "๊ธฐ์ ์", "์์ ๊ด๋ฆฌ์", "์ฌ์ง์ฆ๋ช
", "๋ฐฐ์น"]),
- "change": sum(clean_ctx.count(k) for k in ["์ค์ ๋ณด๊ณ ", "์ค๊ณ๋ณ๊ฒฝ", "๋ณ๊ฒฝ๋ณด๊ณ ", "์ถ๊ฐ๋ฐ์"]),
- "technical": sum(clean_ctx.count(k) for k in ["์ผ์๋๊ฐ", "์ฐ์ถ๊ทผ๊ฑฐ", "์ง๊ณํ", "๋ฌผ๋์ฐ์ถ", "๋จ๊ฐ", "๋ด์ญ", "๋๋ฉด", "dwg"])
- }
-
- # 2. ํ์ผ๋ช
์ ๋ํ '๋ฐฉํฅํ' ๊ฐ์ค์น ๋ถ์ฌ (Final Push)
- # ๋ณธ๋ฌธ ๋ฐ์ดํฐ๊ฐ ์๋ฌด๋ฆฌ ๋ง์๋ ํ์ผ๋ช
์ ์๋๋ฅผ ์กด์คํ๊ธฐ ์ํด 7๋ฐฐ ๊ฐ์ค์น
- if "์ค์ " in fn_clean or "๋ณ๊ฒฝ" in fn_clean: scores["change"] += 50 # ๋ณธ๋ฌธ 50ํ ์ธ๊ธ๊ณผ ๋ง๋จน๋ ๊ฐ์ค์น
- if "๊ณ์ฝ" in fn_clean or "ํ๋๊ธ" in fn_clean: scores["contract"] += 50
- if "์ธ๋ ฅ" in fn_clean or "์ดํ" in fn_clean: scores["hr"] += 50
- if "๋จ๊ฐ" in fn_clean or "์๋" in fn_clean or "๋๋ฉด" in fn_clean: scores["technical"] += 50
- if "์ ์ถ" in fn_clean or "๊ฑด" in fn_clean: scores["official"] += 30
-
- # 3. ์ข
ํฉ ๋๋์ ๋ฐ๋ฅธ ์ต์ข
๋๋ฉ์ธ ์ ์
- dominant_domain = max(scores, key=scores.get)
-
- # ํ๋ก์ ํธ ์๋ณ (Fuzzy ๋งค์นญ ๋ฐ ๊ต์ฐจ ๊ฒ์ฆ)
- project_loc = "์ด์ฒ~๊ณต์ฃผ" if any(k in clean_ctx or k in fn_clean for k in ["์ด์ฒ", "๊ณต์ฃผ"]) else "๋์ ~์ ์" if any(k in clean_ctx or k in fn_clean for k in ["๋์ ", "์ ์"]) else "๊ณตํต"
-
- # --- [ํตํฉ ์ถ๋ก ๋ฐ ๋งค์นญ] ---
-
- # ์๋๋ฆฌ์ค A: ์ค์ ๋ณด๊ณ /์ค๊ณ๋ณ๊ฒฝ (๋ณธ๋ฌธ ๋ฐ์ดํฐ + ํ์ผ๋ช
์๋ ํฉ์ฑ)
- if dominant_domain == "change" or (scores["change"] > 0 and scores["technical"] > 5):
- cat = f"์ค์ ๋ณด๊ณ ({project_loc})"
- sub = "์ง์ฅ๋ฌผ" if any(k in clean_ctx for k in ["์๋๋ฃ", "ํ ์ง", "๋ณด์"]) else "๊ตฌ์กฐ๋ฌผ๊ณต" if "๊ตฌ์กฐ๋ฌผ" in clean_ctx else "๊ธฐํ"
- return f"์ค๊ณ๋ณ๊ฒฝ > {cat} > {sub}", f"๋ณธ๋ฌธ์ ๊ธฐ์ ๋ฐ์ดํฐ ๋ฐ๋์ ํ์ผ๋ช
์ '{dominant_domain}' ๊ด๋ จ ์๋๋ฅผ ์ข
ํฉํ์ฌ {project_loc} ํ๋ก์ ํธ์ ์ค์ ๋ณด๊ณ ๋ณธ์ฒด๋ก ํ์ ."
-
- # ์๋๋ฆฌ์ค B: ํ์ ๊ณ์ฝ/ํ๋๊ธ (๋ณธ์ฒด ์ค์ฌ)
- if dominant_domain == "contract":
- return "ํ์ > ๊ณ์ฝ > ๊ณ์ฝ๊ด๋ฆฌ", "๋ฌธ์ ์ ์ฒด์์ ๊ณ์ฝ ๋ฐ ํ๋๊ธ ์
๋ฌด ๋ณธ์ง์ด ์ง๋ฐฐ์ ์ผ๋ก ํ์ธ๋จ."
-
- # ์๋๋ฆฌ์ค C: ์ธ์ฌ/์ธ๋ ฅ ๊ด๋ฆฌ
- if dominant_domain == "hr":
- if len(all_text_list) <= 2: return "๊ณต์ฌ๊ด๋ฆฌ > ๊ณต๋ฌธ > ์ธ๋ ฅ", "์ธ๋ ฅ ์ฌํญ์ ๊ฐ๋ตํ ๋ณด๊ณ ํ๋ ๊ณต๋ฌธ ํ์์."
- return "ํ์ > ๊ณ์ฝ > ์ธ์๊ด๋ฆฌ", "๋ค๋์ ์ธ๋ ฅ ์ฆ๋น ๋ฐ์ดํฐ๊ฐ ํฌํจ๋ ํ์ ์๋ฅ์."
-
- # ์๋๋ฆฌ์ค D: ์์ ๊ณต๋ฌธ (ํ์ ์ฐ์ )
- if dominant_domain == "official" or scores["official"] > scores["technical"]:
- tab, cat = "๊ณต์ฌ๊ด๋ฆฌ", "๊ณต๋ฌธ"
- sub = "์ ์(์์ )"
- if "๋ฐฉ์นจ" in clean_ctx or "์ง์นจ" in clean_ctx: sub = "๋ฐฉ์นจ"
- elif "๋ฐ์ " in clean_ctx[:500]: sub = "๋ฐ์ก(๋ฐ์ )"
- return f"{tab} > {cat} > {sub}", "์ ์ฒด ๋งฅ๋ฝ์ ๊ธฐ์ ์ ๋ฐ์ดํฐ๋ณด๋ค ํ์ ์ ์ ๋ฌ ํ์(๊ณต๋ฌธ)๊ฐ ํต์ฌ ์ ์ฒด์ฑ์ผ๋ก ํ๋จ๋จ."
-
- # ์๋๋ฆฌ์ค E: ๊ธฐ์ ์ฑ๊ณผํ
- if dominant_domain == "technical":
- if any(k in clean_ctx or k in fn_clean for k in ["๋จ๊ฐ", "๋ด์ญ"]): return "์ค๊ณ์ฑ๊ณผํ > ๋ด์ญ์ > ๋จ๊ฐ์ฐ์ถ์", "๋ด์ญ/๋จ๊ฐ ์ฐ์ถ ๊ธฐ์ ๋ฐ์ดํฐ ํ์ธ."
- if any(k in clean_ctx or k in fn_clean for k in ["๋๋ฉด", "dwg"]): return "์ค๊ณ์ฑ๊ณผํ > ์ค๊ณ๋๋ฉด > ๊ณตํต", "๋๋ฉด/๊ทธ๋ํฝ ๋ฐ์ดํฐ ํ์ธ."
- return "์ค๊ณ์ฑ๊ณผํ > ์๋์ฐ์ถ์ > ํ ๊ณต", "์๋/๋ฌผ๋ ์ฐ์ถ ๋ฐ์ดํฐ ํ์ธ."
-
- return "ํ์ > ์
๋ฌด๊ด๋ฆฌ > ์์์๋ฅ", "์ผ๋ฐ ํ์ ๋ฐ ๊ธฐํ ์์ ์๋ฅ๋ก ๋ถ๋ฅํจ."
-
-def analyze_file_content(filename: str):
- try:
- file_path = os.path.join("sample", filename)
- text_by_pages = []
- if filename.lower().endswith(".pdf"):
- reader = PdfReader(file_path)
- for i in range(len(reader.pages)):
- page_text = reader.pages[i].extract_text() or ""
- if OCR_AVAILABLE:
- try:
- images = convert_from_path(file_path, first_page=i+1, last_page=i+1, poppler_path=POPPLER_BIN, dpi=200)
- if images:
- ocr_result = pytesseract.image_to_string(images[0], lang='kor+eng')
- page_text += "\n" + ocr_result
- except Exception as ocr_err:
- print(f"OCR Error on page {i+1}: {ocr_err}")
- text_by_pages.append(page_text)
- elif filename.lower().endswith(('.xlsx', '.xls')):
- import pandas as pd
- df = pd.read_excel(file_path)
- text_by_pages.append(df.to_string())
- else: text_by_pages.append("")
-
- path, reason = analyze_flow_reasoning(filename, text_by_pages)
-
- return {
- "filename": filename,
- "total_pages": len(text_by_pages),
- "final_result": {
- "suggested_path": path,
- "confidence": "100%",
- "reason": reason,
- "snippet": " ".join(text_by_pages)[:1500]
- }
- }
- except Exception as e:
- return {"error": str(e), "filename": filename}
+import os
+import re
+import unicodedata
+from pypdf import PdfReader
+import pytesseract
+from pdf2image import convert_from_path
+
+# 1. ์์คํ
์ค์
+TESSERACT_EXE = r'C:\Users\User\AppData\Local\Programs\Tesseract-OCR\tesseract.exe'
+TESSDATA_DIR = r'C:\Users\User\AppData\Local\Programs\Tesseract-OCR\tessdata'
+POPPLER_BIN = r'D:\์ดํํ\00ํฌ๋กฌ๋ค์ด๋ก๋\poppler-25.12.0\Library\bin'
+
+pytesseract.pytesseract.tesseract_cmd = TESSERACT_EXE
+os.environ["TESSDATA_PREFIX"] = TESSDATA_DIR
+OCR_AVAILABLE = os.path.exists(TESSERACT_EXE)
+
+SYSTEM_HIERARCHY = {
+ "ํ์ ": {
+ "๊ณ์ฝ": ["๊ณ์ฝ๊ด๋ฆฌ", "๊ธฐ์ฑ๊ด๋ฆฌ", "์
๋ฌด์ง์์", "์ธ์๊ด๋ฆฌ"],
+ "์
๋ฌด๊ด๋ฆฌ": ["์
๋ฌด์ผ์ง(2025)", "์
๋ฌด์ผ์ง(2025๋
์ด์ )", "๋ฐ์ฃผ์ฒ ์ ๊ธฐ๋ณด๊ณ ", "๋ณธ์ฌ์
๋ฌด๋ณด๊ณ ", "๊ณต์ฌ๊ฐ๋
์ผ์ง", "์์์๋ฅ"]
+ },
+ "์ค๊ณ์ฑ๊ณผํ": {
+ "์๋ฐฉ์": ["๊ณต์ฌ์๋ฐฉ์", "์ฅ๋น ๋ฐ์
ํ๊ฐ ๊ฒํ ์"],
+ "์ค๊ณ๋๋ฉด": ["๊ณตํต", "ํ ๊ณต", "๋นํ๋ฉด์์ ๊ณต", "๋ฐฐ์๊ณต", "๊ต๋๊ณต", "ํฌ์ฅ๊ณต", "๊ตํต์์ ์์ค๊ณต", "๋ถ๋๊ณต", "์ฉ์ง๊ณต & ๊ธฐํ๊ณต"],
+ "์๋์ฐ์ถ์": ["ํ ๊ณต", "๋นํ๋ฉด์์ ๊ณต", "๋ฐฐ์๊ณต", "๊ต๋๊ณต", "ํฌ์ฅ๊ณต", "๊ตํต์์ ์์ค๊ณต", "๋ถ๋๊ณต", "์ฉ์ง๊ณต & ๊ธฐํ๊ณต"],
+ "๋ด์ญ์": ["๋จ๊ฐ์ฐ์ถ์"],
+ "๋ณด๊ณ ์": ["์ค์์ค๊ณ๋ณด๊ณ ์", "์ง๋ฐ์กฐ์ฌ๋ณด๊ณ ์", "๊ตฌ์กฐ๊ณ์ฐ์", "์๋ฆฌ ๋ฐ ์ ๊ธฐ๊ณ์ฐ์", "๊ธฐํ๋ณด๊ณ ์", "๊ธฐ์ ์๋ฌธ ๋ฐ ์ฌ์"],
+ "์ธก๋๊ณ์ฐ๋ถ": ["์ธก๋๊ณ์ฐ๋ถ"],
+ "์ค๊ณ๋จ๊ณ ์ํํ์": ["ํ์ยทํ์"]
+ },
+ "์๊ณต์ฑ๊ณผํ": {
+ "์ค๊ณ๋๋ฉด": ["๊ณตํต", "ํ ๊ณต", "๋นํ๋ฉด์์ ๊ณต", "๋ฐฐ์๊ณต", "๊ต๋๊ณต", "ํฌ์ฅ๊ณต", "๊ตํต์์ ์์ค๊ณต", "๋ถ๋๊ณต", "์ฉ์ง๊ณต & ๊ธฐํ๊ณต"]
+ },
+ "์๊ณต๊ฒ์ธก": {
+ "ํ ๊ณต": ["๊ฒ์ธก (๊นจ๊ธฐ)", "๊ฒ์ธก (์ฐ์ฝ์ง๋ฐ)", "๊ฒ์ธก (๋ฐํ)", "๊ฒ์ธก (๋
ธ์ฒด)", "๊ฒ์ธก (๋
ธ์)", "๊ฒ์ธก (ํ ์ทจ์ฅ)"],
+ "๋ฐฐ์๊ณต": ["๊ฒ์ธก (Vํ์ธก๊ตฌ)", "๊ฒ์ธก (์ฐ๋ง๋ฃจ์ธก๊ตฌ)", "๊ฒ์ธก (Uํ์ธก๊ตฌ)", "๊ฒ์ธก (Uํ์ธก๊ตฌ)(์)", "๊ฒ์ธก (Lํ์ธก๊ตฌ, Jํ์ธก๊ตฌ)", "๊ฒ์ธก (๋์๋ก)", "๊ฒ์ธก (๋์๋ก)(์)", "๊ฒ์ธก (ํก๋ฐฐ์๊ด)", "๊ฒ์ธก (์ข
๋ฐฐ์๊ด)", "๊ฒ์ธก (๋งน์๊ฑฐ)", "๊ฒ์ธก (ํต๋ก์๊ฑฐ)", "๊ฒ์ธก (์๋ก์๊ฑฐ)", "๊ฒ์ธก (ํธ์๊ณต)", "๊ฒ์ธก (์น๋ฒฝ๊ณต)", "๊ฒ์ธก (์ฉ์๊ฐ๊ฑฐ)"],
+ "๊ตฌ์กฐ๋ฌผ๊ณต": ["๊ฒ์ธก (ํ๋ชฉ๊ต-๊ฑฐ๋, ๋ถ๋๊ณต)", "๊ฒ์ธก (ํ๋ชฉ๊ต)(์)", "๊ฒ์ธก (๊ฐ์ฐฉํฐ๋, ์ํํต๋ก)"],
+ "ํฌ์ฅ๊ณต": ["๊ฒ์ธก (๊ธฐ์ธต, ๋ณด์กฐ๊ธฐ์ธต)"],
+ "๋ถ๋๊ณต": ["๊ฒ์ธก (ํ๊ฒฝ)", "๊ฒ์ธก (์ง์ฅ๊ฐ์ฅ,๊ฑด๋ฌผ ์ฒ ๊ฑฐ)", "๊ฒ์ธก (๋ฐฉ์๋ฒฝ ๋ฑ)"],
+ "๋นํ๋ฉด์์ ๊ณต": ["๊ฒ์ธก (์์๋ณดํธ๊ณต)", "๊ฒ์ธก (๊ตฌ์กฐ๋ฌผ๋ณดํธ๊ณต)"],
+ "๊ตํต์์ ์์ค๊ณต": ["๊ฒ์ธก (๋์๋ฐฉ์ง์ฑ
)"],
+ "๊ฒ์ธก ์์์๋ฅ": ["๊ฒ์ธก ์์์๋ฅ"]
+ },
+ "์ค๊ณ๋ณ๊ฒฝ": {
+ "์ค์ ๋ณด๊ณ (์ด์ฒ~๊ณต์ฃผ)": ["ํ ๊ณต", "๋ฐฐ์๊ณต", "๊ต๋๊ณต(ํ๋ชฉ๊ต)", "๊ตฌ์กฐ๋ฌผ๊ณต", "ํฌ์ฅ๊ณต", "๊ตํต์์ ๊ณต", "๋ถ๋๊ณต", "์ ๊ธฐ๊ณต์ฌ", "๋ฏธํ์ ๊ณต", "์์ ๊ด๋ฆฌ", "ํ๊ฒฝ๊ด๋ฆฌ", "ํ์ง๊ด๋ฆฌ", "์์ฌ๊ด๋ฆฌ", "์ง์ฅ๋ฌผ", "๊ธฐํ"],
+ "์ค์ ๋ณด๊ณ (๋์ ~์ ์)": ["ํ ๊ณต", "๋ฐฐ์๊ณต", "๋นํ๋ฉด์์ ๊ณต", "ํฌ์ฅ๊ณต", "๋ถ๋๊ณต", "์์ ๊ด๋ฆฌ", "ํ๊ฒฝ๊ด๋ฆฌ", "์์ฌ๊ด๋ฆฌ", "๊ธฐํ"],
+ "๊ธฐ์ ์ง์ ๊ฒํ ": ["ํ ๊ณต", "๋ฐฐ์๊ณต", "๊ต๋๊ณต(ํ๋ชฉ๊ต)", "๊ตฌ์กฐ๋ฌผ&๋ถ๋๊ณต", "๊ธฐํ"],
+ "์๊ณต๊ณํ(์ด์ฒ~๊ณต์ฃผ)": ["ํ ๊ณต", "๋ฐฐ์๊ณต", "๊ต๋๊ณต(ํ๋ชฉ๊ต)", "๊ตฌ์กฐ๋ฌผ&๋ถ๋&ํฌ์ฅ&๊ตํต์์ ๊ณต", "ํ๊ฒฝ ๋ฐ ํ์ง๊ด๋ฆฌ"]
+ },
+ "๊ณต์ฌ๊ด๋ฆฌ": {
+ "๊ณต์ ยท์ผ์ ": ["๊ณต์ ํ", "์๊ฐ ๊ณต์ ๋ณด๊ณ ", "์์
์ผ๋ณด"],
+ "ํ์ง ๊ด๋ฆฌ": ["ํ์ง์ํ๊ณํ์", "ํ์ง์ํ ์ค์ ๋ณด๊ณ ", "์ฝํฌ๋ฆฌํธ ํ์คํํฉ[์ด์ฒ~๊ณต์ฃผ(4์ฐจ)]", "ํ์ง๊ด๋ฆฌ๋น ์ฌ์ฉ๋ด์ญ", "๊ท ์ด๊ด๋ฆฌ", "ํ์ง๊ด๋ฆฌ ์์์๋ฅ"],
+ "์์ ๊ด๋ฆฌ": ["์์ ๊ด๋ฆฌ๊ณํ์", "์์ ๊ด๋ฆฌ ์ค์ ๋ณด๊ณ ", "์ํ์ฑ ํ๊ฐ", "์ฌ์ ์์
ํ๊ฐ์", "์์ ๊ด๋ฆฌ๋น ์ฌ์ฉ๋ด์ญ", "์์ ๊ด๋ฆฌ์์คํ๊ฐ", "์์ ๊ด๋ฆฌ ์์์๋ฅ"],
+ "ํ๊ฒฝ ๊ด๋ฆฌ": ["ํ๊ฒฝ์ํฅํ๊ฐ", "์ฌ์ ์ฌํด์ํฅ์ฑ๊ฒํ ", "์ ์ง๊ด๋ฆฌ ๋ฐ ๋ณด์์ ๊ฒ", "ํ๊ฒฝ๋ณด์ ๋น ์ฌ์ฉ๋ด์ญ", "๊ฑด์คํ๊ธฐ๋ฌผ ๊ด๋ฆฌ"],
+ "์์ฌ ๊ด๋ฆฌ (๊ด๊ธ)": ["์์ฌ๊ตฌ๋งค์์ฒญ (๋ ๋ฏธ์ฝ, ์ฒ ๊ทผ)", "์์ฌ๊ตฌ๋งค์์ฒญ (๊ทธ ์ธ)", "๋ฉํ๊ธฐํ", "๊ณ์ฝ ๋ณ๊ฒฝ", "์์ฌ ๋ฐ์
ยท์๋ถ ๊ด๋ฆฌ", "์์ฌ๊ด๋ฆฌ ์์์๋ฅ"],
+ "์์ฌ ๊ด๋ฆฌ (์ฌ๊ธ)": ["์์ฌ๊ณต๊ธ์ ์น์ธ", "์์ฌ ๋ฐ์
ยท์๋ถ ๊ด๋ฆฌ", "์์ฌ ๊ฒ์ยทํ์ธ"],
+ "์ ๊ฒ (์ ๋ฆฌ์ค)": ["๋ด๋ถ์ ๊ฒ", "์ธ๋ถ์ ๊ฒ"],
+ "๊ณต๋ฌธ": ["์ ์(์์ )", "๋ฐ์ก(๋ฐ์ )", "ํ๋๊ธ", "์ธ๋ ฅ", "๋ฐฉ์นจ"]
+ },
+ "๋ฏผ์๊ด๋ฆฌ": {
+ "๋ฏผ์(์ด์ฒ~๊ณต์ฃผ)": ["์ฒ๋ฆฌ๋์ฅ", "๋ณด์", "๊ณต์ฌ์ผ๋ฐ", "ํ๊ฒฝ๋ถ์"],
+ "์ค์ ๋ณด๊ณ (์ด์ฒ~๊ณต์ฃผ)": ["๋ฏผ์"],
+ "์ค์ ๋ณด๊ณ (๋์ ~์ ์)": ["๋ฏผ์"]
+ }
+}
+
+def analyze_flow_reasoning(filename, all_text_list):
+ """
+ ๋ณธ๋ฌธ์ ์ ์ ์กฐ์ฌ ๊ฒฐ๊ณผ์ ํ์ผ๋ช
์ '์๋ ๊ฐ์ค์น'๋ฅผ ๋ํด ์ต์ข
์ถ๋ก
+ """
+ full_text = " ".join(all_text_list)
+ clean_ctx = full_text.replace(" ", "").replace("\n", "").lower()
+ fn_clean = filename.replace(" ", "").lower()
+
+ # 1. ๋๋ฉ์ธ๋ณ ๊ธฐ๋ณธ ์ ์ (๋ณธ๋ฌธ ์ ์ ์กฐ์ฌ - ํ๋ฑํ๊ฒ)
+ scores = {
+ "official": sum(clean_ctx.count(k) for k in ["์์ :", "๋ฐ์ :", "๊ฒฝ์ :", "์ํ์ผ์", "๊ทํ", "๋๋ฆฝ๋๋ค", "๋ฐ๋๋๋ค"]),
+ "contract": sum(clean_ctx.count(k) for k in ["๊ณ์ฝ์", "ํ๋๊ธ", "์ธ์ฃผ", "๋๊ธ", "์ธ๊ฐ", "์ฌ์
์"]),
+ "hr": sum(clean_ctx.count(k) for k in ["์ดํ๊ณ", "์ธ๋ ฅ", "๊ธฐ์ ์", "์์ ๊ด๋ฆฌ์", "์ฌ์ง์ฆ๋ช
", "๋ฐฐ์น"]),
+ "change": sum(clean_ctx.count(k) for k in ["์ค์ ๋ณด๊ณ ", "์ค๊ณ๋ณ๊ฒฝ", "๋ณ๊ฒฝ๋ณด๊ณ ", "์ถ๊ฐ๋ฐ์"]),
+ "technical": sum(clean_ctx.count(k) for k in ["์ผ์๋๊ฐ", "์ฐ์ถ๊ทผ๊ฑฐ", "์ง๊ณํ", "๋ฌผ๋์ฐ์ถ", "๋จ๊ฐ", "๋ด์ญ", "๋๋ฉด", "dwg"])
+ }
+
+ # 2. ํ์ผ๋ช
์ ๋ํ '๋ฐฉํฅํ' ๊ฐ์ค์น ๋ถ์ฌ (Final Push)
+ # ๋ณธ๋ฌธ ๋ฐ์ดํฐ๊ฐ ์๋ฌด๋ฆฌ ๋ง์๋ ํ์ผ๋ช
์ ์๋๋ฅผ ์กด์คํ๊ธฐ ์ํด 7๋ฐฐ ๊ฐ์ค์น
+ if "์ค์ " in fn_clean or "๋ณ๊ฒฝ" in fn_clean: scores["change"] += 50 # ๋ณธ๋ฌธ 50ํ ์ธ๊ธ๊ณผ ๋ง๋จน๋ ๊ฐ์ค์น
+ if "๊ณ์ฝ" in fn_clean or "ํ๋๊ธ" in fn_clean: scores["contract"] += 50
+ if "์ธ๋ ฅ" in fn_clean or "์ดํ" in fn_clean: scores["hr"] += 50
+ if "๋จ๊ฐ" in fn_clean or "์๋" in fn_clean or "๋๋ฉด" in fn_clean: scores["technical"] += 50
+ if "์ ์ถ" in fn_clean or "๊ฑด" in fn_clean: scores["official"] += 30
+
+ # 3. ์ข
ํฉ ๋๋์ ๋ฐ๋ฅธ ์ต์ข
๋๋ฉ์ธ ์ ์
+ dominant_domain = max(scores, key=scores.get)
+
+ # ํ๋ก์ ํธ ์๋ณ (Fuzzy ๋งค์นญ ๋ฐ ๊ต์ฐจ ๊ฒ์ฆ)
+ project_loc = "์ด์ฒ~๊ณต์ฃผ" if any(k in clean_ctx or k in fn_clean for k in ["์ด์ฒ", "๊ณต์ฃผ"]) else "๋์ ~์ ์" if any(k in clean_ctx or k in fn_clean for k in ["๋์ ", "์ ์"]) else "๊ณตํต"
+
+ # --- [ํตํฉ ์ถ๋ก ๋ฐ ๋งค์นญ] ---
+
+ # ์๋๋ฆฌ์ค A: ์ค์ ๋ณด๊ณ /์ค๊ณ๋ณ๊ฒฝ (๋ณธ๋ฌธ ๋ฐ์ดํฐ + ํ์ผ๋ช
์๋ ํฉ์ฑ)
+ if dominant_domain == "change" or (scores["change"] > 0 and scores["technical"] > 5):
+ cat = f"์ค์ ๋ณด๊ณ ({project_loc})"
+ sub = "์ง์ฅ๋ฌผ" if any(k in clean_ctx for k in ["์๋๋ฃ", "ํ ์ง", "๋ณด์"]) else "๊ตฌ์กฐ๋ฌผ๊ณต" if "๊ตฌ์กฐ๋ฌผ" in clean_ctx else "๊ธฐํ"
+ return f"์ค๊ณ๋ณ๊ฒฝ > {cat} > {sub}", f"๋ณธ๋ฌธ์ ๊ธฐ์ ๋ฐ์ดํฐ ๋ฐ๋์ ํ์ผ๋ช
์ '{dominant_domain}' ๊ด๋ จ ์๋๋ฅผ ์ข
ํฉํ์ฌ {project_loc} ํ๋ก์ ํธ์ ์ค์ ๋ณด๊ณ ๋ณธ์ฒด๋ก ํ์ ."
+
+ # ์๋๋ฆฌ์ค B: ํ์ ๊ณ์ฝ/ํ๋๊ธ (๋ณธ์ฒด ์ค์ฌ)
+ if dominant_domain == "contract":
+ return "ํ์ > ๊ณ์ฝ > ๊ณ์ฝ๊ด๋ฆฌ", "๋ฌธ์ ์ ์ฒด์์ ๊ณ์ฝ ๋ฐ ํ๋๊ธ ์
๋ฌด ๋ณธ์ง์ด ์ง๋ฐฐ์ ์ผ๋ก ํ์ธ๋จ."
+
+ # ์๋๋ฆฌ์ค C: ์ธ์ฌ/์ธ๋ ฅ ๊ด๋ฆฌ
+ if dominant_domain == "hr":
+ if len(all_text_list) <= 2: return "๊ณต์ฌ๊ด๋ฆฌ > ๊ณต๋ฌธ > ์ธ๋ ฅ", "์ธ๋ ฅ ์ฌํญ์ ๊ฐ๋ตํ ๋ณด๊ณ ํ๋ ๊ณต๋ฌธ ํ์์."
+ return "ํ์ > ๊ณ์ฝ > ์ธ์๊ด๋ฆฌ", "๋ค๋์ ์ธ๋ ฅ ์ฆ๋น ๋ฐ์ดํฐ๊ฐ ํฌํจ๋ ํ์ ์๋ฅ์."
+
+ # ์๋๋ฆฌ์ค D: ์์ ๊ณต๋ฌธ (ํ์ ์ฐ์ )
+ if dominant_domain == "official" or scores["official"] > scores["technical"]:
+ tab, cat = "๊ณต์ฌ๊ด๋ฆฌ", "๊ณต๋ฌธ"
+ sub = "์ ์(์์ )"
+ if "๋ฐฉ์นจ" in clean_ctx or "์ง์นจ" in clean_ctx: sub = "๋ฐฉ์นจ"
+ elif "๋ฐ์ " in clean_ctx[:500]: sub = "๋ฐ์ก(๋ฐ์ )"
+ return f"{tab} > {cat} > {sub}", "์ ์ฒด ๋งฅ๋ฝ์ ๊ธฐ์ ์ ๋ฐ์ดํฐ๋ณด๋ค ํ์ ์ ์ ๋ฌ ํ์(๊ณต๋ฌธ)๊ฐ ํต์ฌ ์ ์ฒด์ฑ์ผ๋ก ํ๋จ๋จ."
+
+ # ์๋๋ฆฌ์ค E: ๊ธฐ์ ์ฑ๊ณผํ
+ if dominant_domain == "technical":
+ if any(k in clean_ctx or k in fn_clean for k in ["๋จ๊ฐ", "๋ด์ญ"]): return "์ค๊ณ์ฑ๊ณผํ > ๋ด์ญ์ > ๋จ๊ฐ์ฐ์ถ์", "๋ด์ญ/๋จ๊ฐ ์ฐ์ถ ๊ธฐ์ ๋ฐ์ดํฐ ํ์ธ."
+ if any(k in clean_ctx or k in fn_clean for k in ["๋๋ฉด", "dwg"]): return "์ค๊ณ์ฑ๊ณผํ > ์ค๊ณ๋๋ฉด > ๊ณตํต", "๋๋ฉด/๊ทธ๋ํฝ ๋ฐ์ดํฐ ํ์ธ."
+ return "์ค๊ณ์ฑ๊ณผํ > ์๋์ฐ์ถ์ > ํ ๊ณต", "์๋/๋ฌผ๋ ์ฐ์ถ ๋ฐ์ดํฐ ํ์ธ."
+
+ return "ํ์ > ์
๋ฌด๊ด๋ฆฌ > ์์์๋ฅ", "์ผ๋ฐ ํ์ ๋ฐ ๊ธฐํ ์์ ์๋ฅ๋ก ๋ถ๋ฅํจ."
+
+def analyze_file_content(filename: str):
+ try:
+ file_path = os.path.join("sample", filename)
+ text_by_pages = []
+ if filename.lower().endswith(".pdf"):
+ reader = PdfReader(file_path)
+ for i in range(len(reader.pages)):
+ page_text = reader.pages[i].extract_text() or ""
+ if OCR_AVAILABLE:
+ try:
+ images = convert_from_path(file_path, first_page=i+1, last_page=i+1, poppler_path=POPPLER_BIN, dpi=200)
+ if images:
+ ocr_result = pytesseract.image_to_string(images[0], lang='kor+eng')
+ page_text += "\n" + ocr_result
+ except Exception as ocr_err:
+ print(f"OCR Error on page {i+1}: {ocr_err}")
+ text_by_pages.append(page_text)
+ elif filename.lower().endswith(('.xlsx', '.xls')):
+ import pandas as pd
+ df = pd.read_excel(file_path)
+ text_by_pages.append(df.to_string())
+ else: text_by_pages.append("")
+
+ path, reason = analyze_flow_reasoning(filename, text_by_pages)
+
+ return {
+ "filename": filename,
+ "total_pages": len(text_by_pages),
+ "final_result": {
+ "suggested_path": path,
+ "confidence": "100%",
+ "reason": reason,
+ "snippet": " ".join(text_by_pages)[:1500]
+ }
+ }
+ except Exception as e:
+ return {"error": str(e), "filename": filename}
diff --git a/analyze_logs_pattern.py b/analyze_logs_pattern.py
new file mode 100644
index 0000000..5aae4c4
--- /dev/null
+++ b/analyze_logs_pattern.py
@@ -0,0 +1,48 @@
+import pymysql
+import re
+from collections import Counter
+
+def get_db_connection():
+ return pymysql.connect(
+ host='localhost',
+ user='root',
+ password='45278434',
+ database='pm_proto_test',
+ charset='utf8mb4',
+ cursorclass=pymysql.cursors.DictCursor
+ )
+
+def analyze_logs():
+ conn = get_db_connection()
+ try:
+ with conn.cursor() as cursor:
+ cursor.execute("SELECT DISTINCT recent_log FROM projects_history WHERE recent_log IS NOT NULL AND recent_log != ''")
+ rows = cursor.fetchall()
+
+ logs = [r['recent_log'] for r in rows]
+
+ output = []
+ output.append("[Raw Log Samples]")
+ for log in logs[:20]:
+ output.append(f"- {log}")
+
+ patterns = []
+ for log in logs:
+ p = re.sub(r'\d{2,4}\.\d{2}\.\d{2}', '[DATE]', log)
+ p = re.sub(r'\d+', '[NUM]', p)
+ patterns.append(p)
+
+ output.append("\n[Log Patterns Frequency]")
+ pattern_counts = Counter(patterns).most_common(20)
+ for p, count in pattern_counts:
+ output.append(f"({count}) {p}")
+
+ with open("log_analysis_result.txt", "w", encoding="utf-8") as f:
+ f.write("\n".join(output))
+ print("Analysis complete. Result saved to log_analysis_result.txt")
+
+ finally:
+ conn.close()
+
+if __name__ == "__main__":
+ analyze_logs()
diff --git a/check_tables.py b/check_tables.py
new file mode 100644
index 0000000..caac7c4
--- /dev/null
+++ b/check_tables.py
@@ -0,0 +1,29 @@
+import pymysql
+import os
+from dotenv import load_dotenv
+
+load_dotenv()
+
+def show_tables():
+ conn = pymysql.connect(
+ host=os.getenv('DB_HOST', 'localhost'),
+ user=os.getenv('DB_USER', 'root'),
+ password=os.getenv('DB_PASSWORD', '45278434'),
+ database='PM_proto_test',
+ charset='utf8mb4',
+ cursorclass=pymysql.cursors.DictCursor
+ )
+ try:
+ with conn.cursor() as cursor:
+ cursor.execute("SHOW TABLES")
+ tables = cursor.fetchall()
+ print("Tables in PM_proto_test:")
+ for t in tables:
+ print(f" - {list(t.values())[0]}")
+ except Exception as e:
+ print(f"Error occurred: {e}")
+ finally:
+ conn.close()
+
+if __name__ == "__main__":
+ show_tables()
diff --git a/clear_test_db.py b/clear_test_db.py
new file mode 100644
index 0000000..adec441
--- /dev/null
+++ b/clear_test_db.py
@@ -0,0 +1,29 @@
+import pymysql
+import os
+from dotenv import load_dotenv
+
+load_dotenv()
+
+def clear_project_history():
+ conn = pymysql.connect(
+ host=os.getenv('DB_HOST', 'localhost'),
+ user=os.getenv('DB_USER', 'root'),
+ password=os.getenv('DB_PASSWORD', '45278434'),
+ database='PM_proto_test',
+ charset='utf8mb4',
+ cursorclass=pymysql.cursors.DictCursor
+ )
+ try:
+ with conn.cursor() as cursor:
+ # ํ
์ด๋ธ์ ๋ชจ๋ ๋ฐ์ดํฐ๋ฅผ ์ญ์
+ print("Cleaning projects_history table in PM_proto_test...")
+ cursor.execute("DELETE FROM projects_history")
+ conn.commit()
+ print("Successfully cleared all records from projects_history.")
+ except Exception as e:
+ print(f"Error occurred: {e}")
+ finally:
+ conn.close()
+
+if __name__ == "__main__":
+ clear_project_history()
diff --git a/clone_db.py b/clone_db.py
new file mode 100644
index 0000000..06d4abe
--- /dev/null
+++ b/clone_db.py
@@ -0,0 +1,45 @@
+import pymysql
+import os
+
+def clone_database():
+ try:
+ connection = pymysql.connect(
+ host=os.getenv('DB_HOST', 'localhost'),
+ user=os.getenv('DB_USER', 'root'),
+ password=os.getenv('DB_PASSWORD', '45278434'),
+ charset='utf8mb4',
+ cursorclass=pymysql.cursors.DictCursor
+ )
+
+ with connection.cursor() as cursor:
+ # 1. Create test database
+ cursor.execute("CREATE DATABASE IF NOT EXISTS PM_proto_test")
+ print("Database PM_proto_test created or already exists.")
+
+ # 2. Get all tables from source database
+ cursor.execute("SHOW TABLES FROM PM_proto")
+ tables = cursor.fetchall()
+
+ for table_row in tables:
+ table_name = list(table_row.values())[0]
+
+ # 3. Drop existing table in test DB if exists
+ cursor.execute(f"DROP TABLE IF EXISTS PM_proto_test.{table_name}")
+
+ # 4. Clone schema and data
+ # Note: CREATE TABLE ... LIKE doesn't copy data, and CREATE TABLE ... AS SELECT doesn't copy indexes.
+ # So we use LIKE first, then INSERT INTO ... SELECT *
+ cursor.execute(f"CREATE TABLE PM_proto_test.{table_name} LIKE PM_proto.{table_name}")
+ cursor.execute(f"INSERT INTO PM_proto_test.{table_name} SELECT * FROM PM_proto.{table_name}")
+ print(f"Table {table_name} cloned.")
+
+ connection.commit()
+ print("Database cloning completed successfully.")
+ except Exception as e:
+ print(f"Error during database cloning: {e}")
+ finally:
+ if 'connection' in locals():
+ connection.close()
+
+if __name__ == "__main__":
+ clone_database()
diff --git a/crawler_service.py b/crawler_service.py
index 0d9ca37..1b333b3 100644
--- a/crawler_service.py
+++ b/crawler_service.py
@@ -1,258 +1,258 @@
-import os
-import re
-import asyncio
-import json
-import traceback
-import sys
-import threading
-import queue
-import pymysql
-from datetime import datetime
-from playwright.async_api import async_playwright
-from dotenv import load_dotenv
-from sql_queries import CrawlerQueries
-
-load_dotenv(override=True)
-
-# ๊ธ๋ก๋ฒ ์ค๋จ ์ ์ด์ฉ ์ด๋ฒคํธ
-crawl_stop_event = threading.Event()
-
-def get_db_connection():
- """MySQL ๋ฐ์ดํฐ๋ฒ ์ด์ค ์ฐ๊ฒฐ์ ๋ฐํ (ํ๊ฒฝ๋ณ์ ๊ธฐ๋ฐ)"""
- return pymysql.connect(
- host=os.getenv('DB_HOST', 'localhost'),
- user=os.getenv('DB_USER', 'root'),
- password=os.getenv('DB_PASSWORD', '45278434'),
- database=os.getenv('DB_NAME', 'PM_proto'),
- charset='utf8mb4',
- cursorclass=pymysql.cursors.DictCursor
- )
-
-def clean_date_string(date_str):
- if not date_str: return ""
- match = re.search(r'(\d{2})[./-](\d{2})[./-](\d{2})', date_str)
- if match: return f"20{match.group(1)}.{match.group(2)}.{match.group(3)}"
- return date_str[:10].replace("-", ".")
-
-def parse_log_id(log_id):
- if not log_id or "_" not in log_id: return log_id
- try:
- parts = log_id.split('_')
- if len(parts) >= 4:
- date_part = clean_date_string(parts[1])
- activity = parts[3].strip()
- activity = re.sub(r'\(.*?\)', '', activity).strip()
- return f"{date_part}, {activity}"
- except: pass
- return log_id
-
-def crawler_thread_worker(msg_queue, user_id, password):
- crawl_stop_event.clear()
- if sys.platform == 'win32':
- asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy())
-
- loop = asyncio.new_event_loop()
- asyncio.set_event_loop(loop)
-
- async def run():
- async with async_playwright() as p:
- browser = None
- try:
- msg_queue.put(json.dumps({'type': 'log', 'message': '๋ธ๋ผ์ฐ์ ์์ง ๊ฐ๋ (์ ๊ธฐ๋ฅ ๋ณต๊ตฌ ๋ชจ๋)...'}))
- browser = await p.chromium.launch(headless=True, args=[
- "--no-sandbox",
- "--disable-dev-shm-usage",
- "--disable-blink-features=AutomationControlled"
- ])
- context = await browser.new_context(
- viewport={'width': 1600, 'height': 900},
- user_agent="Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36"
- )
-
- captured_data = {"tree": None, "_is_root_archive": False, "project_list": [], "last_project_data": None}
-
- async def global_interceptor(response):
- url = response.url
- try:
- if "getAllList" in url:
- data = await response.json()
- captured_data["project_list"] = data.get("data", [])
- elif "getTreeObject" in url:
- is_root = False
- if "params[resourcePath]=" in url:
- path_val = url.split("params[resourcePath]=")[1].split("&")[0]
- if path_val in ["%2F", "/"]: is_root = True
- if is_root:
- captured_data["tree"] = await response.json()
- captured_data["_is_root_archive"] = True
- elif "getData" in url and "overview" in url:
- captured_data["last_project_data"] = await response.json()
- except: pass
-
- context.on("response", global_interceptor)
- page = await context.new_page()
- await page.goto("https://overseas.projectmastercloud.com/dashboard", wait_until="domcontentloaded")
-
- # ๋ก๊ทธ์ธ
- if await page.locator("#login-by-id").is_visible(timeout=10000):
- await page.click("#login-by-id")
- await page.fill("#user_id", user_id)
- await page.fill("#user_pw", password)
- await page.click("#login-btn")
-
- await page.wait_for_selector("h4.list__contents_aria_group_body_list_item_label", timeout=60000)
- await asyncio.sleep(3)
-
- # [Phase 1] DB ๋ง์คํฐ ์ ๋ณด ๋๊ธฐํ
- if captured_data["project_list"]:
- conn = get_db_connection()
- try:
- with conn.cursor() as cursor:
- for p_info in captured_data["project_list"]:
- cursor.execute(CrawlerQueries.UPSERT_MASTER, (p_info.get("project_id"), p_info.get("project_nm"),
- p_info.get("short_nm", "").strip(), p_info.get("master"),
- p_info.get("large_class"), p_info.get("mid_class")))
- conn.commit()
- msg_queue.put(json.dumps({'type': 'log', 'message': 'DB ๋ง์คํฐ ์ ๋ณด ๋๊ธฐํ ์๋ฃ.'}))
- finally: conn.close()
-
- # [Phase 2] ์์ง ๋ฃจํ
- names = await page.locator("h4.list__contents_aria_group_body_list_item_label").all_inner_texts()
- project_names = list(dict.fromkeys([n.strip() for n in names if n.strip()]))
- count = len(project_names)
-
- for i, project_name in enumerate(project_names):
- if crawl_stop_event.is_set():
- msg_queue.put(json.dumps({'type': 'log', 'message': '>>> ์ค๋จ ์ ํธ ๊ฐ์ง: ์ข
๋ฃํฉ๋๋ค.'}))
- break
-
- msg_queue.put(json.dumps({'type': 'log', 'message': f'[{i+1}/{count}] {project_name} ์์ง ์์'}))
- p_match = next((p for p in captured_data["project_list"] if p.get('project_nm') == project_name or p.get('short_nm', '').strip() == project_name), None)
- current_p_id = p_match.get('project_id') if p_match else None
- captured_data["tree"] = None; captured_data["_is_root_archive"] = False
-
- try:
- # 1. ํ๋ก์ ํธ ์ง์
(์ขํ ํด๋ฆญ)
- target_el = page.locator(f"h4.list__contents_aria_group_body_list_item_label:has-text('{project_name}')").first
- await target_el.scroll_into_view_if_needed()
- box = await target_el.bounding_box()
- if box: await page.mouse.click(box['x'] + 5, box['y'] + 5)
- else: await target_el.click(force=True)
-
- await page.wait_for_selector("text=ํ๋๋ก๊ทธ", timeout=30000)
-
- # [๋ถ์ ์ ๋ณด ์์ง] getData ์๋ต ๋๊ธฐ ๋ฐ DB ์
๋ฐ์ดํธ
- for _ in range(10):
- if captured_data.get("last_project_data"): break
- await asyncio.sleep(0.5)
-
- last_data = captured_data.get("last_project_data")
- if last_data:
- if isinstance(last_data, list) and len(last_data) > 0:
- last_data = last_data[0]
-
- if isinstance(last_data, dict):
- proj_data = last_data.get("data", {})
- if isinstance(proj_data, list) and len(proj_data) > 0:
- proj_data = proj_data[0]
-
- if isinstance(proj_data, dict):
- dept = proj_data.get("department")
- p_id = proj_data.get("project_id")
- if dept and p_id:
- with get_db_connection() as conn:
- with conn.cursor() as cursor:
- cursor.execute(CrawlerQueries.UPDATE_DEPARTMENT, (dept, p_id))
- conn.commit()
- captured_data["last_project_data"] = None # ์ด๊ธฐํ
-
- await asyncio.sleep(2)
-
- recent_log = "๋ฐ์ดํฐ ์์"; file_count = 0
-
- # 2. ํ๋๋ก๊ทธ (๋ ์ง ํํฐ ์ ์ฉ ๋ฒ์ )
- modal_opened = False
- for _ in range(3):
- await page.get_by_text("ํ๋๋ก๊ทธ").first.click()
- try:
- await page.wait_for_selector("article.archive-modal", timeout=5000)
- modal_opened = True; break
- except: await asyncio.sleep(1)
-
- if modal_opened:
- # ๋ ์ง ํํฐ 2020-01-01 ์ ์ฉ
- inputs = await page.locator("article.archive-modal input").all()
- for inp in inputs:
- if (await inp.get_attribute("type")) == "date":
- await inp.fill("2020-01-01"); break
-
- apply_btn = page.locator("article.archive-modal").get_by_text("์ ์ฉ").first
- if await apply_btn.is_visible():
- await apply_btn.click()
- await asyncio.sleep(5)
- log_elements = await page.locator("article.archive-modal div[id*='_']").all()
- if log_elements:
- recent_log = parse_log_id(await log_elements[0].get_attribute("id"))
- await page.keyboard.press("Escape")
-
- # 3. ๊ตฌ์ฑ ์์ง (API Fetch ๋ฐฉ์ - ํ์
์์)
- await page.evaluate("""() => {
- const baseUrl = window.location.origin + window.location.pathname.split('/').slice(0, 2).join('/');
- fetch(`${baseUrl}/archive/getTreeObject?params[storageType]=CLOUD¶ms[resourcePath]=/`);
- }""")
- for _ in range(30):
- if captured_data["_is_root_archive"]: break
- await asyncio.sleep(0.5)
-
- if captured_data["tree"]:
- tree_data = captured_data["tree"]
- if isinstance(tree_data, list) and len(tree_data) > 0:
- tree_data = tree_data[0]
-
- if isinstance(tree_data, dict):
- tree = tree_data.get('currentTreeObject', tree_data)
- if isinstance(tree, dict):
- total = len(tree.get("file", {}))
- folders = tree.get("folder", {})
- if isinstance(folders, dict):
- for f in folders.values(): total += int(f.get("filesCount", 0))
- file_count = total
-
- # 4. DB ์ค์๊ฐ ์ ์ฅ
- if current_p_id:
- with get_db_connection() as conn:
- with conn.cursor() as cursor:
- cursor.execute(CrawlerQueries.UPSERT_HISTORY, (current_p_id, recent_log, file_count))
- conn.commit()
- msg_queue.put(json.dumps({'type': 'log', 'message': f' - [์ฑ๊ณต] ๋ก๊ทธ: {recent_log[:20]}... / ํ์ผ: {file_count}๊ฐ'}))
-
- await page.goto("https://overseas.projectmastercloud.com/dashboard", wait_until="domcontentloaded")
-
- except Exception as e:
- msg_queue.put(json.dumps({'type': 'log', 'message': f' - {project_name} ์คํจ: {str(e)}'}))
- await page.goto("https://overseas.projectmastercloud.com/dashboard")
-
- msg_queue.put(json.dumps({'type': 'done', 'data': []}))
-
- except Exception as e:
- msg_queue.put(json.dumps({'type': 'log', 'message': f'์น๋ช
์ ์ค๋ฅ: {str(e)}'}))
- finally:
- if browser: await browser.close()
- msg_queue.put(None)
-
- loop.run_until_complete(run())
- loop.close()
-
-async def run_crawler_service():
- msg_queue = queue.Queue()
- thread = threading.Thread(target=crawler_thread_worker, args=(msg_queue, os.getenv("PM_USER_ID"), os.getenv("PM_PASSWORD")))
- thread.start()
- while True:
- try:
- msg = await asyncio.to_thread(msg_queue.get, timeout=1.0)
- if msg is None: break
- yield f"data: {msg}\n\n"
- except queue.Empty:
- if not thread.is_alive(): break
- await asyncio.sleep(0.1)
- thread.join()
+import os
+import re
+import asyncio
+import json
+import traceback
+import sys
+import threading
+import queue
+import pymysql
+from datetime import datetime
+from playwright.async_api import async_playwright
+from dotenv import load_dotenv
+from sql_queries import CrawlerQueries
+
+load_dotenv(override=True)
+
+# ๊ธ๋ก๋ฒ ์ค๋จ ์ ์ด์ฉ ์ด๋ฒคํธ
+crawl_stop_event = threading.Event()
+
+def get_db_connection():
+ """MySQL ๋ฐ์ดํฐ๋ฒ ์ด์ค ์ฐ๊ฒฐ์ ๋ฐํ (ํ๊ฒฝ๋ณ์ ๊ธฐ๋ฐ)"""
+ return pymysql.connect(
+ host=os.getenv('DB_HOST', 'localhost'),
+ user=os.getenv('DB_USER', 'root'),
+ password=os.getenv('DB_PASSWORD', '45278434'),
+ database=os.getenv('DB_NAME', 'PM_proto'),
+ charset='utf8mb4',
+ cursorclass=pymysql.cursors.DictCursor
+ )
+
+def clean_date_string(date_str):
+ if not date_str: return ""
+ match = re.search(r'(\d{2})[./-](\d{2})[./-](\d{2})', date_str)
+ if match: return f"20{match.group(1)}.{match.group(2)}.{match.group(3)}"
+ return date_str[:10].replace("-", ".")
+
+def parse_log_id(log_id):
+ if not log_id or "_" not in log_id: return log_id
+ try:
+ parts = log_id.split('_')
+ if len(parts) >= 4:
+ date_part = clean_date_string(parts[1])
+ activity = parts[3].strip()
+ activity = re.sub(r'\(.*?\)', '', activity).strip()
+ return f"{date_part}, {activity}"
+ except: pass
+ return log_id
+
+def crawler_thread_worker(msg_queue, user_id, password):
+ crawl_stop_event.clear()
+ if sys.platform == 'win32':
+ asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy())
+
+ loop = asyncio.new_event_loop()
+ asyncio.set_event_loop(loop)
+
+ async def run():
+ async with async_playwright() as p:
+ browser = None
+ try:
+ msg_queue.put(json.dumps({'type': 'log', 'message': '๋ธ๋ผ์ฐ์ ์์ง ๊ฐ๋ (์ ๊ธฐ๋ฅ ๋ณต๊ตฌ ๋ชจ๋)...'}))
+ browser = await p.chromium.launch(headless=True, args=[
+ "--no-sandbox",
+ "--disable-dev-shm-usage",
+ "--disable-blink-features=AutomationControlled"
+ ])
+ context = await browser.new_context(
+ viewport={'width': 1600, 'height': 900},
+ user_agent="Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36"
+ )
+
+ captured_data = {"tree": None, "_is_root_archive": False, "project_list": [], "last_project_data": None}
+
+ async def global_interceptor(response):
+ url = response.url
+ try:
+ if "getAllList" in url:
+ data = await response.json()
+ captured_data["project_list"] = data.get("data", [])
+ elif "getTreeObject" in url:
+ is_root = False
+ if "params[resourcePath]=" in url:
+ path_val = url.split("params[resourcePath]=")[1].split("&")[0]
+ if path_val in ["%2F", "/"]: is_root = True
+ if is_root:
+ captured_data["tree"] = await response.json()
+ captured_data["_is_root_archive"] = True
+ elif "getData" in url and "overview" in url:
+ captured_data["last_project_data"] = await response.json()
+ except: pass
+
+ context.on("response", global_interceptor)
+ page = await context.new_page()
+ await page.goto("https://overseas.projectmastercloud.com/dashboard", wait_until="domcontentloaded")
+
+ # ๋ก๊ทธ์ธ
+ if await page.locator("#login-by-id").is_visible(timeout=10000):
+ await page.click("#login-by-id")
+ await page.fill("#user_id", user_id)
+ await page.fill("#user_pw", password)
+ await page.click("#login-btn")
+
+ await page.wait_for_selector("h4.list__contents_aria_group_body_list_item_label", timeout=60000)
+ await asyncio.sleep(3)
+
+ # [Phase 1] DB ๋ง์คํฐ ์ ๋ณด ๋๊ธฐํ
+ if captured_data["project_list"]:
+ conn = get_db_connection()
+ try:
+ with conn.cursor() as cursor:
+ for p_info in captured_data["project_list"]:
+ cursor.execute(CrawlerQueries.UPSERT_MASTER, (p_info.get("project_id"), p_info.get("project_nm"),
+ p_info.get("short_nm", "").strip(), p_info.get("master"),
+ p_info.get("large_class"), p_info.get("mid_class")))
+ conn.commit()
+ msg_queue.put(json.dumps({'type': 'log', 'message': 'DB ๋ง์คํฐ ์ ๋ณด ๋๊ธฐํ ์๋ฃ.'}))
+ finally: conn.close()
+
+ # [Phase 2] ์์ง ๋ฃจํ
+ names = await page.locator("h4.list__contents_aria_group_body_list_item_label").all_inner_texts()
+ project_names = list(dict.fromkeys([n.strip() for n in names if n.strip()]))
+ count = len(project_names)
+
+ for i, project_name in enumerate(project_names):
+ if crawl_stop_event.is_set():
+ msg_queue.put(json.dumps({'type': 'log', 'message': '>>> ์ค๋จ ์ ํธ ๊ฐ์ง: ์ข
๋ฃํฉ๋๋ค.'}))
+ break
+
+ msg_queue.put(json.dumps({'type': 'log', 'message': f'[{i+1}/{count}] {project_name} ์์ง ์์'}))
+ p_match = next((p for p in captured_data["project_list"] if p.get('project_nm') == project_name or p.get('short_nm', '').strip() == project_name), None)
+ current_p_id = p_match.get('project_id') if p_match else None
+ captured_data["tree"] = None; captured_data["_is_root_archive"] = False
+
+ try:
+ # 1. ํ๋ก์ ํธ ์ง์
(์ขํ ํด๋ฆญ)
+ target_el = page.locator(f"h4.list__contents_aria_group_body_list_item_label:has-text('{project_name}')").first
+ await target_el.scroll_into_view_if_needed()
+ box = await target_el.bounding_box()
+ if box: await page.mouse.click(box['x'] + 5, box['y'] + 5)
+ else: await target_el.click(force=True)
+
+ await page.wait_for_selector("text=ํ๋๋ก๊ทธ", timeout=30000)
+
+ # [๋ถ์ ์ ๋ณด ์์ง] getData ์๋ต ๋๊ธฐ ๋ฐ DB ์
๋ฐ์ดํธ
+ for _ in range(10):
+ if captured_data.get("last_project_data"): break
+ await asyncio.sleep(0.5)
+
+ last_data = captured_data.get("last_project_data")
+ if last_data:
+ if isinstance(last_data, list) and len(last_data) > 0:
+ last_data = last_data[0]
+
+ if isinstance(last_data, dict):
+ proj_data = last_data.get("data", {})
+ if isinstance(proj_data, list) and len(proj_data) > 0:
+ proj_data = proj_data[0]
+
+ if isinstance(proj_data, dict):
+ dept = proj_data.get("department")
+ p_id = proj_data.get("project_id")
+ if dept and p_id:
+ with get_db_connection() as conn:
+ with conn.cursor() as cursor:
+ cursor.execute(CrawlerQueries.UPDATE_DEPARTMENT, (dept, p_id))
+ conn.commit()
+ captured_data["last_project_data"] = None # ์ด๊ธฐํ
+
+ await asyncio.sleep(2)
+
+ recent_log = "๋ฐ์ดํฐ ์์"; file_count = 0
+
+ # 2. ํ๋๋ก๊ทธ (๋ ์ง ํํฐ ์ ์ฉ ๋ฒ์ )
+ modal_opened = False
+ for _ in range(3):
+ await page.get_by_text("ํ๋๋ก๊ทธ").first.click()
+ try:
+ await page.wait_for_selector("article.archive-modal", timeout=5000)
+ modal_opened = True; break
+ except: await asyncio.sleep(1)
+
+ if modal_opened:
+ # ๋ ์ง ํํฐ 2020-01-01 ์ ์ฉ
+ inputs = await page.locator("article.archive-modal input").all()
+ for inp in inputs:
+ if (await inp.get_attribute("type")) == "date":
+ await inp.fill("2020-01-01"); break
+
+ apply_btn = page.locator("article.archive-modal").get_by_text("์ ์ฉ").first
+ if await apply_btn.is_visible():
+ await apply_btn.click()
+ await asyncio.sleep(5)
+ log_elements = await page.locator("article.archive-modal div[id*='_']").all()
+ if log_elements:
+ recent_log = parse_log_id(await log_elements[0].get_attribute("id"))
+ await page.keyboard.press("Escape")
+
+ # 3. ๊ตฌ์ฑ ์์ง (API Fetch ๋ฐฉ์ - ํ์
์์)
+ await page.evaluate("""() => {
+ const baseUrl = window.location.origin + window.location.pathname.split('/').slice(0, 2).join('/');
+ fetch(`${baseUrl}/archive/getTreeObject?params[storageType]=CLOUD¶ms[resourcePath]=/`);
+ }""")
+ for _ in range(30):
+ if captured_data["_is_root_archive"]: break
+ await asyncio.sleep(0.5)
+
+ if captured_data["tree"]:
+ tree_data = captured_data["tree"]
+ if isinstance(tree_data, list) and len(tree_data) > 0:
+ tree_data = tree_data[0]
+
+ if isinstance(tree_data, dict):
+ tree = tree_data.get('currentTreeObject', tree_data)
+ if isinstance(tree, dict):
+ total = len(tree.get("file", {}))
+ folders = tree.get("folder", {})
+ if isinstance(folders, dict):
+ for f in folders.values(): total += int(f.get("filesCount", 0))
+ file_count = total
+
+ # 4. DB ์ค์๊ฐ ์ ์ฅ
+ if current_p_id:
+ with get_db_connection() as conn:
+ with conn.cursor() as cursor:
+ cursor.execute(CrawlerQueries.UPSERT_HISTORY, (current_p_id, recent_log, file_count))
+ conn.commit()
+ msg_queue.put(json.dumps({'type': 'log', 'message': f' - [์ฑ๊ณต] ๋ก๊ทธ: {recent_log[:20]}... / ํ์ผ: {file_count}๊ฐ'}))
+
+ await page.goto("https://overseas.projectmastercloud.com/dashboard", wait_until="domcontentloaded")
+
+ except Exception as e:
+ msg_queue.put(json.dumps({'type': 'log', 'message': f' - {project_name} ์คํจ: {str(e)}'}))
+ await page.goto("https://overseas.projectmastercloud.com/dashboard")
+
+ msg_queue.put(json.dumps({'type': 'done', 'data': []}))
+
+ except Exception as e:
+ msg_queue.put(json.dumps({'type': 'log', 'message': f'์น๋ช
์ ์ค๋ฅ: {str(e)}'}))
+ finally:
+ if browser: await browser.close()
+ msg_queue.put(None)
+
+ loop.run_until_complete(run())
+ loop.close()
+
+async def run_crawler_service():
+ msg_queue = queue.Queue()
+ thread = threading.Thread(target=crawler_thread_worker, args=(msg_queue, os.getenv("PM_USER_ID"), os.getenv("PM_PASSWORD")))
+ thread.start()
+ while True:
+ try:
+ msg = await asyncio.to_thread(msg_queue.get, timeout=1.0)
+ if msg is None: break
+ yield f"data: {msg}\n\n"
+ except queue.Empty:
+ if not thread.is_alive(): break
+ await asyncio.sleep(0.1)
+ thread.join()
diff --git a/crawler_service_test.py b/crawler_service_test.py
new file mode 100644
index 0000000..57e10fe
--- /dev/null
+++ b/crawler_service_test.py
@@ -0,0 +1,273 @@
+import os
+import re
+import asyncio
+import json
+import traceback
+import sys
+import threading
+import queue
+import pymysql
+from datetime import datetime, timedelta
+from playwright.async_api import async_playwright
+from dotenv import load_dotenv
+from sql_queries import CrawlerQueries
+
+load_dotenv(override=True)
+
+# ๊ธ๋ก๋ฒ ์ค๋จ ์ ์ด์ฉ ์ด๋ฒคํธ
+crawl_stop_event = threading.Event()
+
+def get_db_connection():
+ """MySQL ๋ฐ์ดํฐ๋ฒ ์ด์ค(TEST) ์ฐ๊ฒฐ์ ๋ฐํ"""
+ return pymysql.connect(
+ host=os.getenv('DB_HOST', 'localhost'),
+ user=os.getenv('DB_USER', 'root'),
+ password=os.getenv('DB_PASSWORD', '45278434'),
+ database='PM_proto_test', # ํ
์คํธ์ฉ DB ๊ณ ์
+ charset='utf8mb4',
+ cursorclass=pymysql.cursors.DictCursor
+ )
+
+def clean_date_string(date_str):
+ """์๋ณธ crawler_service.py์ ๋์ผํ ๋ ์ง ์ ๋ฆฌ ๋ก์ง"""
+ if not date_str: return ""
+ match = re.search(r'(\d{2})[./-](\d{2})[./-](\d{2})', date_str)
+ if match: return f"20{match.group(1)}.{match.group(2)}.{match.group(3)}"
+ return date_str[:10].replace("-", ".")
+
+def parse_log_id(log_id):
+ """์๋ณธ crawler_service.py์ ๋์ผํ ๋ก๊ทธ ID ํ์ฑ ๋ก์ง"""
+ if not log_id or "_" not in log_id: return log_id
+ try:
+ parts = log_id.split('_')
+ if len(parts) >= 4:
+ date_part = clean_date_string(parts[1])
+ activity = parts[3].strip()
+ activity = re.sub(r'\(.*?\)', '', activity).strip()
+ return f"{date_part}, {activity}"
+ except: pass
+ return log_id
+
+def crawler_thread_worker(msg_queue, user_id, password):
+ crawl_stop_event.clear()
+ if sys.platform == 'win32':
+ asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy())
+
+ loop = asyncio.new_event_loop()
+ asyncio.set_event_loop(loop)
+
+ async def run():
+ async with async_playwright() as p:
+ browser = None
+ try:
+ msg_queue.put(json.dumps({'type': 'log', 'message': '[TEST] ์๋ณธ ์์ง ๋ฐฉ์ ๋ณต๊ตฌ ๋ฐ ์ถ๋ก ์์ง ๊ฐ๋...'}))
+ browser = await p.chromium.launch(headless=True, args=[
+ "--no-sandbox",
+ "--disable-dev-shm-usage",
+ "--disable-blink-features=AutomationControlled"
+ ])
+ context = await browser.new_context(
+ viewport={'width': 1600, 'height': 900},
+ user_agent="Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36"
+ )
+
+ captured_data = {"tree": None, "_is_root_archive": False, "project_list": [], "last_project_data": None}
+
+ async def global_interceptor(response):
+ url = response.url
+ try:
+ if "getAllList" in url:
+ data = await response.json()
+ captured_data["project_list"] = data.get("data", [])
+ elif "getTreeObject" in url:
+ # [๋ณต๊ตฌ] ์๋ณธ๊ณผ 100% ๋์ผํ ๋ฃจํธ ํ์ ๋ก์ง
+ is_root = False
+ if "params[resourcePath]=" in url:
+ path_val = url.split("params[resourcePath]=")[1].split("&")[0]
+ if path_val in ["%2F", "/"]: is_root = True
+ if is_root:
+ captured_data["tree"] = await response.json()
+ captured_data["_is_root_archive"] = True
+ elif "getData" in url and "overview" in url:
+ captured_data["last_project_data"] = await response.json()
+ except: pass
+
+ context.on("response", global_interceptor)
+ page = await context.new_page()
+ await page.goto("https://overseas.projectmastercloud.com/dashboard", wait_until="domcontentloaded")
+
+ if await page.locator("#login-by-id").is_visible(timeout=10000):
+ await page.click("#login-by-id"); await page.fill("#user_id", user_id); await page.fill("#user_pw", password); await page.click("#login-btn")
+
+ await page.wait_for_selector("h4.list__contents_aria_group_body_list_item_label", timeout=60000)
+ await asyncio.sleep(2)
+
+ project_names = list(dict.fromkeys([n.strip() for n in await page.locator("h4.list__contents_aria_group_body_list_item_label").all_inner_texts() if n.strip()]))
+ count = len(project_names)
+
+ for i, project_name in enumerate(project_names):
+ if crawl_stop_event.is_set(): break
+ msg_queue.put(json.dumps({'type': 'log', 'message': f'[TEST] [{i+1}/{count}] {project_name} ์์ง'}))
+ p_match = next((p for p in captured_data["project_list"] if p.get('project_nm') == project_name or p.get('short_nm', '').strip() == project_name), None)
+ current_p_id = p_match.get('project_id') if p_match else None
+
+ try:
+ # 1. ํ๋ก์ ํธ ์ง์
+ target_el = page.locator(f"h4.list__contents_aria_group_body_list_item_label:has-text('{project_name}')").first
+ await target_el.scroll_into_view_if_needed()
+ box = await target_el.bounding_box()
+ if box: await page.mouse.click(box['x'] + 5, box['y'] + 5)
+ else: await target_el.click(force=True)
+ await page.wait_for_selector("text=ํ๋๋ก๊ทธ", timeout=30000)
+
+ # 2. [๋ณต๊ตฌ] ์ต์ ํ์ผ ์ ์ค์ธก (์๋ณธ์ ์๋ Fetch ๋ฐฉ์ ๊ทธ๋๋ก)
+ captured_data["tree"] = None; captured_data["_is_root_archive"] = False
+ await page.evaluate("""() => {
+ const baseUrl = window.location.origin + window.location.pathname.split('/').slice(0, 2).join('/');
+ fetch(`${baseUrl}/archive/getTreeObject?params[storageType]=CLOUD¶ms[resourcePath]=/`);
+ }""")
+ for _ in range(30):
+ if captured_data["_is_root_archive"]: break
+ await asyncio.sleep(0.5)
+
+ actual_count = 0
+ if captured_data["tree"]:
+ tree_data = captured_data["tree"]
+ if isinstance(tree_data, list) and len(tree_data) > 0: tree_data = tree_data[0]
+ if isinstance(tree_data, dict):
+ tree = tree_data.get('currentTreeObject', tree_data)
+ if isinstance(tree, dict):
+ # ์๋ณธ ํ์ผ ์ ํฉ์ฐ ๋ก์ง
+ total = len(tree.get("file", {}))
+ folders = tree.get("folder", {})
+ if isinstance(folders, dict):
+ for f in folders.values(): total += int(f.get("filesCount", 0))
+ actual_count = total
+
+ # 3. ํ๋๋ก๊ทธ ์ ์ ์์ง (ํ์ด๋ธ๋ฆฌ๋ ๋ฐฉ์: ์ต์๋จ ์ฐ์ ํ๋ณด + ์ ์ ์คํฌ๋กค)
+ all_logs = []
+ await page.get_by_text("ํ๋๋ก๊ทธ").first.click()
+ if await page.wait_for_selector("article.archive-modal", timeout=10000):
+ # ๋ ์ง ํํฐ ์ ์ฉ (2020-01-01)
+ inputs = await page.locator("article.archive-modal input").all()
+ for inp in inputs:
+ if (await inp.get_attribute("type")) == "date": await inp.fill("2020-01-01"); break
+
+ apply_btn = page.locator("article.archive-modal").get_by_text("์ ์ฉ").first
+ if await apply_btn.is_visible():
+ await apply_btn.click()
+ # [ํต์ฌ] ์ฒซ ๋ฒ์งธ ๋ก๊ทธ๊ฐ ๋ํ๋ ๋๊น์ง ๋ช
์์ ๋๊ธฐ (์ต๋ 10์ด)
+ try:
+ await page.wait_for_selector("article.archive-modal div[id*='_']", timeout=10000)
+ except: pass
+ await asyncio.sleep(2)
+
+ # (1) ์ต์๋จ ๋ก๊ทธ ์ฆ์ ํ๋ณด (์์ ์ฅ์น)
+ first_log_el = await page.locator("article.archive-modal div[id*='_']").first.get_attribute("id")
+ if first_log_el:
+ first_log_text = parse_log_id(first_log_el)
+ if ", " in first_log_text:
+ d, a = first_log_text.split(", ", 1)
+ all_logs.append({'date': d, 'activity': a})
+
+ # (2) ์ ์ ์์ง์ ์ํ ๋ฌดํ ์คํฌ๋กค ๋ฐ ์ง์ ๋ ํด๋์ค ๋ด ID ์์ง
+ last_count = len(all_logs)
+ for _ in range(20):
+ # ์คํฌ๋กค ์ํ (์ฌ์ฉ์๊ฐ ์ง์ ํ log-body ํด๋์ค ๊ธฐ์ค)
+ await page.evaluate("""() => {
+ const body = document.querySelector('.log-item-wrap.log-body.scrollbar.scroll-container') ||
+ document.querySelector('article.archive-modal .modal-body') ||
+ document.querySelector('article.archive-modal');
+ if (body) body.scrollTop = body.scrollHeight;
+ }""")
+ await asyncio.sleep(1.5)
+
+ # ์ฌ์ฉ์ ์ง์ ํด๋์ค ๋ด์ ๋ชจ๋ div ID ์์ง
+ # .log-item-wrap.log-body.scrollbar.scroll-container ๋ด๋ถ์ div๋ค์ ํ๊ฒํ
+ selector = ".log-item-wrap.log-body.scrollbar.scroll-container div"
+ current_elements = await page.locator(selector).all()
+
+ # ๋ง์ฝ ์ง์ ๋ ํด๋์ค๋ก ๊ฒ์๋์ง ์์ ๊ฒฝ์ฐ ๊ธฐ์กด div[id*='_']๋ฅผ ๋ฐฑ์
์ผ๋ก ์ฌ์ฉ
+ if not current_elements:
+ current_elements = await page.locator("article.archive-modal div[id*='_']").all()
+
+ seen_ids = {f"{log['date']}, {log['activity']}" for log in all_logs}
+ for el in current_elements:
+ log_id = await el.get_attribute("id")
+ if not log_id: continue
+
+ log_text = parse_log_id(log_id)
+ if ", " in log_text and log_text not in seen_ids:
+ d, a = log_text.split(", ", 1)
+ all_logs.append({'date': d, 'activity': a})
+ seen_ids.add(log_text)
+
+ if len(all_logs) == last_count: break
+ last_count = len(all_logs)
+
+ if not all_logs:
+ msg_queue.put(json.dumps({'type': 'log', 'message': f' - [์ฃผ์] {project_name}: ์์ง๋ ๋ก๊ทธ๊ฐ ์์ต๋๋ค.'}))
+
+ await page.keyboard.press("Escape")
+
+ # 4. ํ์ผ ์ ์ถ๋ก (์ ์ ๋ณด์กด ๋ชจ๋)
+ history_map = {}
+ curr_calc_count = actual_count
+
+ if all_logs:
+ # ์ค๋ ๋ ์ง ๊ฐ์ ์ฃผ์
๋์ ์์ง๋ ๋ก๊ทธ์ ์ค์ ๋ ์ง ์ฌ์ฉ
+ for log in all_logs:
+ d_db = log['date'].replace(".", "-")
+ act = log['activity']
+ if d_db not in history_map:
+ history_map[d_db] = {"log": act, "count": curr_calc_count}
+
+ if "์
๋ก๋" in act: curr_calc_count -= 1
+ elif "์ญ์ " in act: curr_calc_count += 1
+ if curr_calc_count < 0: curr_calc_count = 0
+ history_map[d_db]["count"] = curr_calc_count
+ else:
+ # ๋ก๊ทธ๊ฐ ์ ํ ์์ ๊ฒฝ์ฐ์๋ง ๊ธฐ๋ณธ๊ฐ ์์ฑ
+ today_str = datetime.now().strftime("%Y-%m-%d")
+ history_map[today_str] = {"log": "๊ธฐ์กด ์ํ ์ ์ง (ํ๋ ์์)", "count": actual_count}
+
+ # 5. DB ์ ์ฅ
+ if current_p_id:
+ with get_db_connection() as conn:
+ with conn.cursor() as cursor:
+ for date_key, data in history_map.items():
+ cursor.execute(CrawlerQueries.UPSERT_HISTORY_WITH_DATE,
+ (current_p_id, date_key, f"{date_key.replace('-', '.')}, {data['log']}", data['count']))
+ conn.commit()
+ msg_queue.put(json.dumps({'type': 'log', 'message': f' - [์ฑ๊ณต] ์ค์ธก {actual_count}๊ฐ ๊ธฐ์ค ์๊ณ์ด ์ ์ฌ ์๋ฃ'}))
+
+ await page.goto("https://overseas.projectmastercloud.com/dashboard", wait_until="domcontentloaded")
+
+ except Exception as e:
+ msg_queue.put(json.dumps({'type': 'log', 'message': f' - {project_name} ์๋ฌ: {str(e)}'}))
+ await page.goto("https://overseas.projectmastercloud.com/dashboard")
+
+ msg_queue.put(json.dumps({'type': 'done', 'data': []}))
+
+ except Exception as e:
+ msg_queue.put(json.dumps({'type': 'log', 'message': f'์น๋ช
์ ์ค๋ฅ: {str(e)}'}))
+ finally:
+ if browser: await browser.close()
+ msg_queue.put(None)
+
+ loop.run_until_complete(run())
+ loop.close()
+
+async def run_crawler_service():
+ msg_queue = queue.Queue()
+ thread = threading.Thread(target=crawler_thread_worker, args=(msg_queue, os.getenv("PM_USER_ID"), os.getenv("PM_PASSWORD")))
+ thread.start()
+ while True:
+ try:
+ msg = await asyncio.to_thread(msg_queue.get, timeout=1.0)
+ if msg is None: break
+ yield f"data: {msg}\n\n"
+ except queue.Empty:
+ if not thread.is_alive(): break
+ await asyncio.sleep(0.1)
+ thread.join()
diff --git a/docker-compose.yml b/docker-compose.yml
new file mode 100644
index 0000000..0ab6e46
--- /dev/null
+++ b/docker-compose.yml
@@ -0,0 +1,17 @@
+services:
+ web:
+ # ํ์ฌ ํด๋์ Dockerfile์ ์ฌ์ฉํ์ฌ ๋น๋
+ build: .
+ # ์ปจํ
์ด๋ ์ด๋ฆ ์ค์
+ container_name: aicode-server
+ # ํฌํธ ํฌ์๋ฉ (ํธ์คํธ 8000 -> ์ปจํ
์ด๋ 8000)
+ ports:
+ - "8000:8000"
+ # ์์ค ์ฝ๋ ์์ ์ ์ค์๊ฐ ๋ฐ์ (๋ณผ๋ฅจ ๋ง์ดํธ)
+ volumes:
+ - .:/app
+ # ํ๊ฒฝ ๋ณ์ ์ค์
+ environment:
+ - PYTHONUNBUFFERED=1
+ # ์ปจํ
์ด๋ ์ข
๋ฃ ์ ์๋ ์ฌ์์
+ restart: always
diff --git a/inquiry_service.py b/inquiry_service.py
index 262af3d..5d0838f 100644
--- a/inquiry_service.py
+++ b/inquiry_service.py
@@ -1,42 +1,42 @@
-from datetime import datetime
-from sql_queries import InquiryQueries
-
-class InquiryService:
- @staticmethod
- def get_inquiries_logic(cursor, pm_type=None, category=None, status=None, keyword=None):
- sql = InquiryQueries.SELECT_BASE
- params = []
- if pm_type:
- sql += " AND pm_type = %s"
- params.append(pm_type)
- if category:
- sql += " AND category = %s"
- params.append(category)
- if status:
- sql += " AND status = %s"
- params.append(status)
- if keyword:
- sql += " AND (content LIKE %s OR author LIKE %s OR project_nm LIKE %s)"
- params.extend([f"%{keyword}%", f"%{keyword}%", f"%{keyword}%"])
-
- sql += f" {InquiryQueries.ORDER_BY_DESC}"
- cursor.execute(sql, params)
- return cursor.fetchall()
-
- @staticmethod
- def get_inquiry_detail_logic(cursor, inquiry_id):
- cursor.execute(InquiryQueries.SELECT_BY_ID, (inquiry_id,))
- return cursor.fetchone()
-
- @staticmethod
- def update_inquiry_reply_logic(cursor, conn, inquiry_id, req):
- handled_date = datetime.now().strftime("%Y.%m.%d")
- cursor.execute(InquiryQueries.UPDATE_REPLY, (req.reply, req.status, req.handler, handled_date, inquiry_id))
- conn.commit()
- return {"success": True}
-
- @staticmethod
- def delete_inquiry_reply_logic(cursor, conn, inquiry_id):
- cursor.execute(InquiryQueries.DELETE_REPLY, (inquiry_id,))
- conn.commit()
- return {"success": True}
+from datetime import datetime
+from sql_queries import InquiryQueries
+
+class InquiryService:
+ @staticmethod
+ def get_inquiries_logic(cursor, pm_type=None, category=None, status=None, keyword=None):
+ sql = InquiryQueries.SELECT_BASE
+ params = []
+ if pm_type:
+ sql += " AND pm_type = %s"
+ params.append(pm_type)
+ if category:
+ sql += " AND category = %s"
+ params.append(category)
+ if status:
+ sql += " AND status = %s"
+ params.append(status)
+ if keyword:
+ sql += " AND (content LIKE %s OR author LIKE %s OR project_nm LIKE %s)"
+ params.extend([f"%{keyword}%", f"%{keyword}%", f"%{keyword}%"])
+
+ sql += f" {InquiryQueries.ORDER_BY_DESC}"
+ cursor.execute(sql, params)
+ return cursor.fetchall()
+
+ @staticmethod
+ def get_inquiry_detail_logic(cursor, inquiry_id):
+ cursor.execute(InquiryQueries.SELECT_BY_ID, (inquiry_id,))
+ return cursor.fetchone()
+
+ @staticmethod
+ def update_inquiry_reply_logic(cursor, conn, inquiry_id, req):
+ handled_date = datetime.now().strftime("%Y.%m.%d")
+ cursor.execute(InquiryQueries.UPDATE_REPLY, (req.reply, req.status, req.handler, handled_date, inquiry_id))
+ conn.commit()
+ return {"success": True}
+
+ @staticmethod
+ def delete_inquiry_reply_logic(cursor, conn, inquiry_id):
+ cursor.execute(InquiryQueries.DELETE_REPLY, (inquiry_id,))
+ conn.commit()
+ return {"success": True}
diff --git a/js/analysis.js b/js/analysis.js
index 2f4ecf0..007e346 100644
--- a/js/analysis.js
+++ b/js/analysis.js
@@ -1,463 +1,485 @@
-/**
- * Project Master Analysis JS
- * AVI (Activity Vitality Index) & VCI (Value Contribution Index) ๋ถ์ ์์ง
- * OCI (Operational Consistency Index) ํตํฉ ๋ฒ์
- */
-
-// Chart.js ํ๋ฌ๊ทธ์ธ ์ ์ญ ๋ฑ๋ก
-if (typeof ChartDataLabels !== 'undefined') {
- Chart.register(ChartDataLabels);
-}
-
-document.addEventListener('DOMContentLoaded', () => {
- console.log("Business Analysis Engine initialized...");
- loadProjectAnalysisData();
-});
-
-async function loadProjectAnalysisData() {
- try {
- const response = await fetch('/api/analysis/p-war');
- const data = await response.json();
- if (data.error) throw new Error(data.error);
-
- renderVitalityLeaderboard(data);
- renderValueCharts(data);
-
- if (data.length > 0 && data[0].avg_info) {
- const avg = data[0].avg_info;
- const infoEl = document.getElementById('avg-system-info');
- if (infoEl) infoEl.textContent = `* ์์คํ
์ข
ํฉ ์์ฐ ๊ฑด์ ๋: ${avg.avg_risk}% (์ด์ ํ์ค 70.0% ๋๋น)`;
- }
- } catch (e) {
- console.error("๋ถ์ ๋ฐ์ดํฐ ๋ก๋ฉ ์คํจ:", e);
- }
-}
-
-function getStatusInfo(avi, isAutoDelete) {
- if (isAutoDelete || avi < 10) return { label: '์ฌ๋ง', class: 'badge-system', key: 'dead' };
- if (avi < 30) return { label: '์ํ ๋
ธ์ถ', class: 'badge-danger', key: 'danger' };
- if (avi < 70) return { label: '๊ด๋ฆฌ ์ฃผ์', class: 'badge-warning', key: 'warning' };
- return { label: '์ ์ ์ด์', class: 'badge-active', key: 'active' };
-}
-
-function getVciGrade(vci) {
- if (vci >= 10) return { label: 'Masterpiece', class: 'grade-mvp', desc: '์์คํ
๊ฐ์น๋ฅผ ๊ฒฌ์ธํ๋ ์ต์ฐ๋ ํต์ฌ ์์ฐ' };
- if (vci >= 2) return { label: 'Blue Chip', class: 'grade-allstar', desc: '๊พธ์คํ ํ๋ ฅ์ผ๋ก ๊ฐ์น๋ฅผ ์ฐฝ์ถํ๋ ์ฐ๋ ์์ฐ' };
- if (vci >= -2) return { label: 'Steady', class: 'grade-starter', desc: 'ํ์ค ์์ค์ ์ด์์ ์ ์ง ์ค์ธ ์์ ์์ฐ' };
- if (vci >= -10) return { label: 'Underperform', class: 'grade-bench', desc: '๊ท๋ชจ ๋๋น ํ๋ ฅ ๋ถ์กฑ์ผ๋ก ๊ฐ์น๊ฐ ํ๋ฝ ์ค์ธ ์์ฐ' };
- return { label: 'Liability', class: 'grade-out', desc: '๊ฐ์น๋ฅผ ํผ์ ์ค์ธ ๊ณ ์ํ ๋ฐฉ์น ์์ฐ' };
-}
-
-function renderValueCharts(data) {
- if (!data || data.length === 0) return;
-
- // 1. ์ด์ ํ๋ ฅ ๋ถํฌ (Doughnut)
- try {
- const stats = { active: [], warning: [], danger: [], dead: [] };
- data.forEach(p => {
- const status = getStatusInfo(p.p_war, p.is_auto_delete);
- stats[status.key].push(p);
- });
-
- const statusCtx = document.getElementById('statusChart').getContext('2d');
- if (window.myStatusChart) window.myStatusChart.destroy();
-
- window.myStatusChart = new Chart(statusCtx, {
- type: 'doughnut',
- data: {
- labels: ['์ ์ ์ด์', '๊ด๋ฆฌ ์ฃผ์', '์ํ ๋
ธ์ถ', '์ฌ๋ง'],
- datasets: [{
- data: [stats.active.length, stats.warning.length, stats.danger.length, stats.dead.length],
- backgroundColor: ['#1E5149', '#22c55e', '#f59e0b', '#ef4444'],
- borderWidth: 0
- }]
- },
- options: {
- responsive: true,
- maintainAspectRatio: false,
- layout: { padding: 15 },
- plugins: {
- legend: { position: 'right', labels: { boxWidth: 10, font: { size: 11, weight: '700' }, usePointStyle: true } },
- datalabels: { display: false }
- },
- cutout: '65%',
- onClick: (e, elements) => {
- if (elements.length > 0) {
- const idx = elements[0].index;
- openProjectListModal(['์ ์ ์ด์', '๊ด๋ฆฌ ์ฃผ์', '์ํ ๋
ธ์ถ', '์ฌ๋ง'][idx], stats[['active', 'warning', 'danger', 'dead'][idx]]);
- }
- }
- }
- });
- } catch (err) { console.error("๋๋ ์ฐจํธ ์๋ฌ:", err); }
-
- // 2. ์ ๋ต์ ์์ฐ ๋งคํธ๋ฆญ์ค (Scatter) - ์ ๋ฐ ๋ณต๊ตฌ
- try {
- const sortedByAVI = [...data].sort((a, b) => b.p_war - a.p_war);
- const top5Ids = sortedByAVI.slice(0, 5).map(p => p.project_nm);
- const bottom5Ids = sortedByAVI.slice(-5).map(p => p.project_nm);
- const largeProjects = data.filter(p => p.file_count > 450).map(p => p.project_nm);
- const vipProjectNames = new Set([...top5Ids, ...bottom5Ids, ...largeProjects]);
-
- const scatterData = data.map(p => {
- const vci = p.risk_count || 0;
- const absVci = Math.abs(vci);
- return {
- x: Math.min(500, p.file_count),
- y: p.p_war,
- label: p.project_nm,
- isVip: vipProjectNames.has(p.project_nm),
- vci: vci,
- radius: Math.max(5, Math.min(25, 5 + (absVci / 10)))
- };
- });
-
- const vitalityCtx = document.getElementById('forecastChart').getContext('2d');
- if (window.myVitalityChart) window.myVitalityChart.destroy();
-
- window.myVitalityChart = new Chart(vitalityCtx, {
- type: 'scatter',
- data: {
- datasets: [{
- data: scatterData,
- backgroundColor: (ctx) => {
- const p = ctx.raw;
- if (!p) return '#94a3b8';
- if (p.x >= 250 && p.y >= 50) return '#1E5149';
- if (p.x < 250 && p.y >= 50) return '#22c55e';
- if (p.x < 250 && p.y < 50) return '#94a3b8';
- return '#ef4444';
- },
- pointRadius: (ctx) => ctx.raw ? ctx.raw.radius : 5,
- hoverRadius: (ctx) => (ctx.raw ? ctx.raw.radius : 5) + 3
- }]
- },
- options: {
- responsive: true,
- maintainAspectRatio: false,
- layout: { padding: { top: 30, right: 45, left: 10, bottom: 10 } },
- scales: {
- x: {
- type: 'linear', min: 0, max: 500,
- title: { display: true, text: '์์ฐ ๊ท๋ชจ (ํ์ผ ์)', font: { size: 11, weight: '700' } },
- grid: { display: false }
- },
- y: {
- min: 0, max: 100,
- title: { display: true, text: '์ด์ ํ๋ ฅ (AVI %)', font: { size: 11, weight: '700' } },
- grid: { display: false }
- }
- },
- plugins: {
- legend: { display: false },
- datalabels: {
- backgroundColor: 'rgba(255, 255, 255, 0.8)',
- borderRadius: 4, padding: 4,
- font: { size: 10, weight: '800' },
- formatter: (v) => v ? v.label : '',
- display: (ctx) => ctx.raw && ctx.raw.isVip,
- clip: false
- },
- tooltip: {
- callbacks: {
- label: (ctx) => ` [${ctx.raw.label}] AVI: ${ctx.raw.y.toFixed(1)}% | VCI: ${ctx.raw.vci.toFixed(1)}`
- }
- }
- }
- },
- plugins: [{
- id: 'quadrants',
- beforeDraw: (chart) => {
- const { ctx, chartArea: { left, top, right, bottom }, scales: { x, y } } = chart;
- const midX = x.getPixelForValue(250);
- const midY = y.getPixelForValue(50);
- ctx.save();
- ctx.fillStyle = 'rgba(34, 197, 94, 0.03)'; ctx.fillRect(left, top, midX - left, midY - top);
- ctx.fillStyle = 'rgba(30, 81, 73, 0.03)'; ctx.fillRect(midX, top, right - midX, midY - top);
- ctx.fillStyle = 'rgba(148, 163, 184, 0.03)'; ctx.fillRect(left, midY, midX - left, bottom - midY);
- ctx.fillStyle = 'rgba(239, 68, 68, 0.05)'; ctx.fillRect(midX, midY, right - midX, bottom - midY);
- ctx.lineWidth = 2; ctx.strokeStyle = 'rgba(0,0,0,0.1)'; ctx.beginPath();
- ctx.moveTo(midX, top); ctx.lineTo(midX, bottom); ctx.moveTo(left, midY); ctx.lineTo(right, midY); ctx.stroke();
- ctx.font = 'bold 12px Pretendard'; ctx.textAlign = 'center'; ctx.fillStyle = 'rgba(0,0,0,0.2)';
- ctx.fillText('ํ๋ ฅ ์ํธ', (left + midX) / 2, (top + midY) / 2);
- ctx.fillText('ํต์ฌ ๊ฐ์น', (midX + right) / 2, (top + midY) / 2);
- ctx.fillText('์ ์ฒด/์๊ท๋ชจ', (left + midX) / 2, (midY + bottom) / 2);
- ctx.fillStyle = 'rgba(239, 68, 68, 0.4)'; ctx.fillText('์์ฐ ์์ค ์ํ', (midX + right) / 2, (midY + bottom) / 2);
- ctx.restore();
- }
- }]
- });
- } catch (err) { console.error("์ ๋ต ๋งคํธ๋ฆญ์ค ์๋ฌ:", err); }
-}
-
-function renderVitalityLeaderboard(data) {
- const container = document.getElementById('p-war-table-container');
- if (!container) return;
- const sortedData = [...data].sort((a, b) => a.p_war - b.p_war);
-
- container.innerHTML = `
-
`;
-}
-
-function toggleProjectDetail(rowId) {
- const container = document.querySelector('.table-scroll-wrapper');
- const mainRow = document.querySelector(`tr[onclick*="toggleProjectDetail('${rowId}')"]`);
- const detailRow = document.getElementById(`detail-${rowId}`);
-
- if (detailRow && container) {
- if (!detailRow.classList.contains('active')) {
- document.querySelectorAll('.detail-row').forEach(row => row.classList.remove('active'));
- detailRow.classList.add('active');
-
- // ์ ๋ฐ ์คํฌ๋กค ์ด๋ ๋ก์ง ๋ณต๊ตฌ
- setTimeout(() => {
- const headerH = container.querySelector('thead').offsetHeight || 45;
- const targetScrollTop = mainRow.offsetTop - headerH;
- container.scrollTo({ top: targetScrollTop, behavior: 'smooth' });
- }, 100);
- } else {
- detailRow.classList.remove('active');
- }
- }
-}
-
-function openProjectListModal(label, projects) {
- const modal = document.getElementById('analysisModal');
- const title = document.getElementById('modalTitle');
- const body = document.getElementById('modalBody');
- title.innerText = `[${label}] ํ๋ก์ ํธ ๋ฆฌ์คํธ (${projects.length}๊ฑด)`;
- body.innerHTML = `
-
- `;
- modal.style.display = 'flex';
-}
-
-function openAnalysisModal(type) {
- const modal = document.getElementById('analysisModal');
- const title = document.getElementById('modalTitle');
- const body = document.getElementById('modalBody');
-
- if (type === 'avi') {
- title.innerText = '์ด์ ํ๋ ฅ ์ง์ (AVI) ๋ฑ๊ธ ๊ฐ์ด๋';
- body.innerHTML = `
- AVI = exp(-ฮป ร days) ร Quality ร 100
- ์์ฐ์ ๊ฐ๋ ์ํ์ ์์กด์จ์ ๋ํ๋ด๋ ์งํ์
๋๋ค.
-
- | ์ง์ (AVI) | ๋ฑ๊ธ | ์ด์ ์ํ |
-
- | 90%โ | Live | ์ค์๊ฐ ์ฑ๊ณผ๋ฌผ์ด ๋์ถ๋๋ ์ต์๊ธ ๊ฐ๋ |
- | 70~90% | Stable | ์ฃผ๊ธฐ์ ์
๋ฐ์ดํธ๊ฐ ์ด๋ค์ง๋ ํ์ค ์์ |
- | 30~70% | Idle | ๊ด๋ฆฌ๊ฐ ํ์ํ ์ ํด/์ ์ฒด ์ํ |
- | 10~30% | Risk | ์์ฐ ๊ฐ์น ์๋ฉธ ์ง์ ์ ์ํ ์ํ |
- | 10%โ | Frozen | ์ด์์ด ์ค๋จ๋ ์ฌ๋ง/๋ฐฉ์น ์ํ |
-
-
- `;
- } else if (type === 'vci') {
- title.innerText = '์์ฐ ๊ฐ์น ๊ธฐ์ฌ๋ (VCI) ๋ฑ๊ธ ๊ฐ์ด๋';
- body.innerHTML = `
- VCI = (AVI - 70.0) ร (Files / 200 + 0.5)
- ์ด์ ํ์ค(AVI 70%) ๋๋น ์์ฐ ๊ฐ์น ๊ธฐ์ฌ๋์ ๋ฐ๋ฅธ ํ๋ก์ ํธ ์์ ๋ถ๋ฅ์
๋๋ค.
-
- | ์ ์ (VCI) | ๋ฑ๊ธ | ์ด์ ์๋ฏธ |
-
- | +10.0โ | Masterpiece | ์์คํ
๊ฐ์น๋ฅผ ๊ฒฌ์ธํ๋ ์ต์ฐ๋ ํต์ฌ ์์ฐ |
- | +2.0 ~ +10.0 | Blue Chip | ๊พธ์คํ ํ๋ ฅ์ผ๋ก ๊ฐ์น๋ฅผ ์ฐฝ์ถํ๋ ์ฐ๋ ์์ฐ |
- | -2.0 ~ +2.0 | Steady | ํ์ค ์์ค์ ์ด์์ ์ ์ง ์ค์ธ ์์ ์์ฐ |
- | -10.0 ~ -2.0 | Underperform | ๊ท๋ชจ ๋๋น ํ๋ ฅ ๋ถ์กฑ์ผ๋ก ๊ฐ์น ํ๋ฝ ์ค์ธ ์์ฐ |
- | -10.0โ | Liability | ๊ฐ์น๋ฅผ ํผ์ ์ค์ธ ๊ณ ์ํ ๋ฐฉ์น ์์ฐ |
-
-
- `;
- } else if (type === 'oci') {
- title.innerText = '์ด์ ์ผ๊ด์ฑ ์ง์ (OCI) ๋ถ์ ๊ฐ์ด๋';
- body.innerHTML = `
-
-
"์ผ๋ง๋ ๊พธ์คํ๊ฒ ๊ด๋ฆฌ๋๊ณ ์๋๊ฐ?"
-
๋ฏธ๋ ์์ธก์ด ์๋, ์ต๊ทผ 30์ผ๊ฐ์ ํ๋ ๋ฆฌ๋ฌ๊ณผ ๊ด๋ฆฌ์ ๊ท์น์ฑ์ ๋ถ์ํ์ฌ ์ฑ์ค๋๋ฅผ ์ ์ํํฉ๋๋ค.
-
-
- | ๋ถ์ ๊ฒฐ๊ณผ | ์ผ๊ด์ฑ ๋ฑ๊ธ | ๊ด๋ฆฌ ์ ๋ขฐ๋ |
-
- | 80%โ | ๋งค์ฐ ์ฐ์ | ์ฃผ ๋จ์์ ์ ๊ธฐ์ ๊ด๋ฆฌ๊ฐ ์๋ฒฝํ ์ด๋ค์ง |
- | 50~80% | ์ํธ | ๊ฐํ์ ์ ์ฒด๋ ์์ผ๋ ๊พธ์คํ ๊ด๋ฆฌ๋จ |
- | 20~50% | ์ฃผ์ | ๋๋ฐ์ ํ๋ ์์ฃผ, ๊ด๋ฆฌ์ ๋ฆฌ๋ฌ์ด ๊นจ์ง |
- | 20%โ | ๋งค์ฐ ๋ถ๋ | ์ฅ๊ธฐ ์ ์ฒด ์ค์ด๊ฑฐ๋ ๊ด๋ฆฌ ์์ง ํ์ธ ๋ถ๊ฐ |
-
-
- `;
- } else {
- title.innerText = '์
๋ฌด ์ง์ค๋ (Job Focus) ๋ฑ๊ธ ๊ฐ์ด๋';
- body.innerHTML = `
- ์ต๊ทผ ์์ง ๋ก๊ทธ ์ค ๋จ์ ํ์ ๋ก๊ทธ๋ฅผ ์ ์ธํ๊ณ ์ค์ง์ ์ธ ์ฑ๊ณผ๋ฌผ(ํ์ผ) ๋ณ๋์ด ํฌ์ฐฉ๋ ๋น์จ์
๋๋ค.
-
- | ๋น์จ (%) | ๋ฑ๊ธ | ํ๋ ์ฑ๊ฒฉ |
-
- | 80%โ | Intensive | ์ฑ๊ณผ๋ฌผ ์์ฃผ์ ๊ณ ๋ฐ๋ ์ง์ค ์์
|
- | 50~80% | Active | ์ฑ๊ณผ์ ๊ด๋ฆฌ๊ฐ ๊ท ํ ์กํ ์ํํ ์คํ |
- | 20~50% | Maintenance | ์ค์ /ํ์ ๋ฑ ๋จ์ ๊ด๋ฆฌ ์ค์ฌ์ ์์
|
- | 20%โ | Surface | ์ค์ฒด์ ๋ณํ๊ฐ ์ ์ ํ์์ ๋ก๊ทธ ์ค์ฌ |
-
-
- `;
- }
- modal.style.display = 'flex';
-}
-
-function closeAnalysisModal() { document.getElementById('analysisModal').style.display = 'none'; }
+/**
+ * Project Master Analysis JS
+ * AVI (Activity Vitality Index) & VCI (Value Contribution Index) ๋ถ์ ์์ง
+ * OCI (Operational Consistency Index) ํตํฉ ๋ฒ์
+ */
+
+// Chart.js ํ๋ฌ๊ทธ์ธ ์ ์ญ ๋ฑ๋ก
+if (typeof ChartDataLabels !== 'undefined') {
+ Chart.register(ChartDataLabels);
+}
+
+document.addEventListener('DOMContentLoaded', () => {
+ console.log("Business Analysis Engine initialized...");
+ loadProjectAnalysisData();
+});
+
+async function loadProjectAnalysisData() {
+ try {
+ const response = await fetch('/api/analysis/p-war');
+ const data = await response.json();
+ if (data.error) throw new Error(data.error);
+
+ renderVitalityLeaderboard(data);
+ renderValueCharts(data);
+
+ if (data.length > 0 && data[0].avg_info) {
+ const avg = data[0].avg_info;
+ const infoEl = document.getElementById('avg-system-info');
+ if (infoEl) infoEl.textContent = `* ์์คํ
์ข
ํฉ ์ด์ ํ๋ ฅ(AVI): ${avg.avg_risk}% (ํ๊ท ๊ด๋ฆฌ ์์ค)`;
+ }
+ } catch (e) {
+ console.error("๋ถ์ ๋ฐ์ดํฐ ๋ก๋ฉ ์คํจ:", e);
+ }
+}
+
+function getStatusInfo(avi, isAutoDelete) {
+ if (isAutoDelete || avi < 10) return { label: '์ฌ๋ง', class: 'badge-system', key: 'dead' };
+ if (avi < 30) return { label: '์ํ ๋
ธ์ถ', class: 'badge-danger', key: 'danger' };
+ if (avi < 70) return { label: '๊ด๋ฆฌ ์ฃผ์', class: 'badge-warning', key: 'warning' };
+ return { label: '์ ์ ์ด์', class: 'badge-active', key: 'active' };
+}
+
+function getVciGrade(vci) {
+ if (vci >= 10) return { label: 'Masterpiece', class: 'grade-mvp', desc: '์์คํ
๊ฐ์น๋ฅผ ๊ฒฌ์ธํ๋ ์ต์ฐ๋ ํต์ฌ ์์ฐ' };
+ if (vci >= 2) return { label: 'Blue Chip', class: 'grade-allstar', desc: '๊พธ์คํ ํ๋ ฅ์ผ๋ก ๊ฐ์น๋ฅผ ์ฐฝ์ถํ๋ ์ฐ๋ ์์ฐ' };
+ if (vci >= -2) return { label: 'Steady', class: 'grade-starter', desc: 'ํ์ค ์์ค์ ์ด์์ ์ ์ง ์ค์ธ ์์ ์์ฐ' };
+ if (vci >= -10) return { label: 'Underperform', class: 'grade-bench', desc: '๊ท๋ชจ ๋๋น ํ๋ ฅ ๋ถ์กฑ์ผ๋ก ๊ฐ์น๊ฐ ํ๋ฝ ์ค์ธ ์์ฐ' };
+ return { label: 'Liability', class: 'grade-out', desc: '๊ฐ์น๋ฅผ ํผ์ ์ค์ธ ๊ณ ์ํ ๋ฐฉ์น ์์ฐ' };
+}
+
+function renderValueCharts(data) {
+ if (!data || data.length === 0) return;
+
+ // 1. ์ด์ ํ๋ ฅ ๋ถํฌ (Doughnut)
+ try {
+ const stats = { active: [], warning: [], danger: [], dead: [] };
+ data.forEach(p => {
+ const status = getStatusInfo(p.p_war, p.is_auto_delete);
+ stats[status.key].push(p);
+ });
+
+ const statusCtx = document.getElementById('statusChart').getContext('2d');
+ if (window.myStatusChart) window.myStatusChart.destroy();
+
+ window.myStatusChart = new Chart(statusCtx, {
+ type: 'doughnut',
+ data: {
+ labels: ['์ ์ ์ด์', '๊ด๋ฆฌ ์ฃผ์', '์ํ ๋
ธ์ถ', '์ฌ๋ง'],
+ datasets: [{
+ data: [stats.active.length, stats.warning.length, stats.danger.length, stats.dead.length],
+ backgroundColor: ['#1E5149', '#22c55e', '#f59e0b', '#ef4444'],
+ borderWidth: 0
+ }]
+ },
+ options: {
+ responsive: true,
+ maintainAspectRatio: false,
+ layout: { padding: 15 },
+ plugins: {
+ legend: { position: 'right', labels: { boxWidth: 10, font: { size: 11, weight: '700' }, usePointStyle: true } },
+ datalabels: { display: false }
+ },
+ cutout: '65%',
+ onClick: (e, elements) => {
+ if (elements.length > 0) {
+ const idx = elements[0].index;
+ openProjectListModal(['์ ์ ์ด์', '๊ด๋ฆฌ ์ฃผ์', '์ํ ๋
ธ์ถ', '์ฌ๋ง'][idx], stats[['active', 'warning', 'danger', 'dead'][idx]]);
+ }
+ }
+ }
+ });
+ } catch (err) { console.error("๋๋ ์ฐจํธ ์๋ฌ:", err); }
+
+ // 2. ์ ๋ต์ ์์ฐ ๋งคํธ๋ฆญ์ค (Scatter) - ์ ๋ฐ ๋ณต๊ตฌ
+ try {
+ const sortedByAVI = [...data].sort((a, b) => b.p_war - a.p_war);
+ const top5Ids = sortedByAVI.slice(0, 5).map(p => p.project_nm);
+ const bottom5Ids = sortedByAVI.slice(-5).map(p => p.project_nm);
+ const largeProjects = data.filter(p => p.file_count > 450).map(p => p.project_nm);
+ const vipProjectNames = new Set([...top5Ids, ...bottom5Ids, ...largeProjects]);
+
+ const scatterData = data.map(p => {
+ const vci = p.risk_count || 0;
+ const absVci = Math.abs(vci);
+ return {
+ x: Math.min(500, p.file_count),
+ y: p.p_war,
+ label: p.project_nm,
+ isVip: vipProjectNames.has(p.project_nm),
+ vci: vci,
+ radius: Math.max(5, Math.min(25, 5 + (absVci / 10)))
+ };
+ });
+
+ const vitalityCtx = document.getElementById('forecastChart').getContext('2d');
+ if (window.myVitalityChart) window.myVitalityChart.destroy();
+
+ window.myVitalityChart = new Chart(vitalityCtx, {
+ type: 'scatter',
+ data: {
+ datasets: [{
+ data: scatterData,
+ backgroundColor: (ctx) => {
+ const p = ctx.raw;
+ if (!p) return '#94a3b8';
+ if (p.x >= 250 && p.y >= 50) return '#1E5149';
+ if (p.x < 250 && p.y >= 50) return '#22c55e';
+ if (p.x < 250 && p.y < 50) return '#94a3b8';
+ return '#ef4444';
+ },
+ pointRadius: (ctx) => ctx.raw ? ctx.raw.radius : 5,
+ hoverRadius: (ctx) => (ctx.raw ? ctx.raw.radius : 5) + 3
+ }]
+ },
+ options: {
+ responsive: true,
+ maintainAspectRatio: false,
+ layout: { padding: { top: 30, right: 45, left: 10, bottom: 10 } },
+ scales: {
+ x: {
+ type: 'linear', min: 0, max: 500,
+ title: { display: true, text: '์์ฐ ๊ท๋ชจ (ํ์ผ ์)', font: { size: 11, weight: '700' } },
+ grid: { display: false }
+ },
+ y: {
+ min: 0, max: 100,
+ title: { display: true, text: '์ด์ ํ๋ ฅ (AVI %)', font: { size: 11, weight: '700' } },
+ grid: { display: false }
+ }
+ },
+ plugins: {
+ legend: { display: false },
+ datalabels: {
+ backgroundColor: 'rgba(255, 255, 255, 0.8)',
+ borderRadius: 4, padding: 4,
+ font: { size: 10, weight: '800' },
+ formatter: (v) => v ? v.label : '',
+ display: (ctx) => ctx.raw && ctx.raw.isVip,
+ clip: false
+ },
+ tooltip: {
+ callbacks: {
+ label: (ctx) => ` [${ctx.raw.label}] AVI: ${ctx.raw.y.toFixed(1)}% | VCI: ${ctx.raw.vci.toFixed(1)}`
+ }
+ }
+ }
+ },
+ plugins: [{
+ id: 'quadrants',
+ beforeDraw: (chart) => {
+ const { ctx, chartArea: { left, top, right, bottom }, scales: { x, y } } = chart;
+ const midX = x.getPixelForValue(250);
+ const midY = y.getPixelForValue(50);
+ ctx.save();
+ ctx.fillStyle = 'rgba(34, 197, 94, 0.03)'; ctx.fillRect(left, top, midX - left, midY - top);
+ ctx.fillStyle = 'rgba(30, 81, 73, 0.03)'; ctx.fillRect(midX, top, right - midX, midY - top);
+ ctx.fillStyle = 'rgba(148, 163, 184, 0.03)'; ctx.fillRect(left, midY, midX - left, bottom - midY);
+ ctx.fillStyle = 'rgba(239, 68, 68, 0.05)'; ctx.fillRect(midX, midY, right - midX, bottom - midY);
+ ctx.lineWidth = 2; ctx.strokeStyle = 'rgba(0,0,0,0.1)'; ctx.beginPath();
+ ctx.moveTo(midX, top); ctx.lineTo(midX, bottom); ctx.moveTo(left, midY); ctx.lineTo(right, midY); ctx.stroke();
+ ctx.font = 'bold 12px Pretendard'; ctx.textAlign = 'center'; ctx.fillStyle = 'rgba(0,0,0,0.2)';
+ ctx.fillText('ํ๋ ฅ ์ํธ', (left + midX) / 2, (top + midY) / 2);
+ ctx.fillText('ํต์ฌ ๊ฐ์น', (midX + right) / 2, (top + midY) / 2);
+ ctx.fillText('์ ์ฒด/์๊ท๋ชจ', (left + midX) / 2, (midY + bottom) / 2);
+ ctx.fillStyle = 'rgba(239, 68, 68, 0.4)'; ctx.fillText('์์ฐ ์์ค ์ํ', (midX + right) / 2, (midY + bottom) / 2);
+ ctx.restore();
+ }
+ }]
+ });
+ } catch (err) { console.error("์ ๋ต ๋งคํธ๋ฆญ์ค ์๋ฌ:", err); }
+}
+
+function renderVitalityLeaderboard(data) {
+ const container = document.getElementById('p-war-table-container');
+ if (!container) return;
+ const sortedData = [...data].sort((a, b) => a.p_war - b.p_war);
+
+ container.innerHTML = `
+ `;
+}
+
+function toggleProjectDetail(rowId) {
+ const container = document.querySelector('.table-scroll-wrapper');
+ const mainRow = document.querySelector(`tr[onclick*="toggleProjectDetail('${rowId}')"]`);
+ const detailRow = document.getElementById(`detail-${rowId}`);
+
+ if (detailRow && container) {
+ if (!detailRow.classList.contains('active')) {
+ document.querySelectorAll('.detail-row').forEach(row => row.classList.remove('active'));
+ detailRow.classList.add('active');
+
+ // ์ ๋ฐ ์คํฌ๋กค ์ด๋ ๋ก์ง ๋ณต๊ตฌ
+ setTimeout(() => {
+ const headerH = container.querySelector('thead').offsetHeight || 45;
+ const targetScrollTop = mainRow.offsetTop - headerH;
+ container.scrollTo({ top: targetScrollTop, behavior: 'smooth' });
+ }, 100);
+ } else {
+ detailRow.classList.remove('active');
+ }
+ }
+}
+
+function openProjectListModal(label, projects) {
+ const modal = document.getElementById('analysisModal');
+ const title = document.getElementById('modalTitle');
+ const body = document.getElementById('modalBody');
+ title.innerText = `[${label}] ํ๋ก์ ํธ ๋ฆฌ์คํธ (${projects.length}๊ฑด)`;
+ body.innerHTML = `
+
+ `;
+ modal.style.display = 'flex';
+}
+
+function openAnalysisModal(type) {
+ const modal = document.getElementById('analysisModal');
+ const title = document.getElementById('modalTitle');
+ const body = document.getElementById('modalBody');
+
+ if (type === 'avi') {
+ title.innerText = '์ด์ ํ๋ ฅ ์ง์ (AVI) ๋ถ์ ๊ฐ์ด๋';
+ body.innerHTML = `
+
+ AVI = exp(-ฮป ร Stagnant Days) ร Quality ร 100
+
+
+
์ด์ ํ๋ ฅ ์ง์(AVI)๋ ํ๋ก์ ํธ๊ฐ ํ์ฌ ์ผ๋ง๋ ๊ฑด๊ฐํ๊ฒ ๊ฐ๋๋๊ณ ์๋์ง๋ฅผ ๋ํ๋ด๋ '๋์งํธ ์์กด ์งํ'์
๋๋ค.
+
+ - ์ง์ ๊ฐ์(Exponential Decay): ๋ง์ง๋ง ํ๋ ์ดํ ์ ์ฒด ๊ธฐ๊ฐ์ด ๊ธธ์ด์ง์๋ก ์์ฐ์ ์ต์ ์ฑ๊ณผ ๊ฐ์น๋ ๊ธฐํ๊ธ์์ ์ผ๋ก ํ๋ฝํฉ๋๋ค.
+ - ์ํ ๊ฐ์ ๊ณ์(ฮป): ์์ฐ ๊ท๋ชจ(ํ์ผ ์)๊ฐ ํด์๋ก ๊ด๋ฆฌ ๋ถ์ฌ ์์ ์ ๋ณด ๋ง์ค ์ํ์ด ํฌ๋ค๊ณ ํ๋จํ์ฌ, ๋ ๊ฐํ๋ฅธ ๊ฐ์ ๊ณก์ ์ ์ ์ฉํฉ๋๋ค.
+ - ํ๋ ํ์ง(Quality Factor): ๋จ์ ํ์ ๋ก๊ทธ(๊ถํ ๋ณ๊ฒฝ ๋ฑ)๋ณด๋ค ์ค๋ฌด ์ฑ๊ณผ๋ฌผ(ํ์ผ ์
๋ก๋ ๋ฑ)์ด ๋ฐ์ํ์ ๋ ์ง์ ๋ณต์๋ ฅ์ ๋ ๋๊ฒ ๋ถ์ฌํฉ๋๋ค.
+
+
โป 70% ๋ฏธ๋ง ํ๋ฝ ์, ํด๋น ํ๋ก์ ํธ์ ๋ฐ์ดํฐ ๋
ธํํ ๋ฐ ๊ด๋ฆฌ ๋ฐฉ์น ์ํ์ด ์์๋ ๊ฒ์ผ๋ก ๊ฐ์ฃผํฉ๋๋ค.
+
+
+ | ์ง์ (AVI) | ๋ฑ๊ธ | ์ด์ ์ํ |
+
+ | 90%โ | Live | ์ค์๊ฐ ์ฑ๊ณผ๋ฌผ์ด ๋์ถ๋๋ ์ต์๊ธ ๊ฐ๋ ์ํ |
+ | 70~90% | Stable | ์ฃผ๊ธฐ์ ์
๋ฐ์ดํธ๊ฐ ์ด๋ค์ง๋ ํ์ค ์์ ์ํ |
+ | 30~70% | Idle | ํ๋ ฅ์ด ์ ํ๋์ด ๊ด๋ฆฌ๊ฐ ํ์ํ ์ ์ฒด ์ํ |
+ | 10~30% | Risk | ๋ฐ์ดํฐ ๋
ธํํ ๋ฐ ์์ฐ ๊ฐ์น ์๋ฉธ ์ํ ์ํ |
+ | 10%โ | Frozen | ์ด์์ด ์ค๋จ๋ ์ฌ๋ง/๋ฐฉ์น ์ํ |
+
+
+ `;
+ } else if (type === 'vci') {
+ title.innerText = '์์ฐ ๊ฐ์น ๊ธฐ์ฌ๋ (VCI) ๋ถ์ ๊ฐ์ด๋';
+ body.innerHTML = `
+ VCI๋ ์ผ๊ตฌ์ WAR(Wins Above Replacement) ๊ฐ๋
์ ๋์
ํ์ฌ, ๊ฐ๋ณ ํ๋ก์ ํธ๊ฐ ์ ์ฒด ํฌํธํด๋ฆฌ์ค ํ๊ท ๋๋น ์ผ๋ง๋ ์กฐ์ง์ ๊ฐ์น์ ๊ธฐ์ฌํ๋์ง ์ฐ์ถํ ์งํ์
๋๋ค.
+
+ VCI = (ํ์ฌ AVI - ์ ์ฒด ํ๊ท AVI) ร (ํ์ผ ๊ท๋ชจ ๊ฐ์ค์น)
+
+
+ โข 0.0 (ํ๊ท ): ์ฐ๋ฆฌ ์กฐ์ง์ ํ๊ท ์ ์ธ ๊ด๋ฆฌ ์์ค์ ์ ์ง ์ค์ธ ์ํ
+ โข (+) ์ ์: ํ๊ท ์ด์์ ํ๋ ฅ์ผ๋ก ์กฐ์ง์ ๋์งํธ ์์ฐ ๊ฐ์น๋ฅผ ์ฆ๋์ํด
+ โข (-) ์ ์: ํ๊ท ์ดํ์ ๋ฐฉ์น๋ก ์ธํด ์ ์ฌ์ ๊ธฐํ๋น์ฉ ์์ค ๋ฐ์ ์ค
+
+
+ | ์ ์ (VCI) | ๋ฑ๊ธ | ์ด์ ์๋ฏธ |
+
+ | +10.0โ | Masterpiece | ์์คํ
๊ฐ์น๋ฅผ ๊ฒฌ์ธํ๋ ์ต์ฐ๋ ํต์ฌ ์์ฐ |
+ | +2.0 ~ +10.0 | Blue Chip | ๊พธ์คํ ํ๋ ฅ์ผ๋ก ๊ฐ์น๋ฅผ ์ฐฝ์ถํ๋ ์ฐ๋ ์์ฐ |
+ | -2.0 ~ +2.0 | Steady | ํ๊ท ์์ค์ ์ด์์ ์ ์ง ์ค์ธ ์์ ์์ฐ |
+ | -10.0 ~ -2.0 | Underperform | ํ๊ท ๋๋น ํ๋ ฅ ๋ถ์กฑ์ผ๋ก ๊ฐ์น ํ๋ฝ ์ค์ธ ์์ฐ |
+ | -10.0โ | Liability | ๊ฐ์น๋ฅผ ํผ์ ์ค์ธ ๊ณ ์ํ ๋ฐฉ์น ์์ฐ |
+
+
+ `;
+ } else if (type === 'oci') {
+ title.innerText = '์ด์ ์ผ๊ด์ฑ ์ง์ (OCI) ๋ถ์ ๊ฐ์ด๋';
+ body.innerHTML = `
+
+
"์ผ๋ง๋ ๊พธ์คํ๊ฒ ๊ด๋ฆฌ๋๊ณ ์๋๊ฐ?"
+
๋ฏธ๋ ์์ธก์ด ์๋, ์ต๊ทผ 30์ผ๊ฐ์ ํ๋ ๋ฆฌ๋ฌ๊ณผ ๊ด๋ฆฌ์ ๊ท์น์ฑ์ ๋ถ์ํ์ฌ ์ฑ์ค๋๋ฅผ ์ ์ํํฉ๋๋ค.
+
+
+ | ๋ถ์ ๊ฒฐ๊ณผ | ์ผ๊ด์ฑ ๋ฑ๊ธ | ๊ด๋ฆฌ ์ ๋ขฐ๋ |
+
+ | 80%โ | ๋งค์ฐ ์ฐ์ | ์ฃผ ๋จ์์ ์ ๊ธฐ์ ๊ด๋ฆฌ๊ฐ ์๋ฒฝํ ์ด๋ค์ง |
+ | 50~80% | ์ํธ | ๊ฐํ์ ์ ์ฒด๋ ์์ผ๋ ๊พธ์คํ ๊ด๋ฆฌ๋จ |
+ | 20~50% | ์ฃผ์ | ๋๋ฐ์ ํ๋ ์์ฃผ, ๊ด๋ฆฌ์ ๋ฆฌ๋ฌ์ด ๊นจ์ง |
+ | 20%โ | ๋งค์ฐ ๋ถ๋ | ์ฅ๊ธฐ ์ ์ฒด ์ค์ด๊ฑฐ๋ ๊ด๋ฆฌ ์์ง ํ์ธ ๋ถ๊ฐ |
+
+
+ `;
+ } else {
+ title.innerText = '์
๋ฌด ์ง์ค๋ (Job Focus) ๋ฑ๊ธ ๊ฐ์ด๋';
+ body.innerHTML = `
+ ์ต๊ทผ ์์ง ๋ก๊ทธ ์ค ๋จ์ ํ์ ๋ก๊ทธ๋ฅผ ์ ์ธํ๊ณ ์ค์ง์ ์ธ ์ฑ๊ณผ๋ฌผ(ํ์ผ) ๋ณ๋์ด ํฌ์ฐฉ๋ ๋น์จ์
๋๋ค.
+
+ | ๋น์จ (%) | ๋ฑ๊ธ | ํ๋ ์ฑ๊ฒฉ |
+
+ | 80%โ | Intensive | ์ฑ๊ณผ๋ฌผ ์์ฃผ์ ๊ณ ๋ฐ๋ ์ง์ค ์์
|
+ | 50~80% | Active | ์ฑ๊ณผ์ ๊ด๋ฆฌ๊ฐ ๊ท ํ ์กํ ์ํํ ์คํ |
+ | 20~50% | Maintenance | ์ค์ /ํ์ ๋ฑ ๋จ์ ๊ด๋ฆฌ ์ค์ฌ์ ์์
|
+ | 20%โ | Surface | ์ค์ฒด์ ๋ณํ๊ฐ ์ ์ ํ์์ ๋ก๊ทธ ์ค์ฌ |
+
+
+ `;
+ }
+ modal.style.display = 'flex';
+}
+
+function closeAnalysisModal() { document.getElementById('analysisModal').style.display = 'none'; }
diff --git a/js/analysis.js_fragment_leaderboard b/js/analysis.js_fragment_leaderboard
index a00bd16..445ad4a 100644
--- a/js/analysis.js_fragment_leaderboard
+++ b/js/analysis.js_fragment_leaderboard
@@ -1,178 +1,178 @@
-function renderPWarLeaderboard(data) {
- const container = document.getElementById('p-war-table-container');
- if (!container) return;
-
- const sortedData = [...data].sort((a, b) => a.p_war - b.p_war);
-
- container.innerHTML = `
-
- `;
-}
+function renderPWarLeaderboard(data) {
+ const container = document.getElementById('p-war-table-container');
+ if (!container) return;
+
+ const sortedData = [...data].sort((a, b) => a.p_war - b.p_war);
+
+ container.innerHTML = `
+
+ `;
+}
diff --git a/js/analysis_test.js b/js/analysis_test.js
new file mode 100644
index 0000000..dbbb1b6
--- /dev/null
+++ b/js/analysis_test.js
@@ -0,0 +1,485 @@
+/**
+ * Project Master Analysis JS (TEST VERSION)
+ * AVI (Activity Vitality Index) & VCI (Value Contribution Index) ๋ถ์ ์์ง
+ * OCI (Operational Consistency Index) ํตํฉ ๋ฒ์
+ */
+
+// Chart.js ํ๋ฌ๊ทธ์ธ ์ ์ญ ๋ฑ๋ก
+if (typeof ChartDataLabels !== 'undefined') {
+ Chart.register(ChartDataLabels);
+}
+
+document.addEventListener('DOMContentLoaded', () => {
+ console.log("Business Analysis Engine (TEST) initialized...");
+ loadProjectAnalysisData();
+});
+
+async function loadProjectAnalysisData() {
+ try {
+ const response = await fetch('/api/analysis/p-war');
+ const data = await response.json();
+ if (data.error) throw new Error(data.error);
+
+ renderVitalityLeaderboard(data);
+ renderValueCharts(data);
+
+ if (data.length > 0 && data[0].avg_info) {
+ const avg = data[0].avg_info;
+ const infoEl = document.getElementById('avg-system-info');
+ if (infoEl) infoEl.textContent = `* ์์คํ
์ข
ํฉ ์ด์ ํ๋ ฅ(AVI): ${avg.avg_risk}% (ํ๊ท ๊ด๋ฆฌ ์์ค) [TEST MODE]`;
+ }
+ } catch (e) {
+ console.error("๋ถ์ ๋ฐ์ดํฐ ๋ก๋ฉ ์คํจ:", e);
+ }
+}
+
+function getStatusInfo(avi, isAutoDelete) {
+ if (isAutoDelete || avi < 10) return { label: '์ฌ๋ง', class: 'badge-system', key: 'dead' };
+ if (avi < 30) return { label: '์ํ ๋
ธ์ถ', class: 'badge-danger', key: 'danger' };
+ if (avi < 70) return { label: '๊ด๋ฆฌ ์ฃผ์', class: 'badge-warning', key: 'warning' };
+ return { label: '์ ์ ์ด์', class: 'badge-active', key: 'active' };
+}
+
+function getVciGrade(vci) {
+ if (vci >= 10) return { label: 'Masterpiece', class: 'grade-mvp', desc: '์์คํ
๊ฐ์น๋ฅผ ๊ฒฌ์ธํ๋ ์ต์ฐ๋ ํต์ฌ ์์ฐ' };
+ if (vci >= 2) return { label: 'Blue Chip', class: 'grade-allstar', desc: '๊พธ์คํ ํ๋ ฅ์ผ๋ก ๊ฐ์น๋ฅผ ์ฐฝ์ถํ๋ ์ฐ๋ ์์ฐ' };
+ if (vci >= -2) return { label: 'Steady', class: 'grade-starter', desc: 'ํ์ค ์์ค์ ์ด์์ ์ ์ง ์ค์ธ ์์ ์์ฐ' };
+ if (vci >= -10) return { label: 'Underperform', class: 'grade-bench', desc: '๊ท๋ชจ ๋๋น ํ๋ ฅ ๋ถ์กฑ์ผ๋ก ๊ฐ์น๊ฐ ํ๋ฝ ์ค์ธ ์์ฐ' };
+ return { label: 'Liability', class: 'grade-out', desc: '๊ฐ์น๋ฅผ ํผ์ ์ค์ธ ๊ณ ์ํ ๋ฐฉ์น ์์ฐ' };
+}
+
+function renderValueCharts(data) {
+ if (!data || data.length === 0) return;
+
+ // 1. ์ด์ ํ๋ ฅ ๋ถํฌ (Doughnut)
+ try {
+ const stats = { active: [], warning: [], danger: [], dead: [] };
+ data.forEach(p => {
+ const status = getStatusInfo(p.p_war, p.is_auto_delete);
+ stats[status.key].push(p);
+ });
+
+ const statusCtx = document.getElementById('statusChart').getContext('2d');
+ if (window.myStatusChart) window.myStatusChart.destroy();
+
+ window.myStatusChart = new Chart(statusCtx, {
+ type: 'doughnut',
+ data: {
+ labels: ['์ ์ ์ด์', '๊ด๋ฆฌ ์ฃผ์', '์ํ ๋
ธ์ถ', '์ฌ๋ง'],
+ datasets: [{
+ data: [stats.active.length, stats.warning.length, stats.danger.length, stats.dead.length],
+ backgroundColor: ['#1E5149', '#22c55e', '#f59e0b', '#ef4444'],
+ borderWidth: 0
+ }]
+ },
+ options: {
+ responsive: true,
+ maintainAspectRatio: false,
+ layout: { padding: 15 },
+ plugins: {
+ legend: { position: 'right', labels: { boxWidth: 10, font: { size: 11, weight: '700' }, usePointStyle: true } },
+ datalabels: { display: false }
+ },
+ cutout: '65%',
+ onClick: (e, elements) => {
+ if (elements.length > 0) {
+ const idx = elements[0].index;
+ openProjectListModal(['์ ์ ์ด์', '๊ด๋ฆฌ ์ฃผ์', '์ํ ๋
ธ์ถ', '์ฌ๋ง'][idx], stats[['active', 'warning', 'danger', 'dead'][idx]]);
+ }
+ }
+ }
+ });
+ } catch (err) { console.error("๋๋ ์ฐจํธ ์๋ฌ:", err); }
+
+ // 2. ์ ๋ต์ ์์ฐ ๋งคํธ๋ฆญ์ค (Scatter) - ์ ๋ฐ ๋ณต๊ตฌ
+ try {
+ const sortedByAVI = [...data].sort((a, b) => b.p_war - a.p_war);
+ const top5Ids = sortedByAVI.slice(0, 5).map(p => p.project_nm);
+ const bottom5Ids = sortedByAVI.slice(-5).map(p => p.project_nm);
+ const largeProjects = data.filter(p => p.file_count > 450).map(p => p.project_nm);
+ const vipProjectNames = new Set([...top5Ids, ...bottom5Ids, ...largeProjects]);
+
+ const scatterData = data.map(p => {
+ const vci = p.risk_count || 0;
+ const absVci = Math.abs(vci);
+ return {
+ x: Math.min(500, p.file_count),
+ y: p.p_war,
+ label: p.project_nm,
+ isVip: vipProjectNames.has(p.project_nm),
+ vci: vci,
+ radius: Math.max(5, Math.min(25, 5 + (absVci / 10)))
+ };
+ });
+
+ const vitalityCtx = document.getElementById('forecastChart').getContext('2d');
+ if (window.myVitalityChart) window.myVitalityChart.destroy();
+
+ window.myVitalityChart = new Chart(vitalityCtx, {
+ type: 'scatter',
+ data: {
+ datasets: [{
+ data: scatterData,
+ backgroundColor: (ctx) => {
+ const p = ctx.raw;
+ if (!p) return '#94a3b8';
+ if (p.x >= 250 && p.y >= 50) return '#1E5149';
+ if (p.x < 250 && p.y >= 50) return '#22c55e';
+ if (p.x < 250 && p.y < 50) return '#94a3b8';
+ return '#ef4444';
+ },
+ pointRadius: (ctx) => ctx.raw ? ctx.raw.radius : 5,
+ hoverRadius: (ctx) => (ctx.raw ? ctx.raw.radius : 5) + 3
+ }]
+ },
+ options: {
+ responsive: true,
+ maintainAspectRatio: false,
+ layout: { padding: { top: 30, right: 45, left: 10, bottom: 10 } },
+ scales: {
+ x: {
+ type: 'linear', min: 0, max: 500,
+ title: { display: true, text: '์์ฐ ๊ท๋ชจ (ํ์ผ ์)', font: { size: 11, weight: '700' } },
+ grid: { display: false }
+ },
+ y: {
+ min: 0, max: 100,
+ title: { display: true, text: '์ด์ ํ๋ ฅ (AVI %)', font: { size: 11, weight: '700' } },
+ grid: { display: false }
+ }
+ },
+ plugins: {
+ legend: { display: false },
+ datalabels: {
+ backgroundColor: 'rgba(255, 255, 255, 0.8)',
+ borderRadius: 4, padding: 4,
+ font: { size: 10, weight: '800' },
+ formatter: (v) => v ? v.label : '',
+ display: (ctx) => ctx.raw && ctx.raw.isVip,
+ clip: false
+ },
+ tooltip: {
+ callbacks: {
+ label: (ctx) => ` [${ctx.raw.label}] AVI: ${ctx.raw.y.toFixed(1)}% | VCI: ${ctx.raw.vci.toFixed(1)}`
+ }
+ }
+ }
+ },
+ plugins: [{
+ id: 'quadrants',
+ beforeDraw: (chart) => {
+ const { ctx, chartArea: { left, top, right, bottom }, scales: { x, y } } = chart;
+ const midX = x.getPixelForValue(250);
+ const midY = y.getPixelForValue(50);
+ ctx.save();
+ ctx.fillStyle = 'rgba(34, 197, 94, 0.03)'; ctx.fillRect(left, top, midX - left, midY - top);
+ ctx.fillStyle = 'rgba(30, 81, 73, 0.03)'; ctx.fillRect(midX, top, right - midX, midY - top);
+ ctx.fillStyle = 'rgba(148, 163, 184, 0.03)'; ctx.fillRect(left, midY, midX - left, bottom - midY);
+ ctx.fillStyle = 'rgba(239, 68, 68, 0.05)'; ctx.fillRect(midX, midY, right - midX, bottom - midY);
+ ctx.lineWidth = 2; ctx.strokeStyle = 'rgba(0,0,0,0.1)'; ctx.beginPath();
+ ctx.moveTo(midX, top); ctx.lineTo(midX, bottom); ctx.moveTo(left, midY); ctx.lineTo(right, midY); ctx.stroke();
+ ctx.font = 'bold 12px Pretendard'; ctx.textAlign = 'center'; ctx.fillStyle = 'rgba(0,0,0,0.2)';
+ ctx.fillText('ํ๋ ฅ ์ํธ', (left + midX) / 2, (top + midY) / 2);
+ ctx.fillText('ํต์ฌ ๊ฐ์น', (midX + right) / 2, (top + midY) / 2);
+ ctx.fillText('์ ์ฒด/์๊ท๋ชจ', (left + midX) / 2, (midY + bottom) / 2);
+ ctx.fillStyle = 'rgba(239, 68, 68, 0.4)'; ctx.fillText('์์ฐ ์์ค ์ํ', (midX + right) / 2, (midY + bottom) / 2);
+ ctx.restore();
+ }
+ }]
+ });
+ } catch (err) { console.error("์ ๋ต ๋งคํธ๋ฆญ์ค ์๋ฌ:", err); }
+}
+
+function renderVitalityLeaderboard(data) {
+ const container = document.getElementById('p-war-table-container');
+ if (!container) return;
+ const sortedData = [...data].sort((a, b) => a.p_war - b.p_war);
+
+ container.innerHTML = `
+ `;
+}
+
+function toggleProjectDetail(rowId) {
+ const container = document.querySelector('.table-scroll-wrapper');
+ const mainRow = document.querySelector(`tr[onclick*="toggleProjectDetail('${rowId}')"]`);
+ const detailRow = document.getElementById(`detail-${rowId}`);
+
+ if (detailRow && container) {
+ if (!detailRow.classList.contains('active')) {
+ document.querySelectorAll('.detail-row').forEach(row => row.classList.remove('active'));
+ detailRow.classList.add('active');
+
+ // ์ ๋ฐ ์คํฌ๋กค ์ด๋ ๋ก์ง ๋ณต๊ตฌ
+ setTimeout(() => {
+ const headerH = container.querySelector('thead').offsetHeight || 45;
+ const targetScrollTop = mainRow.offsetTop - headerH;
+ container.scrollTo({ top: targetScrollTop, behavior: 'smooth' });
+ }, 100);
+ } else {
+ detailRow.classList.remove('active');
+ }
+ }
+}
+
+function openProjectListModal(label, projects) {
+ const modal = document.getElementById('analysisModal');
+ const title = document.getElementById('modalTitle');
+ const body = document.getElementById('modalBody');
+ title.innerText = `[${label}] ํ๋ก์ ํธ ๋ฆฌ์คํธ (${projects.length}๊ฑด)`;
+ body.innerHTML = `
+
+ `;
+ modal.style.display = 'flex';
+}
+
+function openAnalysisModal(type) {
+ const modal = document.getElementById('analysisModal');
+ const title = document.getElementById('modalTitle');
+ const body = document.getElementById('modalBody');
+
+ if (type === 'avi') {
+ title.innerText = '์ด์ ํ๋ ฅ ์ง์ (AVI) ๋ถ์ ๊ฐ์ด๋ (TEST)';
+ body.innerHTML = `
+
+ AVI = exp(-ฮป ร Stagnant Days) ร Quality ร 100
+
+
+
์ด์ ํ๋ ฅ ์ง์(AVI)๋ ํ๋ก์ ํธ๊ฐ ํ์ฌ ์ผ๋ง๋ ๊ฑด๊ฐํ๊ฒ ๊ฐ๋๋๊ณ ์๋์ง๋ฅผ ๋ํ๋ด๋ '๋์งํธ ์์กด ์งํ'์
๋๋ค.
+
+ - ์ง์ ๊ฐ์(Exponential Decay): ๋ง์ง๋ง ํ๋ ์ดํ ์ ์ฒด ๊ธฐ๊ฐ์ด ๊ธธ์ด์ง์๋ก ์์ฐ์ ์ต์ ์ฑ๊ณผ ๊ฐ์น๋ ๊ธฐํ๊ธ์์ ์ผ๋ก ํ๋ฝํฉ๋๋ค.
+ - ์ํ ๊ฐ์ ๊ณ์(ฮป): ์์ฐ ๊ท๋ชจ(ํ์ผ ์)๊ฐ ํด์๋ก ๊ด๋ฆฌ ๋ถ์ฌ ์์ ์ ๋ณด ๋ง์ค ์ํ์ด ํฌ๋ค๊ณ ํ๋จํ์ฌ, ๋ ๊ฐํ๋ฅธ ๊ฐ์ ๊ณก์ ์ ์ ์ฉํฉ๋๋ค.
+ - ํ๋ ํ์ง(Quality Factor): ๋จ์ ํ์ ๋ก๊ทธ(๊ถํ ๋ณ๊ฒฝ ๋ฑ)๋ณด๋ค ์ค๋ฌด ์ฑ๊ณผ๋ฌผ(ํ์ผ ์
๋ก๋ ๋ฑ)์ด ๋ฐ์ํ์ ๋ ์ง์ ๋ณต์๋ ฅ์ ๋ ๋๊ฒ ๋ถ์ฌํฉ๋๋ค.
+
+
โป 70% ๋ฏธ๋ง ํ๋ฝ ์, ํด๋น ํ๋ก์ ํธ์ ๋ฐ์ดํฐ ๋
ธํํ ๋ฐ ๊ด๋ฆฌ ๋ฐฉ์น ์ํ์ด ์์๋ ๊ฒ์ผ๋ก ๊ฐ์ฃผํฉ๋๋ค.
+
+
+ | ์ง์ (AVI) | ๋ฑ๊ธ | ์ด์ ์ํ |
+
+ | 90%โ | Live | ์ค์๊ฐ ์ฑ๊ณผ๋ฌผ์ด ๋์ถ๋๋ ์ต์๊ธ ๊ฐ๋ ์ํ |
+ | 70~90% | Stable | ์ฃผ๊ธฐ์ ์
๋ฐ์ดํธ๊ฐ ์ด๋ค์ง๋ ํ์ค ์์ ์ํ |
+ | 30~70% | Idle | ํ๋ ฅ์ด ์ ํ๋์ด ๊ด๋ฆฌ๊ฐ ํ์ํ ์ ์ฒด ์ํ |
+ | 10~30% | Risk | ๋ฐ์ดํฐ ๋
ธํํ ๋ฐ ์์ฐ ๊ฐ์น ์๋ฉธ ์ํ ์ํ |
+ | 10%โ | Frozen | ์ด์์ด ์ค๋จ๋ ์ฌ๋ง/๋ฐฉ์น ์ํ |
+
+
+ `;
+ } else if (type === 'vci') {
+ title.innerText = '์์ฐ ๊ฐ์น ๊ธฐ์ฌ๋ (VCI) ๋ถ์ ๊ฐ์ด๋ (TEST)';
+ body.innerHTML = `
+ VCI๋ ์ผ๊ตฌ์ WAR(Wins Above Replacement) ๊ฐ๋
์ ๋์
ํ์ฌ, ๊ฐ๋ณ ํ๋ก์ ํธ๊ฐ ์ ์ฒด ํฌํธํด๋ฆฌ์ค ํ๊ท ๋๋น ์ผ๋ง๋ ์กฐ์ง์ ๊ฐ์น์ ๊ธฐ์ฌํ๋์ง ์ฐ์ถํ ์งํ์
๋๋ค.
+
+ VCI = (ํ์ฌ AVI - ์ ์ฒด ํ๊ท AVI) ร (ํ์ผ ๊ท๋ชจ ๊ฐ์ค์น)
+
+
+ โข 0.0 (ํ๊ท ): ์ฐ๋ฆฌ ์กฐ์ง์ ํ๊ท ์ ์ธ ๊ด๋ฆฌ ์์ค์ ์ ์ง ์ค์ธ ์ํ
+ โข (+) ์ ์: ํ๊ท ์ด์์ ํ๋ ฅ์ผ๋ก ์กฐ์ง์ ๋์งํธ ์์ฐ ๊ฐ์น๋ฅผ ์ฆ๋์ํด
+ โข (-) ์ ์: ํ๊ท ์ดํ์ ๋ฐฉ์น๋ก ์ธํด ์ ์ฌ์ ๊ธฐํ๋น์ฉ ์์ค ๋ฐ์ ์ค
+
+
+ | ์ ์ (VCI) | ๋ฑ๊ธ | ์ด์ ์๋ฏธ |
+
+ | +10.0โ | Masterpiece | ์์คํ
๊ฐ์น๋ฅผ ๊ฒฌ์ธํ๋ ์ต์ฐ๋ ํต์ฌ ์์ฐ |
+ | +2.0 ~ +10.0 | Blue Chip | ๊พธ์คํ ํ๋ ฅ์ผ๋ก ๊ฐ์น๋ฅผ ์ฐฝ์ถํ๋ ์ฐ๋ ์์ฐ |
+ | -2.0 ~ +2.0 | Steady | ํ๊ท ์์ค์ ์ด์์ ์ ์ง ์ค์ธ ์์ ์์ฐ |
+ | -10.0 ~ -2.0 | Underperform | ํ๊ท ๋๋น ํ๋ ฅ ๋ถ์กฑ์ผ๋ก ๊ฐ์น ํ๋ฝ ์ค์ธ ์์ฐ |
+ | -10.0โ | Liability | ๊ฐ์น๋ฅผ ํผ์ ์ค์ธ ๊ณ ์ํ ๋ฐฉ์น ์์ฐ |
+
+
+ `;
+ } else if (type === 'oci') {
+ title.innerText = '์ด์ ์ผ๊ด์ฑ ์ง์ (OCI) ๋ถ์ ๊ฐ์ด๋ (TEST)';
+ body.innerHTML = `
+
+
"์ผ๋ง๋ ๊พธ์คํ๊ฒ ๊ด๋ฆฌ๋๊ณ ์๋๊ฐ?"
+
๋ฏธ๋ ์์ธก์ด ์๋, ์ต๊ทผ 30์ผ๊ฐ์ ํ๋ ๋ฆฌ๋ฌ๊ณผ ๊ด๋ฆฌ์ ๊ท์น์ฑ์ ๋ถ์ํ์ฌ ์ฑ์ค๋๋ฅผ ์ ์ํํฉ๋๋ค.
+
+
+ | ๋ถ์ ๊ฒฐ๊ณผ | ์ผ๊ด์ฑ ๋ฑ๊ธ | ๊ด๋ฆฌ ์ ๋ขฐ๋ |
+
+ | 80%โ | ๋งค์ฐ ์ฐ์ | ์ฃผ ๋จ์์ ์ ๊ธฐ์ ๊ด๋ฆฌ๊ฐ ์๋ฒฝํ ์ด๋ค์ง |
+ | 50~80% | ์ํธ | ๊ฐํ์ ์ ์ฒด๋ ์์ผ๋ ๊พธ์คํ ๊ด๋ฆฌ๋จ |
+ | 20~50% | ์ฃผ์ | ๋๋ฐ์ ํ๋ ์์ฃผ, ๊ด๋ฆฌ์ ๋ฆฌ๋ฌ์ด ๊นจ์ง |
+ | 20%โ | ๋งค์ฐ ๋ถ๋ | ์ฅ๊ธฐ ์ ์ฒด ์ค์ด๊ฑฐ๋ ๊ด๋ฆฌ ์์ง ํ์ธ ๋ถ๊ฐ |
+
+
+ `;
+ } else {
+ title.innerText = '์
๋ฌด ์ง์ค๋ (Job Focus) ๋ฑ๊ธ ๊ฐ์ด๋ (TEST)';
+ body.innerHTML = `
+ ์ต๊ทผ ์์ง ๋ก๊ทธ ์ค ๋จ์ ํ์ ๋ก๊ทธ๋ฅผ ์ ์ธํ๊ณ ์ค์ง์ ์ธ ์ฑ๊ณผ๋ฌผ(ํ์ผ) ๋ณ๋์ด ํฌ์ฐฉ๋ ๋น์จ์
๋๋ค.
+
+ | ๋น์จ (%) | ๋ฑ๊ธ | ํ๋ ์ฑ๊ฒฉ |
+
+ | 80%โ | Intensive | ์ฑ๊ณผ๋ฌผ ์์ฃผ์ ๊ณ ๋ฐ๋ ์ง์ค ์์
|
+ | 50~80% | Active | ์ฑ๊ณผ์ ๊ด๋ฆฌ๊ฐ ๊ท ํ ์กํ ์ํํ ์คํ |
+ | 20~50% | Maintenance | ์ค์ /ํ์ ๋ฑ ๋จ์ ๊ด๋ฆฌ ์ค์ฌ์ ์์
|
+ | 20%โ | Surface | ์ค์ฒด์ ๋ณํ๊ฐ ์ ์ ํ์์ ๋ก๊ทธ ์ค์ฌ |
+
+
+ `;
+ }
+ modal.style.display = 'flex';
+}
+
+function closeAnalysisModal() { document.getElementById('analysisModal').style.display = 'none'; }
diff --git a/js/common.js b/js/common.js
index 6be7296..0c854b1 100644
--- a/js/common.js
+++ b/js/common.js
@@ -1,78 +1,78 @@
-/**
- * Project Master Overseas Common JS
- * ๊ณตํต ๋ค๋น๊ฒ์ด์
, ํตํฉ ๋ชจ๋ฌ ๊ด๋ฆฌ, ์ ํธ๋ฆฌํฐ
- */
-
-// --- ๊ณตํต ์์ ---
-const API = {
- INQUIRIES: '/api/inquiries',
- PROJECT_DATA: '/project-data',
- PROJECT_ACTIVITY: '/project-activity',
- AVAILABLE_DATES: '/available-dates',
- SYNC: '/sync',
- STOP_SYNC: '/stop-sync',
- AUTH_CRAWL: '/auth/crawl',
- ANALYZE_FILE: '/analyze-file',
- ATTACHMENTS: '/attachments'
-};
-
-// --- ๋ค๋น๊ฒ์ด์
---
-function navigateTo(path) {
- location.href = path;
-}
-
-// --- ํตํฉ ๋ชจ๋ฌ ๊ด๋ฆฌ์ ---
-const ModalManager = {
- open(modalId) {
- const modal = document.getElementById(modalId);
- if (modal) {
- modal.style.display = 'flex';
- // ํฌ์ปค์ค ์๋ ์ด๋ (ID ์
๋ ฅ๋์ด ์์ผ๋ฉด)
- const firstInput = modal.querySelector('input');
- if (firstInput) firstInput.focus();
- }
- },
- close(modalId) {
- const modal = document.getElementById(modalId);
- if (modal) modal.style.display = 'none';
- },
- closeAll() {
- document.querySelectorAll('.modal-overlay').forEach(m => m.style.display = 'none');
- }
-};
-
-// --- ์ ํธ๋ฆฌํฐ ํจ์ ---
-const Utils = {
- formatDate(dateStr) {
- if (!dateStr) return '-';
- return dateStr.replace(/-/g, '.');
- },
-
- // ์ํ๋ณ CSS ํด๋์ค ๋งคํ
- getStatusClass(status) {
- const map = {
- '์๋ฃ': 'status-complete',
- '์์
์ค': 'status-working',
- 'ํ์ธ ์ค': 'status-checking',
- '์ ์': 'active',
- '์ฃผ์': 'warning',
- '๋ฐฉ์น': 'stale',
- '๋ฐ์ดํฐ ์์': 'unknown'
- };
- return map[status] || 'status-pending';
- },
-
- // ํ๊ธ ํ์ผ๋ช
์ธ์ฝ๋ฉ ์์ ์ฒ๋ฆฌ
- getSafeFileUrl(filename) {
- return `/sample_files/${encodeURIComponent(filename)}`;
- }
-};
-
-// --- ์ ์ญ ์ด๋ฒคํธ ---
-document.addEventListener('keydown', (e) => {
- if (e.key === 'Escape') ModalManager.closeAll();
-});
-
-document.addEventListener('DOMContentLoaded', () => {
- console.log("Common module initialized.");
-});
+/**
+ * Project Master Overseas Common JS
+ * ๊ณตํต ๋ค๋น๊ฒ์ด์
, ํตํฉ ๋ชจ๋ฌ ๊ด๋ฆฌ, ์ ํธ๋ฆฌํฐ
+ */
+
+// --- ๊ณตํต ์์ ---
+const API = {
+ INQUIRIES: '/api/inquiries',
+ PROJECT_DATA: '/project-data',
+ PROJECT_ACTIVITY: '/project-activity',
+ AVAILABLE_DATES: '/available-dates',
+ SYNC: '/sync',
+ STOP_SYNC: '/stop-sync',
+ AUTH_CRAWL: '/auth/crawl',
+ ANALYZE_FILE: '/analyze-file',
+ ATTACHMENTS: '/attachments'
+};
+
+// --- ๋ค๋น๊ฒ์ด์
---
+function navigateTo(path) {
+ location.href = path;
+}
+
+// --- ํตํฉ ๋ชจ๋ฌ ๊ด๋ฆฌ์ ---
+const ModalManager = {
+ open(modalId) {
+ const modal = document.getElementById(modalId);
+ if (modal) {
+ modal.style.display = 'flex';
+ // ํฌ์ปค์ค ์๋ ์ด๋ (ID ์
๋ ฅ๋์ด ์์ผ๋ฉด)
+ const firstInput = modal.querySelector('input');
+ if (firstInput) firstInput.focus();
+ }
+ },
+ close(modalId) {
+ const modal = document.getElementById(modalId);
+ if (modal) modal.style.display = 'none';
+ },
+ closeAll() {
+ document.querySelectorAll('.modal-overlay').forEach(m => m.style.display = 'none');
+ }
+};
+
+// --- ์ ํธ๋ฆฌํฐ ํจ์ ---
+const Utils = {
+ formatDate(dateStr) {
+ if (!dateStr) return '-';
+ return dateStr.replace(/-/g, '.');
+ },
+
+ // ์ํ๋ณ CSS ํด๋์ค ๋งคํ
+ getStatusClass(status) {
+ const map = {
+ '์๋ฃ': 'status-complete',
+ '์์
์ค': 'status-working',
+ 'ํ์ธ ์ค': 'status-checking',
+ '์ ์': 'active',
+ '์ฃผ์': 'warning',
+ '๋ฐฉ์น': 'stale',
+ '๋ฐ์ดํฐ ์์': 'unknown'
+ };
+ return map[status] || 'status-pending';
+ },
+
+ // ํ๊ธ ํ์ผ๋ช
์ธ์ฝ๋ฉ ์์ ์ฒ๋ฆฌ
+ getSafeFileUrl(filename) {
+ return `/sample_files/${encodeURIComponent(filename)}`;
+ }
+};
+
+// --- ์ ์ญ ์ด๋ฒคํธ ---
+document.addEventListener('keydown', (e) => {
+ if (e.key === 'Escape') ModalManager.closeAll();
+});
+
+document.addEventListener('DOMContentLoaded', () => {
+ console.log("Common module initialized.");
+});
diff --git a/js/dashboard.js b/js/dashboard.js
index 726802a..6f10db4 100644
--- a/js/dashboard.js
+++ b/js/dashboard.js
@@ -1,237 +1,237 @@
-/**
- * Project Master Overseas Dashboard JS
- * ๊ธฐ๋ฅ: ๋ฐ์ดํฐ ๋ก๋, ํ์ฑ๋ ๋ถ์, ์ธ์ฆ ๋ชจ๋ฌ ์ ์ด, ํฌ๋กค๋ง ๋๊ธฐํ ๋ฐ ์ค๋จ
- */
-
-// --- ๊ธ๋ก๋ฒ ์ํ ๊ด๋ฆฌ ---
-let rawData = [];
-let projectActivityDetails = [];
-let isCrawling = false;
-
-const CONTINENT_ORDER = { "์์์": 1, "์ํ๋ฆฌ์นด": 2, "์๋ฉ๋ฆฌ์นด": 3, "์ง์ฌ": 4 };
-
-// --- ์ด๊ธฐํ ---
-async function init() {
- console.log("Dashboard Initializing...");
- if (!document.getElementById('projectAccordion')) return;
-
- await loadAvailableDates();
- await loadDataByDate();
-}
-
-// --- ๋ฐ์ดํฐ ํต์ ๋ฐ ๋ก๋ ---
-async function loadAvailableDates() {
- try {
- const response = await fetch(API.AVAILABLE_DATES);
- const dates = await response.json();
- if (dates?.length > 0) {
- const selectHtml = `
- `;
- const baseDateStrong = document.getElementById('baseDate');
- if (baseDateStrong) baseDateStrong.innerHTML = selectHtml;
- }
- } catch (e) { console.error("๋ ์ง ๋ก๋ ์คํจ:", e); }
-}
-
-async function loadDataByDate(selectedDate = "") {
- try {
- await loadActivityAnalysis(selectedDate);
- const url = selectedDate ? `${API.PROJECT_DATA}?date=${selectedDate}` : `${API.PROJECT_DATA}?t=${Date.now()}`;
- const response = await fetch(url);
- const data = await response.json();
- if (data.error) throw new Error(data.error);
- rawData = data.projects || [];
- renderDashboard(rawData);
- } catch (e) {
- console.error("๋ฐ์ดํฐ ๋ก๋ ์คํจ:", e);
- alert("๋ฐ์ดํฐ๋ฅผ ๊ฐ์ ธ์ค๋ ๋ฐ ์คํจํ์ต๋๋ค.");
- }
-}
-
-async function loadActivityAnalysis(date = "") {
- const dashboard = document.getElementById('activityDashboard');
- if (!dashboard) return;
- try {
- const url = date ? `${API.PROJECT_ACTIVITY}?date=${date}` : API.PROJECT_ACTIVITY;
- const response = await fetch(url);
- const data = await response.json();
- if (data.error) return;
- const { summary, details } = data;
- projectActivityDetails = details;
- dashboard.innerHTML = `
-
-
์ ์ (7์ผ ์ด๋ด)
${summary.active}
-
-
-
์ฃผ์ (14์ผ ์ด๋ด)
${summary.warning}
-
-
-
๋ฐฉ์น (14์ผ ์ด๊ณผ / ํด๋์๋์ญ์ )
${summary.stale}
-
-
-
๋ฐ์ดํฐ ์์ (ํ์ผ 0๊ฐ)
${summary.unknown}
-
`;
- } catch (e) { console.error("๋ถ์ ๋ก๋ ์คํจ:", e); }
-}
-
-// --- ๋ ๋๋ง ์์ง ---
-function renderDashboard(data) {
- const container = document.getElementById('projectAccordion');
- container.innerHTML = '';
- const grouped = groupData(data);
- Object.keys(grouped).sort((a, b) => (CONTINENT_ORDER[a] || 99) - (CONTINENT_ORDER[b] || 99)).forEach(continent => {
- const continentDiv = document.createElement('div');
- continentDiv.className = 'continent-group active';
- let html = ``;
- Object.keys(grouped[continent]).sort().forEach(country => {
- html += `
-
- ${grouped[continent][country].sort((a, b) => a[0].localeCompare(b[0])).map(p => createProjectHtml(p)).join('')}
`;
- });
- html += `
`;
- continentDiv.innerHTML = html;
- container.appendChild(continentDiv);
- });
-}
-
-function groupData(data) {
- const res = {};
- data.forEach(item => {
- const c1 = item[5] || "๊ธฐํ", c2 = item[6] || "๋ฏธ๋ถ๋ฅ";
- if (!res[c1]) res[c1] = {};
- if (!res[c1][c2]) res[c1][c2] = [];
- res[c1][c2].push(item);
- });
- return res;
-}
-
-function createProjectHtml(p) {
- const [name, dept, admin, logRaw, files] = p;
- const recentLog = (!logRaw || logRaw === "X" || logRaw === "๋ฐ์ดํฐ ์์") ? "๊ธฐ๋ก ์์" : logRaw;
- const logTime = recentLog !== "๊ธฐ๋ก ์์" ? recentLog.split(',')[0] : "๊ธฐ๋ก ์์";
-
- const isStaleLog = recentLog.replace(/\s/g, "").includes("ํด๋์๋์ญ์ ");
- const isNoFiles = (files === 0 || files === null);
- const statusClass = isNoFiles ? "status-error" : "";
-
- let logStyleClass = "";
- if (isStaleLog) logStyleClass = "error-text";
- else if (recentLog === "๊ธฐ๋ก ์์") logStyleClass = "warning-text";
-
- const logBoldStyle = isStaleLog ? 'font-weight: 800;' : '';
-
- return `
-
-
-
-
-
-
์ฐธ์ฌ ์ธ์ ์์ธ
-
- | ์ด๋ฆ | ์์ | ๊ถํ |
- | ${admin} | ${dept} | ๊ด๋ฆฌ์ |
-
-
-
-
์ต๊ทผ ํ๋
-
- | ์ ํ | ๋ด์ฉ | ์ผ์ |
- | ๋ก๊ทธ | ๋๊ธฐํ ์๋ฃ | ${logTime} |
-
-
-
-
-
`;
-}
-
-// --- ์ด๋ฒคํธ ํธ๋ค๋ฌ ---
-function toggleGroup(h) { h.parentElement.classList.toggle('active'); }
-function toggleAccordion(h) {
- const item = h.parentElement;
- item.parentElement.querySelectorAll('.accordion-item').forEach(el => { if (el !== item) el.classList.remove('active'); });
- item.classList.toggle('active');
-}
-
-function showActivityDetails(status) {
- const names = { active: '์ ์', warning: '์ฃผ์', stale: '๋ฐฉ์น', unknown: '๋ฐ์ดํฐ ์์' };
- const filtered = (projectActivityDetails || []).filter(d => d.status === status);
- document.getElementById('modalTitle').innerText = `${names[status]} ๋ชฉ๋ก (${filtered.length}๊ฐ)`;
- document.getElementById('modalTableBody').innerHTML = filtered.map(p => {
- const o = rawData.find(r => r[0] === p.name);
- return `| ${p.name} | ${o ? o[1] : "-"} | ${o ? o[2] : "-"} |
`;
- }).join('');
- ModalManager.open('activityDetailModal');
-}
-
-function scrollToProject(name) {
- ModalManager.close('activityDetailModal');
- const target = Array.from(document.querySelectorAll('.repo-title')).find(t => t.innerText.trim() === name.trim())?.closest('.accordion-header');
- if (target) {
- let p = target.parentElement;
- while (p && p !== document.body) {
- if (p.classList.contains('continent-group') || p.classList.contains('country-group')) p.classList.add('active');
- p = p.parentElement;
- }
- target.parentElement.classList.add('active');
- const pos = target.getBoundingClientRect().top + window.pageYOffset - 260;
- window.scrollTo({ top: pos, behavior: 'smooth' });
- target.style.backgroundColor = 'var(--primary-lv-1)';
- setTimeout(() => target.style.backgroundColor = '', 2000);
- }
-}
-
-// --- ํฌ๋กค๋ง ๋ฐ ์ธ์ฆ ์ ์ด ---
-async function syncData() {
- if (isCrawling) {
- if (confirm("ํฌ๋กค๋ง์ ์ค๋จํ์๊ฒ ์ต๋๊น?")) {
- const res = await fetch(API.STOP_SYNC);
- if ((await res.json()).success) document.getElementById('syncBtn').innerText = "์ค๋จ ์์ฒญ ์ค...";
- }
- return;
- }
- document.getElementById('authId').value = '';
- document.getElementById('authPw').value = '';
- document.getElementById('authErrorMessage').style.display = 'none';
- ModalManager.open('authModal');
-}
-
-async function submitAuth() {
- const id = document.getElementById('authId').value, pw = document.getElementById('authPw').value, err = document.getElementById('authErrorMessage');
- try {
- const res = await fetch(API.AUTH_CRAWL, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ user_id: id, password: pw }) });
- const data = await res.json();
- if (data.success) { ModalManager.close('authModal'); startCrawlProcess(); }
- else { err.innerText = "ํฌ๋กค๋ง์ ํ ์ ์์ต๋๋ค."; err.style.display = 'block'; }
- } catch { err.innerText = "์๋ฒ ์ฐ๊ฒฐ ์คํจ"; err.style.display = 'block'; }
-}
-
-async function startCrawlProcess() {
- isCrawling = true;
- const btn = document.getElementById('syncBtn'), logC = document.getElementById('logConsole'), logB = document.getElementById('logBody');
- btn.classList.add('loading'); btn.style.backgroundColor = 'var(--error-color)'; btn.innerHTML = ` ํฌ๋กค๋ง ์ค๋จ`;
- logC.style.display = 'block'; logB.innerHTML = '>>> ์์ง ์ด๊ธฐํ ์ค...
';
- try {
- const res = await fetch(API.SYNC);
- const reader = res.body.getReader(), decoder = new TextDecoder();
- while (true) {
- const { done, value } = await reader.read(); if (done) break;
- decoder.decode(value).split('\n').forEach(line => {
- if (line.startsWith('data: ')) {
- const p = JSON.parse(line.substring(6));
- if (p.type === 'log') {
- const div = document.createElement('div'); div.innerText = `[${new Date().toLocaleTimeString()}] ${p.message}`;
- logB.appendChild(div); logC.scrollTop = logC.scrollHeight;
- } else if (p.type === 'done') { init(); alert(`๋๊ธฐํ ์ข
๋ฃ`); logC.style.display = 'none'; }
- }
- });
- }
- } catch { alert("์คํธ๋ฆผ ๋๊น"); }
- finally { isCrawling = false; btn.classList.remove('loading'); btn.style.backgroundColor = ''; btn.innerHTML = ` ๋ฐ์ดํฐ ๋๊ธฐํ (ํฌ๋กค๋ง)`; }
-}
-
-document.addEventListener('DOMContentLoaded', init);
+/**
+ * Project Master Overseas Dashboard JS
+ * ๊ธฐ๋ฅ: ๋ฐ์ดํฐ ๋ก๋, ํ์ฑ๋ ๋ถ์, ์ธ์ฆ ๋ชจ๋ฌ ์ ์ด, ํฌ๋กค๋ง ๋๊ธฐํ ๋ฐ ์ค๋จ
+ */
+
+// --- ๊ธ๋ก๋ฒ ์ํ ๊ด๋ฆฌ ---
+let rawData = [];
+let projectActivityDetails = [];
+let isCrawling = false;
+
+const CONTINENT_ORDER = { "์์์": 1, "์ํ๋ฆฌ์นด": 2, "์๋ฉ๋ฆฌ์นด": 3, "์ง์ฌ": 4 };
+
+// --- ์ด๊ธฐํ ---
+async function init() {
+ console.log("Dashboard Initializing...");
+ if (!document.getElementById('projectAccordion')) return;
+
+ await loadAvailableDates();
+ await loadDataByDate();
+}
+
+// --- ๋ฐ์ดํฐ ํต์ ๋ฐ ๋ก๋ ---
+async function loadAvailableDates() {
+ try {
+ const response = await fetch(API.AVAILABLE_DATES);
+ const dates = await response.json();
+ if (dates?.length > 0) {
+ const selectHtml = `
+ `;
+ const baseDateStrong = document.getElementById('baseDate');
+ if (baseDateStrong) baseDateStrong.innerHTML = selectHtml;
+ }
+ } catch (e) { console.error("๋ ์ง ๋ก๋ ์คํจ:", e); }
+}
+
+async function loadDataByDate(selectedDate = "") {
+ try {
+ await loadActivityAnalysis(selectedDate);
+ const url = selectedDate ? `${API.PROJECT_DATA}?date=${selectedDate}` : `${API.PROJECT_DATA}?t=${Date.now()}`;
+ const response = await fetch(url);
+ const data = await response.json();
+ if (data.error) throw new Error(data.error);
+ rawData = data.projects || [];
+ renderDashboard(rawData);
+ } catch (e) {
+ console.error("๋ฐ์ดํฐ ๋ก๋ ์คํจ:", e);
+ alert("๋ฐ์ดํฐ๋ฅผ ๊ฐ์ ธ์ค๋ ๋ฐ ์คํจํ์ต๋๋ค.");
+ }
+}
+
+async function loadActivityAnalysis(date = "") {
+ const dashboard = document.getElementById('activityDashboard');
+ if (!dashboard) return;
+ try {
+ const url = date ? `${API.PROJECT_ACTIVITY}?date=${date}` : API.PROJECT_ACTIVITY;
+ const response = await fetch(url);
+ const data = await response.json();
+ if (data.error) return;
+ const { summary, details } = data;
+ projectActivityDetails = details;
+ dashboard.innerHTML = `
+
+
์ ์ (7์ผ ์ด๋ด)
${summary.active}
+
+
+
์ฃผ์ (14์ผ ์ด๋ด)
${summary.warning}
+
+
+
๋ฐฉ์น (14์ผ ์ด๊ณผ / ํด๋์๋์ญ์ )
${summary.stale}
+
+
+
๋ฐ์ดํฐ ์์ (ํ์ผ 0๊ฐ)
${summary.unknown}
+
`;
+ } catch (e) { console.error("๋ถ์ ๋ก๋ ์คํจ:", e); }
+}
+
+// --- ๋ ๋๋ง ์์ง ---
+function renderDashboard(data) {
+ const container = document.getElementById('projectAccordion');
+ container.innerHTML = '';
+ const grouped = groupData(data);
+ Object.keys(grouped).sort((a, b) => (CONTINENT_ORDER[a] || 99) - (CONTINENT_ORDER[b] || 99)).forEach(continent => {
+ const continentDiv = document.createElement('div');
+ continentDiv.className = 'continent-group active';
+ let html = ``;
+ Object.keys(grouped[continent]).sort().forEach(country => {
+ html += `
+
+ ${grouped[continent][country].sort((a, b) => a[0].localeCompare(b[0])).map(p => createProjectHtml(p)).join('')}
`;
+ });
+ html += `
`;
+ continentDiv.innerHTML = html;
+ container.appendChild(continentDiv);
+ });
+}
+
+function groupData(data) {
+ const res = {};
+ data.forEach(item => {
+ const c1 = item[5] || "๊ธฐํ", c2 = item[6] || "๋ฏธ๋ถ๋ฅ";
+ if (!res[c1]) res[c1] = {};
+ if (!res[c1][c2]) res[c1][c2] = [];
+ res[c1][c2].push(item);
+ });
+ return res;
+}
+
+function createProjectHtml(p) {
+ const [name, dept, admin, logRaw, files] = p;
+ const recentLog = (!logRaw || logRaw === "X" || logRaw === "๋ฐ์ดํฐ ์์") ? "๊ธฐ๋ก ์์" : logRaw;
+ const logTime = recentLog !== "๊ธฐ๋ก ์์" ? recentLog.split(',')[0] : "๊ธฐ๋ก ์์";
+
+ const isStaleLog = recentLog.replace(/\s/g, "").includes("ํด๋์๋์ญ์ ");
+ const isNoFiles = (files === 0 || files === null);
+ const statusClass = isNoFiles ? "status-error" : "";
+
+ let logStyleClass = "";
+ if (isStaleLog) logStyleClass = "error-text";
+ else if (recentLog === "๊ธฐ๋ก ์์") logStyleClass = "warning-text";
+
+ const logBoldStyle = isStaleLog ? 'font-weight: 800;' : '';
+
+ return `
+
+
+
+
+
+
์ฐธ์ฌ ์ธ์ ์์ธ
+
+ | ์ด๋ฆ | ์์ | ๊ถํ |
+ | ${admin} | ${dept} | ๊ด๋ฆฌ์ |
+
+
+
+
์ต๊ทผ ํ๋
+
+ | ์ ํ | ๋ด์ฉ | ์ผ์ |
+ | ๋ก๊ทธ | ๋๊ธฐํ ์๋ฃ | ${logTime} |
+
+
+
+
+
`;
+}
+
+// --- ์ด๋ฒคํธ ํธ๋ค๋ฌ ---
+function toggleGroup(h) { h.parentElement.classList.toggle('active'); }
+function toggleAccordion(h) {
+ const item = h.parentElement;
+ item.parentElement.querySelectorAll('.accordion-item').forEach(el => { if (el !== item) el.classList.remove('active'); });
+ item.classList.toggle('active');
+}
+
+function showActivityDetails(status) {
+ const names = { active: '์ ์', warning: '์ฃผ์', stale: '๋ฐฉ์น', unknown: '๋ฐ์ดํฐ ์์' };
+ const filtered = (projectActivityDetails || []).filter(d => d.status === status);
+ document.getElementById('modalTitle').innerText = `${names[status]} ๋ชฉ๋ก (${filtered.length}๊ฐ)`;
+ document.getElementById('modalTableBody').innerHTML = filtered.map(p => {
+ const o = rawData.find(r => r[0] === p.name);
+ return `| ${p.name} | ${o ? o[1] : "-"} | ${o ? o[2] : "-"} |
`;
+ }).join('');
+ ModalManager.open('activityDetailModal');
+}
+
+function scrollToProject(name) {
+ ModalManager.close('activityDetailModal');
+ const target = Array.from(document.querySelectorAll('.repo-title')).find(t => t.innerText.trim() === name.trim())?.closest('.accordion-header');
+ if (target) {
+ let p = target.parentElement;
+ while (p && p !== document.body) {
+ if (p.classList.contains('continent-group') || p.classList.contains('country-group')) p.classList.add('active');
+ p = p.parentElement;
+ }
+ target.parentElement.classList.add('active');
+ const pos = target.getBoundingClientRect().top + window.pageYOffset - 260;
+ window.scrollTo({ top: pos, behavior: 'smooth' });
+ target.style.backgroundColor = 'var(--primary-lv-1)';
+ setTimeout(() => target.style.backgroundColor = '', 2000);
+ }
+}
+
+// --- ํฌ๋กค๋ง ๋ฐ ์ธ์ฆ ์ ์ด ---
+async function syncData() {
+ if (isCrawling) {
+ if (confirm("ํฌ๋กค๋ง์ ์ค๋จํ์๊ฒ ์ต๋๊น?")) {
+ const res = await fetch(API.STOP_SYNC);
+ if ((await res.json()).success) document.getElementById('syncBtn').innerText = "์ค๋จ ์์ฒญ ์ค...";
+ }
+ return;
+ }
+ document.getElementById('authId').value = '';
+ document.getElementById('authPw').value = '';
+ document.getElementById('authErrorMessage').style.display = 'none';
+ ModalManager.open('authModal');
+}
+
+async function submitAuth() {
+ const id = document.getElementById('authId').value, pw = document.getElementById('authPw').value, err = document.getElementById('authErrorMessage');
+ try {
+ const res = await fetch(API.AUTH_CRAWL, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ user_id: id, password: pw }) });
+ const data = await res.json();
+ if (data.success) { ModalManager.close('authModal'); startCrawlProcess(); }
+ else { err.innerText = "ํฌ๋กค๋ง์ ํ ์ ์์ต๋๋ค."; err.style.display = 'block'; }
+ } catch { err.innerText = "์๋ฒ ์ฐ๊ฒฐ ์คํจ"; err.style.display = 'block'; }
+}
+
+async function startCrawlProcess() {
+ isCrawling = true;
+ const btn = document.getElementById('syncBtn'), logC = document.getElementById('logConsole'), logB = document.getElementById('logBody');
+ btn.classList.add('loading'); btn.style.backgroundColor = 'var(--error-color)'; btn.innerHTML = ` ํฌ๋กค๋ง ์ค๋จ`;
+ logC.style.display = 'block'; logB.innerHTML = '>>> ์์ง ์ด๊ธฐํ ์ค...
';
+ try {
+ const res = await fetch(API.SYNC);
+ const reader = res.body.getReader(), decoder = new TextDecoder();
+ while (true) {
+ const { done, value } = await reader.read(); if (done) break;
+ decoder.decode(value).split('\n').forEach(line => {
+ if (line.startsWith('data: ')) {
+ const p = JSON.parse(line.substring(6));
+ if (p.type === 'log') {
+ const div = document.createElement('div'); div.innerText = `[${new Date().toLocaleTimeString()}] ${p.message}`;
+ logB.appendChild(div); logC.scrollTop = logC.scrollHeight;
+ } else if (p.type === 'done') { init(); alert(`๋๊ธฐํ ์ข
๋ฃ`); logC.style.display = 'none'; }
+ }
+ });
+ }
+ } catch { alert("์คํธ๋ฆผ ๋๊น"); }
+ finally { isCrawling = false; btn.classList.remove('loading'); btn.style.backgroundColor = ''; btn.innerHTML = ` ๋ฐ์ดํฐ ๋๊ธฐํ (ํฌ๋กค๋ง)`; }
+}
+
+document.addEventListener('DOMContentLoaded', init);
diff --git a/js/dashboard_test.js b/js/dashboard_test.js
new file mode 100644
index 0000000..267e270
--- /dev/null
+++ b/js/dashboard_test.js
@@ -0,0 +1,245 @@
+/**
+ * Project Master Overseas Dashboard JS (TEST VERSION)
+ * ๊ธฐ๋ฅ: ๋ฐ์ดํฐ ๋ก๋, ํ์ฑ๋ ๋ถ์, ์ธ์ฆ ๋ชจ๋ฌ ์ ์ด, ํฌ๋กค๋ง ๋๊ธฐํ ๋ฐ ์ค๋จ
+ */
+
+// --- ๊ธ๋ก๋ฒ ์ํ ๊ด๋ฆฌ ---
+let rawData = [];
+let projectActivityDetails = [];
+let isCrawling = false;
+
+const CONTINENT_ORDER = { "์์์": 1, "์ํ๋ฆฌ์นด": 2, "์๋ฉ๋ฆฌ์นด": 3, "์ง์ฌ": 4 };
+
+// --- ์ด๊ธฐํ ---
+async function init() {
+ console.log("Dashboard (TEST) Initializing...");
+ if (!document.getElementById('projectAccordion')) return;
+
+ await loadAvailableDates();
+ await loadDataByDate();
+}
+
+// --- ๋ฐ์ดํฐ ํต์ ๋ฐ ๋ก๋ ---
+async function loadAvailableDates() {
+ try {
+ const response = await fetch(API.AVAILABLE_DATES);
+ const dates = await response.json(); // YYYY.MM.DD ํ์ ๋ฆฌ์คํธ
+
+ if (dates?.length > 0) {
+ // ๋ ์ง ํ์ ๋ณํ (YYYY.MM.DD -> YYYY-MM-DD)
+ const formattedDates = dates.map(d => d.replace(/\./g, '-')).sort();
+ const minDate = formattedDates[0];
+ const maxDate = new Date().toISOString().split('T')[0]; // ์ค๋
+ const defaultDate = formattedDates[formattedDates.length - 1]; // ๊ฐ์ฅ ์ต์ ์์ง์ผ
+
+ const dateInputHtml = `
+
+ `;
+ const baseDateStrong = document.getElementById('baseDate');
+ if (baseDateStrong) baseDateStrong.innerHTML = dateInputHtml;
+ }
+ } catch (e) { console.error("๋ ์ง ๋ก๋ ์คํจ:", e); }
+}
+
+async function loadDataByDate(selectedDate = "") {
+ try {
+ await loadActivityAnalysis(selectedDate);
+ const url = selectedDate ? `${API.PROJECT_DATA}?date=${selectedDate}` : `${API.PROJECT_DATA}?t=${Date.now()}`;
+ const response = await fetch(url);
+ const data = await response.json();
+ if (data.error) throw new Error(data.error);
+ rawData = data.projects || [];
+ renderDashboard(rawData);
+ } catch (e) {
+ console.error("๋ฐ์ดํฐ ๋ก๋ ์คํจ:", e);
+ alert("๋ฐ์ดํฐ๋ฅผ ๊ฐ์ ธ์ค๋ ๋ฐ ์คํจํ์ต๋๋ค.");
+ }
+}
+
+async function loadActivityAnalysis(date = "") {
+ const dashboard = document.getElementById('activityDashboard');
+ if (!dashboard) return;
+ try {
+ const url = date ? `${API.PROJECT_ACTIVITY}?date=${date}` : API.PROJECT_ACTIVITY;
+ const response = await fetch(url);
+ const data = await response.json();
+ if (data.error) return;
+ const { summary, details } = data;
+ projectActivityDetails = details;
+ dashboard.innerHTML = `
+
+
์ ์ (7์ผ ์ด๋ด) [TEST]
${summary.active}
+
+
+
์ฃผ์ (14์ผ ์ด๋ด) [TEST]
${summary.warning}
+
+
+
๋ฐฉ์น (14์ผ ์ด๊ณผ) [TEST]
${summary.stale}
+
+
+
๋ฐ์ดํฐ ์์ (ํ์ผ 0๊ฐ) [TEST]
${summary.unknown}
+
`;
+ } catch (e) { console.error("๋ถ์ ๋ก๋ ์คํจ:", e); }
+}
+
+// --- ๋ ๋๋ง ์์ง ---
+function renderDashboard(data) {
+ const container = document.getElementById('projectAccordion');
+ container.innerHTML = '';
+ const grouped = groupData(data);
+ Object.keys(grouped).sort((a, b) => (CONTINENT_ORDER[a] || 99) - (CONTINENT_ORDER[b] || 99)).forEach(continent => {
+ const continentDiv = document.createElement('div');
+ continentDiv.className = 'continent-group active';
+ let html = ``;
+ Object.keys(grouped[continent]).sort().forEach(country => {
+ html += `
+
+ ${grouped[continent][country].sort((a, b) => a[0].localeCompare(b[0])).map(p => createProjectHtml(p)).join('')}
`;
+ });
+ html += `
`;
+ continentDiv.innerHTML = html;
+ container.appendChild(continentDiv);
+ });
+}
+
+function groupData(data) {
+ const res = {};
+ data.forEach(item => {
+ const c1 = item[5] || "๊ธฐํ", c2 = item[6] || "๋ฏธ๋ถ๋ฅ";
+ if (!res[c1]) res[c1] = {};
+ if (!res[c1][c2]) res[c1][c2] = [];
+ res[c1][c2].push(item);
+ });
+ return res;
+}
+
+function createProjectHtml(p) {
+ const [name, dept, admin, logRaw, files] = p;
+ const recentLog = (!logRaw || logRaw === "X" || logRaw === "๋ฐ์ดํฐ ์์") ? "๊ธฐ๋ก ์์" : logRaw;
+ const logTime = recentLog !== "๊ธฐ๋ก ์์" ? recentLog.split(',')[0] : "๊ธฐ๋ก ์์";
+
+ const isStaleLog = recentLog.replace(/\s/g, "").includes("ํด๋์๋์ญ์ ");
+ const isNoFiles = (files === 0 || files === null);
+ const statusClass = isNoFiles ? "status-error" : "";
+
+ let logStyleClass = "";
+ if (isStaleLog) logStyleClass = "error-text";
+ else if (recentLog === "๊ธฐ๋ก ์์") logStyleClass = "warning-text";
+
+ const logBoldStyle = isStaleLog ? 'font-weight: 800;' : '';
+
+ return `
+
+
+
+
+
+
์ฐธ์ฌ ์ธ์ ์์ธ (TEST)
+
+ | ์ด๋ฆ | ์์ | ๊ถํ |
+ | ${admin} | ${dept} | ๊ด๋ฆฌ์ |
+
+
+
+
์ต๊ทผ ํ๋ (TEST)
+
+ | ์ ํ | ๋ด์ฉ | ์ผ์ |
+ | ๋ก๊ทธ | ๋๊ธฐํ ์๋ฃ | ${logTime} |
+
+
+
+
+
`;
+}
+
+// --- ์ด๋ฒคํธ ํธ๋ค๋ฌ ---
+function toggleGroup(h) { h.parentElement.classList.toggle('active'); }
+function toggleAccordion(h) {
+ const item = h.parentElement;
+ item.parentElement.querySelectorAll('.accordion-item').forEach(el => { if (el !== item) el.classList.remove('active'); });
+ item.classList.toggle('active');
+}
+
+function showActivityDetails(status) {
+ const names = { active: '์ ์', warning: '์ฃผ์', stale: '๋ฐฉ์น', unknown: '๋ฐ์ดํฐ ์์' };
+ const filtered = (projectActivityDetails || []).filter(d => d.status === status);
+ document.getElementById('modalTitle').innerText = `${names[status]} ๋ชฉ๋ก (${filtered.length}๊ฐ) [TEST]`;
+ document.getElementById('modalTableBody').innerHTML = filtered.map(p => {
+ const o = rawData.find(r => r[0] === p.name);
+ return `| ${p.name} | ${o ? o[1] : "-"} | ${o ? o[2] : "-"} |
`;
+ }).join('');
+ ModalManager.open('activityDetailModal');
+}
+
+function scrollToProject(name) {
+ ModalManager.close('activityDetailModal');
+ const target = Array.from(document.querySelectorAll('.repo-title')).find(t => t.innerText.trim() === name.trim())?.closest('.accordion-header');
+ if (target) {
+ let p = target.parentElement;
+ while (p && p !== document.body) {
+ if (p.classList.contains('continent-group') || p.classList.contains('country-group')) p.classList.add('active');
+ p = p.parentElement;
+ }
+ target.parentElement.classList.add('active');
+ const pos = target.getBoundingClientRect().top + window.pageYOffset - 260;
+ window.scrollTo({ top: pos, behavior: 'smooth' });
+ target.style.backgroundColor = 'var(--primary-lv-1)';
+ setTimeout(() => target.style.backgroundColor = '', 2000);
+ }
+}
+
+// --- ํฌ๋กค๋ง ๋ฐ ์ธ์ฆ ์ ์ด ---
+async function syncData() {
+ if (isCrawling) {
+ if (confirm("ํฌ๋กค๋ง์ ์ค๋จํ์๊ฒ ์ต๋๊น?")) {
+ const res = await fetch(API.STOP_SYNC);
+ if ((await res.json()).success) document.getElementById('syncBtn').innerText = "์ค๋จ ์์ฒญ ์ค...";
+ }
+ return;
+ }
+ document.getElementById('authId').value = '';
+ document.getElementById('authPw').value = '';
+ document.getElementById('authErrorMessage').style.display = 'none';
+ ModalManager.open('authModal');
+}
+
+async function submitAuth() {
+ const id = document.getElementById('authId').value, pw = document.getElementById('authPw').value, err = document.getElementById('authErrorMessage');
+ try {
+ const res = await fetch(API.AUTH_CRAWL, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ user_id: id, password: pw }) });
+ const data = await res.json();
+ if (data.success) { ModalManager.close('authModal'); startCrawlProcess(); }
+ else { err.innerText = "ํฌ๋กค๋ง์ ํ ์ ์์ต๋๋ค."; err.style.display = 'block'; }
+ } catch { err.innerText = "์๋ฒ ์ฐ๊ฒฐ ์คํจ"; err.style.display = 'block'; }
+}
+
+async function startCrawlProcess() {
+ isCrawling = true;
+ const btn = document.getElementById('syncBtn'), logC = document.getElementById('logConsole'), logB = document.getElementById('logBody');
+ btn.classList.add('loading'); btn.style.backgroundColor = 'var(--error-color)'; btn.innerHTML = ` ํฌ๋กค๋ง ์ค๋จ`;
+ logC.style.display = 'block'; logB.innerHTML = '>>> ์์ง ์ด๊ธฐํ ์ค...
';
+ try {
+ const res = await fetch(API.SYNC);
+ const reader = res.body.getReader(), decoder = new TextDecoder();
+ while (true) {
+ const { done, value } = await reader.read(); if (done) break;
+ decoder.decode(value).split('\n').forEach(line => {
+ if (line.startsWith('data: ')) {
+ const p = JSON.parse(line.substring(6));
+ if (p.type === 'log') {
+ const div = document.createElement('div'); div.innerText = `[${new Date().toLocaleTimeString()}] ${p.message}`;
+ logB.appendChild(div); logC.scrollTop = logC.scrollHeight;
+ } else if (p.type === 'done') { init(); alert(`๋๊ธฐํ ์ข
๋ฃ`); logC.style.display = 'none'; }
+ }
+ });
+ }
+ } catch { alert("์คํธ๋ฆผ ๋๊น"); }
+ finally { isCrawling = false; btn.classList.remove('loading'); btn.style.backgroundColor = ''; btn.innerHTML = ` ๋ฐ์ดํฐ ๋๊ธฐํ (ํฌ๋กค๋ง)`; }
+}
+
+document.addEventListener('DOMContentLoaded', init);
\ No newline at end of file
diff --git a/js/inquiries.js b/js/inquiries.js
index 74197d0..9e1f394 100644
--- a/js/inquiries.js
+++ b/js/inquiries.js
@@ -1,314 +1,314 @@
-/**
- * Project Master Overseas Inquiries JS
- * ๊ธฐ๋ฅ: ๋ฌธ์์ฌํญ ๋ก๋, ํํฐ๋ง, ๋ต๋ณ ๊ด๋ฆฌ, ์์ฝ๋์ธ ๋ฐ ์ด๋ฏธ์ง ๋ชจ๋ฌ
- */
-
-// --- ์ด๊ธฐํ ---
-let allInquiries = [];
-let currentSort = { field: 'no', direction: 'desc' };
-
-async function loadInquiries() {
- initStickyHeader();
-
- const pmType = document.getElementById('filterPmType').value;
- const category = document.getElementById('filterCategory').value;
- const keyword = document.getElementById('searchKeyword').value;
-
- const params = new URLSearchParams({
- pm_type: pmType,
- category: category,
- keyword: keyword
- });
-
- try {
- const response = await fetch(`${API.INQUIRIES}?${params}`);
- allInquiries = await response.json();
-
- refreshInquiryBoard();
- } catch (e) {
- console.error("๋ฐ์ดํฐ ๋ก๋ฉ ์ค ์ค๋ฅ ๋ฐ์:", e);
- }
-}
-
-function refreshInquiryBoard() {
- const status = document.getElementById('filterStatus').value;
-
- // 1. ์ํ ํํฐ๋ง
- let filteredData = status ? allInquiries.filter(item => item.status === status) : [...allInquiries];
-
- // 2. ์ ๋ ฌ ์ ์ฉ
- filteredData = sortData(filteredData);
-
- // 3. ํต๊ณ ๋ฐ ๋ฆฌ์คํธ ๋ ๋๋ง
- updateStats(allInquiries);
- updateSortUI();
- renderInquiryList(filteredData);
-}
-
-function handleSort(field) {
- if (currentSort.field === field) {
- currentSort.direction = currentSort.direction === 'asc' ? 'desc' : 'asc';
- } else {
- currentSort.field = field;
- currentSort.direction = 'asc';
- }
- refreshInquiryBoard();
-}
-
-function sortData(data) {
- const { field, direction } = currentSort;
- const modifier = direction === 'asc' ? 1 : -1;
-
- return data.sort((a, b) => {
- let valA = a[field];
- let valB = b[field];
-
- // ์ซ์ํ ๋ณํ ์๋ (No ํ๋ ๋ฑ)
- if (field === 'no' || !isNaN(valA)) {
- valA = Number(valA);
- valB = Number(valB);
- }
-
- // null/undefined ์ฒ๋ฆฌ
- if (valA === null || valA === undefined) valA = "";
- if (valB === null || valB === undefined) valB = "";
-
- if (valA < valB) return -1 * modifier;
- if (valA > valB) return 1 * modifier;
- return 0;
- });
-}
-
-function updateSortUI() {
- // ๋ชจ๋ ํค๋ ํด๋์ค ๋ฐ ์์ด์ฝ ์ด๊ธฐํ
- document.querySelectorAll('.inquiry-table thead th.sortable').forEach(th => {
- th.classList.remove('active-sort');
- const icon = th.querySelector('.sort-icon');
- if (icon) {
- // ๋ ์ด์์ ์ํํธ ๋ฐฉ์ง๋ฅผ ์ํด ํฌ๋ช
ํ ๊ธฐ๋ณธ ์์ด์ฝ(๋๋ ๊ณต๋ฐฑ) ์ ์ง
- icon.textContent = "โฒ";
- icon.style.opacity = "0";
- }
- });
-
- // ํ์ฌ ์ ๋ ฌ๋ ํค๋ ๊ฐ์กฐ ๋ฐ ์์ด์ฝ ํ์
- const activeTh = document.querySelector(`.inquiry-table thead th[onclick*="'${currentSort.field}'"]`);
- if (activeTh) {
- activeTh.classList.add('active-sort');
- const icon = activeTh.querySelector('.sort-icon');
- if (icon) {
- icon.textContent = currentSort.direction === 'asc' ? "โฒ" : "โผ";
- icon.style.opacity = "1";
- }
- }
-}
-
-function initStickyHeader() {
- const header = document.getElementById('stickyHeader');
- const thead = document.querySelector('.inquiry-table thead');
- if (header && thead) {
- const headerHeight = header.offsetHeight;
- const totalOffset = 36 + headerHeight;
- document.querySelectorAll('.inquiry-table thead th').forEach(th => {
- th.style.top = totalOffset + 'px';
- });
- }
-}
-
-function renderInquiryList(data) {
- const tbody = document.getElementById('inquiryList');
- tbody.innerHTML = data.map(item => `
-
- | ${item.no} |
-
- ${item.image_url ? ` ` : '์์'}
- |
- ${item.pm_type} |
- ${item.browser || 'Chrome'} |
- ${item.category} |
- ${item.project_nm} |
- ${item.content} |
- ${item.author} |
- ${item.reg_date} |
- ${item.reply || '-'} |
- ${item.status} |
-
-
-
-
-
-
-
-
-
- [์ง๋ฌธ ๋ด์ฉ]
- ${item.content}
-
-
- ${item.image_url ? `
-
-
-
- 
-
-
- ` : ''}
-
-
- [์กฐ์น ๋ฐ ๋ต๋ณ]
-
-
-
-
- |
-
- `).join('');
-}
-
-function enableEdit(id) {
- const form = document.getElementById(`reply-form-${id}`);
- form.classList.replace('readonly', 'editable');
- const elements = [`reply-text-${id}`, `reply-status-${id}`, `reply-handler-${id}`];
- elements.forEach(elId => document.getElementById(elId).disabled = false);
- document.getElementById(`reply-text-${id}`).focus();
-}
-
-async function cancelEdit(id) {
- try {
- const response = await fetch(`${API.INQUIRIES}/${id}`);
- const item = await response.json();
- const txt = document.getElementById(`reply-text-${id}`);
- const status = document.getElementById(`reply-status-${id}`);
- const handler = document.getElementById(`reply-handler-${id}`);
- txt.value = item.reply || '';
- status.value = item.status;
- handler.value = item.handler || '';
- [txt, status, handler].forEach(el => el.disabled = true);
- document.getElementById(`reply-form-${id}`).classList.replace('editable', 'readonly');
- } catch { loadInquiries(); }
-}
-
-async function saveReply(id) {
- const reply = document.getElementById(`reply-text-${id}`).value;
- const status = document.getElementById(`reply-status-${id}`).value;
- const handler = document.getElementById(`reply-handler-${id}`).value;
- if (!reply.trim() || !handler.trim()) return alert("๋ด์ฉ๊ณผ ์ฒ๋ฆฌ์๋ฅผ ๋ชจ๋ ์
๋ ฅํด ์ฃผ์ธ์.");
- try {
- const response = await fetch(`${API.INQUIRIES}/${id}/reply`, {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ reply, status, handler })
- });
- if ((await response.json()).success) { alert("์ ์ฅ๋์์ต๋๋ค."); loadInquiries(); }
- } catch { alert("์ ์ฅ ์ค ์ค๋ฅ๊ฐ ๋ฐ์ํ์ต๋๋ค."); }
-}
-
-async function deleteReply(id) {
- if (!confirm("๋ต๋ณ์ ์ญ์ ํ์๊ฒ ์ต๋๊น?")) return;
- try {
- const response = await fetch(`${API.INQUIRIES}/${id}/reply`, { method: 'DELETE' });
- if ((await response.json()).success) { alert("์ญ์ ๋์์ต๋๋ค."); loadInquiries(); }
- } catch { alert("์ญ์ ์ค ์ค๋ฅ๊ฐ ๋ฐ์ํ์ต๋๋ค."); }
-}
-
-function toggleAccordion(id) {
- const detailRow = document.getElementById(`detail-${id}`);
- if (!detailRow) return;
- const inquiryRow = detailRow.previousElementSibling;
- const isActive = detailRow.classList.contains('active');
-
- document.querySelectorAll('.detail-row.active').forEach(row => {
- if (row.id !== `detail-${id}`) {
- row.classList.remove('active');
- if (row.previousElementSibling) row.previousElementSibling.classList.remove('active-row');
- }
- });
-
- if (isActive) {
- detailRow.classList.remove('active');
- inquiryRow.classList.remove('active-row');
- } else {
- detailRow.classList.add('active');
- inquiryRow.classList.add('active-row');
- scrollToRow(inquiryRow);
- }
-}
-
-function scrollToRow(row) {
- setTimeout(() => {
- const headerHeight = document.getElementById('stickyHeader').offsetHeight;
- const totalOffset = 36 + headerHeight + 40;
- const offsetPosition = (row.getBoundingClientRect().top + window.pageYOffset) - totalOffset;
- window.scrollTo({ top: offsetPosition, behavior: 'smooth' });
- }, 100);
-}
-
-function updateStats(data) {
- const counts = {
- Total: data.length,
- Complete: data.filter(i => i.status === '์๋ฃ').length,
- Working: data.filter(i => i.status === '์์
์ค').length,
- Checking: data.filter(i => i.status === 'ํ์ธ ์ค').length,
- Pending: data.filter(i => i.status === '๊ฐ๋ฐ์์ ').length,
- Unconfirmed: data.filter(i => i.status === '๋ฏธํ์ธ').length
- };
- Object.keys(counts).forEach(k => {
- const el = document.getElementById(`count${k}`);
- if (el) el.textContent = counts[k].toLocaleString();
- });
-}
-
-function openImageModal(src) {
- document.getElementById('modalImage').src = src;
- ModalManager.open('imageModal');
-}
-
-function toggleImageSection(id) {
- const section = document.getElementById(`img-section-${id}`);
- const content = document.getElementById(`img-content-${id}`);
- const icon = section.querySelector('.toggle-icon');
- const isCollapsed = content.classList.toggle('collapsed');
- section.classList.toggle('active', !isCollapsed);
- icon.textContent = isCollapsed ? 'โผ' : 'โฒ';
-}
-
-document.addEventListener('DOMContentLoaded', loadInquiries);
-window.addEventListener('resize', initStickyHeader);
+/**
+ * Project Master Overseas Inquiries JS
+ * ๊ธฐ๋ฅ: ๋ฌธ์์ฌํญ ๋ก๋, ํํฐ๋ง, ๋ต๋ณ ๊ด๋ฆฌ, ์์ฝ๋์ธ ๋ฐ ์ด๋ฏธ์ง ๋ชจ๋ฌ
+ */
+
+// --- ์ด๊ธฐํ ---
+let allInquiries = [];
+let currentSort = { field: 'no', direction: 'desc' };
+
+async function loadInquiries() {
+ initStickyHeader();
+
+ const pmType = document.getElementById('filterPmType').value;
+ const category = document.getElementById('filterCategory').value;
+ const keyword = document.getElementById('searchKeyword').value;
+
+ const params = new URLSearchParams({
+ pm_type: pmType,
+ category: category,
+ keyword: keyword
+ });
+
+ try {
+ const response = await fetch(`${API.INQUIRIES}?${params}`);
+ allInquiries = await response.json();
+
+ refreshInquiryBoard();
+ } catch (e) {
+ console.error("๋ฐ์ดํฐ ๋ก๋ฉ ์ค ์ค๋ฅ ๋ฐ์:", e);
+ }
+}
+
+function refreshInquiryBoard() {
+ const status = document.getElementById('filterStatus').value;
+
+ // 1. ์ํ ํํฐ๋ง
+ let filteredData = status ? allInquiries.filter(item => item.status === status) : [...allInquiries];
+
+ // 2. ์ ๋ ฌ ์ ์ฉ
+ filteredData = sortData(filteredData);
+
+ // 3. ํต๊ณ ๋ฐ ๋ฆฌ์คํธ ๋ ๋๋ง
+ updateStats(allInquiries);
+ updateSortUI();
+ renderInquiryList(filteredData);
+}
+
+function handleSort(field) {
+ if (currentSort.field === field) {
+ currentSort.direction = currentSort.direction === 'asc' ? 'desc' : 'asc';
+ } else {
+ currentSort.field = field;
+ currentSort.direction = 'asc';
+ }
+ refreshInquiryBoard();
+}
+
+function sortData(data) {
+ const { field, direction } = currentSort;
+ const modifier = direction === 'asc' ? 1 : -1;
+
+ return data.sort((a, b) => {
+ let valA = a[field];
+ let valB = b[field];
+
+ // ์ซ์ํ ๋ณํ ์๋ (No ํ๋ ๋ฑ)
+ if (field === 'no' || !isNaN(valA)) {
+ valA = Number(valA);
+ valB = Number(valB);
+ }
+
+ // null/undefined ์ฒ๋ฆฌ
+ if (valA === null || valA === undefined) valA = "";
+ if (valB === null || valB === undefined) valB = "";
+
+ if (valA < valB) return -1 * modifier;
+ if (valA > valB) return 1 * modifier;
+ return 0;
+ });
+}
+
+function updateSortUI() {
+ // ๋ชจ๋ ํค๋ ํด๋์ค ๋ฐ ์์ด์ฝ ์ด๊ธฐํ
+ document.querySelectorAll('.inquiry-table thead th.sortable').forEach(th => {
+ th.classList.remove('active-sort');
+ const icon = th.querySelector('.sort-icon');
+ if (icon) {
+ // ๋ ์ด์์ ์ํํธ ๋ฐฉ์ง๋ฅผ ์ํด ํฌ๋ช
ํ ๊ธฐ๋ณธ ์์ด์ฝ(๋๋ ๊ณต๋ฐฑ) ์ ์ง
+ icon.textContent = "โฒ";
+ icon.style.opacity = "0";
+ }
+ });
+
+ // ํ์ฌ ์ ๋ ฌ๋ ํค๋ ๊ฐ์กฐ ๋ฐ ์์ด์ฝ ํ์
+ const activeTh = document.querySelector(`.inquiry-table thead th[onclick*="'${currentSort.field}'"]`);
+ if (activeTh) {
+ activeTh.classList.add('active-sort');
+ const icon = activeTh.querySelector('.sort-icon');
+ if (icon) {
+ icon.textContent = currentSort.direction === 'asc' ? "โฒ" : "โผ";
+ icon.style.opacity = "1";
+ }
+ }
+}
+
+function initStickyHeader() {
+ const header = document.getElementById('stickyHeader');
+ const thead = document.querySelector('.inquiry-table thead');
+ if (header && thead) {
+ const headerHeight = header.offsetHeight;
+ const totalOffset = 36 + headerHeight;
+ document.querySelectorAll('.inquiry-table thead th').forEach(th => {
+ th.style.top = totalOffset + 'px';
+ });
+ }
+}
+
+function renderInquiryList(data) {
+ const tbody = document.getElementById('inquiryList');
+ tbody.innerHTML = data.map(item => `
+
+ | ${item.no} |
+
+ ${item.image_url ? ` ` : '์์'}
+ |
+ ${item.pm_type} |
+ ${item.browser || 'Chrome'} |
+ ${item.category} |
+ ${item.project_nm} |
+ ${item.content} |
+ ${item.author} |
+ ${item.reg_date} |
+ ${item.reply || '-'} |
+ ${item.status} |
+
+
+
+
+
+
+
+
+
+ [์ง๋ฌธ ๋ด์ฉ]
+ ${item.content}
+
+
+ ${item.image_url ? `
+
+
+
+ 
+
+
+ ` : ''}
+
+
+ [์กฐ์น ๋ฐ ๋ต๋ณ]
+
+
+
+
+ |
+
+ `).join('');
+}
+
+function enableEdit(id) {
+ const form = document.getElementById(`reply-form-${id}`);
+ form.classList.replace('readonly', 'editable');
+ const elements = [`reply-text-${id}`, `reply-status-${id}`, `reply-handler-${id}`];
+ elements.forEach(elId => document.getElementById(elId).disabled = false);
+ document.getElementById(`reply-text-${id}`).focus();
+}
+
+async function cancelEdit(id) {
+ try {
+ const response = await fetch(`${API.INQUIRIES}/${id}`);
+ const item = await response.json();
+ const txt = document.getElementById(`reply-text-${id}`);
+ const status = document.getElementById(`reply-status-${id}`);
+ const handler = document.getElementById(`reply-handler-${id}`);
+ txt.value = item.reply || '';
+ status.value = item.status;
+ handler.value = item.handler || '';
+ [txt, status, handler].forEach(el => el.disabled = true);
+ document.getElementById(`reply-form-${id}`).classList.replace('editable', 'readonly');
+ } catch { loadInquiries(); }
+}
+
+async function saveReply(id) {
+ const reply = document.getElementById(`reply-text-${id}`).value;
+ const status = document.getElementById(`reply-status-${id}`).value;
+ const handler = document.getElementById(`reply-handler-${id}`).value;
+ if (!reply.trim() || !handler.trim()) return alert("๋ด์ฉ๊ณผ ์ฒ๋ฆฌ์๋ฅผ ๋ชจ๋ ์
๋ ฅํด ์ฃผ์ธ์.");
+ try {
+ const response = await fetch(`${API.INQUIRIES}/${id}/reply`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ reply, status, handler })
+ });
+ if ((await response.json()).success) { alert("์ ์ฅ๋์์ต๋๋ค."); loadInquiries(); }
+ } catch { alert("์ ์ฅ ์ค ์ค๋ฅ๊ฐ ๋ฐ์ํ์ต๋๋ค."); }
+}
+
+async function deleteReply(id) {
+ if (!confirm("๋ต๋ณ์ ์ญ์ ํ์๊ฒ ์ต๋๊น?")) return;
+ try {
+ const response = await fetch(`${API.INQUIRIES}/${id}/reply`, { method: 'DELETE' });
+ if ((await response.json()).success) { alert("์ญ์ ๋์์ต๋๋ค."); loadInquiries(); }
+ } catch { alert("์ญ์ ์ค ์ค๋ฅ๊ฐ ๋ฐ์ํ์ต๋๋ค."); }
+}
+
+function toggleAccordion(id) {
+ const detailRow = document.getElementById(`detail-${id}`);
+ if (!detailRow) return;
+ const inquiryRow = detailRow.previousElementSibling;
+ const isActive = detailRow.classList.contains('active');
+
+ document.querySelectorAll('.detail-row.active').forEach(row => {
+ if (row.id !== `detail-${id}`) {
+ row.classList.remove('active');
+ if (row.previousElementSibling) row.previousElementSibling.classList.remove('active-row');
+ }
+ });
+
+ if (isActive) {
+ detailRow.classList.remove('active');
+ inquiryRow.classList.remove('active-row');
+ } else {
+ detailRow.classList.add('active');
+ inquiryRow.classList.add('active-row');
+ scrollToRow(inquiryRow);
+ }
+}
+
+function scrollToRow(row) {
+ setTimeout(() => {
+ const headerHeight = document.getElementById('stickyHeader').offsetHeight;
+ const totalOffset = 36 + headerHeight + 40;
+ const offsetPosition = (row.getBoundingClientRect().top + window.pageYOffset) - totalOffset;
+ window.scrollTo({ top: offsetPosition, behavior: 'smooth' });
+ }, 100);
+}
+
+function updateStats(data) {
+ const counts = {
+ Total: data.length,
+ Complete: data.filter(i => i.status === '์๋ฃ').length,
+ Working: data.filter(i => i.status === '์์
์ค').length,
+ Checking: data.filter(i => i.status === 'ํ์ธ ์ค').length,
+ Pending: data.filter(i => i.status === '๊ฐ๋ฐ์์ ').length,
+ Unconfirmed: data.filter(i => i.status === '๋ฏธํ์ธ').length
+ };
+ Object.keys(counts).forEach(k => {
+ const el = document.getElementById(`count${k}`);
+ if (el) el.textContent = counts[k].toLocaleString();
+ });
+}
+
+function openImageModal(src) {
+ document.getElementById('modalImage').src = src;
+ ModalManager.open('imageModal');
+}
+
+function toggleImageSection(id) {
+ const section = document.getElementById(`img-section-${id}`);
+ const content = document.getElementById(`img-content-${id}`);
+ const icon = section.querySelector('.toggle-icon');
+ const isCollapsed = content.classList.toggle('collapsed');
+ section.classList.toggle('active', !isCollapsed);
+ icon.textContent = isCollapsed ? 'โผ' : 'โฒ';
+}
+
+document.addEventListener('DOMContentLoaded', loadInquiries);
+window.addEventListener('resize', initStickyHeader);
diff --git a/js/mail.js b/js/mail.js
index f3d1898..a4d512b 100644
--- a/js/mail.js
+++ b/js/mail.js
@@ -1,312 +1,312 @@
-/**
- * Project Master Overseas Mail Management JS
- * ๊ธฐ๋ฅ: ์ฒจ๋ถํ์ผ ๋ก๋, AI ๋ถ์, ๋ฉ์ผ ๋ชฉ๋ก ๋ ๋๋ง, ๋ฏธ๋ฆฌ๋ณด๊ธฐ, ์ฃผ์๋ก ๊ด๋ฆฌ
- */
-
-let currentFiles = [];
-let editingIndex = -1;
-
-const HIERARCHY = {
- "ํ์ ": { "๊ณ์ฝ": ["๊ณ์ฝ๊ด๋ฆฌ", "๊ธฐ์ฑ๊ด๋ฆฌ", "์
๋ฌด์ง์์", "์ธ์๊ด๋ฆฌ"], "์
๋ฌด๊ด๋ฆฌ": ["์
๋ฌด์ผ์ง(2025)", "์
๋ฌด์ผ์ง(2025๋
์ด์ )", "๋ฐ์ฃผ์ฒ ์ ๊ธฐ๋ณด๊ณ ", "๋ณธ์ฌ์
๋ฌด๋ณด๊ณ ", "๊ณต์ฌ๊ฐ๋
์ผ์ง", "์์์๋ฅ"] },
- "์ค๊ณ์ฑ๊ณผํ": { "์๋ฐฉ์": ["๊ณต์ฌ์๋ฐฉ์"], "์ค๊ณ๋๋ฉด": ["๊ณตํต", "ํ ๊ณต", "๋นํ๋ฉด์์ ๊ณต", "๋ฐฐ์๊ณต", "๊ต๋๊ณต", "ํฌ์ฅ๊ณต"], "์๋์ฐ์ถ์": ["ํ ๊ณต", "๋ฐฐ์๊ณต"], "๋ด์ญ์": ["๋จ๊ฐ์ฐ์ถ์"], "๋ณด๊ณ ์": ["์ค์์ค๊ณ๋ณด๊ณ ์", "์ง๋ฐ์กฐ์ฌ๋ณด๊ณ ์"], "์ธก๋๊ณ์ฐ๋ถ": ["์ธก๋๊ณ์ฐ๋ถ"], "์ค๊ณ๋จ๊ณ ์ํํ์": ["ํ์ยทํ์"] },
- "์๊ณต๊ฒ์ธก": { "ํ ๊ณต": ["๊ฒ์ธก (๊นจ๊ธฐ)", "๊ฒ์ธก (๋
ธ์ฒด)"], "๋ฐฐ์๊ณต": ["๊ฒ์ธก (Vํ์ธก๊ตฌ)", "๊ฒ์ธก (์ข
๋ฐฐ์๊ด)"], "๊ตฌ์กฐ๋ฌผ๊ณต": ["๊ฒ์ธก (ํ๋ชฉ๊ต)"], "ํฌ์ฅ๊ณต": ["๊ฒ์ธก (๊ธฐ์ธต)"] },
- "์ค๊ณ๋ณ๊ฒฝ": { "์ค์ ๋ณด๊ณ ": ["ํ ๊ณต", "๋ฐฐ์๊ณต", "์์ ๊ด๋ฆฌ"], "๊ธฐ์ ์ง์ ๊ฒํ ": ["ํ ๊ณต", "๊ตฌ์กฐ๋ฌผ&๋ถ๋๊ณต"] }
-};
-
-const MAIL_SAMPLES = {
- inbound: [
- { person: "๋ผ์ค์ค ๋๋ฆผ๋ถ", email: "pany.s@lao.gov.la", time: "2026-03-05", title: "ITTC ๊ต์ก์ผํฐ ์ฐฉ๊ณต์ ์ผ์ ํ์", summary: "์ฐฉ๊ณต์ ๊ด๋ จํ์ฌ ์ ๋ถ ์ธก ์ธ์ฌ์ ์ผ์ ์ ๋ฐ์ํ ์ต์ข
๊ณต๋ฌธ์ ์ก๋ถํฉ๋๋ค.", active: true },
- { person: "ํ๋๊ฑด์ค (๊น์ฒ ์ ์์ฅ)", email: "cs.kim@hdec.co.kr", time: "2026-03-04", title: "[๊ธด๊ธ] ์ด์ฒ-๊ณต์ฃผ(4์ฐจ) ํ๋๊ธ ๋ณ๊ฒฝ๊ณ์ฝ ํต๋ณด", summary: "์ฒ ๊ฑฐ๊ณต์ฌ ๋ฌผ๋ ๋ณ๋์ ๋ฐ๋ฅธ ๊ณ์ฝ ๊ธ์ก ์กฐ์ ๊ฑด์
๋๋ค. ๊ฒํ ํ ์น์ธ ๋ถํ๋๋ฆฝ๋๋ค.", active: false }
- ],
- outbound: [
- { person: "๊ณต์ฌ๊ด๋ฆฌ๋ถ (๋ณธ์ฌ)", email: "hq_pm@projectmaster.com", time: "2026-03-04", title: "์ด์ฒ-๊ณต์ฃผ 2์ ์๊ฐ ๊ณต์ ๋ณด๊ณ ์ ์ ์ถ", summary: "2์ ํ ๋ฌ๊ฐ์ ์ฃผ์ ๊ณต์ ๋ฐ ์์ฐ ์งํ ํํฉ ๋ณด๊ณ ์์
๋๋ค.", active: false }
- ],
- drafts: [], deleted: []
-};
-
-let currentMailTab = 'inbound';
-let filteredMails = [];
-
-// --- ์ฒจ๋ถํ์ผ ๋ฐ์ดํฐ ๋ก๋ ๋ฐ ๋ ๋๋ง ---
-async function loadAttachments() {
- try {
- const res = await fetch(API.ATTACHMENTS);
- currentFiles = await res.json();
- renderFiles();
- } catch (e) { console.error("Failed to load attachments:", e); }
-}
-
-function renderFiles() {
- const isAiActive = document.getElementById('aiToggle').checked;
- const container = document.getElementById('attachmentList');
- if (!container) return;
- container.innerHTML = '';
-
- currentFiles.forEach((file, index) => {
- const item = document.createElement('div');
- item.className = 'attachment-item-wrap';
- item.style.marginBottom = "8px";
-
- let pathText = "๊ฒฝ๋ก๋ฅผ ์ ํํด์ฃผ์ธ์";
- let modeClass = "manual-mode";
-
- if (file.analysis) {
- const prefix = file.analysis.isManual ? "์ ํ ๊ฒฝ๋ก: " : "์ถ์ฒ: ";
- pathText = `${prefix}${file.analysis.suggested_path}`;
- modeClass = file.analysis.isManual ? "manual-mode" : "smart-mode";
- } else if (isAiActive) {
- pathText = "AI ๋ถ์ ๋๊ธฐ ์ค...";
- modeClass = "smart-mode";
- }
-
- item.innerHTML = `
-
-
๐
-
-
${file.name}
-
${file.size}
-
-
- ${pathText}
- ${isAiActive ? `` : ''}
-
-
-
-
- `;
- container.appendChild(item);
- });
-}
-
-// --- AI ๋ถ์ ์คํ ---
-async function startAnalysis(index, event) {
- if (event) event.stopPropagation();
- const file = currentFiles[index];
- if (!file) return;
-
- // UI ์ํ ์
๋ฐ์ดํธ: ๋ถ์ ์ค ํ์
- const logArea = document.getElementById(`log-area-${index}`);
- const logContent = document.getElementById(`log-content-${index}`);
- if (logArea) logArea.classList.add('active');
- if (logContent) {
- logContent.innerHTML = `
-
- AI๊ฐ ๋ฌธ์๋ฅผ ์ ๋ฐ ๋ถ์ ์ค์
๋๋ค...
-
`;
- }
-
- try {
- const res = await fetch(`${API.ANALYZE_FILE}?filename=${encodeURIComponent(file.name)}`);
- const result = await res.json();
-
- if (result.error) {
- if (logContent) logContent.innerHTML = `์ค๋ฅ: ${result.error}
`;
- return;
- }
-
- // ๋ถ์ ๊ฒฐ๊ณผ ์ ์ฅ ๋ฐ UI ๊ฐฑ์
- currentFiles[index].analysis = result.final_result;
- currentFiles[index].analysis.isManual = false;
-
- if (logContent) {
- logContent.innerHTML = `
-
-
โจ AI ๋ถ์ ์๋ฃ
-
${result.final_result.reason}
-
- `;
- }
-
- renderFiles();
- } catch (e) {
- console.error("AI Analysis failed:", e);
- if (logContent) logContent.innerHTML = `๋ถ์ ์คํจ: ๋คํธ์ํฌ ์ค๋ฅ๊ฐ ๋ฐ์ํ์ต๋๋ค.
`;
- }
-}
-
-// --- ๋ฏธ๋ฆฌ๋ณด๊ธฐ ์ ์ด ---
-function showPreview(index, event) {
- if (event && (event.target.closest('.btn-group') || event.target.closest('.path-display'))) return;
- const file = currentFiles[index];
- if (!file) return;
-
- const previewArea = document.getElementById('mailPreviewArea');
- const toggleIcon = document.getElementById('previewToggleIcon');
- const fullViewBtn = document.getElementById('fullViewBtn');
- const previewContainer = document.getElementById('previewContainer');
-
- if (previewArea) {
- previewArea.classList.add('active');
- if (toggleIcon) toggleIcon.innerText = 'โถ';
- }
-
- const fileUrl = Utils.getSafeFileUrl(file.name);
- if (fullViewBtn) {
- fullViewBtn.style.display = 'block';
- fullViewBtn.onclick = () => window.open(fileUrl, 'PMFullView', 'width=1000,height=800');
- }
-
- if (file.name.toLowerCase().endsWith('.pdf')) {
- previewContainer.innerHTML = ``;
- } else {
- previewContainer.innerHTML = `
${file.name}
`;
- }
-
- document.querySelectorAll('.attachment-item').forEach(item => item.classList.remove('active'));
- if (event?.currentTarget) event.currentTarget.classList.add('active');
-}
-
-function togglePreviewAuto() {
- const area = document.getElementById('mailPreviewArea');
- const icon = document.getElementById('previewToggleIcon');
- if (area) {
- const isActive = area.classList.toggle('active');
- if (icon) icon.innerText = isActive ? 'โถ' : 'โ';
- }
-}
-
-// --- ๋ฉ์ผ ๋ฆฌ์คํธ ์ ์ด ---
-function renderMailList(tabType, mailsToShow = null) {
- currentMailTab = tabType;
- const container = document.querySelector('.mail-items-container');
- if (!container) return;
-
- const mails = mailsToShow || MAIL_SAMPLES[tabType] || [];
- filteredMails = mails;
- updateBulkActionBar();
-
- container.innerHTML = mails.map((mail, idx) => `
-
-
-
-
-
${mail.person}
-
${mail.time}
-
-
${mail.title}
-
${mail.summary}
-
-
- `).join('');
-
- const activeIdx = mails.findIndex(m => m.active);
- if (activeIdx !== -1) updateMailContent(mails[activeIdx]);
-}
-
-function selectMailItem(el, index) {
- document.querySelectorAll('.mail-item').forEach(item => item.classList.remove('active'));
- el.classList.add('active');
- const mail = filteredMails[index];
- if (mail) updateMailContent(mail);
-}
-
-function updateMailContent(mail) {
- const title = document.querySelector('.mail-content-header h2');
- if (title) title.innerText = mail.title;
- document.querySelector('.mail-body').innerHTML = mail.summary.replace(/\n/g, '
') + "
๋ณธ ๋ด์ฉ์ ์ํ ๋ฐ์ดํฐ์
๋๋ค.";
-}
-
-function switchMailTab(el, tabType) {
- document.querySelectorAll('.mail-tab').forEach(tab => tab.classList.remove('active'));
- el.classList.add('active');
- renderMailList(tabType);
-}
-
-// --- ๊ฒฝ๋ก ์ ํ ๋ชจ๋ฌ ---
-function openPathModal(index, event) {
- if (event) event.stopPropagation();
- editingIndex = index;
- const tabSelect = document.getElementById('tabSelect');
- if (tabSelect) {
- tabSelect.innerHTML = Object.keys(HIERARCHY).map(tab => ``).join('');
- updateCategories();
- ModalManager.open('pathModal');
- }
-}
-
-function updateCategories() {
- const tab = document.getElementById('tabSelect').value;
- document.getElementById('categorySelect').innerHTML = Object.keys(HIERARCHY[tab]).map(cat => ``).join('');
- updateSubs();
-}
-
-function updateSubs() {
- const tab = document.getElementById('tabSelect').value;
- const cat = document.getElementById('categorySelect').value;
- document.getElementById('subSelect').innerHTML = HIERARCHY[tab][cat].map(sub => ``).join('');
-}
-
-function applyPathSelection() {
- const path = `${document.getElementById('tabSelect').value} > ${document.getElementById('categorySelect').value} > ${document.getElementById('subSelect').value}`;
- if (!currentFiles[editingIndex].analysis) currentFiles[editingIndex].analysis = {};
- currentFiles[editingIndex].analysis.suggested_path = path;
- currentFiles[editingIndex].analysis.isManual = true;
- renderFiles();
- ModalManager.close('pathModal');
-}
-
-// --- ์ฃผ์๋ก ๊ด๋ฆฌ ---
-let addressBookData = [
- { name: "์ดํํ", dept: "PM Overseas / ์ ์์ฐ๊ตฌ์", email: "th.lee@projectmaster.com", phone: "010-1234-5678" },
- { name: "Pany S.", dept: "๋ผ์ค์ค ๋๋ฆผ๋ถ / ๊ตญ์ฅ", email: "pany.s@lao.gov.la", phone: "+856-20-1234-5678" }
-];
-let contactEditingIndex = -1;
-
-function openAddressBook() { renderAddressBook(); ModalManager.open('addressBookModal'); }
-function closeAddressBook() { ModalManager.close('addressBookModal'); }
-
-function renderAddressBook() {
- const body = document.getElementById('addressBookBody');
- if (!body) return;
- body.innerHTML = addressBookData.map((c, idx) => `
-
- | ${c.name} | ${c.dept} | ${c.email} | ${c.phone} |
-
-
-
- |
-
`).join('');
-}
-
-function toggleAddContactForm() {
- const form = document.getElementById('addContactForm');
- if (form.style.display === 'none') form.style.display = 'block';
- else { form.style.display = 'none'; contactEditingIndex = -1; }
-}
-
-function editContact(index) {
- const c = addressBookData[index];
- contactEditingIndex = index;
- document.getElementById('newContactName').value = c.name;
- document.getElementById('newContactDept').value = c.dept;
- document.getElementById('newContactEmail').value = c.email;
- document.getElementById('newContactPhone').value = c.phone;
- document.getElementById('addContactForm').style.display = 'block';
-}
-
-function deleteContact(index) {
- if (confirm(`'${addressBookData[index].name}'๋์ ์ญ์ ํ์๊ฒ ์ต๋๊น?`)) { addressBookData.splice(index, 1); renderAddressBook(); }
-}
-
-function addContact() {
- const name = document.getElementById('newContactName').value;
- if (!name) return alert("์ด๋ฆ์ ์
๋ ฅํด์ฃผ์ธ์.");
- const data = { name, dept: document.getElementById('newContactDept').value, email: document.getElementById('newContactEmail').value, phone: document.getElementById('newContactPhone').value };
- if (contactEditingIndex > -1) addressBookData[contactEditingIndex] = data;
- else addressBookData.push(data);
- renderAddressBook(); toggleAddContactForm();
-}
-
-// --- ๊ณตํต ์ก์
---
-function updateBulkActionBar() {
- const count = document.querySelectorAll('.mail-item-checkbox:checked').length;
- const bar = document.getElementById('mailBulkActions');
- if (count > 0) { bar.classList.add('active'); document.getElementById('selectedCount').innerText = `${count}๊ฐ ์ ํ๋จ`; }
- else bar.classList.remove('active');
-}
-
-// ์ด๊ธฐํ
-document.addEventListener('DOMContentLoaded', () => {
- loadAttachments();
- renderMailList('inbound');
-});
+/**
+ * Project Master Overseas Mail Management JS
+ * ๊ธฐ๋ฅ: ์ฒจ๋ถํ์ผ ๋ก๋, AI ๋ถ์, ๋ฉ์ผ ๋ชฉ๋ก ๋ ๋๋ง, ๋ฏธ๋ฆฌ๋ณด๊ธฐ, ์ฃผ์๋ก ๊ด๋ฆฌ
+ */
+
+let currentFiles = [];
+let editingIndex = -1;
+
+const HIERARCHY = {
+ "ํ์ ": { "๊ณ์ฝ": ["๊ณ์ฝ๊ด๋ฆฌ", "๊ธฐ์ฑ๊ด๋ฆฌ", "์
๋ฌด์ง์์", "์ธ์๊ด๋ฆฌ"], "์
๋ฌด๊ด๋ฆฌ": ["์
๋ฌด์ผ์ง(2025)", "์
๋ฌด์ผ์ง(2025๋
์ด์ )", "๋ฐ์ฃผ์ฒ ์ ๊ธฐ๋ณด๊ณ ", "๋ณธ์ฌ์
๋ฌด๋ณด๊ณ ", "๊ณต์ฌ๊ฐ๋
์ผ์ง", "์์์๋ฅ"] },
+ "์ค๊ณ์ฑ๊ณผํ": { "์๋ฐฉ์": ["๊ณต์ฌ์๋ฐฉ์"], "์ค๊ณ๋๋ฉด": ["๊ณตํต", "ํ ๊ณต", "๋นํ๋ฉด์์ ๊ณต", "๋ฐฐ์๊ณต", "๊ต๋๊ณต", "ํฌ์ฅ๊ณต"], "์๋์ฐ์ถ์": ["ํ ๊ณต", "๋ฐฐ์๊ณต"], "๋ด์ญ์": ["๋จ๊ฐ์ฐ์ถ์"], "๋ณด๊ณ ์": ["์ค์์ค๊ณ๋ณด๊ณ ์", "์ง๋ฐ์กฐ์ฌ๋ณด๊ณ ์"], "์ธก๋๊ณ์ฐ๋ถ": ["์ธก๋๊ณ์ฐ๋ถ"], "์ค๊ณ๋จ๊ณ ์ํํ์": ["ํ์ยทํ์"] },
+ "์๊ณต๊ฒ์ธก": { "ํ ๊ณต": ["๊ฒ์ธก (๊นจ๊ธฐ)", "๊ฒ์ธก (๋
ธ์ฒด)"], "๋ฐฐ์๊ณต": ["๊ฒ์ธก (Vํ์ธก๊ตฌ)", "๊ฒ์ธก (์ข
๋ฐฐ์๊ด)"], "๊ตฌ์กฐ๋ฌผ๊ณต": ["๊ฒ์ธก (ํ๋ชฉ๊ต)"], "ํฌ์ฅ๊ณต": ["๊ฒ์ธก (๊ธฐ์ธต)"] },
+ "์ค๊ณ๋ณ๊ฒฝ": { "์ค์ ๋ณด๊ณ ": ["ํ ๊ณต", "๋ฐฐ์๊ณต", "์์ ๊ด๋ฆฌ"], "๊ธฐ์ ์ง์ ๊ฒํ ": ["ํ ๊ณต", "๊ตฌ์กฐ๋ฌผ&๋ถ๋๊ณต"] }
+};
+
+const MAIL_SAMPLES = {
+ inbound: [
+ { person: "๋ผ์ค์ค ๋๋ฆผ๋ถ", email: "pany.s@lao.gov.la", time: "2026-03-05", title: "ITTC ๊ต์ก์ผํฐ ์ฐฉ๊ณต์ ์ผ์ ํ์", summary: "์ฐฉ๊ณต์ ๊ด๋ จํ์ฌ ์ ๋ถ ์ธก ์ธ์ฌ์ ์ผ์ ์ ๋ฐ์ํ ์ต์ข
๊ณต๋ฌธ์ ์ก๋ถํฉ๋๋ค.", active: true },
+ { person: "ํ๋๊ฑด์ค (๊น์ฒ ์ ์์ฅ)", email: "cs.kim@hdec.co.kr", time: "2026-03-04", title: "[๊ธด๊ธ] ์ด์ฒ-๊ณต์ฃผ(4์ฐจ) ํ๋๊ธ ๋ณ๊ฒฝ๊ณ์ฝ ํต๋ณด", summary: "์ฒ ๊ฑฐ๊ณต์ฌ ๋ฌผ๋ ๋ณ๋์ ๋ฐ๋ฅธ ๊ณ์ฝ ๊ธ์ก ์กฐ์ ๊ฑด์
๋๋ค. ๊ฒํ ํ ์น์ธ ๋ถํ๋๋ฆฝ๋๋ค.", active: false }
+ ],
+ outbound: [
+ { person: "๊ณต์ฌ๊ด๋ฆฌ๋ถ (๋ณธ์ฌ)", email: "hq_pm@projectmaster.com", time: "2026-03-04", title: "์ด์ฒ-๊ณต์ฃผ 2์ ์๊ฐ ๊ณต์ ๋ณด๊ณ ์ ์ ์ถ", summary: "2์ ํ ๋ฌ๊ฐ์ ์ฃผ์ ๊ณต์ ๋ฐ ์์ฐ ์งํ ํํฉ ๋ณด๊ณ ์์
๋๋ค.", active: false }
+ ],
+ drafts: [], deleted: []
+};
+
+let currentMailTab = 'inbound';
+let filteredMails = [];
+
+// --- ์ฒจ๋ถํ์ผ ๋ฐ์ดํฐ ๋ก๋ ๋ฐ ๋ ๋๋ง ---
+async function loadAttachments() {
+ try {
+ const res = await fetch(API.ATTACHMENTS);
+ currentFiles = await res.json();
+ renderFiles();
+ } catch (e) { console.error("Failed to load attachments:", e); }
+}
+
+function renderFiles() {
+ const isAiActive = document.getElementById('aiToggle').checked;
+ const container = document.getElementById('attachmentList');
+ if (!container) return;
+ container.innerHTML = '';
+
+ currentFiles.forEach((file, index) => {
+ const item = document.createElement('div');
+ item.className = 'attachment-item-wrap';
+ item.style.marginBottom = "8px";
+
+ let pathText = "๊ฒฝ๋ก๋ฅผ ์ ํํด์ฃผ์ธ์";
+ let modeClass = "manual-mode";
+
+ if (file.analysis) {
+ const prefix = file.analysis.isManual ? "์ ํ ๊ฒฝ๋ก: " : "์ถ์ฒ: ";
+ pathText = `${prefix}${file.analysis.suggested_path}`;
+ modeClass = file.analysis.isManual ? "manual-mode" : "smart-mode";
+ } else if (isAiActive) {
+ pathText = "AI ๋ถ์ ๋๊ธฐ ์ค...";
+ modeClass = "smart-mode";
+ }
+
+ item.innerHTML = `
+
+
๐
+
+
${file.name}
+
${file.size}
+
+
+ ${pathText}
+ ${isAiActive ? `` : ''}
+
+
+
+
+ `;
+ container.appendChild(item);
+ });
+}
+
+// --- AI ๋ถ์ ์คํ ---
+async function startAnalysis(index, event) {
+ if (event) event.stopPropagation();
+ const file = currentFiles[index];
+ if (!file) return;
+
+ // UI ์ํ ์
๋ฐ์ดํธ: ๋ถ์ ์ค ํ์
+ const logArea = document.getElementById(`log-area-${index}`);
+ const logContent = document.getElementById(`log-content-${index}`);
+ if (logArea) logArea.classList.add('active');
+ if (logContent) {
+ logContent.innerHTML = `
+
+ AI๊ฐ ๋ฌธ์๋ฅผ ์ ๋ฐ ๋ถ์ ์ค์
๋๋ค...
+
`;
+ }
+
+ try {
+ const res = await fetch(`${API.ANALYZE_FILE}?filename=${encodeURIComponent(file.name)}`);
+ const result = await res.json();
+
+ if (result.error) {
+ if (logContent) logContent.innerHTML = `์ค๋ฅ: ${result.error}
`;
+ return;
+ }
+
+ // ๋ถ์ ๊ฒฐ๊ณผ ์ ์ฅ ๋ฐ UI ๊ฐฑ์
+ currentFiles[index].analysis = result.final_result;
+ currentFiles[index].analysis.isManual = false;
+
+ if (logContent) {
+ logContent.innerHTML = `
+
+
โจ AI ๋ถ์ ์๋ฃ
+
${result.final_result.reason}
+
+ `;
+ }
+
+ renderFiles();
+ } catch (e) {
+ console.error("AI Analysis failed:", e);
+ if (logContent) logContent.innerHTML = `๋ถ์ ์คํจ: ๋คํธ์ํฌ ์ค๋ฅ๊ฐ ๋ฐ์ํ์ต๋๋ค.
`;
+ }
+}
+
+// --- ๋ฏธ๋ฆฌ๋ณด๊ธฐ ์ ์ด ---
+function showPreview(index, event) {
+ if (event && (event.target.closest('.btn-group') || event.target.closest('.path-display'))) return;
+ const file = currentFiles[index];
+ if (!file) return;
+
+ const previewArea = document.getElementById('mailPreviewArea');
+ const toggleIcon = document.getElementById('previewToggleIcon');
+ const fullViewBtn = document.getElementById('fullViewBtn');
+ const previewContainer = document.getElementById('previewContainer');
+
+ if (previewArea) {
+ previewArea.classList.add('active');
+ if (toggleIcon) toggleIcon.innerText = 'โถ';
+ }
+
+ const fileUrl = Utils.getSafeFileUrl(file.name);
+ if (fullViewBtn) {
+ fullViewBtn.style.display = 'block';
+ fullViewBtn.onclick = () => window.open(fileUrl, 'PMFullView', 'width=1000,height=800');
+ }
+
+ if (file.name.toLowerCase().endsWith('.pdf')) {
+ previewContainer.innerHTML = ``;
+ } else {
+ previewContainer.innerHTML = `
${file.name}
`;
+ }
+
+ document.querySelectorAll('.attachment-item').forEach(item => item.classList.remove('active'));
+ if (event?.currentTarget) event.currentTarget.classList.add('active');
+}
+
+function togglePreviewAuto() {
+ const area = document.getElementById('mailPreviewArea');
+ const icon = document.getElementById('previewToggleIcon');
+ if (area) {
+ const isActive = area.classList.toggle('active');
+ if (icon) icon.innerText = isActive ? 'โถ' : 'โ';
+ }
+}
+
+// --- ๋ฉ์ผ ๋ฆฌ์คํธ ์ ์ด ---
+function renderMailList(tabType, mailsToShow = null) {
+ currentMailTab = tabType;
+ const container = document.querySelector('.mail-items-container');
+ if (!container) return;
+
+ const mails = mailsToShow || MAIL_SAMPLES[tabType] || [];
+ filteredMails = mails;
+ updateBulkActionBar();
+
+ container.innerHTML = mails.map((mail, idx) => `
+
+
+
+
+
${mail.person}
+
${mail.time}
+
+
${mail.title}
+
${mail.summary}
+
+
+ `).join('');
+
+ const activeIdx = mails.findIndex(m => m.active);
+ if (activeIdx !== -1) updateMailContent(mails[activeIdx]);
+}
+
+function selectMailItem(el, index) {
+ document.querySelectorAll('.mail-item').forEach(item => item.classList.remove('active'));
+ el.classList.add('active');
+ const mail = filteredMails[index];
+ if (mail) updateMailContent(mail);
+}
+
+function updateMailContent(mail) {
+ const title = document.querySelector('.mail-content-header h2');
+ if (title) title.innerText = mail.title;
+ document.querySelector('.mail-body').innerHTML = mail.summary.replace(/\n/g, '
') + "
๋ณธ ๋ด์ฉ์ ์ํ ๋ฐ์ดํฐ์
๋๋ค.";
+}
+
+function switchMailTab(el, tabType) {
+ document.querySelectorAll('.mail-tab').forEach(tab => tab.classList.remove('active'));
+ el.classList.add('active');
+ renderMailList(tabType);
+}
+
+// --- ๊ฒฝ๋ก ์ ํ ๋ชจ๋ฌ ---
+function openPathModal(index, event) {
+ if (event) event.stopPropagation();
+ editingIndex = index;
+ const tabSelect = document.getElementById('tabSelect');
+ if (tabSelect) {
+ tabSelect.innerHTML = Object.keys(HIERARCHY).map(tab => ``).join('');
+ updateCategories();
+ ModalManager.open('pathModal');
+ }
+}
+
+function updateCategories() {
+ const tab = document.getElementById('tabSelect').value;
+ document.getElementById('categorySelect').innerHTML = Object.keys(HIERARCHY[tab]).map(cat => ``).join('');
+ updateSubs();
+}
+
+function updateSubs() {
+ const tab = document.getElementById('tabSelect').value;
+ const cat = document.getElementById('categorySelect').value;
+ document.getElementById('subSelect').innerHTML = HIERARCHY[tab][cat].map(sub => ``).join('');
+}
+
+function applyPathSelection() {
+ const path = `${document.getElementById('tabSelect').value} > ${document.getElementById('categorySelect').value} > ${document.getElementById('subSelect').value}`;
+ if (!currentFiles[editingIndex].analysis) currentFiles[editingIndex].analysis = {};
+ currentFiles[editingIndex].analysis.suggested_path = path;
+ currentFiles[editingIndex].analysis.isManual = true;
+ renderFiles();
+ ModalManager.close('pathModal');
+}
+
+// --- ์ฃผ์๋ก ๊ด๋ฆฌ ---
+let addressBookData = [
+ { name: "์ดํํ", dept: "PM Overseas / ์ ์์ฐ๊ตฌ์", email: "th.lee@projectmaster.com", phone: "010-1234-5678" },
+ { name: "Pany S.", dept: "๋ผ์ค์ค ๋๋ฆผ๋ถ / ๊ตญ์ฅ", email: "pany.s@lao.gov.la", phone: "+856-20-1234-5678" }
+];
+let contactEditingIndex = -1;
+
+function openAddressBook() { renderAddressBook(); ModalManager.open('addressBookModal'); }
+function closeAddressBook() { ModalManager.close('addressBookModal'); }
+
+function renderAddressBook() {
+ const body = document.getElementById('addressBookBody');
+ if (!body) return;
+ body.innerHTML = addressBookData.map((c, idx) => `
+
+ | ${c.name} | ${c.dept} | ${c.email} | ${c.phone} |
+
+
+
+ |
+
`).join('');
+}
+
+function toggleAddContactForm() {
+ const form = document.getElementById('addContactForm');
+ if (form.style.display === 'none') form.style.display = 'block';
+ else { form.style.display = 'none'; contactEditingIndex = -1; }
+}
+
+function editContact(index) {
+ const c = addressBookData[index];
+ contactEditingIndex = index;
+ document.getElementById('newContactName').value = c.name;
+ document.getElementById('newContactDept').value = c.dept;
+ document.getElementById('newContactEmail').value = c.email;
+ document.getElementById('newContactPhone').value = c.phone;
+ document.getElementById('addContactForm').style.display = 'block';
+}
+
+function deleteContact(index) {
+ if (confirm(`'${addressBookData[index].name}'๋์ ์ญ์ ํ์๊ฒ ์ต๋๊น?`)) { addressBookData.splice(index, 1); renderAddressBook(); }
+}
+
+function addContact() {
+ const name = document.getElementById('newContactName').value;
+ if (!name) return alert("์ด๋ฆ์ ์
๋ ฅํด์ฃผ์ธ์.");
+ const data = { name, dept: document.getElementById('newContactDept').value, email: document.getElementById('newContactEmail').value, phone: document.getElementById('newContactPhone').value };
+ if (contactEditingIndex > -1) addressBookData[contactEditingIndex] = data;
+ else addressBookData.push(data);
+ renderAddressBook(); toggleAddContactForm();
+}
+
+// --- ๊ณตํต ์ก์
---
+function updateBulkActionBar() {
+ const count = document.querySelectorAll('.mail-item-checkbox:checked').length;
+ const bar = document.getElementById('mailBulkActions');
+ if (count > 0) { bar.classList.add('active'); document.getElementById('selectedCount').innerText = `${count}๊ฐ ์ ํ๋จ`; }
+ else bar.classList.remove('active');
+}
+
+// ์ด๊ธฐํ
+document.addEventListener('DOMContentLoaded', () => {
+ loadAttachments();
+ renderMailList('inbound');
+});
diff --git a/log_scorer.py b/log_scorer.py
new file mode 100644
index 0000000..6a64e13
--- /dev/null
+++ b/log_scorer.py
@@ -0,0 +1,63 @@
+import re
+
+class LogScorer:
+ """๋ก๊ทธ ํ
์คํธ์ ์๋งจํฑ ๊ฐ์น๋ฅผ ํ๋ณํ์ฌ ์ ์ํํ๋ ๋ชจ๋ (SWVW)"""
+
+ # ์
๋ฌด ๊ฐ์น ๋ฒ์ฃผ๋ณ ๊ฐ์ค์น ์ ์
+ # 1.0: ํต์ฌ ์์ฌ๊ฒฐ์ /๊ณ์ฝ, 0.7: ์ง๋ฅํ/๊ถํ๊ด๋ฆฌ, 0.4: ์ผ๋ฐ๊ด๋ฆฌ, 0.1: ๋จ์ํ๋
+ WEIGHT_MAP = {
+ "CORE": 1.0, # ์ค๊ณ๋ณ๊ฒฝ, ์ค์ ๋ณด๊ณ , ๊ณ์ฝ, ์ ์ฐ ๋ฑ (์ถํ ํ์ฅ ๋๋น)
+ "INTELLIGENT": 0.7, # AI์์ฝ, PDF๋ณํ, ๋ถ์ ๋ฑ
+ "AUTH": 0.6, # ๊ถํ ์ถ๊ฐ, ๋ณด์ ์ค์ ๋ฑ
+ "MGMT": 0.4, # ์
๋ก๋, ์์ , ์ด๋ฆ ๋ณ๊ฒฝ ๋ฑ
+ "SIMPLE": 0.2, # ๋ค์ด๋ก๋, ์ญ์ , ์์ฑ ๋ฑ
+ "AUTO": 0.0 # ์๋ ์ญ์ , ์์คํ
๋ก๊ทธ ๋ฑ
+ }
+
+ # ๋ฒ์ฃผ๋ณ ํค์๋ ์ ์ (์ค๋ฌด ๋ฌธ๋งฅ ๋ฐ์)
+ KEYWORDS = {
+ "CORE": ["๋ณด๊ณ ", "๊ณ์ฝ", "์ ์ฐ", "์ค๊ณ", "๊ฒํ ", "์น์ธ", "๊ณต๋ฌธ", "ํต๋ณด"],
+ "INTELLIGENT": ["AI", "์์ฝ", "๋ณํ", "PDF"],
+ "AUTH": ["๊ถํ", "์ฐธ์ฌ์", "๊ด๋ฆฌ์", "๋ณด์"],
+ "MGMT": ["์
๋ก๋", "์์ ", "๋ณ๊ฒฝ", "์ฒจ๋ถ", "์ถ๊ฐ"],
+ "SIMPLE": ["๋ค์ด๋ก๋", "์์ฑ", "์ด๋", "์ญ์ "],
+ "AUTO": ["์๋", "์์คํ
"]
+ }
+
+ @classmethod
+ def get_score(cls, log_text):
+ """๋ก๊ทธ ํ
์คํธ๋ฅผ ๋ถ์ํ์ฌ 0.0 ~ 1.0 ์ฌ์ด์ ๊ฐ์น ์ ์๋ฅผ ๋ฐํ"""
+ if not log_text or log_text == "๋ฐ์ดํฐ ์์":
+ return 0.0
+
+ # ๋ ์ง ๋ถ๋ถ ์ ๊ฑฐ (์: "2024.03.01, " ์ ๊ฑฐ)
+ clean_log = re.sub(r'^\d{2,4}\.\d{2}\.\d{2},\s*', '', log_text)
+
+ # 1. ํน์ ํค์๋ ๋งค์นญ์ ํตํ ๊ธฐ๋ณธ ๋ฒ์ฃผ ํ๋ณ
+ for category, keywords in cls.KEYWORDS.items():
+ if any(k in clean_log for k in keywords):
+ # '์๋ ์ญ์ '๋ ๋ณ๋ ์ฒ๋ฆฌ
+ if category == "SIMPLE" and "์๋" in clean_log:
+ return cls.WEIGHT_MAP["AUTO"]
+ return cls.WEIGHT_MAP[category]
+
+ return 0.3 # ๊ธฐ๋ณธ๊ฐ (๋ถ๋ฅ๋์ง ์์ ํ๋)
+
+ @classmethod
+ def calculate_work_density(cls, logs):
+ """๋ก๊ทธ ๋ชฉ๋ก์ ๋ฐ์ ํ๊ท ์
๋ฌด ๋ฐ๋ ์ฐ์ถ"""
+ if not logs: return 0.0
+ scores = [cls.get_score(log) for log in logs]
+ return sum(scores) / len(scores)
+
+# ํ
์คํธ ์ฝ๋
+if __name__ == "__main__":
+ test_logs = [
+ "2026.03.30, ํ๋๊ธ ๊ณ์ฝ ํต๋ณด์ ๊ฒํ ",
+ "2026.03.25, AI์์ฝ ์๋ฃ",
+ "2026.03.20, ๋ถ๊ด๋ฆฌ์ ๊ถํ ์ถ๊ฐ",
+ "2026.03.15, ํ์ผ ์
๋ก๋",
+ "2026.03.10, ํด๋ ์๋ ์ญ์ "
+ ]
+ for log in test_logs:
+ print(f"Log: {log} => Score: {LogScorer.get_score(log)}")
diff --git a/prediction_service.py b/prediction_service.py
index 7212d0b..3c95c9a 100644
--- a/prediction_service.py
+++ b/prediction_service.py
@@ -1,97 +1,97 @@
-import numpy as np
-from datetime import datetime
-
-class SOIPredictionService:
- """ํ์ตํ ์๊ณ์ด ์์ธก ๋ฐ ํผ์ฒ ์ถ์ถ ์์ง"""
-
- @staticmethod
- def get_historical_soi(cursor, project_id):
- """DB์์ ํ๋ก์ ํธ์ ๊ณผ๊ฑฐ SOI ํ์คํ ๋ฆฌ๋ฅผ ์ํ์ค๋ก ์ถ์ถ"""
- cursor.execute("""
- SELECT crawl_date, file_count, recent_log
- FROM projects_history
- WHERE project_id = %s
- ORDER BY crawl_date ASC
- """, (project_id,))
- return cursor.fetchall()
-
- @staticmethod
- def extract_vitality_features(history):
- """๋ฅ๋ฌ๋ ํ์ต์ ์ํ 4๋ ํต์ฌ ํผ์ฒ ์ถ์ถ (Feature Engineering)"""
- if len(history) < 2:
- return {"velocity": 0, "acceleration": 0, "consistency": 0.5, "density": 0.1}
-
- # ์ค์ ๋ฐ์ดํฐ ๊ตฌ์กฐ์ ๋ง๊ฒ ๋ณด์
- counts = []
- for h in history:
- try:
- val = int(h['file_count']) if h['file_count'] is not None else 0
- counts.append(val)
- except:
- counts.append(0)
-
- # 1. ํ๋ ์๋ (Velocity)
- velocity = np.diff(counts).mean() if len(counts) > 1 else 0
-
- # 2. ํ๋ ๊ฐ์๋ (Acceleration): ์ต๊ทผ ํ๋์ด ๋นจ๋ผ์ง๋์ง ๋๋ ค์ง๋์ง
- acceleration = np.diff(np.diff(counts)).mean() if len(counts) > 2 else 0
-
- # 3. ๋ก๊ทธ ๋ฐ๋ (Density): ์ ์ฒด ๊ธฐ๊ฐ ๋๋น ์ค์ ๋ก๊ทธ ๋ฐ์ ๋น์จ
- logs = [h['recent_log'] for h in history if h['recent_log'] and h['recent_log'] != "๋ฐ์ดํฐ ์์"]
- density = len(logs) / len(history) if len(history) > 0 else 0
-
- # 4. ๊ด๋ฆฌ ์ผ๊ด์ฑ (Consistency): ์
๋ฐ์ดํธ ๊ฐ๊ฒฉ์ ํ์คํธ์ฐจ (๋ฎ์์๋ก ์ข์)
- # (ํ์ฌ ๋ฐ์ดํฐ๋ ์ผ์ผ ํฌ๋กค๋ง์ด๋ฏ๋ก ๋ก๊ทธ ํ
์คํธ ๋ณํ ์์ ์ ๊ธฐ์ค์ผ๋ก ๊ฐ๊ฒฉ ๊ณ์ฐ ๊ฐ๋ฅ)
-
- return {
- "velocity": float(velocity),
- "acceleration": float(acceleration),
- "density": float(density),
- "sample_count": len(history)
- }
-
- @staticmethod
- def predict_future_soi(current_soi, history, days_ahead=14):
- """๊ธฐ์กด ์ ์์ ์๊ณ์ด ํผ์ฒ๋ฅผ ๊ฒฐํฉํ์ฌ ๋ฏธ๋ ์ ์ ์์ธก"""
- # ๋ฐ์ดํฐ๊ฐ ๋๋ฌด ์ ์ผ๋ฉด ๋ฌด์กฐ๊ฑด ๋ณด์์ ๊ฐ์ (14์ผ ๊ธฐ์ค ์ฝ -2.1์ )
- if not history or len(history) < 3:
- return round(max(0, min(100, current_soi - (0.15 * days_ahead))), 1)
-
- features = SOIPredictionService.extract_vitality_features(history)
- current_val = float(current_soi)
-
- # [์ ๋ฐ ์ ์ฒด ๋ถ์]
- # 1. ํ์ผ ์ ๋ณํ ํ์ธ (์ต๊ทผ 5๊ฐ ์ํ)
- recent_counts = [int(h['file_count'] or 0) for h in history[-5:]]
- is_hard_stagnant = len(set(recent_counts)) <= 1 # ํ์ผ ์ ๋ณ๋์ด ์ ํ ์์
-
- # 2. ์ต๊ทผ ๋ก๊ทธ ์ํ ํ์ธ
- last_log = history[-1]['recent_log']
- is_no_activity = last_log is None or last_log == "๋ฐ์ดํฐ ์์" or "ํด๋์๋์ญ์ " in last_log
-
- # [๋ชจ๋ฉํ
์ฐ์ถ ๋ก์ง ๊ฐํธ]
- if is_hard_stagnant:
- # ํ์ผ ๋ณํ๊ฐ ์๋ค๋ฉด ์๋ฌด๋ฆฌ ๋ก๊ทธ๊ฐ ์์ด๋ '์ ์ง ๊ด๋ฆฌ'์ผ ๋ฟ '์ฑ์ฅ'์ด ์๋
- # ์คํ๋ ค ์๊ฐ์ด ๊ฐ์๋ก ๊ธฐ์ ๋ถ์ฑ์ ๋ฐ์ดํฐ ๋
ธํํ๊ฐ ์งํ๋๋ค๊ณ ํ๋จ (๊ฐ๋ ฅ ํจ๋ํฐ)
- momentum_factor = -2.5 if is_no_activity else -1.0
- else:
- # ์ค์ง์ ์ธ ํ์ผ ์ ๋ณํ(Velocity)๊ฐ ์์ ๋๋ง ๊ธ์ ์ ๋ชจ๋ฉํ
๊ฒํ
- v_gain = features['velocity'] * 0.5
- d_gain = features['density'] * 0.8
- momentum_factor = v_gain + d_gain - 0.5 # ๊ธฐ๋ณธ์ ์ผ๋ก ํํฅ ์๋ ฅ ๋ถ์ฌ
-
- # ์์ธก ๋ก์ง: ํ์ฌ๊ฐ + ๋ชจ๋ฉํ
- (์๊ฐ์ ๋ฐ๋ฅธ ์์ฐ ๋ถ์)
- # ์ ์ฒด ์ momentum_factor๊ฐ -1.0~-2.5์ด๋ฏ๋ก ๊ฐ์๊ฐ ๋งค์ฐ ๋น ๋ฆ
- decay_constant = 0.08
- predicted = current_val + momentum_factor - (decay_constant * days_ahead)
-
- # [์ต์ข
๋ฐฉ์ด ๋ก์ง]
- # ์ค์ง์ ํ์ผ ์ฆ๊ฐ(velocity > 0)๊ฐ ํฌ์ฐฉ๋์ง ์์๋ค๋ฉด ์๋ณด๋ ํ์ฌ๊ฐ๋ณด๋ค ํด ์ ์์
- if features['velocity'] <= 0 and predicted > current_val:
- predicted = current_val - 1.5 # ๊ฐ์ ํ๋ฝ
-
- # ์ฌ๋ง ์ ๊ณ (AVI๊ฐ ์ด๋ฏธ ๋ฎ๊ณ ์ ์ฒด ์ค์ด๋ฉด 0์ ์๋ ดํ๋๋ก ๊ฐ์)
- if current_val < 20 and is_hard_stagnant:
- predicted = max(0, predicted - 5.0)
-
- return round(max(0, min(100, predicted)), 1)
+import numpy as np
+from datetime import datetime
+
+class SOIPredictionService:
+ """ํ์ตํ ์๊ณ์ด ์์ธก ๋ฐ ํผ์ฒ ์ถ์ถ ์์ง"""
+
+ @staticmethod
+ def get_historical_avi(cursor, project_id):
+ """DB์์ ํ๋ก์ ํธ์ ๊ณผ๊ฑฐ AVI ํ์คํ ๋ฆฌ๋ฅผ ์ํ์ค๋ก ์ถ์ถ"""
+ cursor.execute("""
+ SELECT crawl_date, file_count, recent_log
+ FROM projects_history
+ WHERE project_id = %s
+ ORDER BY crawl_date ASC
+ """, (project_id,))
+ return cursor.fetchall()
+
+ @staticmethod
+ def extract_vitality_features(history):
+ """๋ฅ๋ฌ๋ ํ์ต์ ์ํ 4๋ ํต์ฌ ํผ์ฒ ์ถ์ถ (Feature Engineering)"""
+ if len(history) < 2:
+ return {"velocity": 0, "acceleration": 0, "consistency": 0.5, "density": 0.1}
+
+ # ์ค์ ๋ฐ์ดํฐ ๊ตฌ์กฐ์ ๋ง๊ฒ ๋ณด์
+ counts = []
+ for h in history:
+ try:
+ val = int(h['file_count']) if h['file_count'] is not None else 0
+ counts.append(val)
+ except:
+ counts.append(0)
+
+ # 1. ํ๋ ์๋ (Velocity)
+ velocity = np.diff(counts).mean() if len(counts) > 1 else 0
+
+ # 2. ํ๋ ๊ฐ์๋ (Acceleration): ์ต๊ทผ ํ๋์ด ๋นจ๋ผ์ง๋์ง ๋๋ ค์ง๋์ง
+ acceleration = np.diff(np.diff(counts)).mean() if len(counts) > 2 else 0
+
+ # 3. ๋ก๊ทธ ๋ฐ๋ (Density): ์ ์ฒด ๊ธฐ๊ฐ ๋๋น ์ค์ ๋ก๊ทธ ๋ฐ์ ๋น์จ
+ logs = [h['recent_log'] for h in history if h['recent_log'] and h['recent_log'] != "๋ฐ์ดํฐ ์์"]
+ density = len(logs) / len(history) if len(history) > 0 else 0
+
+ # 4. ๊ด๋ฆฌ ์ผ๊ด์ฑ (Consistency): ์
๋ฐ์ดํธ ๊ฐ๊ฒฉ์ ํ์คํธ์ฐจ (๋ฎ์์๋ก ์ข์)
+ # (ํ์ฌ ๋ฐ์ดํฐ๋ ์ผ์ผ ํฌ๋กค๋ง์ด๋ฏ๋ก ๋ก๊ทธ ํ
์คํธ ๋ณํ ์์ ์ ๊ธฐ์ค์ผ๋ก ๊ฐ๊ฒฉ ๊ณ์ฐ ๊ฐ๋ฅ)
+
+ return {
+ "velocity": float(velocity),
+ "acceleration": float(acceleration),
+ "density": float(density),
+ "sample_count": len(history)
+ }
+
+ @staticmethod
+ def predict_future_avi(current_avi, history, days_ahead=14):
+ """๊ธฐ์กด ์ ์์ ์๊ณ์ด ํผ์ฒ๋ฅผ ๊ฒฐํฉํ์ฌ ๋ฏธ๋ ์ ์ ์์ธก"""
+ # ๋ฐ์ดํฐ๊ฐ ๋๋ฌด ์ ์ผ๋ฉด ๋ฌด์กฐ๊ฑด ๋ณด์์ ๊ฐ์ (14์ผ ๊ธฐ์ค ์ฝ -2.1์ )
+ if not history or len(history) < 3:
+ return round(max(0, min(100, current_avi - (0.15 * days_ahead))), 1)
+
+ features = SOIPredictionService.extract_vitality_features(history)
+ current_val = float(current_avi)
+
+ # [์ ๋ฐ ์ ์ฒด ๋ถ์]
+ # 1. ํ์ผ ์ ๋ณํ ํ์ธ (์ต๊ทผ 5๊ฐ ์ํ)
+ recent_counts = [int(h['file_count'] or 0) for h in history[-5:]]
+ is_hard_stagnant = len(set(recent_counts)) <= 1 # ํ์ผ ์ ๋ณ๋์ด ์ ํ ์์
+
+ # 2. ์ต๊ทผ ๋ก๊ทธ ์ํ ํ์ธ
+ last_log = history[-1]['recent_log']
+ is_no_activity = last_log is None or last_log == "๋ฐ์ดํฐ ์์" or "ํด๋์๋์ญ์ " in last_log
+
+ # [๋ชจ๋ฉํ
์ฐ์ถ ๋ก์ง ๊ฐํธ]
+ if is_hard_stagnant:
+ # ํ์ผ ๋ณํ๊ฐ ์๋ค๋ฉด ์๋ฌด๋ฆฌ ๋ก๊ทธ๊ฐ ์์ด๋ '์ ์ง ๊ด๋ฆฌ'์ผ ๋ฟ '์ฑ์ฅ'์ด ์๋
+ # ์คํ๋ ค ์๊ฐ์ด ๊ฐ์๋ก ๊ธฐ์ ๋ถ์ฑ์ ๋ฐ์ดํฐ ๋
ธํํ๊ฐ ์งํ๋๋ค๊ณ ํ๋จ (๊ฐ๋ ฅ ํจ๋ํฐ)
+ momentum_factor = -2.5 if is_no_activity else -1.0
+ else:
+ # ์ค์ง์ ์ธ ํ์ผ ์ ๋ณํ(Velocity)๊ฐ ์์ ๋๋ง ๊ธ์ ์ ๋ชจ๋ฉํ
๊ฒํ
+ v_gain = features['velocity'] * 0.5
+ d_gain = features['density'] * 0.8
+ momentum_factor = v_gain + d_gain - 0.5 # ๊ธฐ๋ณธ์ ์ผ๋ก ํํฅ ์๋ ฅ ๋ถ์ฌ
+
+ # ์์ธก ๋ก์ง: ํ์ฌ๊ฐ + ๋ชจ๋ฉํ
- (์๊ฐ์ ๋ฐ๋ฅธ ์์ฐ ๋ถ์)
+ # ์ ์ฒด ์ momentum_factor๊ฐ -1.0~-2.5์ด๋ฏ๋ก ๊ฐ์๊ฐ ๋งค์ฐ ๋น ๋ฆ
+ decay_constant = 0.08
+ predicted = current_val + momentum_factor - (decay_constant * days_ahead)
+
+ # [์ต์ข
๋ฐฉ์ด ๋ก์ง]
+ # ์ค์ง์ ํ์ผ ์ฆ๊ฐ(velocity > 0)๊ฐ ํฌ์ฐฉ๋์ง ์์๋ค๋ฉด ์๋ณด๋ ํ์ฌ๊ฐ๋ณด๋ค ํด ์ ์์
+ if features['velocity'] <= 0 and predicted > current_val:
+ predicted = current_val - 1.5 # ๊ฐ์ ํ๋ฝ
+
+ # ์ฌ๋ง ์ ๊ณ (AVI๊ฐ ์ด๋ฏธ ๋ฎ๊ณ ์ ์ฒด ์ค์ด๋ฉด 0์ ์๋ ดํ๋๋ก ๊ฐ์)
+ if current_val < 20 and is_hard_stagnant:
+ predicted = max(0, predicted - 5.0)
+
+ return round(max(0, min(100, predicted)), 1)
diff --git a/project_service.py b/project_service.py
index 6ad3702..7ec2f75 100644
--- a/project_service.py
+++ b/project_service.py
@@ -1,33 +1,33 @@
-from sql_queries import DashboardQueries
-
-class ProjectService:
- @staticmethod
- def get_available_dates_logic(cursor):
- cursor.execute(DashboardQueries.GET_AVAILABLE_DATES)
- rows = cursor.fetchall()
- return [row['crawl_date'].strftime("%Y.%m.%d") for row in rows if row['crawl_date']]
-
- @staticmethod
- def get_project_data_logic(cursor, date_str):
- target_date = date_str.replace(".", "-") if date_str and date_str != "-" else None
-
- if not target_date:
- cursor.execute(DashboardQueries.GET_LAST_CRAWL_DATE)
- res = cursor.fetchone()
- target_date = res['last_date']
-
- if not target_date:
- return {"projects": []}
-
- cursor.execute(DashboardQueries.GET_PROJECT_LIST, (target_date,))
- rows = cursor.fetchall()
-
- projects = []
- for r in rows:
- name = r['short_nm'] if r['short_nm'] and r['short_nm'].strip() else r['project_nm']
- projects.append([
- name, r['department'], r['master'],
- r['recent_log'], r['file_count'],
- r['continent'], r['country']
- ])
- return {"projects": projects}
+from sql_queries import DashboardQueries
+
+class ProjectService:
+ @staticmethod
+ def get_available_dates_logic(cursor):
+ cursor.execute(DashboardQueries.GET_AVAILABLE_DATES)
+ rows = cursor.fetchall()
+ return [row['crawl_date'].strftime("%Y.%m.%d") for row in rows if row['crawl_date']]
+
+ @staticmethod
+ def get_project_data_logic(cursor, date_str):
+ target_date = date_str.replace(".", "-") if date_str and date_str != "-" else None
+
+ if not target_date:
+ cursor.execute(DashboardQueries.GET_LAST_CRAWL_DATE)
+ res = cursor.fetchone()
+ target_date = res['last_date']
+
+ if not target_date:
+ return {"projects": []}
+
+ cursor.execute(DashboardQueries.GET_PROJECT_LIST, (target_date,))
+ rows = cursor.fetchall()
+
+ projects = []
+ for r in rows:
+ name = r['short_nm'] if r['short_nm'] and r['short_nm'].strip() else r['project_nm']
+ projects.append([
+ name, r['department'], r['master'],
+ r['recent_log'], r['file_count'],
+ r['continent'], r['country']
+ ])
+ return {"projects": projects}
diff --git a/requirements.txt b/requirements.txt
index 71c0078..be20ccd 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -2,4 +2,14 @@ fastapi==0.110.0
uvicorn==0.29.0
playwright==1.42.0
python-dotenv==1.0.1
-pypdf==4.1.0
\ No newline at end of file
+pypdf==4.1.0
+pymysql
+pandas
+sqlalchemy
+openpyxl
+pytesseract
+pdf2image
+pillow
+numpy
+pydantic
+jinja2
diff --git a/schemas.py b/schemas.py
index 790822f..b14c0a6 100644
--- a/schemas.py
+++ b/schemas.py
@@ -1,10 +1,10 @@
-from pydantic import BaseModel
-
-class AuthRequest(BaseModel):
- user_id: str
- password: str
-
-class InquiryReplyRequest(BaseModel):
- reply: str
- status: str
- handler: str
+from pydantic import BaseModel
+
+class AuthRequest(BaseModel):
+ user_id: str
+ password: str
+
+class InquiryReplyRequest(BaseModel):
+ reply: str
+ status: str
+ handler: str
diff --git a/server.py b/server.py
index 11b04ea..5d649fd 100644
--- a/server.py
+++ b/server.py
@@ -1,182 +1,182 @@
-import os
-import sys
-import asyncio
-import pymysql
-from fastapi import FastAPI, Request
-from fastapi.middleware.cors import CORSMiddleware
-from fastapi.responses import StreamingResponse, FileResponse
-from fastapi.staticfiles import StaticFiles
-from fastapi.templating import Jinja2Templates
-
-from analyze import analyze_file_content
-from crawler_service import run_crawler_service, crawl_stop_event
-from schemas import AuthRequest, InquiryReplyRequest
-from inquiry_service import InquiryService
-from project_service import ProjectService
-from analysis_service import AnalysisService
-
-# --- ํ๊ฒฝ ์ค์ ---
-os.environ["PYTHONIOENCODING"] = "utf-8"
-TESSDATA_PREFIX = os.getenv("TESSDATA_PREFIX", r"C:\Users\User\AppData\Local\Programs\Tesseract-OCR\tessdata")
-os.environ["TESSDATA_PREFIX"] = TESSDATA_PREFIX
-
-app = FastAPI(title="Project Master Overseas API")
-templates = Jinja2Templates(directory="templates")
-
-# ์ ์ ํ์ผ ๋ง์ดํธ
-app.mount("/style", StaticFiles(directory="style"), name="style")
-app.mount("/js", StaticFiles(directory="js"), name="js")
-app.mount("/sample_files", StaticFiles(directory="sample"), name="sample_files")
-
-app.add_middleware(
- CORSMiddleware,
- allow_origins=["*"],
- allow_credentials=False,
- allow_methods=["*"],
- allow_headers=["*"],
-)
-
-# --- ์ ํธ๋ฆฌํฐ ํจ์ ---
-def get_db_connection():
- """MySQL ๋ฐ์ดํฐ๋ฒ ์ด์ค ์ฐ๊ฒฐ์ ๋ฐํ"""
- return pymysql.connect(
- host=os.getenv('DB_HOST', 'localhost'),
- user=os.getenv('DB_USER', 'root'),
- password=os.getenv('DB_PASSWORD', '45278434'),
- database=os.getenv('DB_NAME', 'PM_proto'),
- charset='utf8mb4',
- cursorclass=pymysql.cursors.DictCursor
- )
-
-async def run_in_threadpool(func, *args):
- """๋๊ธฐ ํจ์๋ฅผ ๋น์ฐจ๋จ ๋ฐฉ์์ผ๋ก ์คํ"""
- loop = asyncio.get_event_loop()
- return await loop.run_in_executor(None, func, *args)
-
-# --- HTML ๋ผ์ฐํ
---
-@app.get("/")
-async def root(request: Request):
- return templates.TemplateResponse("index.html", {"request": request})
-
-@app.get("/dashboard")
-async def get_dashboard(request: Request):
- return templates.TemplateResponse("dashboard.html", {"request": request})
-
-@app.get("/mailTest")
-async def get_mail_test(request: Request):
- return templates.TemplateResponse("mailTest.html", {"request": request})
-
-@app.get("/inquiries")
-async def get_inquiries_page(request: Request):
- return templates.TemplateResponse("inquiries.html", {"request": request})
-
-@app.get("/analysis")
-async def get_analysis_page(request: Request):
- return templates.TemplateResponse("analysis.html", {"request": request})
-
-# --- ๋ฌธ์์ฌํญ API ---
-@app.get("/api/inquiries")
-async def get_inquiries(pm_type: str = None, category: str = None, status: str = None, keyword: str = None):
- try:
- with get_db_connection() as conn:
- with conn.cursor() as cursor:
- return InquiryService.get_inquiries_logic(cursor, pm_type, category, status, keyword)
- except Exception as e:
- return {"error": str(e)}
-
-@app.get("/api/inquiries/{id}")
-async def get_inquiry_detail(id: int):
- try:
- with get_db_connection() as conn:
- with conn.cursor() as cursor:
- return InquiryService.get_inquiry_detail_logic(cursor, id)
- except Exception as e:
- return {"error": str(e)}
-
-@app.post("/api/inquiries/{id}/reply")
-async def update_inquiry_reply(id: int, req: InquiryReplyRequest):
- try:
- with get_db_connection() as conn:
- with conn.cursor() as cursor:
- return InquiryService.update_inquiry_reply_logic(cursor, conn, id, req)
- except Exception as e:
- return {"error": str(e)}
-
-@app.delete("/api/inquiries/{id}/reply")
-async def delete_inquiry_reply(id: int):
- try:
- with get_db_connection() as conn:
- with conn.cursor() as cursor:
- return InquiryService.delete_inquiry_reply_logic(cursor, conn, id)
- except Exception as e:
- return {"error": str(e)}
-
-# --- ํ๋ก์ ํธ ๋ฐ ํ์คํ ๋ฆฌ API ---
-@app.get("/available-dates")
-async def get_available_dates():
- try:
- with get_db_connection() as conn:
- with conn.cursor() as cursor:
- return ProjectService.get_available_dates_logic(cursor)
- except Exception as e:
- return {"error": str(e)}
-
-@app.get("/project-data")
-async def get_project_data(date: str = None):
- try:
- with get_db_connection() as conn:
- with conn.cursor() as cursor:
- return ProjectService.get_project_data_logic(cursor, date)
- except Exception as e:
- return {"error": str(e)}
-
-# --- ๋ถ์ API (AnalysisService ์ฐ๋) ---
-@app.get("/project-activity")
-async def get_project_activity(date: str = None):
- try:
- with get_db_connection() as conn:
- with conn.cursor() as cursor:
- return AnalysisService.get_project_activity_logic(cursor, date)
- except Exception as e:
- return {"error": str(e)}
-
-@app.get("/api/analysis/p-war")
-async def get_p_war_analysis():
- try:
- with get_db_connection() as conn:
- with conn.cursor() as cursor:
- return AnalysisService.get_p_zsr_analysis_logic(cursor)
- except Exception as e:
- return {"error": str(e)}
-
-# --- ์์ง ๋ฐ ๋๊ธฐํ API ---
-@app.post("/auth/crawl")
-async def auth_crawl(req: AuthRequest):
- if req.user_id == os.getenv("PM_USER_ID") and req.password == os.getenv("PM_PASSWORD"):
- return {"success": True}
- return {"success": False, "message": "ํฌ๋กค๋ง์ ํ ์ ์์ต๋๋ค."}
-
-@app.get("/sync")
-async def sync_data():
- return StreamingResponse(run_crawler_service(), media_type="text_event-stream")
-
-@app.get("/stop-sync")
-async def stop_sync():
- crawl_stop_event.set()
- return {"success": True}
-
-# --- ํ์ผ ๋ฐ ์ฒจ๋ถํ์ผ API ---
-@app.get("/attachments")
-async def get_attachments():
- path = "sample"
- if not os.path.exists(path): os.makedirs(path)
- return [{"name": f, "size": f"{os.path.getsize(os.path.join(path, f))/1024:.1f} KB"}
- for f in os.listdir(path) if os.path.isfile(os.path.join(path, f))]
-
-@app.get("/analyze-file")
-async def analyze_file(filename: str):
- return await run_in_threadpool(analyze_file_content, filename)
-
-@app.get("/sample.png")
-async def get_sample_img():
- return FileResponse("sample.png")
+import os
+import sys
+import asyncio
+import pymysql
+from fastapi import FastAPI, Request
+from fastapi.middleware.cors import CORSMiddleware
+from fastapi.responses import StreamingResponse, FileResponse
+from fastapi.staticfiles import StaticFiles
+from fastapi.templating import Jinja2Templates
+
+from analyze import analyze_file_content
+from crawler_service import run_crawler_service, crawl_stop_event
+from schemas import AuthRequest, InquiryReplyRequest
+from inquiry_service import InquiryService
+from project_service import ProjectService
+from analysis_service import AnalysisService
+
+# --- ํ๊ฒฝ ์ค์ ---
+os.environ["PYTHONIOENCODING"] = "utf-8"
+TESSDATA_PREFIX = os.getenv("TESSDATA_PREFIX", r"C:\Users\User\AppData\Local\Programs\Tesseract-OCR\tessdata")
+os.environ["TESSDATA_PREFIX"] = TESSDATA_PREFIX
+
+app = FastAPI(title="Project Master Overseas API")
+templates = Jinja2Templates(directory="templates")
+
+# ์ ์ ํ์ผ ๋ง์ดํธ
+app.mount("/style", StaticFiles(directory="style"), name="style")
+app.mount("/js", StaticFiles(directory="js"), name="js")
+app.mount("/sample_files", StaticFiles(directory="sample"), name="sample_files")
+
+app.add_middleware(
+ CORSMiddleware,
+ allow_origins=["*"],
+ allow_credentials=False,
+ allow_methods=["*"],
+ allow_headers=["*"],
+)
+
+# --- ์ ํธ๋ฆฌํฐ ํจ์ ---
+def get_db_connection():
+ """MySQL ๋ฐ์ดํฐ๋ฒ ์ด์ค ์ฐ๊ฒฐ์ ๋ฐํ"""
+ return pymysql.connect(
+ host=os.getenv('DB_HOST', 'localhost'),
+ user=os.getenv('DB_USER', 'root'),
+ password=os.getenv('DB_PASSWORD', '45278434'),
+ database=os.getenv('DB_NAME', 'PM_proto'),
+ charset='utf8mb4',
+ cursorclass=pymysql.cursors.DictCursor
+ )
+
+async def run_in_threadpool(func, *args):
+ """๋๊ธฐ ํจ์๋ฅผ ๋น์ฐจ๋จ ๋ฐฉ์์ผ๋ก ์คํ"""
+ loop = asyncio.get_event_loop()
+ return await loop.run_in_executor(None, func, *args)
+
+# --- HTML ๋ผ์ฐํ
---
+@app.get("/")
+async def root(request: Request):
+ return templates.TemplateResponse("index.html", {"request": request})
+
+@app.get("/dashboard")
+async def get_dashboard(request: Request):
+ return templates.TemplateResponse("dashboard.html", {"request": request})
+
+@app.get("/mailTest")
+async def get_mail_test(request: Request):
+ return templates.TemplateResponse("mailTest.html", {"request": request})
+
+@app.get("/inquiries")
+async def get_inquiries_page(request: Request):
+ return templates.TemplateResponse("inquiries.html", {"request": request})
+
+@app.get("/analysis")
+async def get_analysis_page(request: Request):
+ return templates.TemplateResponse("analysis.html", {"request": request})
+
+# --- ๋ฌธ์์ฌํญ API ---
+@app.get("/api/inquiries")
+async def get_inquiries(pm_type: str = None, category: str = None, status: str = None, keyword: str = None):
+ try:
+ with get_db_connection() as conn:
+ with conn.cursor() as cursor:
+ return InquiryService.get_inquiries_logic(cursor, pm_type, category, status, keyword)
+ except Exception as e:
+ return {"error": str(e)}
+
+@app.get("/api/inquiries/{id}")
+async def get_inquiry_detail(id: int):
+ try:
+ with get_db_connection() as conn:
+ with conn.cursor() as cursor:
+ return InquiryService.get_inquiry_detail_logic(cursor, id)
+ except Exception as e:
+ return {"error": str(e)}
+
+@app.post("/api/inquiries/{id}/reply")
+async def update_inquiry_reply(id: int, req: InquiryReplyRequest):
+ try:
+ with get_db_connection() as conn:
+ with conn.cursor() as cursor:
+ return InquiryService.update_inquiry_reply_logic(cursor, conn, id, req)
+ except Exception as e:
+ return {"error": str(e)}
+
+@app.delete("/api/inquiries/{id}/reply")
+async def delete_inquiry_reply(id: int):
+ try:
+ with get_db_connection() as conn:
+ with conn.cursor() as cursor:
+ return InquiryService.delete_inquiry_reply_logic(cursor, conn, id)
+ except Exception as e:
+ return {"error": str(e)}
+
+# --- ํ๋ก์ ํธ ๋ฐ ํ์คํ ๋ฆฌ API ---
+@app.get("/available-dates")
+async def get_available_dates():
+ try:
+ with get_db_connection() as conn:
+ with conn.cursor() as cursor:
+ return ProjectService.get_available_dates_logic(cursor)
+ except Exception as e:
+ return {"error": str(e)}
+
+@app.get("/project-data")
+async def get_project_data(date: str = None):
+ try:
+ with get_db_connection() as conn:
+ with conn.cursor() as cursor:
+ return ProjectService.get_project_data_logic(cursor, date)
+ except Exception as e:
+ return {"error": str(e)}
+
+# --- ๋ถ์ API (AnalysisService ์ฐ๋) ---
+@app.get("/project-activity")
+async def get_project_activity(date: str = None):
+ try:
+ with get_db_connection() as conn:
+ with conn.cursor() as cursor:
+ return AnalysisService.get_project_activity_logic(cursor, date)
+ except Exception as e:
+ return {"error": str(e)}
+
+@app.get("/api/analysis/p-war")
+async def get_p_war_analysis():
+ try:
+ with get_db_connection() as conn:
+ with conn.cursor() as cursor:
+ return AnalysisService.get_p_zsr_analysis_logic(cursor)
+ except Exception as e:
+ return {"error": str(e)}
+
+# --- ์์ง ๋ฐ ๋๊ธฐํ API ---
+@app.post("/auth/crawl")
+async def auth_crawl(req: AuthRequest):
+ if req.user_id == os.getenv("PM_USER_ID") and req.password == os.getenv("PM_PASSWORD"):
+ return {"success": True}
+ return {"success": False, "message": "ํฌ๋กค๋ง์ ํ ์ ์์ต๋๋ค."}
+
+@app.get("/sync")
+async def sync_data():
+ return StreamingResponse(run_crawler_service(), media_type="text_event-stream")
+
+@app.get("/stop-sync")
+async def stop_sync():
+ crawl_stop_event.set()
+ return {"success": True}
+
+# --- ํ์ผ ๋ฐ ์ฒจ๋ถํ์ผ API ---
+@app.get("/attachments")
+async def get_attachments():
+ path = "sample"
+ if not os.path.exists(path): os.makedirs(path)
+ return [{"name": f, "size": f"{os.path.getsize(os.path.join(path, f))/1024:.1f} KB"}
+ for f in os.listdir(path) if os.path.isfile(os.path.join(path, f))]
+
+@app.get("/analyze-file")
+async def analyze_file(filename: str):
+ return await run_in_threadpool(analyze_file_content, filename)
+
+@app.get("/sample.png")
+async def get_sample_img():
+ return FileResponse("sample.png")
diff --git a/server_test.py b/server_test.py
new file mode 100644
index 0000000..bc0d762
--- /dev/null
+++ b/server_test.py
@@ -0,0 +1,190 @@
+import os
+import sys
+import asyncio
+import pymysql
+from fastapi import FastAPI, Request
+from fastapi.middleware.cors import CORSMiddleware
+from fastapi.responses import StreamingResponse, FileResponse
+from fastapi.staticfiles import StaticFiles
+from fastapi.templating import Jinja2Templates
+
+from analyze import analyze_file_content
+from crawler_service_test import run_crawler_service, crawl_stop_event
+from schemas import AuthRequest, InquiryReplyRequest
+from inquiry_service import InquiryService
+from project_service import ProjectService
+from analysis_service import AnalysisService
+
+# --- ํ๊ฒฝ ์ค์ ---
+os.environ["PYTHONIOENCODING"] = "utf-8"
+TESSDATA_PREFIX = os.getenv("TESSDATA_PREFIX", r"C:\Users\User\AppData\Local\Programs\Tesseract-OCR\tessdata")
+os.environ["TESSDATA_PREFIX"] = TESSDATA_PREFIX
+
+app = FastAPI(title="Project Master Overseas API (TEST MODE)")
+templates = Jinja2Templates(directory="templates")
+
+# ์ ์ ํ์ผ ๋ง์ดํธ
+app.mount("/style", StaticFiles(directory="style"), name="style")
+app.mount("/js", StaticFiles(directory="js"), name="js")
+app.mount("/sample_files", StaticFiles(directory="sample"), name="sample_files")
+
+app.add_middleware(
+ CORSMiddleware,
+ allow_origins=["*"],
+ allow_credentials=False,
+ allow_methods=["*"],
+ allow_headers=["*"],
+)
+
+# --- ์ ํธ๋ฆฌํฐ ํจ์ ---
+def get_db_connection():
+ """MySQL ๋ฐ์ดํฐ๋ฒ ์ด์ค(TEST) ์ฐ๊ฒฐ์ ๋ฐํ"""
+ return pymysql.connect(
+ host=os.getenv('DB_HOST', 'localhost'),
+ user=os.getenv('DB_USER', 'root'),
+ password=os.getenv('DB_PASSWORD', '45278434'),
+ database='PM_proto_test', # ํ
์คํธ์ฉ DB๋ก ๊ณ ์
+ charset='utf8mb4',
+ cursorclass=pymysql.cursors.DictCursor
+ )
+
+async def run_in_threadpool(func, *args):
+ """๋๊ธฐ ํจ์๋ฅผ ๋น์ฐจ๋จ ๋ฐฉ์์ผ๋ก ์คํ"""
+ loop = asyncio.get_event_loop()
+ return await loop.run_in_executor(None, func, *args)
+
+# --- HTML ๋ผ์ฐํ
---
+@app.get("/")
+async def root(request: Request):
+ return templates.TemplateResponse("index.html", {"request": request})
+
+@app.get("/dashboard")
+@app.get("/dashboard_test")
+async def get_dashboard_test(request: Request):
+ # ํ
์คํธ ํ๊ฒฝ์์๋ dashboard_test.html์ ์ฐ์ ์ ์ผ๋ก ๋ฐํ
+ return templates.TemplateResponse("dashboard_test.html", {"request": request})
+
+@app.get("/mailTest")
+async def get_mail_test(request: Request):
+ return templates.TemplateResponse("mailTest.html", {"request": request})
+
+@app.get("/inquiries")
+async def get_inquiries_page(request: Request):
+ return templates.TemplateResponse("inquiries.html", {"request": request})
+
+@app.get("/analysis")
+@app.get("/analysis_test")
+async def get_analysis_test(request: Request):
+ # ํ
์คํธ ํ๊ฒฝ์์๋ analysis_test.html์ ์ฐ์ ์ ์ผ๋ก ๋ฐํ
+ return templates.TemplateResponse("analysis_test.html", {"request": request})
+
+# --- ๋ฌธ์์ฌํญ API ---
+@app.get("/api/inquiries")
+async def get_inquiries(pm_type: str = None, category: str = None, status: str = None, keyword: str = None):
+ try:
+ with get_db_connection() as conn:
+ with conn.cursor() as cursor:
+ return InquiryService.get_inquiries_logic(cursor, pm_type, category, status, keyword)
+ except Exception as e:
+ return {"error": str(e)}
+
+@app.get("/api/inquiries/{id}")
+async def get_inquiry_detail(id: int):
+ try:
+ with get_db_connection() as conn:
+ with conn.cursor() as cursor:
+ return InquiryService.get_inquiry_detail_logic(cursor, id)
+ except Exception as e:
+ return {"error": str(e)}
+
+@app.post("/api/inquiries/{id}/reply")
+async def update_inquiry_reply(id: int, req: InquiryReplyRequest):
+ try:
+ with get_db_connection() as conn:
+ with conn.cursor() as cursor:
+ return InquiryService.update_inquiry_reply_logic(cursor, conn, id, req)
+ except Exception as e:
+ return {"error": str(e)}
+
+@app.delete("/api/inquiries/{id}/reply")
+async def delete_inquiry_reply(id: int):
+ try:
+ with get_db_connection() as conn:
+ with conn.cursor() as cursor:
+ return InquiryService.delete_inquiry_reply_logic(cursor, conn, id)
+ except Exception as e:
+ return {"error": str(e)}
+
+# --- ํ๋ก์ ํธ ๋ฐ ํ์คํ ๋ฆฌ API ---
+@app.get("/available-dates")
+async def get_available_dates():
+ try:
+ with get_db_connection() as conn:
+ with conn.cursor() as cursor:
+ return ProjectService.get_available_dates_logic(cursor)
+ except Exception as e:
+ return {"error": str(e)}
+
+@app.get("/project-data")
+async def get_project_data(date: str = None):
+ try:
+ with get_db_connection() as conn:
+ with conn.cursor() as cursor:
+ return ProjectService.get_project_data_logic(cursor, date)
+ except Exception as e:
+ return {"error": str(e)}
+
+# --- ๋ถ์ API (AnalysisService ์ฐ๋) ---
+@app.get("/project-activity")
+async def get_project_activity(date: str = None):
+ try:
+ with get_db_connection() as conn:
+ with conn.cursor() as cursor:
+ return AnalysisService.get_project_activity_logic(cursor, date)
+ except Exception as e:
+ return {"error": str(e)}
+
+@app.get("/api/analysis/p-war")
+async def get_p_war_analysis():
+ try:
+ with get_db_connection() as conn:
+ with conn.cursor() as cursor:
+ return AnalysisService.get_p_zsr_analysis_logic(cursor)
+ except Exception as e:
+ return {"error": str(e)}
+
+# --- ์์ง ๋ฐ ๋๊ธฐํ API ---
+@app.post("/auth/crawl")
+async def auth_crawl(req: AuthRequest):
+ if req.user_id == os.getenv("PM_USER_ID") and req.password == os.getenv("PM_PASSWORD"):
+ return {"success": True}
+ return {"success": False, "message": "ํฌ๋กค๋ง์ ํ ์ ์์ต๋๋ค."}
+
+@app.get("/sync")
+async def sync_data():
+ return StreamingResponse(run_crawler_service(), media_type="text_event-stream")
+
+@app.get("/stop-sync")
+async def stop_sync():
+ crawl_stop_event.set()
+ return {"success": True}
+
+# --- ํ์ผ ๋ฐ ์ฒจ๋ถํ์ผ API ---
+@app.get("/attachments")
+async def get_attachments():
+ path = "sample"
+ if not os.path.exists(path): os.makedirs(path)
+ return [{"name": f, "size": f"{os.path.getsize(os.path.join(path, f))/1024:.1f} KB"}
+ for f in os.listdir(path) if os.path.isfile(os.path.join(path, f))]
+
+@app.get("/analyze-file")
+async def analyze_file(filename: str):
+ return await run_in_threadpool(analyze_file_content, filename)
+
+@app.get("/sample.png")
+async def get_sample_img():
+ return FileResponse("sample.png")
+
+if __name__ == "__main__":
+ import uvicorn
+ uvicorn.run(app, host="0.0.0.0", port=8001)
diff --git a/sql_queries.py b/sql_queries.py
index fd61b1f..b74c0d1 100644
--- a/sql_queries.py
+++ b/sql_queries.py
@@ -1,67 +1,82 @@
-class InquiryQueries:
- """๋ฌธ์์ฌํญ(Inquiries) ํ์ด์ง ๊ด๋ จ ์ฟผ๋ฆฌ"""
- # ํํฐ๋ง์ ์ํ ๊ธฐ๋ณธ ์ฟผ๋ฆฌ (WHERE 1=1 ํฌํจ)
- SELECT_BASE = "SELECT * FROM inquiries WHERE 1=1"
- ORDER_BY_DESC = "ORDER BY no DESC"
-
- # ์์ธ ์กฐํ
- SELECT_BY_ID = "SELECT * FROM inquiries WHERE id = %s"
-
- # ๋ต๋ณ ์
๋ฐ์ดํธ (handled_date ํฌํจ)
- UPDATE_REPLY = """
- UPDATE inquiries
- SET reply = %s, status = %s, handler = %s, handled_date = %s
- WHERE id = %s
- """
-
- # ๋ต๋ณ ์ญ์ (์ด๊ธฐํ)
- DELETE_REPLY = """
- UPDATE inquiries
- SET reply = '', status = '๋ฏธํ์ธ', handled_date = ''
- WHERE id = %s
- """
-
-class DashboardQueries:
- """๋์๋ณด๋(Dashboard) ๋ฐ ํ๋ก์ ํธ ํํฉ ๊ด๋ จ ์ฟผ๋ฆฌ"""
- # ๊ฐ์ฉ ๋ ์ง ๋ชฉ๋ก ์กฐํ
- GET_AVAILABLE_DATES = "SELECT DISTINCT crawl_date FROM projects_history ORDER BY crawl_date DESC"
-
- # ์ต์ ์์ง ๋ ์ง ์กฐํ
- GET_LAST_CRAWL_DATE = "SELECT MAX(crawl_date) as last_date FROM projects_history"
-
- # ํน์ ๋ ์ง ํ๋ก์ ํธ ๋ฐ์ดํฐ JOIN ์กฐํ
- GET_PROJECT_LIST = """
- SELECT m.project_nm, m.short_nm, m.department, m.master,
- h.recent_log, h.file_count, m.continent, m.country
- FROM projects_master m
- LEFT JOIN projects_history h ON m.project_id = h.project_id AND h.crawl_date = %s
- ORDER BY m.project_id ASC
- """
-
- # ํ์ฑ๋ ๋ถ์์ ์ํ ํ๋ก์ ํธ ๋ชฉ๋ก ์กฐํ
- GET_PROJECT_LIST_FOR_ANALYSIS = """
- SELECT m.project_id, m.project_nm, m.short_nm, m.department, h.recent_log, h.file_count
- FROM projects_master m
- LEFT JOIN projects_history h ON m.project_id = h.project_id AND h.crawl_date = %s
- """
-
-class CrawlerQueries:
- """ํฌ๋กค๋ฌ(Crawler) ๋ฐ์ดํฐ ๋๊ธฐํ ๊ด๋ จ ์ฟผ๋ฆฌ"""
- # ๋ง์คํฐ ์ ๋ณด UPSERT (INSERT OR UPDATE)
- UPSERT_MASTER = """
- INSERT INTO projects_master (project_id, project_nm, short_nm, master, continent, country)
- VALUES (%s, %s, %s, %s, %s, %s)
- ON DUPLICATE KEY UPDATE
- project_nm = VALUES(project_nm), short_nm = VALUES(short_nm),
- master = VALUES(master), continent = VALUES(continent), country = VALUES(country)
- """
-
- # ๋ถ์ ์ ๋ณด ์
๋ฐ์ดํธ
- UPDATE_DEPARTMENT = "UPDATE projects_master SET department = %s WHERE project_id = %s"
-
- # ํ์คํ ๋ฆฌ(๋ก๊ทธ/ํ์ผ์) ์ ์ฅ
- UPSERT_HISTORY = """
- INSERT INTO projects_history (project_id, crawl_date, recent_log, file_count)
- VALUES (%s, CURRENT_DATE(), %s, %s)
- ON DUPLICATE KEY UPDATE recent_log=VALUES(recent_log), file_count=VALUES(file_count)
- """
+class InquiryQueries:
+ """๋ฌธ์์ฌํญ(Inquiries) ํ์ด์ง ๊ด๋ จ ์ฟผ๋ฆฌ"""
+ # ํํฐ๋ง์ ์ํ ๊ธฐ๋ณธ ์ฟผ๋ฆฌ (WHERE 1=1 ํฌํจ)
+ SELECT_BASE = "SELECT * FROM inquiries WHERE 1=1"
+ ORDER_BY_DESC = "ORDER BY no DESC"
+
+ # ์์ธ ์กฐํ
+ SELECT_BY_ID = "SELECT * FROM inquiries WHERE id = %s"
+
+ # ๋ต๋ณ ์
๋ฐ์ดํธ (handled_date ํฌํจ)
+ UPDATE_REPLY = """
+ UPDATE inquiries
+ SET reply = %s, status = %s, handler = %s, handled_date = %s
+ WHERE id = %s
+ """
+
+ # ๋ต๋ณ ์ญ์ (์ด๊ธฐํ)
+ DELETE_REPLY = """
+ UPDATE inquiries
+ SET reply = '', status = '๋ฏธํ์ธ', handled_date = ''
+ WHERE id = %s
+ """
+
+class DashboardQueries:
+ """๋์๋ณด๋(Dashboard) ๋ฐ ํ๋ก์ ํธ ํํฉ ๊ด๋ จ ์ฟผ๋ฆฌ"""
+ # ๊ฐ์ฉ ๋ ์ง ๋ชฉ๋ก ์กฐํ
+ GET_AVAILABLE_DATES = "SELECT DISTINCT crawl_date FROM projects_history ORDER BY crawl_date DESC"
+
+ # ์ต์ ์์ง ๋ ์ง ์กฐํ
+ GET_LAST_CRAWL_DATE = "SELECT MAX(crawl_date) as last_date FROM projects_history"
+
+ # ํน์ ๋ ์ง(๋๋ ๊ทธ ์ดํ ์ต์ ) ํ๋ก์ ํธ ๋ฐ์ดํฐ JOIN ์กฐํ
+ GET_PROJECT_LIST = """
+ SELECT m.project_nm, m.short_nm, m.department, m.master,
+ h.recent_log, h.file_count, m.continent, m.country
+ FROM projects_master m
+ LEFT JOIN projects_history h ON h.project_id = m.project_id AND h.crawl_date = (
+ SELECT MAX(crawl_date)
+ FROM projects_history
+ WHERE project_id = m.project_id AND crawl_date <= %s
+ )
+ ORDER BY m.project_id ASC
+ """
+
+ # ํ์ฑ๋ ๋ถ์์ ์ํ ํ๋ก์ ํธ ๋ชฉ๋ก ์กฐํ (ํน์ ๋ ์ง ์ดํ ์ต์ ๋ฐ์ดํฐ ๊ธฐ์ค)
+ GET_PROJECT_LIST_FOR_ANALYSIS = """
+ SELECT m.project_id, m.project_nm, m.short_nm, m.department, h.recent_log, h.file_count
+ FROM projects_master m
+ LEFT JOIN projects_history h ON h.project_id = m.project_id AND h.crawl_date = (
+ SELECT MAX(crawl_date)
+ FROM projects_history
+ WHERE project_id = m.project_id AND crawl_date <= %s
+ )
+ """
+
+class CrawlerQueries:
+ """ํฌ๋กค๋ฌ(Crawler) ๋ฐ์ดํฐ ๋๊ธฐํ ๊ด๋ จ ์ฟผ๋ฆฌ"""
+ # ๋ง์คํฐ ์ ๋ณด UPSERT (INSERT OR UPDATE)
+ UPSERT_MASTER = """
+ INSERT INTO projects_master (project_id, project_nm, short_nm, master, continent, country)
+ VALUES (%s, %s, %s, %s, %s, %s)
+ ON DUPLICATE KEY UPDATE
+ project_nm = VALUES(project_nm), short_nm = VALUES(short_nm),
+ master = VALUES(master), continent = VALUES(continent), country = VALUES(country)
+ """
+
+ # ๋ถ์ ์ ๋ณด ์
๋ฐ์ดํธ
+ UPDATE_DEPARTMENT = "UPDATE projects_master SET department = %s WHERE project_id = %s"
+
+ # ํ์คํ ๋ฆฌ(๋ก๊ทธ/ํ์ผ์) ์ ์ฅ (๋ ์ง ์ง์ ํ)
+ UPSERT_HISTORY_WITH_DATE = """
+ INSERT INTO projects_history (project_id, crawl_date, recent_log, file_count)
+ VALUES (%s, %s, %s, %s)
+ ON DUPLICATE KEY UPDATE recent_log=VALUES(recent_log), file_count=VALUES(file_count)
+ """
+
+ # ํ์คํ ๋ฆฌ(๋ก๊ทธ/ํ์ผ์) ์ ์ฅ (๊ธฐ๋ณธํ - ์ค๋ ๋ ์ง)
+ UPSERT_HISTORY = """
+ INSERT INTO projects_history (project_id, crawl_date, recent_log, file_count)
+ VALUES (%s, CURDATE(), %s, %s)
+ ON DUPLICATE KEY UPDATE recent_log=VALUES(recent_log), file_count=VALUES(file_count)
+ """
diff --git a/style/analysis.css b/style/analysis.css
index e9d9690..e8e28a1 100644
--- a/style/analysis.css
+++ b/style/analysis.css
@@ -1,233 +1,233 @@
-/* ==========================================================================
- Project Master Analysis - Specific Styles
- (Inherits base styles from common.css)
- ========================================================================== */
-
-.analysis-content {
- padding: 24px;
- max-width: 1400px;
- margin: var(--topbar-h, 36px) auto 0;
-}
-
-/* AI Badge & Header */
-.ai-badge {
- background: #6366f1;
- color: white;
- padding: 2px 10px;
- border-radius: 20px;
- font-size: 11px;
- font-weight: 800;
- display: inline-block;
- margin-bottom: 8px;
-}
-
-.analysis-header {
- display: flex;
- justify-content: space-between;
- align-items: center;
- margin-bottom: 24px;
- padding: 10px 0;
- border-bottom: 1px solid var(--border-color);
-}
-
-.analysis-header h2 { font-size: 22px; font-weight: 800; color: var(--text-main); margin-bottom: 4px; }
-.analysis-header p { font-size: 13px; color: var(--text-sub); }
-
-/* Top Info Grid */
-.top-info-grid {
- display: grid;
- grid-template-columns: 1fr 2.2fr;
- gap: 16px;
- margin-bottom: 24px;
-}
-
-.dl-model-info, .soi-deep-dive {
- background: white;
- border-radius: var(--radius-xl);
- border: 1px solid var(--border-color);
- padding: 20px;
- box-shadow: var(--box-shadow);
-}
-
-.card-header { margin-bottom: 15px; display: flex; align-items: center; justify-content: space-between; }
-.card-header h4 { font-size: 14px; font-weight: 800; color: var(--primary-color); margin: 0; }
-
-.model-desc-vertical { display: flex; flex-direction: column; gap: 12px; }
-.model-item-vertical { display: flex; align-items: center; gap: 12px; }
-.model-tag { background: var(--bg-muted); color: var(--text-sub); padding: 2px 8px; border-radius: 4px; font-size: 10px; font-weight: 700; }
-
-.soi-info-columns { display: grid; grid-template-columns: repeat(3, 1fr); gap: 20px; }
-.soi-info-column h6 { font-size: 12px; font-weight: 800; color: var(--primary-color); margin: 0 0 8px 0; }
-.soi-info-column p { font-size: 11.5px; color: var(--text-sub); line-height: 1.6; margin: 0; }
-
-/* Chart Grid Layout */
-.analysis-charts-grid {
- display: grid;
- grid-template-columns: 1fr 1.8fr;
- gap: 20px;
- margin-bottom: 24px;
-}
-
-.chart-container-box {
- background: white;
- border-radius: var(--radius-xl);
- padding: 20px;
- border: 1px solid var(--border-color);
- height: 360px;
- display: flex;
- flex-direction: column;
- box-shadow: var(--box-shadow);
-}
-
-.chart-container-box h5 { margin: 0 0 15px 0; font-size: 13px; font-weight: 700; color: var(--text-main); }
-
-/* Timeline Analysis Card */
-.analysis-card {
- background: white;
- border-radius: var(--radius-xl);
- border: 1px solid var(--border-color);
- box-shadow: var(--box-shadow);
- margin-bottom: 24px;
- overflow: hidden;
-}
-
-.analysis-card .card-header {
- padding: 16px 24px;
- background: #fff;
- border-bottom: 1px solid var(--border-color);
-}
-
-.analysis-card .card-body { padding: 24px; }
-
-/* SOI Guide Styles */
-.d-war-guide {
- display: flex;
- gap: 10px;
- margin-bottom: 20px;
- padding: 12px;
- background: var(--bg-muted);
- border-radius: var(--radius-lg);
-}
-
-.guide-item {
- font-size: 11px;
- font-weight: 700;
- padding: 4px 10px;
- border-radius: 4px;
- display: flex;
- align-items: center;
- gap: 6px;
-}
-
-.guide-item.active-low { background: #dcfce7; color: #166534; }
-.guide-item.warning-mid { background: #fef9c3; color: #854d0e; }
-.guide-item.danger-high { background: #ffedd5; color: #9a3412; }
-.guide-item.hazard-critical { background: #fee2e2; color: #991b1b; }
-
-/* Data Table Customization */
-.table-scroll-wrapper {
- overflow-x: auto;
- overflow-y: auto;
- max-height: 600px;
- border-radius: var(--radius-lg);
- border: 1px solid var(--border-color);
- background: white;
-}
-
-.p-war-table {
- width: 100%;
- border-collapse: separate;
- border-spacing: 0;
- table-layout: fixed; /* ์ปฌ๋ผ ๋๋น ๊ณ ์ */
-}
-
-.p-war-table th {
- position: sticky;
- top: 0;
- z-index: 20;
- background: #f8fafc;
- padding: 16px 15px;
- font-size: 12px;
- font-weight: 800;
- color: #475569;
- border-bottom: 2px solid #e2e8f0;
- white-space: nowrap;
-}
-
-.p-war-table td {
- padding: 14px 15px;
- font-size: 13px;
- border-bottom: 1px solid #f1f5f9;
- vertical-align: middle;
-}
-
-/* ์ปฌ๋ผ๋ณ ๋๋น ์ ์ */
-.p-war-table th:nth-child(1), .p-war-table td:nth-child(1) { width: 28%; text-align: left; } /* ํ๋ก์ ํธ๋ช
*/
-.p-war-table th:nth-child(2), .p-war-table td:nth-child(2) { width: 10%; text-align: right; } /* ํ์ผ ์ */
-.p-war-table th:nth-child(3), .p-war-table td:nth-child(3) { width: 10%; text-align: right; } /* ๋ฐฉ์น์ผ */
-.p-war-table th:nth-child(4), .p-war-table td:nth-child(4) { width: 10%; text-align: center; } /* ์ํ ํ์ */
-.p-war-table th:nth-child(5), .p-war-table td:nth-child(5) { width: 14%; text-align: right; } /* P-WAR+ */
-.p-war-table th:nth-child(6), .p-war-table td:nth-child(6) { width: 12%; text-align: right; } /* ํ์ฌ SOI */
-.p-war-table th:nth-child(7), .p-war-table td:nth-child(7) { width: 12%; text-align: center; } /* ์ค๋ฌด ํฌ์
*/
-.p-war-table th:nth-child(8), .p-war-table td:nth-child(8) { width: 14%; text-align: center; } /* AI ์๋ณด */
-
-.project-row { cursor: pointer; transition: background 0.2s; }
-.project-row:hover { background: var(--hover-bg) !important; }
-
-/* SOI Value Styling */
-.p-war-value { font-weight: 800; font-family: 'Consolas', monospace; }
-
-/* Accordion Detail Styles */
-.detail-row { display: none; background: #fafafa; }
-.detail-row.active { display: table-row; }
-.detail-container { padding: 20px 24px; }
-
-.formula-explanation-card {
- background: white;
- border-radius: var(--radius-lg);
- padding: 24px;
- border: 1px solid var(--border-color);
- box-shadow: var(--box-shadow);
-}
-
-.formula-header { font-size: 13px; font-weight: 700; color: #6366f1; margin-bottom: 15px; }
-
-/* Work Effort Section */
-.work-effort-section { background: var(--bg-muted); padding: 16px; border-radius: var(--radius-lg); margin-bottom: 20px; }
-.work-effort-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; }
-.work-effort-bar-bg { width: 100%; height: 6px; background: #e2e8f0; border-radius: 3px; overflow: hidden; margin-bottom: 10px; }
-
-/* Formula Steps Grid */
-.formula-steps-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; }
-.formula-step { display: flex; gap: 12px; }
-.step-num { background: var(--primary-color); color: white; width: 20px; height: 20px; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 11px; font-weight: 800; flex-shrink: 0; }
-.step-title { font-size: 12px; font-weight: 700; color: var(--text-main); margin-bottom: 4px; }
-.math-logic { font-family: 'Consolas', monospace; background: var(--bg-muted); padding: 4px 8px; border-radius: 4px; font-weight: 700; color: var(--text-main); font-size: 12px; display: inline-block; }
-
-.final-result-area { margin-top: 20px; padding-top: 15px; display: flex; justify-content: space-between; align-items: center; }
-
-/* Modal Analysis Specific */
-.modal-footer {
- padding: 16px 24px;
- background: #fff;
- border-top: 1px solid var(--border-color);
- text-align: right;
- display: flex;
- justify-content: flex-end;
-}
-
-/* Formula & Badges */
-.formula-section { margin-bottom: 20px; }
-.formula-box { background: var(--primary-lv-0); color: var(--primary-color); padding: 15px; border-radius: var(--radius-lg); font-weight: 800; text-align: center; font-family: monospace; font-size: 16px; }
-
-.badge-active { background: #dcfce7; color: #166534; padding: 2px 8px; border-radius: 4px; font-size: 11px; font-weight: 700; }
-.badge-warning { background: #fef9c3; color: #854d0e; padding: 2px 8px; border-radius: 4px; font-size: 11px; font-weight: 700; }
-.badge-danger { background: #ffedd5; color: #9a3412; padding: 2px 8px; border-radius: 4px; font-size: 11px; font-weight: 700; }
-.badge-system { background: #fee2e2; color: #991b1b; padding: 2px 8px; border-radius: 4px; font-size: 11px; font-weight: 700; }
-
-.highlight-var { color: #2563eb; }
-.highlight-val { color: #059669; }
-.highlight-penalty { color: #dc2626; }
-.text-plus { color: #059669; font-weight: 700; }
-.text-minus { color: #dc2626; font-weight: 700; }
-.font-bold { font-weight: 700; }
+/* ==========================================================================
+ Project Master Analysis - Specific Styles
+ (Inherits base styles from common.css)
+ ========================================================================== */
+
+.analysis-content {
+ padding: 24px;
+ max-width: 1400px;
+ margin: var(--topbar-h, 36px) auto 0;
+}
+
+/* AI Badge & Header */
+.ai-badge {
+ background: #6366f1;
+ color: white;
+ padding: 2px 10px;
+ border-radius: 20px;
+ font-size: 11px;
+ font-weight: 800;
+ display: inline-block;
+ margin-bottom: 8px;
+}
+
+.analysis-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 24px;
+ padding: 10px 0;
+ border-bottom: 1px solid var(--border-color);
+}
+
+.analysis-header h2 { font-size: 22px; font-weight: 800; color: var(--text-main); margin-bottom: 4px; }
+.analysis-header p { font-size: 13px; color: var(--text-sub); }
+
+/* Top Info Grid */
+.top-info-grid {
+ display: grid;
+ grid-template-columns: 1fr 2.2fr;
+ gap: 16px;
+ margin-bottom: 24px;
+}
+
+.dl-model-info, .soi-deep-dive {
+ background: white;
+ border-radius: var(--radius-xl);
+ border: 1px solid var(--border-color);
+ padding: 20px;
+ box-shadow: var(--box-shadow);
+}
+
+.card-header { margin-bottom: 15px; display: flex; align-items: center; justify-content: space-between; }
+.card-header h4 { font-size: 14px; font-weight: 800; color: var(--primary-color); margin: 0; }
+
+.model-desc-vertical { display: flex; flex-direction: column; gap: 12px; }
+.model-item-vertical { display: flex; align-items: center; gap: 12px; }
+.model-tag { background: var(--bg-muted); color: var(--text-sub); padding: 2px 8px; border-radius: 4px; font-size: 10px; font-weight: 700; }
+
+.soi-info-columns { display: grid; grid-template-columns: repeat(3, 1fr); gap: 20px; }
+.soi-info-column h6 { font-size: 12px; font-weight: 800; color: var(--primary-color); margin: 0 0 8px 0; }
+.soi-info-column p { font-size: 11.5px; color: var(--text-sub); line-height: 1.6; margin: 0; }
+
+/* Chart Grid Layout */
+.analysis-charts-grid {
+ display: grid;
+ grid-template-columns: 1fr 1.8fr;
+ gap: 20px;
+ margin-bottom: 24px;
+}
+
+.chart-container-box {
+ background: white;
+ border-radius: var(--radius-xl);
+ padding: 20px;
+ border: 1px solid var(--border-color);
+ height: 360px;
+ display: flex;
+ flex-direction: column;
+ box-shadow: var(--box-shadow);
+}
+
+.chart-container-box h5 { margin: 0 0 15px 0; font-size: 13px; font-weight: 700; color: var(--text-main); }
+
+/* Timeline Analysis Card */
+.analysis-card {
+ background: white;
+ border-radius: var(--radius-xl);
+ border: 1px solid var(--border-color);
+ box-shadow: var(--box-shadow);
+ margin-bottom: 24px;
+ overflow: hidden;
+}
+
+.analysis-card .card-header {
+ padding: 16px 24px;
+ background: #fff;
+ border-bottom: 1px solid var(--border-color);
+}
+
+.analysis-card .card-body { padding: 24px; }
+
+/* SOI Guide Styles */
+.d-war-guide {
+ display: flex;
+ gap: 10px;
+ margin-bottom: 20px;
+ padding: 12px;
+ background: var(--bg-muted);
+ border-radius: var(--radius-lg);
+}
+
+.guide-item {
+ font-size: 11px;
+ font-weight: 700;
+ padding: 4px 10px;
+ border-radius: 4px;
+ display: flex;
+ align-items: center;
+ gap: 6px;
+}
+
+.guide-item.active-low { background: #dcfce7; color: #166534; }
+.guide-item.warning-mid { background: #fef9c3; color: #854d0e; }
+.guide-item.danger-high { background: #ffedd5; color: #9a3412; }
+.guide-item.hazard-critical { background: #fee2e2; color: #991b1b; }
+
+/* Data Table Customization */
+.table-scroll-wrapper {
+ overflow-x: auto;
+ overflow-y: auto;
+ max-height: 600px;
+ border-radius: var(--radius-lg);
+ border: 1px solid var(--border-color);
+ background: white;
+}
+
+.p-war-table {
+ width: 100%;
+ border-collapse: separate;
+ border-spacing: 0;
+ table-layout: fixed; /* ์ปฌ๋ผ ๋๋น ๊ณ ์ */
+}
+
+.p-war-table th {
+ position: sticky;
+ top: 0;
+ z-index: 20;
+ background: #f8fafc;
+ padding: 16px 15px;
+ font-size: 12px;
+ font-weight: 800;
+ color: #475569;
+ border-bottom: 2px solid #e2e8f0;
+ white-space: nowrap;
+}
+
+.p-war-table td {
+ padding: 14px 15px;
+ font-size: 13px;
+ border-bottom: 1px solid #f1f5f9;
+ vertical-align: middle;
+}
+
+/* ์ปฌ๋ผ๋ณ ๋๋น ์ ์ */
+.p-war-table th:nth-child(1), .p-war-table td:nth-child(1) { width: 28%; text-align: left; } /* ํ๋ก์ ํธ๋ช
*/
+.p-war-table th:nth-child(2), .p-war-table td:nth-child(2) { width: 10%; text-align: right; } /* ํ์ผ ์ */
+.p-war-table th:nth-child(3), .p-war-table td:nth-child(3) { width: 10%; text-align: right; } /* ๋ฐฉ์น์ผ */
+.p-war-table th:nth-child(4), .p-war-table td:nth-child(4) { width: 10%; text-align: center; } /* ์ํ ํ์ */
+.p-war-table th:nth-child(5), .p-war-table td:nth-child(5) { width: 14%; text-align: right; } /* P-WAR+ */
+.p-war-table th:nth-child(6), .p-war-table td:nth-child(6) { width: 12%; text-align: right; } /* ํ์ฌ SOI */
+.p-war-table th:nth-child(7), .p-war-table td:nth-child(7) { width: 12%; text-align: center; } /* ์ค๋ฌด ํฌ์
*/
+.p-war-table th:nth-child(8), .p-war-table td:nth-child(8) { width: 14%; text-align: center; } /* AI ์๋ณด */
+
+.project-row { cursor: pointer; transition: background 0.2s; }
+.project-row:hover { background: var(--hover-bg) !important; }
+
+/* SOI Value Styling */
+.p-war-value { font-weight: 800; font-family: 'Consolas', monospace; }
+
+/* Accordion Detail Styles */
+.detail-row { display: none; background: #fafafa; }
+.detail-row.active { display: table-row; }
+.detail-container { padding: 20px 24px; }
+
+.formula-explanation-card {
+ background: white;
+ border-radius: var(--radius-lg);
+ padding: 24px;
+ border: 1px solid var(--border-color);
+ box-shadow: var(--box-shadow);
+}
+
+.formula-header { font-size: 13px; font-weight: 700; color: #6366f1; margin-bottom: 15px; }
+
+/* Work Effort Section */
+.work-effort-section { background: var(--bg-muted); padding: 16px; border-radius: var(--radius-lg); margin-bottom: 20px; }
+.work-effort-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; }
+.work-effort-bar-bg { width: 100%; height: 6px; background: #e2e8f0; border-radius: 3px; overflow: hidden; margin-bottom: 10px; }
+
+/* Formula Steps Grid */
+.formula-steps-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; }
+.formula-step { display: flex; gap: 12px; }
+.step-num { background: var(--primary-color); color: white; width: 20px; height: 20px; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 11px; font-weight: 800; flex-shrink: 0; }
+.step-title { font-size: 12px; font-weight: 700; color: var(--text-main); margin-bottom: 4px; }
+.math-logic { font-family: 'Consolas', monospace; background: var(--bg-muted); padding: 4px 8px; border-radius: 4px; font-weight: 700; color: var(--text-main); font-size: 12px; display: inline-block; }
+
+.final-result-area { margin-top: 20px; padding-top: 15px; display: flex; justify-content: space-between; align-items: center; }
+
+/* Modal Analysis Specific */
+.modal-footer {
+ padding: 16px 24px;
+ background: #fff;
+ border-top: 1px solid var(--border-color);
+ text-align: right;
+ display: flex;
+ justify-content: flex-end;
+}
+
+/* Formula & Badges */
+.formula-section { margin-bottom: 20px; }
+.formula-box { background: var(--primary-lv-0); color: var(--primary-color); padding: 15px; border-radius: var(--radius-lg); font-weight: 800; text-align: center; font-family: monospace; font-size: 16px; }
+
+.badge-active { background: #dcfce7; color: #166534; padding: 2px 8px; border-radius: 4px; font-size: 11px; font-weight: 700; }
+.badge-warning { background: #fef9c3; color: #854d0e; padding: 2px 8px; border-radius: 4px; font-size: 11px; font-weight: 700; }
+.badge-danger { background: #ffedd5; color: #9a3412; padding: 2px 8px; border-radius: 4px; font-size: 11px; font-weight: 700; }
+.badge-system { background: #fee2e2; color: #991b1b; padding: 2px 8px; border-radius: 4px; font-size: 11px; font-weight: 700; }
+
+.highlight-var { color: #2563eb; }
+.highlight-val { color: #059669; }
+.highlight-penalty { color: #dc2626; }
+.text-plus { color: #059669; font-weight: 700; }
+.text-minus { color: #dc2626; font-weight: 700; }
+.font-bold { font-weight: 700; }
diff --git a/style/common.css b/style/common.css
index 00d5608..2ae2fe0 100644
--- a/style/common.css
+++ b/style/common.css
@@ -1,170 +1,170 @@
-:root {
- /* 1. Core Colors */
- --primary-color: #1E5149;
- --primary-hover: #163b36;
- --primary-lv-0: #f0f7f4;
- --primary-lv-1: #e1eee9;
- --primary-lv-8: #193833;
-
- --bg-default: #FFFFFF;
- --bg-muted: #F9FAFB;
- --hover-bg: #F7FAFC;
-
- --text-main: #111827;
- --text-sub: #6B7280;
- --error-color: #F21D0D;
- --border-color: #E2E8F0;
-
- /* 2. Gradients */
- --header-gradient: linear-gradient(90deg, #193833 0%, #1e5149 100%);
- --ai-gradient: linear-gradient(180deg, #da8cf1 0%, #8bb1f2 100%);
-
- /* 3. Spacing & Radius */
- --space-xs: 4px;
- --space-sm: 8px;
- --space-md: 16px;
- --space-lg: 32px;
- --radius-sm: 4px;
- --radius-md: 6px;
- --radius-lg: 8px;
- --radius-xl: 12px;
-
- /* 4. Typography */
- --fz-h1: 20px;
- --fz-h2: 16px;
- --fz-body: 13px;
- --fz-small: 11px;
-
- /* 5. Shadows */
- --box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.05);
- --box-shadow-lg: 0 10px 20px rgba(0, 0, 0, 0.1);
- --box-shadow-modal: 0 25px 50px -12px rgba(0,0,0,0.5);
-
- /* 6. Layout Constants */
- --topbar-h: 36px;
-}
-
-/* Base Reset */
-* { margin: 0; padding: 0; box-sizing: border-box; }
-body {
- font-family: 'Pretendard', -apple-system, sans-serif;
- font-size: var(--fz-body);
- color: var(--text-main);
- background: var(--bg-default);
- min-height: 100vh;
-}
-
-/* Page Specific Overrides */
-body:has(.mail-wrapper) { height: 100vh; overflow: hidden; }
-
-input, select, textarea, button { font-family: inherit; }
-a { text-decoration: none; color: inherit; }
-button { cursor: pointer; border: none; transition: all 0.2s ease; }
-
-/* Utilities: Layout & Text */
-.flex-center { display: flex; align-items: center; justify-content: center; }
-.flex-between { display: flex; align-items: center; justify-content: space-between; }
-.flex-column { display: flex; flex-direction: column; }
-.text-truncate { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
-.w-full { width: 100%; }
-.pointer { cursor: pointer; }
-
-/* Components: Topbar */
-.topbar {
- width: 100%;
- background: var(--header-gradient);
- color: #fff;
- padding: 0 var(--space-lg);
- position: fixed;
- top: 0;
- height: var(--topbar-h);
- display: flex;
- align-items: center;
- z-index: 2000;
-}
-.topbar-header h2 { font-size: 16px; color: white; margin-right: 60px; font-weight: 700; }
-.nav-list { display: flex; list-style: none; gap: var(--space-sm); }
-.nav-item {
- padding: 4px 12px; border-radius: var(--radius-sm);
- color: rgba(255, 255, 255, 0.8); font-size: 14px;
- cursor: pointer;
-}
-.nav-item:hover { background: var(--primary-lv-8); color: #fff; }
-.nav-item.active { background: var(--primary-lv-0); color: var(--primary-color) !important; font-weight: 700; }
-
-/* Components: Modals */
-.modal-overlay {
- display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%;
- background: rgba(0, 0, 0, 0.6); backdrop-filter: blur(4px);
- z-index: 3000; justify-content: center; align-items: center;
-}
-.modal-content {
- background: white; padding: 24px; border-radius: var(--radius-xl);
- width: 90%; max-width: 500px; box-shadow: var(--box-shadow-modal);
-}
-.modal-header {
- display: flex; justify-content: space-between; align-items: center;
- margin-bottom: 20px; border-bottom: 1px solid var(--border-color); padding-bottom: 12px;
-}
-.modal-header h3 { margin: 0; font-size: 16px; color: var(--primary-color); font-weight: 700; }
-.modal-close { cursor: pointer; font-size: 24px; color: var(--text-sub); line-height: 1; transition: 0.2s; }
-.modal-close:hover { color: var(--text-main); }
-
-/* Components: Data Tables */
-.data-table { width: 100%; border-collapse: collapse; font-size: 12px; background: #fff; }
-.data-table th, .data-table td { padding: 12px 10px; border-bottom: 1px solid var(--border-color); text-align: left; }
-.data-table th { color: var(--text-sub); font-weight: 700; background: var(--bg-muted); font-size: 11px; text-transform: uppercase; }
-.data-table tr:hover { background: var(--hover-bg); }
-
-/* Components: Standard Buttons */
-.btn {
- display: inline-flex; align-items: center; justify-content: center; gap: 8px;
- padding: 8px 16px; border-radius: var(--radius-lg); font-weight: 600; font-size: 13px;
- border: none; cursor: pointer; transition: all 0.2s ease;
-}
-.btn-primary { background: var(--primary-color); color: #fff; }
-.btn-primary:hover { background: var(--primary-hover); transform: translateY(-1px); }
-.btn-secondary { background: #f1f3f5; color: #495057; }
-.btn-secondary:hover { background: #e9ecef; }
-.btn-danger { background: #fee2e2; color: #dc2626; }
-.btn-danger:hover { background: #fecaca; }
-
-/* Compatibility Utils */
-._button-xsmall {
- display: inline-flex; align-items: center; justify-content: center;
- padding: 4px 10px; font-size: 11px; font-weight: 600; border-radius: 4px; border: 1px solid var(--border-color);
- background: #fff; color: var(--text-main); cursor: pointer; transition: 0.2s;
-}
-._button-xsmall:hover { background: var(--bg-muted); border-color: var(--primary-color); color: var(--primary-color); }
-
-._button-small {
- display: inline-flex; align-items: center; justify-content: center;
- padding: 6px 14px; font-size: 12px; background: var(--primary-color); color: #fff; border-radius: 6px; border: none; cursor: pointer;
-}
-._button-medium {
- display: inline-flex; align-items: center; justify-content: center;
- padding: 10px 20px; background: var(--primary-color); color: #fff; border-radius: 6px; font-weight: 700; border: none; cursor: pointer;
-}
-.sync-btn { background: var(--primary-color); color: #fff; padding: 8px 16px; border-radius: 8px; font-size: 13px; font-weight: 600; }
-
-/* Badges & Status Colors */
-.badge {
- padding: 2px 8px; border-radius: 4px; font-size: 10px; font-weight: 700;
- display: inline-block; background: var(--primary-lv-1); color: var(--primary-color);
-}
-
-.status-complete { background: #e8f5e9; color: #2e7d32; }
-.status-working { background: #fff8e1; color: #FFBF00; }
-.status-checking { background: #e3f2fd; color: #1565c0; }
-.status-pending { background: #f5f5f5; color: #757575; }
-.status-error { background: #fee9e7; }
-
-.warning-text { color: #FFBF00; font-weight: 600; }
-.error-text { color: #F21D0D !important; font-weight: 700; }
-
-/* Spinner */
-.spinner {
- display: none; width: 16px; height: 16px; border: 2px solid rgba(255, 255, 255, .3);
- border-radius: 50%; border-top-color: #fff; animation: spin 1s ease-in-out infinite;
-}
-@keyframes spin { to { transform: rotate(360deg); } }
+:root {
+ /* 1. Core Colors */
+ --primary-color: #1E5149;
+ --primary-hover: #163b36;
+ --primary-lv-0: #f0f7f4;
+ --primary-lv-1: #e1eee9;
+ --primary-lv-8: #193833;
+
+ --bg-default: #FFFFFF;
+ --bg-muted: #F9FAFB;
+ --hover-bg: #F7FAFC;
+
+ --text-main: #111827;
+ --text-sub: #6B7280;
+ --error-color: #F21D0D;
+ --border-color: #E2E8F0;
+
+ /* 2. Gradients */
+ --header-gradient: linear-gradient(90deg, #193833 0%, #1e5149 100%);
+ --ai-gradient: linear-gradient(180deg, #da8cf1 0%, #8bb1f2 100%);
+
+ /* 3. Spacing & Radius */
+ --space-xs: 4px;
+ --space-sm: 8px;
+ --space-md: 16px;
+ --space-lg: 32px;
+ --radius-sm: 4px;
+ --radius-md: 6px;
+ --radius-lg: 8px;
+ --radius-xl: 12px;
+
+ /* 4. Typography */
+ --fz-h1: 20px;
+ --fz-h2: 16px;
+ --fz-body: 13px;
+ --fz-small: 11px;
+
+ /* 5. Shadows */
+ --box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.05);
+ --box-shadow-lg: 0 10px 20px rgba(0, 0, 0, 0.1);
+ --box-shadow-modal: 0 25px 50px -12px rgba(0,0,0,0.5);
+
+ /* 6. Layout Constants */
+ --topbar-h: 36px;
+}
+
+/* Base Reset */
+* { margin: 0; padding: 0; box-sizing: border-box; }
+body {
+ font-family: 'Pretendard', -apple-system, sans-serif;
+ font-size: var(--fz-body);
+ color: var(--text-main);
+ background: var(--bg-default);
+ min-height: 100vh;
+}
+
+/* Page Specific Overrides */
+body:has(.mail-wrapper) { height: 100vh; overflow: hidden; }
+
+input, select, textarea, button { font-family: inherit; }
+a { text-decoration: none; color: inherit; }
+button { cursor: pointer; border: none; transition: all 0.2s ease; }
+
+/* Utilities: Layout & Text */
+.flex-center { display: flex; align-items: center; justify-content: center; }
+.flex-between { display: flex; align-items: center; justify-content: space-between; }
+.flex-column { display: flex; flex-direction: column; }
+.text-truncate { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.w-full { width: 100%; }
+.pointer { cursor: pointer; }
+
+/* Components: Topbar */
+.topbar {
+ width: 100%;
+ background: var(--header-gradient);
+ color: #fff;
+ padding: 0 var(--space-lg);
+ position: fixed;
+ top: 0;
+ height: var(--topbar-h);
+ display: flex;
+ align-items: center;
+ z-index: 2000;
+}
+.topbar-header h2 { font-size: 16px; color: white; margin-right: 60px; font-weight: 700; }
+.nav-list { display: flex; list-style: none; gap: var(--space-sm); }
+.nav-item {
+ padding: 4px 12px; border-radius: var(--radius-sm);
+ color: rgba(255, 255, 255, 0.8); font-size: 14px;
+ cursor: pointer;
+}
+.nav-item:hover { background: var(--primary-lv-8); color: #fff; }
+.nav-item.active { background: var(--primary-lv-0); color: var(--primary-color) !important; font-weight: 700; }
+
+/* Components: Modals */
+.modal-overlay {
+ display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%;
+ background: rgba(0, 0, 0, 0.6); backdrop-filter: blur(4px);
+ z-index: 3000; justify-content: center; align-items: center;
+}
+.modal-content {
+ background: white; padding: 24px; border-radius: var(--radius-xl);
+ width: 90%; max-width: 500px; box-shadow: var(--box-shadow-modal);
+}
+.modal-header {
+ display: flex; justify-content: space-between; align-items: center;
+ margin-bottom: 20px; border-bottom: 1px solid var(--border-color); padding-bottom: 12px;
+}
+.modal-header h3 { margin: 0; font-size: 16px; color: var(--primary-color); font-weight: 700; }
+.modal-close { cursor: pointer; font-size: 24px; color: var(--text-sub); line-height: 1; transition: 0.2s; }
+.modal-close:hover { color: var(--text-main); }
+
+/* Components: Data Tables */
+.data-table { width: 100%; border-collapse: collapse; font-size: 12px; background: #fff; }
+.data-table th, .data-table td { padding: 12px 10px; border-bottom: 1px solid var(--border-color); text-align: left; }
+.data-table th { color: var(--text-sub); font-weight: 700; background: var(--bg-muted); font-size: 11px; text-transform: uppercase; }
+.data-table tr:hover { background: var(--hover-bg); }
+
+/* Components: Standard Buttons */
+.btn {
+ display: inline-flex; align-items: center; justify-content: center; gap: 8px;
+ padding: 8px 16px; border-radius: var(--radius-lg); font-weight: 600; font-size: 13px;
+ border: none; cursor: pointer; transition: all 0.2s ease;
+}
+.btn-primary { background: var(--primary-color); color: #fff; }
+.btn-primary:hover { background: var(--primary-hover); transform: translateY(-1px); }
+.btn-secondary { background: #f1f3f5; color: #495057; }
+.btn-secondary:hover { background: #e9ecef; }
+.btn-danger { background: #fee2e2; color: #dc2626; }
+.btn-danger:hover { background: #fecaca; }
+
+/* Compatibility Utils */
+._button-xsmall {
+ display: inline-flex; align-items: center; justify-content: center;
+ padding: 4px 10px; font-size: 11px; font-weight: 600; border-radius: 4px; border: 1px solid var(--border-color);
+ background: #fff; color: var(--text-main); cursor: pointer; transition: 0.2s;
+}
+._button-xsmall:hover { background: var(--bg-muted); border-color: var(--primary-color); color: var(--primary-color); }
+
+._button-small {
+ display: inline-flex; align-items: center; justify-content: center;
+ padding: 6px 14px; font-size: 12px; background: var(--primary-color); color: #fff; border-radius: 6px; border: none; cursor: pointer;
+}
+._button-medium {
+ display: inline-flex; align-items: center; justify-content: center;
+ padding: 10px 20px; background: var(--primary-color); color: #fff; border-radius: 6px; font-weight: 700; border: none; cursor: pointer;
+}
+.sync-btn { background: var(--primary-color); color: #fff; padding: 8px 16px; border-radius: 8px; font-size: 13px; font-weight: 600; }
+
+/* Badges & Status Colors */
+.badge {
+ padding: 2px 8px; border-radius: 4px; font-size: 10px; font-weight: 700;
+ display: inline-block; background: var(--primary-lv-1); color: var(--primary-color);
+}
+
+.status-complete { background: #e8f5e9; color: #2e7d32; }
+.status-working { background: #fff8e1; color: #FFBF00; }
+.status-checking { background: #e3f2fd; color: #1565c0; }
+.status-pending { background: #f5f5f5; color: #757575; }
+.status-error { background: #fee9e7; }
+
+.warning-text { color: #FFBF00; font-weight: 600; }
+.error-text { color: #F21D0D !important; font-weight: 700; }
+
+/* Spinner */
+.spinner {
+ display: none; width: 16px; height: 16px; border: 2px solid rgba(255, 255, 255, .3);
+ border-radius: 50%; border-top-color: #fff; animation: spin 1s ease-in-out infinite;
+}
+@keyframes spin { to { transform: rotate(360deg); } }
diff --git a/style/dashboard.css b/style/dashboard.css
index dd22710..ea5748d 100644
--- a/style/dashboard.css
+++ b/style/dashboard.css
@@ -1,123 +1,123 @@
-/* Dashboard Constants */
-:root {
- --header-h: 56px;
- --activity-h: 110px;
- --fixed-total-h: calc(var(--topbar-h) + var(--header-h) + var(--activity-h));
-}
-
-/* 1. Portal (Index) */
-.portal-container {
- display: flex; flex-direction: column; align-items: center; justify-content: center;
- height: calc(100vh - var(--topbar-h)); background: var(--bg-muted); padding: var(--space-lg); margin-top: var(--topbar-h);
-}
-.portal-header { text-align: center; margin-bottom: 50px; }
-.portal-header h1 { font-size: 28px; color: var(--primary-color); margin-bottom: 10px; font-weight: 800; }
-.portal-header p { color: var(--text-sub); font-size: 15px; }
-
-.button-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 30px; width: 100%; max-width: 800px; }
-.portal-card {
- background: #fff; border: 1px solid var(--border-color); border-radius: 12px; padding: 40px;
- text-align: center; transition: all 0.3s ease; width: 100%; box-shadow: var(--box-shadow);
- display: flex; flex-direction: column; align-items: center; gap: 20px;
-}
-.portal-card:hover { transform: translateY(-8px); border-color: var(--primary-color); box-shadow: var(--box-shadow-lg); }
-.portal-card i { font-size: 48px; color: var(--primary-color); }
-.portal-card h3 { font-size: 20px; color: var(--text-main); margin: 0; }
-.portal-card p { font-size: 14px; color: var(--text-sub); margin: 0; }
-
-/* 2. Dashboard Header & Activity */
-header {
- position: fixed; top: var(--topbar-h); left: 0; right: 0; z-index: 1001;
- background: #fff; height: var(--header-h); display: flex; justify-content: space-between; align-items: center;
- padding: 0 var(--space-lg); border-bottom: 1px solid #f5f5f5;
-}
-
-.activity-dashboard-wrapper {
- position: fixed; top: calc(var(--topbar-h) + var(--header-h)); left: 0; right: 0; z-index: 1000;
- background: #fff; height: var(--activity-h); border-bottom: 1px solid var(--border-color); box-shadow: 0 4px 6px rgba(0,0,0,0.03);
-}
-
-.activity-dashboard { max-width: 1200px; margin: 0 auto; height: 100%; display: flex; gap: 15px; padding: 10px 32px 20px; }
-.activity-card {
- flex: 1; padding: 12px 15px; border-radius: var(--radius-lg); cursor: pointer;
- display: flex; flex-direction: column; justify-content: center; gap: 2px; border-left: 5px solid transparent;
-}
-.activity-card:hover { transform: translateY(-2px); box-shadow: var(--box-shadow); }
-.activity-card.active { background: #e8f5e9; }
-.activity-card.warning { background: #fff8e1; }
-.activity-card.stale { background: #ffebee; }
-.activity-card.unknown { background: #f5f5f5; }
-.activity-card .label { font-size: 11px; font-weight: 600; opacity: 0.7; }
-.activity-card .count { font-size: 20px; font-weight: 800; }
-
-.main-content { margin-top: var(--fixed-total-h); padding: var(--space-lg); max-width: 1400px; margin-left: auto; margin-right: auto; }
-
-/* 3. Log Console */
-.log-console {
- position: sticky; top: var(--fixed-total-h); z-index: 999;
- background: #000; color: #0f0; font-family: 'Consolas', monospace; padding: 15px; margin-bottom: 20px;
- border-radius: 4px; max-height: 250px; overflow-y: auto; font-size: 12px; line-height: 1.5; box-shadow: 0 10px 20px rgba(0,0,0,0.2);
-}
-.log-console-header { color: #fff; border-bottom: 1px solid #333; margin-bottom: 10px; padding-bottom: 5px; font-weight: bold; }
-
-/* 4. Auth Modal (Page Specific) */
-.auth-modal-content {
- background: #fff; width: 440px; border-radius: 16px; padding: 40px; text-align: center;
- box-shadow: var(--box-shadow-modal); display: flex; flex-direction: column; gap: 32px;
-}
-.input-group { display: flex; flex-direction: column; gap: 8px; text-align: left; }
-.input-group label { font-size: 12px; font-weight: 700; color: var(--text-main); }
-.input-group input {
- height: 48px; padding: 0 16px; border: 1px solid var(--border-color); border-radius: 8px;
- font-size: 14px; background: #f9f9f9; width: 100%;
-}
-.input-group input:focus { border-color: var(--primary-color); background: #fff; outline: none; }
-
-/* 5. Accordion & Data Tables */
-.accordion-list-header {
- position: sticky; top: var(--fixed-total-h); background: #fff; z-index: 900;
- font-size: 11px; font-weight: 700; color: var(--text-sub);
- padding: 12px 24px; border-bottom: 2px solid var(--primary-color);
- display: grid; grid-template-columns: 2.5fr 1fr 1fr 0.8fr 2fr; gap: 16px;
-}
-
-.accordion-header {
- display: grid; grid-template-columns: 2.5fr 1fr 1fr 0.8fr 2fr; gap: 16px;
- padding: 12px 24px; align-items: center; cursor: pointer; border-bottom: 1px solid var(--border-color);
-}
-.accordion-item:hover .accordion-header { background: var(--primary-lv-0); }
-.accordion-item.active .accordion-header { background: var(--primary-lv-0); border-bottom: none; }
-
-.repo-title { font-weight: 700; color: var(--primary-color); @extend .text-truncate; }
-.repo-files { text-align: center; font-weight: 600; }
-.repo-log { font-size: 11px; color: var(--text-sub); @extend .text-truncate; }
-
-.accordion-body { display: none; padding: 24px; background: #fff; border-bottom: 1px solid var(--border-color); }
-.accordion-item.active .accordion-body { display: block; }
-
-.detail-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 32px; }
-.detail-section h4 {
- font-size: 13px; margin-bottom: 12px; color: var(--text-main);
- padding-left: 10px; font-weight: 700;
-}
-
-/* Personnel & Activity Tables */
-#personnel-table th:nth-child(1) { width: 25%; }
-#personnel-table th:nth-child(2) { width: 45%; }
-#activity-table th:nth-child(1) { width: 20%; }
-#activity-table th:nth-child(2) { width: 50%; }
-
-/* Location Groups */
-.continent-group, .country-group { margin-bottom: 15px; }
-.continent-header, .country-header {
- background: #fff; padding: 14px 20px; border: 1px solid var(--border-color); border-radius: 8px;
- display: flex; justify-content: space-between; align-items: center; cursor: pointer; font-weight: 700;
-}
-.continent-header { background: var(--primary-color); color: white; border: none; font-size: 15px; }
-.country-header { font-size: 14px; color: var(--text-main); margin-top: 8px; }
-.continent-body, .country-body { display: none; padding: 10px 0 10px 15px; }
-.active>.continent-body, .active>.country-body { display: block; }
-
-.admin-info { font-size: 12px; color: var(--text-sub); margin-left: 16px; padding: 6px 12px; background: #f8f9fa; border-radius: 4px; border: 1px solid var(--border-color); }
-.admin-info strong { color: var(--primary-color); font-weight: 700; }
-.base-date-info { font-size: 13px; color: var(--text-sub); background: #fdfdfd; padding: 6px 15px; border-radius: 6px; border: 1px solid var(--border-color); }
+/* Dashboard Constants */
+:root {
+ --header-h: 56px;
+ --activity-h: 110px;
+ --fixed-total-h: calc(var(--topbar-h) + var(--header-h) + var(--activity-h));
+}
+
+/* 1. Portal (Index) */
+.portal-container {
+ display: flex; flex-direction: column; align-items: center; justify-content: center;
+ height: calc(100vh - var(--topbar-h)); background: var(--bg-muted); padding: var(--space-lg); margin-top: var(--topbar-h);
+}
+.portal-header { text-align: center; margin-bottom: 50px; }
+.portal-header h1 { font-size: 28px; color: var(--primary-color); margin-bottom: 10px; font-weight: 800; }
+.portal-header p { color: var(--text-sub); font-size: 15px; }
+
+.button-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 30px; width: 100%; max-width: 800px; }
+.portal-card {
+ background: #fff; border: 1px solid var(--border-color); border-radius: 12px; padding: 40px;
+ text-align: center; transition: all 0.3s ease; width: 100%; box-shadow: var(--box-shadow);
+ display: flex; flex-direction: column; align-items: center; gap: 20px;
+}
+.portal-card:hover { transform: translateY(-8px); border-color: var(--primary-color); box-shadow: var(--box-shadow-lg); }
+.portal-card i { font-size: 48px; color: var(--primary-color); }
+.portal-card h3 { font-size: 20px; color: var(--text-main); margin: 0; }
+.portal-card p { font-size: 14px; color: var(--text-sub); margin: 0; }
+
+/* 2. Dashboard Header & Activity */
+header {
+ position: fixed; top: var(--topbar-h); left: 0; right: 0; z-index: 1001;
+ background: #fff; height: var(--header-h); display: flex; justify-content: space-between; align-items: center;
+ padding: 0 var(--space-lg); border-bottom: 1px solid #f5f5f5;
+}
+
+.activity-dashboard-wrapper {
+ position: fixed; top: calc(var(--topbar-h) + var(--header-h)); left: 0; right: 0; z-index: 1000;
+ background: #fff; height: var(--activity-h); border-bottom: 1px solid var(--border-color); box-shadow: 0 4px 6px rgba(0,0,0,0.03);
+}
+
+.activity-dashboard { max-width: 1200px; margin: 0 auto; height: 100%; display: flex; gap: 15px; padding: 10px 32px 20px; }
+.activity-card {
+ flex: 1; padding: 12px 15px; border-radius: var(--radius-lg); cursor: pointer;
+ display: flex; flex-direction: column; justify-content: center; gap: 2px; border-left: 5px solid transparent;
+}
+.activity-card:hover { transform: translateY(-2px); box-shadow: var(--box-shadow); }
+.activity-card.active { background: #e8f5e9; }
+.activity-card.warning { background: #fff8e1; }
+.activity-card.stale { background: #ffebee; }
+.activity-card.unknown { background: #f5f5f5; }
+.activity-card .label { font-size: 11px; font-weight: 600; opacity: 0.7; }
+.activity-card .count { font-size: 20px; font-weight: 800; }
+
+.main-content { margin-top: var(--fixed-total-h); padding: var(--space-lg); max-width: 1400px; margin-left: auto; margin-right: auto; }
+
+/* 3. Log Console */
+.log-console {
+ position: sticky; top: var(--fixed-total-h); z-index: 999;
+ background: #000; color: #0f0; font-family: 'Consolas', monospace; padding: 15px; margin-bottom: 20px;
+ border-radius: 4px; max-height: 250px; overflow-y: auto; font-size: 12px; line-height: 1.5; box-shadow: 0 10px 20px rgba(0,0,0,0.2);
+}
+.log-console-header { color: #fff; border-bottom: 1px solid #333; margin-bottom: 10px; padding-bottom: 5px; font-weight: bold; }
+
+/* 4. Auth Modal (Page Specific) */
+.auth-modal-content {
+ background: #fff; width: 440px; border-radius: 16px; padding: 40px; text-align: center;
+ box-shadow: var(--box-shadow-modal); display: flex; flex-direction: column; gap: 32px;
+}
+.input-group { display: flex; flex-direction: column; gap: 8px; text-align: left; }
+.input-group label { font-size: 12px; font-weight: 700; color: var(--text-main); }
+.input-group input {
+ height: 48px; padding: 0 16px; border: 1px solid var(--border-color); border-radius: 8px;
+ font-size: 14px; background: #f9f9f9; width: 100%;
+}
+.input-group input:focus { border-color: var(--primary-color); background: #fff; outline: none; }
+
+/* 5. Accordion & Data Tables */
+.accordion-list-header {
+ position: sticky; top: var(--fixed-total-h); background: #fff; z-index: 900;
+ font-size: 11px; font-weight: 700; color: var(--text-sub);
+ padding: 12px 24px; border-bottom: 2px solid var(--primary-color);
+ display: grid; grid-template-columns: 2.5fr 1fr 1fr 0.8fr 2fr; gap: 16px;
+}
+
+.accordion-header {
+ display: grid; grid-template-columns: 2.5fr 1fr 1fr 0.8fr 2fr; gap: 16px;
+ padding: 12px 24px; align-items: center; cursor: pointer; border-bottom: 1px solid var(--border-color);
+}
+.accordion-item:hover .accordion-header { background: var(--primary-lv-0); }
+.accordion-item.active .accordion-header { background: var(--primary-lv-0); border-bottom: none; }
+
+.repo-title { font-weight: 700; color: var(--primary-color); @extend .text-truncate; }
+.repo-files { text-align: center; font-weight: 600; }
+.repo-log { font-size: 11px; color: var(--text-sub); @extend .text-truncate; }
+
+.accordion-body { display: none; padding: 24px; background: #fff; border-bottom: 1px solid var(--border-color); }
+.accordion-item.active .accordion-body { display: block; }
+
+.detail-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 32px; }
+.detail-section h4 {
+ font-size: 13px; margin-bottom: 12px; color: var(--text-main);
+ padding-left: 10px; font-weight: 700;
+}
+
+/* Personnel & Activity Tables */
+#personnel-table th:nth-child(1) { width: 25%; }
+#personnel-table th:nth-child(2) { width: 45%; }
+#activity-table th:nth-child(1) { width: 20%; }
+#activity-table th:nth-child(2) { width: 50%; }
+
+/* Location Groups */
+.continent-group, .country-group { margin-bottom: 15px; }
+.continent-header, .country-header {
+ background: #fff; padding: 14px 20px; border: 1px solid var(--border-color); border-radius: 8px;
+ display: flex; justify-content: space-between; align-items: center; cursor: pointer; font-weight: 700;
+}
+.continent-header { background: var(--primary-color); color: white; border: none; font-size: 15px; }
+.country-header { font-size: 14px; color: var(--text-main); margin-top: 8px; }
+.continent-body, .country-body { display: none; padding: 10px 0 10px 15px; }
+.active>.continent-body, .active>.country-body { display: block; }
+
+.admin-info { font-size: 12px; color: var(--text-sub); margin-left: 16px; padding: 6px 12px; background: #f8f9fa; border-radius: 4px; border: 1px solid var(--border-color); }
+.admin-info strong { color: var(--primary-color); font-weight: 700; }
+.base-date-info { font-size: 13px; color: var(--text-sub); background: #fdfdfd; padding: 6px 15px; border-radius: 6px; border: 1px solid var(--border-color); }
diff --git a/style/inquiries.css b/style/inquiries.css
index 164eede..f63bc81 100644
--- a/style/inquiries.css
+++ b/style/inquiries.css
@@ -1,251 +1,251 @@
-/* 1. Layout & Board Structure */
-.inquiry-board {
- padding: 0 20px 32px 20px;
- max-width: 98%;
- margin: 36px auto 0;
-}
-
-.board-sticky-header {
- position: sticky;
- top: 36px;
- background: #fff;
- z-index: 1000;
- padding: 15px 0 10px;
- border-bottom: 1px solid #eee;
-}
-
-.board-header {
- display: flex;
- justify-content: space-between;
- align-items: flex-end;
- margin-bottom: 20px;
-}
-
-/* 2. Stats Dashboard */
-.header-stats {
- display: flex;
- gap: 12px;
-}
-
-.stat-item {
- background: #fff;
- border: 1px solid #eee;
- padding: 8px 16px;
- border-radius: 8px;
- display: flex;
- flex-direction: column;
- align-items: center;
- min-width: 80px;
- box-shadow: 0 2px 4px rgba(0,0,0,0.02);
- transition: transform 0.2s, box-shadow 0.2s;
-}
-
-.stat-item:hover {
- transform: translateY(-2px);
- box-shadow: 0 4px 8px rgba(0,0,0,0.05);
-}
-
-.stat-label { font-size: 11px; font-weight: 600; color: #888; margin-bottom: 2px; }
-.stat-value { font-size: 18px; font-weight: 700; color: #333; }
-
-/* Status Border Colors */
-.stat-item.total { }
-.stat-item.total .stat-value { color: #1e5149; }
-.stat-item.complete { }
-.stat-item.complete .stat-value { color: #2e7d32; }
-.stat-item.working { }
-.stat-item.working .stat-value { color: #1565c0; }
-.stat-item.checking { }
-.stat-item.checking .stat-value { color: #ef6c00; }
-.stat-item.pending { }
-.stat-item.pending .stat-value { color: #673ab7; }
-.stat-item.unconfirmed { }
-.stat-item.unconfirmed .stat-value { color: #9e9e9e; }
-
-/* 3. Filters & Notice */
-.notice-container {
- background: #fdfdfd;
- padding: 20px;
- border-radius: 8px;
- border: 1px solid #e0e0e0;
- margin-bottom: 24px;
- box-shadow: 0 2px 5px rgba(0,0,0,0.02);
-}
-
-.filter-section {
- display: flex;
- gap: 12px;
- background: #f8f9fa;
- padding: 12px 16px;
- border-radius: 8px;
- margin-top: 15px;
-}
-
-.filter-group { display: flex; flex-direction: column; gap: 4px; }
-.filter-group label { font-size: 12px; font-weight: 600; color: #666; }
-.filter-group select, .filter-group input { padding: 8px 12px; border: 1px solid #ddd; border-radius: 4px; font-size: 14px; }
-
-/* 4. Table Styles */
-.inquiry-table {
- width: 100%;
- background: #fff;
- border-radius: 8px;
- box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
- border-collapse: separate;
- border-spacing: 0;
- margin-top: 10px;
-}
-
-.inquiry-table thead th {
- position: sticky;
- background: #f8f9fa;
- padding: 14px 16px;
- text-align: left;
- font-size: 13px;
- font-weight: 700;
- color: #333;
- border-bottom: 2px solid #eee;
- z-index: 900;
-}
-
-/* ์ ๋ ฌ ๊ฐ๋ฅํ ํค๋ ์คํ์ผ ์ถ๊ฐ */
-.inquiry-table thead th.sortable {
- cursor: pointer;
- user-select: none;
- transition: background 0.2s;
- white-space: nowrap;
-}
-
-.inquiry-table thead th.sortable .header-content {
- display: flex;
- align-items: center;
- gap: 4px;
-}
-
-.sort-icon {
- display: inline-flex;
- flex-direction: column;
- justify-content: center;
- width: 12px;
- height: 12px;
- font-size: 8px;
- color: #ccc;
- line-height: 1;
- margin-left: 2px;
-}
-
-.inquiry-table thead th.active-sort {
- color: #1e5149;
- background: #f0f7f6;
-}
-
-.inquiry-table thead th.active-sort .sort-icon {
- color: #1e5149;
- font-size: 10px;
-}
-
-.inquiry-table td {
- padding: 14px 16px;
- font-size: 13px;
- border-bottom: 1px solid #eee;
- vertical-align: middle;
- white-space: nowrap;
- overflow: hidden;
- text-overflow: ellipsis;
-}
-
-/* Table Row Hover & Active State */
-.inquiry-row:hover { background: #fcfcfc; cursor: pointer; }
-.inquiry-row.active-row { background-color: #f0f7f6 !important; }
-.inquiry-row.active-row td { font-weight: 600; color: #1e5149; border-bottom-color: transparent; }
-
-/* Status Badges */
-.status-badge { padding: 4px 10px; border-radius: 20px; font-size: 11px; font-weight: 700; display: inline-block; }
-.status-complete { background: #e8f5e9; color: #2e7d32; }
-.status-working { background: #e3f2fd; color: #1565c0; }
-.status-checking { background: #fff3e0; color: #ef6c00; }
-.status-pending { background: #f5f5f5; color: #616161; }
-
-/* Table Columns Width & Truncation */
-.inquiry-table td:nth-child(1) { max-width: 50px; } /* No */
-.inquiry-table td:nth-child(2) { max-width: 80px; text-align: center; } /* Image */
-.inquiry-table td:nth-child(3) { max-width: 120px; } /* PM Type */
-.inquiry-table td:nth-child(4) { max-width: 100px; } /* Env */
-.inquiry-table td:nth-child(5) { max-width: 150px; } /* Category */
-.inquiry-table td:nth-child(6) { max-width: 200px; } /* Project */
-.inquiry-table td:nth-child(7), .inquiry-table td:nth-child(8) { max-width: 400px; } /* Content & Reply */
-.inquiry-table td:nth-child(9) { max-width: 100px; } /* Author */
-.inquiry-table td:nth-child(10) { max-width: 120px; } /* Date */
-.inquiry-table td:nth-child(11) { max-width: 100px; } /* Status */
-
-/* 5. Detail (Accordion) Styles */
-.detail-row { display: none; background: #fdfdfd; }
-.detail-row.active { display: table-row; }
-.detail-row td { max-width: none; white-space: normal; overflow: visible; }
-
-.detail-container {
- padding: 24px;
- background: #f9fafb;
- box-shadow: inset 0 4px 15px rgba(0,0,0,0.08);
- position: relative;
- border-bottom: 2px solid #eee;
-}
-
-.detail-content-wrapper {
- display: flex;
- flex-direction: column;
- gap: 20px;
- border: 1px solid #e5e7eb;
- background: #fff;
- padding: 25px;
- border-radius: 12px;
- box-shadow: 0 4px 10px rgba(0,0,0,0.03);
-}
-
-.btn-close-accordion {
- position: absolute;
- top: 35px;
- right: 45px;
- background: #eee;
- border: none;
- padding: 6px 14px;
- border-radius: 20px;
- font-size: 12px;
- font-weight: 600;
- color: #666;
- cursor: pointer;
- z-index: 10;
-}
-.btn-close-accordion::after { content: "โฒ"; font-size: 10px; margin-left: 5px; }
-
-.detail-meta-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 15px; margin-bottom: 15px; font-size: 13px; color: #666; }
-.detail-label { font-weight: 700; color: #888; margin-right: 8px; }
-
-.detail-q-section { background: #f8f9fa; padding: 20px; border-radius: 8px; }
-.detail-a-section { background: #f1f8f7; padding: 20px; border-radius: 8px; }
-
-/* 6. Image Preview & Foldable Section */
-.img-thumbnail { width: 32px; height: 32px; border-radius: 4px; object-fit: cover; border: 1px solid #ddd; cursor: pointer; transition: transform 0.2s; }
-.img-thumbnail:hover { transform: scale(1.1); }
-.no-img { font-size: 10px; color: #ccc; font-style: italic; }
-
-.detail-image-section { margin-bottom: 20px; background: #f9fafb; border-radius: 8px; border: 1px solid #e5e7eb; overflow: hidden; }
-.image-section-header { padding: 12px 16px; background: #f1f5f9; display: flex; justify-content: space-between; align-items: center; cursor: pointer; }
-.image-section-header:hover { background: #e2e8f0; }
-.image-section-header h4 { margin: 0; color: #1e5149; display: flex; align-items: center; gap: 8px; }
-.image-section-content { padding: 20px; display: flex; justify-content: center; background: #fff; border-top: 1px solid #eee; }
-.image-section-content.collapsed { display: none; }
-.toggle-icon { font-size: 12px; color: #64748b; transition: transform 0.3s; }
-.detail-image-section.active .toggle-icon { transform: rotate(180deg); }
-
-.preview-img { max-width: 100%; max-height: 400px; border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.1); object-fit: contain; }
-
-/* 7. Forms & Reply */
-.reply-edit-form textarea {
- width: 100%; height: 120px; padding: 12px; border: 1px solid #ddd; border-radius: 6px;
- font-family: inherit; font-size: 14px; margin-bottom: 15px; resize: none; background: #fff;
-}
-.reply-edit-form textarea:disabled, .reply-edit-form select:disabled, .reply-edit-form input:disabled { background: #fcfcfc; color: #666; border-color: #eee; }
-.reply-edit-form.readonly .btn-save, .reply-edit-form.readonly .btn-delete, .reply-edit-form.readonly .btn-cancel { display: none; }
-.reply-edit-form.editable .btn-edit { display: none; }
-.reply-edit-form.editable textarea { border-color: #1e5149; box-shadow: 0 0 0 2px rgba(30, 81, 73, 0.1); }
+/* 1. Layout & Board Structure */
+.inquiry-board {
+ padding: 0 20px 32px 20px;
+ max-width: 98%;
+ margin: 36px auto 0;
+}
+
+.board-sticky-header {
+ position: sticky;
+ top: 36px;
+ background: #fff;
+ z-index: 1000;
+ padding: 15px 0 10px;
+ border-bottom: 1px solid #eee;
+}
+
+.board-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: flex-end;
+ margin-bottom: 20px;
+}
+
+/* 2. Stats Dashboard */
+.header-stats {
+ display: flex;
+ gap: 12px;
+}
+
+.stat-item {
+ background: #fff;
+ border: 1px solid #eee;
+ padding: 8px 16px;
+ border-radius: 8px;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ min-width: 80px;
+ box-shadow: 0 2px 4px rgba(0,0,0,0.02);
+ transition: transform 0.2s, box-shadow 0.2s;
+}
+
+.stat-item:hover {
+ transform: translateY(-2px);
+ box-shadow: 0 4px 8px rgba(0,0,0,0.05);
+}
+
+.stat-label { font-size: 11px; font-weight: 600; color: #888; margin-bottom: 2px; }
+.stat-value { font-size: 18px; font-weight: 700; color: #333; }
+
+/* Status Border Colors */
+.stat-item.total { }
+.stat-item.total .stat-value { color: #1e5149; }
+.stat-item.complete { }
+.stat-item.complete .stat-value { color: #2e7d32; }
+.stat-item.working { }
+.stat-item.working .stat-value { color: #1565c0; }
+.stat-item.checking { }
+.stat-item.checking .stat-value { color: #ef6c00; }
+.stat-item.pending { }
+.stat-item.pending .stat-value { color: #673ab7; }
+.stat-item.unconfirmed { }
+.stat-item.unconfirmed .stat-value { color: #9e9e9e; }
+
+/* 3. Filters & Notice */
+.notice-container {
+ background: #fdfdfd;
+ padding: 20px;
+ border-radius: 8px;
+ border: 1px solid #e0e0e0;
+ margin-bottom: 24px;
+ box-shadow: 0 2px 5px rgba(0,0,0,0.02);
+}
+
+.filter-section {
+ display: flex;
+ gap: 12px;
+ background: #f8f9fa;
+ padding: 12px 16px;
+ border-radius: 8px;
+ margin-top: 15px;
+}
+
+.filter-group { display: flex; flex-direction: column; gap: 4px; }
+.filter-group label { font-size: 12px; font-weight: 600; color: #666; }
+.filter-group select, .filter-group input { padding: 8px 12px; border: 1px solid #ddd; border-radius: 4px; font-size: 14px; }
+
+/* 4. Table Styles */
+.inquiry-table {
+ width: 100%;
+ background: #fff;
+ border-radius: 8px;
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
+ border-collapse: separate;
+ border-spacing: 0;
+ margin-top: 10px;
+}
+
+.inquiry-table thead th {
+ position: sticky;
+ background: #f8f9fa;
+ padding: 14px 16px;
+ text-align: left;
+ font-size: 13px;
+ font-weight: 700;
+ color: #333;
+ border-bottom: 2px solid #eee;
+ z-index: 900;
+}
+
+/* ์ ๋ ฌ ๊ฐ๋ฅํ ํค๋ ์คํ์ผ ์ถ๊ฐ */
+.inquiry-table thead th.sortable {
+ cursor: pointer;
+ user-select: none;
+ transition: background 0.2s;
+ white-space: nowrap;
+}
+
+.inquiry-table thead th.sortable .header-content {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+}
+
+.sort-icon {
+ display: inline-flex;
+ flex-direction: column;
+ justify-content: center;
+ width: 12px;
+ height: 12px;
+ font-size: 8px;
+ color: #ccc;
+ line-height: 1;
+ margin-left: 2px;
+}
+
+.inquiry-table thead th.active-sort {
+ color: #1e5149;
+ background: #f0f7f6;
+}
+
+.inquiry-table thead th.active-sort .sort-icon {
+ color: #1e5149;
+ font-size: 10px;
+}
+
+.inquiry-table td {
+ padding: 14px 16px;
+ font-size: 13px;
+ border-bottom: 1px solid #eee;
+ vertical-align: middle;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+/* Table Row Hover & Active State */
+.inquiry-row:hover { background: #fcfcfc; cursor: pointer; }
+.inquiry-row.active-row { background-color: #f0f7f6 !important; }
+.inquiry-row.active-row td { font-weight: 600; color: #1e5149; border-bottom-color: transparent; }
+
+/* Status Badges */
+.status-badge { padding: 4px 10px; border-radius: 20px; font-size: 11px; font-weight: 700; display: inline-block; }
+.status-complete { background: #e8f5e9; color: #2e7d32; }
+.status-working { background: #e3f2fd; color: #1565c0; }
+.status-checking { background: #fff3e0; color: #ef6c00; }
+.status-pending { background: #f5f5f5; color: #616161; }
+
+/* Table Columns Width & Truncation */
+.inquiry-table td:nth-child(1) { max-width: 50px; } /* No */
+.inquiry-table td:nth-child(2) { max-width: 80px; text-align: center; } /* Image */
+.inquiry-table td:nth-child(3) { max-width: 120px; } /* PM Type */
+.inquiry-table td:nth-child(4) { max-width: 100px; } /* Env */
+.inquiry-table td:nth-child(5) { max-width: 150px; } /* Category */
+.inquiry-table td:nth-child(6) { max-width: 200px; } /* Project */
+.inquiry-table td:nth-child(7), .inquiry-table td:nth-child(8) { max-width: 400px; } /* Content & Reply */
+.inquiry-table td:nth-child(9) { max-width: 100px; } /* Author */
+.inquiry-table td:nth-child(10) { max-width: 120px; } /* Date */
+.inquiry-table td:nth-child(11) { max-width: 100px; } /* Status */
+
+/* 5. Detail (Accordion) Styles */
+.detail-row { display: none; background: #fdfdfd; }
+.detail-row.active { display: table-row; }
+.detail-row td { max-width: none; white-space: normal; overflow: visible; }
+
+.detail-container {
+ padding: 24px;
+ background: #f9fafb;
+ box-shadow: inset 0 4px 15px rgba(0,0,0,0.08);
+ position: relative;
+ border-bottom: 2px solid #eee;
+}
+
+.detail-content-wrapper {
+ display: flex;
+ flex-direction: column;
+ gap: 20px;
+ border: 1px solid #e5e7eb;
+ background: #fff;
+ padding: 25px;
+ border-radius: 12px;
+ box-shadow: 0 4px 10px rgba(0,0,0,0.03);
+}
+
+.btn-close-accordion {
+ position: absolute;
+ top: 35px;
+ right: 45px;
+ background: #eee;
+ border: none;
+ padding: 6px 14px;
+ border-radius: 20px;
+ font-size: 12px;
+ font-weight: 600;
+ color: #666;
+ cursor: pointer;
+ z-index: 10;
+}
+.btn-close-accordion::after { content: "โฒ"; font-size: 10px; margin-left: 5px; }
+
+.detail-meta-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 15px; margin-bottom: 15px; font-size: 13px; color: #666; }
+.detail-label { font-weight: 700; color: #888; margin-right: 8px; }
+
+.detail-q-section { background: #f8f9fa; padding: 20px; border-radius: 8px; }
+.detail-a-section { background: #f1f8f7; padding: 20px; border-radius: 8px; }
+
+/* 6. Image Preview & Foldable Section */
+.img-thumbnail { width: 32px; height: 32px; border-radius: 4px; object-fit: cover; border: 1px solid #ddd; cursor: pointer; transition: transform 0.2s; }
+.img-thumbnail:hover { transform: scale(1.1); }
+.no-img { font-size: 10px; color: #ccc; font-style: italic; }
+
+.detail-image-section { margin-bottom: 20px; background: #f9fafb; border-radius: 8px; border: 1px solid #e5e7eb; overflow: hidden; }
+.image-section-header { padding: 12px 16px; background: #f1f5f9; display: flex; justify-content: space-between; align-items: center; cursor: pointer; }
+.image-section-header:hover { background: #e2e8f0; }
+.image-section-header h4 { margin: 0; color: #1e5149; display: flex; align-items: center; gap: 8px; }
+.image-section-content { padding: 20px; display: flex; justify-content: center; background: #fff; border-top: 1px solid #eee; }
+.image-section-content.collapsed { display: none; }
+.toggle-icon { font-size: 12px; color: #64748b; transition: transform 0.3s; }
+.detail-image-section.active .toggle-icon { transform: rotate(180deg); }
+
+.preview-img { max-width: 100%; max-height: 400px; border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.1); object-fit: contain; }
+
+/* 7. Forms & Reply */
+.reply-edit-form textarea {
+ width: 100%; height: 120px; padding: 12px; border: 1px solid #ddd; border-radius: 6px;
+ font-family: inherit; font-size: 14px; margin-bottom: 15px; resize: none; background: #fff;
+}
+.reply-edit-form textarea:disabled, .reply-edit-form select:disabled, .reply-edit-form input:disabled { background: #fcfcfc; color: #666; border-color: #eee; }
+.reply-edit-form.readonly .btn-save, .reply-edit-form.readonly .btn-delete, .reply-edit-form.readonly .btn-cancel { display: none; }
+.reply-edit-form.editable .btn-edit { display: none; }
+.reply-edit-form.editable textarea { border-color: #1e5149; box-shadow: 0 0 0 2px rgba(30, 81, 73, 0.1); }
diff --git a/style/mail.css b/style/mail.css
index 01a3c24..a5cbcfb 100644
--- a/style/mail.css
+++ b/style/mail.css
@@ -1,219 +1,219 @@
-/* Mail Manager Layout (Vertical Split) */
-.mail-wrapper {
- display: flex; height: calc(100vh - var(--topbar-h));
- margin-top: var(--topbar-h); background: #fff; overflow: hidden;
-}
-
-.mail-list-area {
- width: 400px; border-right: 1px solid var(--border-color);
- display: flex; flex-direction: column; height: 100%; background: #fff; position: relative;
-}
-
-/* 1. Tabs & Search */
-.mail-tabs { display: flex; border-bottom: 1px solid var(--border-color); background: #f8f9fa; flex-shrink: 0; }
-.mail-tab {
- flex: 1; padding: 12px 0; text-align: center; cursor: pointer;
- font-weight: 700; color: #a0aec0; font-size: 11px; transition: all 0.2s ease;
- border-bottom: 2px solid transparent; display: flex; align-items: center; justify-content: center; gap: 6px;
-}
-.mail-tab:hover { background: #edf2f7; color: var(--primary-color); }
-.mail-tab.active { color: var(--primary-color); border-bottom: 2px solid var(--primary-color); background: #fff; }
-
-.search-bar { padding: 16px 24px; border-bottom: 1px solid var(--border-color); background: #fff; flex-shrink: 0; }
-
-.mail-bulk-actions {
- display: none; padding: 8px 16px; background: #f7fafc;
- border-bottom: 1px solid var(--border-color); align-items: center; justify-content: space-between; font-size: 12px;
-}
-.mail-bulk-actions.active { display: flex; }
-
-/* 2. Mail Items */
-.mail-items-container { flex: 1; overflow-y: auto; padding-bottom: 60px; }
-.mail-item {
- padding: 16px; border-bottom: 1px solid var(--border-color); cursor: pointer;
- display: flex; align-items: flex-start; transition: 0.2s;
-}
-.mail-item:hover { background: var(--bg-muted); }
-.mail-item.active { background: var(--primary-lv-0); }
-
-.mail-item-checkbox { width: 16px; height: 16px; cursor: pointer; margin-right: 12px; margin-top: 2px; }
-.mail-item-content { flex: 1; min-width: 0; }
-.mail-item-info { display: flex; align-items: center; gap: 12px; margin-bottom: 4px; }
-.mail-date { font-size: 11px; color: var(--text-sub); white-space: nowrap; }
-
-.btn-mail-delete {
- background: #f7fafc; border: 1px solid var(--border-color); color: #718096;
- font-size: 10px; padding: 2px 8px; border-radius: 4px; font-weight: 600;
-}
-.btn-mail-delete:hover { color: var(--error-color); background: #fff5f5; border-color: #feb2b2; }
-
-/* 3. Content Area */
-.mail-content-area { flex: 1; display: flex; flex-direction: column; overflow-y: auto; border-right: 1px solid var(--border-color); }
-.mail-content-header { padding: var(--space-lg); border-bottom: 1px solid var(--border-color); }
-.mail-body { padding: var(--space-lg); line-height: 1.6; min-height: 200px; }
-
-/* 4. Attachments & AI */
-.attachment-area { padding: var(--space-lg); border-top: 1px solid var(--border-color); background: var(--bg-muted); }
-.attachment-item {
- display: flex; align-items: center; gap: var(--space-md); background: #fff;
- padding: 12px 20px; border-radius: var(--radius-lg);
- border: 1px solid var(--border-color); margin-bottom: var(--space-sm); cursor: pointer;
- transition: 0.2s;
-}
-.attachment-item:hover { border-color: var(--primary-color); box-shadow: var(--box-shadow); }
-.attachment-item.active { background: var(--primary-lv-0); border-color: var(--primary-color); }
-
-.file-details { flex: 1; min-width: 0; }
-.file-name { font-size: 13px; font-weight: 700; max-width: 450px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
-.file-size { font-size: 11px; color: var(--text-sub); }
-
-.btn-group {
- display: flex; align-items: center; gap: 12px; flex-shrink: 0; justify-content: flex-end;
-}
-
-.btn-upload {
- padding: 6px 14px; border-radius: 6px; font-size: 11px; font-weight: 700;
- color: #fff; border: none; cursor: pointer; transition: 0.2s; height: 32px;
-}
-
-.btn-ai { background: var(--ai-gradient); }
-.btn-ai:hover { filter: brightness(1.1); transform: translateY(-1px); }
-
-.btn-normal { background: var(--primary-color); }
-.btn-normal:hover { background: var(--primary-hover); transform: translateY(-1px); }
-
-.ai-recommend {
- font-size: 11px; padding: 6px 12px; border-radius: 6px; font-weight: 600;
- cursor: pointer; transition: 0.2s; display: inline-block;
- max-width: 250px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
-}
-.ai-recommend.smart-mode { background: #eef2ff; color: #4338ca; border: 1px solid #c7d2fe; }
-.ai-recommend.manual-mode { background: #f1f5f9; color: #475569; border: 1px dashed #cbd5e1; }
-.ai-recommend:hover { transform: scale(1.02); }
-
-/* 5. Preview Area */
-.mail-preview-area {
- width: 0; background: #f8f9fa; display: flex; flex-direction: column;
- transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); position: relative;
- border-left: 0 solid transparent;
-}
-
-.mail-preview-area.active {
- width: 600px;
- border-left: 1px solid var(--border-color);
- visibility: visible;
-}
-
-.preview-header {
- height: 56px; padding: 0 24px; border-bottom: 1px solid var(--border-color);
- display: flex; align-items: center; justify-content: space-between;
- background: #fff; flex-shrink: 0;
-}
-
-.preview-header h3 { font-size: 15px; font-weight: 800; color: var(--primary-color); margin: 0; }
-
-#fullViewBtn {
- background: var(--primary-lv-0) !important;
- color: var(--primary-color) !important;
- border: 1px solid var(--primary-lv-1) !important;
- font-weight: 700 !important;
- padding: 4px 16px !important;
- border-radius: 4px !important;
- font-size: 11px !important;
- transition: 0.2s !important;
-}
-#fullViewBtn:hover { background: var(--primary-lv-1) !important; }
-
-.preview-toggle-handle {
- position: absolute; left: -20px; top: 50%; transform: translateY(-50%);
- width: 20px; height: 60px; background: var(--primary-color); color: #fff;
- display: flex; align-items: center; justify-content: center;
- border-radius: 8px 0 0 8px; font-size: 10px; cursor: pointer;
- box-shadow: -2px 0 5px rgba(0,0,0,0.1); z-index: 100;
-}
-.preview-toggle-handle:hover { background: var(--primary-hover); }
-
-.a4-container {
- flex: 1; padding: 30px; overflow-y: auto; background: #e9ecef;
- display: flex; justify-content: center;
-}
-
-.a4-container iframe, .a4-container .preview-placeholder {
- width: 100%; height: 100%; background: #fff;
- box-shadow: 0 4px 20px rgba(0,0,0,0.08); border-radius: 4px;
-}
-
-.preview-placeholder {
- display: flex; align-items: center; justify-content: center;
- text-align: center; color: var(--text-sub); font-size: 13px; line-height: 1.6;
-}
-
-.mail-preview-area.active > * { opacity: 1 !important; visibility: visible !important; pointer-events: auto !important; }
-.mail-preview-area > *:not(.preview-toggle-handle) { opacity: 0; visibility: hidden; pointer-events: none; transition: 0.2s; }
-
-/* 6. Footer & Others */
-.address-book-footer {
- position: absolute; bottom: 0; left: 0; width: 100%; padding: 12px 16px;
- border-top: 1px solid var(--border-color); background: #fff; display: flex; gap: 8px; z-index: 5;
-}
-
-.file-log-area {
- display: none; width: 100%; margin-top: 10px; background: #1a202c;
- border-radius: 4px; padding: 12px; font-family: monospace; font-size: 11px; color: #cbd5e0;
-}
-.file-log-area.active { display: block; }
-.log-success { color: #48bb78; font-weight: 700; }
-
-.switch { position: relative; display: inline-block; width: 34px; height: 20px; }
-.switch input { opacity: 0; width: 0; height: 0; }
-.slider {
- position: absolute; cursor: pointer; top: 0; left: 0; right: 0; bottom: 0;
- background-color: #ccc; transition: .4s; border-radius: 20px;
-}
-.slider:before {
- position: absolute; content: ""; height: 14px; width: 14px; left: 3px; bottom: 3px;
- background-color: white; transition: .4s; border-radius: 50%;
-}
-input:checked+.slider { background: var(--ai-gradient); }
-input:checked+.slider:before { transform: translateX(14px); }
-
-/* Restore Path Selector Modal Specific Styles */
-.select-group {
- display: flex;
- flex-direction: column;
- gap: 8px;
-}
-
-.select-group label {
- font-size: 12px;
- font-weight: 700;
- color: var(--text-main);
-}
-
-.modal-select {
- width: 100%;
- height: 44px;
- padding: 0 15px;
- border: 1px solid var(--border-color);
- border-radius: 8px;
- background-color: #f9f9f9;
- font-size: 14px;
- color: #333;
- outline: none;
- transition: all 0.2s;
- cursor: pointer;
- appearance: none;
- background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%23666' d='M6 8L2 4h8L6 8z'/%3E%3C/svg%3E");
- background-repeat: no-repeat;
- background-position: right 15px center;
-}
-
-.modal-select:focus {
- border-color: var(--primary-color);
- background-color: #fff;
- box-shadow: 0 0 0 3px rgba(30, 81, 73, 0.1);
-}
-
-.modal-select option {
- padding: 10px;
-}
+/* Mail Manager Layout (Vertical Split) */
+.mail-wrapper {
+ display: flex; height: calc(100vh - var(--topbar-h));
+ margin-top: var(--topbar-h); background: #fff; overflow: hidden;
+}
+
+.mail-list-area {
+ width: 400px; border-right: 1px solid var(--border-color);
+ display: flex; flex-direction: column; height: 100%; background: #fff; position: relative;
+}
+
+/* 1. Tabs & Search */
+.mail-tabs { display: flex; border-bottom: 1px solid var(--border-color); background: #f8f9fa; flex-shrink: 0; }
+.mail-tab {
+ flex: 1; padding: 12px 0; text-align: center; cursor: pointer;
+ font-weight: 700; color: #a0aec0; font-size: 11px; transition: all 0.2s ease;
+ border-bottom: 2px solid transparent; display: flex; align-items: center; justify-content: center; gap: 6px;
+}
+.mail-tab:hover { background: #edf2f7; color: var(--primary-color); }
+.mail-tab.active { color: var(--primary-color); border-bottom: 2px solid var(--primary-color); background: #fff; }
+
+.search-bar { padding: 16px 24px; border-bottom: 1px solid var(--border-color); background: #fff; flex-shrink: 0; }
+
+.mail-bulk-actions {
+ display: none; padding: 8px 16px; background: #f7fafc;
+ border-bottom: 1px solid var(--border-color); align-items: center; justify-content: space-between; font-size: 12px;
+}
+.mail-bulk-actions.active { display: flex; }
+
+/* 2. Mail Items */
+.mail-items-container { flex: 1; overflow-y: auto; padding-bottom: 60px; }
+.mail-item {
+ padding: 16px; border-bottom: 1px solid var(--border-color); cursor: pointer;
+ display: flex; align-items: flex-start; transition: 0.2s;
+}
+.mail-item:hover { background: var(--bg-muted); }
+.mail-item.active { background: var(--primary-lv-0); }
+
+.mail-item-checkbox { width: 16px; height: 16px; cursor: pointer; margin-right: 12px; margin-top: 2px; }
+.mail-item-content { flex: 1; min-width: 0; }
+.mail-item-info { display: flex; align-items: center; gap: 12px; margin-bottom: 4px; }
+.mail-date { font-size: 11px; color: var(--text-sub); white-space: nowrap; }
+
+.btn-mail-delete {
+ background: #f7fafc; border: 1px solid var(--border-color); color: #718096;
+ font-size: 10px; padding: 2px 8px; border-radius: 4px; font-weight: 600;
+}
+.btn-mail-delete:hover { color: var(--error-color); background: #fff5f5; border-color: #feb2b2; }
+
+/* 3. Content Area */
+.mail-content-area { flex: 1; display: flex; flex-direction: column; overflow-y: auto; border-right: 1px solid var(--border-color); }
+.mail-content-header { padding: var(--space-lg); border-bottom: 1px solid var(--border-color); }
+.mail-body { padding: var(--space-lg); line-height: 1.6; min-height: 200px; }
+
+/* 4. Attachments & AI */
+.attachment-area { padding: var(--space-lg); border-top: 1px solid var(--border-color); background: var(--bg-muted); }
+.attachment-item {
+ display: flex; align-items: center; gap: var(--space-md); background: #fff;
+ padding: 12px 20px; border-radius: var(--radius-lg);
+ border: 1px solid var(--border-color); margin-bottom: var(--space-sm); cursor: pointer;
+ transition: 0.2s;
+}
+.attachment-item:hover { border-color: var(--primary-color); box-shadow: var(--box-shadow); }
+.attachment-item.active { background: var(--primary-lv-0); border-color: var(--primary-color); }
+
+.file-details { flex: 1; min-width: 0; }
+.file-name { font-size: 13px; font-weight: 700; max-width: 450px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.file-size { font-size: 11px; color: var(--text-sub); }
+
+.btn-group {
+ display: flex; align-items: center; gap: 12px; flex-shrink: 0; justify-content: flex-end;
+}
+
+.btn-upload {
+ padding: 6px 14px; border-radius: 6px; font-size: 11px; font-weight: 700;
+ color: #fff; border: none; cursor: pointer; transition: 0.2s; height: 32px;
+}
+
+.btn-ai { background: var(--ai-gradient); }
+.btn-ai:hover { filter: brightness(1.1); transform: translateY(-1px); }
+
+.btn-normal { background: var(--primary-color); }
+.btn-normal:hover { background: var(--primary-hover); transform: translateY(-1px); }
+
+.ai-recommend {
+ font-size: 11px; padding: 6px 12px; border-radius: 6px; font-weight: 600;
+ cursor: pointer; transition: 0.2s; display: inline-block;
+ max-width: 250px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
+}
+.ai-recommend.smart-mode { background: #eef2ff; color: #4338ca; border: 1px solid #c7d2fe; }
+.ai-recommend.manual-mode { background: #f1f5f9; color: #475569; border: 1px dashed #cbd5e1; }
+.ai-recommend:hover { transform: scale(1.02); }
+
+/* 5. Preview Area */
+.mail-preview-area {
+ width: 0; background: #f8f9fa; display: flex; flex-direction: column;
+ transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); position: relative;
+ border-left: 0 solid transparent;
+}
+
+.mail-preview-area.active {
+ width: 600px;
+ border-left: 1px solid var(--border-color);
+ visibility: visible;
+}
+
+.preview-header {
+ height: 56px; padding: 0 24px; border-bottom: 1px solid var(--border-color);
+ display: flex; align-items: center; justify-content: space-between;
+ background: #fff; flex-shrink: 0;
+}
+
+.preview-header h3 { font-size: 15px; font-weight: 800; color: var(--primary-color); margin: 0; }
+
+#fullViewBtn {
+ background: var(--primary-lv-0) !important;
+ color: var(--primary-color) !important;
+ border: 1px solid var(--primary-lv-1) !important;
+ font-weight: 700 !important;
+ padding: 4px 16px !important;
+ border-radius: 4px !important;
+ font-size: 11px !important;
+ transition: 0.2s !important;
+}
+#fullViewBtn:hover { background: var(--primary-lv-1) !important; }
+
+.preview-toggle-handle {
+ position: absolute; left: -20px; top: 50%; transform: translateY(-50%);
+ width: 20px; height: 60px; background: var(--primary-color); color: #fff;
+ display: flex; align-items: center; justify-content: center;
+ border-radius: 8px 0 0 8px; font-size: 10px; cursor: pointer;
+ box-shadow: -2px 0 5px rgba(0,0,0,0.1); z-index: 100;
+}
+.preview-toggle-handle:hover { background: var(--primary-hover); }
+
+.a4-container {
+ flex: 1; padding: 30px; overflow-y: auto; background: #e9ecef;
+ display: flex; justify-content: center;
+}
+
+.a4-container iframe, .a4-container .preview-placeholder {
+ width: 100%; height: 100%; background: #fff;
+ box-shadow: 0 4px 20px rgba(0,0,0,0.08); border-radius: 4px;
+}
+
+.preview-placeholder {
+ display: flex; align-items: center; justify-content: center;
+ text-align: center; color: var(--text-sub); font-size: 13px; line-height: 1.6;
+}
+
+.mail-preview-area.active > * { opacity: 1 !important; visibility: visible !important; pointer-events: auto !important; }
+.mail-preview-area > *:not(.preview-toggle-handle) { opacity: 0; visibility: hidden; pointer-events: none; transition: 0.2s; }
+
+/* 6. Footer & Others */
+.address-book-footer {
+ position: absolute; bottom: 0; left: 0; width: 100%; padding: 12px 16px;
+ border-top: 1px solid var(--border-color); background: #fff; display: flex; gap: 8px; z-index: 5;
+}
+
+.file-log-area {
+ display: none; width: 100%; margin-top: 10px; background: #1a202c;
+ border-radius: 4px; padding: 12px; font-family: monospace; font-size: 11px; color: #cbd5e0;
+}
+.file-log-area.active { display: block; }
+.log-success { color: #48bb78; font-weight: 700; }
+
+.switch { position: relative; display: inline-block; width: 34px; height: 20px; }
+.switch input { opacity: 0; width: 0; height: 0; }
+.slider {
+ position: absolute; cursor: pointer; top: 0; left: 0; right: 0; bottom: 0;
+ background-color: #ccc; transition: .4s; border-radius: 20px;
+}
+.slider:before {
+ position: absolute; content: ""; height: 14px; width: 14px; left: 3px; bottom: 3px;
+ background-color: white; transition: .4s; border-radius: 50%;
+}
+input:checked+.slider { background: var(--ai-gradient); }
+input:checked+.slider:before { transform: translateX(14px); }
+
+/* Restore Path Selector Modal Specific Styles */
+.select-group {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+}
+
+.select-group label {
+ font-size: 12px;
+ font-weight: 700;
+ color: var(--text-main);
+}
+
+.modal-select {
+ width: 100%;
+ height: 44px;
+ padding: 0 15px;
+ border: 1px solid var(--border-color);
+ border-radius: 8px;
+ background-color: #f9f9f9;
+ font-size: 14px;
+ color: #333;
+ outline: none;
+ transition: all 0.2s;
+ cursor: pointer;
+ appearance: none;
+ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%23666' d='M6 8L2 4h8L6 8z'/%3E%3C/svg%3E");
+ background-repeat: no-repeat;
+ background-position: right 15px center;
+}
+
+.modal-select:focus {
+ border-color: var(--primary-color);
+ background-color: #fff;
+ box-shadow: 0 0 0 3px rgba(30, 81, 73, 0.1);
+}
+
+.modal-select option {
+ padding: 10px;
+}
diff --git a/style/style.css b/style/style.css
index 21bee0b..4b1d736 100644
--- a/style/style.css
+++ b/style/style.css
@@ -1,3 +1,3 @@
-@import url('common.css');
-@import url('dashboard.css');
-@import url('mail.css');
+@import url('common.css');
+@import url('dashboard.css');
+@import url('mail.css');
diff --git a/templates/analysis.html b/templates/analysis.html
index beb6c26..1edc0ab 100644
--- a/templates/analysis.html
+++ b/templates/analysis.html
@@ -1,139 +1,139 @@
-
-
-
-
-
-
- ๋ฐ์ดํฐ ๋ถ์ - Project Master Sabermetrics
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
๋ถ์ ๋ชจ๋ธ
-
์ต๊ทผ 9ํ์ฐจ ์๊ณ์ด์ Velocity ๋ฐ ๋ณํ์จ ๋ถ์
-
-
-
๊ฐ์ค์น ๋ก์ง
-
ํ๋ ์ '์ ํ ์ ์ง', ์ ์ฒด ์ '์ง์ ๊ฐ์' ๋์ ์ ์ฉ
-
-
-
-
-
-
-
-
-
-
-
1. ์์ฐ ๊ฐ์น ๋ณ๋ ์ถ์
-
๊ท๋ชจ๋ฅผ ๊ฐ์งํ์ฌ, ๋ํ ํ๋ก์ ํธ ์ ์ฒด ์ ๋ฐ์ดํฐ ๊ฐ์น ํ๋ฝ ์๋๋ฅผ ๊ฐ์(Acceleration)์ํต๋๋ค.
-
-
-
2. ํ๋ ์๊ณ์ด ๊ด์ฑ ๋ถ์
-
์ต๊ทผ ํ๋์ ์ฐ์์ฑ์ ๋ถ์ํ์ฌ, ๋จ๊ธฐ ์ ์ฒด ์์๋ ๊ณผ๊ฑฐ์ ์ด์ ๋ชจ๋ฉํ
์ ๋ฐ์ํ์ฌ ์ง์๋ฅผ ๋ณด์ ํฉ๋๋ค.
-
-
-
3. ๋์ ๊ฐ์น ๊ณ์
-
ํ๋ก์ ํธ๋ง๋ค ๊ฐ๋ณํ๋ ๊ฐ์ ๊ณก์ ์ ์์ฑํ์ฌ ํ์ฅ์ ๊ฐ์ฅ ๋ฐ์ฐฉ๋ ๋ณด์กด์จ์ ์ ๊ณตํฉ๋๋ค.
-
-
-
-
-
-
-
-
-
-
์ด์ ํ๋ ฅ ๋ถํฌ (Activity Distribution)
-
-
-
-
์์ฐ ๊ฐ์น ์ ๋ต ๋งคํธ๋ฆญ์ค (Strategic Analysis)
-
-
-
-
-
-
-
-
-
-
70%โ ์ ์ ์ด์
-
30~70% ๊ด๋ฆฌ ์ฃผ์
-
10~30% ์ํ ๋
ธ์ถ
-
10%โ ์ค๋จ/๋ฐฉ์น
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+ ๋ฐ์ดํฐ ๋ถ์ - Project Master Sabermetrics
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
๋ถ์ ๋ชจ๋ธ
+
์ต๊ทผ 9ํ์ฐจ ์๊ณ์ด์ Velocity ๋ฐ ๋ณํ์จ ๋ถ์
+
+
+
๊ฐ์ค์น ๋ก์ง
+
ํ๋ ์ '์ ํ ์ ์ง', ์ ์ฒด ์ '์ง์ ๊ฐ์' ๋์ ์ ์ฉ
+
+
+
+
+
+
+
+
+
+
+
1. ์์ฐ ๊ฐ์น ๋ณ๋ ์ถ์
+
๊ท๋ชจ๋ฅผ ๊ฐ์งํ์ฌ, ๋ํ ํ๋ก์ ํธ ์ ์ฒด ์ ๋ฐ์ดํฐ ๊ฐ์น ํ๋ฝ ์๋๋ฅผ ๊ฐ์(Acceleration)์ํต๋๋ค.
+
+
+
2. ํ๋ ์๊ณ์ด ๊ด์ฑ ๋ถ์
+
์ต๊ทผ ํ๋์ ์ฐ์์ฑ์ ๋ถ์ํ์ฌ, ๋จ๊ธฐ ์ ์ฒด ์์๋ ๊ณผ๊ฑฐ์ ์ด์ ๋ชจ๋ฉํ
์ ๋ฐ์ํ์ฌ ์ง์๋ฅผ ๋ณด์ ํฉ๋๋ค.
+
+
+
3. ๋์ ๊ฐ์น ๊ณ์
+
ํ๋ก์ ํธ๋ง๋ค ๊ฐ๋ณํ๋ ๊ฐ์ ๊ณก์ ์ ์์ฑํ์ฌ ํ์ฅ์ ๊ฐ์ฅ ๋ฐ์ฐฉ๋ ๋ณด์กด์จ์ ์ ๊ณตํฉ๋๋ค.
+
+
+
+
+
+
+
+
+
+
์ด์ ํ๋ ฅ ๋ถํฌ (Activity Distribution)
+
+
+
+
์์ฐ ๊ฐ์น ์ ๋ต ๋งคํธ๋ฆญ์ค (Strategic Analysis)
+
+
+
+
+
+
+
+
+
+
70%โ ์ ์ ์ด์
+
30~70% ๊ด๋ฆฌ ์ฃผ์
+
10~30% ์ํ ๋
ธ์ถ
+
10%โ ์ค๋จ/๋ฐฉ์น
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/templates/analysis_test.html b/templates/analysis_test.html
new file mode 100644
index 0000000..47fcef4
--- /dev/null
+++ b/templates/analysis_test.html
@@ -0,0 +1,139 @@
+
+
+
+
+
+
+ ๋ฐ์ดํฐ ๋ถ์ (ํ
์คํธ) - Project Master Sabermetrics
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
๋ถ์ ๋ชจ๋ธ
+
์ต๊ทผ 9ํ์ฐจ ์๊ณ์ด์ Velocity ๋ฐ ๋ณํ์จ ๋ถ์
+
+
+
๊ฐ์ค์น ๋ก์ง
+
ํ๋ ์ '์ ํ ์ ์ง', ์ ์ฒด ์ '์ง์ ๊ฐ์' ๋์ ์ ์ฉ
+
+
+
+
+
+
+
+
+
+
+
1. ์์ฐ ๊ฐ์น ๋ณ๋ ์ถ์
+
๊ท๋ชจ๋ฅผ ๊ฐ์งํ์ฌ, ๋ํ ํ๋ก์ ํธ ์ ์ฒด ์ ๋ฐ์ดํฐ ๊ฐ์น ํ๋ฝ ์๋๋ฅผ ๊ฐ์(Acceleration)์ํต๋๋ค.
+
+
+
2. ํ๋ ์๊ณ์ด ๊ด์ฑ ๋ถ์
+
์ต๊ทผ ํ๋์ ์ฐ์์ฑ์ ๋ถ์ํ์ฌ, ๋จ๊ธฐ ์ ์ฒด ์์๋ ๊ณผ๊ฑฐ์ ์ด์ ๋ชจ๋ฉํ
์ ๋ฐ์ํ์ฌ ์ง์๋ฅผ ๋ณด์ ํฉ๋๋ค.
+
+
+
3. ๋์ ๊ฐ์น ๊ณ์
+
ํ๋ก์ ํธ๋ง๋ค ๊ฐ๋ณํ๋ ๊ฐ์ ๊ณก์ ์ ์์ฑํ์ฌ ํ์ฅ์ ๊ฐ์ฅ ๋ฐ์ฐฉ๋ ๋ณด์กด์จ์ ์ ๊ณตํฉ๋๋ค.
+
+
+
+
+
+
+
+
+
+
์ด์ ํ๋ ฅ ๋ถํฌ (Activity Distribution)
+
+
+
+
์์ฐ ๊ฐ์น ์ ๋ต ๋งคํธ๋ฆญ์ค (Strategic Analysis)
+
+
+
+
+
+
+
+
+
+
70%โ ์ ์ ์ด์
+
30~70% ๊ด๋ฆฌ ์ฃผ์
+
10~30% ์ํ ๋
ธ์ถ
+
10%โ ์ค๋จ/๋ฐฉ์น
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/templates/dashboard.html b/templates/dashboard.html
index 5eb189b..2edd734 100644
--- a/templates/dashboard.html
+++ b/templates/dashboard.html
@@ -1,118 +1,118 @@
-
-
-
-
-
-
- Project Master Overseas ๊ด๋ฆฌ์
-
-
-
-
-
-
-
-
-
-
-
- ํ๋ก์ ํธ ํํฉ
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
ํฌ๋กค๋ง์ ํ ์ ์์ต๋๋ค.
-
-
-
-
-
-
-
-
-
-
-
-
- | ํ๋ก์ ํธ๋ช
|
- ๋ด๋น๋ถ์ |
- ๋ด๋น์ |
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+ Project Master Overseas ๊ด๋ฆฌ์
+
+
+
+
+
+
+
+
+
+
+
+ ํ๋ก์ ํธ ํํฉ
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
ํฌ๋กค๋ง์ ํ ์ ์์ต๋๋ค.
+
+
+
+
+
+
+
+
+
+
+
+
+ | ํ๋ก์ ํธ๋ช
|
+ ๋ด๋น๋ถ์ |
+ ๋ด๋น์ |
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/templates/dashboard_test.html b/templates/dashboard_test.html
new file mode 100644
index 0000000..8042b28
--- /dev/null
+++ b/templates/dashboard_test.html
@@ -0,0 +1,118 @@
+
+
+
+
+
+
+ Project Master Overseas ๊ด๋ฆฌ์ (ํ
์คํธ)
+
+
+
+
+
+
+
+
+
+
+
+ ํ๋ก์ ํธ ํํฉ (ํ
์คํธ ํ๊ฒฝ)
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
ํฌ๋กค๋ง์ ํ ์ ์์ต๋๋ค.
+
+
+
+
+
+
+
+
+
+
+
+
+ | ํ๋ก์ ํธ๋ช
|
+ ๋ด๋น๋ถ์ |
+ ๋ด๋น์ |
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/templates/index.html b/templates/index.html
index b3972f6..a51e5b2 100644
--- a/templates/index.html
+++ b/templates/index.html
@@ -1,46 +1,58 @@
-
-
-
-
-
-
- Project Master Portal
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+ Project Master Portal
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/templates/inquiries.html b/templates/inquiries.html
index 3b39ca1..110837f 100644
--- a/templates/inquiries.html
+++ b/templates/inquiries.html
@@ -1,194 +1,194 @@
-
-
-
-
-
-
- ๋ฌธ์์ฌํญ ๊ด๋ฆฌ - Project Master
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- |
-
- |
- ์ด๋ฏธ์ง |
-
-
- |
-
-
- |
-
-
- |
-
-
- |
- ๋ฌธ์๋ด์ฉ |
-
-
- |
-
-
- |
- ๋ต๋ณ๋ด์ฉ |
-
-
- |
-
-
-
-
-
-
-
-
-
-
-
-
-
-
![]()
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+ ๋ฌธ์์ฌํญ ๊ด๋ฆฌ - Project Master
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ |
+
+ |
+ ์ด๋ฏธ์ง |
+
+
+ |
+
+
+ |
+
+
+ |
+
+
+ |
+ ๋ฌธ์๋ด์ฉ |
+
+
+ |
+
+
+ |
+ ๋ต๋ณ๋ด์ฉ |
+
+
+ |
+
+
+
+
+
+
+
+
+
+
+
+
+
+
![]()
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/templates/mailTest.html b/templates/mailTest.html
index 4f98cdd..8369ea6 100644
--- a/templates/mailTest.html
+++ b/templates/mailTest.html
@@ -1,144 +1,144 @@
-
-
-
-
-
-
- Project Mail Manager
-
-
-
-
-
-
- {% include 'modals/path_selector.html' %}
-
-
-
-
-
-
-
-
๐ฅ ์์
-
๐ค ๋ฐ์
-
๐ ์์
-
๐๏ธ ํด์งํต
-
-
-
-
-
-
-
- 0๊ฐ ์ ํ๋จ
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- ์๋
ํ์ธ์. ์ดํํ ์ ์์ฐ๊ตฌ์๋.
- ๋ผ์ค์ค ITTC ๊ด๊ฐ ๊ต์ก์ผํฐ ์ฐฉ๊ณต์๊ณผ ๊ด๋ จํ์ฌ ์ ๋ถ ์ธก ์ธ์ฌ์ ์ผ์ ์ ๋ฐ์ํ ์ต์ข
๊ณต๋ฌธ์ ์ก๋ถํฉ๋๋ค.
-
-
-
-
-
์ฒจ๋ถํ์ผ ๋ฆฌ์คํธ
-
- AI ํ๋จ
-
-
-
-
-
-
-
-
-
-
-
- {% include 'modals/address_book.html' %}
-
-
-
-
-
+
+
+
+
+
+
+ Project Mail Manager
+
+
+
+
+
+
+ {% include 'modals/path_selector.html' %}
+
+
+
+
+
+
+
+
๐ฅ ์์
+
๐ค ๋ฐ์
+
๐ ์์
+
๐๏ธ ํด์งํต
+
+
+
+
+
+
+
+ 0๊ฐ ์ ํ๋จ
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ์๋
ํ์ธ์. ์ดํํ ์ ์์ฐ๊ตฌ์๋.
+ ๋ผ์ค์ค ITTC ๊ด๊ฐ ๊ต์ก์ผํฐ ์ฐฉ๊ณต์๊ณผ ๊ด๋ จํ์ฌ ์ ๋ถ ์ธก ์ธ์ฌ์ ์ผ์ ์ ๋ฐ์ํ ์ต์ข
๊ณต๋ฌธ์ ์ก๋ถํฉ๋๋ค.
+
+
+
+
+
์ฒจ๋ถํ์ผ ๋ฆฌ์คํธ
+
+ AI ํ๋จ
+
+
+
+
+
+
+
+
+
+
+
+ {% include 'modals/address_book.html' %}
+
+
+
+
+
\ No newline at end of file
diff --git a/templates/modals/address_book.html b/templates/modals/address_book.html
index 4fc6c7d..a84954c 100644
--- a/templates/modals/address_book.html
+++ b/templates/modals/address_book.html
@@ -1,62 +1,62 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- | ์ฑ๋ช
|
- ์์/์ง์ |
- ์ด๋ฉ์ผ |
- ์ฐ๋ฝ์ฒ |
- ๊ด๋ฆฌ |
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ | ์ฑ๋ช
|
+ ์์/์ง์ |
+ ์ด๋ฉ์ผ |
+ ์ฐ๋ฝ์ฒ |
+ ๊ด๋ฆฌ |
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/templates/modals/path_selector.html b/templates/modals/path_selector.html
index 38e49c6..bd38eb9 100644
--- a/templates/modals/path_selector.html
+++ b/templates/modals/path_selector.html
@@ -1,24 +1,24 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/tokens.json b/tokens.json
index 6e99e9a..e503fa1 100644
--- a/tokens.json
+++ b/tokens.json
@@ -1,1229 +1,1229 @@
-{
- "core": {
- "dimension": {
- "scale": {
- "$value": "2",
- "$type": "dimension"
- },
- "xs": {
- "$value": "4",
- "$type": "dimension"
- },
- "sm": {
- "$value": "{dimension.xs} * {dimension.scale}",
- "$type": "dimension"
- },
- "md": {
- "$value": "{dimension.sm} * {dimension.scale}",
- "$type": "dimension"
- },
- "lg": {
- "$value": "{dimension.md} * {dimension.scale}",
- "$type": "dimension"
- },
- "xl": {
- "$value": "{dimension.lg} * {dimension.scale}",
- "$type": "dimension"
- }
- },
- "spacing": {
- "xs": {
- "$value": "{dimension.xs}",
- "$type": "spacing"
- },
- "sm": {
- "$value": "{dimension.sm}",
- "$type": "spacing"
- },
- "md": {
- "$value": "{dimension.md}",
- "$type": "spacing"
- },
- "lg": {
- "$value": "{dimension.lg}",
- "$type": "spacing"
- },
- "xl": {
- "$value": "{dimension.xl}",
- "$type": "spacing"
- },
- "multi-value": {
- "$value": "{dimension.sm} {dimension.xl}",
- "$type": "spacing",
- "$description": "You can have multiple values in a single spacing token. Read more on these: https://docs.tokens.studio/available-tokens/spacing-tokens#multi-value-spacing-tokens"
- }
- },
- "borderRadius": {
- "sm": {
- "$value": "4",
- "$type": "borderRadius"
- },
- "lg": {
- "$value": "8",
- "$type": "borderRadius"
- },
- "xl": {
- "$value": "16",
- "$type": "borderRadius"
- },
- "multi-value": {
- "$value": "{borderRadius.sm} {borderRadius.lg}",
- "$type": "borderRadius",
- "$description": "You can have multiple values in a single radius token. Read more on these: https://docs.tokens.studio/available-tokens/border-radius-tokens#single--multiple-values"
- }
- },
- "colors": {
- "black": {
- "$value": "#000000",
- "$type": "color"
- },
- "white": {
- "$value": "#ffffff",
- "$type": "color"
- },
- "gray": {
- "100": {
- "$value": "#f7fafc",
- "$type": "color"
- },
- "200": {
- "$value": "#edf2f7",
- "$type": "color"
- },
- "300": {
- "$value": "#e2e8f0",
- "$type": "color"
- },
- "400": {
- "$value": "#cbd5e0",
- "$type": "color"
- },
- "500": {
- "$value": "#a0aec0",
- "$type": "color"
- },
- "600": {
- "$value": "#718096",
- "$type": "color"
- },
- "700": {
- "$value": "#4a5568",
- "$type": "color"
- },
- "800": {
- "$value": "#2d3748",
- "$type": "color"
- },
- "900": {
- "$value": "#1a202c",
- "$type": "color"
- }
- },
- "red": {
- "100": {
- "$value": "#fff5f5",
- "$type": "color"
- },
- "200": {
- "$value": "#fed7d7",
- "$type": "color"
- },
- "300": {
- "$value": "#feb2b2",
- "$type": "color"
- },
- "400": {
- "$value": "#fc8181",
- "$type": "color"
- },
- "500": {
- "$value": "#f56565",
- "$type": "color"
- },
- "600": {
- "$value": "#e53e3e",
- "$type": "color"
- },
- "700": {
- "$value": "#c53030",
- "$type": "color"
- },
- "800": {
- "$value": "#9b2c2c",
- "$type": "color"
- },
- "900": {
- "$value": "#742a2a",
- "$type": "color"
- }
- },
- "orange": {
- "100": {
- "$value": "#fffaf0",
- "$type": "color"
- },
- "200": {
- "$value": "#feebc8",
- "$type": "color"
- },
- "300": {
- "$value": "#fbd38d",
- "$type": "color"
- },
- "400": {
- "$value": "#f6ad55",
- "$type": "color"
- },
- "500": {
- "$value": "#ed8936",
- "$type": "color"
- },
- "600": {
- "$value": "#dd6b20",
- "$type": "color"
- },
- "700": {
- "$value": "#c05621",
- "$type": "color"
- },
- "800": {
- "$value": "#9c4221",
- "$type": "color"
- },
- "900": {
- "$value": "#7b341e",
- "$type": "color"
- }
- },
- "yellow": {
- "100": {
- "$value": "#fffff0",
- "$type": "color"
- },
- "200": {
- "$value": "#fefcbf",
- "$type": "color"
- },
- "300": {
- "$value": "#faf089",
- "$type": "color"
- },
- "400": {
- "$value": "#f6e05e",
- "$type": "color"
- },
- "500": {
- "$value": "#ecc94b",
- "$type": "color"
- },
- "600": {
- "$value": "#d69e2e",
- "$type": "color"
- },
- "700": {
- "$value": "#b7791f",
- "$type": "color"
- },
- "800": {
- "$value": "#975a16",
- "$type": "color"
- },
- "900": {
- "$value": "#744210",
- "$type": "color"
- }
- },
- "green": {
- "100": {
- "$value": "#f0fff4",
- "$type": "color"
- },
- "200": {
- "$value": "#c6f6d5",
- "$type": "color"
- },
- "300": {
- "$value": "#9ae6b4",
- "$type": "color"
- },
- "400": {
- "$value": "#68d391",
- "$type": "color"
- },
- "500": {
- "$value": "#48bb78",
- "$type": "color"
- },
- "600": {
- "$value": "#38a169",
- "$type": "color"
- },
- "700": {
- "$value": "#2f855a",
- "$type": "color"
- },
- "800": {
- "$value": "#276749",
- "$type": "color"
- },
- "900": {
- "$value": "#22543d",
- "$type": "color"
- }
- },
- "teal": {
- "100": {
- "$value": "#e6fffa",
- "$type": "color"
- },
- "200": {
- "$value": "#b2f5ea",
- "$type": "color"
- },
- "300": {
- "$value": "#81e6d9",
- "$type": "color"
- },
- "400": {
- "$value": "#4fd1c5",
- "$type": "color"
- },
- "500": {
- "$value": "#38b2ac",
- "$type": "color"
- },
- "600": {
- "$value": "#319795",
- "$type": "color"
- },
- "700": {
- "$value": "#2c7a7b",
- "$type": "color"
- },
- "800": {
- "$value": "#285e61",
- "$type": "color"
- },
- "900": {
- "$value": "#234e52",
- "$type": "color"
- }
- },
- "blue": {
- "100": {
- "$value": "#ebf8ff",
- "$type": "color"
- },
- "200": {
- "$value": "#bee3f8",
- "$type": "color"
- },
- "300": {
- "$value": "#90cdf4",
- "$type": "color"
- },
- "400": {
- "$value": "#63b3ed",
- "$type": "color"
- },
- "500": {
- "$value": "#4299e1",
- "$type": "color"
- },
- "600": {
- "$value": "#3182ce",
- "$type": "color"
- },
- "700": {
- "$value": "#2b6cb0",
- "$type": "color"
- },
- "800": {
- "$value": "#2c5282",
- "$type": "color"
- },
- "900": {
- "$value": "#2a4365",
- "$type": "color"
- }
- },
- "indigo": {
- "100": {
- "$value": "#ebf4ff",
- "$type": "color"
- },
- "200": {
- "$value": "#c3dafe",
- "$type": "color"
- },
- "300": {
- "$value": "#a3bffa",
- "$type": "color"
- },
- "400": {
- "$value": "#7f9cf5",
- "$type": "color"
- },
- "500": {
- "$value": "#667eea",
- "$type": "color"
- },
- "600": {
- "$value": "#5a67d8",
- "$type": "color"
- },
- "700": {
- "$value": "#4c51bf",
- "$type": "color"
- },
- "800": {
- "$value": "#434190",
- "$type": "color"
- },
- "900": {
- "$value": "#3c366b",
- "$type": "color"
- }
- },
- "purple": {
- "100": {
- "$value": "#faf5ff",
- "$type": "color"
- },
- "200": {
- "$value": "#e9d8fd",
- "$type": "color"
- },
- "300": {
- "$value": "#d6bcfa",
- "$type": "color"
- },
- "400": {
- "$value": "#b794f4",
- "$type": "color"
- },
- "500": {
- "$value": "#9f7aea",
- "$type": "color"
- },
- "600": {
- "$value": "#805ad5",
- "$type": "color"
- },
- "700": {
- "$value": "#6b46c1",
- "$type": "color"
- },
- "800": {
- "$value": "#553c9a",
- "$type": "color"
- },
- "900": {
- "$value": "#44337a",
- "$type": "color"
- }
- },
- "pink": {
- "100": {
- "$value": "#fff5f7",
- "$type": "color"
- },
- "200": {
- "$value": "#fed7e2",
- "$type": "color"
- },
- "300": {
- "$value": "#fbb6ce",
- "$type": "color"
- },
- "400": {
- "$value": "#f687b3",
- "$type": "color"
- },
- "500": {
- "$value": "#ed64a6",
- "$type": "color"
- },
- "600": {
- "$value": "#d53f8c",
- "$type": "color"
- },
- "700": {
- "$value": "#b83280",
- "$type": "color"
- },
- "800": {
- "$value": "#97266d",
- "$type": "color"
- },
- "900": {
- "$value": "#702459",
- "$type": "color"
- }
- }
- },
- "opacity": {
- "low": {
- "$value": "10%",
- "$type": "opacity"
- },
- "md": {
- "$value": "50%",
- "$type": "opacity"
- },
- "high": {
- "$value": "90%",
- "$type": "opacity"
- }
- },
- "fontFamilies": {
- "heading": {
- "$value": "Inter",
- "$type": "fontFamilies"
- },
- "body": {
- "$value": "Roboto",
- "$type": "fontFamilies"
- },
- "pretendard": {
- "$value": "Pretendard",
- "$type": "fontFamilies"
- }
- },
- "lineHeights": {
- "0": {
- "$value": 20,
- "$type": "lineHeights"
- },
- "1": {
- "$value": 20,
- "$type": "lineHeights"
- },
- "2": {
- "$value": 20,
- "$type": "lineHeights"
- },
- "3": {
- "$value": 20,
- "$type": "lineHeights"
- },
- "4": {
- "$value": 20,
- "$type": "lineHeights"
- },
- "5": {
- "$value": 20,
- "$type": "lineHeights"
- },
- "6": {
- "$value": 20,
- "$type": "lineHeights"
- },
- "heading": {
- "$value": "110%",
- "$type": "lineHeights"
- },
- "body": {
- "$value": "140%",
- "$type": "lineHeights"
- }
- },
- "letterSpacing": {
- "0": {
- "$value": "-2%",
- "$type": "letterSpacing"
- },
- "default": {
- "$value": "0",
- "$type": "letterSpacing"
- },
- "increased": {
- "$value": "150%",
- "$type": "letterSpacing"
- },
- "decreased": {
- "$value": "-5%",
- "$type": "letterSpacing"
- }
- },
- "paragraphSpacing": {
- "0": {
- "$value": 0,
- "$type": "paragraphSpacing"
- },
- "h1": {
- "$value": "32",
- "$type": "paragraphSpacing"
- },
- "h2": {
- "$value": "26",
- "$type": "paragraphSpacing"
- }
- },
- "fontWeights": {
- "headingRegular": {
- "$value": "Regular",
- "$type": "fontWeights"
- },
- "headingBold": {
- "$value": "Bold",
- "$type": "fontWeights"
- },
- "bodyRegular": {
- "$value": "Regular",
- "$type": "fontWeights"
- },
- "bodyBold": {
- "$value": "Bold",
- "$type": "fontWeights"
- },
- "pretendard-0": {
- "$value": "ExtraBold",
- "$type": "fontWeights"
- },
- "pretendard-1": {
- "$value": "SemiBold",
- "$type": "fontWeights"
- },
- "pretendard-2": {
- "$value": "Regular",
- "$type": "fontWeights"
- }
- },
- "fontSizes": {
- "h1": {
- "$value": "roundTo({fontSizes.body}*1.25^5)",
- "$type": "fontSizes"
- },
- "h2": {
- "$value": "roundTo({fontSizes.body}*1.25^4)",
- "$type": "fontSizes"
- },
- "h3": {
- "$value": "roundTo({fontSizes.body}*1.25^3)",
- "$type": "fontSizes"
- },
- "h4": {
- "$value": "roundTo({fontSizes.body}*1.25^2)",
- "$type": "fontSizes"
- },
- "h5": {
- "$value": "roundTo({fontSizes.body}*1.25^1)",
- "$type": "fontSizes"
- },
- "h6": {
- "$value": "{fontSizes.body}",
- "$type": "fontSizes"
- },
- "body": {
- "$value": "16",
- "$type": "fontSizes"
- },
- "sm": {
- "$value": "{fontSizes.body} * 0.85",
- "$type": "fontSizes"
- },
- "xs": {
- "$value": "{fontSizes.body} * 0.65",
- "$type": "fontSizes"
- }
- },
- "ai_color": {
- "$value": "linear-gradient(180deg, #da8cf1 0%, #8bb1f2 100%)",
- "$type": "color"
- },
- "primary-lv-0": {
- "$value": "#e9eeed",
- "$type": "color"
- },
- "primary-lv-1": {
- "$value": "#d2dcdb",
- "$type": "color"
- },
- "primary-lv-2": {
- "$value": "#a5b9b6",
- "$type": "color"
- },
- "primary-lv-3": {
- "$value": "#789792",
- "$type": "color"
- },
- "primary-lv-4": {
- "$value": "#4b746d",
- "$type": "color"
- },
- "primary-lv-5": {
- "$value": "#35635c",
- "$type": "color"
- },
- "primary-lv-6": {
- "$value": "#1e5149",
- "$type": "color"
- },
- "primary-lv-7": {
- "$value": "#1b443d",
- "$type": "color"
- },
- "primary-lv-8": {
- "$value": "#193833",
- "$type": "color"
- },
- "primary-lv-9": {
- "$value": "#162a27",
- "$type": "color"
- },
- "color-red": {
- "$value": "#f21d0d",
- "$type": "color"
- },
- "color-pink": {
- "$value": "#e8175e",
- "$type": "color"
- },
- "color-magenta": {
- "$value": "#b92ed1",
- "$type": "color"
- },
- "color-purple": {
- "$value": "#6d3dc2",
- "$type": "color"
- },
- "color-navy": {
- "$value": "#4255bd",
- "$type": "color"
- },
- "color-blue": {
- "$value": "#0d8df2",
- "$type": "color"
- },
- "color-cyan": {
- "$value": "#03aefc",
- "$type": "color"
- },
- "color-green": {
- "$value": "#4db251",
- "$type": "color"
- },
- "color-yellow": {
- "$value": "#ffbf00",
- "$type": "color"
- },
- "color-orange": {
- "$value": "#ff9800",
- "$type": "color"
- },
- "color-dahong": {
- "$value": "#ff3d00",
- "$type": "color"
- },
- "color-brown": {
- "$value": "#a0705f",
- "$type": "color"
- },
- "color-iron": {
- "$value": "#7f7f7f",
- "$type": "color"
- },
- "color-steel": {
- "$value": "#688897",
- "$type": "color"
- },
- "color-red-light": {
- "$value": "#fee9e7",
- "$type": "color"
- },
- "color-pink-light": {
- "$value": "#fde8ef",
- "$type": "color"
- },
- "color-magenta-light": {
- "$value": "#f8ebfb",
- "$type": "color"
- },
- "color-purple-light": {
- "$value": "#f1ecf9",
- "$type": "color"
- },
- "color-navy-light": {
- "$value": "#edeef9",
- "$type": "color"
- },
- "color-blue-light": {
- "$value": "#e7f4fe",
- "$type": "color"
- },
- "color-cyan-light": {
- "$value": "#e6f7ff",
- "$type": "color"
- },
- "color-green-light": {
- "$value": "#eef8ee",
- "$type": "color"
- },
- "color-yellow-light": {
- "$value": "#fff9e6",
- "$type": "color"
- },
- "color-orange-light": {
- "$value": "#fff5e6",
- "$type": "color"
- },
- "color-dahong-light": {
- "$value": "#ffece6",
- "$type": "color"
- },
- "color-brown-light": {
- "$value": "#f6f1ef",
- "$type": "color"
- },
- "color-iron-light": {
- "$value": "#f3f3f3",
- "$type": "color"
- },
- "color-steel-light": {
- "$value": "#f0f4f5",
- "$type": "color"
- },
- "color-red-medium": {
- "$value": "#faa59e",
- "$type": "color"
- },
- "color-pink-medium": {
- "$value": "#f6a2bf",
- "$type": "color"
- },
- "color-magenta-medium": {
- "$value": "#e3abec",
- "$type": "color"
- },
- "color-purple-medium": {
- "$value": "#c5b1e7",
- "$type": "color"
- },
- "color-navy-medium": {
- "$value": "#b3bbe5",
- "$type": "color"
- },
- "color-blue-medium": {
- "$value": "#9ed1fa",
- "$type": "color"
- },
- "color-cyan-medium": {
- "$value": "#9adffe",
- "$type": "color"
- },
- "color-green-medium": {
- "$value": "#b8e0b9",
- "$type": "color"
- },
- "color-yellow-medium": {
- "$value": "#ffe599",
- "$type": "color"
- },
- "color-orange-medium": {
- "$value": "#ffd699",
- "$type": "color"
- },
- "color-dahong-medium": {
- "$value": "#ffb199",
- "$type": "color"
- },
- "color-brown-medium": {
- "$value": "#d9c6bf",
- "$type": "color"
- },
- "color-iron-medium": {
- "$value": "#cccccc",
- "$type": "color"
- },
- "color-steel-medium": {
- "$value": "#c3cfd5",
- "$type": "color"
- },
- "checked-color-background": {
- "$value": "#03aefc1a",
- "$type": "color"
- },
- "headercolor": {
- "$value": "linear-gradient(90deg, #193833 0%, #1e5149 100%)",
- "$type": "color"
- },
- "line-style": {
- "$value": "linear-gradient(135deg, #ffffff 0%, #0000001a 50%, #ffffff 100%)",
- "$type": "color"
- },
- "box__drop-shadow": {
- "$value": {
- "color": "#00000029",
- "type": "dropShadow",
- "x": 0,
- "y": 8,
- "blur": 24,
- "spread": 0
- },
- "$type": "boxShadow"
- },
- "fontSize": {
- "0": {
- "$value": 12,
- "$type": "fontSizes"
- },
- "1": {
- "$value": 14,
- "$type": "fontSizes"
- },
- "2": {
- "$value": 16,
- "$type": "fontSizes"
- },
- "3": {
- "$value": 20,
- "$type": "fontSizes"
- }
- },
- "": {
- "$value": {
- "fontFamily": "{fontFamilies.pretendard}",
- "fontWeight": "{fontWeights.pretendard-0}",
- "lineHeight": "{lineHeights.0}",
- "fontSize": "{fontSize.3}",
- "letterSpacing": "{letterSpacing.0}",
- "paragraphSpacing": "{paragraphSpacing.0}",
- "paragraphIndent": "{paragraphIndent.0}",
- "textCase": "{textCase.none}",
- "textDecoration": "{textDecoration.none}"
- },
- "$type": "typography"
- },
- "": {
- "$value": {
- "fontFamily": "{fontFamilies.pretendard}",
- "fontWeight": "{fontWeights.pretendard-1}",
- "lineHeight": "{lineHeights.0}",
- "fontSize": "{fontSize.2}",
- "letterSpacing": "{letterSpacing.0}",
- "paragraphSpacing": "{paragraphSpacing.0}",
- "paragraphIndent": "{paragraphIndent.0}",
- "textCase": "{textCase.none}",
- "textDecoration": "{textDecoration.none}"
- },
- "$type": "typography"
- },
- "": {
- "$value": {
- "fontFamily": "{fontFamilies.pretendard}",
- "fontWeight": "{fontWeights.pretendard-1}",
- "lineHeight": "{lineHeights.0}",
- "fontSize": "{fontSize.1}",
- "letterSpacing": "{letterSpacing.0}",
- "paragraphSpacing": "{paragraphSpacing.0}",
- "paragraphIndent": "{paragraphIndent.0}",
- "textCase": "{textCase.none}",
- "textDecoration": "{textDecoration.none}"
- },
- "$type": "typography"
- },
- "": {
- "$value": {
- "fontFamily": "{fontFamilies.pretendard}",
- "fontWeight": "{fontWeights.pretendard-2}",
- "lineHeight": "{lineHeights.0}",
- "fontSize": "{fontSize.1}",
- "letterSpacing": "{letterSpacing.0}",
- "paragraphSpacing": "{paragraphSpacing.0}",
- "paragraphIndent": "{paragraphIndent.0}",
- "textCase": "{textCase.none}",
- "textDecoration": "{textDecoration.none}"
- },
- "$type": "typography"
- },
- "": {
- "$value": {
- "fontFamily": "{fontFamilies.pretendard}",
- "fontWeight": "{fontWeights.pretendard-1}",
- "lineHeight": "{lineHeights.0}",
- "fontSize": "{fontSize.0}",
- "letterSpacing": "{letterSpacing.0}",
- "paragraphSpacing": "{paragraphSpacing.0}",
- "paragraphIndent": "{paragraphIndent.0}",
- "textCase": "{textCase.none}",
- "textDecoration": "{textDecoration.none}"
- },
- "$type": "typography"
- },
- "": {
- "$value": {
- "fontFamily": "{fontFamilies.pretendard}",
- "fontWeight": "{fontWeights.pretendard-2}",
- "lineHeight": "{lineHeights.0}",
- "fontSize": "{fontSize.0}",
- "letterSpacing": "{letterSpacing.0}",
- "paragraphSpacing": "{paragraphSpacing.0}",
- "paragraphIndent": "{paragraphIndent.0}",
- "textCase": "{textCase.none}",
- "textDecoration": "{textDecoration.none}"
- },
- "$type": "typography"
- },
- "
": {
- "$value": {
- "fontFamily": "{fontFamilies.pretendard}",
- "fontWeight": "{fontWeights.pretendard-2}",
- "lineHeight": "{lineHeights.0}",
- "fontSize": "{fontSize.0}",
- "letterSpacing": "{letterSpacing.0}",
- "paragraphSpacing": "{paragraphSpacing.0}",
- "paragraphIndent": "{paragraphIndent.0}",
- "textCase": "{textCase.none}",
- "textDecoration": "{textDecoration.none}"
- },
- "$type": "typography"
- },
- "textCase": {
- "none": {
- "$value": "none",
- "$type": "textCase"
- }
- },
- "textDecoration": {
- "none": {
- "$value": "none",
- "$type": "textDecoration"
- }
- },
- "paragraphIndent": {
- "0": {
- "$value": "0px",
- "$type": "dimension"
- }
- }
- },
- "light": {
- "fg": {
- "default": {
- "$value": "{colors.black}",
- "$type": "color"
- },
- "muted": {
- "$value": "{colors.gray.700}",
- "$type": "color"
- },
- "subtle": {
- "$value": "{colors.gray.500}",
- "$type": "color"
- }
- },
- "bg": {
- "default": {
- "$value": "{colors.white}",
- "$type": "color"
- },
- "muted": {
- "$value": "{colors.gray.100}",
- "$type": "color"
- },
- "subtle": {
- "$value": "{colors.gray.200}",
- "$type": "color"
- }
- },
- "accent": {
- "default": {
- "$value": "{colors.indigo.400}",
- "$type": "color"
- },
- "onAccent": {
- "$value": "{colors.white}",
- "$type": "color"
- },
- "bg": {
- "$value": "{colors.indigo.200}",
- "$type": "color"
- }
- },
- "shadows": {
- "default": {
- "$value": "{colors.gray.900}",
- "$type": "color"
- }
- }
- },
- "dark": {
- "fg": {
- "default": {
- "$value": "{colors.white}",
- "$type": "color"
- },
- "muted": {
- "$value": "{colors.gray.300}",
- "$type": "color"
- },
- "subtle": {
- "$value": "{colors.gray.500}",
- "$type": "color"
- }
- },
- "bg": {
- "default": {
- "$value": "{colors.gray.900}",
- "$type": "color"
- },
- "muted": {
- "$value": "{colors.gray.700}",
- "$type": "color"
- },
- "subtle": {
- "$value": "{colors.gray.600}",
- "$type": "color"
- }
- },
- "accent": {
- "default": {
- "$value": "{colors.indigo.600}",
- "$type": "color"
- },
- "onAccent": {
- "$value": "{colors.white}",
- "$type": "color"
- },
- "bg": {
- "$value": "{colors.indigo.800}",
- "$type": "color"
- }
- },
- "shadows": {
- "default": {
- "$value": "rgba({colors.black}, 0.3)",
- "$type": "color"
- }
- }
- },
- "theme": {
- "button": {
- "primary": {
- "background": {
- "$value": "{accent.default}",
- "$type": "color"
- },
- "text": {
- "$value": "{accent.onAccent}",
- "$type": "color"
- }
- },
- "borderRadius": {
- "$value": "{borderRadius.lg}",
- "$type": "borderRadius"
- },
- "borderWidth": {
- "$value": "{dimension.sm}",
- "$type": "borderWidth"
- }
- },
- "card": {
- "borderRadius": {
- "$value": "{borderRadius.lg}",
- "$type": "borderRadius"
- },
- "background": {
- "$value": "{bg.default}",
- "$type": "color"
- },
- "padding": {
- "$value": "{dimension.md}",
- "$type": "dimension"
- }
- },
- "boxShadow": {
- "default": {
- "$value": [
- {
- "x": 5,
- "y": 5,
- "spread": 3,
- "color": "rgba({shadows.default}, 0.15)",
- "blur": 5,
- "$type": "dropShadow"
- },
- {
- "x": 4,
- "y": 4,
- "spread": 6,
- "color": "#00000033",
- "blur": 5,
- "$type": "innerShadow"
- }
- ],
- "$type": "boxShadow"
- }
- },
- "typography": {
- "H1": {
- "Bold": {
- "$value": {
- "fontFamily": "{fontFamilies.heading}",
- "fontWeight": "{fontWeights.headingBold}",
- "lineHeight": "{lineHeights.heading}",
- "fontSize": "{fontSizes.h1}",
- "paragraphSpacing": "{paragraphSpacing.h1}",
- "letterSpacing": "{letterSpacing.decreased}"
- },
- "$type": "typography"
- },
- "Regular": {
- "$value": {
- "fontFamily": "{fontFamilies.heading}",
- "fontWeight": "{fontWeights.headingRegular}",
- "lineHeight": "{lineHeights.heading}",
- "fontSize": "{fontSizes.h1}",
- "paragraphSpacing": "{paragraphSpacing.h1}",
- "letterSpacing": "{letterSpacing.decreased}"
- },
- "$type": "typography"
- }
- },
- "H2": {
- "Bold": {
- "$value": {
- "fontFamily": "{fontFamilies.heading}",
- "fontWeight": "{fontWeights.headingBold}",
- "lineHeight": "{lineHeights.heading}",
- "fontSize": "{fontSizes.h2}",
- "paragraphSpacing": "{paragraphSpacing.h2}",
- "letterSpacing": "{letterSpacing.decreased}"
- },
- "$type": "typography"
- },
- "Regular": {
- "$value": {
- "fontFamily": "{fontFamilies.heading}",
- "fontWeight": "{fontWeights.headingRegular}",
- "lineHeight": "{lineHeights.heading}",
- "fontSize": "{fontSizes.h2}",
- "paragraphSpacing": "{paragraphSpacing.h2}",
- "letterSpacing": "{letterSpacing.decreased}"
- },
- "$type": "typography"
- }
- },
- "Body": {
- "$value": {
- "fontFamily": "{fontFamilies.body}",
- "fontWeight": "{fontWeights.bodyRegular}",
- "lineHeight": "{lineHeights.heading}",
- "fontSize": "{fontSizes.body}",
- "paragraphSpacing": "{paragraphSpacing.h2}"
- },
- "$type": "typography"
- }
- }
- },
- "$themes": [],
- "$metadata": {
- "tokenSetOrder": [
- "core",
- "light",
- "dark",
- "theme"
- ]
- }
+{
+ "core": {
+ "dimension": {
+ "scale": {
+ "$value": "2",
+ "$type": "dimension"
+ },
+ "xs": {
+ "$value": "4",
+ "$type": "dimension"
+ },
+ "sm": {
+ "$value": "{dimension.xs} * {dimension.scale}",
+ "$type": "dimension"
+ },
+ "md": {
+ "$value": "{dimension.sm} * {dimension.scale}",
+ "$type": "dimension"
+ },
+ "lg": {
+ "$value": "{dimension.md} * {dimension.scale}",
+ "$type": "dimension"
+ },
+ "xl": {
+ "$value": "{dimension.lg} * {dimension.scale}",
+ "$type": "dimension"
+ }
+ },
+ "spacing": {
+ "xs": {
+ "$value": "{dimension.xs}",
+ "$type": "spacing"
+ },
+ "sm": {
+ "$value": "{dimension.sm}",
+ "$type": "spacing"
+ },
+ "md": {
+ "$value": "{dimension.md}",
+ "$type": "spacing"
+ },
+ "lg": {
+ "$value": "{dimension.lg}",
+ "$type": "spacing"
+ },
+ "xl": {
+ "$value": "{dimension.xl}",
+ "$type": "spacing"
+ },
+ "multi-value": {
+ "$value": "{dimension.sm} {dimension.xl}",
+ "$type": "spacing",
+ "$description": "You can have multiple values in a single spacing token. Read more on these: https://docs.tokens.studio/available-tokens/spacing-tokens#multi-value-spacing-tokens"
+ }
+ },
+ "borderRadius": {
+ "sm": {
+ "$value": "4",
+ "$type": "borderRadius"
+ },
+ "lg": {
+ "$value": "8",
+ "$type": "borderRadius"
+ },
+ "xl": {
+ "$value": "16",
+ "$type": "borderRadius"
+ },
+ "multi-value": {
+ "$value": "{borderRadius.sm} {borderRadius.lg}",
+ "$type": "borderRadius",
+ "$description": "You can have multiple values in a single radius token. Read more on these: https://docs.tokens.studio/available-tokens/border-radius-tokens#single--multiple-values"
+ }
+ },
+ "colors": {
+ "black": {
+ "$value": "#000000",
+ "$type": "color"
+ },
+ "white": {
+ "$value": "#ffffff",
+ "$type": "color"
+ },
+ "gray": {
+ "100": {
+ "$value": "#f7fafc",
+ "$type": "color"
+ },
+ "200": {
+ "$value": "#edf2f7",
+ "$type": "color"
+ },
+ "300": {
+ "$value": "#e2e8f0",
+ "$type": "color"
+ },
+ "400": {
+ "$value": "#cbd5e0",
+ "$type": "color"
+ },
+ "500": {
+ "$value": "#a0aec0",
+ "$type": "color"
+ },
+ "600": {
+ "$value": "#718096",
+ "$type": "color"
+ },
+ "700": {
+ "$value": "#4a5568",
+ "$type": "color"
+ },
+ "800": {
+ "$value": "#2d3748",
+ "$type": "color"
+ },
+ "900": {
+ "$value": "#1a202c",
+ "$type": "color"
+ }
+ },
+ "red": {
+ "100": {
+ "$value": "#fff5f5",
+ "$type": "color"
+ },
+ "200": {
+ "$value": "#fed7d7",
+ "$type": "color"
+ },
+ "300": {
+ "$value": "#feb2b2",
+ "$type": "color"
+ },
+ "400": {
+ "$value": "#fc8181",
+ "$type": "color"
+ },
+ "500": {
+ "$value": "#f56565",
+ "$type": "color"
+ },
+ "600": {
+ "$value": "#e53e3e",
+ "$type": "color"
+ },
+ "700": {
+ "$value": "#c53030",
+ "$type": "color"
+ },
+ "800": {
+ "$value": "#9b2c2c",
+ "$type": "color"
+ },
+ "900": {
+ "$value": "#742a2a",
+ "$type": "color"
+ }
+ },
+ "orange": {
+ "100": {
+ "$value": "#fffaf0",
+ "$type": "color"
+ },
+ "200": {
+ "$value": "#feebc8",
+ "$type": "color"
+ },
+ "300": {
+ "$value": "#fbd38d",
+ "$type": "color"
+ },
+ "400": {
+ "$value": "#f6ad55",
+ "$type": "color"
+ },
+ "500": {
+ "$value": "#ed8936",
+ "$type": "color"
+ },
+ "600": {
+ "$value": "#dd6b20",
+ "$type": "color"
+ },
+ "700": {
+ "$value": "#c05621",
+ "$type": "color"
+ },
+ "800": {
+ "$value": "#9c4221",
+ "$type": "color"
+ },
+ "900": {
+ "$value": "#7b341e",
+ "$type": "color"
+ }
+ },
+ "yellow": {
+ "100": {
+ "$value": "#fffff0",
+ "$type": "color"
+ },
+ "200": {
+ "$value": "#fefcbf",
+ "$type": "color"
+ },
+ "300": {
+ "$value": "#faf089",
+ "$type": "color"
+ },
+ "400": {
+ "$value": "#f6e05e",
+ "$type": "color"
+ },
+ "500": {
+ "$value": "#ecc94b",
+ "$type": "color"
+ },
+ "600": {
+ "$value": "#d69e2e",
+ "$type": "color"
+ },
+ "700": {
+ "$value": "#b7791f",
+ "$type": "color"
+ },
+ "800": {
+ "$value": "#975a16",
+ "$type": "color"
+ },
+ "900": {
+ "$value": "#744210",
+ "$type": "color"
+ }
+ },
+ "green": {
+ "100": {
+ "$value": "#f0fff4",
+ "$type": "color"
+ },
+ "200": {
+ "$value": "#c6f6d5",
+ "$type": "color"
+ },
+ "300": {
+ "$value": "#9ae6b4",
+ "$type": "color"
+ },
+ "400": {
+ "$value": "#68d391",
+ "$type": "color"
+ },
+ "500": {
+ "$value": "#48bb78",
+ "$type": "color"
+ },
+ "600": {
+ "$value": "#38a169",
+ "$type": "color"
+ },
+ "700": {
+ "$value": "#2f855a",
+ "$type": "color"
+ },
+ "800": {
+ "$value": "#276749",
+ "$type": "color"
+ },
+ "900": {
+ "$value": "#22543d",
+ "$type": "color"
+ }
+ },
+ "teal": {
+ "100": {
+ "$value": "#e6fffa",
+ "$type": "color"
+ },
+ "200": {
+ "$value": "#b2f5ea",
+ "$type": "color"
+ },
+ "300": {
+ "$value": "#81e6d9",
+ "$type": "color"
+ },
+ "400": {
+ "$value": "#4fd1c5",
+ "$type": "color"
+ },
+ "500": {
+ "$value": "#38b2ac",
+ "$type": "color"
+ },
+ "600": {
+ "$value": "#319795",
+ "$type": "color"
+ },
+ "700": {
+ "$value": "#2c7a7b",
+ "$type": "color"
+ },
+ "800": {
+ "$value": "#285e61",
+ "$type": "color"
+ },
+ "900": {
+ "$value": "#234e52",
+ "$type": "color"
+ }
+ },
+ "blue": {
+ "100": {
+ "$value": "#ebf8ff",
+ "$type": "color"
+ },
+ "200": {
+ "$value": "#bee3f8",
+ "$type": "color"
+ },
+ "300": {
+ "$value": "#90cdf4",
+ "$type": "color"
+ },
+ "400": {
+ "$value": "#63b3ed",
+ "$type": "color"
+ },
+ "500": {
+ "$value": "#4299e1",
+ "$type": "color"
+ },
+ "600": {
+ "$value": "#3182ce",
+ "$type": "color"
+ },
+ "700": {
+ "$value": "#2b6cb0",
+ "$type": "color"
+ },
+ "800": {
+ "$value": "#2c5282",
+ "$type": "color"
+ },
+ "900": {
+ "$value": "#2a4365",
+ "$type": "color"
+ }
+ },
+ "indigo": {
+ "100": {
+ "$value": "#ebf4ff",
+ "$type": "color"
+ },
+ "200": {
+ "$value": "#c3dafe",
+ "$type": "color"
+ },
+ "300": {
+ "$value": "#a3bffa",
+ "$type": "color"
+ },
+ "400": {
+ "$value": "#7f9cf5",
+ "$type": "color"
+ },
+ "500": {
+ "$value": "#667eea",
+ "$type": "color"
+ },
+ "600": {
+ "$value": "#5a67d8",
+ "$type": "color"
+ },
+ "700": {
+ "$value": "#4c51bf",
+ "$type": "color"
+ },
+ "800": {
+ "$value": "#434190",
+ "$type": "color"
+ },
+ "900": {
+ "$value": "#3c366b",
+ "$type": "color"
+ }
+ },
+ "purple": {
+ "100": {
+ "$value": "#faf5ff",
+ "$type": "color"
+ },
+ "200": {
+ "$value": "#e9d8fd",
+ "$type": "color"
+ },
+ "300": {
+ "$value": "#d6bcfa",
+ "$type": "color"
+ },
+ "400": {
+ "$value": "#b794f4",
+ "$type": "color"
+ },
+ "500": {
+ "$value": "#9f7aea",
+ "$type": "color"
+ },
+ "600": {
+ "$value": "#805ad5",
+ "$type": "color"
+ },
+ "700": {
+ "$value": "#6b46c1",
+ "$type": "color"
+ },
+ "800": {
+ "$value": "#553c9a",
+ "$type": "color"
+ },
+ "900": {
+ "$value": "#44337a",
+ "$type": "color"
+ }
+ },
+ "pink": {
+ "100": {
+ "$value": "#fff5f7",
+ "$type": "color"
+ },
+ "200": {
+ "$value": "#fed7e2",
+ "$type": "color"
+ },
+ "300": {
+ "$value": "#fbb6ce",
+ "$type": "color"
+ },
+ "400": {
+ "$value": "#f687b3",
+ "$type": "color"
+ },
+ "500": {
+ "$value": "#ed64a6",
+ "$type": "color"
+ },
+ "600": {
+ "$value": "#d53f8c",
+ "$type": "color"
+ },
+ "700": {
+ "$value": "#b83280",
+ "$type": "color"
+ },
+ "800": {
+ "$value": "#97266d",
+ "$type": "color"
+ },
+ "900": {
+ "$value": "#702459",
+ "$type": "color"
+ }
+ }
+ },
+ "opacity": {
+ "low": {
+ "$value": "10%",
+ "$type": "opacity"
+ },
+ "md": {
+ "$value": "50%",
+ "$type": "opacity"
+ },
+ "high": {
+ "$value": "90%",
+ "$type": "opacity"
+ }
+ },
+ "fontFamilies": {
+ "heading": {
+ "$value": "Inter",
+ "$type": "fontFamilies"
+ },
+ "body": {
+ "$value": "Roboto",
+ "$type": "fontFamilies"
+ },
+ "pretendard": {
+ "$value": "Pretendard",
+ "$type": "fontFamilies"
+ }
+ },
+ "lineHeights": {
+ "0": {
+ "$value": 20,
+ "$type": "lineHeights"
+ },
+ "1": {
+ "$value": 20,
+ "$type": "lineHeights"
+ },
+ "2": {
+ "$value": 20,
+ "$type": "lineHeights"
+ },
+ "3": {
+ "$value": 20,
+ "$type": "lineHeights"
+ },
+ "4": {
+ "$value": 20,
+ "$type": "lineHeights"
+ },
+ "5": {
+ "$value": 20,
+ "$type": "lineHeights"
+ },
+ "6": {
+ "$value": 20,
+ "$type": "lineHeights"
+ },
+ "heading": {
+ "$value": "110%",
+ "$type": "lineHeights"
+ },
+ "body": {
+ "$value": "140%",
+ "$type": "lineHeights"
+ }
+ },
+ "letterSpacing": {
+ "0": {
+ "$value": "-2%",
+ "$type": "letterSpacing"
+ },
+ "default": {
+ "$value": "0",
+ "$type": "letterSpacing"
+ },
+ "increased": {
+ "$value": "150%",
+ "$type": "letterSpacing"
+ },
+ "decreased": {
+ "$value": "-5%",
+ "$type": "letterSpacing"
+ }
+ },
+ "paragraphSpacing": {
+ "0": {
+ "$value": 0,
+ "$type": "paragraphSpacing"
+ },
+ "h1": {
+ "$value": "32",
+ "$type": "paragraphSpacing"
+ },
+ "h2": {
+ "$value": "26",
+ "$type": "paragraphSpacing"
+ }
+ },
+ "fontWeights": {
+ "headingRegular": {
+ "$value": "Regular",
+ "$type": "fontWeights"
+ },
+ "headingBold": {
+ "$value": "Bold",
+ "$type": "fontWeights"
+ },
+ "bodyRegular": {
+ "$value": "Regular",
+ "$type": "fontWeights"
+ },
+ "bodyBold": {
+ "$value": "Bold",
+ "$type": "fontWeights"
+ },
+ "pretendard-0": {
+ "$value": "ExtraBold",
+ "$type": "fontWeights"
+ },
+ "pretendard-1": {
+ "$value": "SemiBold",
+ "$type": "fontWeights"
+ },
+ "pretendard-2": {
+ "$value": "Regular",
+ "$type": "fontWeights"
+ }
+ },
+ "fontSizes": {
+ "h1": {
+ "$value": "roundTo({fontSizes.body}*1.25^5)",
+ "$type": "fontSizes"
+ },
+ "h2": {
+ "$value": "roundTo({fontSizes.body}*1.25^4)",
+ "$type": "fontSizes"
+ },
+ "h3": {
+ "$value": "roundTo({fontSizes.body}*1.25^3)",
+ "$type": "fontSizes"
+ },
+ "h4": {
+ "$value": "roundTo({fontSizes.body}*1.25^2)",
+ "$type": "fontSizes"
+ },
+ "h5": {
+ "$value": "roundTo({fontSizes.body}*1.25^1)",
+ "$type": "fontSizes"
+ },
+ "h6": {
+ "$value": "{fontSizes.body}",
+ "$type": "fontSizes"
+ },
+ "body": {
+ "$value": "16",
+ "$type": "fontSizes"
+ },
+ "sm": {
+ "$value": "{fontSizes.body} * 0.85",
+ "$type": "fontSizes"
+ },
+ "xs": {
+ "$value": "{fontSizes.body} * 0.65",
+ "$type": "fontSizes"
+ }
+ },
+ "ai_color": {
+ "$value": "linear-gradient(180deg, #da8cf1 0%, #8bb1f2 100%)",
+ "$type": "color"
+ },
+ "primary-lv-0": {
+ "$value": "#e9eeed",
+ "$type": "color"
+ },
+ "primary-lv-1": {
+ "$value": "#d2dcdb",
+ "$type": "color"
+ },
+ "primary-lv-2": {
+ "$value": "#a5b9b6",
+ "$type": "color"
+ },
+ "primary-lv-3": {
+ "$value": "#789792",
+ "$type": "color"
+ },
+ "primary-lv-4": {
+ "$value": "#4b746d",
+ "$type": "color"
+ },
+ "primary-lv-5": {
+ "$value": "#35635c",
+ "$type": "color"
+ },
+ "primary-lv-6": {
+ "$value": "#1e5149",
+ "$type": "color"
+ },
+ "primary-lv-7": {
+ "$value": "#1b443d",
+ "$type": "color"
+ },
+ "primary-lv-8": {
+ "$value": "#193833",
+ "$type": "color"
+ },
+ "primary-lv-9": {
+ "$value": "#162a27",
+ "$type": "color"
+ },
+ "color-red": {
+ "$value": "#f21d0d",
+ "$type": "color"
+ },
+ "color-pink": {
+ "$value": "#e8175e",
+ "$type": "color"
+ },
+ "color-magenta": {
+ "$value": "#b92ed1",
+ "$type": "color"
+ },
+ "color-purple": {
+ "$value": "#6d3dc2",
+ "$type": "color"
+ },
+ "color-navy": {
+ "$value": "#4255bd",
+ "$type": "color"
+ },
+ "color-blue": {
+ "$value": "#0d8df2",
+ "$type": "color"
+ },
+ "color-cyan": {
+ "$value": "#03aefc",
+ "$type": "color"
+ },
+ "color-green": {
+ "$value": "#4db251",
+ "$type": "color"
+ },
+ "color-yellow": {
+ "$value": "#ffbf00",
+ "$type": "color"
+ },
+ "color-orange": {
+ "$value": "#ff9800",
+ "$type": "color"
+ },
+ "color-dahong": {
+ "$value": "#ff3d00",
+ "$type": "color"
+ },
+ "color-brown": {
+ "$value": "#a0705f",
+ "$type": "color"
+ },
+ "color-iron": {
+ "$value": "#7f7f7f",
+ "$type": "color"
+ },
+ "color-steel": {
+ "$value": "#688897",
+ "$type": "color"
+ },
+ "color-red-light": {
+ "$value": "#fee9e7",
+ "$type": "color"
+ },
+ "color-pink-light": {
+ "$value": "#fde8ef",
+ "$type": "color"
+ },
+ "color-magenta-light": {
+ "$value": "#f8ebfb",
+ "$type": "color"
+ },
+ "color-purple-light": {
+ "$value": "#f1ecf9",
+ "$type": "color"
+ },
+ "color-navy-light": {
+ "$value": "#edeef9",
+ "$type": "color"
+ },
+ "color-blue-light": {
+ "$value": "#e7f4fe",
+ "$type": "color"
+ },
+ "color-cyan-light": {
+ "$value": "#e6f7ff",
+ "$type": "color"
+ },
+ "color-green-light": {
+ "$value": "#eef8ee",
+ "$type": "color"
+ },
+ "color-yellow-light": {
+ "$value": "#fff9e6",
+ "$type": "color"
+ },
+ "color-orange-light": {
+ "$value": "#fff5e6",
+ "$type": "color"
+ },
+ "color-dahong-light": {
+ "$value": "#ffece6",
+ "$type": "color"
+ },
+ "color-brown-light": {
+ "$value": "#f6f1ef",
+ "$type": "color"
+ },
+ "color-iron-light": {
+ "$value": "#f3f3f3",
+ "$type": "color"
+ },
+ "color-steel-light": {
+ "$value": "#f0f4f5",
+ "$type": "color"
+ },
+ "color-red-medium": {
+ "$value": "#faa59e",
+ "$type": "color"
+ },
+ "color-pink-medium": {
+ "$value": "#f6a2bf",
+ "$type": "color"
+ },
+ "color-magenta-medium": {
+ "$value": "#e3abec",
+ "$type": "color"
+ },
+ "color-purple-medium": {
+ "$value": "#c5b1e7",
+ "$type": "color"
+ },
+ "color-navy-medium": {
+ "$value": "#b3bbe5",
+ "$type": "color"
+ },
+ "color-blue-medium": {
+ "$value": "#9ed1fa",
+ "$type": "color"
+ },
+ "color-cyan-medium": {
+ "$value": "#9adffe",
+ "$type": "color"
+ },
+ "color-green-medium": {
+ "$value": "#b8e0b9",
+ "$type": "color"
+ },
+ "color-yellow-medium": {
+ "$value": "#ffe599",
+ "$type": "color"
+ },
+ "color-orange-medium": {
+ "$value": "#ffd699",
+ "$type": "color"
+ },
+ "color-dahong-medium": {
+ "$value": "#ffb199",
+ "$type": "color"
+ },
+ "color-brown-medium": {
+ "$value": "#d9c6bf",
+ "$type": "color"
+ },
+ "color-iron-medium": {
+ "$value": "#cccccc",
+ "$type": "color"
+ },
+ "color-steel-medium": {
+ "$value": "#c3cfd5",
+ "$type": "color"
+ },
+ "checked-color-background": {
+ "$value": "#03aefc1a",
+ "$type": "color"
+ },
+ "headercolor": {
+ "$value": "linear-gradient(90deg, #193833 0%, #1e5149 100%)",
+ "$type": "color"
+ },
+ "line-style": {
+ "$value": "linear-gradient(135deg, #ffffff 0%, #0000001a 50%, #ffffff 100%)",
+ "$type": "color"
+ },
+ "box__drop-shadow": {
+ "$value": {
+ "color": "#00000029",
+ "type": "dropShadow",
+ "x": 0,
+ "y": 8,
+ "blur": 24,
+ "spread": 0
+ },
+ "$type": "boxShadow"
+ },
+ "fontSize": {
+ "0": {
+ "$value": 12,
+ "$type": "fontSizes"
+ },
+ "1": {
+ "$value": 14,
+ "$type": "fontSizes"
+ },
+ "2": {
+ "$value": 16,
+ "$type": "fontSizes"
+ },
+ "3": {
+ "$value": 20,
+ "$type": "fontSizes"
+ }
+ },
+ "
": {
+ "$value": {
+ "fontFamily": "{fontFamilies.pretendard}",
+ "fontWeight": "{fontWeights.pretendard-0}",
+ "lineHeight": "{lineHeights.0}",
+ "fontSize": "{fontSize.3}",
+ "letterSpacing": "{letterSpacing.0}",
+ "paragraphSpacing": "{paragraphSpacing.0}",
+ "paragraphIndent": "{paragraphIndent.0}",
+ "textCase": "{textCase.none}",
+ "textDecoration": "{textDecoration.none}"
+ },
+ "$type": "typography"
+ },
+ "": {
+ "$value": {
+ "fontFamily": "{fontFamilies.pretendard}",
+ "fontWeight": "{fontWeights.pretendard-1}",
+ "lineHeight": "{lineHeights.0}",
+ "fontSize": "{fontSize.2}",
+ "letterSpacing": "{letterSpacing.0}",
+ "paragraphSpacing": "{paragraphSpacing.0}",
+ "paragraphIndent": "{paragraphIndent.0}",
+ "textCase": "{textCase.none}",
+ "textDecoration": "{textDecoration.none}"
+ },
+ "$type": "typography"
+ },
+ "": {
+ "$value": {
+ "fontFamily": "{fontFamilies.pretendard}",
+ "fontWeight": "{fontWeights.pretendard-1}",
+ "lineHeight": "{lineHeights.0}",
+ "fontSize": "{fontSize.1}",
+ "letterSpacing": "{letterSpacing.0}",
+ "paragraphSpacing": "{paragraphSpacing.0}",
+ "paragraphIndent": "{paragraphIndent.0}",
+ "textCase": "{textCase.none}",
+ "textDecoration": "{textDecoration.none}"
+ },
+ "$type": "typography"
+ },
+ "": {
+ "$value": {
+ "fontFamily": "{fontFamilies.pretendard}",
+ "fontWeight": "{fontWeights.pretendard-2}",
+ "lineHeight": "{lineHeights.0}",
+ "fontSize": "{fontSize.1}",
+ "letterSpacing": "{letterSpacing.0}",
+ "paragraphSpacing": "{paragraphSpacing.0}",
+ "paragraphIndent": "{paragraphIndent.0}",
+ "textCase": "{textCase.none}",
+ "textDecoration": "{textDecoration.none}"
+ },
+ "$type": "typography"
+ },
+ "": {
+ "$value": {
+ "fontFamily": "{fontFamilies.pretendard}",
+ "fontWeight": "{fontWeights.pretendard-1}",
+ "lineHeight": "{lineHeights.0}",
+ "fontSize": "{fontSize.0}",
+ "letterSpacing": "{letterSpacing.0}",
+ "paragraphSpacing": "{paragraphSpacing.0}",
+ "paragraphIndent": "{paragraphIndent.0}",
+ "textCase": "{textCase.none}",
+ "textDecoration": "{textDecoration.none}"
+ },
+ "$type": "typography"
+ },
+ "": {
+ "$value": {
+ "fontFamily": "{fontFamilies.pretendard}",
+ "fontWeight": "{fontWeights.pretendard-2}",
+ "lineHeight": "{lineHeights.0}",
+ "fontSize": "{fontSize.0}",
+ "letterSpacing": "{letterSpacing.0}",
+ "paragraphSpacing": "{paragraphSpacing.0}",
+ "paragraphIndent": "{paragraphIndent.0}",
+ "textCase": "{textCase.none}",
+ "textDecoration": "{textDecoration.none}"
+ },
+ "$type": "typography"
+ },
+ "
": {
+ "$value": {
+ "fontFamily": "{fontFamilies.pretendard}",
+ "fontWeight": "{fontWeights.pretendard-2}",
+ "lineHeight": "{lineHeights.0}",
+ "fontSize": "{fontSize.0}",
+ "letterSpacing": "{letterSpacing.0}",
+ "paragraphSpacing": "{paragraphSpacing.0}",
+ "paragraphIndent": "{paragraphIndent.0}",
+ "textCase": "{textCase.none}",
+ "textDecoration": "{textDecoration.none}"
+ },
+ "$type": "typography"
+ },
+ "textCase": {
+ "none": {
+ "$value": "none",
+ "$type": "textCase"
+ }
+ },
+ "textDecoration": {
+ "none": {
+ "$value": "none",
+ "$type": "textDecoration"
+ }
+ },
+ "paragraphIndent": {
+ "0": {
+ "$value": "0px",
+ "$type": "dimension"
+ }
+ }
+ },
+ "light": {
+ "fg": {
+ "default": {
+ "$value": "{colors.black}",
+ "$type": "color"
+ },
+ "muted": {
+ "$value": "{colors.gray.700}",
+ "$type": "color"
+ },
+ "subtle": {
+ "$value": "{colors.gray.500}",
+ "$type": "color"
+ }
+ },
+ "bg": {
+ "default": {
+ "$value": "{colors.white}",
+ "$type": "color"
+ },
+ "muted": {
+ "$value": "{colors.gray.100}",
+ "$type": "color"
+ },
+ "subtle": {
+ "$value": "{colors.gray.200}",
+ "$type": "color"
+ }
+ },
+ "accent": {
+ "default": {
+ "$value": "{colors.indigo.400}",
+ "$type": "color"
+ },
+ "onAccent": {
+ "$value": "{colors.white}",
+ "$type": "color"
+ },
+ "bg": {
+ "$value": "{colors.indigo.200}",
+ "$type": "color"
+ }
+ },
+ "shadows": {
+ "default": {
+ "$value": "{colors.gray.900}",
+ "$type": "color"
+ }
+ }
+ },
+ "dark": {
+ "fg": {
+ "default": {
+ "$value": "{colors.white}",
+ "$type": "color"
+ },
+ "muted": {
+ "$value": "{colors.gray.300}",
+ "$type": "color"
+ },
+ "subtle": {
+ "$value": "{colors.gray.500}",
+ "$type": "color"
+ }
+ },
+ "bg": {
+ "default": {
+ "$value": "{colors.gray.900}",
+ "$type": "color"
+ },
+ "muted": {
+ "$value": "{colors.gray.700}",
+ "$type": "color"
+ },
+ "subtle": {
+ "$value": "{colors.gray.600}",
+ "$type": "color"
+ }
+ },
+ "accent": {
+ "default": {
+ "$value": "{colors.indigo.600}",
+ "$type": "color"
+ },
+ "onAccent": {
+ "$value": "{colors.white}",
+ "$type": "color"
+ },
+ "bg": {
+ "$value": "{colors.indigo.800}",
+ "$type": "color"
+ }
+ },
+ "shadows": {
+ "default": {
+ "$value": "rgba({colors.black}, 0.3)",
+ "$type": "color"
+ }
+ }
+ },
+ "theme": {
+ "button": {
+ "primary": {
+ "background": {
+ "$value": "{accent.default}",
+ "$type": "color"
+ },
+ "text": {
+ "$value": "{accent.onAccent}",
+ "$type": "color"
+ }
+ },
+ "borderRadius": {
+ "$value": "{borderRadius.lg}",
+ "$type": "borderRadius"
+ },
+ "borderWidth": {
+ "$value": "{dimension.sm}",
+ "$type": "borderWidth"
+ }
+ },
+ "card": {
+ "borderRadius": {
+ "$value": "{borderRadius.lg}",
+ "$type": "borderRadius"
+ },
+ "background": {
+ "$value": "{bg.default}",
+ "$type": "color"
+ },
+ "padding": {
+ "$value": "{dimension.md}",
+ "$type": "dimension"
+ }
+ },
+ "boxShadow": {
+ "default": {
+ "$value": [
+ {
+ "x": 5,
+ "y": 5,
+ "spread": 3,
+ "color": "rgba({shadows.default}, 0.15)",
+ "blur": 5,
+ "$type": "dropShadow"
+ },
+ {
+ "x": 4,
+ "y": 4,
+ "spread": 6,
+ "color": "#00000033",
+ "blur": 5,
+ "$type": "innerShadow"
+ }
+ ],
+ "$type": "boxShadow"
+ }
+ },
+ "typography": {
+ "H1": {
+ "Bold": {
+ "$value": {
+ "fontFamily": "{fontFamilies.heading}",
+ "fontWeight": "{fontWeights.headingBold}",
+ "lineHeight": "{lineHeights.heading}",
+ "fontSize": "{fontSizes.h1}",
+ "paragraphSpacing": "{paragraphSpacing.h1}",
+ "letterSpacing": "{letterSpacing.decreased}"
+ },
+ "$type": "typography"
+ },
+ "Regular": {
+ "$value": {
+ "fontFamily": "{fontFamilies.heading}",
+ "fontWeight": "{fontWeights.headingRegular}",
+ "lineHeight": "{lineHeights.heading}",
+ "fontSize": "{fontSizes.h1}",
+ "paragraphSpacing": "{paragraphSpacing.h1}",
+ "letterSpacing": "{letterSpacing.decreased}"
+ },
+ "$type": "typography"
+ }
+ },
+ "H2": {
+ "Bold": {
+ "$value": {
+ "fontFamily": "{fontFamilies.heading}",
+ "fontWeight": "{fontWeights.headingBold}",
+ "lineHeight": "{lineHeights.heading}",
+ "fontSize": "{fontSizes.h2}",
+ "paragraphSpacing": "{paragraphSpacing.h2}",
+ "letterSpacing": "{letterSpacing.decreased}"
+ },
+ "$type": "typography"
+ },
+ "Regular": {
+ "$value": {
+ "fontFamily": "{fontFamilies.heading}",
+ "fontWeight": "{fontWeights.headingRegular}",
+ "lineHeight": "{lineHeights.heading}",
+ "fontSize": "{fontSizes.h2}",
+ "paragraphSpacing": "{paragraphSpacing.h2}",
+ "letterSpacing": "{letterSpacing.decreased}"
+ },
+ "$type": "typography"
+ }
+ },
+ "Body": {
+ "$value": {
+ "fontFamily": "{fontFamilies.body}",
+ "fontWeight": "{fontWeights.bodyRegular}",
+ "lineHeight": "{lineHeights.heading}",
+ "fontSize": "{fontSizes.body}",
+ "paragraphSpacing": "{paragraphSpacing.h2}"
+ },
+ "$type": "typography"
+ }
+ }
+ },
+ "$themes": [],
+ "$metadata": {
+ "tokenSetOrder": [
+ "core",
+ "light",
+ "dark",
+ "theme"
+ ]
+ }
}
\ No newline at end of file
diff --git a/verify_swvw.py b/verify_swvw.py
new file mode 100644
index 0000000..9746506
--- /dev/null
+++ b/verify_swvw.py
@@ -0,0 +1,28 @@
+import pymysql
+from analysis_service import AnalysisService
+
+def verify_analysis():
+ conn = pymysql.connect(
+ host='localhost',
+ user='root',
+ password='45278434',
+ database='pm_proto_test',
+ charset='utf8mb4',
+ cursorclass=pymysql.cursors.DictCursor
+ )
+ try:
+ with conn.cursor() as cursor:
+ results = AnalysisService.get_p_zsr_analysis_logic(cursor)
+ print(f"Total Projects Analyzed: {len(results)}")
+ print("\n[Sample Project Analysis Result]")
+ for res in results[:5]:
+ print(f"Project: {res['project_nm']}")
+ print(f" - Log Quality Score (Semantic): {res['log_quality']}")
+ print(f" - AVI Score (p_war): {res['p_war']}")
+ print(f" - OCI Score: {res['oci_score']}")
+ print("-" * 30)
+ finally:
+ conn.close()
+
+if __name__ == "__main__":
+ verify_analysis()