도커환경으로 변경

This commit is contained in:
Taehoon
2026-06-19 16:19:11 +09:00
parent b864d615ea
commit 5c416d5f43
59 changed files with 9509 additions and 5798 deletions

1616
.agents/git-mcp/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,16 @@
{
"name": "git-mcp",
"version": "1.0.0",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "",
"license": "ISC",
"description": "",
"dependencies": {
"@andrebuzeli/git-mcp": "^15.12.4",
"zod": "^4.4.3"
}
}

14
.agents/mcp_config.json Normal file
View File

@@ -0,0 +1,14 @@
{
"mcpServers": {
"gitea": {
"command": "node",
"args": [
"/mnt/d/이태훈/19ProjectMaster/AICodeTest/.agents/git-mcp/node_modules/@andrebuzeli/git-mcp/src/index.js"
],
"env": {
"GITEA_URL": "https://gitea.hmac.kr",
"GITEA_TOKEN": "34a35034b9335b5129c8bfcd27e841d83f0aeaed"
}
}
}
}

12
.env
View File

@@ -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=db
DB_USER=root
DB_PASSWORD=45278434
DB_NAME=PM_proto

BIN
.gitignore vendored

Binary file not shown.

View File

@@ -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가 낮은 프로젝트**: "활동은 있으나 불규칙합니다. 관리의 지속성을 확보하여 운영 리듬을 찾으십시오."

40
Dockerfile Normal file
View File

@@ -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"]

68
PLAN.md
View File

@@ -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**: 다양한 해상도에 대응하는 반응형 레이아웃 구성

179
README.md
View File

@@ -1,86 +1,93 @@
# 🚀 서버 정책 (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)
**서버 구동 시 반드시 아래 Docker 기반 명령어를 사용하여 컨테이너를 가동한다:**
* **우분투(WSL2) 환경에서 구동 시:**
```bash
docker compose up --build -d
```
* **윈도우 호스트(PowerShell/cmd) 환경에서 원격 구동 시:**
```powershell
wsl -u root -d Ubuntu bash -c "cd '/mnt/d/이태훈/19ProjectMaster/AICodeTest' && docker compose up --build -d"
```
- **접속 포트**: `8000` (포트포워딩 설정 완료 시 호스트 IP `172.16.8.151:8000`으로 외부 접근 가능)
- **실시간 반영**: 소스 코드 수정 시 볼륨 바인딩을 통해 컨테이너 서버에 실시간으로 즉시 동기화됨
---
# 🤖 메일시스템 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` 그라데이션 적용 가능

Binary file not shown.

Binary file not shown.

View File

@@ -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

View File

@@ -1,167 +1,175 @@
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. 시스템 설정
# OS에 따른 경로 설정 (Docker/Linux 환경 대응)
if os.name == 'posix': # Linux (Docker)
TESSERACT_EXE = 'tesseract'
TESSDATA_DIR = '/usr/share/tesseract-ocr/4.00/tessdata' # 일반적인 리눅스 경로
# 리눅스에서는 보통 패스에 있으므로 별도 설정 불필요할 수 있음
pytesseract.pytesseract.tesseract_cmd = TESSERACT_EXE
POPPLER_BIN = None
OCR_AVAILABLE = True # Dockerfile에서 설치함
else: # Windows
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}

48
analyze_logs_pattern.py Normal file
View File

@@ -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()

29
check_tables.py Normal file
View File

@@ -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()

29
clear_test_db.py Normal file
View File

@@ -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()

45
clone_db.py Normal file
View File

@@ -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()

View File

@@ -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&params[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&params[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()

273
crawler_service_test.py Normal file
View File

@@ -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&params[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()

40
docker-compose.yml Normal file
View File

@@ -0,0 +1,40 @@
services:
db:
image: mariadb:10.6
container_name: aicode-db
restart: always
environment:
MYSQL_ROOT_PASSWORD: "45278434"
ports:
- "3307:3306"
volumes:
- db-data:/var/lib/mysql
web:
# 현재 폴더의 Dockerfile을 사용하여 빌드
build: .
# 컨테이너 이름 설정
container_name: aicode-server
# 포트 포워딩 (호스트 8000 -> 컨테이너 8000)
ports:
- "8000:8000"
# 소스 코드 수정 시 실시간 반영 (볼륨 마운트)
volumes:
- .:/app
# 환경 변수 설정
environment:
- PYTHONUNBUFFERED=1
- TESSDATA_PREFIX=/usr/share/tesseract-ocr/5/tessdata
# 호스트 PC의 IP로의 라우팅을 위한 게이트웨이 설정
extra_hosts:
- "host.docker.internal:host-gateway"
# DB 구동 완료 대기
depends_on:
- db
# 컨테이너 종료 시 자동 재시작
restart: always
volumes:
db-data:

View File

@@ -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}

View File

@@ -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 = `
<div class="table-scroll-wrapper">
<table class="data-table p-war-table">
<thead>
<tr>
<th style="width: 250px;">프로젝트명</th>
<th>파일 수</th>
<th>정체 일수</th>
<th>상태 판정</th>
<th style="text-align:right;">가치 기여 (VCI) <button class="btn-help" onclick="event.stopPropagation(); openAnalysisModal('vci')">?</button></th>
<th>운영 활력 (AVI) <button class="btn-help" onclick="event.stopPropagation(); openAnalysisModal('avi')">?</button></th>
<th style="text-align:center;">업무 집중도 <button class="btn-help" onclick="event.stopPropagation(); openAnalysisModal('focus')">?</button></th>
<th>운영 일관성 (OCI) <button class="btn-help" onclick="event.stopPropagation(); openAnalysisModal('oci')">?</button></th>
</tr>
</thead>
<tbody>
${sortedData.map((p, idx) => {
const status = getStatusInfo(p.p_war, p.is_auto_delete);
const avi = p.p_war;
const vci = p.risk_count;
const oci = p.oci_score || 0;
const rowId = `project-${idx}`;
const grade = getVciGrade(vci);
let rhythmLabel = oci >= 80 ? "정기적" : oci >= 50 ? "안정적" : oci >= 20 ? "간헐적" : "불규칙";
let rhythmColor = oci >= 80 ? "#059669" : oci >= 50 ? "#1e5149" : oci >= 20 ? "#f59e0b" : "#dc2626";
// 존재 신뢰도 패널티 (ECV) 상세 설명 복구
let ecvText = "100% (데이터 실체 검증)";
let ecvClass = "highlight-val";
let ecvDesc = `현재 ${p.file_count}개의 유효 성과물이 확인됩니다. 시스템적으로 실체가 완벽히 존재하는 상태입니다.`;
if (p.file_count === 0) {
ecvText = "5% (유령 프로젝트 판명)";
ecvClass = "highlight-penalty";
ecvDesc = "데이터가 전무하여 프로젝트의 디지털 실체가 없습니다. 모든 분석에서 최하위 패널티가 적용됩니다.";
} else if (p.file_count < 10) {
ecvText = "40% (형식적 껍데기 판명)";
ecvClass = "highlight-penalty";
ecvDesc = "최소 수준의 문서만 존재하며, 실질적인 운영 가치를 인정하기 어려운 소규모 상태입니다.";
}
// 활동 품질 텍스트 복구
const qualityLabel = p.log_quality >= 1.0 ? '성과물 중심의 <b>실무 활동</b>' : p.log_quality >= 0.7 ? '구조 관리를 위한 <b>시스템 활동</b>' : '단순 행정 기반의 <b>형식 활동</b>';
const qualityDetail = p.log_quality >= 1.0 ? '최근 로그에서 파일 업로드/수정 등 가치 증분 활동이 명확히 포착되었습니다.' : p.log_quality >= 0.7 ? '폴더 생성/이동 등 구조적 관리는 이뤄지고 있으나, 직접적 결과물 생산은 부족합니다.' : '메일 확인, 권한 변경 등 시스템 유지성 활동 위주로 파악되어 품질 가중치가 낮게 적용되었습니다.';
return `
<tr class="project-row ${status.key === 'danger' ? 'row-danger' : status.key === 'warning' ? 'row-warning' : ''}" onclick="toggleProjectDetail('${rowId}')">
<td class="font-bold">${p.project_nm}</td>
<td>${p.file_count.toLocaleString()}</td>
<td>${p.days_stagnant}</td>
<td><span class="${status.class}">${status.label}</span></td>
<td style="text-align:right; font-weight:800; color:${vci >= 0 ? '#059669' : '#dc2626'};">
${vci > 0 ? '+' : ''}${vci.toFixed(1)}
</td>
<td class="p-war-value ${avi >= 70 ? 'text-plus' : 'text-minus'}">${avi.toFixed(1)}%</td>
<td style="text-align:center;">
<div style="display:flex; flex-direction:column; align-items:center; gap:4px;">
<span style="font-weight:800; font-size:12px; color:${p.work_effort >= 70 ? '#059669' : p.work_effort <= 30 ? '#dc2626' : '#6366f1'};">${p.work_effort}%</span>
<div style="width:40px; height:4px; background:#f1f5f9; border-radius:2px; overflow:hidden;">
<div style="width:${p.work_effort}%; height:100%; background:${p.work_effort >= 70 ? '#059669' : p.work_effort <= 30 ? '#dc2626' : '#6366f1'};"></div>
</div>
</div>
</td>
<td style="text-align:center;">
<div style="display:flex; align-items:center; justify-content:center; gap:8px;">
<span style="font-weight:800; font-size:13px; color:${rhythmColor};">${oci}%</span>
<span style="font-size:10px; padding:2px 6px; border-radius:10px; background:${rhythmColor}15; color:${rhythmColor}; border:1px solid ${rhythmColor}30;">${rhythmLabel}</span>
</div>
</td>
</tr>
<tr id="detail-${rowId}" class="detail-row">
<td colspan="8">
<div class="detail-container">
<div class="formula-explanation-card">
<div class="formula-header"> AI 위험 적응형 모델(AAS) 기반 인과관계 분석</div>
<div style="display: flex; gap: 20px; margin-bottom: 20px;">
<div class="work-effort-section" style="flex: 1; margin-bottom: 0;">
<div class="work-effort-header">
<span style="font-size: 13px; font-weight: 800; color: #1e5149;">📊 실질 업무 집중도 (Job Focus)</span>
<span style="font-size: 14px; font-weight: 800; color: ${p.work_effort >= 70 ? '#059669' : p.work_effort <= 30 ? '#dc2626' : '#6366f1'};">${p.work_effort}%</span>
</div>
<div class="work-effort-bar-bg"><div style="width: ${p.work_effort}%; height: 100%; background: ${p.work_effort >= 70 ? '#059669' : p.work_effort <= 30 ? '#dc2626' : '#6366f1'};"></div></div>
<div style="font-size: 11px; color: #64748b; line-height: 1.5;">최근 30 수집 이력 단순 로그 갱신이 아닌 <b>실제 성과물의 변동</b> . '' .</div>
</div>
<div style="flex: 1; background: #f8fafc; border-radius: 8px; padding: 16px; border: 1px solid #e2e8f0; display: flex; align-items: center; gap: 15px;">
<div style="text-align: center;">
<div style="font-size: 10px; color: #64748b; font-weight: 700; margin-bottom: 2px;">VCI GRADE</div>
<div class="grade-badge ${grade.class}" style="padding: 4px 12px; border-radius: 6px; font-weight: 900; font-size: 14px; display: inline-block;">${grade.label}</div>
</div>
<div style="font-size: 12px; color: #475569; line-height: 1.4; font-weight: 600;">${grade.desc}</div>
</div>
</div>
<div class="formula-steps-grid">
<div class="formula-step">
<div class="step-num">1</div>
<div class="step-content">
<div class="step-title">동적 위험 계수(λ) 산출</div>
<div style="font-size:11px; color:#64748b; margin-bottom:5px;">프로젝트 규모가 클수록 정보 망실 시의 충격을 반영하여 데이터의 하락 속도가 가속됩니다. 현재 <b>λ=${p.ai_lambda.toFixed(4)}</b> .</div>
<div class="math-logic">Dynamic λ = <span class="highlight-var">${p.ai_lambda.toFixed(4)}</span></div>
</div>
</div>
<div class="formula-step">
<div class="step-num">4</div>
<div class="step-content">
<div class="step-title">활동 품질 검증 (Quality)</div>
<div class="step-desc" style="font-size:11px; margin-bottom:5px;">최근 로그 분석 결과 <b>${qualityLabel}</b> . ${qualityDetail}</div>
<div class="math-logic">Quality Factor = <span class="${p.log_quality < 0.7 ? 'highlight-penalty' : 'highlight-val'}">${(p.log_quality * 100).toFixed(0)}%</span></div>
</div>
</div>
<div class="formula-step">
<div class="step-num">2</div>
<div class="step-content">
<div class="step-title">방치 시간 감쇄 적용</div>
<div style="font-size:11px; color:#64748b; margin-bottom:5px;">마지막 유효 활동 이후 <b>${p.days_stagnant}</b> .</div>
<div class="math-logic">Decay Result = <span class="highlight-val">${((avi / (p.file_count === 0 ? 0.05 : p.file_count < 10 ? 0.4 : 1) / p.log_quality) || 0).toFixed(1)}%</span></div>
</div>
</div>
<div class="formula-step">
<div class="step-num">3</div>
<div class="step-content">
<div class="step-title">존재 진정성 (ECV)</div>
<div style="font-size:11px; color:#64748b; margin-bottom:5px;">${ecvDesc} 파일 자체가 분석의 데이터 진정성을 보정하는 핵심 팩터로 작용합니다.</div>
<div class="math-logic">Entity Factor = <span class="${ecvClass}">${ecvText}</span></div>
</div>
</div>
</div>
<div style="margin-top: 20px; padding-top: 15px; border-top: 2px solid #1e5149; text-align: right; display: flex; justify-content: space-between; align-items: center;">
<div style="text-align: left; max-width: 70%;">
<div style="font-size: 13px; font-weight: 800; color: ${vci >= 0 ? '#059669' : '#dc2626'}; margin-bottom: 4px;">
가치 기여도 (VCI) 진단: ${vci >= 0 ? '+' : ''}${vci.toFixed(2)}
</div>
<div style="font-size: 11px; color: #64748b; line-height: 1.5;">
현재 프로젝트는 운영 표준(AVI 70%) 대비 <b>${Math.abs(avi - 70).toFixed(1)}%p ${avi >= 70 ? '상회' : '하회'}</b> ,
<b>${p.file_count}</b>의 자산 규모에 따른 <b>${((p.file_count / 200) + 0.5).toFixed(2)}</b> .
이는 시스템 전체 관점에서 <b>${vci >= 0 ? '순자산 가치를 증대' : '잠재적 기회비용을 손실'}</b> .
</div>
</div>
<div>
<span style="font-size: 13px; color: #64748b; font-weight: 700;">최종 AVI: </span>
<span style="color: #1e5149; font-size: 22px; font-weight: 900;">${avi.toFixed(1)}%</span>
</div>
</div>
</div>
</div>
</td>
</tr>`;
}).join('')}
</tbody>
</table>
</div>`;
}
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 = `
<div class="table-scroll-wrapper" style="max-height: 400px;">
<table class="data-table">
<thead><tr><th>프로젝트명</th><th>관리자</th><th>정체일</th><th>AVI</th></tr></thead>
<tbody>${projects.map(p => `<tr><td class="font-bold">${p.project_nm}</td><td>${p.master || '-'}</td><td>${p.days_stagnant}일</td><td style="font-weight:700; color:#1e5149;">${p.p_war.toFixed(1)}%</td></tr>`).join('')}</tbody>
</table>
</div>
<div class="modal-footer"><button class="btn btn-primary" onclick="closeAnalysisModal()">닫기</button></div>`;
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 = `
<div class="formula-box" style="margin-bottom:15px;">AVI = exp(-λ × days) × Quality × 100</div>
<p style="font-size:13px; color:#64748b; margin-bottom:15px;">자산의 가동 상태와 생존율을 나타내는 지표입니다.</p>
<table class="data-table" style="font-size:12px;">
<thead><tr style="background:#f8fafc;"><th>지수 (AVI)</th><th>등급</th><th>운영 상태</th></tr></thead>
<tbody>
<tr><td>90%↑</td><td style="font-weight:900; color:#059669;">Live</td><td>실시간 성과물이 도출되는 최상급 가동</td></tr>
<tr><td>70~90%</td><td style="font-weight:900; color:#1e5149;">Stable</td><td>주기적 업데이트가 이뤄지는 표준 안정</td></tr>
<tr><td>30~70%</td><td style="font-weight:900; color:#f59e0b;">Idle</td><td>관리가 필요한 유휴/정체 상태</td></tr>
<tr><td>10~30%</td><td style="font-weight:900; color:#dc2626;">Risk</td><td>자산 가치 소멸 직전의 위험 상태</td></tr>
<tr><td>10%↓</td><td style="font-weight:900; color:#64748b;">Frozen</td><td>운영이 중단된 사망/방치 상태</td></tr>
</tbody>
</table>
<div class="modal-footer"><button class="btn btn-primary" onclick="closeAnalysisModal()">닫기</button></div>`;
} else if (type === 'vci') {
title.innerText = '자산 가치 기여도 (VCI) 등급 가이드';
body.innerHTML = `
<div class="formula-box" style="margin-bottom:15px;">VCI = (AVI - 70.0) × (Files / 200 + 0.5)</div>
<p style="font-size:13px; color:#64748b; margin-bottom:15px;">운영 표준(AVI 70%) 대비 자산 가치 기여도에 따른 프로젝트 위상 분류입니다.</p>
<table class="data-table" style="font-size:12px;">
<thead><tr style="background:#f8fafc;"><th>점수 (VCI)</th><th>등급</th><th>운영 의미</th></tr></thead>
<tbody>
<tr><td>+10.0↑</td><td style="font-weight:900; color:#6366f1;">Masterpiece</td><td>시스템 가치를 견인하는 최우량 핵심 자산</td></tr>
<tr><td>+2.0 ~ +10.0</td><td style="font-weight:900; color:#059669;">Blue Chip</td><td>꾸준한 활력으로 가치를 창출하는 우량 자산</td></tr>
<tr><td>-2.0 ~ +2.0</td><td style="font-weight:900; color:#475569;">Steady</td><td>표준 수준의 운영을 유지 중인 안정 자산</td></tr>
<tr><td>-10.0 ~ -2.0</td><td style="font-weight:900; color:#f59e0b;">Underperform</td><td>규모 대비 활력 부족으로 가치 하락 중인 자산</td></tr>
<tr><td>-10.0↓</td><td style="font-weight:900; color:#dc2626;">Liability</td><td>가치를 훼손 중인 고위험 방치 자산</td></tr>
</tbody>
</table>
<div class="modal-footer"><button class="btn btn-primary" onclick="closeAnalysisModal()">닫기</button></div>`;
} else if (type === 'oci') {
title.innerText = '운영 일관성 지수 (OCI) 분석 가이드';
body.innerHTML = `
<div style="background:#f0fdf4; padding:15px; border-radius:8px; margin-bottom:15px;">
<strong style="color:#166534; display:block; margin-bottom:5px;">"얼마나 꾸준하게 관리되고 있는가?"</strong>
<p style="font-size:12.5px; color:#166534; margin:0;">미래 예측이 아닌, 최근 30일간의 <b>활동 리듬</b>과 <b>관리의 규칙성</b>을 분석하여 성실도를 점수화합니다.</p>
</div>
<table class="data-table" style="font-size:12px;">
<thead><tr style="background:#f8fafc;"><th>분석 결과</th><th>일관성 등급</th><th>관리 신뢰도</th></tr></thead>
<tbody>
<tr><td style="color:#059669;">80%↑</td><td style="font-weight:900; color:#059669;">매우 우수</td><td>주 단위의 정기적 관리가 완벽히 이뤄짐</td></tr>
<tr><td style="color:#1e5149;">50~80%</td><td style="font-weight:900; color:#1e5149;">양호</td><td>간헐적 정체는 있으나 꾸준히 관리됨</td></tr>
<tr><td style="color:#f59e0b;">20~50%</td><td style="font-weight:900; color:#f59e0b;">주의</td><td>돌발적 활동 위주, 관리의 리듬이 깨짐</td></tr>
<tr><td style="color:#dc2626;">20%↓</td><td style="font-weight:900; color:#dc2626;">매우 불량</td><td>장기 정체 중이거나 관리 의지 확인 불가</td></tr>
</tbody>
</table>
<div class="modal-footer"><button class="btn btn-primary" onclick="closeAnalysisModal()">닫기</button></div>`;
} else {
title.innerText = '업무 집중도 (Job Focus) 등급 가이드';
body.innerHTML = `
<p style="font-size:13px; color:#64748b; margin-bottom:15px;">최근 수집 로그 중 단순 행정 로그를 제외하고 실질적인 성과물(파일) 변동이 포착된 비율입니다.</p>
<table class="data-table" style="font-size:12px;">
<thead><tr style="background:#f8fafc;"><th>비율 (%)</th><th>등급</th><th>활동 성격</th></tr></thead>
<tbody>
<tr><td>80%↑</td><td style="font-weight:900; color:#6366f1;">Intensive</td><td>성과물 위주의 고밀도 집중 작업</td></tr>
<tr><td>50~80%</td><td style="font-weight:900; color:#059669;">Active</td><td>성과와 관리가 균형 잡힌 원활한 실행</td></tr>
<tr><td>20~50%</td><td style="font-weight:900; color:#f59e0b;">Maintenance</td><td>설정/행정 등 단순 관리 중심의 작업</td></tr>
<tr><td>20%↓</td><td style="font-weight:900; color:#dc2626;">Surface</td><td>실체적 변화가 적은 형식적 로그 중심</td></tr>
</tbody>
</table>
<div class="modal-footer"><button class="btn btn-primary" onclick="closeAnalysisModal()">닫기</button></div>`;
}
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 = `
<div class="table-scroll-wrapper">
<table class="data-table p-war-table">
<thead>
<tr>
<th style="width: 250px;">프로젝트명</th>
<th>파일 수</th>
<th>정체 일수</th>
<th>상태 판정</th>
<th style="text-align:right;">가치 기여 (VCI) <button class="btn-help" onclick="event.stopPropagation(); openAnalysisModal('vci')">?</button></th>
<th>운영 활력 (AVI) <button class="btn-help" onclick="event.stopPropagation(); openAnalysisModal('avi')">?</button></th>
<th style="text-align:center;">업무 집중도 <button class="btn-help" onclick="event.stopPropagation(); openAnalysisModal('focus')">?</button></th>
<th>운영 일관성 (OCI) <button class="btn-help" onclick="event.stopPropagation(); openAnalysisModal('oci')">?</button></th>
</tr>
</thead>
<tbody>
${sortedData.map((p, idx) => {
const status = getStatusInfo(p.p_war, p.is_auto_delete);
const avi = p.p_war;
const vci = p.risk_count;
const oci = p.oci_score || 0;
const rowId = `project-${idx}`;
const grade = getVciGrade(vci);
let rhythmLabel = oci >= 80 ? "정기적" : oci >= 50 ? "안정적" : oci >= 20 ? "간헐적" : "불규칙";
let rhythmColor = oci >= 80 ? "#059669" : oci >= 50 ? "#1e5149" : oci >= 20 ? "#f59e0b" : "#dc2626";
// 존재 신뢰도 패널티 (ECV) 상세 설명 복구
let ecvText = "100% (데이터 실체 검증)";
let ecvClass = "highlight-val";
let ecvDesc = `현재 ${p.file_count}개의 유효 성과물이 확인됩니다. 시스템적으로 실체가 완벽히 존재하는 상태입니다.`;
if (p.file_count === 0) {
ecvText = "5% (유령 프로젝트 판명)";
ecvClass = "highlight-penalty";
ecvDesc = "데이터가 전무하여 프로젝트의 디지털 실체가 없습니다. 모든 분석에서 최하위 패널티가 적용됩니다.";
} else if (p.file_count < 10) {
ecvText = "40% (형식적 껍데기 판명)";
ecvClass = "highlight-penalty";
ecvDesc = "최소 수준의 문서만 존재하며, 실질적인 운영 가치를 인정하기 어려운 소규모 상태입니다.";
}
// 활동 품질 텍스트 복구
const qualityLabel = p.log_quality >= 1.0 ? '성과물 중심의 <b>실무 활동</b>' : p.log_quality >= 0.7 ? '구조 관리를 위한 <b>시스템 활동</b>' : '단순 행정 기반의 <b>형식 활동</b>';
const qualityDetail = p.log_quality >= 1.0 ? '최근 로그에서 파일 업로드/수정 등 가치 증분 활동이 명확히 포착되었습니다.' : p.log_quality >= 0.7 ? '폴더 생성/이동 등 구조적 관리는 이뤄지고 있으나, 직접적 결과물 생산은 부족합니다.' : '메일 확인, 권한 변경 등 시스템 유지성 활동 위주로 파악되어 품질 가중치가 낮게 적용되었습니다.';
return `
<tr class="project-row ${status.key === 'danger' ? 'row-danger' : status.key === 'warning' ? 'row-warning' : ''}" onclick="toggleProjectDetail('${rowId}')">
<td class="font-bold">${p.project_nm}</td>
<td>${p.file_count.toLocaleString()}</td>
<td>${p.days_stagnant}</td>
<td><span class="${status.class}">${status.label}</span></td>
<td style="text-align:right; font-weight:800; color:${vci >= 0 ? '#059669' : '#dc2626'};">
${vci > 0 ? '+' : ''}${vci.toFixed(1)}
</td>
<td class="p-war-value ${avi >= 70 ? 'text-plus' : 'text-minus'}">${avi.toFixed(1)}%</td>
<td style="text-align:center;">
<div style="display:flex; flex-direction:column; align-items:center; gap:4px;">
<span style="font-weight:800; font-size:12px; color:${p.work_effort >= 70 ? '#059669' : p.work_effort <= 30 ? '#dc2626' : '#6366f1'};">${p.work_effort}%</span>
<div style="width:40px; height:4px; background:#f1f5f9; border-radius:2px; overflow:hidden;">
<div style="width:${p.work_effort}%; height:100%; background:${p.work_effort >= 70 ? '#059669' : p.work_effort <= 30 ? '#dc2626' : '#6366f1'};"></div>
</div>
</div>
</td>
<td style="text-align:center;">
<div style="display:flex; align-items:center; justify-content:center; gap:8px;">
<span style="font-weight:800; font-size:13px; color:${rhythmColor};">${oci}%</span>
<span style="font-size:10px; padding:2px 6px; border-radius:10px; background:${rhythmColor}15; color:${rhythmColor}; border:1px solid ${rhythmColor}30;">${rhythmLabel}</span>
</div>
</td>
</tr>
<tr id="detail-${rowId}" class="detail-row">
<td colspan="8">
<div class="detail-container">
<div class="formula-explanation-card">
<div class="formula-header"> AI 위험 적응형 모델(AAS) 기반 인과관계 분석</div>
<div style="display: flex; gap: 20px; margin-bottom: 20px;">
<div class="work-effort-section" style="flex: 1; margin-bottom: 0;">
<div class="work-effort-header">
<span style="font-size: 13px; font-weight: 800; color: #1e5149;">📊 실질 업무 집중도 (Job Focus)</span>
<span style="font-size: 14px; font-weight: 800; color: ${p.work_effort >= 70 ? '#059669' : p.work_effort <= 30 ? '#dc2626' : '#6366f1'};">${p.work_effort}%</span>
</div>
<div class="work-effort-bar-bg"><div style="width: ${p.work_effort}%; height: 100%; background: ${p.work_effort >= 70 ? '#059669' : p.work_effort <= 30 ? '#dc2626' : '#6366f1'};"></div></div>
<div style="font-size: 11px; color: #64748b; line-height: 1.5;">최근 30 수집 이력 단순 로그 갱신이 아닌 <b>실제 성과물의 변동</b> . '' .</div>
</div>
<div style="flex: 1; background: #f8fafc; border-radius: 8px; padding: 16px; border: 1px solid #e2e8f0; display: flex; align-items: center; gap: 15px;">
<div style="text-align: center;">
<div style="font-size: 10px; color: #64748b; font-weight: 700; margin-bottom: 2px;">VCI GRADE</div>
<div class="grade-badge ${grade.class}" style="padding: 4px 12px; border-radius: 6px; font-weight: 900; font-size: 14px; display: inline-block;">${grade.label}</div>
</div>
<div style="font-size: 12px; color: #475569; line-height: 1.4; font-weight: 600;">${grade.desc}</div>
</div>
</div>
<div class="formula-steps-grid">
<div class="formula-step">
<div class="step-num">1</div>
<div class="step-content">
<div class="step-title">동적 위험 계수(λ) 산출</div>
<div style="font-size:11px; color:#64748b; margin-bottom:5px;">프로젝트 규모가 클수록 정보 망실 시의 충격을 반영하여 데이터의 하락 속도가 가속됩니다. 현재 <b>λ=${p.ai_lambda.toFixed(4)}</b> .</div>
<div class="math-logic">Dynamic λ = <span class="highlight-var">${p.ai_lambda.toFixed(4)}</span></div>
</div>
</div>
<div class="formula-step">
<div class="step-num">4</div>
<div class="step-content">
<div class="step-title">활동 품질 검증 (Quality)</div>
<div class="step-desc" style="font-size:11px; margin-bottom:5px;">최근 로그 분석 결과 <b>${qualityLabel}</b> . ${qualityDetail}</div>
<div class="math-logic">Quality Factor = <span class="${p.log_quality < 0.7 ? 'highlight-penalty' : 'highlight-val'}">${(p.log_quality * 100).toFixed(0)}%</span></div>
</div>
</div>
<div class="formula-step">
<div class="step-num">2</div>
<div class="step-content">
<div class="step-title">방치 시간 감쇄 적용</div>
<div style="font-size:11px; color:#64748b; margin-bottom:5px;">마지막 유효 활동 이후 <b>${p.days_stagnant}</b> .</div>
<div class="math-logic">Decay Result = <span class="highlight-val">${((avi / (p.file_count === 0 ? 0.05 : p.file_count < 10 ? 0.4 : 1) / p.log_quality) || 0).toFixed(1)}%</span></div>
</div>
</div>
<div class="formula-step">
<div class="step-num">3</div>
<div class="step-content">
<div class="step-title">존재 진정성 (ECV)</div>
<div style="font-size:11px; color:#64748b; margin-bottom:5px;">${ecvDesc} 파일 자체가 분석의 데이터 진정성을 보정하는 핵심 팩터로 작용합니다.</div>
<div class="math-logic">Entity Factor = <span class="${ecvClass}">${ecvText}</span></div>
</div>
</div>
</div>
<div style="margin-top: 20px; padding-top: 15px; border-top: 2px solid #1e5149; text-align: right; display: flex; justify-content: space-between; align-items: center;">
<div style="text-align: left; max-width: 70%;">
<div style="display: flex; align-items: center; gap: 8px; margin-bottom: 4px;">
<div style="font-size: 13px; font-weight: 800; color: ${vci >= 0 ? '#059669' : '#dc2626'};">
가치 기여도 (VCI) 진단: ${vci >= 0 ? '+' : ''}${vci.toFixed(2)}
</div>
<div style="font-size: 10px; color: #94a3b8; background: #f8fafc; padding: 2px 6px; border-radius: 4px; border: 1px solid #e2e8f0;">
조직 평균 자산: ${p.avg_info.avg_files}
</div>
</div>
<div style="font-size: 11px; color: #64748b; line-height: 1.5;">
현재 프로젝트는 <b>포트폴리오 평균 관리 수준</b> 대비 <b>${Math.abs(vci / Math.max(0.2, p.file_count / p.avg_info.avg_files)).toFixed(1)}%p ${vci >= 0 ? '상회' : '하회'}</b> ,
<b>${p.file_count}</b>의 자산 규모에 따른 <b>${Math.max(0.2, p.file_count / p.avg_info.avg_files).toFixed(2)}</b> .
이는 시스템 전체 관점에서 <b>${vci >= 0 ? '순자산 가치를 증대' : '잠재적 기회비용을 손실'}</b> .
</div>
</div>
<div>
<span style="font-size: 13px; color: #64748b; font-weight: 700;">최종 AVI: </span>
<span style="color: #1e5149; font-size: 22px; font-weight: 900;">${avi.toFixed(1)}%</span>
</div>
</div>
</div>
</div>
</td>
</tr>`;
}).join('')}
</tbody>
</table>
</div>`;
}
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 = `
<div class="table-scroll-wrapper" style="max-height: 400px;">
<table class="data-table">
<thead><tr><th>프로젝트명</th><th>관리자</th><th>정체일</th><th>AVI</th></tr></thead>
<tbody>${projects.map(p => `<tr><td class="font-bold">${p.project_nm}</td><td>${p.master || '-'}</td><td>${p.days_stagnant}일</td><td style="font-weight:700; color:#1e5149;">${p.p_war.toFixed(1)}%</td></tr>`).join('')}</tbody>
</table>
</div>
<div class="modal-footer"><button class="btn btn-primary" onclick="closeAnalysisModal()">닫기</button></div>`;
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 = `
<div class="formula-box" style="margin-bottom:15px; background:#f1f5f9; padding:12px; border-radius:8px; font-family:'Consolas', monospace; font-weight:700; text-align:center;">
AVI = exp(-λ × Stagnant Days) × Quality × 100
</div>
<div style="margin-bottom:15px; font-size:13px; line-height:1.6; color:#334155;">
<p style="margin-bottom:8px;"><b>운영 활력 지수(AVI)</b>는 프로젝트가 현재 얼마나 건강하게 가동되고 있는지를 나타내는 '디지털 생존 지표'입니다.</p>
<ul style="padding-left:18px; margin-bottom:10px;">
<li style="margin-bottom:4px;"><b>지수 감쇄(Exponential Decay)</b>: 마지막 활동 이후 정체 기간이 길어질수록 자산의 최신성과 가치는 기하급수적으로 하락합니다.</li>
<li style="margin-bottom:4px;"><b>위험 가속 계수(λ)</b>: 자산 규모(파일 수)가 클수록 관리 부재 시의 정보 망실 위험이 크다고 판단하여, 더 가파른 감쇄 곡선을 적용합니다.</li>
<li style="margin-bottom:4px;"><b>활동 품질(Quality Factor)</b>: 단순 행정 로그(권한 변경 등)보다 실무 성과물(파일 업로드 등)이 발생했을 때 지수 복원력을 더 높게 부여합니다.</li>
</ul>
<p style="margin:0; font-weight:700; color:#1e5149;">※ 70% 미만 하락 시, 해당 프로젝트의 데이터 노후화 및 관리 방치 위험이 시작된 것으로 간주합니다.</p>
</div>
<table class="data-table" style="font-size:12px; width:100%;">
<thead><tr style="background:#f8fafc;"><th>지수 (AVI)</th><th>등급</th><th>운영 상태</th></tr></thead>
<tbody>
<tr><td class="font-bold">90%↑</td><td style="font-weight:900; color:#059669;">Live</td><td>실시간 성과물이 도출되는 최상급 가동 상태</td></tr>
<tr><td class="font-bold">70~90%</td><td style="font-weight:900; color:#1e5149;">Stable</td><td>주기적 업데이트가 이뤄지는 표준 안정 상태</td></tr>
<tr><td class="font-bold">30~70%</td><td style="font-weight:900; color:#f59e0b;">Idle</td><td>활력이 저하되어 관리가 필요한 정체 상태</td></tr>
<tr><td class="font-bold">10~30%</td><td style="font-weight:900; color:#dc2626;">Risk</td><td>데이터 노후화 및 자산 가치 소멸 위험 상태</td></tr>
<tr><td class="font-bold">10%↓</td><td style="font-weight:900; color:#64748b;">Frozen</td><td>운영이 중단된 사망/방치 상태</td></tr>
</tbody>
</table>
<div class="modal-footer"><button class="btn btn-primary" onclick="closeAnalysisModal()">닫기</button></div>`;
} else if (type === 'vci') {
title.innerText = '자산 가치 기여도 (VCI) 분석 가이드';
body.innerHTML = `
<p style="margin-bottom:12px; font-size:13.5px;">VCI는 야구의 <b>WAR(Wins Above Replacement)</b> 개념을 도입하여, 개별 프로젝트가 전체 포트폴리오 평균 대비 얼마나 조직의 가치에 기여하는지 산출한 지표입니다.</p>
<div class="formula-box" style="margin-bottom:15px; background:#f1f5f9; padding:12px; border-radius:8px; font-family:'Consolas', monospace; font-weight:700; text-align:center;">
VCI = (현재 AVI - 전체 평균 AVI) × (파일 규모 가중치)
</div>
<p style="margin-bottom:15px; font-size:13px; line-height:1.6;">
• <b>0.0 (평균)</b>: 우리 조직의 평균적인 관리 수준을 유지 중인 상태<br>
• <b>(+) 점수</b>: 평균 이상의 활력으로 조직의 디지털 자산 가치를 증대시킴<br>
• <b>(-) 점수</b>: 평균 이하의 방치로 인해 잠재적 기회비용 손실 발생 중
</p>
<table class="data-table" style="font-size:12px; width:100%;">
<thead><tr style="background:#f8fafc;"><th>점수 (VCI)</th><th>등급</th><th>운영 의미</th></tr></thead>
<tbody>
<tr><td>+10.0↑</td><td style="font-weight:900; color:#6366f1;">Masterpiece</td><td>시스템 가치를 견인하는 최우량 핵심 자산</td></tr>
<tr><td>+2.0 ~ +10.0</td><td style="font-weight:900; color:#059669;">Blue Chip</td><td>꾸준한 활력으로 가치를 창출하는 우량 자산</td></tr>
<tr><td>-2.0 ~ +2.0</td><td style="font-weight:900; color:#475569;">Steady</td><td>평균 수준의 운영을 유지 중인 안정 자산</td></tr>
<tr><td>-10.0 ~ -2.0</td><td style="font-weight:900; color:#f59e0b;">Underperform</td><td>평균 대비 활력 부족으로 가치 하락 중인 자산</td></tr>
<tr><td>-10.0↓</td><td style="font-weight:900; color:#dc2626;">Liability</td><td>가치를 훼손 중인 고위험 방치 자산</td></tr>
</tbody>
</table>
<div class="modal-footer"><button class="btn btn-primary" onclick="closeAnalysisModal()">닫기</button></div>`;
} else if (type === 'oci') {
title.innerText = '운영 일관성 지수 (OCI) 분석 가이드';
body.innerHTML = `
<div style="background:#f0fdf4; padding:15px; border-radius:8px; margin-bottom:15px;">
<strong style="color:#166534; display:block; margin-bottom:5px;">"얼마나 꾸준하게 관리되고 있는가?"</strong>
<p style="font-size:12.5px; color:#166534; margin:0;">미래 예측이 아닌, 최근 30일간의 <b>활동 리듬</b>과 <b>관리의 규칙성</b>을 분석하여 성실도를 점수화합니다.</p>
</div>
<table class="data-table" style="font-size:12px;">
<thead><tr style="background:#f8fafc;"><th>분석 결과</th><th>일관성 등급</th><th>관리 신뢰도</th></tr></thead>
<tbody>
<tr><td style="color:#059669;">80%↑</td><td style="font-weight:900; color:#059669;">매우 우수</td><td>주 단위의 정기적 관리가 완벽히 이뤄짐</td></tr>
<tr><td style="color:#1e5149;">50~80%</td><td style="font-weight:900; color:#1e5149;">양호</td><td>간헐적 정체는 있으나 꾸준히 관리됨</td></tr>
<tr><td style="color:#f59e0b;">20~50%</td><td style="font-weight:900; color:#f59e0b;">주의</td><td>돌발적 활동 위주, 관리의 리듬이 깨짐</td></tr>
<tr><td style="color:#dc2626;">20%↓</td><td style="font-weight:900; color:#dc2626;">매우 불량</td><td>장기 정체 중이거나 관리 의지 확인 불가</td></tr>
</tbody>
</table>
<div class="modal-footer"><button class="btn btn-primary" onclick="closeAnalysisModal()">닫기</button></div>`;
} else {
title.innerText = '업무 집중도 (Job Focus) 등급 가이드';
body.innerHTML = `
<p style="font-size:13px; color:#64748b; margin-bottom:15px;">최근 수집 로그 중 단순 행정 로그를 제외하고 실질적인 성과물(파일) 변동이 포착된 비율입니다.</p>
<table class="data-table" style="font-size:12px;">
<thead><tr style="background:#f8fafc;"><th>비율 (%)</th><th>등급</th><th>활동 성격</th></tr></thead>
<tbody>
<tr><td>80%↑</td><td style="font-weight:900; color:#6366f1;">Intensive</td><td>성과물 위주의 고밀도 집중 작업</td></tr>
<tr><td>50~80%</td><td style="font-weight:900; color:#059669;">Active</td><td>성과와 관리가 균형 잡힌 원활한 실행</td></tr>
<tr><td>20~50%</td><td style="font-weight:900; color:#f59e0b;">Maintenance</td><td>설정/행정 등 단순 관리 중심의 작업</td></tr>
<tr><td>20%↓</td><td style="font-weight:900; color:#dc2626;">Surface</td><td>실체적 변화가 적은 형식적 로그 중심</td></tr>
</tbody>
</table>
<div class="modal-footer"><button class="btn btn-primary" onclick="closeAnalysisModal()">닫기</button></div>`;
}
modal.style.display = 'flex';
}
function closeAnalysisModal() { document.getElementById('analysisModal').style.display = 'none'; }

View File

@@ -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 = `
<div class="table-scroll-wrapper">
<table class="data-table p-war-table">
<thead>
<tr>
<th style="position: sticky; top: 0; z-index: 10; width: 280px;">프로젝트명</th>
<th style="position: sticky; top: 0; z-index: 10;">파일 수</th>
<th style="position: sticky; top: 0; z-index: 10;">방치일</th>
<th style="position: sticky; top: 0; z-index: 10;">상태 판정</th>
<th style="position: sticky; top: 0; z-index: 10;">
활력 지수 (AVI) <button class="btn-help" onclick="event.stopPropagation(); openAnalysisModal('avi')">?</button>
</th>
<th style="position: sticky; top: 0; z-index: 10; text-align:right;">가치 기여 (VCI)</th>
<th style="position: sticky; top: 0; z-index: 10; text-align:center;">업무 집중도</th>
<th style="position: sticky; top: 0; z-index: 10;">
운영 일관성 (OCI) <button class="btn-help" onclick="event.stopPropagation(); openAnalysisModal('oci')">?</button>
</th>
</tr>
</thead>
<tbody>
${sortedData.map((p, idx) => {
const status = getStatusInfo(p.p_war, p.is_auto_delete);
const avi = p.p_war;
const vci = p.risk_count;
const oci = p.oci_score || 0;
const rowId = `project-${idx}`;
let rhythmLabel = "";
let rhythmColor = "";
if (oci >= 80) { rhythmLabel = "정기적"; rhythmColor = "#059669"; }
else if (oci >= 50) { rhythmLabel = "안정적"; rhythmColor = "#1e5149"; }
else if (oci >= 20) { rhythmLabel = "간헐적"; rhythmColor = "#f59e0b"; }
else { rhythmLabel = "불규칙"; rhythmColor = "#dc2626"; }
// 존재 신뢰도 패널티 (ECV) 텍스트 준비
let ecvText = "100% (데이터 신뢰)";
let ecvClass = "highlight-val";
let ecvDesc = "충분한 성과물이 존재합니다.";
if (p.file_count === 0) {
ecvText = "5% (유령 프로젝트)";
ecvClass = "highlight-penalty";
ecvDesc = "성과물이 전무하여 시스템 가치가 소멸되었습니다.";
} else if (p.file_count < 10) {
ecvText = "40% (소규모 껍데기)";
ecvClass = "highlight-penalty";
ecvDesc = "최소 수준의 데이터만 존재하여 가치가 낮게 평가됩니다.";
}
// 활동 품질 텍스트 준비
const qualityLabel = p.log_quality >= 1.0 ? '성과물 직결 <b>실무 활동</b>' : p.log_quality >= 0.7 ? '시스템 <b>구조적 활동</b>' : '단순 <b>행정적 활동</b>';
return `
<tr class="project-row ${status.key === 'danger' ? 'row-danger' : status.key === 'warning' ? 'row-warning' : ''}"
onclick="toggleProjectDetail('${rowId}')">
<td class="font-bold">${p.project_nm}</td>
<td>${p.file_count.toLocaleString()}개</td>
<td>${p.days_stagnant}일</td>
<td><span class="${status.class}">${status.label}</span></td>
<td class="p-war-value ${avi >= 70 ? 'text-plus' : 'text-minus'}">
${avi.toFixed(1)}%
</td>
<td style="text-align:right; font-weight:700; color:${vci >= 0 ? '#059669' : '#dc2626'};">
${vci >= 0 ? '+' : ''}${vci.toFixed(2)}
</td>
<td style="text-align:center;">
<div style="display:flex; flex-direction:column; align-items:center; gap:4px;">
<span style="font-weight:800; font-size:12px; color:${p.work_effort >= 70 ? '#059669' : p.work_effort <= 30 ? '#dc2626' : '#6366f1'};">
${p.work_effort}%
</span>
<div style="width:40px; height:4px; background:#f1f5f9; border-radius:2px; overflow:hidden;">
<div style="width:${p.work_effort}%; height:100%; background:${p.work_effort >= 70 ? '#059669' : p.work_effort <= 30 ? '#dc2626' : '#6366f1'}; transition: width 0.5s;"></div>
</div>
</div>
</td>
<td style="text-align:center;">
<div style="display:flex; align-items:center; justify-content:center; gap:8px;">
<span style="font-weight:800; font-size:13px; color:${rhythmColor};">
${oci}%
</span>
<span style="font-size:10px; padding:2px 6px; border-radius:10px; background:${rhythmColor}15; color:${rhythmColor}; border:1px solid ${rhythmColor}30;">
${rhythmLabel}
</span>
</div>
</td>
</tr>
<tr id="detail-${rowId}" class="detail-row">
<td colspan="8">
<div class="detail-container">
<div class="formula-explanation-card">
<div style="font-size: 13px; font-weight: 700; color: #6366f1; margin-bottom: 15px;">
⚙️ AI 위험 적응형 모델(AAS) 산출 시뮬레이션
</div>
<!-- 업무 집중도 분석 (상단 배치) -->
<div style="background: #f8fafc; padding: 15px; border-radius: 8px; margin-bottom: 20px; border: 1px solid #eef2f6;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px;">
<span style="font-size: 13px; font-weight: 800; color: #1e5149;">📊 실질 업무 집중도 분석 (Job Focus)</span>
<span style="font-size: 14px; font-weight: 800; color: ${p.work_effort >= 70 ? '#059669' : p.work_effort <= 30 ? '#dc2626' : '#6366f1'};">
집중도 ${p.work_effort}%
</span>
</div>
<div style="width: 100%; height: 6px; background: #e2e8f0; border-radius: 3px; overflow: hidden; margin-bottom: 10px;">
<div style="width: ${p.work_effort}%; height: 100%; background: ${p.work_effort >= 70 ? '#059669' : p.work_effort <= 30 ? '#dc2626' : '#6366f1'}; transition: width 0.5s;"></div>
</div>
<div style="font-size: 11.5px; color: #64748b; line-height: 1.5;">
최근 30개 수집 이력 중 단순 로그 갱신이 아닌 <b>실제 파일 수의 변동</b>이 포착된 날의 비율입니다.
현재 이 프로젝트는 <b>${p.work_effort >= 70 ? '매우 밀도 높은 실무' : p.work_effort <= 30 ? '형식적 관리 위주의 정체' : '간헐적인 성과물'}</b> 상태를 보이고 있습니다.
</div>
</div>
<!-- 수식 단계 2x2 그리드 -->
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 20px;">
<div class="formula-step">
<div class="step-num">1</div>
<div class="step-content">
<div class="step-title">동적 위험 계수(λ) 산출</div>
<div style="font-size:11px; color:#64748b; margin-bottom:5px;">자산 규모(${p.file_count}개) 및 부서 위험도를 합산한 하락 속도입니다.</div>
<div class="math-logic">λ = <span class="highlight-var">${p.ai_lambda.toFixed(4)}</span></div>
</div>
</div>
<div class="formula-step">
<div class="step-num">4</div>
<div class="step-content">
<div class="step-title">활동 품질 검증 (Quality)</div>
<div class="step-desc" style="font-size:11px; margin-bottom:5px;">
최근 로그 분석 결과 ${qualityLabel}으로 판명되었습니다.
</div>
<div class="math-logic">Factor = <span class="${p.log_quality < 0.7 ? 'highlight-penalty' : 'highlight-val'}">${(p.log_quality * 100).toFixed(0)}%</span></div>
</div>
</div>
<div class="formula-step">
<div class="step-num">2</div>
<div class="step-content">
<div class="step-title">방치 시간 감쇄 적용</div>
<div style="font-size:11px; color:#64748b; margin-bottom:5px;">${p.days_stagnant}일간의 정체로 인한 가치 보존율입니다.</div>
<div class="math-logic">Result = <span class="highlight-val">${((avi / (p.file_count === 0 ? 0.05 : p.file_count < 10 ? 0.4 : 1) / p.log_quality) || 0).toFixed(1)}%</span></div>
</div>
</div>
<div class="formula-step">
<div class="step-num">3</div>
<div class="step-content">
<div class="step-title">존재 진정성 (ECV)</div>
<div style="font-size:11px; color:#64748b; margin-bottom:5px;">${ecvDesc}</div>
<div class="math-logic">Factor = <span class="${ecvClass}">${ecvText}</span></div>
</div>
</div>
</div>
<div style="margin-top: 20px; padding-top: 15px; border-top: 2px solid #1e5149; text-align: right; display: flex; justify-content: space-between; align-items: center;">
<div style="text-align: left;">
<div style="font-size: 12px; font-weight: 700; color: ${vci >= 0 ? '#059669' : '#dc2626'};">
가치 기여도 (VCI): ${vci >= 0 ? '+' : ''}${vci.toFixed(2)}
</div>
<div style="font-size: 10px; color: #94a3b8;">* AVI 70% 대비 프로젝트의 실질적 자산 하중 반영</div>
</div>
<div>
<span style="font-size: 13px; color: #64748b; font-weight: 700;">최종 AVI: </span>
<span style="color: #1e5149; font-size: 22px; font-weight: 900;">${avi.toFixed(1)}%</span>
</div>
</div>
</div>
</div>
</td>
</tr>
`;
}).join('')}
</tbody>
</table>
</div>
`;
}
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 = `
<div class="table-scroll-wrapper">
<table class="data-table p-war-table">
<thead>
<tr>
<th style="position: sticky; top: 0; z-index: 10; width: 280px;">프로젝트명</th>
<th style="position: sticky; top: 0; z-index: 10;">파일 수</th>
<th style="position: sticky; top: 0; z-index: 10;">방치일</th>
<th style="position: sticky; top: 0; z-index: 10;">상태 판정</th>
<th style="position: sticky; top: 0; z-index: 10;">
활력 지수 (AVI) <button class="btn-help" onclick="event.stopPropagation(); openAnalysisModal('avi')">?</button>
</th>
<th style="position: sticky; top: 0; z-index: 10; text-align:right;">가치 기여 (VCI)</th>
<th style="position: sticky; top: 0; z-index: 10; text-align:center;">업무 집중도</th>
<th style="position: sticky; top: 0; z-index: 10;">
운영 일관성 (OCI) <button class="btn-help" onclick="event.stopPropagation(); openAnalysisModal('oci')">?</button>
</th>
</tr>
</thead>
<tbody>
${sortedData.map((p, idx) => {
const status = getStatusInfo(p.p_war, p.is_auto_delete);
const avi = p.p_war;
const vci = p.risk_count;
const oci = p.oci_score || 0;
const rowId = `project-${idx}`;
let rhythmLabel = "";
let rhythmColor = "";
if (oci >= 80) { rhythmLabel = "정기적"; rhythmColor = "#059669"; }
else if (oci >= 50) { rhythmLabel = "안정적"; rhythmColor = "#1e5149"; }
else if (oci >= 20) { rhythmLabel = "간헐적"; rhythmColor = "#f59e0b"; }
else { rhythmLabel = "불규칙"; rhythmColor = "#dc2626"; }
// 존재 신뢰도 패널티 (ECV) 텍스트 준비
let ecvText = "100% (데이터 신뢰)";
let ecvClass = "highlight-val";
let ecvDesc = "충분한 성과물이 존재합니다.";
if (p.file_count === 0) {
ecvText = "5% (유령 프로젝트)";
ecvClass = "highlight-penalty";
ecvDesc = "성과물이 전무하여 시스템 가치가 소멸되었습니다.";
} else if (p.file_count < 10) {
ecvText = "40% (소규모 껍데기)";
ecvClass = "highlight-penalty";
ecvDesc = "최소 수준의 데이터만 존재하여 가치가 낮게 평가됩니다.";
}
// 활동 품질 텍스트 준비
const qualityLabel = p.log_quality >= 1.0 ? '성과물 직결 <b>실무 활동</b>' : p.log_quality >= 0.7 ? '시스템 <b>구조적 활동</b>' : '단순 <b>행정적 활동</b>';
return `
<tr class="project-row ${status.key === 'danger' ? 'row-danger' : status.key === 'warning' ? 'row-warning' : ''}"
onclick="toggleProjectDetail('${rowId}')">
<td class="font-bold">${p.project_nm}</td>
<td>${p.file_count.toLocaleString()}개</td>
<td>${p.days_stagnant}일</td>
<td><span class="${status.class}">${status.label}</span></td>
<td class="p-war-value ${avi >= 70 ? 'text-plus' : 'text-minus'}">
${avi.toFixed(1)}%
</td>
<td style="text-align:right; font-weight:700; color:${vci >= 0 ? '#059669' : '#dc2626'};">
${vci >= 0 ? '+' : ''}${vci.toFixed(2)}
</td>
<td style="text-align:center;">
<div style="display:flex; flex-direction:column; align-items:center; gap:4px;">
<span style="font-weight:800; font-size:12px; color:${p.work_effort >= 70 ? '#059669' : p.work_effort <= 30 ? '#dc2626' : '#6366f1'};">
${p.work_effort}%
</span>
<div style="width:40px; height:4px; background:#f1f5f9; border-radius:2px; overflow:hidden;">
<div style="width:${p.work_effort}%; height:100%; background:${p.work_effort >= 70 ? '#059669' : p.work_effort <= 30 ? '#dc2626' : '#6366f1'}; transition: width 0.5s;"></div>
</div>
</div>
</td>
<td style="text-align:center;">
<div style="display:flex; align-items:center; justify-content:center; gap:8px;">
<span style="font-weight:800; font-size:13px; color:${rhythmColor};">
${oci}%
</span>
<span style="font-size:10px; padding:2px 6px; border-radius:10px; background:${rhythmColor}15; color:${rhythmColor}; border:1px solid ${rhythmColor}30;">
${rhythmLabel}
</span>
</div>
</td>
</tr>
<tr id="detail-${rowId}" class="detail-row">
<td colspan="8">
<div class="detail-container">
<div class="formula-explanation-card">
<div style="font-size: 13px; font-weight: 700; color: #6366f1; margin-bottom: 15px;">
⚙️ AI 위험 적응형 모델(AAS) 산출 시뮬레이션
</div>
<!-- 업무 집중도 분석 (상단 배치) -->
<div style="background: #f8fafc; padding: 15px; border-radius: 8px; margin-bottom: 20px; border: 1px solid #eef2f6;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px;">
<span style="font-size: 13px; font-weight: 800; color: #1e5149;">📊 실질 업무 집중도 분석 (Job Focus)</span>
<span style="font-size: 14px; font-weight: 800; color: ${p.work_effort >= 70 ? '#059669' : p.work_effort <= 30 ? '#dc2626' : '#6366f1'};">
집중도 ${p.work_effort}%
</span>
</div>
<div style="width: 100%; height: 6px; background: #e2e8f0; border-radius: 3px; overflow: hidden; margin-bottom: 10px;">
<div style="width: ${p.work_effort}%; height: 100%; background: ${p.work_effort >= 70 ? '#059669' : p.work_effort <= 30 ? '#dc2626' : '#6366f1'}; transition: width 0.5s;"></div>
</div>
<div style="font-size: 11.5px; color: #64748b; line-height: 1.5;">
최근 30개 수집 이력 중 단순 로그 갱신이 아닌 <b>실제 파일 수의 변동</b>이 포착된 날의 비율입니다.
현재 이 프로젝트는 <b>${p.work_effort >= 70 ? '매우 밀도 높은 실무' : p.work_effort <= 30 ? '형식적 관리 위주의 정체' : '간헐적인 성과물'}</b> 상태를 보이고 있습니다.
</div>
</div>
<!-- 수식 단계 2x2 그리드 -->
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 20px;">
<div class="formula-step">
<div class="step-num">1</div>
<div class="step-content">
<div class="step-title">동적 위험 계수(λ) 산출</div>
<div style="font-size:11px; color:#64748b; margin-bottom:5px;">자산 규모(${p.file_count}개) 및 부서 위험도를 합산한 하락 속도입니다.</div>
<div class="math-logic">λ = <span class="highlight-var">${p.ai_lambda.toFixed(4)}</span></div>
</div>
</div>
<div class="formula-step">
<div class="step-num">4</div>
<div class="step-content">
<div class="step-title">활동 품질 검증 (Quality)</div>
<div class="step-desc" style="font-size:11px; margin-bottom:5px;">
최근 로그 분석 결과 ${qualityLabel}으로 판명되었습니다.
</div>
<div class="math-logic">Factor = <span class="${p.log_quality < 0.7 ? 'highlight-penalty' : 'highlight-val'}">${(p.log_quality * 100).toFixed(0)}%</span></div>
</div>
</div>
<div class="formula-step">
<div class="step-num">2</div>
<div class="step-content">
<div class="step-title">방치 시간 감쇄 적용</div>
<div style="font-size:11px; color:#64748b; margin-bottom:5px;">${p.days_stagnant}일간의 정체로 인한 가치 보존율입니다.</div>
<div class="math-logic">Result = <span class="highlight-val">${((avi / (p.file_count === 0 ? 0.05 : p.file_count < 10 ? 0.4 : 1) / p.log_quality) || 0).toFixed(1)}%</span></div>
</div>
</div>
<div class="formula-step">
<div class="step-num">3</div>
<div class="step-content">
<div class="step-title">존재 진정성 (ECV)</div>
<div style="font-size:11px; color:#64748b; margin-bottom:5px;">${ecvDesc}</div>
<div class="math-logic">Factor = <span class="${ecvClass}">${ecvText}</span></div>
</div>
</div>
</div>
<div style="margin-top: 20px; padding-top: 15px; border-top: 2px solid #1e5149; text-align: right; display: flex; justify-content: space-between; align-items: center;">
<div style="text-align: left;">
<div style="font-size: 12px; font-weight: 700; color: ${vci >= 0 ? '#059669' : '#dc2626'};">
가치 기여도 (VCI): ${vci >= 0 ? '+' : ''}${vci.toFixed(2)}
</div>
<div style="font-size: 10px; color: #94a3b8;">* AVI 70% 대비 프로젝트의 실질적 자산 하중 반영</div>
</div>
<div>
<span style="font-size: 13px; color: #64748b; font-weight: 700;">최종 AVI: </span>
<span style="color: #1e5149; font-size: 22px; font-weight: 900;">${avi.toFixed(1)}%</span>
</div>
</div>
</div>
</div>
</td>
</tr>
`;
}).join('')}
</tbody>
</table>
</div>
`;
}

485
js/analysis_test.js Normal file
View File

@@ -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 = `
<div class="table-scroll-wrapper">
<table class="data-table p-war-table">
<thead>
<tr>
<th style="width: 250px;">프로젝트명</th>
<th>파일 수</th>
<th>정체 일수</th>
<th>상태 판정</th>
<th style="text-align:right;">가치 기여 (VCI) <button class="btn-help" onclick="event.stopPropagation(); openAnalysisModal('vci')">?</button></th>
<th>운영 활력 (AVI) <button class="btn-help" onclick="event.stopPropagation(); openAnalysisModal('avi')">?</button></th>
<th style="text-align:center;">업무 집중도 <button class="btn-help" onclick="event.stopPropagation(); openAnalysisModal('focus')">?</button></th>
<th>운영 일관성 (OCI) <button class="btn-help" onclick="event.stopPropagation(); openAnalysisModal('oci')">?</button></th>
</tr>
</thead>
<tbody>
${sortedData.map((p, idx) => {
const status = getStatusInfo(p.p_war, p.is_auto_delete);
const avi = p.p_war;
const vci = p.risk_count;
const oci = p.oci_score || 0;
const rowId = `project-${idx}`;
const grade = getVciGrade(vci);
let rhythmLabel = oci >= 80 ? "정기적" : oci >= 50 ? "안정적" : oci >= 20 ? "간헐적" : "불규칙";
let rhythmColor = oci >= 80 ? "#059669" : oci >= 50 ? "#1e5149" : oci >= 20 ? "#f59e0b" : "#dc2626";
// 존재 신뢰도 패널티 (ECV) 상세 설명 복구
let ecvText = "100% (데이터 실체 검증)";
let ecvClass = "highlight-val";
let ecvDesc = `현재 ${p.file_count}개의 유효 성과물이 확인됩니다. 시스템적으로 실체가 완벽히 존재하는 상태입니다.`;
if (p.file_count === 0) {
ecvText = "5% (유령 프로젝트 판명)";
ecvClass = "highlight-penalty";
ecvDesc = "데이터가 전무하여 프로젝트의 디지털 실체가 없습니다. 모든 분석에서 최하위 패널티가 적용됩니다.";
} else if (p.file_count < 10) {
ecvText = "40% (형식적 껍데기 판명)";
ecvClass = "highlight-penalty";
ecvDesc = "최소 수준의 문서만 존재하며, 실질적인 운영 가치를 인정하기 어려운 소규모 상태입니다.";
}
// 활동 품질 텍스트 복구
const qualityLabel = p.log_quality >= 1.0 ? '성과물 중심의 <b>실무 활동</b>' : p.log_quality >= 0.7 ? '구조 관리를 위한 <b>시스템 활동</b>' : '단순 행정 기반의 <b>형식 활동</b>';
const qualityDetail = p.log_quality >= 1.0 ? '최근 로그에서 파일 업로드/수정 등 가치 증분 활동이 명확히 포착되었습니다.' : p.log_quality >= 0.7 ? '폴더 생성/이동 등 구조적 관리는 이뤄지고 있으나, 직접적 결과물 생산은 부족합니다.' : '메일 확인, 권한 변경 등 시스템 유지성 활동 위주로 파악되어 품질 가중치가 낮게 적용되었습니다.';
return `
<tr class="project-row ${status.key === 'danger' ? 'row-danger' : status.key === 'warning' ? 'row-warning' : ''}" onclick="toggleProjectDetail('${rowId}')">
<td class="font-bold">${p.project_nm}</td>
<td>${p.file_count.toLocaleString()}</td>
<td>${p.days_stagnant}</td>
<td><span class="${status.class}">${status.label}</span></td>
<td style="text-align:right; font-weight:800; color:${vci >= 0 ? '#059669' : '#dc2626'};">
${vci > 0 ? '+' : ''}${vci.toFixed(1)}
</td>
<td class="p-war-value ${avi >= 70 ? 'text-plus' : 'text-minus'}">${avi.toFixed(1)}%</td>
<td style="text-align:center;">
<div style="display:flex; flex-direction:column; align-items:center; gap:4px;">
<span style="font-weight:800; font-size:12px; color:${p.work_effort >= 70 ? '#059669' : p.work_effort <= 30 ? '#dc2626' : '#6366f1'};">${p.work_effort}%</span>
<div style="width:40px; height:4px; background:#f1f5f9; border-radius:2px; overflow:hidden;">
<div style="width:${p.work_effort}%; height:100%; background:${p.work_effort >= 70 ? '#059669' : p.work_effort <= 30 ? '#dc2626' : '#6366f1'};"></div>
</div>
</div>
</td>
<td style="text-align:center;">
<div style="display:flex; align-items:center; justify-content:center; gap:8px;">
<span style="font-weight:800; font-size:13px; color:${rhythmColor};">${oci}%</span>
<span style="font-size:10px; padding:2px 6px; border-radius:10px; background:${rhythmColor}15; color:${rhythmColor}; border:1px solid ${rhythmColor}30;">${rhythmLabel}</span>
</div>
</td>
</tr>
<tr id="detail-${rowId}" class="detail-row">
<td colspan="8">
<div class="detail-container">
<div class="formula-explanation-card">
<div class="formula-header"> AI 위험 적응형 모델(AAS) 기반 인과관계 분석</div>
<div style="display: flex; gap: 20px; margin-bottom: 20px;">
<div class="work-effort-section" style="flex: 1; margin-bottom: 0;">
<div class="work-effort-header">
<span style="font-size: 13px; font-weight: 800; color: #1e5149;">📊 실질 업무 집중도 (Job Focus)</span>
<span style="font-size: 14px; font-weight: 800; color: ${p.work_effort >= 70 ? '#059669' : p.work_effort <= 30 ? '#dc2626' : '#6366f1'};">${p.work_effort}%</span>
</div>
<div class="work-effort-bar-bg"><div style="width: ${p.work_effort}%; height: 100%; background: ${p.work_effort >= 70 ? '#059669' : p.work_effort <= 30 ? '#dc2626' : '#6366f1'};"></div></div>
<div style="font-size: 11px; color: #64748b; line-height: 1.5;">최근 30 수집 이력 단순 로그 갱신이 아닌 <b>실제 성과물의 변동</b> . '' .</div>
</div>
<div style="flex: 1; background: #f8fafc; border-radius: 8px; padding: 16px; border: 1px solid #e2e8f0; display: flex; align-items: center; gap: 15px;">
<div style="text-align: center;">
<div style="font-size: 10px; color: #64748b; font-weight: 700; margin-bottom: 2px;">VCI GRADE</div>
<div class="grade-badge ${grade.class}" style="padding: 4px 12px; border-radius: 6px; font-weight: 900; font-size: 14px; display: inline-block;">${grade.label}</div>
</div>
<div style="font-size: 12px; color: #475569; line-height: 1.4; font-weight: 600;">${grade.desc}</div>
</div>
</div>
<div class="formula-steps-grid">
<div class="formula-step">
<div class="step-num">1</div>
<div class="step-content">
<div class="step-title">동적 위험 계수(λ) 산출</div>
<div style="font-size:11px; color:#64748b; margin-bottom:5px;">프로젝트 규모가 클수록 정보 망실 시의 충격을 반영하여 데이터의 하락 속도가 가속됩니다. 현재 <b>λ=${p.ai_lambda.toFixed(4)}</b> .</div>
<div class="math-logic">Dynamic λ = <span class="highlight-var">${p.ai_lambda.toFixed(4)}</span></div>
</div>
</div>
<div class="formula-step">
<div class="step-num">4</div>
<div class="step-content">
<div class="step-title">활동 품질 검증 (Quality)</div>
<div class="step-desc" style="font-size:11px; margin-bottom:5px;">최근 로그 분석 결과 <b>${qualityLabel}</b> . ${qualityDetail}</div>
<div class="math-logic">Quality Factor = <span class="${p.log_quality < 0.7 ? 'highlight-penalty' : 'highlight-val'}">${(p.log_quality * 100).toFixed(0)}%</span></div>
</div>
</div>
<div class="formula-step">
<div class="step-num">2</div>
<div class="step-content">
<div class="step-title">방치 시간 감쇄 적용</div>
<div style="font-size:11px; color:#64748b; margin-bottom:5px;">마지막 유효 활동 이후 <b>${p.days_stagnant}</b> .</div>
<div class="math-logic">Decay Result = <span class="highlight-val">${((avi / (p.file_count === 0 ? 0.05 : p.file_count < 10 ? 0.4 : 1) / p.log_quality) || 0).toFixed(1)}%</span></div>
</div>
</div>
<div class="formula-step">
<div class="step-num">3</div>
<div class="step-content">
<div class="step-title">존재 진정성 (ECV)</div>
<div style="font-size:11px; color:#64748b; margin-bottom:5px;">${ecvDesc} 파일 자체가 분석의 데이터 진정성을 보정하는 핵심 팩터로 작용합니다.</div>
<div class="math-logic">Entity Factor = <span class="${ecvClass}">${ecvText}</span></div>
</div>
</div>
</div>
<div style="margin-top: 20px; padding-top: 15px; border-top: 2px solid #1e5149; text-align: right; display: flex; justify-content: space-between; align-items: center;">
<div style="text-align: left; max-width: 70%;">
<div style="display: flex; align-items: center; gap: 8px; margin-bottom: 4px;">
<div style="font-size: 13px; font-weight: 800; color: ${vci >= 0 ? '#059669' : '#dc2626'};">
가치 기여도 (VCI) 진단: ${vci >= 0 ? '+' : ''}${vci.toFixed(2)}
</div>
<div style="font-size: 10px; color: #94a3b8; background: #f8fafc; padding: 2px 6px; border-radius: 4px; border: 1px solid #e2e8f0;">
조직 평균 자산: ${p.avg_info.avg_files}
</div>
</div>
<div style="font-size: 11px; color: #64748b; line-height: 1.5;">
현재 프로젝트는 <b>포트폴리오 평균 관리 수준</b> 대비 <b>${Math.abs(vci / Math.max(0.2, p.file_count / p.avg_info.avg_files)).toFixed(1)}%p ${vci >= 0 ? '상회' : '하회'}</b> ,
<b>${p.file_count}</b>의 자산 규모에 따른 <b>${Math.max(0.2, p.file_count / p.avg_info.avg_files).toFixed(2)}</b> .
이는 시스템 전체 관점에서 <b>${vci >= 0 ? '순자산 가치를 증대' : '잠재적 기회비용을 손실'}</b> .
</div>
</div>
<div>
<span style="font-size: 13px; color: #64748b; font-weight: 700;">최종 AVI: </span>
<span style="color: #1e5149; font-size: 22px; font-weight: 900;">${avi.toFixed(1)}%</span>
</div>
</div>
</div>
</div>
</td>
</tr>`;
}).join('')}
</tbody>
</table>
</div>`;
}
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 = `
<div class="table-scroll-wrapper" style="max-height: 400px;">
<table class="data-table">
<thead><tr><th>프로젝트명</th><th>관리자</th><th>정체일</th><th>AVI</th></tr></thead>
<tbody>${projects.map(p => `<tr><td class="font-bold">${p.project_nm}</td><td>${p.master || '-'}</td><td>${p.days_stagnant}일</td><td style="font-weight:700; color:#1e5149;">${p.p_war.toFixed(1)}%</td></tr>`).join('')}</tbody>
</table>
</div>
<div class="modal-footer"><button class="btn btn-primary" onclick="closeAnalysisModal()">닫기</button></div>`;
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 = `
<div class="formula-box" style="margin-bottom:15px; background:#f1f5f9; padding:12px; border-radius:8px; font-family:'Consolas', monospace; font-weight:700; text-align:center;">
AVI = exp(-λ × Stagnant Days) × Quality × 100
</div>
<div style="margin-bottom:15px; font-size:13px; line-height:1.6; color:#334155;">
<p style="margin-bottom:8px;"><b>운영 활력 지수(AVI)</b>는 프로젝트가 현재 얼마나 건강하게 가동되고 있는지를 나타내는 '디지털 생존 지표'입니다.</p>
<ul style="padding-left:18px; margin-bottom:10px;">
<li style="margin-bottom:4px;"><b>지수 감쇄(Exponential Decay)</b>: 마지막 활동 이후 정체 기간이 길어질수록 자산의 최신성과 가치는 기하급수적으로 하락합니다.</li>
<li style="margin-bottom:4px;"><b>위험 가속 계수(λ)</b>: 자산 규모(파일 수)가 클수록 관리 부재 시의 정보 망실 위험이 크다고 판단하여, 더 가파른 감쇄 곡선을 적용합니다.</li>
<li style="margin-bottom:4px;"><b>활동 품질(Quality Factor)</b>: 단순 행정 로그(권한 변경 등)보다 실무 성과물(파일 업로드 등)이 발생했을 때 지수 복원력을 더 높게 부여합니다.</li>
</ul>
<p style="margin:0; font-weight:700; color:#1e5149;">※ 70% 미만 하락 시, 해당 프로젝트의 데이터 노후화 및 관리 방치 위험이 시작된 것으로 간주합니다.</p>
</div>
<table class="data-table" style="font-size:12px; width:100%;">
<thead><tr style="background:#f8fafc;"><th>지수 (AVI)</th><th>등급</th><th>운영 상태</th></tr></thead>
<tbody>
<tr><td class="font-bold">90%↑</td><td style="font-weight:900; color:#059669;">Live</td><td>실시간 성과물이 도출되는 최상급 가동 상태</td></tr>
<tr><td class="font-bold">70~90%</td><td style="font-weight:900; color:#1e5149;">Stable</td><td>주기적 업데이트가 이뤄지는 표준 안정 상태</td></tr>
<tr><td class="font-bold">30~70%</td><td style="font-weight:900; color:#f59e0b;">Idle</td><td>활력이 저하되어 관리가 필요한 정체 상태</td></tr>
<tr><td class="font-bold">10~30%</td><td style="font-weight:900; color:#dc2626;">Risk</td><td>데이터 노후화 및 자산 가치 소멸 위험 상태</td></tr>
<tr><td class="font-bold">10%↓</td><td style="font-weight:900; color:#64748b;">Frozen</td><td>운영이 중단된 사망/방치 상태</td></tr>
</tbody>
</table>
<div class="modal-footer"><button class="btn btn-primary" onclick="closeAnalysisModal()">닫기</button></div>`;
} else if (type === 'vci') {
title.innerText = '자산 가치 기여도 (VCI) 분석 가이드 (TEST)';
body.innerHTML = `
<p style="margin-bottom:12px; font-size:13.5px;">VCI는 야구의 <b>WAR(Wins Above Replacement)</b> 개념을 도입하여, 개별 프로젝트가 전체 포트폴리오 평균 대비 얼마나 조직의 가치에 기여하는지 산출한 지표입니다.</p>
<div class="formula-box" style="margin-bottom:15px; background:#f1f5f9; padding:12px; border-radius:8px; font-family:'Consolas', monospace; font-weight:700; text-align:center;">
VCI = (현재 AVI - 전체 평균 AVI) × (파일 규모 가중치)
</div>
<p style="margin-bottom:15px; font-size:13px; line-height:1.6;">
• <b>0.0 (평균)</b>: 우리 조직의 평균적인 관리 수준을 유지 중인 상태<br>
• <b>(+) 점수</b>: 평균 이상의 활력으로 조직의 디지털 자산 가치를 증대시킴<br>
• <b>(-) 점수</b>: 평균 이하의 방치로 인해 잠재적 기회비용 손실 발생 중
</p>
<table class="data-table" style="font-size:12px; width:100%;">
<thead><tr style="background:#f8fafc;"><th>점수 (VCI)</th><th>등급</th><th>운영 의미</th></tr></thead>
<tbody>
<tr><td>+10.0↑</td><td style="font-weight:900; color:#6366f1;">Masterpiece</td><td>시스템 가치를 견인하는 최우량 핵심 자산</td></tr>
<tr><td>+2.0 ~ +10.0</td><td style="font-weight:900; color:#059669;">Blue Chip</td><td>꾸준한 활력으로 가치를 창출하는 우량 자산</td></tr>
<tr><td>-2.0 ~ +2.0</td><td style="font-weight:900; color:#475569;">Steady</td><td>평균 수준의 운영을 유지 중인 안정 자산</td></tr>
<tr><td>-10.0 ~ -2.0</td><td style="font-weight:900; color:#f59e0b;">Underperform</td><td>평균 대비 활력 부족으로 가치 하락 중인 자산</td></tr>
<tr><td>-10.0↓</td><td style="font-weight:900; color:#dc2626;">Liability</td><td>가치를 훼손 중인 고위험 방치 자산</td></tr>
</tbody>
</table>
<div class="modal-footer"><button class="btn btn-primary" onclick="closeAnalysisModal()">닫기</button></div>`;
} else if (type === 'oci') {
title.innerText = '운영 일관성 지수 (OCI) 분석 가이드 (TEST)';
body.innerHTML = `
<div style="background:#f0fdf4; padding:15px; border-radius:8px; margin-bottom:15px;">
<strong style="color:#166534; display:block; margin-bottom:5px;">"얼마나 꾸준하게 관리되고 있는가?"</strong>
<p style="font-size:12.5px; color:#166534; margin:0;">미래 예측이 아닌, 최근 30일간의 <b>활동 리듬</b>과 <b>관리의 규칙성</b>을 분석하여 성실도를 점수화합니다.</p>
</div>
<table class="data-table" style="font-size:12px;">
<thead><tr style="background:#f8fafc;"><th>분석 결과</th><th>일관성 등급</th><th>관리 신뢰도</th></tr></thead>
<tbody>
<tr><td style="color:#059669;">80%↑</td><td style="font-weight:900; color:#059669;">매우 우수</td><td>주 단위의 정기적 관리가 완벽히 이뤄짐</td></tr>
<tr><td style="color:#1e5149;">50~80%</td><td style="font-weight:900; color:#1e5149;">양호</td><td>간헐적 정체는 있으나 꾸준히 관리됨</td></tr>
<tr><td style="color:#f59e0b;">20~50%</td><td style="font-weight:900; color:#f59e0b;">주의</td><td>돌발적 활동 위주, 관리의 리듬이 깨짐</td></tr>
<tr><td style="color:#dc2626;">20%↓</td><td style="font-weight:900; color:#dc2626;">매우 불량</td><td>장기 정체 중이거나 관리 의지 확인 불가</td></tr>
</tbody>
</table>
<div class="modal-footer"><button class="btn btn-primary" onclick="closeAnalysisModal()">닫기</button></div>`;
} else {
title.innerText = '업무 집중도 (Job Focus) 등급 가이드 (TEST)';
body.innerHTML = `
<p style="font-size:13px; color:#64748b; margin-bottom:15px;">최근 수집 로그 중 단순 행정 로그를 제외하고 실질적인 성과물(파일) 변동이 포착된 비율입니다.</p>
<table class="data-table" style="font-size:12px;">
<thead><tr style="background:#f8fafc;"><th>비율 (%)</th><th>등급</th><th>활동 성격</th></tr></thead>
<tbody>
<tr><td>80%↑</td><td style="font-weight:900; color:#6366f1;">Intensive</td><td>성과물 위주의 고밀도 집중 작업</td></tr>
<tr><td>50~80%</td><td style="font-weight:900; color:#059669;">Active</td><td>성과와 관리가 균형 잡힌 원활한 실행</td></tr>
<tr><td>20~50%</td><td style="font-weight:900; color:#f59e0b;">Maintenance</td><td>설정/행정 등 단순 관리 중심의 작업</td></tr>
<tr><td>20%↓</td><td style="font-weight:900; color:#dc2626;">Surface</td><td>실체적 변화가 적은 형식적 로그 중심</td></tr>
</tbody>
</table>
<div class="modal-footer"><button class="btn btn-primary" onclick="closeAnalysisModal()">닫기</button></div>`;
}
modal.style.display = 'flex';
}
function closeAnalysisModal() { document.getElementById('analysisModal').style.display = 'none'; }

View File

@@ -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.");
});

View File

@@ -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 = `
<select id="dateSelector" onchange="loadDataByDate(this.value)"
style="margin-left:10px; border:none; background:none; font-weight:700; cursor:pointer; font-family:inherit; color:inherit;">
${dates.map(d => `<option value="${d}">${d}</option>`).join('')}
</select>`;
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 = `
<div class="activity-card active" onclick="showActivityDetails('active')">
<div class="label">정상 (7일 이내)</div><div class="count">${summary.active}</div>
</div>
<div class="activity-card warning" onclick="showActivityDetails('warning')">
<div class="label">주의 (14일 이내)</div><div class="count">${summary.warning}</div>
</div>
<div class="activity-card stale" onclick="showActivityDetails('stale')">
<div class="label">방치 (14일 초과 / 폴더자동삭제)</div><div class="count">${summary.stale}</div>
</div>
<div class="activity-card unknown" onclick="showActivityDetails('unknown')">
<div class="label">데이터 없음 (파일 0개)</div><div class="count">${summary.unknown}</div>
</div>`;
} 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 = `<div class="continent-header" onclick="toggleGroup(this)"><span>${continent}</span><span class="toggle-icon">▼</span></div><div class="continent-body">`;
Object.keys(grouped[continent]).sort().forEach(country => {
html += `<div class="country-group active"><div class="country-header" onclick="toggleGroup(this)"><span>${country}</span><span class="toggle-icon">▼</span></div><div class="country-body"><div class="accordion-container">
<div class="accordion-list-header"><div>프로젝트명</div><div>담당부서</div><div>담당자</div><div style="text-align:center;">파일수</div><div>최근로그</div></div>
${grouped[continent][country].sort((a, b) => a[0].localeCompare(b[0])).map(p => createProjectHtml(p)).join('')}</div></div></div>`;
});
html += `</div>`;
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 `
<div class="accordion-item ${statusClass}">
<div class="accordion-header" onclick="toggleAccordion(this)">
<div class="repo-title" title="${name}">${name}</div><div class="repo-dept">${dept}</div><div class="repo-admin">${admin}</div><div class="repo-files ${isNoFiles ? 'error-text' : ''}">${files || 0}</div><div class="repo-log ${logStyleClass}" style="${logBoldStyle}" title="${recentLog}">${recentLog}</div>
</div>
<div class="accordion-body">
<div class="detail-grid">
<div class="detail-section">
<h4>참여 인원 상세</h4>
<table class="data-table">
<thead><tr><th>이름</th><th>소속</th><th>권한</th></tr></thead>
<tbody><tr><td>${admin}</td><td>${dept}</td><td>관리자</td></tr></tbody>
</table>
</div>
<div class="detail-section">
<h4>최근 활동</h4>
<table class="data-table">
<thead><tr><th>유형</th><th>내용</th><th>일시</th></tr></thead>
<tbody><tr><td><span class="badge">로그</span></td><td>동기화 완료</td><td>${logTime}</td></tr></tbody>
</table>
</div>
</div>
</div>
</div>`;
}
// --- 이벤트 핸들러 ---
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 `<tr class="modal-row" onclick="scrollToProject('${p.name}')"><td><strong>${p.name}</strong></td><td>${o ? o[1] : "-"}</td><td>${o ? o[2] : "-"}</td></tr>`;
}).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 = `<span class="spinner"></span> 크롤링 중단`;
logC.style.display = 'block'; logB.innerHTML = '<div style="color:#aaa; margin-bottom:10px;">>>> 엔진 초기화 중...</div>';
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 = `<span class="spinner"></span> 데이터 동기화 (크롤링)`; }
}
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 = `
<select id="dateSelector" onchange="loadDataByDate(this.value)"
style="margin-left:10px; border:none; background:none; font-weight:700; cursor:pointer; font-family:inherit; color:inherit;">
${dates.map(d => `<option value="${d}">${d}</option>`).join('')}
</select>`;
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 = `
<div class="activity-card active" onclick="showActivityDetails('active')">
<div class="label">정상 (7일 이내)</div><div class="count">${summary.active}</div>
</div>
<div class="activity-card warning" onclick="showActivityDetails('warning')">
<div class="label">주의 (14일 이내)</div><div class="count">${summary.warning}</div>
</div>
<div class="activity-card stale" onclick="showActivityDetails('stale')">
<div class="label">방치 (14일 초과 / 폴더자동삭제)</div><div class="count">${summary.stale}</div>
</div>
<div class="activity-card unknown" onclick="showActivityDetails('unknown')">
<div class="label">데이터 없음 (파일 0개)</div><div class="count">${summary.unknown}</div>
</div>`;
} 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 = `<div class="continent-header" onclick="toggleGroup(this)"><span>${continent}</span><span class="toggle-icon">▼</span></div><div class="continent-body">`;
Object.keys(grouped[continent]).sort().forEach(country => {
html += `<div class="country-group active"><div class="country-header" onclick="toggleGroup(this)"><span>${country}</span><span class="toggle-icon">▼</span></div><div class="country-body"><div class="accordion-container">
<div class="accordion-list-header"><div>프로젝트명</div><div>담당부서</div><div>담당자</div><div style="text-align:center;">파일수</div><div>최근로그</div></div>
${grouped[continent][country].sort((a, b) => a[0].localeCompare(b[0])).map(p => createProjectHtml(p)).join('')}</div></div></div>`;
});
html += `</div>`;
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 `
<div class="accordion-item ${statusClass}">
<div class="accordion-header" onclick="toggleAccordion(this)">
<div class="repo-title" title="${name}">${name}</div><div class="repo-dept">${dept}</div><div class="repo-admin">${admin}</div><div class="repo-files ${isNoFiles ? 'error-text' : ''}">${files || 0}</div><div class="repo-log ${logStyleClass}" style="${logBoldStyle}" title="${recentLog}">${recentLog}</div>
</div>
<div class="accordion-body">
<div class="detail-grid">
<div class="detail-section">
<h4>참여 인원 상세</h4>
<table class="data-table">
<thead><tr><th>이름</th><th>소속</th><th>권한</th></tr></thead>
<tbody><tr><td>${admin}</td><td>${dept}</td><td>관리자</td></tr></tbody>
</table>
</div>
<div class="detail-section">
<h4>최근 활동</h4>
<table class="data-table">
<thead><tr><th>유형</th><th>내용</th><th>일시</th></tr></thead>
<tbody><tr><td><span class="badge">로그</span></td><td>동기화 완료</td><td>${logTime}</td></tr></tbody>
</table>
</div>
</div>
</div>
</div>`;
}
// --- 이벤트 핸들러 ---
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 `<tr class="modal-row" onclick="scrollToProject('${p.name}')"><td><strong>${p.name}</strong></td><td>${o ? o[1] : "-"}</td><td>${o ? o[2] : "-"}</td></tr>`;
}).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 = `<span class="spinner"></span> 크롤링 중단`;
logC.style.display = 'block'; logB.innerHTML = '<div style="color:#aaa; margin-bottom:10px;">>>> 엔진 초기화 중...</div>';
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 = `<span class="spinner"></span> 데이터 동기화 (크롤링)`; }
}
document.addEventListener('DOMContentLoaded', init);

245
js/dashboard_test.js Normal file
View File

@@ -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 = `
<input type="date" id="dateSelector"
min="${minDate}" max="${maxDate}" value="${defaultDate}"
onchange="loadDataByDate(this.value)"
style="margin-left:10px; border:1px solid var(--border-color); border-radius:4px; padding:2px 8px; font-weight:700; cursor:pointer; font-family:inherit; color:var(--text-main); font-size:14px;">
`;
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 = `
<div class="activity-card active" onclick="showActivityDetails('active')">
<div class="label">정상 (7일 이내) [TEST]</div><div class="count">${summary.active}</div>
</div>
<div class="activity-card warning" onclick="showActivityDetails('warning')">
<div class="label">주의 (14일 이내) [TEST]</div><div class="count">${summary.warning}</div>
</div>
<div class="activity-card stale" onclick="showActivityDetails('stale')">
<div class="label">방치 (14일 초과) [TEST]</div><div class="count">${summary.stale}</div>
</div>
<div class="activity-card unknown" onclick="showActivityDetails('unknown')">
<div class="label">데이터 없음 (파일 0개) [TEST]</div><div class="count">${summary.unknown}</div>
</div>`;
} 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 = `<div class="continent-header" onclick="toggleGroup(this)"><span>${continent}</span><span class="toggle-icon">▼</span></div><div class="continent-body">`;
Object.keys(grouped[continent]).sort().forEach(country => {
html += `<div class="country-group active"><div class="country-header" onclick="toggleGroup(this)"><span>${country}</span><span class="toggle-icon">▼</span></div><div class="country-body"><div class="accordion-container">
<div class="accordion-list-header"><div>프로젝트명</div><div>담당부서</div><div>담당자</div><div style="text-align:center;">파일수</div><div>최근로그</div></div>
${grouped[continent][country].sort((a, b) => a[0].localeCompare(b[0])).map(p => createProjectHtml(p)).join('')}</div></div></div>`;
});
html += `</div>`;
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 `
<div class="accordion-item ${statusClass}">
<div class="accordion-header" onclick="toggleAccordion(this)">
<div class="repo-title" title="${name}">${name}</div><div class="repo-dept">${dept}</div><div class="repo-admin">${admin}</div><div class="repo-files ${isNoFiles ? 'error-text' : ''}">${files || 0}</div><div class="repo-log ${logStyleClass}" style="${logBoldStyle}" title="${recentLog}">${recentLog}</div>
</div>
<div class="accordion-body">
<div class="detail-grid">
<div class="detail-section">
<h4>참여 인원 상세 (TEST)</h4>
<table class="data-table">
<thead><tr><th>이름</th><th>소속</th><th>권한</th></tr></thead>
<tbody><tr><td>${admin}</td><td>${dept}</td><td>관리자</td></tr></tbody>
</table>
</div>
<div class="detail-section">
<h4>최근 활동 (TEST)</h4>
<table class="data-table">
<thead><tr><th>유형</th><th>내용</th><th>일시</th></tr></thead>
<tbody><tr><td><span class="badge">로그</span></td><td>동기화 완료</td><td>${logTime}</td></tr></tbody>
</table>
</div>
</div>
</div>
</div>`;
}
// --- 이벤트 핸들러 ---
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 `<tr class="modal-row" onclick="scrollToProject('${p.name}')"><td><strong>${p.name}</strong></td><td>${o ? o[1] : "-"}</td><td>${o ? o[2] : "-"}</td></tr>`;
}).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 = `<span class="spinner"></span> 크롤링 중단`;
logC.style.display = 'block'; logB.innerHTML = '<div style="color:#aaa; margin-bottom:10px;">>>> 엔진 초기화 중...</div>';
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 = `<span class="spinner"></span> 데이터 동기화 (크롤링)`; }
}
document.addEventListener('DOMContentLoaded', init);

View File

@@ -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 => `
<tr class="inquiry-row" onclick="toggleAccordion(${item.id})">
<td title="${item.no}">${item.no}</td>
<td style="text-align:center;">
${item.image_url ? `<img src="${item.image_url}" class="img-thumbnail" alt="thumbnail">` : '<span class="no-img">없음</span>'}
</td>
<td title="${item.pm_type}">${item.pm_type}</td>
<td title="${item.browser || 'Chrome'}">${item.browser || 'Chrome'}</td>
<td title="${item.category}">${item.category}</td>
<td title="${item.project_nm}">${item.project_nm}</td>
<td class="content-preview" title="${item.content}">${item.content}</td>
<td title="${item.author}">${item.author}</td>
<td title="${item.reg_date}">${item.reg_date}</td>
<td class="content-preview" title="${item.reply || ''}" style="color: #1e5149; font-weight: 500;">${item.reply || '-'}</td>
<td><span class="status-badge ${Utils.getStatusClass(item.status)}">${item.status}</span></td>
</tr>
<tr id="detail-${item.id}" class="detail-row">
<td colspan="11">
<div class="detail-container">
<button class="btn-close-accordion" onclick="toggleAccordion(${item.id})">접기</button>
<div class="detail-content-wrapper">
<div class="detail-meta-grid">
<div><span class="detail-label">작성자:</span> ${item.author}</div>
<div><span class="detail-label">등록일:</span> ${item.reg_date}</div>
<div><span class="detail-label">시스템:</span> ${item.pm_type}</div>
<div><span class="detail-label">환경:</span> ${item.browser || 'Chrome'} / ${item.device || 'PC'}</div>
</div>
<div class="detail-q-section">
<h4 style="margin-top:0; margin-bottom:10px; color:#1e5149;">[질문 내용]</h4>
<div style="line-height:1.6; white-space: pre-wrap;">${item.content}</div>
</div>
${item.image_url ? `
<div class="detail-image-section" id="img-section-${item.id}">
<div class="image-section-header" onclick="toggleImageSection(${item.id})">
<h4>
<span>🖼️</span> [첨부 이미지]
<span style="font-size:11px; color:#888; font-weight:normal;">(클릭 시 크게 보기)</span>
</h4>
<span class="toggle-icon">▼</span>
</div>
<div class="image-section-content collapsed" id="img-content-${item.id}">
<img src="${item.image_url}" class="preview-img" alt="Inquiry Image" style="cursor: pointer;" onclick="event.stopPropagation(); openImageModal(this.src)">
</div>
</div>
` : ''}
<div class="detail-a-section">
<h4 style="margin-top:0; margin-bottom:10px; color:#1e5149;">[조치 및 답변]</h4>
<div id="reply-form-${item.id}" class="reply-edit-form readonly">
<textarea id="reply-text-${item.id}" disabled placeholder="답변 내용이 없습니다.">${item.reply || ''}</textarea>
<div style="display:flex; justify-content: space-between; align-items: center;">
<div style="display:flex; gap:15px; align-items:center;">
<div class="filter-group" style="flex-direction:row; align-items:center; gap:8px;">
<label style="margin:0;">처리상태:</label>
<select id="reply-status-${item.id}" disabled style="padding:5px 10px;">
<option value="완료" ${item.status === '완료' ? 'selected' : ''}>완료</option>
<option value="작업 중" ${item.status === '작업 중' ? 'selected' : ''}>작업 중</option>
<option value="확인 중" ${item.status === '확인 중' ? 'selected' : ''}>확인 중</option>
<option value="개발예정" ${item.status === '개발예정' ? 'selected' : ''}>개발예정</option>
<option value="미확인" ${item.status === '미확인' ? 'selected' : ''}>미확인</option>
</select>
</div>
<div class="filter-group" style="flex-direction:row; align-items:center; gap:8px;">
<label style="margin:0;">처리자:</label>
<input type="text" id="reply-handler-${item.id}" disabled value="${item.handler || ''}" placeholder="이름 입력" style="padding:5px 10px; width:100px;">
</div>
</div>
<div style="display:flex; gap:8px;">
<button class="btn-edit sync-btn" onclick="enableEdit(${item.id})" style="background:#1e5149; color:#fff; border:none;">${item.reply ? '수정하기' : '답변작성'}</button>
<button class="btn-save sync-btn" onclick="saveReply(${item.id})" style="background:#1e5149; color:#fff; border:none;">저장</button>
<button class="btn-delete sync-btn" onclick="deleteReply(${item.id})" style="background:#f44336; color:#fff; border:none;">삭제</button>
<button class="btn-cancel sync-btn" onclick="cancelEdit(${item.id})" style="background:#666; color:#fff; border:none;">취소</button>
</div>
</div>
${item.handled_date ? `<div style="margin-top:10px; font-size:12px; color:#888; text-align:right;">최종 수정일: ${item.handled_date}</div>` : ''}
</div>
</div>
</div>
</div>
</td>
</tr>
`).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 => `
<tr class="inquiry-row" onclick="toggleAccordion(${item.id})">
<td title="${item.no}">${item.no}</td>
<td style="text-align:center;">
${item.image_url ? `<img src="${item.image_url}" class="img-thumbnail" alt="thumbnail">` : '<span class="no-img">없음</span>'}
</td>
<td title="${item.pm_type}">${item.pm_type}</td>
<td title="${item.browser || 'Chrome'}">${item.browser || 'Chrome'}</td>
<td title="${item.category}">${item.category}</td>
<td title="${item.project_nm}">${item.project_nm}</td>
<td class="content-preview" title="${item.content}">${item.content}</td>
<td title="${item.author}">${item.author}</td>
<td title="${item.reg_date}">${item.reg_date}</td>
<td class="content-preview" title="${item.reply || ''}" style="color: #1e5149; font-weight: 500;">${item.reply || '-'}</td>
<td><span class="status-badge ${Utils.getStatusClass(item.status)}">${item.status}</span></td>
</tr>
<tr id="detail-${item.id}" class="detail-row">
<td colspan="11">
<div class="detail-container">
<button class="btn-close-accordion" onclick="toggleAccordion(${item.id})">접기</button>
<div class="detail-content-wrapper">
<div class="detail-meta-grid">
<div><span class="detail-label">작성자:</span> ${item.author}</div>
<div><span class="detail-label">등록일:</span> ${item.reg_date}</div>
<div><span class="detail-label">시스템:</span> ${item.pm_type}</div>
<div><span class="detail-label">환경:</span> ${item.browser || 'Chrome'} / ${item.device || 'PC'}</div>
</div>
<div class="detail-q-section">
<h4 style="margin-top:0; margin-bottom:10px; color:#1e5149;">[질문 내용]</h4>
<div style="line-height:1.6; white-space: pre-wrap;">${item.content}</div>
</div>
${item.image_url ? `
<div class="detail-image-section" id="img-section-${item.id}">
<div class="image-section-header" onclick="toggleImageSection(${item.id})">
<h4>
<span>🖼️</span> [첨부 이미지]
<span style="font-size:11px; color:#888; font-weight:normal;">(클릭 시 크게 보기)</span>
</h4>
<span class="toggle-icon">▼</span>
</div>
<div class="image-section-content collapsed" id="img-content-${item.id}">
<img src="${item.image_url}" class="preview-img" alt="Inquiry Image" style="cursor: pointer;" onclick="event.stopPropagation(); openImageModal(this.src)">
</div>
</div>
` : ''}
<div class="detail-a-section">
<h4 style="margin-top:0; margin-bottom:10px; color:#1e5149;">[조치 및 답변]</h4>
<div id="reply-form-${item.id}" class="reply-edit-form readonly">
<textarea id="reply-text-${item.id}" disabled placeholder="답변 내용이 없습니다.">${item.reply || ''}</textarea>
<div style="display:flex; justify-content: space-between; align-items: center;">
<div style="display:flex; gap:15px; align-items:center;">
<div class="filter-group" style="flex-direction:row; align-items:center; gap:8px;">
<label style="margin:0;">처리상태:</label>
<select id="reply-status-${item.id}" disabled style="padding:5px 10px;">
<option value="완료" ${item.status === '완료' ? 'selected' : ''}>완료</option>
<option value="작업 중" ${item.status === '작업 중' ? 'selected' : ''}>작업 중</option>
<option value="확인 중" ${item.status === '확인 중' ? 'selected' : ''}>확인 중</option>
<option value="개발예정" ${item.status === '개발예정' ? 'selected' : ''}>개발예정</option>
<option value="미확인" ${item.status === '미확인' ? 'selected' : ''}>미확인</option>
</select>
</div>
<div class="filter-group" style="flex-direction:row; align-items:center; gap:8px;">
<label style="margin:0;">처리자:</label>
<input type="text" id="reply-handler-${item.id}" disabled value="${item.handler || ''}" placeholder="이름 입력" style="padding:5px 10px; width:100px;">
</div>
</div>
<div style="display:flex; gap:8px;">
<button class="btn-edit sync-btn" onclick="enableEdit(${item.id})" style="background:#1e5149; color:#fff; border:none;">${item.reply ? '수정하기' : '답변작성'}</button>
<button class="btn-save sync-btn" onclick="saveReply(${item.id})" style="background:#1e5149; color:#fff; border:none;">저장</button>
<button class="btn-delete sync-btn" onclick="deleteReply(${item.id})" style="background:#f44336; color:#fff; border:none;">삭제</button>
<button class="btn-cancel sync-btn" onclick="cancelEdit(${item.id})" style="background:#666; color:#fff; border:none;">취소</button>
</div>
</div>
${item.handled_date ? `<div style="margin-top:10px; font-size:12px; color:#888; text-align:right;">최종 수정일: ${item.handled_date}</div>` : ''}
</div>
</div>
</div>
</div>
</td>
</tr>
`).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);

View File

@@ -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 = `
<div class="attachment-item" onclick="showPreview(${index}, event)" style="position:relative;">
<span class="file-icon" style="pointer-events:none;">📄</span>
<div class="file-details" style="pointer-events:none;">
<div class="file-name" title="${file.name}">${file.name}</div>
<div class="file-size">${file.size}</div>
</div>
<div class="btn-group" onclick="event.stopPropagation()" style="position:relative; z-index:2;">
<span id="recommend-${index}" class="ai-recommend path-display ${modeClass}" onclick="openPathModal(${index}, event)">${pathText}</span>
${isAiActive ? `<button class="btn-upload btn-ai" onclick="startAnalysis(${index}, event)">AI 분석</button>` : ''}
<button class="btn-upload btn-normal" onclick="confirmUpload(${index}, event)">파일업로드</button>
</div>
</div>
<div id="log-area-${index}" class="file-log-area"><div id="log-content-${index}"></div></div>
`;
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 = `<div class="ai-status-msg">
<span class="ai-loading-spinner"></span>
AI가 문서를 정밀 분석 중입니다...
</div>`;
}
try {
const res = await fetch(`${API.ANALYZE_FILE}?filename=${encodeURIComponent(file.name)}`);
const result = await res.json();
if (result.error) {
if (logContent) logContent.innerHTML = `<div style="color:var(--error-color);">오류: ${result.error}</div>`;
return;
}
// 분석 결과 저장 및 UI 갱신
currentFiles[index].analysis = result.final_result;
currentFiles[index].analysis.isManual = false;
if (logContent) {
logContent.innerHTML = `
<div class="ai-analysis-result">
<div style="font-weight:700; color:var(--primary-lv-6); margin-bottom:4px;">✨ AI 분석 완료</div>
<div style="font-size:11px; color:var(--text-sub); line-height:1.4;">${result.final_result.reason}</div>
</div>
`;
}
renderFiles();
} catch (e) {
console.error("AI Analysis failed:", e);
if (logContent) logContent.innerHTML = `<div style="color:var(--error-color);">분석 실패: 네트워크 오류가 발생했습니다.</div>`;
}
}
// --- 미리보기 제어 ---
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 = `<iframe src="${fileUrl}#page=1" style="width:100%; height:100%; border:none;"></iframe>`;
} else {
previewContainer.innerHTML = `<div class="flex-column flex-center" style="height:100%; padding:20px; text-align:center;"><img src="/sample.png" style="max-width:80%; max-height:60%; margin-bottom:20px;"><div style="font-weight:700; color:var(--primary-color);">${file.name}</div></div>`;
}
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) => `
<div class="mail-item ${mail.active ? 'active' : ''}" onclick="selectMailItem(this, ${idx})">
<input type="checkbox" class="mail-item-checkbox" onclick="event.stopPropagation()" onchange="updateBulkActionBar()">
<div class="mail-item-content">
<div class="flex-between" style="margin-bottom:6px;">
<span style="font-weight:700; font-size:14px; color:${mail.active ? 'var(--primary-color)' : 'var(--text-main)'};">${mail.person}</span>
<div class="mail-item-info"><span class="mail-date">${mail.time}</span></div>
</div>
<div style="font-weight:600; font-size:12px; margin-bottom:4px; color:#2d3748;">${mail.title}</div>
<div class="text-truncate" style="-webkit-line-clamp:2; display:-webkit-box; -webkit-box-orient:vertical; white-space:normal; font-size:11px; color:var(--text-sub);">${mail.summary}</div>
</div>
</div>
`).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, '<br>') + "<br><br>본 내용은 샘플 데이터입니다.";
}
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 => `<option value="${tab}">${tab}</option>`).join('');
updateCategories();
ModalManager.open('pathModal');
}
}
function updateCategories() {
const tab = document.getElementById('tabSelect').value;
document.getElementById('categorySelect').innerHTML = Object.keys(HIERARCHY[tab]).map(cat => `<option value="${cat}">${cat}</option>`).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 => `<option value="${sub}">${sub}</option>`).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) => `
<tr>
<td><strong>${c.name}</strong></td><td>${c.dept}</td><td>${c.email}</td><td>${c.phone}</td>
<td style="text-align:right;">
<button class="_button-xsmall" onclick="editContact(${idx})">수정</button>
<button class="_button-xsmall" style="color:var(--error-color); border-color:#feb2b2; background:#fff5f5;" onclick="deleteContact(${idx})">삭제</button>
</td>
</tr>`).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 = `
<div class="attachment-item" onclick="showPreview(${index}, event)" style="position:relative;">
<span class="file-icon" style="pointer-events:none;">📄</span>
<div class="file-details" style="pointer-events:none;">
<div class="file-name" title="${file.name}">${file.name}</div>
<div class="file-size">${file.size}</div>
</div>
<div class="btn-group" onclick="event.stopPropagation()" style="position:relative; z-index:2;">
<span id="recommend-${index}" class="ai-recommend path-display ${modeClass}" onclick="openPathModal(${index}, event)">${pathText}</span>
${isAiActive ? `<button class="btn-upload btn-ai" onclick="startAnalysis(${index}, event)">AI 분석</button>` : ''}
<button class="btn-upload btn-normal" onclick="confirmUpload(${index}, event)">파일업로드</button>
</div>
</div>
<div id="log-area-${index}" class="file-log-area"><div id="log-content-${index}"></div></div>
`;
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 = `<div class="ai-status-msg">
<span class="ai-loading-spinner"></span>
AI가 문서를 정밀 분석 중입니다...
</div>`;
}
try {
const res = await fetch(`${API.ANALYZE_FILE}?filename=${encodeURIComponent(file.name)}`);
const result = await res.json();
if (result.error) {
if (logContent) logContent.innerHTML = `<div style="color:var(--error-color);">오류: ${result.error}</div>`;
return;
}
// 분석 결과 저장 및 UI 갱신
currentFiles[index].analysis = result.final_result;
currentFiles[index].analysis.isManual = false;
if (logContent) {
logContent.innerHTML = `
<div class="ai-analysis-result">
<div style="font-weight:700; color:var(--primary-lv-6); margin-bottom:4px;">✨ AI 분석 완료</div>
<div style="font-size:11px; color:var(--text-sub); line-height:1.4;">${result.final_result.reason}</div>
</div>
`;
}
renderFiles();
} catch (e) {
console.error("AI Analysis failed:", e);
if (logContent) logContent.innerHTML = `<div style="color:var(--error-color);">분석 실패: 네트워크 오류가 발생했습니다.</div>`;
}
}
// --- 미리보기 제어 ---
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 = `<iframe src="${fileUrl}#page=1" style="width:100%; height:100%; border:none;"></iframe>`;
} else {
previewContainer.innerHTML = `<div class="flex-column flex-center" style="height:100%; padding:20px; text-align:center;"><img src="/sample.png" style="max-width:80%; max-height:60%; margin-bottom:20px;"><div style="font-weight:700; color:var(--primary-color);">${file.name}</div></div>`;
}
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) => `
<div class="mail-item ${mail.active ? 'active' : ''}" onclick="selectMailItem(this, ${idx})">
<input type="checkbox" class="mail-item-checkbox" onclick="event.stopPropagation()" onchange="updateBulkActionBar()">
<div class="mail-item-content">
<div class="flex-between" style="margin-bottom:6px;">
<span style="font-weight:700; font-size:14px; color:${mail.active ? 'var(--primary-color)' : 'var(--text-main)'};">${mail.person}</span>
<div class="mail-item-info"><span class="mail-date">${mail.time}</span></div>
</div>
<div style="font-weight:600; font-size:12px; margin-bottom:4px; color:#2d3748;">${mail.title}</div>
<div class="text-truncate" style="-webkit-line-clamp:2; display:-webkit-box; -webkit-box-orient:vertical; white-space:normal; font-size:11px; color:var(--text-sub);">${mail.summary}</div>
</div>
</div>
`).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, '<br>') + "<br><br>본 내용은 샘플 데이터입니다.";
}
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 => `<option value="${tab}">${tab}</option>`).join('');
updateCategories();
ModalManager.open('pathModal');
}
}
function updateCategories() {
const tab = document.getElementById('tabSelect').value;
document.getElementById('categorySelect').innerHTML = Object.keys(HIERARCHY[tab]).map(cat => `<option value="${cat}">${cat}</option>`).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 => `<option value="${sub}">${sub}</option>`).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) => `
<tr>
<td><strong>${c.name}</strong></td><td>${c.dept}</td><td>${c.email}</td><td>${c.phone}</td>
<td style="text-align:right;">
<button class="_button-xsmall" onclick="editContact(${idx})">수정</button>
<button class="_button-xsmall" style="color:var(--error-color); border-color:#feb2b2; background:#fff5f5;" onclick="deleteContact(${idx})">삭제</button>
</td>
</tr>`).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');
});

42
log_analysis_result.txt Normal file
View File

@@ -0,0 +1,42 @@
[Raw Log Samples]
- 2026.03.30, 폴더 삭제
- 2025.12.07, 파일 업로드
- 2025.12.01, 파일 다운로드
- 2025.11.26, 파일 다운로드
- 2025.11.24, 파일 이름 변경
- 2025.11.20, 부관리자 권한 추가
- 2025.11.19, 보안참여자 권한 추가
- 2025.11.03, 부관리자 권한 추가
- 2025.10.17, 일반참여자 권한 추가
- 2025.10.14, 참관자 권한 추가
- 2025.09.25, 일반참여자 권한 추가
- 2025.09.24, 부관리자 권한 추가
- 2025.09.23, 부관리자 권한 추가
- 2026.01.29, 폴더 삭제
- 2025.12.12, 폴더 자동 삭제
- 2025.11.26, 폴더 이름 변경
- 2025.11.21, 참관자 권한 추가
- 2025.10.30, 폴더 삭제
- 2025.09.25, 부관리자 권한 추가
- 2026.03.25, PDF 변환
[Log Patterns Frequency]
(46) [DATE], 파일 업로드
(35) [DATE], 파일 다운로드
(28) [DATE], PDF 변환
(21) [DATE], 폴더 삭제
(12) [DATE], 폴더 자동 삭제
(12) [DATE], 폴더 이름 변경
(11) [DATE], 부관리자 권한 추가
(11) [DATE], 휴지통으로 이동
(9) [DATE], 파일 이름 변경
(9) [DATE], 새 폴더 생성
(6) [DATE], 보안참여자 권한 추가
(6) [DATE], AI요약
(5) [DATE], 일반참여자 권한 추가
(4) [DATE], 참관자 권한 추가
(3) [DATE], 부관리자 권한 삭제
(2) [DATE], 참관자 권한 삭제
(1) [DATE], 폴더 권한 설정
(1) [DATE], 일반참여자 권한 삭제
(1) [DATE], 첨부파일 추가

63
log_scorer.py Normal file
View File

@@ -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)}")

124
migrate_to_docker.py Normal file
View File

@@ -0,0 +1,124 @@
import pymysql
import os
import sys
def migrate():
# Source DB (Windows Host) connection details
# We can use the IP from current DB_HOST or fallback to 172.26.208.1
src_host = os.getenv('DB_HOST', '172.26.208.1')
src_user = os.getenv('DB_USER', 'root')
src_password = os.getenv('DB_PASSWORD', '45278434')
# Target DB (Docker container) connection details
# Inside container, 'db' refers to the MariaDB service.
# Note: we connect to target's internal port 3306.
tgt_host = 'db'
tgt_user = 'root'
tgt_password = '45278434'
databases = ['PM_proto', 'PM_proto_test']
print(f"Starting migration from Source ({src_host}:3306) to Target ({tgt_host}:3306)...")
try:
# Establish connection to source
src_conn = pymysql.connect(
host=src_host,
user=src_user,
password=src_password,
charset='utf8mb4'
)
print("Connected to Source Database successfully.")
except Exception as e:
print(f"Failed to connect to Source Database: {e}")
sys.exit(1)
try:
# Establish connection to target
tgt_conn = pymysql.connect(
host=tgt_host,
user=tgt_user,
password=tgt_password,
charset='utf8mb4'
)
print("Connected to Target Database successfully.")
except Exception as e:
print(f"Failed to connect to Target Database: {e}")
src_conn.close()
sys.exit(1)
try:
with src_conn.cursor() as src_cur, tgt_conn.cursor() as tgt_cur:
# Disable foreign key checks to prevent dependency order issues during creation
tgt_cur.execute("SET FOREIGN_KEY_CHECKS = 0")
for db_name in databases:
print(f"\n--- Migrating Database: {db_name} ---")
# Check if database exists in source
src_cur.execute(f"SHOW DATABASES LIKE '{db_name}'")
if not src_cur.fetchone():
print(f"Database {db_name} does not exist in source. Skipping.")
continue
# Create database on target
tgt_cur.execute(f"CREATE DATABASE IF NOT EXISTS `{db_name}` CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci")
print(f"Created database {db_name} on target (or already exists).")
# Switch to database
src_cur.execute(f"USE `{db_name}`")
tgt_cur.execute(f"USE `{db_name}`")
# Get list of tables
src_cur.execute("SHOW TABLES")
tables = [r[0] for r in src_cur.fetchall()]
print(f"Tables to migrate: {tables}")
for table in tables:
print(f" Migrating table: {table}...")
# Get CREATE TABLE statement
src_cur.execute(f"SHOW CREATE TABLE `{table}`")
create_stmt = src_cur.fetchone()[1]
# Replace MySQL 8.0 specific collation with MariaDB compatible collation
create_stmt = create_stmt.replace('utf8mb4_0900_ai_ci', 'utf8mb4_general_ci')
# Drop table on target if exists
tgt_cur.execute(f"DROP TABLE IF EXISTS `{table}`")
# Create table on target
tgt_cur.execute(create_stmt)
# Get data from source
src_cur.execute(f"SELECT * FROM `{table}`")
rows = src_cur.fetchall()
if not rows:
print(f" Table {table} is empty.")
continue
# Get columns list
src_cur.execute(f"DESCRIBE `{table}`")
cols = [r[0] for r in src_cur.fetchall()]
# Insert data into target
col_names = ", ".join([f"`{c}`" for c in cols])
placeholders = ", ".join(["%s"] * len(cols))
insert_stmt = f"INSERT INTO `{table}` ({col_names}) VALUES ({placeholders})"
tgt_cur.executemany(insert_stmt, rows)
tgt_conn.commit()
print(f" Successfully migrated {len(rows)} rows into {table}.")
# Re-enable foreign key checks after completion
with tgt_conn.cursor() as tgt_cur:
tgt_cur.execute("SET FOREIGN_KEY_CHECKS = 1")
print("\nMigration completed successfully!")
except Exception as e:
print(f"Error during migration: {e}")
tgt_conn.rollback()
finally:
src_conn.close()
tgt_conn.close()
if __name__ == "__main__":
migrate()

View File

@@ -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)

View File

@@ -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}

View File

@@ -1,5 +1,12 @@
fastapi==0.110.0
uvicorn==0.29.0
playwright==1.42.0
python-dotenv==1.0.1
pypdf==4.1.0
fastapi==0.110.0
uvicorn==0.29.0
playwright==1.42.0
python-dotenv==1.0.1
pypdf==4.1.0
pymysql
jinja2
pytesseract
pdf2image
numpy
pandas
openpyxl

View File

@@ -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

369
server.py
View File

@@ -1,182 +1,187 @@
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"
if os.name == 'posix': # Linux (Docker)
DEFAULT_TESSDATA = "/usr/share/tesseract-ocr/4.00/tessdata"
else: # Windows
DEFAULT_TESSDATA = r"C:\Users\User\AppData\Local\Programs\Tesseract-OCR\tessdata"
TESSDATA_PREFIX = os.getenv("TESSDATA_PREFIX", DEFAULT_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")

190
server_test.py Normal file
View File

@@ -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)

View File

@@ -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)
"""

View File

@@ -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; }

View File

@@ -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); } }

View File

@@ -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); }

View File

@@ -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); }

View File

@@ -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;
}

View File

@@ -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');

View File

@@ -1,139 +1,139 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>데이터 분석 - Project Master Sabermetrics</title>
<link rel="stylesheet" href="style/common.css">
<link rel="stylesheet" href="style/analysis.css">
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<script src="https://cdn.jsdelivr.net/npm/chartjs-plugin-datalabels@2"></script>
</head>
<body>
<nav class="topbar">
<div class="topbar-header">
<a href="/">
<h2>Project Master Test</h2>
</a>
</div>
<ul class="nav-list">
<li class="nav-item" onclick="location.href='/dashboard'">대시보드</li>
<li class="nav-item" onclick="location.href='/inquiries'">문의사항</li>
<li class="nav-item" onclick="location.href='/mailTest'">메일관리</li>
<li class="nav-item active" onclick="location.href='/analysis'">분석</li>
</ul>
</nav>
<main class="analysis-content">
<header class="analysis-header">
<div class="title-group">
<div class="ai-badge">AI Sabermetrics</div>
<h2>시스템 운영 자산 가치 분석</h2>
<p>수집된 활동 로그 및 자산 데이터를 기반으로 한 통계적 활력 지표 (Beta)</p>
</div>
<div class="analysis-actions">
<button class="btn btn-primary" onclick="location.reload()">데이터 갱신</button>
</div>
</header>
<!-- 상단 정보 영역 -->
<div class="top-info-grid">
<section class="dl-model-info">
<div class="card-header">
<h4><i class="ai-icon">AI</i> Hybrid Prediction Engine</h4>
</div>
<div class="card-body">
<div class="model-desc-vertical">
<div class="model-item-vertical">
<span class="model-tag">분석 모델</span>
<p>최근 9회차 시계열의 Velocity 및 변화율 분석</p>
</div>
<div class="model-item-vertical">
<span class="model-tag">가중치 로직</span>
<p>활동 시 '선형 유지', 정체 시 '지수 감쇄' 동적 적용</p>
</div>
</div>
</div>
</section>
<section class="soi-deep-dive">
<div class="card-header">
<h4><i class="info-icon">i</i> AI 위험 적응형 모델 (AAS) 기반 지표 정의</h4>
</div>
<div class="card-body">
<div class="soi-info-columns">
<div class="soi-info-column">
<h6>1. 자산 가치 변동 추적</h6>
<p>규모를 감지하여, 대형 프로젝트 정체 시 데이터 가치 하락 속도를 <strong>가속(Acceleration)</strong>시킵니다.</p>
</div>
<div class="soi-info-column">
<h6>2. 활동 시계열 관성 분석</h6>
<p>최근 활동의 연속성을 분석하여, 단기 정체 시에도 과거의 <strong>운영 모멘텀</strong>을 반영하여 지수를 보정합니다.</p>
</div>
<div class="soi-info-column">
<h6>3. 동적 가치 계수</h6>
<p>프로젝트마다 <strong>개별화된 감쇄 곡선</strong>을 생성하여 현장에 가장 밀착된 보존율을 제공합니다.</p>
</div>
</div>
</div>
</section>
</div>
<!-- 메인 분석 차트 영역 -->
<div class="analysis-charts-grid">
<div class="chart-container-box">
<h5>운영 활력 분포 (Activity Distribution)</h5>
<canvas id="statusChart"></canvas>
</div>
<div class="chart-container-box">
<h5>자산 가치 전략 매트릭스 (Strategic Analysis)</h5>
<canvas id="forecastChart"></canvas>
</div>
</div>
<!-- 리더보드 영역 -->
<div class="analysis-card timeline-analysis">
<div class="card-header">
<div style="display: flex; flex-direction: column; gap: 4px;">
<h4>Project Activity Vitality Leaderboard (AVI Status)</h4>
<p style="font-size: 11px; color: #888; margin: 0;">운영 표준(AVI 70%) 대비 운영 활력 및 VCI 기여 리더보드</p>
</div>
<div class="card-tools">
<span id="avg-system-info" style="font-size: 11px; color: #888;">* AVI (Activity Vitality Index)</span>
</div>
</div>
<div class="card-body">
<div class="d-war-guide">
<div class="guide-item active-low"><span>70%↑</span> 정상 운영</div>
<div class="guide-item warning-mid"><span>30~70%</span> 관리 주의</div>
<div class="guide-item danger-high"><span>10~30%</span> 위험 노출</div>
<div class="guide-item hazard-critical"><span>10%↓</span> 중단/방치</div>
</div>
<div id="p-war-table-container">
<!-- JS에 의해 동적으로 테이블 삽입 -->
</div>
</div>
</div>
</main>
<!-- 설명 모달 -->
<div id="analysisModal" class="modal-overlay" onclick="if(event.target===this) closeAnalysisModal()">
<div class="modal-content">
<div class="modal-header">
<h3 id="modalTitle">분석 상세</h3>
<span class="modal-close" onclick="closeAnalysisModal()">&times;</span>
</div>
<div class="modal-body" id="modalBody">
<!-- 내용 동적 삽입 -->
</div>
</div>
</div>
<script src="js/common.js"></script>
<script src="js/analysis.js"></script>
</body>
</html>
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>데이터 분석 - Project Master Sabermetrics</title>
<link rel="stylesheet" href="style/common.css">
<link rel="stylesheet" href="style/analysis.css">
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<script src="https://cdn.jsdelivr.net/npm/chartjs-plugin-datalabels@2"></script>
</head>
<body>
<nav class="topbar">
<div class="topbar-header">
<a href="/">
<h2>Project Master Test</h2>
</a>
</div>
<ul class="nav-list">
<li class="nav-item" onclick="location.href='/dashboard'">대시보드</li>
<li class="nav-item" onclick="location.href='/inquiries'">문의사항</li>
<li class="nav-item" onclick="location.href='/mailTest'">메일관리</li>
<li class="nav-item active" onclick="location.href='/analysis'">분석</li>
</ul>
</nav>
<main class="analysis-content">
<header class="analysis-header">
<div class="title-group">
<div class="ai-badge">AI Sabermetrics</div>
<h2>시스템 운영 자산 가치 분석</h2>
<p>수집된 활동 로그 및 자산 데이터를 기반으로 한 통계적 활력 지표 (Beta)</p>
</div>
<div class="analysis-actions">
<button class="btn btn-primary" onclick="location.reload()">데이터 갱신</button>
</div>
</header>
<!-- 상단 정보 영역 -->
<div class="top-info-grid">
<section class="dl-model-info">
<div class="card-header">
<h4><i class="ai-icon">AI</i> Hybrid Prediction Engine</h4>
</div>
<div class="card-body">
<div class="model-desc-vertical">
<div class="model-item-vertical">
<span class="model-tag">분석 모델</span>
<p>최근 9회차 시계열의 Velocity 및 변화율 분석</p>
</div>
<div class="model-item-vertical">
<span class="model-tag">가중치 로직</span>
<p>활동 시 '선형 유지', 정체 시 '지수 감쇄' 동적 적용</p>
</div>
</div>
</div>
</section>
<section class="soi-deep-dive">
<div class="card-header">
<h4><i class="info-icon">i</i> AI 위험 적응형 모델 (AAS) 기반 지표 정의</h4>
</div>
<div class="card-body">
<div class="soi-info-columns">
<div class="soi-info-column">
<h6>1. 자산 가치 변동 추적</h6>
<p>규모를 감지하여, 대형 프로젝트 정체 시 데이터 가치 하락 속도를 <strong>가속(Acceleration)</strong>시킵니다.</p>
</div>
<div class="soi-info-column">
<h6>2. 활동 시계열 관성 분석</h6>
<p>최근 활동의 연속성을 분석하여, 단기 정체 시에도 과거의 <strong>운영 모멘텀</strong>을 반영하여 지수를 보정합니다.</p>
</div>
<div class="soi-info-column">
<h6>3. 동적 가치 계수</h6>
<p>프로젝트마다 <strong>개별화된 감쇄 곡선</strong>을 생성하여 현장에 가장 밀착된 보존율을 제공합니다.</p>
</div>
</div>
</div>
</section>
</div>
<!-- 메인 분석 차트 영역 -->
<div class="analysis-charts-grid">
<div class="chart-container-box">
<h5>운영 활력 분포 (Activity Distribution)</h5>
<canvas id="statusChart"></canvas>
</div>
<div class="chart-container-box">
<h5>자산 가치 전략 매트릭스 (Strategic Analysis)</h5>
<canvas id="forecastChart"></canvas>
</div>
</div>
<!-- 리더보드 영역 -->
<div class="analysis-card timeline-analysis">
<div class="card-header">
<div style="display: flex; flex-direction: column; gap: 4px;">
<h4>Project Activity Vitality Leaderboard (AVI Status)</h4>
<p style="font-size: 11px; color: #888; margin: 0;">전체 포트폴리오 평균(0.0) 대비 운영 가치 기여(VCI) 리더보드</p>
</div>
<div class="card-tools">
<span id="avg-system-info" style="font-size: 11px; color: #888;">* AVI (Activity Vitality Index)</span>
</div>
</div>
<div class="card-body">
<div class="d-war-guide">
<div class="guide-item active-low"><span>70%↑</span> 정상 운영</div>
<div class="guide-item warning-mid"><span>30~70%</span> 관리 주의</div>
<div class="guide-item danger-high"><span>10~30%</span> 위험 노출</div>
<div class="guide-item hazard-critical"><span>10%↓</span> 중단/방치</div>
</div>
<div id="p-war-table-container">
<!-- JS에 의해 동적으로 테이블 삽입 -->
</div>
</div>
</div>
</main>
<!-- 설명 모달 -->
<div id="analysisModal" class="modal-overlay" onclick="if(event.target===this) closeAnalysisModal()">
<div class="modal-content">
<div class="modal-header">
<h3 id="modalTitle">분석 상세</h3>
<span class="modal-close" onclick="closeAnalysisModal()">&times;</span>
</div>
<div class="modal-body" id="modalBody">
<!-- 내용 동적 삽입 -->
</div>
</div>
</div>
<script src="js/common.js"></script>
<script src="js/analysis.js"></script>
</body>
</html>

View File

@@ -0,0 +1,139 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>데이터 분석 (테스트) - Project Master Sabermetrics</title>
<link rel="stylesheet" href="style/common.css">
<link rel="stylesheet" href="style/analysis.css">
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<script src="https://cdn.jsdelivr.net/npm/chartjs-plugin-datalabels@2"></script>
</head>
<body>
<nav class="topbar">
<div class="topbar-header">
<a href="/">
<h2>Project Master Test (TEST)</h2>
</a>
</div>
<ul class="nav-list">
<li class="nav-item" onclick="location.href='/dashboard_test'">대시보드(테스트)</li>
<li class="nav-item" onclick="location.href='/inquiries'">문의사항</li>
<li class="nav-item" onclick="location.href='/mailTest'">메일관리</li>
<li class="nav-item active" onclick="location.href='/analysis_test'">분석(테스트)</li>
</ul>
</nav>
<main class="analysis-content">
<header class="analysis-header">
<div class="title-group">
<div class="ai-badge" style="background: var(--ai-color);">AI Sabermetrics [TEST]</div>
<h2>시스템 운영 자산 가치 분석 (테스트 환경)</h2>
<p>테스트용 데이터베이스(PM_proto_test)를 기반으로 한 활력 지표 분석입니다.</p>
</div>
<div class="analysis-actions">
<button class="btn btn-primary" onclick="location.reload()">데이터 갱신</button>
</div>
</header>
<!-- 상단 정보 영역 -->
<div class="top-info-grid">
<section class="dl-model-info">
<div class="card-header">
<h4><i class="ai-icon">AI</i> Hybrid Prediction Engine (TEST)</h4>
</div>
<div class="card-body">
<div class="model-desc-vertical">
<div class="model-item-vertical">
<span class="model-tag">분석 모델</span>
<p>최근 9회차 시계열의 Velocity 및 변화율 분석</p>
</div>
<div class="model-item-vertical">
<span class="model-tag">가중치 로직</span>
<p>활동 시 '선형 유지', 정체 시 '지수 감쇄' 동적 적용</p>
</div>
</div>
</div>
</section>
<section class="soi-deep-dive">
<div class="card-header">
<h4><i class="info-icon">i</i> AI 위험 적응형 모델 (AAS) 기반 지표 정의</h4>
</div>
<div class="card-body">
<div class="soi-info-columns">
<div class="soi-info-column">
<h6>1. 자산 가치 변동 추적</h6>
<p>규모를 감지하여, 대형 프로젝트 정체 시 데이터 가치 하락 속도를 <strong>가속(Acceleration)</strong>시킵니다.</p>
</div>
<div class="soi-info-column">
<h6>2. 활동 시계열 관성 분석</h6>
<p>최근 활동의 연속성을 분석하여, 단기 정체 시에도 과거의 <strong>운영 모멘텀</strong>을 반영하여 지수를 보정합니다.</p>
</div>
<div class="soi-info-column">
<h6>3. 동적 가치 계수</h6>
<p>프로젝트마다 <strong>개별화된 감쇄 곡선</strong>을 생성하여 현장에 가장 밀착된 보존율을 제공합니다.</p>
</div>
</div>
</div>
</section>
</div>
<!-- 메인 분석 차트 영역 -->
<div class="analysis-charts-grid">
<div class="chart-container-box">
<h5>운영 활력 분포 (Activity Distribution)</h5>
<canvas id="statusChart"></canvas>
</div>
<div class="chart-container-box">
<h5>자산 가치 전략 매트릭스 (Strategic Analysis)</h5>
<canvas id="forecastChart"></canvas>
</div>
</div>
<!-- 리더보드 영역 -->
<div class="analysis-card timeline-analysis">
<div class="card-header">
<div style="display: flex; flex-direction: column; gap: 4px;">
<h4>Project Activity Vitality Leaderboard (AVI Status) [TEST]</h4>
<p style="font-size: 11px; color: #888; margin: 0;">전체 포트폴리오 평균(0.0) 대비 운영 가치 기여(VCI) 리더보드</p>
</div>
<div class="card-tools">
<span id="avg-system-info" style="font-size: 11px; color: #888;">* AVI (Activity Vitality Index)</span>
</div>
</div>
<div class="card-body">
<div class="d-war-guide">
<div class="guide-item active-low"><span>70%↑</span> 정상 운영</div>
<div class="guide-item warning-mid"><span>30~70%</span> 관리 주의</div>
<div class="guide-item danger-high"><span>10~30%</span> 위험 노출</div>
<div class="guide-item hazard-critical"><span>10%↓</span> 중단/방치</div>
</div>
<div id="p-war-table-container">
<!-- JS에 의해 동적으로 테이블 삽입 -->
</div>
</div>
</div>
</main>
<!-- 설명 모달 -->
<div id="analysisModal" class="modal-overlay" onclick="if(event.target===this) closeAnalysisModal()">
<div class="modal-content">
<div class="modal-header">
<h3 id="modalTitle">분석 상세 (TEST)</h3>
<span class="modal-close" onclick="closeAnalysisModal()">&times;</span>
</div>
<div class="modal-body" id="modalBody">
<!-- 내용 동적 삽입 -->
</div>
</div>
</div>
<script src="js/common.js"></script>
<script src="js/analysis_test.js"></script>
</body>
</html>

View File

@@ -1,118 +1,118 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Project Master Overseas 관리자</title>
<link rel="stylesheet" as="style" crossorigin
href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/static/pretendard.min.css" />
<link rel="stylesheet" href="style/common.css">
<link rel="stylesheet" href="style/dashboard.css">
</head>
<body>
<nav class="topbar">
<div class="topbar-header">
<a href="/">
<h2>Project Master Test</h2>
</a>
</div>
<ul class="nav-list">
<li class="nav-item active" onclick="location.href='/dashboard'">대시보드</li>
<li class="nav-item" onclick="location.href='/inquiries'">문의사항</li>
<li class="nav-item" onclick="location.href='/mailTest'">메일관리</li>
<li class="nav-item" onclick="location.href='/analysis'">분석</li>
</ul>
</nav>
<main class="main-content">
<header>
<h2>프로젝트 현황</h2>
<div class="header-actions" style="display: flex; align-items: center; gap: 15px;">
<div class="base-date-info">기준날짜: <strong id="baseDate">-</strong></div>
<button id="syncBtn" class="sync-btn" onclick="syncData()">
<span class="spinner"></span>
데이터 동기화
</button>
<div class="admin-info">접속자: <strong>이태훈[전체관리자]</strong></div>
</div>
</header>
<!-- 프로젝트 활성도 대시보드 (전체 너비 래퍼) -->
<div class="activity-dashboard-wrapper">
<div id="activityDashboard" class="activity-dashboard">
<!-- JS에서 동적 삽입 -->
</div>
</div>
<!-- 실시간 로그 콘솔 (본문 내부로 복구) -->
<div id="logConsole" class="log-console" style="display:none;">
<div class="log-console-header">실시간 수집 로그 [PM Overseas]</div>
<div id="logBody"></div>
</div>
<div id="projectAccordion">
<!-- Multi-level Accordion items will be generated here -->
</div>
</main>
<!-- 모달 레이어 (공통 규격 적용) -->
<div id="authModal" class="modal-overlay">
<div class="modal-content" style="max-width: 440px; padding: 40px; text-align: center;" onclick="event.stopPropagation()">
<div class="auth-header" style="margin-bottom: 32px;">
<i class="fas fa-lock" style="font-size: 32px; color: var(--primary-color); margin-bottom: 16px; display: block;"></i>
<h3 style="font-size: 20px; font-weight: 800; color: #111; margin-bottom: 8px;">크롤링 권한 인증</h3>
<p style="font-size: 13px; color: var(--text-sub);">시스템 동기화를 위해 관리자 계정으로 로그인하세요.</p>
</div>
<div class="auth-body" style="display: flex; flex-direction: column; gap: 20px; text-align: left; margin-bottom: 32px;">
<div class="input-group">
<label>관리자 아이디</label>
<input type="text" id="authId" placeholder="아이디를 입력하세요">
</div>
<div class="input-group">
<label>비밀번호</label>
<input type="password" id="authPw" placeholder="비밀번호를 입력하세요"
onkeyup="if(event.key==='Enter') submitAuth()">
</div>
<div id="authErrorMessage" class="error-text" style="display:none; color: var(--error-color); font-size: 12px; font-weight: 600; text-align: center; margin-top: -10px;">크롤링을 할 수 없습니다.</div>
</div>
<div class="auth-footer" style="display: grid; grid-template-columns: 1fr 1.5fr; gap: 12px;">
<button class="btn btn-secondary" style="height: 48px;" onclick="ModalManager.close('authModal')">취소</button>
<button class="btn btn-primary" style="height: 48px;" onclick="submitAuth()">인증 및 실행</button>
</div>
</div>
</div>
<div id="activityDetailModal" class="modal-overlay" onclick="ModalManager.close('activityDetailModal')">
<div class="modal-content" style="max-width: 600px; padding: 0; overflow: hidden;" onclick="event.stopPropagation()">
<div class="modal-header" style="padding: 20px; margin-bottom: 0;">
<h3 id="modalTitle">상세 목록</h3>
<span class="modal-close" onclick="ModalManager.close('activityDetailModal')">&times;</span>
</div>
<div class="modal-body" style="padding: 20px; max-height: 70vh; overflow-y: auto;">
<table class="data-table">
<thead>
<tr>
<th>프로젝트명</th>
<th>담당부서</th>
<th>담당자</th>
</tr>
</thead>
<tbody id="modalTableBody">
<!-- JS에서 동적 삽입 -->
</tbody>
</table>
</div>
<div style="padding: 16px 20px; border-top: 1px solid var(--border-color); text-align: right; background: #fdfdfd;">
<button class="btn btn-secondary" onclick="ModalManager.close('activityDetailModal')">닫기</button>
</div>
</div>
</div>
<script src="js/common.js"></script>
<script src="js/dashboard.js"></script>
</body>
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Project Master Overseas 관리자</title>
<link rel="stylesheet" as="style" crossorigin
href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/static/pretendard.min.css" />
<link rel="stylesheet" href="style/common.css">
<link rel="stylesheet" href="style/dashboard.css">
</head>
<body>
<nav class="topbar">
<div class="topbar-header">
<a href="/">
<h2>Project Master Test</h2>
</a>
</div>
<ul class="nav-list">
<li class="nav-item active" onclick="location.href='/dashboard'">대시보드</li>
<li class="nav-item" onclick="location.href='/inquiries'">문의사항</li>
<li class="nav-item" onclick="location.href='/mailTest'">메일관리</li>
<li class="nav-item" onclick="location.href='/analysis'">분석</li>
</ul>
</nav>
<main class="main-content">
<header>
<h2>프로젝트 현황</h2>
<div class="header-actions" style="display: flex; align-items: center; gap: 15px;">
<div class="base-date-info">기준날짜: <strong id="baseDate">-</strong></div>
<button id="syncBtn" class="sync-btn" onclick="syncData()">
<span class="spinner"></span>
데이터 동기화
</button>
<div class="admin-info">접속자: <strong>이태훈[전체관리자]</strong></div>
</div>
</header>
<!-- 프로젝트 활성도 대시보드 (전체 너비 래퍼) -->
<div class="activity-dashboard-wrapper">
<div id="activityDashboard" class="activity-dashboard">
<!-- JS에서 동적 삽입 -->
</div>
</div>
<!-- 실시간 로그 콘솔 (본문 내부로 복구) -->
<div id="logConsole" class="log-console" style="display:none;">
<div class="log-console-header">실시간 수집 로그 [PM Overseas]</div>
<div id="logBody"></div>
</div>
<div id="projectAccordion">
<!-- Multi-level Accordion items will be generated here -->
</div>
</main>
<!-- 모달 레이어 (공통 규격 적용) -->
<div id="authModal" class="modal-overlay">
<div class="modal-content" style="max-width: 440px; padding: 40px; text-align: center;" onclick="event.stopPropagation()">
<div class="auth-header" style="margin-bottom: 32px;">
<i class="fas fa-lock" style="font-size: 32px; color: var(--primary-color); margin-bottom: 16px; display: block;"></i>
<h3 style="font-size: 20px; font-weight: 800; color: #111; margin-bottom: 8px;">크롤링 권한 인증</h3>
<p style="font-size: 13px; color: var(--text-sub);">시스템 동기화를 위해 관리자 계정으로 로그인하세요.</p>
</div>
<div class="auth-body" style="display: flex; flex-direction: column; gap: 20px; text-align: left; margin-bottom: 32px;">
<div class="input-group">
<label>관리자 아이디</label>
<input type="text" id="authId" placeholder="아이디를 입력하세요">
</div>
<div class="input-group">
<label>비밀번호</label>
<input type="password" id="authPw" placeholder="비밀번호를 입력하세요"
onkeyup="if(event.key==='Enter') submitAuth()">
</div>
<div id="authErrorMessage" class="error-text" style="display:none; color: var(--error-color); font-size: 12px; font-weight: 600; text-align: center; margin-top: -10px;">크롤링을 할 수 없습니다.</div>
</div>
<div class="auth-footer" style="display: grid; grid-template-columns: 1fr 1.5fr; gap: 12px;">
<button class="btn btn-secondary" style="height: 48px;" onclick="ModalManager.close('authModal')">취소</button>
<button class="btn btn-primary" style="height: 48px;" onclick="submitAuth()">인증 및 실행</button>
</div>
</div>
</div>
<div id="activityDetailModal" class="modal-overlay" onclick="ModalManager.close('activityDetailModal')">
<div class="modal-content" style="max-width: 600px; padding: 0; overflow: hidden;" onclick="event.stopPropagation()">
<div class="modal-header" style="padding: 20px; margin-bottom: 0;">
<h3 id="modalTitle">상세 목록</h3>
<span class="modal-close" onclick="ModalManager.close('activityDetailModal')">&times;</span>
</div>
<div class="modal-body" style="padding: 20px; max-height: 70vh; overflow-y: auto;">
<table class="data-table">
<thead>
<tr>
<th>프로젝트명</th>
<th>담당부서</th>
<th>담당자</th>
</tr>
</thead>
<tbody id="modalTableBody">
<!-- JS에서 동적 삽입 -->
</tbody>
</table>
</div>
<div style="padding: 16px 20px; border-top: 1px solid var(--border-color); text-align: right; background: #fdfdfd;">
<button class="btn btn-secondary" onclick="ModalManager.close('activityDetailModal')">닫기</button>
</div>
</div>
</div>
<script src="js/common.js"></script>
<script src="js/dashboard.js"></script>
</body>
</html>

View File

@@ -0,0 +1,118 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Project Master Overseas 관리자 (테스트)</title>
<link rel="stylesheet" as="style" crossorigin
href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/static/pretendard.min.css" />
<link rel="stylesheet" href="style/common.css">
<link rel="stylesheet" href="style/dashboard.css">
</head>
<body>
<nav class="topbar">
<div class="topbar-header">
<a href="/">
<h2>Project Master Test (TEST)</h2>
</a>
</div>
<ul class="nav-list">
<li class="nav-item active" onclick="location.href='/dashboard_test'">대시보드(테스트)</li>
<li class="nav-item" onclick="location.href='/inquiries'">문의사항</li>
<li class="nav-item" onclick="location.href='/mailTest'">메일관리</li>
<li class="nav-item" onclick="location.href='/analysis_test'">분석(테스트)</li>
</ul>
</nav>
<main class="main-content">
<header>
<h2>프로젝트 현황 (테스트 환경)</h2>
<div class="header-actions" style="display: flex; align-items: center; gap: 15px;">
<div class="base-date-info">기준날짜: <strong id="baseDate">-</strong></div>
<button id="syncBtn" class="sync-btn" onclick="syncData()">
<span class="spinner"></span>
데이터 동기화 (TEST)
</button>
<div class="admin-info">접속자: <strong>이태훈[테스트관리자]</strong></div>
</div>
</header>
<!-- 프로젝트 활성도 대시보드 (전체 너비 래퍼) -->
<div class="activity-dashboard-wrapper">
<div id="activityDashboard" class="activity-dashboard">
<!-- JS에서 동적 삽입 -->
</div>
</div>
<!-- 실시간 로그 콘솔 (본문 내부로 복구) -->
<div id="logConsole" class="log-console" style="display:none;">
<div class="log-console-header">실시간 수집 로그 [PM Overseas - TEST]</div>
<div id="logBody"></div>
</div>
<div id="projectAccordion">
<!-- Multi-level Accordion items will be generated here -->
</div>
</main>
<!-- 모달 레이어 (공통 규격 적용) -->
<div id="authModal" class="modal-overlay">
<div class="modal-content" style="max-width: 440px; padding: 40px; text-align: center;" onclick="event.stopPropagation()">
<div class="auth-header" style="margin-bottom: 32px;">
<i class="fas fa-lock" style="font-size: 32px; color: var(--primary-color); margin-bottom: 16px; display: block;"></i>
<h3 style="font-size: 20px; font-weight: 800; color: #111; margin-bottom: 8px;">크롤링 권한 인증 (TEST)</h3>
<p style="font-size: 13px; color: var(--text-sub);">테스트 DB 동기화를 위해 계정으로 로그인하세요.</p>
</div>
<div class="auth-body" style="display: flex; flex-direction: column; gap: 20px; text-align: left; margin-bottom: 32px;">
<div class="input-group">
<label>관리자 아이디</label>
<input type="text" id="authId" placeholder="아이디를 입력하세요">
</div>
<div class="input-group">
<label>비밀번호</label>
<input type="password" id="authPw" placeholder="비밀번호를 입력하세요"
onkeyup="if(event.key==='Enter') submitAuth()">
</div>
<div id="authErrorMessage" class="error-text" style="display:none; color: var(--error-color); font-size: 12px; font-weight: 600; text-align: center; margin-top: -10px;">크롤링을 할 수 없습니다.</div>
</div>
<div class="auth-footer" style="display: grid; grid-template-columns: 1fr 1.5fr; gap: 12px;">
<button class="btn btn-secondary" style="height: 48px;" onclick="ModalManager.close('authModal')">취소</button>
<button class="btn btn-primary" style="height: 48px;" onclick="submitAuth()">인증 및 실행</button>
</div>
</div>
</div>
<div id="activityDetailModal" class="modal-overlay" onclick="ModalManager.close('activityDetailModal')">
<div class="modal-content" style="max-width: 600px; padding: 0; overflow: hidden;" onclick="event.stopPropagation()">
<div class="modal-header" style="padding: 20px; margin-bottom: 0;">
<h3 id="modalTitle">상세 목록 (TEST)</h3>
<span class="modal-close" onclick="ModalManager.close('activityDetailModal')">&times;</span>
</div>
<div class="modal-body" style="padding: 20px; max-height: 70vh; overflow-y: auto;">
<table class="data-table">
<thead>
<tr>
<th>프로젝트명</th>
<th>담당부서</th>
<th>담당자</th>
</tr>
</thead>
<tbody id="modalTableBody">
<!-- JS에서 동적 삽입 -->
</tbody>
</table>
</div>
<div style="padding: 16px 20px; border-top: 1px solid var(--border-color); text-align: right; background: #fdfdfd;">
<button class="btn btn-secondary" onclick="ModalManager.close('activityDetailModal')">닫기</button>
</div>
</div>
</div>
<script src="js/common.js"></script>
<script src="js/dashboard_test.js"></script>
</body>
</html>

View File

@@ -1,46 +1,58 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Project Master Portal</title>
<link rel="stylesheet" as="style" crossorigin
href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/static/pretendard.min.css" />
<link rel="stylesheet" href="style/common.css">
<link rel="stylesheet" href="style/dashboard.css">
</head>
<body>
<nav class="topbar">
<div class="topbar-header">
<a href="/"><h2>Project Master Test</h2></a>
</div>
</nav>
<div class="portal-container">
<div class="portal-header">
<h1>Project Master 테스트</h1>
<p>원하시는 서비스에 접속하려면 아래 버튼을 클릭하세요.</p>
</div>
<div class="button-grid">
<a href="/dashboard" class="portal-card">
<div class="icon">📊</div>
<h2>관리자 페이지 테스트</h2>
<p>관리자 페이지 테스트 입니다.</p>
</a>
<a href="/mailTest" class="portal-card">
<div class="icon">✉️</div>
<h2>메일 테스트</h2>
<p>메일 기능 테스트 페이지입니다.</p>
</a>
</div>
</div>
<script src="js/common.js"></script>
</body>
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Project Master Portal</title>
<link rel="stylesheet" as="style" crossorigin
href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/static/pretendard.min.css" />
<link rel="stylesheet" href="style/common.css">
<link rel="stylesheet" href="style/dashboard.css">
</head>
<body>
<nav class="topbar">
<div class="topbar-header">
<a href="/"><h2>Project Master Test</h2></a>
</div>
</nav>
<div class="portal-container">
<div class="portal-header">
<h1>Project Master 테스트</h1>
<p>원하시는 서비스에 접속하려면 아래 버튼을 클릭하세요.</p>
</div>
<div class="button-grid">
<a href="/dashboard" class="portal-card">
<div class="icon">📊</div>
<h3>대시보드</h3>
<p>시스템 운영 현황 및 핵심 지표 요약</p>
</a>
<a href="/inquiries" class="portal-card">
<div class="icon">📝</div>
<h3>문의사항</h3>
<p>프로젝트 관련 문의 및 기술 지원 관리</p>
</a>
<a href="/mailTest" class="portal-card">
<div class="icon">📧</div>
<h3>메일관리</h3>
<p>수집 메일 분석 및 첨부파일 분류 현황</p>
</a>
<a href="/analysis" class="portal-card">
<div class="icon">📈</div>
<h3>분석 (Sabermetrics)</h3>
<p>AI 기반 운영 활력 및 자산 가치 정밀 분석</p>
</a>
</div>
</div>
<script src="js/common.js"></script>
</body>
</html>

View File

@@ -1,194 +1,194 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>문의사항 관리 - Project Master</title>
<link rel="stylesheet" as="style" crossorigin
href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/static/pretendard.min.css" />
<link rel="stylesheet" href="style/common.css">
<link rel="stylesheet" href="style/dashboard.css">
<link rel="stylesheet" href="style/inquiries.css">
</head>
<body>
<nav class="topbar">
<div class="topbar-header">
<a href="/">
<h2>Project Master Test</h2>
</a>
</div>
<ul class="nav-list">
<li class="nav-item" onclick="location.href='/dashboard'">대시보드</li>
<li class="nav-item active" onclick="location.href='/inquiries'">문의사항</li>
<li class="nav-item" onclick="location.href='/mailTest'">메일관리</li>
<li class="nav-item" onclick="location.href='/analysis'">분석</li>
</ul>
</nav>
<main class="inquiry-board">
<div id="stickyHeader" class="board-sticky-header">
<div class="board-header">
<div>
<h2>문의사항</h2>
<p style="font-size: 13px; color: #666; margin-top: 4px;">시스템 운영 관련 불편사항 및 개선 요청 관리</p>
</div>
<div class="header-stats" id="headerStats">
<div class="stat-item total">
<span class="stat-label">전체</span>
<span class="stat-value" id="countTotal">0</span>
</div>
<div class="stat-item complete">
<span class="stat-label">완료</span>
<span class="stat-value" id="countComplete">0</span>
</div>
<div class="stat-item working">
<span class="stat-label">작업 중</span>
<span class="stat-value" id="countWorking">0</span>
</div>
<div class="stat-item checking">
<span class="stat-label">확인 중</span>
<span class="stat-value" id="countChecking">0</span>
</div>
<div class="stat-item pending">
<span class="stat-label">개발예정</span>
<span class="stat-value" id="countPending">0</span>
</div>
<div class="stat-item unconfirmed">
<span class="stat-label">미확인</span>
<span class="stat-value" id="countUnconfirmed">0</span>
</div>
</div>
</div>
<!-- 시트 상단 공지 영역 재현 (통합 박스) -->
<div class="notice-container">
<div style="margin-bottom: 12px; display: flex; gap: 20px;">
<div style="flex: 1;">
<h4
style="color: #d32f2f; margin-bottom: 5px; font-size: 14px; display: flex; align-items: center; gap: 6px;">
<span style="font-size: 16px;">📢</span> &lt;공지&gt;
</h4>
<ul style="font-size: 12px; line-height: 1.6; color: #444; padding-left: 20px; margin: 0;">
<li>파워포인트 파일에 읽기전용포함(제한)글꼴이 있는 경우 컨버팅이 되지 않습니다. 제한된 글꼴 제거 후 업로드 권장.</li>
<li>이미지는 '문의사항 이미지' 시트에 문의사항 번호와 함께 업로드 부탁드립니다.</li>
</ul>
</div>
<div style="flex: 2; border-left: 1px dashed #ddd; padding-left: 20px;">
<h4
style="color: #1976d2; margin-bottom: 5px; font-size: 14px; display: flex; align-items: center; gap: 6px;">
<span style="font-size: 16px;">📂</span> &lt;이전 문의사항 요약&gt;
</h4>
<div
style="display: grid; grid-template-columns: 1fr 1fr; gap: 10px; font-size: 11px; color: #666; line-height: 1.4;">
<div><strong>1. 폴더트리:</strong> 하위폴더 권한, 숨김 기능 등 (완료)</div>
<div><strong>2. 뷰어:</strong> 변환 오류, 특정 문서 깨짐 등 (완료)</div>
<div><strong>3. 업로드/다운로드:</strong> 대용량 전송 오류 등 (완료)</div>
<div><strong>4. 기타:</strong> 정렬, 화면 개선 등 (완료)</div>
</div>
</div>
</div>
</div>
<div class="filter-section">
<div class="filter-group">
<label>시스템(PM 종류)</label>
<select id="filterPmType" onchange="loadInquiries()">
<option value="">전체</option>
<option value="Overseas">Overseas</option>
<option value="기술개발센터">기술개발센터</option>
<option value="장헌">장헌</option>
</select>
</div>
<div class="filter-group">
<label>구분(카테고리)</label>
<select id="filterCategory" onchange="loadInquiries()">
<option value="">전체</option>
<option value="업로드">업로드</option>
<option value="다운로드">다운로드</option>
<option value="폴더트리">폴더트리</option>
<option value="뷰어">뷰어</option>
<option value="기타">기타</option>
</select>
</div>
<div class="filter-group">
<label>처리여부</label>
<select id="filterStatus" onchange="loadInquiries()">
<option value="">전체</option>
<option value="완료">완료</option>
<option value="작업 중">작업 중</option>
<option value="확인 중">확인 중</option>
<option value="개발예정">개발예정</option>
<option value="미확인">미확인</option>
</select>
</div>
<div class="filter-group">
<label>검색(내용/작성자/프로젝트)</label>
<input type="text" id="searchKeyword" placeholder="검색어 입력 후 Enter..."
onkeyup="if(event.key==='Enter') loadInquiries()" style="width: 250px;">
</div>
</div>
</div>
<table class="inquiry-table">
<thead>
<tr>
<th width="50" class="sortable" onclick="handleSort('no')">
<div class="header-content">No <span id="sort-no" class="sort-icon"></span></div>
</th>
<th width="80">이미지</th>
<th width="120" class="sortable" onclick="handleSort('pm_type')">
<div class="header-content">PM 종류 <span id="sort-pm_type" class="sort-icon"></span></div>
</th>
<th width="100" class="sortable" onclick="handleSort('browser')">
<div class="header-content">환경 <span id="sort-browser" class="sort-icon"></span></div>
</th>
<th width="150" class="sortable" onclick="handleSort('category')">
<div class="header-content">구분 <span id="sort-category" class="sort-icon"></span></div>
</th>
<th class="sortable" onclick="handleSort('project_nm')">
<div class="header-content">프로젝트 <span id="sort-project_nm" class="sort-icon"></span></div>
</th>
<th width="400">문의내용</th>
<th width="100" class="sortable" onclick="handleSort('author')">
<div class="header-content">작성자 <span id="sort-author" class="sort-icon"></span></div>
</th>
<th width="120" class="sortable" onclick="handleSort('reg_date')">
<div class="header-content">날짜 <span id="sort-reg_date" class="sort-icon"></span></div>
</th>
<th width="400">답변내용</th>
<th width="100" class="sortable" onclick="handleSort('status')">
<div class="header-content">상태 <span id="sort-status" class="sort-icon"></span></div>
</th>
</tr>
</thead>
<tbody id="inquiryList">
<!-- Data will be loaded here -->
</tbody>
</table>
</main>
<!-- 이미지 크게 보기 모달 (디자인 가이드 - 화이트 계열 및 우측 하단 닫기 적용) -->
<div id="imageModal" class="modal-overlay" onclick="ModalManager.close('imageModal')">
<div class="modal-content" style="max-width: 960px; width: 92%; padding: 0; overflow: hidden; border-radius: 12px; border: 1px solid #e2e8f0; background: #fff;" onclick="event.stopPropagation()">
<div class="modal-header" style="padding: 16px 24px; margin-bottom: 0; border-bottom: 1px solid #f1f5f9; background: #fff;">
<h3 style="color: #1e5149; font-weight: 700; font-size: 16px;">첨부 이미지 확대 보기</h3>
<span class="modal-close" onclick="ModalManager.close('imageModal')" style="font-size: 24px; color: #94a3b8;">&times;</span>
</div>
<div style="padding: 32px; background: #fff; display: flex; justify-content: center; align-items: center; min-height: 300px; max-height: 75vh; overflow: auto;">
<img id="modalImage" style="max-width: 100%; height: auto; border-radius: 8px; box-shadow: 0 10px 25px -5px rgba(0,0,0,0.1), 0 8px 10px -6px rgba(0,0,0,0.1);">
</div>
<div style="padding: 16px 24px; border-top: 1px solid #f1f5f9; text-align: right; background: #fff;">
<button class="_button-medium" style="background: #1e5149; color: #fff; border: none; padding: 10px 28px; border-radius: 8px; cursor: pointer; transition: background 0.2s;"
onmouseover="this.style.background='#163b36'" onmouseout="this.style.background='#1e5149'"
onclick="ModalManager.close('imageModal')">닫기</button>
</div>
</div>
</div>
<script src="js/common.js"></script>
<script src="js/inquiries.js"></script>
</body>
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>문의사항 관리 - Project Master</title>
<link rel="stylesheet" as="style" crossorigin
href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/static/pretendard.min.css" />
<link rel="stylesheet" href="style/common.css">
<link rel="stylesheet" href="style/dashboard.css">
<link rel="stylesheet" href="style/inquiries.css">
</head>
<body>
<nav class="topbar">
<div class="topbar-header">
<a href="/">
<h2>Project Master Test</h2>
</a>
</div>
<ul class="nav-list">
<li class="nav-item" onclick="location.href='/dashboard'">대시보드</li>
<li class="nav-item active" onclick="location.href='/inquiries'">문의사항</li>
<li class="nav-item" onclick="location.href='/mailTest'">메일관리</li>
<li class="nav-item" onclick="location.href='/analysis'">분석</li>
</ul>
</nav>
<main class="inquiry-board">
<div id="stickyHeader" class="board-sticky-header">
<div class="board-header">
<div>
<h2>문의사항</h2>
<p style="font-size: 13px; color: #666; margin-top: 4px;">시스템 운영 관련 불편사항 및 개선 요청 관리</p>
</div>
<div class="header-stats" id="headerStats">
<div class="stat-item total">
<span class="stat-label">전체</span>
<span class="stat-value" id="countTotal">0</span>
</div>
<div class="stat-item complete">
<span class="stat-label">완료</span>
<span class="stat-value" id="countComplete">0</span>
</div>
<div class="stat-item working">
<span class="stat-label">작업 중</span>
<span class="stat-value" id="countWorking">0</span>
</div>
<div class="stat-item checking">
<span class="stat-label">확인 중</span>
<span class="stat-value" id="countChecking">0</span>
</div>
<div class="stat-item pending">
<span class="stat-label">개발예정</span>
<span class="stat-value" id="countPending">0</span>
</div>
<div class="stat-item unconfirmed">
<span class="stat-label">미확인</span>
<span class="stat-value" id="countUnconfirmed">0</span>
</div>
</div>
</div>
<!-- 시트 상단 공지 영역 재현 (통합 박스) -->
<div class="notice-container">
<div style="margin-bottom: 12px; display: flex; gap: 20px;">
<div style="flex: 1;">
<h4
style="color: #d32f2f; margin-bottom: 5px; font-size: 14px; display: flex; align-items: center; gap: 6px;">
<span style="font-size: 16px;">📢</span> &lt;공지&gt;
</h4>
<ul style="font-size: 12px; line-height: 1.6; color: #444; padding-left: 20px; margin: 0;">
<li>파워포인트 파일에 읽기전용포함(제한)글꼴이 있는 경우 컨버팅이 되지 않습니다. 제한된 글꼴 제거 후 업로드 권장.</li>
<li>이미지는 '문의사항 이미지' 시트에 문의사항 번호와 함께 업로드 부탁드립니다.</li>
</ul>
</div>
<div style="flex: 2; border-left: 1px dashed #ddd; padding-left: 20px;">
<h4
style="color: #1976d2; margin-bottom: 5px; font-size: 14px; display: flex; align-items: center; gap: 6px;">
<span style="font-size: 16px;">📂</span> &lt;이전 문의사항 요약&gt;
</h4>
<div
style="display: grid; grid-template-columns: 1fr 1fr; gap: 10px; font-size: 11px; color: #666; line-height: 1.4;">
<div><strong>1. 폴더트리:</strong> 하위폴더 권한, 숨김 기능 등 (완료)</div>
<div><strong>2. 뷰어:</strong> 변환 오류, 특정 문서 깨짐 등 (완료)</div>
<div><strong>3. 업로드/다운로드:</strong> 대용량 전송 오류 등 (완료)</div>
<div><strong>4. 기타:</strong> 정렬, 화면 개선 등 (완료)</div>
</div>
</div>
</div>
</div>
<div class="filter-section">
<div class="filter-group">
<label>시스템(PM 종류)</label>
<select id="filterPmType" onchange="loadInquiries()">
<option value="">전체</option>
<option value="Overseas">Overseas</option>
<option value="기술개발센터">기술개발센터</option>
<option value="장헌">장헌</option>
</select>
</div>
<div class="filter-group">
<label>구분(카테고리)</label>
<select id="filterCategory" onchange="loadInquiries()">
<option value="">전체</option>
<option value="업로드">업로드</option>
<option value="다운로드">다운로드</option>
<option value="폴더트리">폴더트리</option>
<option value="뷰어">뷰어</option>
<option value="기타">기타</option>
</select>
</div>
<div class="filter-group">
<label>처리여부</label>
<select id="filterStatus" onchange="loadInquiries()">
<option value="">전체</option>
<option value="완료">완료</option>
<option value="작업 중">작업 중</option>
<option value="확인 중">확인 중</option>
<option value="개발예정">개발예정</option>
<option value="미확인">미확인</option>
</select>
</div>
<div class="filter-group">
<label>검색(내용/작성자/프로젝트)</label>
<input type="text" id="searchKeyword" placeholder="검색어 입력 후 Enter..."
onkeyup="if(event.key==='Enter') loadInquiries()" style="width: 250px;">
</div>
</div>
</div>
<table class="inquiry-table">
<thead>
<tr>
<th width="50" class="sortable" onclick="handleSort('no')">
<div class="header-content">No <span id="sort-no" class="sort-icon"></span></div>
</th>
<th width="80">이미지</th>
<th width="120" class="sortable" onclick="handleSort('pm_type')">
<div class="header-content">PM 종류 <span id="sort-pm_type" class="sort-icon"></span></div>
</th>
<th width="100" class="sortable" onclick="handleSort('browser')">
<div class="header-content">환경 <span id="sort-browser" class="sort-icon"></span></div>
</th>
<th width="150" class="sortable" onclick="handleSort('category')">
<div class="header-content">구분 <span id="sort-category" class="sort-icon"></span></div>
</th>
<th class="sortable" onclick="handleSort('project_nm')">
<div class="header-content">프로젝트 <span id="sort-project_nm" class="sort-icon"></span></div>
</th>
<th width="400">문의내용</th>
<th width="100" class="sortable" onclick="handleSort('author')">
<div class="header-content">작성자 <span id="sort-author" class="sort-icon"></span></div>
</th>
<th width="120" class="sortable" onclick="handleSort('reg_date')">
<div class="header-content">날짜 <span id="sort-reg_date" class="sort-icon"></span></div>
</th>
<th width="400">답변내용</th>
<th width="100" class="sortable" onclick="handleSort('status')">
<div class="header-content">상태 <span id="sort-status" class="sort-icon"></span></div>
</th>
</tr>
</thead>
<tbody id="inquiryList">
<!-- Data will be loaded here -->
</tbody>
</table>
</main>
<!-- 이미지 크게 보기 모달 (디자인 가이드 - 화이트 계열 및 우측 하단 닫기 적용) -->
<div id="imageModal" class="modal-overlay" onclick="ModalManager.close('imageModal')">
<div class="modal-content" style="max-width: 960px; width: 92%; padding: 0; overflow: hidden; border-radius: 12px; border: 1px solid #e2e8f0; background: #fff;" onclick="event.stopPropagation()">
<div class="modal-header" style="padding: 16px 24px; margin-bottom: 0; border-bottom: 1px solid #f1f5f9; background: #fff;">
<h3 style="color: #1e5149; font-weight: 700; font-size: 16px;">첨부 이미지 확대 보기</h3>
<span class="modal-close" onclick="ModalManager.close('imageModal')" style="font-size: 24px; color: #94a3b8;">&times;</span>
</div>
<div style="padding: 32px; background: #fff; display: flex; justify-content: center; align-items: center; min-height: 300px; max-height: 75vh; overflow: auto;">
<img id="modalImage" style="max-width: 100%; height: auto; border-radius: 8px; box-shadow: 0 10px 25px -5px rgba(0,0,0,0.1), 0 8px 10px -6px rgba(0,0,0,0.1);">
</div>
<div style="padding: 16px 24px; border-top: 1px solid #f1f5f9; text-align: right; background: #fff;">
<button class="_button-medium" style="background: #1e5149; color: #fff; border: none; padding: 10px 28px; border-radius: 8px; cursor: pointer; transition: background 0.2s;"
onmouseover="this.style.background='#163b36'" onmouseout="this.style.background='#1e5149'"
onclick="ModalManager.close('imageModal')">닫기</button>
</div>
</div>
</div>
<script src="js/common.js"></script>
<script src="js/inquiries.js"></script>
</body>
</html>

View File

@@ -1,144 +1,144 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Project Mail Manager</title>
<link rel="stylesheet" as="style" crossorigin
href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/static/pretendard.min.css" />
<link rel="stylesheet" href="style/common.css">
<link rel="stylesheet" href="style/mail.css">
</head>
<body>
{% include 'modals/path_selector.html' %}
<nav class="topbar">
<div class="topbar-header">
<a href="/">
<h2>Project Master Test</h2>
</a>
</div>
<ul class="nav-list">
<li class="nav-item" onclick="location.href='/dashboard'">대시보드</li>
<li class="nav-item" onclick="location.href='/inquiries'">문의사항</li>
<li class="nav-item active" onclick="location.href='/mailTest'">메일관리</li>
<li class="nav-item" onclick="location.href='/analysis'">분석</li>
</ul>
</nav>
<div class="mail-wrapper">
<!-- 메일 리스트 영역 (사이드바 삭제됨) -->
<section class="mail-list-area">
<div class="mail-tabs">
<div class="mail-tab active" onclick="switchMailTab(this, 'inbound')">📥 수신</div>
<div class="mail-tab" onclick="switchMailTab(this, 'outbound')">📤 발신</div>
<div class="mail-tab" onclick="switchMailTab(this, 'drafts')">📝 임시</div>
<div class="mail-tab" onclick="switchMailTab(this, 'deleted')">🗑️ 휴지통</div>
</div>
<div class="search-bar" style="display:flex; flex-direction:column; gap:8px;">
<input type="text" style="height: 32px; width:100%;" placeholder="제목, 내용, 기관 검색...">
<select style="height: 32px; width:100%; padding:4px; font-size:12px;">
<option>모든 상대기관</option>
<option>라오스 농림부</option>
<option>베트남 전력청</option>
</select>
<div class="flex-center" style="gap:4px; width:100%;">
<input type="date" id="startDate"
style="flex:1; height:32px; padding:4px; font-size:11px; border:1px solid var(--border-color); border-radius:4px;">
<span style="font-size:12px; color:var(--text-sub);">~</span>
<input type="date" id="endDate"
style="flex:1; height:32px; padding:4px; font-size:11px; border:1px solid var(--border-color); border-radius:4px;">
</div>
<div class="flex-center" style="gap:8px; margin-top:4px;">
<button class="btn-confirm" style="flex:1; height:32px; font-size:12px; background:var(--primary-color); color:#fff; display:flex; align-items:center; justify-content:center;" onclick="searchMails()">검색</button>
<button class="btn-confirm" style="flex:1; height:32px; font-size:12px; background:#fff; color:var(--text-sub); border:1px solid var(--border-color); display:flex; align-items:center; justify-content:center;" onclick="resetSearch()">초기화</button>
</div>
</div>
<!-- 선택 삭제 액션바 -->
<div id="mailBulkActions" class="mail-bulk-actions">
<div class="flex-center" style="gap:8px;">
<input type="checkbox" id="selectAllMails" onclick="toggleSelectAll(this)">
<span id="selectedCount">0개 선택됨</span>
</div>
<button class="_button-xsmall" style="background:#fff5f5; color:#e53e3e; border:1px solid #feb2b2;" onclick="deleteSelectedMails()">선택 삭제</button>
</div>
<div class="mail-items-container">
<!-- JavaScript (renderMailList)에서 렌더링됨 -->
</div>
<!-- 메일쓰기 및 주소록 버튼 하단 고정 -->
<div class="address-book-footer flex-center" style="gap:8px;">
<button class="btn-confirm"
style="background:var(--primary-color); color:#fff; font-size:12px; padding:8px; flex:1;"
onclick="alert('메일 쓰기 창을 엽니다.')">✍️ 메일쓰기</button>
<button class="btn-confirm"
style="background:#fff; color:var(--primary-color); border:1px solid var(--primary-color); font-size:12px; padding:8px; flex:1;"
onclick="openAddressBook()">📘 주소록</button>
</div>
</section>
<!-- 메일 본문 영역 -->
<section class="mail-content-area">
<div class="mail-content-header">
<h2 style="font-size:18px; color:var(--primary-color); margin-bottom:8px;">ITTC 교육센터 착공식 일정 협의 요청</h2>
<div style="font-size:12px; color:var(--text-sub);"><strong>보낸사람</strong> pany.s@lao.gov.la (라오스 농림부)
</div>
<div style="font-size:12px; color:var(--text-sub);"><strong>날짜</strong> 2026년 2월 26일 14:30</div>
</div>
<div class="mail-body">
안녕하세요. 이태훈 선임연구원님.<br><br>
라오스 ITTC 관개 교육센터 착공식과 관련하여 정부 측 인사의 일정을 반영한 최종 공문을 송부합니다.<br>
</div>
<div class="attachment-area">
<div class="flex-between" style="margin-bottom:12px;">
<div style="font-weight:700; font-size:13px;">첨부파일 리스트</div>
<div class="ai-toggle-wrap">
<span class="ai-label">AI 판단</span>
<label class="switch">
<input type="checkbox" id="aiToggle" onchange="renderFiles()">
<span class="slider"></span>
</label>
</div>
</div>
<div id="attachmentList"></div>
</div>
</section>
<!-- 우측 미리보기 영역 -->
<aside class="mail-preview-area" id="mailPreviewArea">
<!-- 토글 핸들 버튼 -->
<div class="preview-toggle-handle" onclick="togglePreviewAuto()">
<span id="previewToggleIcon"></span>
</div>
<div class="preview-header">
<div class="flex-center" style="gap:8px;">
<h3>미리보기</h3>
<span style="font-size:10px; color:var(--text-sub); font-weight:400;">최대 10페이지까지만 표시됩니다.</span>
</div>
<div class="flex-center" style="gap:12px;">
<button id="fullViewBtn" class="_button-xsmall"
style="background:var(--primary-lv-0); color:#111111; border:none; padding:4px 12px; height:24px; cursor:pointer; display:none;">전체보기</button>
</div>
</div>
<div class="a4-container" id="previewContainer">
<div class="preview-placeholder">파일을 클릭하면<br>미리보기가 표시됩니다.</div>
</div>
</aside>
</div>
{% include 'modals/address_book.html' %}
<script src="js/common.js"></script>
<script src="js/mail.js"></script>
</body>
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Project Mail Manager</title>
<link rel="stylesheet" as="style" crossorigin
href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/static/pretendard.min.css" />
<link rel="stylesheet" href="style/common.css">
<link rel="stylesheet" href="style/mail.css">
</head>
<body>
{% include 'modals/path_selector.html' %}
<nav class="topbar">
<div class="topbar-header">
<a href="/">
<h2>Project Master Test</h2>
</a>
</div>
<ul class="nav-list">
<li class="nav-item" onclick="location.href='/dashboard'">대시보드</li>
<li class="nav-item" onclick="location.href='/inquiries'">문의사항</li>
<li class="nav-item active" onclick="location.href='/mailTest'">메일관리</li>
<li class="nav-item" onclick="location.href='/analysis'">분석</li>
</ul>
</nav>
<div class="mail-wrapper">
<!-- 메일 리스트 영역 (사이드바 삭제됨) -->
<section class="mail-list-area">
<div class="mail-tabs">
<div class="mail-tab active" onclick="switchMailTab(this, 'inbound')">📥 수신</div>
<div class="mail-tab" onclick="switchMailTab(this, 'outbound')">📤 발신</div>
<div class="mail-tab" onclick="switchMailTab(this, 'drafts')">📝 임시</div>
<div class="mail-tab" onclick="switchMailTab(this, 'deleted')">🗑️ 휴지통</div>
</div>
<div class="search-bar" style="display:flex; flex-direction:column; gap:8px;">
<input type="text" style="height: 32px; width:100%;" placeholder="제목, 내용, 기관 검색...">
<select style="height: 32px; width:100%; padding:4px; font-size:12px;">
<option>모든 상대기관</option>
<option>라오스 농림부</option>
<option>베트남 전력청</option>
</select>
<div class="flex-center" style="gap:4px; width:100%;">
<input type="date" id="startDate"
style="flex:1; height:32px; padding:4px; font-size:11px; border:1px solid var(--border-color); border-radius:4px;">
<span style="font-size:12px; color:var(--text-sub);">~</span>
<input type="date" id="endDate"
style="flex:1; height:32px; padding:4px; font-size:11px; border:1px solid var(--border-color); border-radius:4px;">
</div>
<div class="flex-center" style="gap:8px; margin-top:4px;">
<button class="btn-confirm" style="flex:1; height:32px; font-size:12px; background:var(--primary-color); color:#fff; display:flex; align-items:center; justify-content:center;" onclick="searchMails()">검색</button>
<button class="btn-confirm" style="flex:1; height:32px; font-size:12px; background:#fff; color:var(--text-sub); border:1px solid var(--border-color); display:flex; align-items:center; justify-content:center;" onclick="resetSearch()">초기화</button>
</div>
</div>
<!-- 선택 삭제 액션바 -->
<div id="mailBulkActions" class="mail-bulk-actions">
<div class="flex-center" style="gap:8px;">
<input type="checkbox" id="selectAllMails" onclick="toggleSelectAll(this)">
<span id="selectedCount">0개 선택됨</span>
</div>
<button class="_button-xsmall" style="background:#fff5f5; color:#e53e3e; border:1px solid #feb2b2;" onclick="deleteSelectedMails()">선택 삭제</button>
</div>
<div class="mail-items-container">
<!-- JavaScript (renderMailList)에서 렌더링됨 -->
</div>
<!-- 메일쓰기 및 주소록 버튼 하단 고정 -->
<div class="address-book-footer flex-center" style="gap:8px;">
<button class="btn-confirm"
style="background:var(--primary-color); color:#fff; font-size:12px; padding:8px; flex:1;"
onclick="alert('메일 쓰기 창을 엽니다.')">✍️ 메일쓰기</button>
<button class="btn-confirm"
style="background:#fff; color:var(--primary-color); border:1px solid var(--primary-color); font-size:12px; padding:8px; flex:1;"
onclick="openAddressBook()">📘 주소록</button>
</div>
</section>
<!-- 메일 본문 영역 -->
<section class="mail-content-area">
<div class="mail-content-header">
<h2 style="font-size:18px; color:var(--primary-color); margin-bottom:8px;">ITTC 교육센터 착공식 일정 협의 요청</h2>
<div style="font-size:12px; color:var(--text-sub);"><strong>보낸사람</strong> pany.s@lao.gov.la (라오스 농림부)
</div>
<div style="font-size:12px; color:var(--text-sub);"><strong>날짜</strong> 2026년 2월 26일 14:30</div>
</div>
<div class="mail-body">
안녕하세요. 이태훈 선임연구원님.<br><br>
라오스 ITTC 관개 교육센터 착공식과 관련하여 정부 측 인사의 일정을 반영한 최종 공문을 송부합니다.<br>
</div>
<div class="attachment-area">
<div class="flex-between" style="margin-bottom:12px;">
<div style="font-weight:700; font-size:13px;">첨부파일 리스트</div>
<div class="ai-toggle-wrap">
<span class="ai-label">AI 판단</span>
<label class="switch">
<input type="checkbox" id="aiToggle" onchange="renderFiles()">
<span class="slider"></span>
</label>
</div>
</div>
<div id="attachmentList"></div>
</div>
</section>
<!-- 우측 미리보기 영역 -->
<aside class="mail-preview-area" id="mailPreviewArea">
<!-- 토글 핸들 버튼 -->
<div class="preview-toggle-handle" onclick="togglePreviewAuto()">
<span id="previewToggleIcon"></span>
</div>
<div class="preview-header">
<div class="flex-center" style="gap:8px;">
<h3>미리보기</h3>
<span style="font-size:10px; color:var(--text-sub); font-weight:400;">최대 10페이지까지만 표시됩니다.</span>
</div>
<div class="flex-center" style="gap:12px;">
<button id="fullViewBtn" class="_button-xsmall"
style="background:var(--primary-lv-0); color:#111111; border:none; padding:4px 12px; height:24px; cursor:pointer; display:none;">전체보기</button>
</div>
</div>
<div class="a4-container" id="previewContainer">
<div class="preview-placeholder">파일을 클릭하면<br>미리보기가 표시됩니다.</div>
</div>
</aside>
</div>
{% include 'modals/address_book.html' %}
<script src="js/common.js"></script>
<script src="js/mail.js"></script>
</body>
</html>

View File

@@ -1,62 +1,62 @@
<!-- 주소록 모달 (공통 규격 적용) -->
<div id="addressBookModal" class="modal-overlay" onclick="ModalManager.close('addressBookModal')">
<div class="modal-content" style="max-width: 850px;" onclick="event.stopPropagation()">
<div class="modal-header">
<h3>공사 관계자 주소록</h3>
<div class="flex-center" style="gap:10px;">
<button class="btn btn-primary" onclick="toggleAddContactForm()">+ 추가하기</button>
<span class="modal-close" onclick="ModalManager.close('addressBookModal')">&times;</span>
</div>
</div>
<!-- 주소록 추가 폼 (기본 숨김) -->
<div id="addContactForm"
style="display:none; background:var(--bg-muted); padding:20px; border-radius:12px; margin-bottom:20px; border:1px solid var(--border-color);">
<div style="display:grid; grid-template-columns: 1fr 1fr; gap:12px; margin-bottom:15px;">
<div class="input-group">
<label style="font-size:11px; margin-bottom:4px; display:block;">성명</label>
<input type="text" id="newContactName" placeholder="성명 입력" style="height:36px; padding:0 12px; border:1px solid #ddd; border-radius:6px; width:100%; font-size:13px;">
</div>
<div class="input-group">
<label style="font-size:11px; margin-bottom:4px; display:block;">소속/직위</label>
<input type="text" id="newContactDept" placeholder="소속/직위 입력" style="height:36px; padding:0 12px; border:1px solid #ddd; border-radius:6px; width:100%; font-size:13px;">
</div>
<div class="input-group">
<label style="font-size:11px; margin-bottom:4px; display:block;">이메일</label>
<input type="text" id="newContactEmail" placeholder="이메일 입력" style="height:36px; padding:0 12px; border:1px solid #ddd; border-radius:6px; width:100%; font-size:13px;">
</div>
<div class="input-group">
<label style="font-size:11px; margin-bottom:4px; display:block;">연락처</label>
<input type="text" id="newContactPhone" placeholder="연락처 입력" style="height:36px; padding:0 12px; border:1px solid #ddd; border-radius:6px; width:100%; font-size:13px;">
</div>
</div>
<div class="flex-center" style="gap:10px;">
<button class="btn btn-primary" style="flex:1;" onclick="addContact()">저장</button>
<button class="btn btn-secondary" style="flex:1;" onclick="toggleAddContactForm()">취소</button>
</div>
</div>
<div class="search-bar" style="background:#fff; padding:0 0 15px 0;">
<input type="text" placeholder="이름, 부서, 연락처 검색..." style="width:100%; height: 36px; padding:0 12px; border:1px solid var(--border-color); border-radius:6px;">
</div>
<div style="max-height: 400px; overflow-y: auto; border:1px solid var(--border-color); border-radius:8px;">
<table class="data-table">
<thead>
<tr>
<th>성명</th>
<th>소속/직위</th>
<th>이메일</th>
<th>연락처</th>
<th style="text-align:right; padding-right:15px;">관리</th>
</tr>
</thead>
<tbody id="addressBookBody">
<!-- 동적으로 렌더링됨 -->
</tbody>
</table>
</div>
<div style="margin-top:20px; text-align:right;">
<button class="btn btn-secondary" style="padding: 8px 32px;" onclick="ModalManager.close('addressBookModal')">닫기</button>
</div>
</div>
</div>
<!-- 주소록 모달 (공통 규격 적용) -->
<div id="addressBookModal" class="modal-overlay" onclick="ModalManager.close('addressBookModal')">
<div class="modal-content" style="max-width: 850px;" onclick="event.stopPropagation()">
<div class="modal-header">
<h3>공사 관계자 주소록</h3>
<div class="flex-center" style="gap:10px;">
<button class="btn btn-primary" onclick="toggleAddContactForm()">+ 추가하기</button>
<span class="modal-close" onclick="ModalManager.close('addressBookModal')">&times;</span>
</div>
</div>
<!-- 주소록 추가 폼 (기본 숨김) -->
<div id="addContactForm"
style="display:none; background:var(--bg-muted); padding:20px; border-radius:12px; margin-bottom:20px; border:1px solid var(--border-color);">
<div style="display:grid; grid-template-columns: 1fr 1fr; gap:12px; margin-bottom:15px;">
<div class="input-group">
<label style="font-size:11px; margin-bottom:4px; display:block;">성명</label>
<input type="text" id="newContactName" placeholder="성명 입력" style="height:36px; padding:0 12px; border:1px solid #ddd; border-radius:6px; width:100%; font-size:13px;">
</div>
<div class="input-group">
<label style="font-size:11px; margin-bottom:4px; display:block;">소속/직위</label>
<input type="text" id="newContactDept" placeholder="소속/직위 입력" style="height:36px; padding:0 12px; border:1px solid #ddd; border-radius:6px; width:100%; font-size:13px;">
</div>
<div class="input-group">
<label style="font-size:11px; margin-bottom:4px; display:block;">이메일</label>
<input type="text" id="newContactEmail" placeholder="이메일 입력" style="height:36px; padding:0 12px; border:1px solid #ddd; border-radius:6px; width:100%; font-size:13px;">
</div>
<div class="input-group">
<label style="font-size:11px; margin-bottom:4px; display:block;">연락처</label>
<input type="text" id="newContactPhone" placeholder="연락처 입력" style="height:36px; padding:0 12px; border:1px solid #ddd; border-radius:6px; width:100%; font-size:13px;">
</div>
</div>
<div class="flex-center" style="gap:10px;">
<button class="btn btn-primary" style="flex:1;" onclick="addContact()">저장</button>
<button class="btn btn-secondary" style="flex:1;" onclick="toggleAddContactForm()">취소</button>
</div>
</div>
<div class="search-bar" style="background:#fff; padding:0 0 15px 0;">
<input type="text" placeholder="이름, 부서, 연락처 검색..." style="width:100%; height: 36px; padding:0 12px; border:1px solid var(--border-color); border-radius:6px;">
</div>
<div style="max-height: 400px; overflow-y: auto; border:1px solid var(--border-color); border-radius:8px;">
<table class="data-table">
<thead>
<tr>
<th>성명</th>
<th>소속/직위</th>
<th>이메일</th>
<th>연락처</th>
<th style="text-align:right; padding-right:15px;">관리</th>
</tr>
</thead>
<tbody id="addressBookBody">
<!-- 동적으로 렌더링됨 -->
</tbody>
</table>
</div>
<div style="margin-top:20px; text-align:right;">
<button class="btn btn-secondary" style="padding: 8px 32px;" onclick="ModalManager.close('addressBookModal')">닫기</button>
</div>
</div>
</div>

View File

@@ -1,24 +1,24 @@
<!-- 경로 선택 모달 (공통 규격 적용) -->
<div id="pathModal" class="modal-overlay" onclick="ModalManager.close('pathModal')">
<div class="modal-content" style="max-width: 500px;" onclick="event.stopPropagation()">
<div class="modal-header">
<h3>파일 보관 경로 선택</h3>
<span class="modal-close" onclick="ModalManager.close('pathModal')">&times;</span>
</div>
<div style="display: flex; flex-direction: column; gap: 16px; margin-bottom: 24px;">
<div class="select-group" style="margin-bottom: 0;">
<label>탭 (Tab)</label>
<select id="tabSelect" class="modal-select" onchange="updateCategories()"></select>
</div>
<div class="select-group" style="margin-bottom: 0;">
<label>카테고리 (Category)</label>
<select id="categorySelect" class="modal-select" onchange="updateSubs()"></select>
</div>
<div class="select-group" style="margin-bottom: 0;">
<label>서브카테고리 (Sub-Category)</label>
<select id="subSelect" class="modal-select"></select>
</div>
</div>
<button class="btn btn-primary" style="width: 100%; height: 44px;" onclick="applyPathSelection()">경로 확정하기</button>
</div>
</div>
<!-- 경로 선택 모달 (공통 규격 적용) -->
<div id="pathModal" class="modal-overlay" onclick="ModalManager.close('pathModal')">
<div class="modal-content" style="max-width: 500px;" onclick="event.stopPropagation()">
<div class="modal-header">
<h3>파일 보관 경로 선택</h3>
<span class="modal-close" onclick="ModalManager.close('pathModal')">&times;</span>
</div>
<div style="display: flex; flex-direction: column; gap: 16px; margin-bottom: 24px;">
<div class="select-group" style="margin-bottom: 0;">
<label>탭 (Tab)</label>
<select id="tabSelect" class="modal-select" onchange="updateCategories()"></select>
</div>
<div class="select-group" style="margin-bottom: 0;">
<label>카테고리 (Category)</label>
<select id="categorySelect" class="modal-select" onchange="updateSubs()"></select>
</div>
<div class="select-group" style="margin-bottom: 0;">
<label>서브카테고리 (Sub-Category)</label>
<select id="subSelect" class="modal-select"></select>
</div>
</div>
<button class="btn btn-primary" style="width: 100%; height: 44px;" onclick="applyPathSelection()">경로 확정하기</button>
</div>
</div>

File diff suppressed because it is too large Load Diff

28
verify_swvw.py Normal file
View File

@@ -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()