feat: 리눅스 환경 파일 동기화, Dockerfile 및 docker-compose 추가, 캐시 무시 설정

This commit is contained in:
2026-06-22 10:06:19 +09:00
parent b864d615ea
commit 705246923b
59 changed files with 7653 additions and 5794 deletions

12
.env
View File

@@ -1,6 +1,6 @@
PM_USER_ID=b21364 PM_USER_ID=b21364
PM_PASSWORD=b21364!. PM_PASSWORD=b21364!.
DB_HOST=localhost DB_HOST=localhost
DB_USER=root DB_USER=root
DB_PASSWORD=45278434 DB_PASSWORD=45278434
DB_NAME=PM_proto DB_NAME=PM_proto

BIN
.gitignore vendored

Binary file not shown.

View File

@@ -1,81 +1,60 @@
# 📊 시스템 운영 자산 가치 분석 보고 (Sabermetrics Edition) # 📊 시스템 운영 자산 가치 분석 보고 (Sabermetrics Report)
본 보고서는 야구의 통계 분석 기법인 **세이버메트릭스(Sabermetrics)**를 프로젝트 관리 시스템에 이식하여, 단순 활동량 측정을 넘어 **'실질적 자산 가치'**와 **'미래 운영 위험'**을 정밀 분석한 결과입니다. 본 보고서는 프로젝트 관리 시스템 내에서 수집된 활동 로그 및 자산 데이터를 통계적/AI 기법으로 분석하여, 각 프로젝트의 운영 활력과 조직 기여도를 정량화한 지표를 정의합니다.
--- ---
## 1. 핵심 분석 지표 정의 (Core Metrics) ## 1. 운영 활력 지수 (AVI, Activity Vitality Index)
프로젝트가 현재 얼마나 건강하게 가동되고 있는지를 나타내는 **'디지털 자산 생존 지표'**입니다.
### 1.1 운영 활력 지수 (AVI, Activity Vitality Index)
프로젝트가 현재 얼마나 '살아서 숨 쉬고 있는가'를 나타내는 생존 지수입니다. ### 1.1 산출 공식
$$AVI = e^{-\lambda \times Stagnant\_Days} \times Quality \times 100$$
* **산출 공식**: $AVI = exp(-\lambda \times days) \times Quality \times ECV \times 100$
* **핵심 데이터**: ### 1.2 3대 핵심 변수 상세 설명
* **정체 일수(days)**: 마지막 유의미한 파일 업데이트 이후 경과 시간.
* **감쇄 계수($\lambda$)**: 기본 $0.04$에서 시작하여, 자산 규모(최대 $+0.04$)와 부서 정체율(최대 $+0.03$)을 동적으로 결합합니다. #### ① 지수 감쇄 모델 ($e^{-\lambda \times t}$) : "가치의 시한폭탄"
* **활동 품질(Quality)**: 파일 증분 활동($1.0$), 구조적 관리($0.7$), 단순 행정 로그($0.4$)로 차등 배점합니다. 자산은 관리하지 않으면 시간이 흐를수록 가치가 기하급수적으로 소멸한다는 **'정보 휘발성'** 원리를 반영합니다.
* **존재 신뢰도(ECV)**: 파일 수 $0$개($0.05$), $10$개 미만($0.4$) 등 유령 프로젝트에 패널티를 부여합니다. * **Stagnant Days (정체 일수)**: 마지막 유효 활동 로그 기록일로부터 오늘까지 경과된 날짜입니다.
* **의미**: 100%에 가까울수록 실시간 가동 상태이며, 0%가까울수록 데이터 노후화가 완료된 '사망' 상태를 뜻합니다. * **특징**: 정체 초기에는 점수가 빠르게 하락하다가, 시간이 지날수록 하락 폭이 둔화되며 0에 수렴합니다. 이는 관리가 중단된 직후의 정보 망실 위험이 가장 크다는 실무적 경험을 반영한 것입니다.
### 1.2 자산 가치 기여도 (VCI, Value Contribution Index) #### ② 위험 가속 계수 ($\lambda$) : "대형 자산의 높은 관리 비용"
시스템 전체의 운영 표준 대비, 해당 프로젝트가 기여하고 있는 가치의 상대적 하중을 측정합니다. 모든 프로젝트는 자산 규모에 따라 '늙어가는 속도'가 다릅니다.
* **공식**: $\lambda = 0.04 + \log_{10}(Files + 1) \times 0.008$
* **산출 공식**: $VCI = (AVI - 70.0) \times (\frac{Files}{200} + 0.5)$ * **비즈니스 로직**: 파일이 많은 대형 프로젝트일수록 관리 부재 시 조직에 미치는 타격이 큽니다. 따라서 대형 프로젝트일수록 $\lambda$ 값이 커지며, 소형 프로젝트보다 **훨씬 빠른 속도로 AVI가 하락**하도록 설계되었습니다. (대형 프로젝트는 더 자주 관리해야 점수가 유지됨)
* **핵심 로직**:
* **건강 기준선(70.0%)**: 시스템 자산 가치를 유지하기 위한 최소 마지노선(Replacement Level)입니다. #### ③ 활동 품질 가중치 ($Quality$) : "행정과 실무의 구분"
* **규모 가중치**: 파일 $200$개를 $1.0$ 가중치 기준으로 삼아, 대형 프로젝트일수록 시스템에 주는 충격을 기하급수적으로 반영합니다. 단순히 접속하거나 로그가 찍혔다고 해서 활력이 100% 회복되지 않습니다. AI가 로그 키워드를 분석하여 활동의 **'진정성'**을 평가합니다.
* **의미**: 양수(+)는 가치 창출, 음수(-)는 시스템 기회비용을 갉아먹는 '가치 파괴' 상태임을 나타냅니다. * **High (1.0)**: **성과물 중심 활동** (파일 업로드, 수정, 등록, 업데이트 등)
* **Medium (0.7)**: **구조적 유지 활동** (폴더 생성, 삭제, 이동 등)
### 1.3 업무 집중도 (Job Focus) * **Low (0.4)**: **단순 행정 활동** (권한 변경, 메일 확인, 참가자 추가 등)
단순 관리 행위를 제외하고, 실제 성과물(파일)을 생산하는 데 얼마나 몰입했는지를 판별합니다.
---
* **산출 공식**: $Job Focus = \frac{\text{최근 히스토리 중 실제 파일 변동 발생 횟수}}{\text{전체 데이터 수집 횟수}} \times 100$
* **의미**: 로그만 남기는 '보여주기식 활동'을 필터링하여 운영의 진정성을 확인합니다. ## 2. 자산 가치 기여도 (VCI, Value Contribution Index)
야구의 **WAR(Wins Above Replacement)** 개념을 도입하여, 전체 포트폴리오 평균 대비 개별 프로젝트가 조직 가치에 얼마나 기여하는지 산출합니다.
### 1.4 운영 일관성 지수 (OCI, Operational Consistency Index)
프로젝트 관리의 '리듬'과 '성실도'를 측정하는 지표입니다. ### 2.1 산출 공식
$$VCI = (Individual\_AVI - Portfolio\_Avg\_AVI) \times Asset\_Weight$$
* **산출 공식**: 최근 30일 데이터를 4개 주차로 분할하여 활동 여부 분석 (주차별 성실도 70% + 활동 밀도 30%) * **Asset Weight (파일 규모 가중치)**: $max(0.2, \frac{Individual\_Files}{Portfolio\_Avg\_Files})$
* **의미**: 특정 시점에 몰아치기식 작업을 하는 프로젝트보다, 매주 꾸준히 관리되는 프로젝트에 더 높은 신뢰 점수를 부여합니다.
### 2.2 지표의 의미: "평균(0.0)을 기준으로 한 상대 평가"
--- * **0.0 (평균)**: 조직 내 평균적인 관리 수준과 규모를 가진 표준 프로젝트.
* **(+) 점수**: 평균 이상의 활력으로 조직의 디지털 자산 가치를 증대시키는 프로젝트.
## 2. 등급 체계 및 관리 가이드 (Grade System) * **(-) 점수**: 평균 이하의 방치로 인해 조직에 잠재적 기회비용 손실을 입히는 리스크 프로젝트.
* **상대 가중치**: 조직의 평균 파일 수보다 큰 프로젝트가 방치될 때 마이너스 점수가 더 가파르게 하락하여 **'우선 관리 대상'**을 명확히 식별합니다.
### 2.1 VCI 등급 (프로젝트 위상)
| 등급 (Grade) | 점수 기준 | 운영 의미 및 관리 전략 | ---
| :--- | :--- | :--- |
| **Masterpiece** | +10.0 이상 | **최우량 자산**: 시스템 가치를 견인하는 핵심 프로젝트 | ## 3. 강력한 예외 처리 (Hard Rules)
| **Blue Chip** | +2.0 ~ +10.0 | **우량 자산**: 꾸준한 활력으로 가치를 창출하는 핵심군 |
| **Steady** | -2.0 ~ +2.0 | **안정 자산**: 표준 수준의 운영을 유지 중인 현상 유지군 | 데이터의 신뢰도를 확보하기 위해 다음과 같은 **'사망 판정'** 규칙이 적용됩니다.
| **Underperform** | -10.0 ~ -2.0 | **저성과 자산**: 규모 대비 활력이 부족하여 가치 하락 중인 그룹 | 1. **자동 삭제 패널티**: 최근 로그가 시스템에 의한 '폴더자동삭제'인 경우, AVI는 즉시 **0.1%**로 고정됩니다. (관리 포기 상태)
| **Liability** | -10.0 이하 | **고위험 자산**: 시스템 가치를 훼손 중인 방치 프로젝트. 즉시 조치 필요 | 2. **자산 부재 패널티 (ECV)**: 파일 개수가 0개인 경우 운영 일관성(OCI)은 **0.0점**이며, 파일 10개 미만은 최종 가중치에 **50% 패널티**를 적용하여 '껍데기 프로젝트'를 걸러냅니다.
### 2.2 운영 일관성 (OCI) 판정 ---
* **정기적 (80%↑)**: 주 단위의 정기적 관리가 완벽히 이뤄지는 최우량 관리 상태.
* **안정적 (50~80%)**: 간헐적 정체는 있으나 전반적인 관리 리듬을 유지하는 상태. ## 4. 발표 및 분석 가이드 (Executive Summary)
* **간헐적 (20~50%)**: 관리 활동이 불규칙하며, 필요에 의한 일회성 작업 중심인 상태.
* **불규칙 (20%↓)**: 장기 정체 중이거나 관리의 영속성을 확인하기 어려운 위험 상태. * **AVI가 낮은 프로젝트**: "데이터가 낡아가고 있으니 즉시 최신 성과물을 업데이트하십시오."
* **VCI가 음수(-)인 대형 프로젝트**: "조직에서 가장 중요한 자산임에도 불구하고 평균 이하로 방치되고 있습니다. **최우선 관리 대상**입니다."
--- * **OCI가 낮은 프로젝트**: "활동은 있으나 불규칙합니다. 관리의 지속성을 확보하여 운영 리듬을 찾으십시오."
## 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 알고리즘에 의해 자동 생성되었습니다.*

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. 프로젝트 개요 ## 1. 프로젝트 개요
본 프로젝트는 데이터 분석 프로세스 및 프로젝트 리소스를 통합 관리하기 위한 관리자 대시보드입니다. 사용자 인터랙션 관리부터 시스템 로그, 리소스 현황을 한눈에 파악하는 것을 목표로 합니다. 본 프로젝트는 데이터 분석 프로세스 및 프로젝트 리소스를 통합 관리하기 위한 관리자 대시보드입니다. 사용자 인터랙션 관리부터 시스템 로그, 리소스 현황을 한눈에 파악하는 것을 목표로 합니다.
## 2. 주요 기능 상세 ## 2. 주요 기능 상세
### ① 메일 관리 및 요구사항 시스템 (Mail & Inquiry Management) - [완료] ### ① 메일 관리 및 요구사항 시스템 (Mail & Inquiry Management) - [완료]
- **UI/UX 고도화**: 리스트 영역 너비 확장(400px) 및 시각적 가독성 개선 - **UI/UX 고도화**: 리스트 영역 너비 확장(400px) 및 시각적 가독성 개선
- **검색 및 필터링**: 키워드 및 기간별 메일 검색 기능 구현 - **검색 및 필터링**: 키워드 및 기간별 메일 검색 기능 구현
- **동적 연동**: 리스트 클릭 시 메일 본문 실시간 업데이트 구현 - **동적 연동**: 리스트 클릭 시 메일 본문 실시간 업데이트 구현
- **메일 관리**: 개별 삭제 및 체크박스를 활용한 대량 삭제 기능 추가 - **메일 관리**: 개별 삭제 및 체크박스를 활용한 대량 삭제 기능 추가
- **탭 시스템**: 수신/발신/임시/휴지통별 데이터 분류 및 동적 렌더링 적용 - **탭 시스템**: 수신/발신/임시/휴지통별 데이터 분류 및 동적 렌더링 적용
### ② 로그 관리 (Log Management) ### ② 로그 관리 (Log Management)
- **최근 로그**: 실시간으로 발생하는 시스템 및 분석 작업 로그 출력 - **최근 로그**: 실시간으로 발생하는 시스템 및 분석 작업 로그 출력
- **전체 로그**: 날짜별, 프로젝트별 필터링을 통한 로그 기록 조회 및 내보내기 - **전체 로그**: 날짜별, 프로젝트별 필터링을 통한 로그 기록 조회 및 내보내기
### ③ 파일 관리 (File Management) ### ③ 파일 관리 (File Management)
- 프로젝트별 데이터셋, 분석 결과물 파일 개수 및 용량 통계 - 프로젝트별 데이터셋, 분석 결과물 파일 개수 및 용량 통계
- 파일 확장자별 구성 비율(CSV, JSON, Python 등) 시각화 지표 제공 - 파일 확장자별 구성 비율(CSV, JSON, Python 등) 시각화 지표 제공
### ④ 인원 관리 (Personnel Management) ### ④ 인원 관리 (Personnel Management)
- 프로젝트 참여 인원 현황 조회 - 프로젝트 참여 인원 현황 조회
- 사용자별 권한(관리자, 분석가, 뷰어) 부여 및 수정 기능 - 사용자별 권한(관리자, 분석가, 뷰어) 부여 및 수정 기능
### ③ 공지사항 (Notice & Patch Notes) ### ③ 공지사항 (Notice & Patch Notes)
- 분석 모델 업데이트, 시스템 점검, 패치 내역 공유 - 분석 모델 업데이트, 시스템 점검, 패치 내역 공유
- 사용자 대상 공지사항 작성 및 게시판 관리 - 사용자 대상 공지사항 작성 및 게시판 관리
## 3. UI/UX 가이드라인 ## 3. UI/UX 가이드라인
- **Layout**: 좌측 내비게이션 바(Sidebar) + 상단 헤더(Header) + 중앙 컨텐츠 영역 - **Layout**: 좌측 내비게이션 바(Sidebar) + 상단 헤더(Header) + 중앙 컨텐츠 영역
- **Theme**: 신뢰감을 주는 Dark Blue / White 톤의 깨끗한 디자인 - **Theme**: 신뢰감을 주는 Dark Blue / White 톤의 깨끗한 디자인
- **Responsiveness**: 다양한 해상도에 대응하는 반응형 레이아웃 구성 - **Responsiveness**: 다양한 해상도에 대응하는 반응형 레이아웃 구성

172
README.md
View File

@@ -1,86 +1,86 @@
# 🚀 서버 정책 (Server Policy) # 🚀 서버 정책 (Server Policy)
**서버 구동 시 반드시 아래 명령어를 사용한다:** **서버 구동 시 반드시 아래 명령어를 사용한다:**
```bash ```bash
uvicorn server:app --host 0.0.0.0 --port 8000 --reload uvicorn server:app --host 0.0.0.0 --port 8000 --reload
``` ```
- **Host**: `0.0.0.0` (외부 접속 허용) - **Host**: `0.0.0.0` (외부 접속 허용)
- **Port**: `8000` - **Port**: `8000`
- **Reload**: 코드 수정 시 자동 재시작 활성화 - **Reload**: 코드 수정 시 자동 재시작 활성화
--- ---
# 🤖 메일시스템 AI판단가이드 (AI Reasoning Guide) # 🤖 메일시스템 AI판단가이드 (AI Reasoning Guide)
AI는 파일을 분류할 때 단순한 키워드 매칭이 아닌, 아래의 **5단계 통합 추론 모델**을 사용하여 '실무자처럼' 생각하고 판단한다. AI는 파일을 분류할 때 단순한 키워드 매칭이 아닌, 아래의 **5단계 통합 추론 모델**을 사용하여 '실무자처럼' 생각하고 판단한다.
### 1단계: 전수 데이터 수집 (Holistic Reading) ### 1단계: 전수 데이터 수집 (Holistic Reading)
- **무제한 스캔**: 페이지 수에 관계없이 문서 전체를 전수 조사한다. - **무제한 스캔**: 페이지 수에 관계없이 문서 전체를 전수 조사한다.
- **무조건적 OCR**: 디지털 텍스트 유무와 상관없이 모든 페이지에 고해상도(300 DPI) OCR을 실행하여 이미지 속 도장, 수기, 표 데이터까지 완벽히 수집한다. - **무조건적 OCR**: 디지털 텍스트 유무와 상관없이 모든 페이지에 고해상도(300 DPI) OCR을 실행하여 이미지 속 도장, 수기, 표 데이터까지 완벽히 수집한다.
### 2단계: 파일명 가중치 적용 (Title Steering) ### 2단계: 파일명 가중치 적용 (Title Steering)
- **파일명 = 보관 의도**: 사용자가 지은 파일명은 분류의 가장 강력한 '방향타'이다. - **파일명 = 보관 의도**: 사용자가 지은 파일명은 분류의 가장 강력한 '방향타'이다.
- **최종 조율**: 본문의 데이터가 다른 도메인에 쏠려 있더라도, 파일명에 명확한 업무 용어(`실정보고`, `하도급` 등)가 있다면 이를 최종 분류의 가장 큰 무게추로 삼는다. - **최종 조율**: 본문의 데이터가 다른 도메인에 쏠려 있더라도, 파일명에 명확한 업무 용어(`실정보고`, `하도급` 등)가 있다면 이를 최종 분류의 가장 큰 무게추로 삼는다.
### 3단계: 문서의 물리적 틀(Format) 분석 ### 3단계: 문서의 물리적 틀(Format) 분석
- **공문 골격 확인**: 문서의 시작(`수신/발신`)과 끝(`직인/끝.`)의 구조를 확인한다. - **공문 골격 확인**: 문서의 시작(`수신/발신`)과 끝(`직인/끝.`)의 구조를 확인한다.
- **껍데기 vs 알맹이**: - **껍데기 vs 알맹이**:
- **공문 본체**: 골격이 완벽하고 뒤따르는 기술 데이터가 적은 경우 → **[공사관리 > 공문]** - **공문 본체**: 골격이 완벽하고 뒤따르는 기술 데이터가 적은 경우 → **[공사관리 > 공문]**
- **첨부 본체**: 공문 뒤에 대량의 산출서, 계약서, 도면이 붙어 있는 경우 → **[해당 기술 카테고리]** (공문은 전달 수단으로만 간주) - **첨부 본체**: 공문 뒤에 대량의 산출서, 계약서, 도면이 붙어 있는 경우 → **[해당 기술 카테고리]** (공문은 전달 수단으로만 간주)
### 4단계: 비즈니스 도메인 상식 결합 (Common Sense) ### 4단계: 비즈니스 도메인 상식 결합 (Common Sense)
- **지명 교차 검증**: 파일명과 본문의 지명(어천, 공주, 대술, 정안 등)을 대조하여 정확한 프로젝트를 선택한다. (임의 기본값 지정 금지) - **지명 교차 검증**: 파일명과 본문의 지명(어천, 공주, 대술, 정안 등)을 대조하여 정확한 프로젝트를 선택한다. (임의 기본값 지정 금지)
- **실무 맥락 매칭**: '임대료/연장'은 사업비 성격의 '기타'로, '비계'는 '구조물'로 연결하는 등 건설 실무 상식을 추론에 반영한다. - **실무 맥락 매칭**: '임대료/연장'은 사업비 성격의 '기타'로, '비계'는 '구조물'로 연결하는 등 건설 실무 상식을 추론에 반영한다.
### 5단계: 최종 지도 매칭 (Hierarchy Mapping) ### 5단계: 최종 지도 매칭 (Hierarchy Mapping)
- 수집된 모든 정보를 종합하여 사용자가 정의한 **표준 분류 체계(Tab > Category > Sub)** 지도 위에서 가장 논리적이고 실무적인 위치를 최종 확정한다. - 수집된 모든 정보를 종합하여 사용자가 정의한 **표준 분류 체계(Tab > Category > Sub)** 지도 위에서 가장 논리적이고 실무적인 위치를 최종 확정한다.
--- ---
# 🛠️ 개발 및 관리 규칙 (Strict Development Rules) # 🛠️ 개발 및 관리 규칙 (Strict Development Rules)
1. **언어 설정**: 영어로 생각하되, 모든 답변은 **한국어**로 작성한다. 1. **언어 설정**: 영어로 생각하되, 모든 답변은 **한국어**로 작성한다.
2. **임의 수정 절대 금지 (Zero-Arbitrary Change)**: 2. **임의 수정 절대 금지 (Zero-Arbitrary Change)**:
- 사용자가 명시적으로 지시한 부분 외에는 **단 한 줄의 코드도, 그 어떤 파일도 임의로 수정, 정리, 리팩토링하지 않는다.** - 사용자가 명시적으로 지시한 부분 외에는 **단 한 줄의 코드도, 그 어떤 파일도 임의로 수정, 정리, 리팩토링하지 않는다.**
- 지시받지 않은 다른 파트의 코드는 절대 건드리지 않으며, 영향 범위가 요청 범위를 벗어나지 않도록 '외과 수술식(Surgical) 수정'을 원칙으로 한다. - 지시받지 않은 다른 파트의 코드는 절대 건드리지 않으며, 영향 범위가 요청 범위를 벗어나지 않도록 '외과 수술식(Surgical) 수정'을 원칙으로 한다.
3. **개선 작업 절차 (Test-First Approach)**: 3. **개선 작업 절차 (Test-First Approach)**:
- 사용자가 개선(Refactoring, Optimization 등)을 지시한 경우, **수정 전 현재 시스템이 정상적으로 잘 작동하는지 먼저 전수 확인**한다. - 사용자가 개선(Refactoring, Optimization 등)을 지시한 경우, **수정 전 현재 시스템이 정상적으로 잘 작동하는지 먼저 전수 확인**한다.
- 기존 동작 방식과 성능을 기준(Baseline)으로 삼고, 수정 후에도 **기존의 모든 기능이 무결하게 유지되는지 반드시 테스트하여 입증**한다. - 기존 동작 방식과 성능을 기준(Baseline)으로 삼고, 수정 후에도 **기존의 모든 기능이 무결하게 유지되는지 반드시 테스트하여 입증**한다.
- 검증 결과를 바탕으로 "무엇을, 왜, 어떻게" 바꿀지 상세 보고 후, 사용자로부터 **'진행시켜'** 승인을 얻은 뒤에만 집행한다. - 검증 결과를 바탕으로 "무엇을, 왜, 어떻게" 바꿀지 상세 보고 후, 사용자로부터 **'진행시켜'** 승인을 얻은 뒤에만 집행한다.
4. **선보고 후승인**: 모든 기능 수정 및 코드 변경 전에는 예상 방안을 먼저 보고하고 승인 절차를 거친다. 4. **선보고 후승인**: 모든 기능 수정 및 코드 변경 전에는 예상 방안을 먼저 보고하고 승인 절차를 거친다.
5. **로그 기록 철저**: 진행 상황(로그인, 수집, 오류 등)을 실시간 로그에 상세히 표시하여 투명성을 확보한다. 5. **로그 기록 철저**: 진행 상황(로그인, 수집, 오류 등)을 실시간 로그에 상세히 표시하여 투명성을 확보한다.
--- ---
## 🎨 디자인 가이드 (Design System) ## 🎨 디자인 가이드 (Design System)
이 프로젝트는 `tokens.json`에 정의된 디자인 시스템을 준수합니다. 이 프로젝트는 `tokens.json`에 정의된 디자인 시스템을 준수합니다.
### 1. 컬러 시스템 (Colors) ### 1. 컬러 시스템 (Colors)
- **Primary**: `#1E5149` (primary-lv-6) - 브랜드 핵심 컬러 - **Primary**: `#1E5149` (primary-lv-6) - 브랜드 핵심 컬러
- **Background**: `#FFFFFF` (Light Default) / `#F9FAFB` (Light Muted) - **Background**: `#FFFFFF` (Light Default) / `#F9FAFB` (Light Muted)
- **Point Colors**: - **Point Colors**:
- Blue: `#0D8DF2` (Info) - Blue: `#0D8DF2` (Info)
- Green: `#4DB251` (Success) - Green: `#4DB251` (Success)
- Red: `#F21D0D` (Error) - Red: `#F21D0D` (Error)
- Yellow: `#FFBF00` (Warning) - Yellow: `#FFBF00` (Warning)
- **Special**: `ai_color` (Purple-Blue Gradient) - AI 관련 요소 전용 - **Special**: `ai_color` (Purple-Blue Gradient) - AI 관련 요소 전용
### 2. 타이포그래피 (Typography) ### 2. 타이포그래피 (Typography)
- **Font Family**: `Pretendard`, `sans-serif` - **Font Family**: `Pretendard`, `sans-serif`
- **Scale**: - **Scale**:
- **H1**: 20px / ExtraBold (pretendard-0) - **H1**: 20px / ExtraBold (pretendard-0)
- **H2**: 16px / SemiBold (pretendard-1) - **H2**: 16px / SemiBold (pretendard-1)
- **H3/H4**: 14px / SemiBold or Regular - **H3/H4**: 14px / SemiBold or Regular
- **Body/P**: 12px / Regular (pretendard-2) - **Body/P**: 12px / Regular (pretendard-2)
### 3. 레이아웃 및 간격 (Dimensions) ### 3. 레이아웃 및 간격 (Dimensions)
- **Spacing Unit**: Base 4px (xs: 4px, sm: 8px, md: 16px, lg: 32px, xl: 64px) - **Spacing Unit**: Base 4px (xs: 4px, sm: 8px, md: 16px, lg: 32px, xl: 64px)
- **Border Radius**: sm: 4px, lg: 8px, xl: 16px - **Border Radius**: sm: 4px, lg: 8px, xl: 16px
- **Shadow**: `0 8px 24px rgba(0,0,0,0.16)` (box__drop-shadow) - **Shadow**: `0 8px 24px rgba(0,0,0,0.16)` (box__drop-shadow)
### 4. 컴포넌트 규칙 ### 4. 컴포넌트 규칙
- **Buttons**: `borderRadius.lg (8px)` 적용, Primary 배경색 사용 - **Buttons**: `borderRadius.lg (8px)` 적용, Primary 배경색 사용
- **Cards**: `borderRadius.lg (8px)` 적용, Subtle Shadow 활용 - **Cards**: `borderRadius.lg (8px)` 적용, Subtle Shadow 활용
- **Topbar**: Height 36px, `headercolor` 그라데이션 적용 가능 - **Topbar**: Height 36px, `headercolor` 그라데이션 적용 가능

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -1,198 +1,270 @@
import re import re
import math import math
import statistics import statistics
from datetime import datetime, timedelta from datetime import datetime, timedelta
from sql_queries import DashboardQueries from sql_queries import DashboardQueries
from prediction_service import SOIPredictionService from prediction_service import SOIPredictionService
class AnalysisService: class AnalysisService:
"""프로젝트 통계 및 활동성 분석 전문 서비스""" """프로젝트 통계 및 활동성 분석 전문 서비스"""
@staticmethod @staticmethod
def calculate_operational_consistency(history_rows, days_stagnant): def calculate_operational_consistency(history_rows, days_stagnant):
"""운영 일관성 지수(OCI) 산출 로직 (장기 정체 패널티 포함) """운영 일관성 지수(OCI) 산출 로직 (자산 규모 및 장기 정체 패널티 포함)
최근 30일간 활동 리듬 분석 + 현재 방치 기간에 따른 강력한 감쇄 최근 30일간 활동 리듬 분석 + 현재 방치 기간에 따른 강력한 감쇄
""" """
if not history_rows or len(history_rows) < 2: if not history_rows or len(history_rows) < 2:
return 0.0 return 0.0
# 1. 최근 30일 이력 기반 Base Score 산출 # [추가] 최신 상태 확인: 현재 로그가 '폴더자동삭제'면 점수 즉시 0점 (일수는 실제 일수 유지)
now = datetime.now().date() latest_log = history_rows[-1].get('recent_log', '') or ''
recent_30 = [h for h in history_rows if (now - h['crawl_date']).days <= 30] if latest_log and "폴더자동삭제" in latest_log.replace(" ", ""):
return 0.0
# 주차별 활동 여부 (4주)
weeks_active = [False, False, False, False] # 1. 최근 30일 이력 기반 Base Score 산출
for h in recent_30: now = datetime.now().date()
days_ago = (now - h['crawl_date']).days recent_30 = [h for h in history_rows if (now - h['crawl_date']).days <= 30]
week_idx = min(3, days_ago // 7)
weeks_active[week_idx] = True if not recent_30:
return 0.0
base_consistency = (sum(weeks_active) / 4) * 70
# [추가] 자산 규모 확인: 파일이 0개면 운영 일관성 산출 자체가 무의미함
# 활동 밀도 (변화 발생일 비율) max_files = max([int(h['file_count'] or 0) for h in recent_30])
effort_days = 0 if max_files == 0:
for i in range(1, len(recent_30)): return 0.0
if recent_30[i]['file_count'] != recent_30[i-1]['file_count']:
effort_days += 1 # 주차별 활동 여부 (4주) - 파일이 1개 이상 존재할 때만 유효 활동으로 인정
weeks_active = [False, False, False, False]
density_score = (effort_days / max(1, len(recent_30))) * 30 for h in recent_30:
base_oci = base_consistency + density_score if int(h['file_count'] or 0) > 0:
days_ago = (now - h['crawl_date']).days
# 2. [핵심] 장기 정체 패널티 적용 week_idx = min(3, days_ago // 7)
# 방치일이 100일 이상이면 OCI는 0점으로 수렴 (성실도 무효화) weeks_active[week_idx] = True
stagnation_factor = max(0, (100 - days_stagnant) / 100.0)
base_consistency = (sum(weeks_active) / 4) * 70
final_oci = base_oci * stagnation_factor
# 활동 밀도 (변화 발생일 비율)
return round(final_oci, 1) effort_days = 0
for i in range(1, len(recent_30)):
@staticmethod # '폴더자동삭제' 로그가 포함된 날의 변화는 관리 노력으로 인정하지 않음
def calculate_activity_status(target_date_dt, log, file_count): log_content = recent_30[i].get('recent_log', '') or ''
"""개별 프로젝트의 활동 상태 및 방치일 산출""" if "폴더자동삭제" in log_content.replace(" ", ""):
status, days = "unknown", 999 continue
file_val = int(file_count) if file_count else 0
has_log = log and log != "데이터 없음" and log != "X" if recent_30[i]['file_count'] != recent_30[i-1]['file_count']:
effort_days += 1
if file_val == 0:
status = "unknown" density_score = (effort_days / max(1, len(recent_30))) * 30
elif has_log: base_oci = base_consistency + density_score
if "폴더자동삭제" in log.replace(" ", ""):
status = "stale" # 2. [핵심] 패널티 엔진 적용
days = 999 # A. 장기 정체 패널티: 방치일이 100일 이상이면 0점으로 수렴
else: stagnation_factor = max(0, (100 - days_stagnant) / 100.0)
match = re.search(r'(\d{4})\.(\d{2})\.(\d{2})', log)
if match: # B. 자산 부족 패널티 (Existence Confidence): 파일이 너무 적으면 관리 신뢰도 하락
log_date = datetime.strptime(match.group(0), "%Y.%m.%d") # 10개 미만은 50%만 인정, 그 이상은 점진적으로 100%까지 회복
diff = (target_date_dt - log_date).days asset_confidence = 1.0
status = "active" if diff <= 7 else "warning" if diff <= 14 else "stale" if max_files < 10:
days = diff asset_confidence = 0.5
else: elif max_files < 30:
status = "stale" asset_confidence = 0.8
else:
status = "stale" final_oci = base_oci * stagnation_factor * asset_confidence
return status, days return round(final_oci, 1)
@staticmethod @staticmethod
def get_project_activity_logic(cursor, date_str): def calculate_activity_status(target_date_dt, log, file_count):
"""활동도 분석 리포트 생성 로직""" """개별 프로젝트의 활동 상태 및 방치일 산출 (현재 시각 기준 실질 방치일 산출)"""
if not date_str or date_str == "-": status, days = "unknown", 999
cursor.execute(DashboardQueries.GET_LAST_CRAWL_DATE) file_val = int(file_count) if file_count else 0
res = cursor.fetchone() has_log = log and log != "데이터 없음" and log != "X"
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() now_dt = datetime.now()
target_date_dt = datetime.combine(target_date_val, datetime.min.time()) if file_val == 0:
cursor.execute(DashboardQueries.GET_PROJECT_LIST_FOR_ANALYSIS, (target_date_val,)) status = "unknown"
rows = cursor.fetchall() elif has_log:
is_auto = "폴더자동삭제" in log.replace(" ", "")
analysis = {"summary": {"active": 0, "warning": 0, "stale": 0, "unknown": 0}, "details": []} # 2자리 또는 4자리 연도 지원 정규식
for r in rows: match = re.search(r'(\d{2,4})\.(\d{2})\.(\d{2})', log)
status, days = AnalysisService.calculate_activity_status(target_date_dt, r['recent_log'], r['file_count']) if match:
analysis["summary"][status] += 1 y, m, d = match.groups()
analysis["details"].append({"name": r['short_nm'] or r['project_nm'], "status": status, "days_ago": days}) # 2자리 연도 보정
if len(y) == 2: y = "20" + y
return analysis log_date = datetime.strptime(f"{y}.{m}.{d}", "%Y.%m.%d")
@staticmethod # 수집일(target_date_dt)이 아닌 현재 시점(now_dt) 기준으로 차이 계산
def get_p_zsr_analysis_logic(cursor): diff = (now_dt - log_date).days
"""절대적 방치 실태 고발 및 운영 일관성(OCI) 분석 로직""" days = diff
cursor.execute(DashboardQueries.GET_LAST_CRAWL_DATE) # 상태 판정은 수집 시점의 target_date_dt를 기준으로 할지 검토 필요하나,
res_date = cursor.fetchone() # 사용자 요청에 따라 '이상한 계산'을 바로잡기 위해 현재 시점 기준 판정 적용
if not res_date or not res_date['last_date']: status = "stale" if is_auto or diff > 14 else "warning" if diff > 7 else "active"
return [] else:
last_date = res_date['last_date'] status = "stale"
days = 999
cursor.execute(""" else:
SELECT m.project_id, m.project_nm, m.short_nm, m.department, m.master, status = "stale"
h.recent_log, h.file_count, m.continent, m.country
FROM projects_master m return status, days
LEFT JOIN projects_history h ON m.project_id = h.project_id AND h.crawl_date = %s
ORDER BY m.project_id ASC @staticmethod
""", (last_date,)) def get_project_activity_logic(cursor, date_str):
projects = cursor.fetchall() """활동도 분석 리포트 생성 로직"""
if not date_str or date_str == "-":
if not projects: return [] cursor.execute(DashboardQueries.GET_LAST_CRAWL_DATE)
res = cursor.fetchone()
results = [] target_date_val = res['last_date'] if res['last_date'] else datetime.now().date()
total_soi = 0 else:
target_date_val = datetime.strptime(date_str.replace(".", "-"), "%Y-%m-%d").date()
for p in projects:
file_count = int(p['file_count']) if p['file_count'] else 0 target_date_dt = datetime.combine(target_date_val, datetime.min.time())
log = p['recent_log'] cursor.execute(DashboardQueries.GET_PROJECT_LIST_FOR_ANALYSIS, (target_date_val,))
rows = cursor.fetchall()
# 방치일 계산
days_stagnant = 14 analysis = {"summary": {"active": 0, "warning": 0, "stale": 0, "unknown": 0}, "details": []}
if log and log != "데이터 없음": for r in rows:
match = re.search(r'(\d{4})\.(\d{2})\.(\d{2})', log) status, days = AnalysisService.calculate_activity_status(target_date_dt, r['recent_log'], r['file_count'])
if match: analysis["summary"][status] += 1
log_date = datetime.strptime(match.group(0), "%Y.%m.%d").date() analysis["details"].append({"name": r['short_nm'] or r['project_nm'], "status": status, "days_ago": days})
days_stagnant = (last_date - log_date).days
return analysis
is_auto_delete = log and "폴더자동삭제" in log.replace(" ", "")
@staticmethod
# AI-Hazard 추론 로직 (Dynamic Lambda) def get_p_zsr_analysis_logic(cursor):
scale_impact = min(0.04, math.log10(file_count + 1) * 0.008) if file_count > 0 else 0 """절대적 방치 실태 고발 및 운영 일관성(OCI) 분석 로직"""
ai_lambda = 0.04 + scale_impact cursor.execute(DashboardQueries.GET_LAST_CRAWL_DATE)
res_date = cursor.fetchone()
# 지수 감쇄 적용 if not res_date or not res_date['last_date']:
soi_score = math.exp(-ai_lambda * days_stagnant) * 100 return []
last_date = res_date['last_date']
# ECV 패널티
existence_confidence = 1.0 # 특정 날짜(last_date) 이하의 각 프로젝트별 최신 데이터를 조인하도록 수정
if file_count == 0: existence_confidence = 0.05 cursor.execute("""
elif file_count < 10: existence_confidence = 0.4 SELECT m.project_id, m.project_nm, m.short_nm, m.department, m.master,
h.recent_log, h.file_count, m.continent, m.country
# Log Quality Scoring FROM projects_master m
log_quality_factor = 1.0 LEFT JOIN projects_history h ON h.project_id = m.project_id AND h.crawl_date = (
if log and log != "데이터 없음": SELECT MAX(crawl_date)
if any(k in log for k in ["업로드", "수정", "등록", "변환", "파일", "업데이트"]): log_quality_factor = 1.0 FROM projects_history
elif any(k in log for k in ["폴더", "생성", "삭제", "이동"]): log_quality_factor = 0.7 WHERE project_id = m.project_id AND crawl_date <= %s
elif any(k in log for k in ["참가자", "권한", "추가", "변경", "메일"]): log_quality_factor = 0.4 )
else: log_quality_factor = 0.6 ORDER BY m.project_id ASC
""", (last_date,))
soi_score = soi_score * existence_confidence * log_quality_factor projects = cursor.fetchall()
if is_auto_delete: soi_score = 0.1
if not projects: return []
# [운영 일관성 분석 (OCI)]
history_rows = SOIPredictionService.get_historical_soi(cursor, p['project_id']) results = []
oci_score = AnalysisService.calculate_operational_consistency(history_rows, days_stagnant) total_avi = 0
total_files = 0
# 실무 투입 에너지 계산 project_data_list = []
effort_days = 0
if len(history_rows) > 1: # 1차 Pass: 개별 AVI 산출 및 전체 합계 집계
for i in range(1, len(history_rows)): now_dt = datetime.now()
if history_rows[i]['file_count'] != history_rows[i-1]['file_count']: for p in projects:
effort_days += 1 file_count = int(p['file_count']) if p['file_count'] else 0
log = p['recent_log']
work_effort_rate = round((effort_days / max(1, len(history_rows))) * 100, 1)
total_soi += soi_score # 방치일 계산 (현재 시각 기준 동기화)
days_stagnant = 14
# VCI 산출 is_auto_delete = log and "폴더자동삭제" in log.replace(" ", "")
REPLACEMENT_LEVEL = 70.0
asset_weight = (file_count / 200.0) + 0.5 if log and log != "데이터 없음":
p_war_score = (soi_score - REPLACEMENT_LEVEL) * asset_weight match = re.search(r'(\d{2,4})\.(\d{2})\.(\d{2})', log)
if match:
results.append({ y, m, d = match.groups()
"project_nm": p['short_nm'] or p['project_nm'], if len(y) == 2: y = "20" + y
"file_count": file_count, log_date = datetime.strptime(f"{y}.{m}.{d}", "%Y.%m.%d")
"days_stagnant": days_stagnant, days_stagnant = (now_dt - log_date).days
"risk_count": round(p_war_score, 2), elif is_auto_delete:
"p_war": round(soi_score, 1), days_stagnant = 999
"oci_score": oci_score, # 운영 일관성 지수 추가
"is_auto_delete": is_auto_delete, # AI-Hazard 추론 로직 (Dynamic Lambda)
"master": p['master'], scale_impact = min(0.04, math.log10(file_count + 1) * 0.008) if file_count > 0 else 0
"dept": p['department'], ai_lambda = 0.04 + scale_impact
"ai_lambda": round(ai_lambda, 4),
"log_quality": log_quality_factor, # 지수 감쇄 적용
"work_effort": work_effort_rate, avi_score = math.exp(-ai_lambda * days_stagnant) * 100
"avg_info": {
"avg_files": 0, # ECV 패널티
"avg_stagnant": 0, existence_confidence = 1.0
"avg_risk": round(total_soi / len(projects), 1) if file_count == 0: existence_confidence = 0.05
} elif file_count < 10: existence_confidence = 0.4
})
# Log Quality Scoring (SWVW 모델 적용)
results.sort(key=lambda x: x['p_war']) from log_scorer import LogScorer
return results 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,167 @@
import os import os
import re import re
import unicodedata import unicodedata
from pypdf import PdfReader from pypdf import PdfReader
import pytesseract import pytesseract
from pdf2image import convert_from_path from pdf2image import convert_from_path
# 1. 시스템 설정 # 1. 시스템 설정
TESSERACT_EXE = r'C:\Users\User\AppData\Local\Programs\Tesseract-OCR\tesseract.exe' 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' TESSDATA_DIR = r'C:\Users\User\AppData\Local\Programs\Tesseract-OCR\tessdata'
POPPLER_BIN = r'D:\이태훈\00크롬다운로드\poppler-25.12.0\Library\bin' POPPLER_BIN = r'D:\이태훈\00크롬다운로드\poppler-25.12.0\Library\bin'
pytesseract.pytesseract.tesseract_cmd = TESSERACT_EXE pytesseract.pytesseract.tesseract_cmd = TESSERACT_EXE
os.environ["TESSDATA_PREFIX"] = TESSDATA_DIR os.environ["TESSDATA_PREFIX"] = TESSDATA_DIR
OCR_AVAILABLE = os.path.exists(TESSERACT_EXE) OCR_AVAILABLE = os.path.exists(TESSERACT_EXE)
SYSTEM_HIERARCHY = { SYSTEM_HIERARCHY = {
"행정": { "행정": {
"계약": ["계약관리", "기성관리", "업무지시서", "인원관리"], "계약": ["계약관리", "기성관리", "업무지시서", "인원관리"],
"업무관리": ["업무일지(2025)", "업무일지(2025년 이전)", "발주처 정기보고", "본사업무보고", "공사감독일지", "양식서류"] "업무관리": ["업무일지(2025)", "업무일지(2025년 이전)", "발주처 정기보고", "본사업무보고", "공사감독일지", "양식서류"]
}, },
"설계성과품": { "설계성과품": {
"시방서": ["공사시방서", "장비 반입허가 검토서"], "시방서": ["공사시방서", "장비 반입허가 검토서"],
"설계도면": ["공통", "토공", "비탈면안전공", "배수공", "교량공", "포장공", "교통안전시설공", "부대공", "용지공 & 기타공"], "설계도면": ["공통", "토공", "비탈면안전공", "배수공", "교량공", "포장공", "교통안전시설공", "부대공", "용지공 & 기타공"],
"수량산출서": ["토공", "비탈면안전공", "배수공", "교량공", "포장공", "교통안전시설공", "부대공", "용지공 & 기타공"], "수량산출서": ["토공", "비탈면안전공", "배수공", "교량공", "포장공", "교통안전시설공", "부대공", "용지공 & 기타공"],
"내역서": ["단가산출서"], "내역서": ["단가산출서"],
"보고서": ["실시설계보고서", "지반조사보고서", "구조계산서", "수리 및 전기계산서", "기타보고서", "기술자문 및 심의"], "보고서": ["실시설계보고서", "지반조사보고서", "구조계산서", "수리 및 전기계산서", "기타보고서", "기술자문 및 심의"],
"측량계산부": ["측량계산부"], "측량계산부": ["측량계산부"],
"설계단계 수행협의": ["회의·협의"] "설계단계 수행협의": ["회의·협의"]
}, },
"시공성과품": { "시공성과품": {
"설계도면": ["공통", "토공", "비탈면안전공", "배수공", "교량공", "포장공", "교통안전시설공", "부대공", "용지공 & 기타공"] "설계도면": ["공통", "토공", "비탈면안전공", "배수공", "교량공", "포장공", "교통안전시설공", "부대공", "용지공 & 기타공"]
}, },
"시공검측": { "시공검측": {
"토공": ["검측 (깨기)", "검측 (연약지반)", "검측 (발파)", "검측 (노체)", "검측 (노상)", "검측 (토취장)"], "토공": ["검측 (깨기)", "검측 (연약지반)", "검측 (발파)", "검측 (노체)", "검측 (노상)", "검측 (토취장)"],
"배수공": ["검측 (V형측구)", "검측 (산마루측구)", "검측 (U형측구)", "검측 (U형측구)(안)", "검측 (L형측구, J형측구)", "검측 (도수로)", "검측 (도수로)(안)", "검측 (횡배수관)", "검측 (종배수관)", "검측 (맹암거)", "검측 (통로암거)", "검측 (수로암거)", "검측 (호안공)", "검측 (옹벽공)", "검측 (용수개거)"], "배수공": ["검측 (V형측구)", "검측 (산마루측구)", "검측 (U형측구)", "검측 (U형측구)(안)", "검측 (L형측구, J형측구)", "검측 (도수로)", "검측 (도수로)(안)", "검측 (횡배수관)", "검측 (종배수관)", "검측 (맹암거)", "검측 (통로암거)", "검측 (수로암거)", "검측 (호안공)", "검측 (옹벽공)", "검측 (용수개거)"],
"구조물공": ["검측 (평목교-거더, 부대공)", "검측 (평목교)(안)", "검측 (개착터널, 생태통로)"], "구조물공": ["검측 (평목교-거더, 부대공)", "검측 (평목교)(안)", "검측 (개착터널, 생태통로)"],
"포장공": ["검측 (기층, 보조기층)"], "포장공": ["검측 (기층, 보조기층)"],
"부대공": ["검측 (환경)", "검측 (지장가옥,건물 철거)", "검측 (방음벽 등)"], "부대공": ["검측 (환경)", "검측 (지장가옥,건물 철거)", "검측 (방음벽 등)"],
"비탈면안전공": ["검측 (식생보호공)", "검측 (구조물보호공)"], "비탈면안전공": ["검측 (식생보호공)", "검측 (구조물보호공)"],
"교통안전시설공": ["검측 (낙석방지책)"], "교통안전시설공": ["검측 (낙석방지책)"],
"검측 양식서류": ["검측 양식서류"] "검측 양식서류": ["검측 양식서류"]
}, },
"설계변경": { "설계변경": {
"실정보고(어천~공주)": ["토공", "배수공", "교량공(평목교)", "구조물공", "포장공", "교통안전공", "부대공", "전기공사", "미확정공", "안전관리", "환경관리", "품질관리", "자재관리", "지장물", "기타"], "실정보고(어천~공주)": ["토공", "배수공", "교량공(평목교)", "구조물공", "포장공", "교통안전공", "부대공", "전기공사", "미확정공", "안전관리", "환경관리", "품질관리", "자재관리", "지장물", "기타"],
"실정보고(대술~정안)": ["토공", "배수공", "비탈면안전공", "포장공", "부대공", "안전관리", "환경관리", "자재관리", "기타"], "실정보고(대술~정안)": ["토공", "배수공", "비탈면안전공", "포장공", "부대공", "안전관리", "환경관리", "자재관리", "기타"],
"기술지원 검토": ["토공", "배수공", "교량공(평목교)", "구조물&부대공", "기타"], "기술지원 검토": ["토공", "배수공", "교량공(평목교)", "구조물&부대공", "기타"],
"시공계획(어천~공주)": ["토공", "배수공", "교량공(평목교)", "구조물&부대&포장&교통안전공", "환경 및 품질관리"] "시공계획(어천~공주)": ["토공", "배수공", "교량공(평목교)", "구조물&부대&포장&교통안전공", "환경 및 품질관리"]
}, },
"공사관리": { "공사관리": {
"공정·일정": ["공정표", "월간 공정보고", "작업일보"], "공정·일정": ["공정표", "월간 공정보고", "작업일보"],
"품질 관리": ["품질시험계획서", "품질시험 실적보고", "콘크리트 타설현황[어천~공주(4차)]", "품질관리비 사용내역", "균열관리", "품질관리 양식서류"], "품질 관리": ["품질시험계획서", "품질시험 실적보고", "콘크리트 타설현황[어천~공주(4차)]", "품질관리비 사용내역", "균열관리", "품질관리 양식서류"],
"안전 관리": ["안전관리계획서", "안전관리 실적보고", "위험성 평가", "사전작업허가서", "안전관리비 사용내역", "안전관리수준평가", "안전관리 양식서류"], "안전 관리": ["안전관리계획서", "안전관리 실적보고", "위험성 평가", "사전작업허가서", "안전관리비 사용내역", "안전관리수준평가", "안전관리 양식서류"],
"환경 관리": ["환경영향평가", "사전재해영향성검토", "유지관리 및 보수점검", "환경보전비 사용내역", "건설폐기물 관리"], "환경 관리": ["환경영향평가", "사전재해영향성검토", "유지관리 및 보수점검", "환경보전비 사용내역", "건설폐기물 관리"],
"자재 관리 (관급)": ["자재구매요청 (레미콘, 철근)", "자재구매요청 (그 외)", "납품기한", "계약 변경", "자재 반입·수불 관리", "자재관리 양식서류"], "자재 관리 (관급)": ["자재구매요청 (레미콘, 철근)", "자재구매요청 (그 외)", "납품기한", "계약 변경", "자재 반입·수불 관리", "자재관리 양식서류"],
"자재 관리 (사급)": ["자재공급원 승인", "자재 반입·수불 관리", "자재 검수·확인"], "자재 관리 (사급)": ["자재공급원 승인", "자재 반입·수불 관리", "자재 검수·확인"],
"점검 (정리중)": ["내부점검", "외부점검"], "점검 (정리중)": ["내부점검", "외부점검"],
"공문": ["접수(수신)", "발송(발신)", "하도급", "인력", "방침"] "공문": ["접수(수신)", "발송(발신)", "하도급", "인력", "방침"]
}, },
"민원관리": { "민원관리": {
"민원(어천~공주)": ["처리대장", "보상", "공사일반", "환경분쟁"], "민원(어천~공주)": ["처리대장", "보상", "공사일반", "환경분쟁"],
"실정보고(어천~공주)": ["민원"], "실정보고(어천~공주)": ["민원"],
"실정보고(대술~정안)": ["민원"] "실정보고(대술~정안)": ["민원"]
} }
} }
def analyze_flow_reasoning(filename, all_text_list): def analyze_flow_reasoning(filename, all_text_list):
""" """
본문의 전수 조사 결과에 파일명의 '의도 가중치'를 더해 최종 추론 본문의 전수 조사 결과에 파일명의 '의도 가중치'를 더해 최종 추론
""" """
full_text = " ".join(all_text_list) full_text = " ".join(all_text_list)
clean_ctx = full_text.replace(" ", "").replace("\n", "").lower() clean_ctx = full_text.replace(" ", "").replace("\n", "").lower()
fn_clean = filename.replace(" ", "").lower() fn_clean = filename.replace(" ", "").lower()
# 1. 도메인별 기본 점수 (본문 전수 조사 - 평등하게) # 1. 도메인별 기본 점수 (본문 전수 조사 - 평등하게)
scores = { scores = {
"official": sum(clean_ctx.count(k) for k in ["수신:", "발신:", "경유:", "시행일자", "귀하", "드립니다", "바랍니다"]), "official": sum(clean_ctx.count(k) for k in ["수신:", "발신:", "경유:", "시행일자", "귀하", "드립니다", "바랍니다"]),
"contract": 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 ["이탈계", "인력", "기술자", "안전관리자", "재직증명", "배치"]), "hr": sum(clean_ctx.count(k) for k in ["이탈계", "인력", "기술자", "안전관리자", "재직증명", "배치"]),
"change": 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"]) "technical": sum(clean_ctx.count(k) for k in ["일위대가", "산출근거", "집계표", "물량산출", "단가", "내역", "도면", "dwg"])
} }
# 2. 파일명에 대한 '방향타' 가중치 부여 (Final Push) # 2. 파일명에 대한 '방향타' 가중치 부여 (Final Push)
# 본문 데이터가 아무리 많아도 파일명의 의도를 존중하기 위해 7배 가중치 # 본문 데이터가 아무리 많아도 파일명의 의도를 존중하기 위해 7배 가중치
if "실정" in fn_clean or "변경" in fn_clean: scores["change"] += 50 # 본문 50회 언급과 맞먹는 가중치 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["contract"] += 50
if "인력" in fn_clean or "이탈" in fn_clean: scores["hr"] += 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 or "도면" in fn_clean: scores["technical"] += 50
if "제출" in fn_clean or "" in fn_clean: scores["official"] += 30 if "제출" in fn_clean or "" in fn_clean: scores["official"] += 30
# 3. 종합 농도에 따른 최종 도메인 선정 # 3. 종합 농도에 따른 최종 도메인 선정
dominant_domain = max(scores, key=scores.get) dominant_domain = max(scores, key=scores.get)
# 프로젝트 식별 (Fuzzy 매칭 및 교차 검증) # 프로젝트 식별 (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 "공통" 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: 실정보고/설계변경 (본문 데이터 + 파일명 의도 합성) # 시나리오 A: 실정보고/설계변경 (본문 데이터 + 파일명 의도 합성)
if dominant_domain == "change" or (scores["change"] > 0 and scores["technical"] > 5): if dominant_domain == "change" or (scores["change"] > 0 and scores["technical"] > 5):
cat = f"실정보고({project_loc})" cat = f"실정보고({project_loc})"
sub = "지장물" if any(k in clean_ctx for k in ["임대료", "토지", "보상"]) else "구조물공" if "구조물" in clean_ctx else "기타" 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} 프로젝트의 실정보고 본체로 판정." return f"설계변경 > {cat} > {sub}", f"본문의 기술 데이터 밀도와 파일명의 '{dominant_domain}' 관련 의도를 종합하여 {project_loc} 프로젝트의 실정보고 본체로 판정."
# 시나리오 B: 행정 계약/하도급 (본체 중심) # 시나리오 B: 행정 계약/하도급 (본체 중심)
if dominant_domain == "contract": if dominant_domain == "contract":
return "행정 > 계약 > 계약관리", "문서 전체에서 계약 및 하도급 업무 본질이 지배적으로 확인됨." return "행정 > 계약 > 계약관리", "문서 전체에서 계약 및 하도급 업무 본질이 지배적으로 확인됨."
# 시나리오 C: 인사/인력 관리 # 시나리오 C: 인사/인력 관리
if dominant_domain == "hr": if dominant_domain == "hr":
if len(all_text_list) <= 2: return "공사관리 > 공문 > 인력", "인력 사항을 간략히 보고하는 공문 형식임." if len(all_text_list) <= 2: return "공사관리 > 공문 > 인력", "인력 사항을 간략히 보고하는 공문 형식임."
return "행정 > 계약 > 인원관리", "다량의 인력 증빙 데이터가 포함된 행정 서류임." return "행정 > 계약 > 인원관리", "다량의 인력 증빙 데이터가 포함된 행정 서류임."
# 시나리오 D: 순수 공문 (형식 우선) # 시나리오 D: 순수 공문 (형식 우선)
if dominant_domain == "official" or scores["official"] > scores["technical"]: if dominant_domain == "official" or scores["official"] > scores["technical"]:
tab, cat = "공사관리", "공문" tab, cat = "공사관리", "공문"
sub = "접수(수신)" sub = "접수(수신)"
if "방침" in clean_ctx or "지침" in clean_ctx: sub = "방침" if "방침" in clean_ctx or "지침" in clean_ctx: sub = "방침"
elif "발신" in clean_ctx[:500]: sub = "발송(발신)" elif "발신" in clean_ctx[:500]: sub = "발송(발신)"
return f"{tab} > {cat} > {sub}", "전체 맥락상 기술적 데이터보다 행정적 전달 행위(공문)가 핵심 정체성으로 판단됨." return f"{tab} > {cat} > {sub}", "전체 맥락상 기술적 데이터보다 행정적 전달 행위(공문)가 핵심 정체성으로 판단됨."
# 시나리오 E: 기술 성과품 # 시나리오 E: 기술 성과품
if dominant_domain == "technical": 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 ["단가", "내역"]): return "설계성과품 > 내역서 > 단가산출서", "내역/단가 산출 기술 데이터 확인."
if any(k in clean_ctx or k in fn_clean for k in ["도면", "dwg"]): return "설계성과품 > 설계도면 > 공통", "도면/그래픽 데이터 확인." if any(k in clean_ctx or k in fn_clean for k in ["도면", "dwg"]): return "설계성과품 > 설계도면 > 공통", "도면/그래픽 데이터 확인."
return "설계성과품 > 수량산출서 > 토공", "수량/물량 산출 데이터 확인." return "설계성과품 > 수량산출서 > 토공", "수량/물량 산출 데이터 확인."
return "행정 > 업무관리 > 양식서류", "일반 행정 및 기타 양식 서류로 분류함." return "행정 > 업무관리 > 양식서류", "일반 행정 및 기타 양식 서류로 분류함."
def analyze_file_content(filename: str): def analyze_file_content(filename: str):
try: try:
file_path = os.path.join("sample", filename) file_path = os.path.join("sample", filename)
text_by_pages = [] text_by_pages = []
if filename.lower().endswith(".pdf"): if filename.lower().endswith(".pdf"):
reader = PdfReader(file_path) reader = PdfReader(file_path)
for i in range(len(reader.pages)): for i in range(len(reader.pages)):
page_text = reader.pages[i].extract_text() or "" page_text = reader.pages[i].extract_text() or ""
if OCR_AVAILABLE: if OCR_AVAILABLE:
try: try:
images = convert_from_path(file_path, first_page=i+1, last_page=i+1, poppler_path=POPPLER_BIN, dpi=200) images = convert_from_path(file_path, first_page=i+1, last_page=i+1, poppler_path=POPPLER_BIN, dpi=200)
if images: if images:
ocr_result = pytesseract.image_to_string(images[0], lang='kor+eng') ocr_result = pytesseract.image_to_string(images[0], lang='kor+eng')
page_text += "\n" + ocr_result page_text += "\n" + ocr_result
except Exception as ocr_err: except Exception as ocr_err:
print(f"OCR Error on page {i+1}: {ocr_err}") print(f"OCR Error on page {i+1}: {ocr_err}")
text_by_pages.append(page_text) text_by_pages.append(page_text)
elif filename.lower().endswith(('.xlsx', '.xls')): elif filename.lower().endswith(('.xlsx', '.xls')):
import pandas as pd import pandas as pd
df = pd.read_excel(file_path) df = pd.read_excel(file_path)
text_by_pages.append(df.to_string()) text_by_pages.append(df.to_string())
else: text_by_pages.append("") else: text_by_pages.append("")
path, reason = analyze_flow_reasoning(filename, text_by_pages) path, reason = analyze_flow_reasoning(filename, text_by_pages)
return { return {
"filename": filename, "filename": filename,
"total_pages": len(text_by_pages), "total_pages": len(text_by_pages),
"final_result": { "final_result": {
"suggested_path": path, "suggested_path": path,
"confidence": "100%", "confidence": "100%",
"reason": reason, "reason": reason,
"snippet": " ".join(text_by_pages)[:1500] "snippet": " ".join(text_by_pages)[:1500]
} }
} }
except Exception as e: except Exception as e:
return {"error": str(e), "filename": filename} 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 os
import re import re
import asyncio import asyncio
import json import json
import traceback import traceback
import sys import sys
import threading import threading
import queue import queue
import pymysql import pymysql
from datetime import datetime from datetime import datetime
from playwright.async_api import async_playwright from playwright.async_api import async_playwright
from dotenv import load_dotenv from dotenv import load_dotenv
from sql_queries import CrawlerQueries from sql_queries import CrawlerQueries
load_dotenv(override=True) load_dotenv(override=True)
# 글로벌 중단 제어용 이벤트 # 글로벌 중단 제어용 이벤트
crawl_stop_event = threading.Event() crawl_stop_event = threading.Event()
def get_db_connection(): def get_db_connection():
"""MySQL 데이터베이스 연결을 반환 (환경변수 기반)""" """MySQL 데이터베이스 연결을 반환 (환경변수 기반)"""
return pymysql.connect( return pymysql.connect(
host=os.getenv('DB_HOST', 'localhost'), host=os.getenv('DB_HOST', 'localhost'),
user=os.getenv('DB_USER', 'root'), user=os.getenv('DB_USER', 'root'),
password=os.getenv('DB_PASSWORD', '45278434'), password=os.getenv('DB_PASSWORD', '45278434'),
database=os.getenv('DB_NAME', 'PM_proto'), database=os.getenv('DB_NAME', 'PM_proto'),
charset='utf8mb4', charset='utf8mb4',
cursorclass=pymysql.cursors.DictCursor cursorclass=pymysql.cursors.DictCursor
) )
def clean_date_string(date_str): def clean_date_string(date_str):
if not date_str: return "" if not date_str: return ""
match = re.search(r'(\d{2})[./-](\d{2})[./-](\d{2})', date_str) 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)}" if match: return f"20{match.group(1)}.{match.group(2)}.{match.group(3)}"
return date_str[:10].replace("-", ".") return date_str[:10].replace("-", ".")
def parse_log_id(log_id): def parse_log_id(log_id):
if not log_id or "_" not in log_id: return log_id if not log_id or "_" not in log_id: return log_id
try: try:
parts = log_id.split('_') parts = log_id.split('_')
if len(parts) >= 4: if len(parts) >= 4:
date_part = clean_date_string(parts[1]) date_part = clean_date_string(parts[1])
activity = parts[3].strip() activity = parts[3].strip()
activity = re.sub(r'\(.*?\)', '', activity).strip() activity = re.sub(r'\(.*?\)', '', activity).strip()
return f"{date_part}, {activity}" return f"{date_part}, {activity}"
except: pass except: pass
return log_id return log_id
def crawler_thread_worker(msg_queue, user_id, password): def crawler_thread_worker(msg_queue, user_id, password):
crawl_stop_event.clear() crawl_stop_event.clear()
if sys.platform == 'win32': if sys.platform == 'win32':
asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy()) asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy())
loop = asyncio.new_event_loop() loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop) asyncio.set_event_loop(loop)
async def run(): async def run():
async with async_playwright() as p: async with async_playwright() as p:
browser = None browser = None
try: try:
msg_queue.put(json.dumps({'type': 'log', 'message': '브라우저 엔진 가동 (전 기능 복구 모드)...'})) msg_queue.put(json.dumps({'type': 'log', 'message': '브라우저 엔진 가동 (전 기능 복구 모드)...'}))
browser = await p.chromium.launch(headless=True, args=[ browser = await p.chromium.launch(headless=True, args=[
"--no-sandbox", "--no-sandbox",
"--disable-dev-shm-usage", "--disable-dev-shm-usage",
"--disable-blink-features=AutomationControlled" "--disable-blink-features=AutomationControlled"
]) ])
context = await browser.new_context( context = await browser.new_context(
viewport={'width': 1600, 'height': 900}, 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" 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} captured_data = {"tree": None, "_is_root_archive": False, "project_list": [], "last_project_data": None}
async def global_interceptor(response): async def global_interceptor(response):
url = response.url url = response.url
try: try:
if "getAllList" in url: if "getAllList" in url:
data = await response.json() data = await response.json()
captured_data["project_list"] = data.get("data", []) captured_data["project_list"] = data.get("data", [])
elif "getTreeObject" in url: elif "getTreeObject" in url:
is_root = False is_root = False
if "params[resourcePath]=" in url: if "params[resourcePath]=" in url:
path_val = url.split("params[resourcePath]=")[1].split("&")[0] path_val = url.split("params[resourcePath]=")[1].split("&")[0]
if path_val in ["%2F", "/"]: is_root = True if path_val in ["%2F", "/"]: is_root = True
if is_root: if is_root:
captured_data["tree"] = await response.json() captured_data["tree"] = await response.json()
captured_data["_is_root_archive"] = True captured_data["_is_root_archive"] = True
elif "getData" in url and "overview" in url: elif "getData" in url and "overview" in url:
captured_data["last_project_data"] = await response.json() captured_data["last_project_data"] = await response.json()
except: pass except: pass
context.on("response", global_interceptor) context.on("response", global_interceptor)
page = await context.new_page() page = await context.new_page()
await page.goto("https://overseas.projectmastercloud.com/dashboard", wait_until="domcontentloaded") await page.goto("https://overseas.projectmastercloud.com/dashboard", wait_until="domcontentloaded")
# 로그인 # 로그인
if await page.locator("#login-by-id").is_visible(timeout=10000): if await page.locator("#login-by-id").is_visible(timeout=10000):
await page.click("#login-by-id") await page.click("#login-by-id")
await page.fill("#user_id", user_id) await page.fill("#user_id", user_id)
await page.fill("#user_pw", password) await page.fill("#user_pw", password)
await page.click("#login-btn") await page.click("#login-btn")
await page.wait_for_selector("h4.list__contents_aria_group_body_list_item_label", timeout=60000) await page.wait_for_selector("h4.list__contents_aria_group_body_list_item_label", timeout=60000)
await asyncio.sleep(3) await asyncio.sleep(3)
# [Phase 1] DB 마스터 정보 동기화 # [Phase 1] DB 마스터 정보 동기화
if captured_data["project_list"]: if captured_data["project_list"]:
conn = get_db_connection() conn = get_db_connection()
try: try:
with conn.cursor() as cursor: with conn.cursor() as cursor:
for p_info in captured_data["project_list"]: for p_info in captured_data["project_list"]:
cursor.execute(CrawlerQueries.UPSERT_MASTER, (p_info.get("project_id"), p_info.get("project_nm"), 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("short_nm", "").strip(), p_info.get("master"),
p_info.get("large_class"), p_info.get("mid_class"))) p_info.get("large_class"), p_info.get("mid_class")))
conn.commit() conn.commit()
msg_queue.put(json.dumps({'type': 'log', 'message': 'DB 마스터 정보 동기화 완료.'})) msg_queue.put(json.dumps({'type': 'log', 'message': 'DB 마스터 정보 동기화 완료.'}))
finally: conn.close() finally: conn.close()
# [Phase 2] 수집 루프 # [Phase 2] 수집 루프
names = await page.locator("h4.list__contents_aria_group_body_list_item_label").all_inner_texts() 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()])) project_names = list(dict.fromkeys([n.strip() for n in names if n.strip()]))
count = len(project_names) count = len(project_names)
for i, project_name in enumerate(project_names): for i, project_name in enumerate(project_names):
if crawl_stop_event.is_set(): if crawl_stop_event.is_set():
msg_queue.put(json.dumps({'type': 'log', 'message': '>>> 중단 신호 감지: 종료합니다.'})) msg_queue.put(json.dumps({'type': 'log', 'message': '>>> 중단 신호 감지: 종료합니다.'}))
break break
msg_queue.put(json.dumps({'type': 'log', 'message': f'[{i+1}/{count}] {project_name} 수집 시작'})) 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) 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 current_p_id = p_match.get('project_id') if p_match else None
captured_data["tree"] = None; captured_data["_is_root_archive"] = False captured_data["tree"] = None; captured_data["_is_root_archive"] = False
try: try:
# 1. 프로젝트 진입 (좌표 클릭) # 1. 프로젝트 진입 (좌표 클릭)
target_el = page.locator(f"h4.list__contents_aria_group_body_list_item_label:has-text('{project_name}')").first 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() await target_el.scroll_into_view_if_needed()
box = await target_el.bounding_box() box = await target_el.bounding_box()
if box: await page.mouse.click(box['x'] + 5, box['y'] + 5) if box: await page.mouse.click(box['x'] + 5, box['y'] + 5)
else: await target_el.click(force=True) else: await target_el.click(force=True)
await page.wait_for_selector("text=활동로그", timeout=30000) await page.wait_for_selector("text=활동로그", timeout=30000)
# [부서 정보 수집] getData 응답 대기 및 DB 업데이트 # [부서 정보 수집] getData 응답 대기 및 DB 업데이트
for _ in range(10): for _ in range(10):
if captured_data.get("last_project_data"): break if captured_data.get("last_project_data"): break
await asyncio.sleep(0.5) await asyncio.sleep(0.5)
last_data = captured_data.get("last_project_data") last_data = captured_data.get("last_project_data")
if last_data: if last_data:
if isinstance(last_data, list) and len(last_data) > 0: if isinstance(last_data, list) and len(last_data) > 0:
last_data = last_data[0] last_data = last_data[0]
if isinstance(last_data, dict): if isinstance(last_data, dict):
proj_data = last_data.get("data", {}) proj_data = last_data.get("data", {})
if isinstance(proj_data, list) and len(proj_data) > 0: if isinstance(proj_data, list) and len(proj_data) > 0:
proj_data = proj_data[0] proj_data = proj_data[0]
if isinstance(proj_data, dict): if isinstance(proj_data, dict):
dept = proj_data.get("department") dept = proj_data.get("department")
p_id = proj_data.get("project_id") p_id = proj_data.get("project_id")
if dept and p_id: if dept and p_id:
with get_db_connection() as conn: with get_db_connection() as conn:
with conn.cursor() as cursor: with conn.cursor() as cursor:
cursor.execute(CrawlerQueries.UPDATE_DEPARTMENT, (dept, p_id)) cursor.execute(CrawlerQueries.UPDATE_DEPARTMENT, (dept, p_id))
conn.commit() conn.commit()
captured_data["last_project_data"] = None # 초기화 captured_data["last_project_data"] = None # 초기화
await asyncio.sleep(2) await asyncio.sleep(2)
recent_log = "데이터 없음"; file_count = 0 recent_log = "데이터 없음"; file_count = 0
# 2. 활동로그 (날짜 필터 적용 버전) # 2. 활동로그 (날짜 필터 적용 버전)
modal_opened = False modal_opened = False
for _ in range(3): for _ in range(3):
await page.get_by_text("활동로그").first.click() await page.get_by_text("활동로그").first.click()
try: try:
await page.wait_for_selector("article.archive-modal", timeout=5000) await page.wait_for_selector("article.archive-modal", timeout=5000)
modal_opened = True; break modal_opened = True; break
except: await asyncio.sleep(1) except: await asyncio.sleep(1)
if modal_opened: if modal_opened:
# 날짜 필터 2020-01-01 적용 # 날짜 필터 2020-01-01 적용
inputs = await page.locator("article.archive-modal input").all() inputs = await page.locator("article.archive-modal input").all()
for inp in inputs: for inp in inputs:
if (await inp.get_attribute("type")) == "date": if (await inp.get_attribute("type")) == "date":
await inp.fill("2020-01-01"); break await inp.fill("2020-01-01"); break
apply_btn = page.locator("article.archive-modal").get_by_text("적용").first apply_btn = page.locator("article.archive-modal").get_by_text("적용").first
if await apply_btn.is_visible(): if await apply_btn.is_visible():
await apply_btn.click() await apply_btn.click()
await asyncio.sleep(5) await asyncio.sleep(5)
log_elements = await page.locator("article.archive-modal div[id*='_']").all() log_elements = await page.locator("article.archive-modal div[id*='_']").all()
if log_elements: if log_elements:
recent_log = parse_log_id(await log_elements[0].get_attribute("id")) recent_log = parse_log_id(await log_elements[0].get_attribute("id"))
await page.keyboard.press("Escape") await page.keyboard.press("Escape")
# 3. 구성 수집 (API Fetch 방식 - 팝업 없음) # 3. 구성 수집 (API Fetch 방식 - 팝업 없음)
await page.evaluate("""() => { await page.evaluate("""() => {
const baseUrl = window.location.origin + window.location.pathname.split('/').slice(0, 2).join('/'); const baseUrl = window.location.origin + window.location.pathname.split('/').slice(0, 2).join('/');
fetch(`${baseUrl}/archive/getTreeObject?params[storageType]=CLOUD&params[resourcePath]=/`); fetch(`${baseUrl}/archive/getTreeObject?params[storageType]=CLOUD&params[resourcePath]=/`);
}""") }""")
for _ in range(30): for _ in range(30):
if captured_data["_is_root_archive"]: break if captured_data["_is_root_archive"]: break
await asyncio.sleep(0.5) await asyncio.sleep(0.5)
if captured_data["tree"]: if captured_data["tree"]:
tree_data = captured_data["tree"] tree_data = captured_data["tree"]
if isinstance(tree_data, list) and len(tree_data) > 0: if isinstance(tree_data, list) and len(tree_data) > 0:
tree_data = tree_data[0] tree_data = tree_data[0]
if isinstance(tree_data, dict): if isinstance(tree_data, dict):
tree = tree_data.get('currentTreeObject', tree_data) tree = tree_data.get('currentTreeObject', tree_data)
if isinstance(tree, dict): if isinstance(tree, dict):
total = len(tree.get("file", {})) total = len(tree.get("file", {}))
folders = tree.get("folder", {}) folders = tree.get("folder", {})
if isinstance(folders, dict): if isinstance(folders, dict):
for f in folders.values(): total += int(f.get("filesCount", 0)) for f in folders.values(): total += int(f.get("filesCount", 0))
file_count = total file_count = total
# 4. DB 실시간 저장 # 4. DB 실시간 저장
if current_p_id: if current_p_id:
with get_db_connection() as conn: with get_db_connection() as conn:
with conn.cursor() as cursor: with conn.cursor() as cursor:
cursor.execute(CrawlerQueries.UPSERT_HISTORY, (current_p_id, recent_log, file_count)) cursor.execute(CrawlerQueries.UPSERT_HISTORY, (current_p_id, recent_log, file_count))
conn.commit() conn.commit()
msg_queue.put(json.dumps({'type': 'log', 'message': f' - [성공] 로그: {recent_log[:20]}... / 파일: {file_count}'})) 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") await page.goto("https://overseas.projectmastercloud.com/dashboard", wait_until="domcontentloaded")
except Exception as e: except Exception as e:
msg_queue.put(json.dumps({'type': 'log', 'message': f' - {project_name} 실패: {str(e)}'})) msg_queue.put(json.dumps({'type': 'log', 'message': f' - {project_name} 실패: {str(e)}'}))
await page.goto("https://overseas.projectmastercloud.com/dashboard") await page.goto("https://overseas.projectmastercloud.com/dashboard")
msg_queue.put(json.dumps({'type': 'done', 'data': []})) msg_queue.put(json.dumps({'type': 'done', 'data': []}))
except Exception as e: except Exception as e:
msg_queue.put(json.dumps({'type': 'log', 'message': f'치명적 오류: {str(e)}'})) msg_queue.put(json.dumps({'type': 'log', 'message': f'치명적 오류: {str(e)}'}))
finally: finally:
if browser: await browser.close() if browser: await browser.close()
msg_queue.put(None) msg_queue.put(None)
loop.run_until_complete(run()) loop.run_until_complete(run())
loop.close() loop.close()
async def run_crawler_service(): async def run_crawler_service():
msg_queue = queue.Queue() msg_queue = queue.Queue()
thread = threading.Thread(target=crawler_thread_worker, args=(msg_queue, os.getenv("PM_USER_ID"), os.getenv("PM_PASSWORD"))) thread = threading.Thread(target=crawler_thread_worker, args=(msg_queue, os.getenv("PM_USER_ID"), os.getenv("PM_PASSWORD")))
thread.start() thread.start()
while True: while True:
try: try:
msg = await asyncio.to_thread(msg_queue.get, timeout=1.0) msg = await asyncio.to_thread(msg_queue.get, timeout=1.0)
if msg is None: break if msg is None: break
yield f"data: {msg}\n\n" yield f"data: {msg}\n\n"
except queue.Empty: except queue.Empty:
if not thread.is_alive(): break if not thread.is_alive(): break
await asyncio.sleep(0.1) await asyncio.sleep(0.1)
thread.join() 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()

17
docker-compose.yml Normal file
View File

@@ -0,0 +1,17 @@
services:
web:
# 현재 폴더의 Dockerfile을 사용하여 빌드
build: .
# 컨테이너 이름 설정
container_name: aicode-server
# 포트 포워딩 (호스트 8000 -> 컨테이너 8000)
ports:
- "8000:8000"
# 소스 코드 수정 시 실시간 반영 (볼륨 마운트)
volumes:
- .:/app
# 환경 변수 설정
environment:
- PYTHONUNBUFFERED=1
# 컨테이너 종료 시 자동 재시작
restart: always

View File

@@ -1,42 +1,42 @@
from datetime import datetime from datetime import datetime
from sql_queries import InquiryQueries from sql_queries import InquiryQueries
class InquiryService: class InquiryService:
@staticmethod @staticmethod
def get_inquiries_logic(cursor, pm_type=None, category=None, status=None, keyword=None): def get_inquiries_logic(cursor, pm_type=None, category=None, status=None, keyword=None):
sql = InquiryQueries.SELECT_BASE sql = InquiryQueries.SELECT_BASE
params = [] params = []
if pm_type: if pm_type:
sql += " AND pm_type = %s" sql += " AND pm_type = %s"
params.append(pm_type) params.append(pm_type)
if category: if category:
sql += " AND category = %s" sql += " AND category = %s"
params.append(category) params.append(category)
if status: if status:
sql += " AND status = %s" sql += " AND status = %s"
params.append(status) params.append(status)
if keyword: if keyword:
sql += " AND (content LIKE %s OR author LIKE %s OR project_nm LIKE %s)" sql += " AND (content LIKE %s OR author LIKE %s OR project_nm LIKE %s)"
params.extend([f"%{keyword}%", f"%{keyword}%", f"%{keyword}%"]) params.extend([f"%{keyword}%", f"%{keyword}%", f"%{keyword}%"])
sql += f" {InquiryQueries.ORDER_BY_DESC}" sql += f" {InquiryQueries.ORDER_BY_DESC}"
cursor.execute(sql, params) cursor.execute(sql, params)
return cursor.fetchall() return cursor.fetchall()
@staticmethod @staticmethod
def get_inquiry_detail_logic(cursor, inquiry_id): def get_inquiry_detail_logic(cursor, inquiry_id):
cursor.execute(InquiryQueries.SELECT_BY_ID, (inquiry_id,)) cursor.execute(InquiryQueries.SELECT_BY_ID, (inquiry_id,))
return cursor.fetchone() return cursor.fetchone()
@staticmethod @staticmethod
def update_inquiry_reply_logic(cursor, conn, inquiry_id, req): def update_inquiry_reply_logic(cursor, conn, inquiry_id, req):
handled_date = datetime.now().strftime("%Y.%m.%d") handled_date = datetime.now().strftime("%Y.%m.%d")
cursor.execute(InquiryQueries.UPDATE_REPLY, (req.reply, req.status, req.handler, handled_date, inquiry_id)) cursor.execute(InquiryQueries.UPDATE_REPLY, (req.reply, req.status, req.handler, handled_date, inquiry_id))
conn.commit() conn.commit()
return {"success": True} return {"success": True}
@staticmethod @staticmethod
def delete_inquiry_reply_logic(cursor, conn, inquiry_id): def delete_inquiry_reply_logic(cursor, conn, inquiry_id):
cursor.execute(InquiryQueries.DELETE_REPLY, (inquiry_id,)) cursor.execute(InquiryQueries.DELETE_REPLY, (inquiry_id,))
conn.commit() conn.commit()
return {"success": True} return {"success": True}

View File

@@ -1,463 +1,485 @@
/** /**
* Project Master Analysis JS * Project Master Analysis JS
* AVI (Activity Vitality Index) & VCI (Value Contribution Index) 분석 엔진 * AVI (Activity Vitality Index) & VCI (Value Contribution Index) 분석 엔진
* OCI (Operational Consistency Index) 통합 버전 * OCI (Operational Consistency Index) 통합 버전
*/ */
// Chart.js 플러그인 전역 등록 // Chart.js 플러그인 전역 등록
if (typeof ChartDataLabels !== 'undefined') { if (typeof ChartDataLabels !== 'undefined') {
Chart.register(ChartDataLabels); Chart.register(ChartDataLabels);
} }
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
console.log("Business Analysis Engine initialized..."); console.log("Business Analysis Engine initialized...");
loadProjectAnalysisData(); loadProjectAnalysisData();
}); });
async function loadProjectAnalysisData() { async function loadProjectAnalysisData() {
try { try {
const response = await fetch('/api/analysis/p-war'); const response = await fetch('/api/analysis/p-war');
const data = await response.json(); const data = await response.json();
if (data.error) throw new Error(data.error); if (data.error) throw new Error(data.error);
renderVitalityLeaderboard(data); renderVitalityLeaderboard(data);
renderValueCharts(data); renderValueCharts(data);
if (data.length > 0 && data[0].avg_info) { if (data.length > 0 && data[0].avg_info) {
const avg = data[0].avg_info; const avg = data[0].avg_info;
const infoEl = document.getElementById('avg-system-info'); const infoEl = document.getElementById('avg-system-info');
if (infoEl) infoEl.textContent = `* 시스템 종합 자산 건전도: ${avg.avg_risk}% (운영 표준 70.0% 대비)`; if (infoEl) infoEl.textContent = `* 시스템 종합 운영 활력(AVI): ${avg.avg_risk}% (평균 관리 수준)`;
} }
} catch (e) { } catch (e) {
console.error("분석 데이터 로딩 실패:", e); console.error("분석 데이터 로딩 실패:", e);
} }
} }
function getStatusInfo(avi, isAutoDelete) { function getStatusInfo(avi, isAutoDelete) {
if (isAutoDelete || avi < 10) return { label: '사망', class: 'badge-system', key: 'dead' }; if (isAutoDelete || avi < 10) return { label: '사망', class: 'badge-system', key: 'dead' };
if (avi < 30) return { label: '위험 노출', class: 'badge-danger', key: 'danger' }; if (avi < 30) return { label: '위험 노출', class: 'badge-danger', key: 'danger' };
if (avi < 70) return { label: '관리 주의', class: 'badge-warning', key: 'warning' }; if (avi < 70) return { label: '관리 주의', class: 'badge-warning', key: 'warning' };
return { label: '정상 운영', class: 'badge-active', key: 'active' }; return { label: '정상 운영', class: 'badge-active', key: 'active' };
} }
function getVciGrade(vci) { function getVciGrade(vci) {
if (vci >= 10) return { label: 'Masterpiece', class: 'grade-mvp', desc: '시스템 가치를 견인하는 최우량 핵심 자산' }; 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: 'Blue Chip', class: 'grade-allstar', desc: '꾸준한 활력으로 가치를 창출하는 우량 자산' };
if (vci >= -2) return { label: 'Steady', class: 'grade-starter', desc: '표준 수준의 운영을 유지 중인 안정 자산' }; if (vci >= -2) return { label: 'Steady', class: 'grade-starter', desc: '표준 수준의 운영을 유지 중인 안정 자산' };
if (vci >= -10) return { label: 'Underperform', class: 'grade-bench', desc: '규모 대비 활력 부족으로 가치가 하락 중인 자산' }; if (vci >= -10) return { label: 'Underperform', class: 'grade-bench', desc: '규모 대비 활력 부족으로 가치가 하락 중인 자산' };
return { label: 'Liability', class: 'grade-out', desc: '가치를 훼손 중인 고위험 방치 자산' }; return { label: 'Liability', class: 'grade-out', desc: '가치를 훼손 중인 고위험 방치 자산' };
} }
function renderValueCharts(data) { function renderValueCharts(data) {
if (!data || data.length === 0) return; if (!data || data.length === 0) return;
// 1. 운영 활력 분포 (Doughnut) // 1. 운영 활력 분포 (Doughnut)
try { try {
const stats = { active: [], warning: [], danger: [], dead: [] }; const stats = { active: [], warning: [], danger: [], dead: [] };
data.forEach(p => { data.forEach(p => {
const status = getStatusInfo(p.p_war, p.is_auto_delete); const status = getStatusInfo(p.p_war, p.is_auto_delete);
stats[status.key].push(p); stats[status.key].push(p);
}); });
const statusCtx = document.getElementById('statusChart').getContext('2d'); const statusCtx = document.getElementById('statusChart').getContext('2d');
if (window.myStatusChart) window.myStatusChart.destroy(); if (window.myStatusChart) window.myStatusChart.destroy();
window.myStatusChart = new Chart(statusCtx, { window.myStatusChart = new Chart(statusCtx, {
type: 'doughnut', type: 'doughnut',
data: { data: {
labels: ['정상 운영', '관리 주의', '위험 노출', '사망'], labels: ['정상 운영', '관리 주의', '위험 노출', '사망'],
datasets: [{ datasets: [{
data: [stats.active.length, stats.warning.length, stats.danger.length, stats.dead.length], data: [stats.active.length, stats.warning.length, stats.danger.length, stats.dead.length],
backgroundColor: ['#1E5149', '#22c55e', '#f59e0b', '#ef4444'], backgroundColor: ['#1E5149', '#22c55e', '#f59e0b', '#ef4444'],
borderWidth: 0 borderWidth: 0
}] }]
}, },
options: { options: {
responsive: true, responsive: true,
maintainAspectRatio: false, maintainAspectRatio: false,
layout: { padding: 15 }, layout: { padding: 15 },
plugins: { plugins: {
legend: { position: 'right', labels: { boxWidth: 10, font: { size: 11, weight: '700' }, usePointStyle: true } }, legend: { position: 'right', labels: { boxWidth: 10, font: { size: 11, weight: '700' }, usePointStyle: true } },
datalabels: { display: false } datalabels: { display: false }
}, },
cutout: '65%', cutout: '65%',
onClick: (e, elements) => { onClick: (e, elements) => {
if (elements.length > 0) { if (elements.length > 0) {
const idx = elements[0].index; const idx = elements[0].index;
openProjectListModal(['정상 운영', '관리 주의', '위험 노출', '사망'][idx], stats[['active', 'warning', 'danger', 'dead'][idx]]); openProjectListModal(['정상 운영', '관리 주의', '위험 노출', '사망'][idx], stats[['active', 'warning', 'danger', 'dead'][idx]]);
} }
} }
} }
}); });
} catch (err) { console.error("도넛 차트 에러:", err); } } catch (err) { console.error("도넛 차트 에러:", err); }
// 2. 전략적 자산 매트릭스 (Scatter) - 정밀 복구 // 2. 전략적 자산 매트릭스 (Scatter) - 정밀 복구
try { try {
const sortedByAVI = [...data].sort((a, b) => b.p_war - a.p_war); const sortedByAVI = [...data].sort((a, b) => b.p_war - a.p_war);
const top5Ids = sortedByAVI.slice(0, 5).map(p => p.project_nm); const top5Ids = sortedByAVI.slice(0, 5).map(p => p.project_nm);
const bottom5Ids = sortedByAVI.slice(-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 largeProjects = data.filter(p => p.file_count > 450).map(p => p.project_nm);
const vipProjectNames = new Set([...top5Ids, ...bottom5Ids, ...largeProjects]); const vipProjectNames = new Set([...top5Ids, ...bottom5Ids, ...largeProjects]);
const scatterData = data.map(p => { const scatterData = data.map(p => {
const vci = p.risk_count || 0; const vci = p.risk_count || 0;
const absVci = Math.abs(vci); const absVci = Math.abs(vci);
return { return {
x: Math.min(500, p.file_count), x: Math.min(500, p.file_count),
y: p.p_war, y: p.p_war,
label: p.project_nm, label: p.project_nm,
isVip: vipProjectNames.has(p.project_nm), isVip: vipProjectNames.has(p.project_nm),
vci: vci, vci: vci,
radius: Math.max(5, Math.min(25, 5 + (absVci / 10))) radius: Math.max(5, Math.min(25, 5 + (absVci / 10)))
}; };
}); });
const vitalityCtx = document.getElementById('forecastChart').getContext('2d'); const vitalityCtx = document.getElementById('forecastChart').getContext('2d');
if (window.myVitalityChart) window.myVitalityChart.destroy(); if (window.myVitalityChart) window.myVitalityChart.destroy();
window.myVitalityChart = new Chart(vitalityCtx, { window.myVitalityChart = new Chart(vitalityCtx, {
type: 'scatter', type: 'scatter',
data: { data: {
datasets: [{ datasets: [{
data: scatterData, data: scatterData,
backgroundColor: (ctx) => { backgroundColor: (ctx) => {
const p = ctx.raw; const p = ctx.raw;
if (!p) return '#94a3b8'; if (!p) return '#94a3b8';
if (p.x >= 250 && p.y >= 50) return '#1E5149'; 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 '#22c55e';
if (p.x < 250 && p.y < 50) return '#94a3b8'; if (p.x < 250 && p.y < 50) return '#94a3b8';
return '#ef4444'; return '#ef4444';
}, },
pointRadius: (ctx) => ctx.raw ? ctx.raw.radius : 5, pointRadius: (ctx) => ctx.raw ? ctx.raw.radius : 5,
hoverRadius: (ctx) => (ctx.raw ? ctx.raw.radius : 5) + 3 hoverRadius: (ctx) => (ctx.raw ? ctx.raw.radius : 5) + 3
}] }]
}, },
options: { options: {
responsive: true, responsive: true,
maintainAspectRatio: false, maintainAspectRatio: false,
layout: { padding: { top: 30, right: 45, left: 10, bottom: 10 } }, layout: { padding: { top: 30, right: 45, left: 10, bottom: 10 } },
scales: { scales: {
x: { x: {
type: 'linear', min: 0, max: 500, type: 'linear', min: 0, max: 500,
title: { display: true, text: '자산 규모 (파일 수)', font: { size: 11, weight: '700' } }, title: { display: true, text: '자산 규모 (파일 수)', font: { size: 11, weight: '700' } },
grid: { display: false } grid: { display: false }
}, },
y: { y: {
min: 0, max: 100, min: 0, max: 100,
title: { display: true, text: '운영 활력 (AVI %)', font: { size: 11, weight: '700' } }, title: { display: true, text: '운영 활력 (AVI %)', font: { size: 11, weight: '700' } },
grid: { display: false } grid: { display: false }
} }
}, },
plugins: { plugins: {
legend: { display: false }, legend: { display: false },
datalabels: { datalabels: {
backgroundColor: 'rgba(255, 255, 255, 0.8)', backgroundColor: 'rgba(255, 255, 255, 0.8)',
borderRadius: 4, padding: 4, borderRadius: 4, padding: 4,
font: { size: 10, weight: '800' }, font: { size: 10, weight: '800' },
formatter: (v) => v ? v.label : '', formatter: (v) => v ? v.label : '',
display: (ctx) => ctx.raw && ctx.raw.isVip, display: (ctx) => ctx.raw && ctx.raw.isVip,
clip: false clip: false
}, },
tooltip: { tooltip: {
callbacks: { callbacks: {
label: (ctx) => ` [${ctx.raw.label}] AVI: ${ctx.raw.y.toFixed(1)}% | VCI: ${ctx.raw.vci.toFixed(1)}` label: (ctx) => ` [${ctx.raw.label}] AVI: ${ctx.raw.y.toFixed(1)}% | VCI: ${ctx.raw.vci.toFixed(1)}`
} }
} }
} }
}, },
plugins: [{ plugins: [{
id: 'quadrants', id: 'quadrants',
beforeDraw: (chart) => { beforeDraw: (chart) => {
const { ctx, chartArea: { left, top, right, bottom }, scales: { x, y } } = chart; const { ctx, chartArea: { left, top, right, bottom }, scales: { x, y } } = chart;
const midX = x.getPixelForValue(250); const midX = x.getPixelForValue(250);
const midY = y.getPixelForValue(50); const midY = y.getPixelForValue(50);
ctx.save(); ctx.save();
ctx.fillStyle = 'rgba(34, 197, 94, 0.03)'; ctx.fillRect(left, top, midX - left, midY - top); 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(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(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.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.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.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.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('활력 양호', (left + midX) / 2, (top + midY) / 2);
ctx.fillText('핵심 가치', (midX + right) / 2, (top + midY) / 2); ctx.fillText('핵심 가치', (midX + right) / 2, (top + midY) / 2);
ctx.fillText('정체/소규모', (left + midX) / 2, (midY + bottom) / 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.fillStyle = 'rgba(239, 68, 68, 0.4)'; ctx.fillText('자산 손실 위험', (midX + right) / 2, (midY + bottom) / 2);
ctx.restore(); ctx.restore();
} }
}] }]
}); });
} catch (err) { console.error("전략 매트릭스 에러:", err); } } catch (err) { console.error("전략 매트릭스 에러:", err); }
} }
function renderVitalityLeaderboard(data) { function renderVitalityLeaderboard(data) {
const container = document.getElementById('p-war-table-container'); const container = document.getElementById('p-war-table-container');
if (!container) return; if (!container) return;
const sortedData = [...data].sort((a, b) => a.p_war - b.p_war); const sortedData = [...data].sort((a, b) => a.p_war - b.p_war);
container.innerHTML = ` container.innerHTML = `
<div class="table-scroll-wrapper"> <div class="table-scroll-wrapper">
<table class="data-table p-war-table"> <table class="data-table p-war-table">
<thead> <thead>
<tr> <tr>
<th style="width: 250px;">프로젝트명</th> <th style="width: 250px;">프로젝트명</th>
<th>파일 수</th> <th>파일 수</th>
<th>정체 일수</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 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>운영 활력 (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 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> <th>운영 일관성 (OCI) <button class="btn-help" onclick="event.stopPropagation(); openAnalysisModal('oci')">?</button></th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
${sortedData.map((p, idx) => { ${sortedData.map((p, idx) => {
const status = getStatusInfo(p.p_war, p.is_auto_delete); const status = getStatusInfo(p.p_war, p.is_auto_delete);
const avi = p.p_war; const avi = p.p_war;
const vci = p.risk_count; const vci = p.risk_count;
const oci = p.oci_score || 0; const oci = p.oci_score || 0;
const rowId = `project-${idx}`; const rowId = `project-${idx}`;
const grade = getVciGrade(vci); const grade = getVciGrade(vci);
let rhythmLabel = oci >= 80 ? "정기적" : oci >= 50 ? "안정적" : oci >= 20 ? "간헐적" : "불규칙"; let rhythmLabel = oci >= 80 ? "정기적" : oci >= 50 ? "안정적" : oci >= 20 ? "간헐적" : "불규칙";
let rhythmColor = oci >= 80 ? "#059669" : oci >= 50 ? "#1e5149" : oci >= 20 ? "#f59e0b" : "#dc2626"; let rhythmColor = oci >= 80 ? "#059669" : oci >= 50 ? "#1e5149" : oci >= 20 ? "#f59e0b" : "#dc2626";
// 존재 신뢰도 패널티 (ECV) 상세 설명 복구 // 존재 신뢰도 패널티 (ECV) 상세 설명 복구
let ecvText = "100% (데이터 실체 검증)"; let ecvText = "100% (데이터 실체 검증)";
let ecvClass = "highlight-val"; let ecvClass = "highlight-val";
let ecvDesc = `현재 ${p.file_count}개의 유효 성과물이 확인됩니다. 시스템적으로 실체가 완벽히 존재하는 상태입니다.`; let ecvDesc = `현재 ${p.file_count}개의 유효 성과물이 확인됩니다. 시스템적으로 실체가 완벽히 존재하는 상태입니다.`;
if (p.file_count === 0) { if (p.file_count === 0) {
ecvText = "5% (유령 프로젝트 판명)"; ecvText = "5% (유령 프로젝트 판명)";
ecvClass = "highlight-penalty"; ecvClass = "highlight-penalty";
ecvDesc = "데이터가 전무하여 프로젝트의 디지털 실체가 없습니다. 모든 분석에서 최하위 패널티가 적용됩니다."; ecvDesc = "데이터가 전무하여 프로젝트의 디지털 실체가 없습니다. 모든 분석에서 최하위 패널티가 적용됩니다.";
} else if (p.file_count < 10) { } else if (p.file_count < 10) {
ecvText = "40% (형식적 껍데기 판명)"; ecvText = "40% (형식적 껍데기 판명)";
ecvClass = "highlight-penalty"; ecvClass = "highlight-penalty";
ecvDesc = "최소 수준의 문서만 존재하며, 실질적인 운영 가치를 인정하기 어려운 소규모 상태입니다."; ecvDesc = "최소 수준의 문서만 존재하며, 실질적인 운영 가치를 인정하기 어려운 소규모 상태입니다.";
} }
// 활동 품질 텍스트 복구 // 활동 품질 텍스트 복구
const qualityLabel = p.log_quality >= 1.0 ? '성과물 중심의 <b>실무 활동</b>' : p.log_quality >= 0.7 ? '구조 관리를 위한 <b>시스템 활동</b>' : '단순 행정 기반의 <b>형식 활동</b>'; 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 ? '폴더 생성/이동 등 구조적 관리는 이뤄지고 있으나, 직접적 결과물 생산은 부족합니다.' : '메일 확인, 권한 변경 등 시스템 유지성 활동 위주로 파악되어 품질 가중치가 낮게 적용되었습니다.'; const qualityDetail = p.log_quality >= 1.0 ? '최근 로그에서 파일 업로드/수정 등 가치 증분 활동이 명확히 포착되었습니다.' : p.log_quality >= 0.7 ? '폴더 생성/이동 등 구조적 관리는 이뤄지고 있으나, 직접적 결과물 생산은 부족합니다.' : '메일 확인, 권한 변경 등 시스템 유지성 활동 위주로 파악되어 품질 가중치가 낮게 적용되었습니다.';
return ` return `
<tr class="project-row ${status.key === 'danger' ? 'row-danger' : status.key === 'warning' ? 'row-warning' : ''}" onclick="toggleProjectDetail('${rowId}')"> <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 class="font-bold">${p.project_nm}</td>
<td>${p.file_count.toLocaleString()}</td> <td>${p.file_count.toLocaleString()}</td>
<td>${p.days_stagnant}</td> <td>${p.days_stagnant}</td>
<td><span class="${status.class}">${status.label}</span></td> <td><span class="${status.class}">${status.label}</span></td>
<td style="text-align:right; font-weight:800; color:${vci >= 0 ? '#059669' : '#dc2626'};"> <td style="text-align:right; font-weight:800; color:${vci >= 0 ? '#059669' : '#dc2626'};">
${vci > 0 ? '+' : ''}${vci.toFixed(1)} ${vci > 0 ? '+' : ''}${vci.toFixed(1)}
</td> </td>
<td class="p-war-value ${avi >= 70 ? 'text-plus' : 'text-minus'}">${avi.toFixed(1)}%</td> <td class="p-war-value ${avi >= 70 ? 'text-plus' : 'text-minus'}">${avi.toFixed(1)}%</td>
<td style="text-align:center;"> <td style="text-align:center;">
<div style="display:flex; flex-direction:column; align-items:center; gap:4px;"> <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> <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: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 style="width:${p.work_effort}%; height:100%; background:${p.work_effort >= 70 ? '#059669' : p.work_effort <= 30 ? '#dc2626' : '#6366f1'};"></div>
</div> </div>
</div> </div>
</td> </td>
<td style="text-align:center;"> <td style="text-align:center;">
<div style="display:flex; align-items:center; justify-content:center; gap:8px;"> <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-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> <span style="font-size:10px; padding:2px 6px; border-radius:10px; background:${rhythmColor}15; color:${rhythmColor}; border:1px solid ${rhythmColor}30;">${rhythmLabel}</span>
</div> </div>
</td> </td>
</tr> </tr>
<tr id="detail-${rowId}" class="detail-row"> <tr id="detail-${rowId}" class="detail-row">
<td colspan="8"> <td colspan="8">
<div class="detail-container"> <div class="detail-container">
<div class="formula-explanation-card"> <div class="formula-explanation-card">
<div class="formula-header"> AI 위험 적응형 모델(AAS) 기반 인과관계 분석</div> <div class="formula-header"> AI 위험 적응형 모델(AAS) 기반 인과관계 분석</div>
<div style="display: flex; gap: 20px; margin-bottom: 20px;"> <div style="display: flex; gap: 20px; margin-bottom: 20px;">
<div class="work-effort-section" style="flex: 1; margin-bottom: 0;"> <div class="work-effort-section" style="flex: 1; margin-bottom: 0;">
<div class="work-effort-header"> <div class="work-effort-header">
<span style="font-size: 13px; font-weight: 800; color: #1e5149;">📊 실질 업무 집중도 (Job Focus)</span> <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> <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>
<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 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 style="font-size: 11px; color: #64748b; line-height: 1.5;">최근 30 수집 이력 단순 로그 갱신이 아닌 <b>실제 성과물의 변동</b> . '' .</div>
</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="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="text-align: center;">
<div style="font-size: 10px; color: #64748b; font-weight: 700; margin-bottom: 2px;">VCI GRADE</div> <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 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>
<div style="font-size: 12px; color: #475569; line-height: 1.4; font-weight: 600;">${grade.desc}</div> <div style="font-size: 12px; color: #475569; line-height: 1.4; font-weight: 600;">${grade.desc}</div>
</div> </div>
</div> </div>
<div class="formula-steps-grid"> <div class="formula-steps-grid">
<div class="formula-step"> <div class="formula-step">
<div class="step-num">1</div> <div class="step-num">1</div>
<div class="step-content"> <div class="step-content">
<div class="step-title">동적 위험 계수(λ) 산출</div> <div class="step-title">동적 위험 계수(λ) 산출</div>
<div style="font-size:11px; color:#64748b; margin-bottom:5px;">프로젝트 규모가 클수록 정보 망실 시의 충격을 반영하여 데이터의 하락 속도가 가속됩니다. 현재 <b>λ=${p.ai_lambda.toFixed(4)}</b> .</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 class="math-logic">Dynamic λ = <span class="highlight-var">${p.ai_lambda.toFixed(4)}</span></div>
</div> </div>
</div> </div>
<div class="formula-step"> <div class="formula-step">
<div class="step-num">4</div> <div class="step-num">4</div>
<div class="step-content"> <div class="step-content">
<div class="step-title">활동 품질 검증 (Quality)</div> <div class="step-title">활동 품질 검증 (Quality)</div>
<div class="step-desc" style="font-size:11px; margin-bottom:5px;">최근 로그 분석 결과 <b>${qualityLabel}</b> . ${qualityDetail}</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 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> </div>
<div class="formula-step"> <div class="formula-step">
<div class="step-num">2</div> <div class="step-num">2</div>
<div class="step-content"> <div class="step-content">
<div class="step-title">방치 시간 감쇄 적용</div> <div class="step-title">방치 시간 감쇄 적용</div>
<div style="font-size:11px; color:#64748b; margin-bottom:5px;">마지막 유효 활동 이후 <b>${p.days_stagnant}</b> .</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 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> </div>
<div class="formula-step"> <div class="formula-step">
<div class="step-num">3</div> <div class="step-num">3</div>
<div class="step-content"> <div class="step-content">
<div class="step-title">존재 진정성 (ECV)</div> <div class="step-title">존재 진정성 (ECV)</div>
<div style="font-size:11px; color:#64748b; margin-bottom:5px;">${ecvDesc} 파일 자체가 분석의 데이터 진정성을 보정하는 핵심 팩터로 작용합니다.</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 class="math-logic">Entity Factor = <span class="${ecvClass}">${ecvText}</span></div>
</div> </div>
</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="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="text-align: left; max-width: 70%;">
<div style="font-size: 13px; font-weight: 800; color: ${vci >= 0 ? '#059669' : '#dc2626'}; margin-bottom: 4px;"> <div style="display: flex; align-items: center; gap: 8px; margin-bottom: 4px;">
가치 기여도 (VCI) 진단: ${vci >= 0 ? '+' : ''}${vci.toFixed(2)} <div style="font-size: 13px; font-weight: 800; color: ${vci >= 0 ? '#059669' : '#dc2626'};">
</div> 가치 기여도 (VCI) 진단: ${vci >= 0 ? '+' : ''}${vci.toFixed(2)}
<div style="font-size: 11px; color: #64748b; line-height: 1.5;"> </div>
현재 프로젝트는 운영 표준(AVI 70%) 대비 <b>${Math.abs(avi - 70).toFixed(1)}%p ${avi >= 70 ? '상회' : '하회'}</b> , <div style="font-size: 10px; color: #94a3b8; background: #f8fafc; padding: 2px 6px; border-radius: 4px; border: 1px solid #e2e8f0;">
<b>${p.file_count}</b>의 자산 규모에 따른 <b>${((p.file_count / 200) + 0.5).toFixed(2)}</b> . 조직 평균 자산: ${p.avg_info.avg_files}
이는 시스템 전체 관점에서 <b>${vci >= 0 ? '순자산 가치를 증대' : '잠재적 기회비용을 손실'}</b> . </div>
</div> </div>
</div> <div style="font-size: 11px; color: #64748b; line-height: 1.5;">
<div> 현재 프로젝트는 <b>포트폴리오 평균 관리 수준</b> 대비 <b>${Math.abs(vci / Math.max(0.2, p.file_count / p.avg_info.avg_files)).toFixed(1)}%p ${vci >= 0 ? '상회' : '하회'}</b> ,
<span style="font-size: 13px; color: #64748b; font-weight: 700;">최종 AVI: </span> <b>${p.file_count}</b>의 자산 규모에 따른 <b>${Math.max(0.2, p.file_count / p.avg_info.avg_files).toFixed(2)}</b> .
<span style="color: #1e5149; font-size: 22px; font-weight: 900;">${avi.toFixed(1)}%</span> 이는 시스템 전체 관점에서 <b>${vci >= 0 ? '순자산 가치를 증대' : '잠재적 기회비용을 손실'}</b> .
</div> </div>
</div> </div>
</div> <div>
</div> <span style="font-size: 13px; color: #64748b; font-weight: 700;">최종 AVI: </span>
</td> <span style="color: #1e5149; font-size: 22px; font-weight: 900;">${avi.toFixed(1)}%</span>
</tr>`; </div>
}).join('')} </div>
</tbody> </div>
</table> </div>
</div>`; </td>
} </tr>`;
}).join('')}
function toggleProjectDetail(rowId) { </tbody>
const container = document.querySelector('.table-scroll-wrapper'); </table>
const mainRow = document.querySelector(`tr[onclick*="toggleProjectDetail('${rowId}')"]`); </div>`;
const detailRow = document.getElementById(`detail-${rowId}`); }
if (detailRow && container) { function toggleProjectDetail(rowId) {
if (!detailRow.classList.contains('active')) { const container = document.querySelector('.table-scroll-wrapper');
document.querySelectorAll('.detail-row').forEach(row => row.classList.remove('active')); const mainRow = document.querySelector(`tr[onclick*="toggleProjectDetail('${rowId}')"]`);
detailRow.classList.add('active'); const detailRow = document.getElementById(`detail-${rowId}`);
// 정밀 스크롤 이동 로직 복구 if (detailRow && container) {
setTimeout(() => { if (!detailRow.classList.contains('active')) {
const headerH = container.querySelector('thead').offsetHeight || 45; document.querySelectorAll('.detail-row').forEach(row => row.classList.remove('active'));
const targetScrollTop = mainRow.offsetTop - headerH; detailRow.classList.add('active');
container.scrollTo({ top: targetScrollTop, behavior: 'smooth' });
}, 100); // 정밀 스크롤 이동 로직 복구
} else { setTimeout(() => {
detailRow.classList.remove('active'); const headerH = container.querySelector('thead').offsetHeight || 45;
} const targetScrollTop = mainRow.offsetTop - headerH;
} container.scrollTo({ top: targetScrollTop, behavior: 'smooth' });
} }, 100);
} else {
function openProjectListModal(label, projects) { detailRow.classList.remove('active');
const modal = document.getElementById('analysisModal'); }
const title = document.getElementById('modalTitle'); }
const body = document.getElementById('modalBody'); }
title.innerText = `[${label}] 프로젝트 리스트 (${projects.length}건)`;
body.innerHTML = ` function openProjectListModal(label, projects) {
<div class="table-scroll-wrapper" style="max-height: 400px;"> const modal = document.getElementById('analysisModal');
<table class="data-table"> const title = document.getElementById('modalTitle');
<thead><tr><th>프로젝트명</th><th>관리자</th><th>정체일</th><th>AVI</th></tr></thead> const body = document.getElementById('modalBody');
<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> title.innerText = `[${label}] 프로젝트 리스트 (${projects.length}건)`;
</table> body.innerHTML = `
</div> <div class="table-scroll-wrapper" style="max-height: 400px;">
<div class="modal-footer"><button class="btn btn-primary" onclick="closeAnalysisModal()">닫기</button></div>`; <table class="data-table">
modal.style.display = 'flex'; <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>
function openAnalysisModal(type) { </div>
const modal = document.getElementById('analysisModal'); <div class="modal-footer"><button class="btn btn-primary" onclick="closeAnalysisModal()">닫기</button></div>`;
const title = document.getElementById('modalTitle'); modal.style.display = 'flex';
const body = document.getElementById('modalBody'); }
if (type === 'avi') { function openAnalysisModal(type) {
title.innerText = '운영 활력 지수 (AVI) 등급 가이드'; const modal = document.getElementById('analysisModal');
body.innerHTML = ` const title = document.getElementById('modalTitle');
<div class="formula-box" style="margin-bottom:15px;">AVI = exp(-λ × days) × Quality × 100</div> const body = document.getElementById('modalBody');
<p style="font-size:13px; color:#64748b; margin-bottom:15px;">자산의 가동 상태와 생존율을 나타내는 지표입니다.</p>
<table class="data-table" style="font-size:12px;"> if (type === 'avi') {
<thead><tr style="background:#f8fafc;"><th>지수 (AVI)</th><th>등급</th><th>운영 상태</th></tr></thead> title.innerText = '운영 활력 지수 (AVI) 분석 가이드';
<tbody> body.innerHTML = `
<tr><td>90%↑</td><td style="font-weight:900; color:#059669;">Live</td><td>실시간 성과물이 도출되는 최상급 가동</td></tr> <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;">
<tr><td>70~90%</td><td style="font-weight:900; color:#1e5149;">Stable</td><td>주기적 업데이트가 이뤄지는 표준 안정</td></tr> AVI = exp(-λ × Stagnant Days) × Quality × 100
<tr><td>30~70%</td><td style="font-weight:900; color:#f59e0b;">Idle</td><td>관리가 필요한 유휴/정체 상태</td></tr> </div>
<tr><td>10~30%</td><td style="font-weight:900; color:#dc2626;">Risk</td><td>자산 가치 소멸 직전의 위험 상태</td></tr> <div style="margin-bottom:15px; font-size:13px; line-height:1.6; color:#334155;">
<tr><td>10%↓</td><td style="font-weight:900; color:#64748b;">Frozen</td><td>운영이 중단된 사망/방치 상태</td></tr> <p style="margin-bottom:8px;"><b>운영 활력 지수(AVI)</b>는 프로젝트가 현재 얼마나 건강하게 가동되고 있는지를 나타내는 '디지털 생존 지표'입니다.</p>
</tbody> <ul style="padding-left:18px; margin-bottom:10px;">
</table> <li style="margin-bottom:4px;"><b>지수 감쇄(Exponential Decay)</b>: 마지막 활동 이후 정체 기간이 길어질수록 자산의 최신성과 가치는 기하급수적으로 하락합니다.</li>
<div class="modal-footer"><button class="btn btn-primary" onclick="closeAnalysisModal()">닫기</button></div>`; <li style="margin-bottom:4px;"><b>위험 가속 계수(λ)</b>: 자산 규모(파일 수)가 클수록 관리 부재 시의 정보 망실 위험이 크다고 판단하여, 더 가파른 감쇄 곡선을 적용합니다.</li>
} else if (type === 'vci') { <li style="margin-bottom:4px;"><b>활동 품질(Quality Factor)</b>: 단순 행정 로그(권한 변경 등)보다 실무 성과물(파일 업로드 등)이 발생했을 때 지수 복원력을 더 높게 부여합니다.</li>
title.innerText = '자산 가치 기여도 (VCI) 등급 가이드'; </ul>
body.innerHTML = ` <p style="margin:0; font-weight:700; color:#1e5149;">※ 70% 미만 하락 시, 해당 프로젝트의 데이터 노후화 및 관리 방치 위험이 시작된 것으로 간주합니다.</p>
<div class="formula-box" style="margin-bottom:15px;">VCI = (AVI - 70.0) × (Files / 200 + 0.5)</div> </div>
<p style="font-size:13px; color:#64748b; margin-bottom:15px;">운영 표준(AVI 70%) 대비 자산 가치 기여도에 따른 프로젝트 위상 분류입니다.</p> <table class="data-table" style="font-size:12px; width:100%;">
<table class="data-table" style="font-size:12px;"> <thead><tr style="background:#f8fafc;"><th>지수 (AVI)</th><th>등급</th><th>운영 상태</th></tr></thead>
<thead><tr style="background:#f8fafc;"><th>점수 (VCI)</th><th>등급</th><th>운영 의미</th></tr></thead> <tbody>
<tbody> <tr><td class="font-bold">90%↑</td><td style="font-weight:900; color:#059669;">Live</td><td>실시간 성과물이 도출되는 최상급 가동 상태</td></tr>
<tr><td>+10.0↑</td><td style="font-weight:900; color:#6366f1;">Masterpiece</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>+2.0 ~ +10.0</td><td style="font-weight:900; color:#059669;">Blue Chip</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>-2.0 ~ +2.0</td><td style="font-weight:900; color:#475569;">Steady</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>-10.0 ~ -2.0</td><td style="font-weight:900; color:#f59e0b;">Underperform</td><td>규모 대비 활력 부족으로 가치 하락 중인 자산</td></tr> <tr><td class="font-bold">10%↓</td><td style="font-weight:900; color:#64748b;">Frozen</td><td>운영이 중단된 사망/방치 상태</td></tr>
<tr><td>-10.0↓</td><td style="font-weight:900; color:#dc2626;">Liability</td><td>가치를 훼손 중인 고위험 방치 자산</td></tr> </tbody>
</tbody> </table>
</table> <div class="modal-footer"><button class="btn btn-primary" onclick="closeAnalysisModal()">닫기</button></div>`;
<div class="modal-footer"><button class="btn btn-primary" onclick="closeAnalysisModal()">닫기</button></div>`; } else if (type === 'vci') {
} else if (type === 'oci') { title.innerText = '자산 가치 기여도 (VCI) 분석 가이드';
title.innerText = '운영 일관성 지수 (OCI) 분석 가이드'; body.innerHTML = `
body.innerHTML = ` <p style="margin-bottom:12px; font-size:13.5px;">VCI는 야구의 <b>WAR(Wins Above Replacement)</b> 개념을 도입하여, 개별 프로젝트가 전체 포트폴리오 평균 대비 얼마나 조직의 가치에 기여하는지 산출한 지표입니다.</p>
<div style="background:#f0fdf4; padding:15px; border-radius:8px; margin-bottom:15px;"> <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;">
<strong style="color:#166534; display:block; margin-bottom:5px;">"얼마나 꾸준하게 관리되고 있는가?"</strong> VCI = (현재 AVI - 전체 평균 AVI) × (파일 규모 가중치)
<p style="font-size:12.5px; color:#166534; margin:0;">미래 예측이 아닌, 최근 30일간의 <b>활동 리듬</b>과 <b>관리의 규칙성</b>을 분석하여 성실도를 점수화합니다.</p> </div>
</div> <p style="margin-bottom:15px; font-size:13px; line-height:1.6;">
<table class="data-table" style="font-size:12px;"> • <b>0.0 (평균)</b>: 우리 조직의 평균적인 관리 수준을 유지 중인 상태<br>
<thead><tr style="background:#f8fafc;"><th>분석 결과</th><th>일관성 등급</th><th>관리 신뢰도</th></tr></thead> • <b>(+) 점수</b>: 평균 이상의 활력으로 조직의 디지털 자산 가치를 증대시킴<br>
<tbody> • <b>(-) 점수</b>: 평균 이하의 방치로 인해 잠재적 기회비용 손실 발생 중
<tr><td style="color:#059669;">80%↑</td><td style="font-weight:900; color:#059669;">매우 우수</td><td>주 단위의 정기적 관리가 완벽히 이뤄짐</td></tr> </p>
<tr><td style="color:#1e5149;">50~80%</td><td style="font-weight:900; color:#1e5149;">양호</td><td>간헐적 정체는 있으나 꾸준히 관리됨</td></tr> <table class="data-table" style="font-size:12px; width:100%;">
<tr><td style="color:#f59e0b;">20~50%</td><td style="font-weight:900; color:#f59e0b;">주의</td><td>돌발적 활동 위주, 관리의 리듬이 깨짐</td></tr> <thead><tr style="background:#f8fafc;"><th>점수 (VCI)</th><th>등급</th><th>운영 의미</th></tr></thead>
<tr><td style="color:#dc2626;">20%↓</td><td style="font-weight:900; color:#dc2626;">매우 불량</td><td>장기 정체 중이거나 관리 의지 확인 불가</td></tr> <tbody>
</tbody> <tr><td>+10.0↑</td><td style="font-weight:900; color:#6366f1;">Masterpiece</td><td>시스템 가치를 견인하는 최우량 핵심 자산</td></tr>
</table> <tr><td>+2.0 ~ +10.0</td><td style="font-weight:900; color:#059669;">Blue Chip</td><td>꾸준한 활력으로 가치를 창출하는 우량 자산</td></tr>
<div class="modal-footer"><button class="btn btn-primary" onclick="closeAnalysisModal()">닫기</button></div>`; <tr><td>-2.0 ~ +2.0</td><td style="font-weight:900; color:#475569;">Steady</td><td>평균 수준의 운영을 유지 중인 안정 자산</td></tr>
} else { <tr><td>-10.0 ~ -2.0</td><td style="font-weight:900; color:#f59e0b;">Underperform</td><td>평균 대비 활력 부족으로 가치 하락 중인 자산</td></tr>
title.innerText = '업무 집중도 (Job Focus) 등급 가이드'; <tr><td>-10.0↓</td><td style="font-weight:900; color:#dc2626;">Liability</td><td>가치를 훼손 중인 고위험 방치 자산</td></tr>
body.innerHTML = ` </tbody>
<p style="font-size:13px; color:#64748b; margin-bottom:15px;">최근 수집 로그 중 단순 행정 로그를 제외하고 실질적인 성과물(파일) 변동이 포착된 비율입니다.</p> </table>
<table class="data-table" style="font-size:12px;"> <div class="modal-footer"><button class="btn btn-primary" onclick="closeAnalysisModal()">닫기</button></div>`;
<thead><tr style="background:#f8fafc;"><th>비율 (%)</th><th>등급</th><th>활동 성격</th></tr></thead> } else if (type === 'oci') {
<tbody> title.innerText = '운영 일관성 지수 (OCI) 분석 가이드';
<tr><td>80%↑</td><td style="font-weight:900; color:#6366f1;">Intensive</td><td>성과물 위주의 고밀도 집중 작업</td></tr> body.innerHTML = `
<tr><td>50~80%</td><td style="font-weight:900; color:#059669;">Active</td><td>성과와 관리가 균형 잡힌 원활한 실행</td></tr> <div style="background:#f0fdf4; padding:15px; border-radius:8px; margin-bottom:15px;">
<tr><td>20~50%</td><td style="font-weight:900; color:#f59e0b;">Maintenance</td><td>설정/행정 등 단순 관리 중심의 작업</td></tr> <strong style="color:#166534; display:block; margin-bottom:5px;">"얼마나 꾸준하게 관리되고 있는가?"</strong>
<tr><td>20%↓</td><td style="font-weight:900; color:#dc2626;">Surface</td><td>실체적 변화가 적은 형식적 로그 중심</td></tr> <p style="font-size:12.5px; color:#166534; margin:0;">미래 예측이 아닌, 최근 30일간의 <b>활동 리듬</b>과 <b>관리의 규칙성</b>을 분석하여 성실도를 점수화합니다.</p>
</tbody> </div>
</table> <table class="data-table" style="font-size:12px;">
<div class="modal-footer"><button class="btn btn-primary" onclick="closeAnalysisModal()">닫기</button></div>`; <thead><tr style="background:#f8fafc;"><th>분석 결과</th><th>일관성 등급</th><th>관리 신뢰도</th></tr></thead>
} <tbody>
modal.style.display = 'flex'; <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>
function closeAnalysisModal() { document.getElementById('analysisModal').style.display = 'none'; } <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) { function renderPWarLeaderboard(data) {
const container = document.getElementById('p-war-table-container'); const container = document.getElementById('p-war-table-container');
if (!container) return; if (!container) return;
const sortedData = [...data].sort((a, b) => a.p_war - b.p_war); const sortedData = [...data].sort((a, b) => a.p_war - b.p_war);
container.innerHTML = ` container.innerHTML = `
<div class="table-scroll-wrapper"> <div class="table-scroll-wrapper">
<table class="data-table p-war-table"> <table class="data-table p-war-table">
<thead> <thead>
<tr> <tr>
<th style="position: sticky; top: 0; z-index: 10; width: 280px;">프로젝트명</th> <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;">방치일</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 style="position: sticky; top: 0; z-index: 10;">
활력 지수 (AVI) <button class="btn-help" onclick="event.stopPropagation(); openAnalysisModal('avi')">?</button> 활력 지수 (AVI) <button class="btn-help" onclick="event.stopPropagation(); openAnalysisModal('avi')">?</button>
</th> </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: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; text-align:center;">업무 집중도</th>
<th style="position: sticky; top: 0; z-index: 10;"> <th style="position: sticky; top: 0; z-index: 10;">
운영 일관성 (OCI) <button class="btn-help" onclick="event.stopPropagation(); openAnalysisModal('oci')">?</button> 운영 일관성 (OCI) <button class="btn-help" onclick="event.stopPropagation(); openAnalysisModal('oci')">?</button>
</th> </th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
${sortedData.map((p, idx) => { ${sortedData.map((p, idx) => {
const status = getStatusInfo(p.p_war, p.is_auto_delete); const status = getStatusInfo(p.p_war, p.is_auto_delete);
const avi = p.p_war; const avi = p.p_war;
const vci = p.risk_count; const vci = p.risk_count;
const oci = p.oci_score || 0; const oci = p.oci_score || 0;
const rowId = `project-${idx}`; const rowId = `project-${idx}`;
let rhythmLabel = ""; let rhythmLabel = "";
let rhythmColor = ""; let rhythmColor = "";
if (oci >= 80) { rhythmLabel = "정기적"; rhythmColor = "#059669"; } if (oci >= 80) { rhythmLabel = "정기적"; rhythmColor = "#059669"; }
else if (oci >= 50) { rhythmLabel = "안정적"; rhythmColor = "#1e5149"; } else if (oci >= 50) { rhythmLabel = "안정적"; rhythmColor = "#1e5149"; }
else if (oci >= 20) { rhythmLabel = "간헐적"; rhythmColor = "#f59e0b"; } else if (oci >= 20) { rhythmLabel = "간헐적"; rhythmColor = "#f59e0b"; }
else { rhythmLabel = "불규칙"; rhythmColor = "#dc2626"; } else { rhythmLabel = "불규칙"; rhythmColor = "#dc2626"; }
// 존재 신뢰도 패널티 (ECV) 텍스트 준비 // 존재 신뢰도 패널티 (ECV) 텍스트 준비
let ecvText = "100% (데이터 신뢰)"; let ecvText = "100% (데이터 신뢰)";
let ecvClass = "highlight-val"; let ecvClass = "highlight-val";
let ecvDesc = "충분한 성과물이 존재합니다."; let ecvDesc = "충분한 성과물이 존재합니다.";
if (p.file_count === 0) { if (p.file_count === 0) {
ecvText = "5% (유령 프로젝트)"; ecvText = "5% (유령 프로젝트)";
ecvClass = "highlight-penalty"; ecvClass = "highlight-penalty";
ecvDesc = "성과물이 전무하여 시스템 가치가 소멸되었습니다."; ecvDesc = "성과물이 전무하여 시스템 가치가 소멸되었습니다.";
} else if (p.file_count < 10) { } else if (p.file_count < 10) {
ecvText = "40% (소규모 껍데기)"; ecvText = "40% (소규모 껍데기)";
ecvClass = "highlight-penalty"; ecvClass = "highlight-penalty";
ecvDesc = "최소 수준의 데이터만 존재하여 가치가 낮게 평가됩니다."; ecvDesc = "최소 수준의 데이터만 존재하여 가치가 낮게 평가됩니다.";
} }
// 활동 품질 텍스트 준비 // 활동 품질 텍스트 준비
const qualityLabel = p.log_quality >= 1.0 ? '성과물 직결 <b>실무 활동</b>' : p.log_quality >= 0.7 ? '시스템 <b>구조적 활동</b>' : '단순 <b>행정적 활동</b>'; const qualityLabel = p.log_quality >= 1.0 ? '성과물 직결 <b>실무 활동</b>' : p.log_quality >= 0.7 ? '시스템 <b>구조적 활동</b>' : '단순 <b>행정적 활동</b>';
return ` return `
<tr class="project-row ${status.key === 'danger' ? 'row-danger' : status.key === 'warning' ? 'row-warning' : ''}" <tr class="project-row ${status.key === 'danger' ? 'row-danger' : status.key === 'warning' ? 'row-warning' : ''}"
onclick="toggleProjectDetail('${rowId}')"> onclick="toggleProjectDetail('${rowId}')">
<td class="font-bold">${p.project_nm}</td> <td class="font-bold">${p.project_nm}</td>
<td>${p.file_count.toLocaleString()}개</td> <td>${p.file_count.toLocaleString()}개</td>
<td>${p.days_stagnant}일</td> <td>${p.days_stagnant}일</td>
<td><span class="${status.class}">${status.label}</span></td> <td><span class="${status.class}">${status.label}</span></td>
<td class="p-war-value ${avi >= 70 ? 'text-plus' : 'text-minus'}"> <td class="p-war-value ${avi >= 70 ? 'text-plus' : 'text-minus'}">
${avi.toFixed(1)}% ${avi.toFixed(1)}%
</td> </td>
<td style="text-align:right; font-weight:700; color:${vci >= 0 ? '#059669' : '#dc2626'};"> <td style="text-align:right; font-weight:700; color:${vci >= 0 ? '#059669' : '#dc2626'};">
${vci >= 0 ? '+' : ''}${vci.toFixed(2)} ${vci >= 0 ? '+' : ''}${vci.toFixed(2)}
</td> </td>
<td style="text-align:center;"> <td style="text-align:center;">
<div style="display:flex; flex-direction:column; align-items:center; gap:4px;"> <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'};"> <span style="font-weight:800; font-size:12px; color:${p.work_effort >= 70 ? '#059669' : p.work_effort <= 30 ? '#dc2626' : '#6366f1'};">
${p.work_effort}% ${p.work_effort}%
</span> </span>
<div style="width:40px; height:4px; background:#f1f5f9; border-radius:2px; overflow:hidden;"> <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 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>
</div> </div>
</td> </td>
<td style="text-align:center;"> <td style="text-align:center;">
<div style="display:flex; align-items:center; justify-content:center; gap:8px;"> <div style="display:flex; align-items:center; justify-content:center; gap:8px;">
<span style="font-weight:800; font-size:13px; color:${rhythmColor};"> <span style="font-weight:800; font-size:13px; color:${rhythmColor};">
${oci}% ${oci}%
</span> </span>
<span style="font-size:10px; padding:2px 6px; border-radius:10px; background:${rhythmColor}15; color:${rhythmColor}; border:1px solid ${rhythmColor}30;"> <span style="font-size:10px; padding:2px 6px; border-radius:10px; background:${rhythmColor}15; color:${rhythmColor}; border:1px solid ${rhythmColor}30;">
${rhythmLabel} ${rhythmLabel}
</span> </span>
</div> </div>
</td> </td>
</tr> </tr>
<tr id="detail-${rowId}" class="detail-row"> <tr id="detail-${rowId}" class="detail-row">
<td colspan="8"> <td colspan="8">
<div class="detail-container"> <div class="detail-container">
<div class="formula-explanation-card"> <div class="formula-explanation-card">
<div style="font-size: 13px; font-weight: 700; color: #6366f1; margin-bottom: 15px;"> <div style="font-size: 13px; font-weight: 700; color: #6366f1; margin-bottom: 15px;">
⚙️ AI 위험 적응형 모델(AAS) 산출 시뮬레이션 ⚙️ AI 위험 적응형 모델(AAS) 산출 시뮬레이션
</div> </div>
<!-- 업무 집중도 분석 (상단 배치) --> <!-- 업무 집중도 분석 (상단 배치) -->
<div style="background: #f8fafc; padding: 15px; border-radius: 8px; margin-bottom: 20px; border: 1px solid #eef2f6;"> <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;"> <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: 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'};"> <span style="font-size: 14px; font-weight: 800; color: ${p.work_effort >= 70 ? '#059669' : p.work_effort <= 30 ? '#dc2626' : '#6366f1'};">
집중도 ${p.work_effort}% 집중도 ${p.work_effort}%
</span> </span>
</div> </div>
<div style="width: 100%; height: 6px; background: #e2e8f0; border-radius: 3px; overflow: hidden; margin-bottom: 10px;"> <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 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>
<div style="font-size: 11.5px; color: #64748b; line-height: 1.5;"> <div style="font-size: 11.5px; color: #64748b; line-height: 1.5;">
최근 30개 수집 이력 중 단순 로그 갱신이 아닌 <b>실제 파일 수의 변동</b>이 포착된 날의 비율입니다. 최근 30개 수집 이력 중 단순 로그 갱신이 아닌 <b>실제 파일 수의 변동</b>이 포착된 날의 비율입니다.
현재 이 프로젝트는 <b>${p.work_effort >= 70 ? '매우 밀도 높은 실무' : p.work_effort <= 30 ? '형식적 관리 위주의 정체' : '간헐적인 성과물'}</b> 상태를 보이고 있습니다. 현재 이 프로젝트는 <b>${p.work_effort >= 70 ? '매우 밀도 높은 실무' : p.work_effort <= 30 ? '형식적 관리 위주의 정체' : '간헐적인 성과물'}</b> 상태를 보이고 있습니다.
</div> </div>
</div> </div>
<!-- 수식 단계 2x2 그리드 --> <!-- 수식 단계 2x2 그리드 -->
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 20px;"> <div style="display: grid; grid-template-columns: 1fr 1fr; gap: 20px;">
<div class="formula-step"> <div class="formula-step">
<div class="step-num">1</div> <div class="step-num">1</div>
<div class="step-content"> <div class="step-content">
<div class="step-title">동적 위험 계수(λ) 산출</div> <div class="step-title">동적 위험 계수(λ) 산출</div>
<div style="font-size:11px; color:#64748b; margin-bottom:5px;">자산 규모(${p.file_count}개) 및 부서 위험도를 합산한 하락 속도입니다.</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 class="math-logic">λ = <span class="highlight-var">${p.ai_lambda.toFixed(4)}</span></div>
</div> </div>
</div> </div>
<div class="formula-step"> <div class="formula-step">
<div class="step-num">4</div> <div class="step-num">4</div>
<div class="step-content"> <div class="step-content">
<div class="step-title">활동 품질 검증 (Quality)</div> <div class="step-title">활동 품질 검증 (Quality)</div>
<div class="step-desc" style="font-size:11px; margin-bottom:5px;"> <div class="step-desc" style="font-size:11px; margin-bottom:5px;">
최근 로그 분석 결과 ${qualityLabel}으로 판명되었습니다. 최근 로그 분석 결과 ${qualityLabel}으로 판명되었습니다.
</div> </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 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> </div>
<div class="formula-step"> <div class="formula-step">
<div class="step-num">2</div> <div class="step-num">2</div>
<div class="step-content"> <div class="step-content">
<div class="step-title">방치 시간 감쇄 적용</div> <div class="step-title">방치 시간 감쇄 적용</div>
<div style="font-size:11px; color:#64748b; margin-bottom:5px;">${p.days_stagnant}일간의 정체로 인한 가치 보존율입니다.</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 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> </div>
<div class="formula-step"> <div class="formula-step">
<div class="step-num">3</div> <div class="step-num">3</div>
<div class="step-content"> <div class="step-content">
<div class="step-title">존재 진정성 (ECV)</div> <div class="step-title">존재 진정성 (ECV)</div>
<div style="font-size:11px; color:#64748b; margin-bottom:5px;">${ecvDesc}</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 class="math-logic">Factor = <span class="${ecvClass}">${ecvText}</span></div>
</div> </div>
</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="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="text-align: left;">
<div style="font-size: 12px; font-weight: 700; color: ${vci >= 0 ? '#059669' : '#dc2626'};"> <div style="font-size: 12px; font-weight: 700; color: ${vci >= 0 ? '#059669' : '#dc2626'};">
가치 기여도 (VCI): ${vci >= 0 ? '+' : ''}${vci.toFixed(2)} 가치 기여도 (VCI): ${vci >= 0 ? '+' : ''}${vci.toFixed(2)}
</div> </div>
<div style="font-size: 10px; color: #94a3b8;">* AVI 70% 대비 프로젝트의 실질적 자산 하중 반영</div> <div style="font-size: 10px; color: #94a3b8;">* AVI 70% 대비 프로젝트의 실질적 자산 하중 반영</div>
</div> </div>
<div> <div>
<span style="font-size: 13px; color: #64748b; font-weight: 700;">최종 AVI: </span> <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> <span style="color: #1e5149; font-size: 22px; font-weight: 900;">${avi.toFixed(1)}%</span>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</td> </td>
</tr> </tr>
`; `;
}).join('')} }).join('')}
</tbody> </tbody>
</table> </table>
</div> </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 * Project Master Overseas Common JS
* 공통 네비게이션, 통합 모달 관리, 유틸리티 * 공통 네비게이션, 통합 모달 관리, 유틸리티
*/ */
// --- 공통 상수 --- // --- 공통 상수 ---
const API = { const API = {
INQUIRIES: '/api/inquiries', INQUIRIES: '/api/inquiries',
PROJECT_DATA: '/project-data', PROJECT_DATA: '/project-data',
PROJECT_ACTIVITY: '/project-activity', PROJECT_ACTIVITY: '/project-activity',
AVAILABLE_DATES: '/available-dates', AVAILABLE_DATES: '/available-dates',
SYNC: '/sync', SYNC: '/sync',
STOP_SYNC: '/stop-sync', STOP_SYNC: '/stop-sync',
AUTH_CRAWL: '/auth/crawl', AUTH_CRAWL: '/auth/crawl',
ANALYZE_FILE: '/analyze-file', ANALYZE_FILE: '/analyze-file',
ATTACHMENTS: '/attachments' ATTACHMENTS: '/attachments'
}; };
// --- 네비게이션 --- // --- 네비게이션 ---
function navigateTo(path) { function navigateTo(path) {
location.href = path; location.href = path;
} }
// --- 통합 모달 관리자 --- // --- 통합 모달 관리자 ---
const ModalManager = { const ModalManager = {
open(modalId) { open(modalId) {
const modal = document.getElementById(modalId); const modal = document.getElementById(modalId);
if (modal) { if (modal) {
modal.style.display = 'flex'; modal.style.display = 'flex';
// 포커스 자동 이동 (ID 입력란이 있으면) // 포커스 자동 이동 (ID 입력란이 있으면)
const firstInput = modal.querySelector('input'); const firstInput = modal.querySelector('input');
if (firstInput) firstInput.focus(); if (firstInput) firstInput.focus();
} }
}, },
close(modalId) { close(modalId) {
const modal = document.getElementById(modalId); const modal = document.getElementById(modalId);
if (modal) modal.style.display = 'none'; if (modal) modal.style.display = 'none';
}, },
closeAll() { closeAll() {
document.querySelectorAll('.modal-overlay').forEach(m => m.style.display = 'none'); document.querySelectorAll('.modal-overlay').forEach(m => m.style.display = 'none');
} }
}; };
// --- 유틸리티 함수 --- // --- 유틸리티 함수 ---
const Utils = { const Utils = {
formatDate(dateStr) { formatDate(dateStr) {
if (!dateStr) return '-'; if (!dateStr) return '-';
return dateStr.replace(/-/g, '.'); return dateStr.replace(/-/g, '.');
}, },
// 상태별 CSS 클래스 매핑 // 상태별 CSS 클래스 매핑
getStatusClass(status) { getStatusClass(status) {
const map = { const map = {
'완료': 'status-complete', '완료': 'status-complete',
'작업 중': 'status-working', '작업 중': 'status-working',
'확인 중': 'status-checking', '확인 중': 'status-checking',
'정상': 'active', '정상': 'active',
'주의': 'warning', '주의': 'warning',
'방치': 'stale', '방치': 'stale',
'데이터 없음': 'unknown' '데이터 없음': 'unknown'
}; };
return map[status] || 'status-pending'; return map[status] || 'status-pending';
}, },
// 한글 파일명 인코딩 안전 처리 // 한글 파일명 인코딩 안전 처리
getSafeFileUrl(filename) { getSafeFileUrl(filename) {
return `/sample_files/${encodeURIComponent(filename)}`; return `/sample_files/${encodeURIComponent(filename)}`;
} }
}; };
// --- 전역 이벤트 --- // --- 전역 이벤트 ---
document.addEventListener('keydown', (e) => { document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') ModalManager.closeAll(); if (e.key === 'Escape') ModalManager.closeAll();
}); });
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
console.log("Common module initialized."); console.log("Common module initialized.");
}); });

View File

@@ -1,237 +1,237 @@
/** /**
* Project Master Overseas Dashboard JS * Project Master Overseas Dashboard JS
* 기능: 데이터 로드, 활성도 분석, 인증 모달 제어, 크롤링 동기화 및 중단 * 기능: 데이터 로드, 활성도 분석, 인증 모달 제어, 크롤링 동기화 및 중단
*/ */
// --- 글로벌 상태 관리 --- // --- 글로벌 상태 관리 ---
let rawData = []; let rawData = [];
let projectActivityDetails = []; let projectActivityDetails = [];
let isCrawling = false; let isCrawling = false;
const CONTINENT_ORDER = { "아시아": 1, "아프리카": 2, "아메리카": 3, "지사": 4 }; const CONTINENT_ORDER = { "아시아": 1, "아프리카": 2, "아메리카": 3, "지사": 4 };
// --- 초기화 --- // --- 초기화 ---
async function init() { async function init() {
console.log("Dashboard Initializing..."); console.log("Dashboard Initializing...");
if (!document.getElementById('projectAccordion')) return; if (!document.getElementById('projectAccordion')) return;
await loadAvailableDates(); await loadAvailableDates();
await loadDataByDate(); await loadDataByDate();
} }
// --- 데이터 통신 및 로드 --- // --- 데이터 통신 및 로드 ---
async function loadAvailableDates() { async function loadAvailableDates() {
try { try {
const response = await fetch(API.AVAILABLE_DATES); const response = await fetch(API.AVAILABLE_DATES);
const dates = await response.json(); const dates = await response.json();
if (dates?.length > 0) { if (dates?.length > 0) {
const selectHtml = ` const selectHtml = `
<select id="dateSelector" onchange="loadDataByDate(this.value)" <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;"> 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('')} ${dates.map(d => `<option value="${d}">${d}</option>`).join('')}
</select>`; </select>`;
const baseDateStrong = document.getElementById('baseDate'); const baseDateStrong = document.getElementById('baseDate');
if (baseDateStrong) baseDateStrong.innerHTML = selectHtml; if (baseDateStrong) baseDateStrong.innerHTML = selectHtml;
} }
} catch (e) { console.error("날짜 로드 실패:", e); } } catch (e) { console.error("날짜 로드 실패:", e); }
} }
async function loadDataByDate(selectedDate = "") { async function loadDataByDate(selectedDate = "") {
try { try {
await loadActivityAnalysis(selectedDate); await loadActivityAnalysis(selectedDate);
const url = selectedDate ? `${API.PROJECT_DATA}?date=${selectedDate}` : `${API.PROJECT_DATA}?t=${Date.now()}`; const url = selectedDate ? `${API.PROJECT_DATA}?date=${selectedDate}` : `${API.PROJECT_DATA}?t=${Date.now()}`;
const response = await fetch(url); const response = await fetch(url);
const data = await response.json(); const data = await response.json();
if (data.error) throw new Error(data.error); if (data.error) throw new Error(data.error);
rawData = data.projects || []; rawData = data.projects || [];
renderDashboard(rawData); renderDashboard(rawData);
} catch (e) { } catch (e) {
console.error("데이터 로드 실패:", e); console.error("데이터 로드 실패:", e);
alert("데이터를 가져오는 데 실패했습니다."); alert("데이터를 가져오는 데 실패했습니다.");
} }
} }
async function loadActivityAnalysis(date = "") { async function loadActivityAnalysis(date = "") {
const dashboard = document.getElementById('activityDashboard'); const dashboard = document.getElementById('activityDashboard');
if (!dashboard) return; if (!dashboard) return;
try { try {
const url = date ? `${API.PROJECT_ACTIVITY}?date=${date}` : API.PROJECT_ACTIVITY; const url = date ? `${API.PROJECT_ACTIVITY}?date=${date}` : API.PROJECT_ACTIVITY;
const response = await fetch(url); const response = await fetch(url);
const data = await response.json(); const data = await response.json();
if (data.error) return; if (data.error) return;
const { summary, details } = data; const { summary, details } = data;
projectActivityDetails = details; projectActivityDetails = details;
dashboard.innerHTML = ` dashboard.innerHTML = `
<div class="activity-card active" onclick="showActivityDetails('active')"> <div class="activity-card active" onclick="showActivityDetails('active')">
<div class="label">정상 (7일 이내)</div><div class="count">${summary.active}</div> <div class="label">정상 (7일 이내)</div><div class="count">${summary.active}</div>
</div> </div>
<div class="activity-card warning" onclick="showActivityDetails('warning')"> <div class="activity-card warning" onclick="showActivityDetails('warning')">
<div class="label">주의 (14일 이내)</div><div class="count">${summary.warning}</div> <div class="label">주의 (14일 이내)</div><div class="count">${summary.warning}</div>
</div> </div>
<div class="activity-card stale" onclick="showActivityDetails('stale')"> <div class="activity-card stale" onclick="showActivityDetails('stale')">
<div class="label">방치 (14일 초과 / 폴더자동삭제)</div><div class="count">${summary.stale}</div> <div class="label">방치 (14일 초과 / 폴더자동삭제)</div><div class="count">${summary.stale}</div>
</div> </div>
<div class="activity-card unknown" onclick="showActivityDetails('unknown')"> <div class="activity-card unknown" onclick="showActivityDetails('unknown')">
<div class="label">데이터 없음 (파일 0개)</div><div class="count">${summary.unknown}</div> <div class="label">데이터 없음 (파일 0개)</div><div class="count">${summary.unknown}</div>
</div>`; </div>`;
} catch (e) { console.error("분석 로드 실패:", e); } } catch (e) { console.error("분석 로드 실패:", e); }
} }
// --- 렌더링 엔진 --- // --- 렌더링 엔진 ---
function renderDashboard(data) { function renderDashboard(data) {
const container = document.getElementById('projectAccordion'); const container = document.getElementById('projectAccordion');
container.innerHTML = ''; container.innerHTML = '';
const grouped = groupData(data); const grouped = groupData(data);
Object.keys(grouped).sort((a, b) => (CONTINENT_ORDER[a] || 99) - (CONTINENT_ORDER[b] || 99)).forEach(continent => { Object.keys(grouped).sort((a, b) => (CONTINENT_ORDER[a] || 99) - (CONTINENT_ORDER[b] || 99)).forEach(continent => {
const continentDiv = document.createElement('div'); const continentDiv = document.createElement('div');
continentDiv.className = 'continent-group active'; 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">`; 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 => { 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"> 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> <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>`; ${grouped[continent][country].sort((a, b) => a[0].localeCompare(b[0])).map(p => createProjectHtml(p)).join('')}</div></div></div>`;
}); });
html += `</div>`; html += `</div>`;
continentDiv.innerHTML = html; continentDiv.innerHTML = html;
container.appendChild(continentDiv); container.appendChild(continentDiv);
}); });
} }
function groupData(data) { function groupData(data) {
const res = {}; const res = {};
data.forEach(item => { data.forEach(item => {
const c1 = item[5] || "기타", c2 = item[6] || "미분류"; const c1 = item[5] || "기타", c2 = item[6] || "미분류";
if (!res[c1]) res[c1] = {}; if (!res[c1]) res[c1] = {};
if (!res[c1][c2]) res[c1][c2] = []; if (!res[c1][c2]) res[c1][c2] = [];
res[c1][c2].push(item); res[c1][c2].push(item);
}); });
return res; return res;
} }
function createProjectHtml(p) { function createProjectHtml(p) {
const [name, dept, admin, logRaw, files] = p; const [name, dept, admin, logRaw, files] = p;
const recentLog = (!logRaw || logRaw === "X" || logRaw === "데이터 없음") ? "기록 없음" : logRaw; const recentLog = (!logRaw || logRaw === "X" || logRaw === "데이터 없음") ? "기록 없음" : logRaw;
const logTime = recentLog !== "기록 없음" ? recentLog.split(',')[0] : "기록 없음"; const logTime = recentLog !== "기록 없음" ? recentLog.split(',')[0] : "기록 없음";
const isStaleLog = recentLog.replace(/\s/g, "").includes("폴더자동삭제"); const isStaleLog = recentLog.replace(/\s/g, "").includes("폴더자동삭제");
const isNoFiles = (files === 0 || files === null); const isNoFiles = (files === 0 || files === null);
const statusClass = isNoFiles ? "status-error" : ""; const statusClass = isNoFiles ? "status-error" : "";
let logStyleClass = ""; let logStyleClass = "";
if (isStaleLog) logStyleClass = "error-text"; if (isStaleLog) logStyleClass = "error-text";
else if (recentLog === "기록 없음") logStyleClass = "warning-text"; else if (recentLog === "기록 없음") logStyleClass = "warning-text";
const logBoldStyle = isStaleLog ? 'font-weight: 800;' : ''; const logBoldStyle = isStaleLog ? 'font-weight: 800;' : '';
return ` return `
<div class="accordion-item ${statusClass}"> <div class="accordion-item ${statusClass}">
<div class="accordion-header" onclick="toggleAccordion(this)"> <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 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>
<div class="accordion-body"> <div class="accordion-body">
<div class="detail-grid"> <div class="detail-grid">
<div class="detail-section"> <div class="detail-section">
<h4>참여 인원 상세</h4> <h4>참여 인원 상세</h4>
<table class="data-table"> <table class="data-table">
<thead><tr><th>이름</th><th>소속</th><th>권한</th></tr></thead> <thead><tr><th>이름</th><th>소속</th><th>권한</th></tr></thead>
<tbody><tr><td>${admin}</td><td>${dept}</td><td>관리자</td></tr></tbody> <tbody><tr><td>${admin}</td><td>${dept}</td><td>관리자</td></tr></tbody>
</table> </table>
</div> </div>
<div class="detail-section"> <div class="detail-section">
<h4>최근 활동</h4> <h4>최근 활동</h4>
<table class="data-table"> <table class="data-table">
<thead><tr><th>유형</th><th>내용</th><th>일시</th></tr></thead> <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> <tbody><tr><td><span class="badge">로그</span></td><td>동기화 완료</td><td>${logTime}</td></tr></tbody>
</table> </table>
</div> </div>
</div> </div>
</div> </div>
</div>`; </div>`;
} }
// --- 이벤트 핸들러 --- // --- 이벤트 핸들러 ---
function toggleGroup(h) { h.parentElement.classList.toggle('active'); } function toggleGroup(h) { h.parentElement.classList.toggle('active'); }
function toggleAccordion(h) { function toggleAccordion(h) {
const item = h.parentElement; const item = h.parentElement;
item.parentElement.querySelectorAll('.accordion-item').forEach(el => { if (el !== item) el.classList.remove('active'); }); item.parentElement.querySelectorAll('.accordion-item').forEach(el => { if (el !== item) el.classList.remove('active'); });
item.classList.toggle('active'); item.classList.toggle('active');
} }
function showActivityDetails(status) { function showActivityDetails(status) {
const names = { active: '정상', warning: '주의', stale: '방치', unknown: '데이터 없음' }; const names = { active: '정상', warning: '주의', stale: '방치', unknown: '데이터 없음' };
const filtered = (projectActivityDetails || []).filter(d => d.status === status); const filtered = (projectActivityDetails || []).filter(d => d.status === status);
document.getElementById('modalTitle').innerText = `${names[status]} 목록 (${filtered.length}개)`; document.getElementById('modalTitle').innerText = `${names[status]} 목록 (${filtered.length}개)`;
document.getElementById('modalTableBody').innerHTML = filtered.map(p => { document.getElementById('modalTableBody').innerHTML = filtered.map(p => {
const o = rawData.find(r => r[0] === p.name); 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>`; 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(''); }).join('');
ModalManager.open('activityDetailModal'); ModalManager.open('activityDetailModal');
} }
function scrollToProject(name) { function scrollToProject(name) {
ModalManager.close('activityDetailModal'); ModalManager.close('activityDetailModal');
const target = Array.from(document.querySelectorAll('.repo-title')).find(t => t.innerText.trim() === name.trim())?.closest('.accordion-header'); const target = Array.from(document.querySelectorAll('.repo-title')).find(t => t.innerText.trim() === name.trim())?.closest('.accordion-header');
if (target) { if (target) {
let p = target.parentElement; let p = target.parentElement;
while (p && p !== document.body) { while (p && p !== document.body) {
if (p.classList.contains('continent-group') || p.classList.contains('country-group')) p.classList.add('active'); if (p.classList.contains('continent-group') || p.classList.contains('country-group')) p.classList.add('active');
p = p.parentElement; p = p.parentElement;
} }
target.parentElement.classList.add('active'); target.parentElement.classList.add('active');
const pos = target.getBoundingClientRect().top + window.pageYOffset - 260; const pos = target.getBoundingClientRect().top + window.pageYOffset - 260;
window.scrollTo({ top: pos, behavior: 'smooth' }); window.scrollTo({ top: pos, behavior: 'smooth' });
target.style.backgroundColor = 'var(--primary-lv-1)'; target.style.backgroundColor = 'var(--primary-lv-1)';
setTimeout(() => target.style.backgroundColor = '', 2000); setTimeout(() => target.style.backgroundColor = '', 2000);
} }
} }
// --- 크롤링 및 인증 제어 --- // --- 크롤링 및 인증 제어 ---
async function syncData() { async function syncData() {
if (isCrawling) { if (isCrawling) {
if (confirm("크롤링을 중단하시겠습니까?")) { if (confirm("크롤링을 중단하시겠습니까?")) {
const res = await fetch(API.STOP_SYNC); const res = await fetch(API.STOP_SYNC);
if ((await res.json()).success) document.getElementById('syncBtn').innerText = "중단 요청 중..."; if ((await res.json()).success) document.getElementById('syncBtn').innerText = "중단 요청 중...";
} }
return; return;
} }
document.getElementById('authId').value = ''; document.getElementById('authId').value = '';
document.getElementById('authPw').value = ''; document.getElementById('authPw').value = '';
document.getElementById('authErrorMessage').style.display = 'none'; document.getElementById('authErrorMessage').style.display = 'none';
ModalManager.open('authModal'); ModalManager.open('authModal');
} }
async function submitAuth() { async function submitAuth() {
const id = document.getElementById('authId').value, pw = document.getElementById('authPw').value, err = document.getElementById('authErrorMessage'); const id = document.getElementById('authId').value, pw = document.getElementById('authPw').value, err = document.getElementById('authErrorMessage');
try { try {
const res = await fetch(API.AUTH_CRAWL, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ user_id: id, password: pw }) }); 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(); const data = await res.json();
if (data.success) { ModalManager.close('authModal'); startCrawlProcess(); } if (data.success) { ModalManager.close('authModal'); startCrawlProcess(); }
else { err.innerText = "크롤링을 할 수 없습니다."; err.style.display = 'block'; } else { err.innerText = "크롤링을 할 수 없습니다."; err.style.display = 'block'; }
} catch { err.innerText = "서버 연결 실패"; err.style.display = 'block'; } } catch { err.innerText = "서버 연결 실패"; err.style.display = 'block'; }
} }
async function startCrawlProcess() { async function startCrawlProcess() {
isCrawling = true; isCrawling = true;
const btn = document.getElementById('syncBtn'), logC = document.getElementById('logConsole'), logB = document.getElementById('logBody'); 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> 크롤링 중단`; 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>'; logC.style.display = 'block'; logB.innerHTML = '<div style="color:#aaa; margin-bottom:10px;">>>> 엔진 초기화 중...</div>';
try { try {
const res = await fetch(API.SYNC); const res = await fetch(API.SYNC);
const reader = res.body.getReader(), decoder = new TextDecoder(); const reader = res.body.getReader(), decoder = new TextDecoder();
while (true) { while (true) {
const { done, value } = await reader.read(); if (done) break; const { done, value } = await reader.read(); if (done) break;
decoder.decode(value).split('\n').forEach(line => { decoder.decode(value).split('\n').forEach(line => {
if (line.startsWith('data: ')) { if (line.startsWith('data: ')) {
const p = JSON.parse(line.substring(6)); const p = JSON.parse(line.substring(6));
if (p.type === 'log') { if (p.type === 'log') {
const div = document.createElement('div'); div.innerText = `[${new Date().toLocaleTimeString()}] ${p.message}`; const div = document.createElement('div'); div.innerText = `[${new Date().toLocaleTimeString()}] ${p.message}`;
logB.appendChild(div); logC.scrollTop = logC.scrollHeight; logB.appendChild(div); logC.scrollTop = logC.scrollHeight;
} else if (p.type === 'done') { init(); alert(`동기화 종료`); logC.style.display = 'none'; } } else if (p.type === 'done') { init(); alert(`동기화 종료`); logC.style.display = 'none'; }
} }
}); });
} }
} catch { alert("스트림 끊김"); } } catch { alert("스트림 끊김"); }
finally { isCrawling = false; btn.classList.remove('loading'); btn.style.backgroundColor = ''; btn.innerHTML = `<span class="spinner"></span> 데이터 동기화 (크롤링)`; } finally { isCrawling = false; btn.classList.remove('loading'); btn.style.backgroundColor = ''; btn.innerHTML = `<span class="spinner"></span> 데이터 동기화 (크롤링)`; }
} }
document.addEventListener('DOMContentLoaded', init); 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 * Project Master Overseas Inquiries JS
* 기능: 문의사항 로드, 필터링, 답변 관리, 아코디언 및 이미지 모달 * 기능: 문의사항 로드, 필터링, 답변 관리, 아코디언 및 이미지 모달
*/ */
// --- 초기화 --- // --- 초기화 ---
let allInquiries = []; let allInquiries = [];
let currentSort = { field: 'no', direction: 'desc' }; let currentSort = { field: 'no', direction: 'desc' };
async function loadInquiries() { async function loadInquiries() {
initStickyHeader(); initStickyHeader();
const pmType = document.getElementById('filterPmType').value; const pmType = document.getElementById('filterPmType').value;
const category = document.getElementById('filterCategory').value; const category = document.getElementById('filterCategory').value;
const keyword = document.getElementById('searchKeyword').value; const keyword = document.getElementById('searchKeyword').value;
const params = new URLSearchParams({ const params = new URLSearchParams({
pm_type: pmType, pm_type: pmType,
category: category, category: category,
keyword: keyword keyword: keyword
}); });
try { try {
const response = await fetch(`${API.INQUIRIES}?${params}`); const response = await fetch(`${API.INQUIRIES}?${params}`);
allInquiries = await response.json(); allInquiries = await response.json();
refreshInquiryBoard(); refreshInquiryBoard();
} catch (e) { } catch (e) {
console.error("데이터 로딩 중 오류 발생:", e); console.error("데이터 로딩 중 오류 발생:", e);
} }
} }
function refreshInquiryBoard() { function refreshInquiryBoard() {
const status = document.getElementById('filterStatus').value; const status = document.getElementById('filterStatus').value;
// 1. 상태 필터링 // 1. 상태 필터링
let filteredData = status ? allInquiries.filter(item => item.status === status) : [...allInquiries]; let filteredData = status ? allInquiries.filter(item => item.status === status) : [...allInquiries];
// 2. 정렬 적용 // 2. 정렬 적용
filteredData = sortData(filteredData); filteredData = sortData(filteredData);
// 3. 통계 및 리스트 렌더링 // 3. 통계 및 리스트 렌더링
updateStats(allInquiries); updateStats(allInquiries);
updateSortUI(); updateSortUI();
renderInquiryList(filteredData); renderInquiryList(filteredData);
} }
function handleSort(field) { function handleSort(field) {
if (currentSort.field === field) { if (currentSort.field === field) {
currentSort.direction = currentSort.direction === 'asc' ? 'desc' : 'asc'; currentSort.direction = currentSort.direction === 'asc' ? 'desc' : 'asc';
} else { } else {
currentSort.field = field; currentSort.field = field;
currentSort.direction = 'asc'; currentSort.direction = 'asc';
} }
refreshInquiryBoard(); refreshInquiryBoard();
} }
function sortData(data) { function sortData(data) {
const { field, direction } = currentSort; const { field, direction } = currentSort;
const modifier = direction === 'asc' ? 1 : -1; const modifier = direction === 'asc' ? 1 : -1;
return data.sort((a, b) => { return data.sort((a, b) => {
let valA = a[field]; let valA = a[field];
let valB = b[field]; let valB = b[field];
// 숫자형 변환 시도 (No 필드 등) // 숫자형 변환 시도 (No 필드 등)
if (field === 'no' || !isNaN(valA)) { if (field === 'no' || !isNaN(valA)) {
valA = Number(valA); valA = Number(valA);
valB = Number(valB); valB = Number(valB);
} }
// null/undefined 처리 // null/undefined 처리
if (valA === null || valA === undefined) valA = ""; if (valA === null || valA === undefined) valA = "";
if (valB === null || valB === undefined) valB = ""; if (valB === null || valB === undefined) valB = "";
if (valA < valB) return -1 * modifier; if (valA < valB) return -1 * modifier;
if (valA > valB) return 1 * modifier; if (valA > valB) return 1 * modifier;
return 0; return 0;
}); });
} }
function updateSortUI() { function updateSortUI() {
// 모든 헤더 클래스 및 아이콘 초기화 // 모든 헤더 클래스 및 아이콘 초기화
document.querySelectorAll('.inquiry-table thead th.sortable').forEach(th => { document.querySelectorAll('.inquiry-table thead th.sortable').forEach(th => {
th.classList.remove('active-sort'); th.classList.remove('active-sort');
const icon = th.querySelector('.sort-icon'); const icon = th.querySelector('.sort-icon');
if (icon) { if (icon) {
// 레이아웃 시프트 방지를 위해 투명한 기본 아이콘(또는 공백) 유지 // 레이아웃 시프트 방지를 위해 투명한 기본 아이콘(또는 공백) 유지
icon.textContent = "▲"; icon.textContent = "▲";
icon.style.opacity = "0"; icon.style.opacity = "0";
} }
}); });
// 현재 정렬된 헤더 강조 및 아이콘 표시 // 현재 정렬된 헤더 강조 및 아이콘 표시
const activeTh = document.querySelector(`.inquiry-table thead th[onclick*="'${currentSort.field}'"]`); const activeTh = document.querySelector(`.inquiry-table thead th[onclick*="'${currentSort.field}'"]`);
if (activeTh) { if (activeTh) {
activeTh.classList.add('active-sort'); activeTh.classList.add('active-sort');
const icon = activeTh.querySelector('.sort-icon'); const icon = activeTh.querySelector('.sort-icon');
if (icon) { if (icon) {
icon.textContent = currentSort.direction === 'asc' ? "▲" : "▼"; icon.textContent = currentSort.direction === 'asc' ? "▲" : "▼";
icon.style.opacity = "1"; icon.style.opacity = "1";
} }
} }
} }
function initStickyHeader() { function initStickyHeader() {
const header = document.getElementById('stickyHeader'); const header = document.getElementById('stickyHeader');
const thead = document.querySelector('.inquiry-table thead'); const thead = document.querySelector('.inquiry-table thead');
if (header && thead) { if (header && thead) {
const headerHeight = header.offsetHeight; const headerHeight = header.offsetHeight;
const totalOffset = 36 + headerHeight; const totalOffset = 36 + headerHeight;
document.querySelectorAll('.inquiry-table thead th').forEach(th => { document.querySelectorAll('.inquiry-table thead th').forEach(th => {
th.style.top = totalOffset + 'px'; th.style.top = totalOffset + 'px';
}); });
} }
} }
function renderInquiryList(data) { function renderInquiryList(data) {
const tbody = document.getElementById('inquiryList'); const tbody = document.getElementById('inquiryList');
tbody.innerHTML = data.map(item => ` tbody.innerHTML = data.map(item => `
<tr class="inquiry-row" onclick="toggleAccordion(${item.id})"> <tr class="inquiry-row" onclick="toggleAccordion(${item.id})">
<td title="${item.no}">${item.no}</td> <td title="${item.no}">${item.no}</td>
<td style="text-align:center;"> <td style="text-align:center;">
${item.image_url ? `<img src="${item.image_url}" class="img-thumbnail" alt="thumbnail">` : '<span class="no-img">없음</span>'} ${item.image_url ? `<img src="${item.image_url}" class="img-thumbnail" alt="thumbnail">` : '<span class="no-img">없음</span>'}
</td> </td>
<td title="${item.pm_type}">${item.pm_type}</td> <td title="${item.pm_type}">${item.pm_type}</td>
<td title="${item.browser || 'Chrome'}">${item.browser || 'Chrome'}</td> <td title="${item.browser || 'Chrome'}">${item.browser || 'Chrome'}</td>
<td title="${item.category}">${item.category}</td> <td title="${item.category}">${item.category}</td>
<td title="${item.project_nm}">${item.project_nm}</td> <td title="${item.project_nm}">${item.project_nm}</td>
<td class="content-preview" title="${item.content}">${item.content}</td> <td class="content-preview" title="${item.content}">${item.content}</td>
<td title="${item.author}">${item.author}</td> <td title="${item.author}">${item.author}</td>
<td title="${item.reg_date}">${item.reg_date}</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 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> <td><span class="status-badge ${Utils.getStatusClass(item.status)}">${item.status}</span></td>
</tr> </tr>
<tr id="detail-${item.id}" class="detail-row"> <tr id="detail-${item.id}" class="detail-row">
<td colspan="11"> <td colspan="11">
<div class="detail-container"> <div class="detail-container">
<button class="btn-close-accordion" onclick="toggleAccordion(${item.id})">접기</button> <button class="btn-close-accordion" onclick="toggleAccordion(${item.id})">접기</button>
<div class="detail-content-wrapper"> <div class="detail-content-wrapper">
<div class="detail-meta-grid"> <div class="detail-meta-grid">
<div><span class="detail-label">작성자:</span> ${item.author}</div> <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.reg_date}</div>
<div><span class="detail-label">시스템:</span> ${item.pm_type}</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><span class="detail-label">환경:</span> ${item.browser || 'Chrome'} / ${item.device || 'PC'}</div>
</div> </div>
<div class="detail-q-section"> <div class="detail-q-section">
<h4 style="margin-top:0; margin-bottom:10px; color:#1e5149;">[질문 내용]</h4> <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 style="line-height:1.6; white-space: pre-wrap;">${item.content}</div>
</div> </div>
${item.image_url ? ` ${item.image_url ? `
<div class="detail-image-section" id="img-section-${item.id}"> <div class="detail-image-section" id="img-section-${item.id}">
<div class="image-section-header" onclick="toggleImageSection(${item.id})"> <div class="image-section-header" onclick="toggleImageSection(${item.id})">
<h4> <h4>
<span>🖼️</span> [첨부 이미지] <span>🖼️</span> [첨부 이미지]
<span style="font-size:11px; color:#888; font-weight:normal;">(클릭 시 크게 보기)</span> <span style="font-size:11px; color:#888; font-weight:normal;">(클릭 시 크게 보기)</span>
</h4> </h4>
<span class="toggle-icon">▼</span> <span class="toggle-icon">▼</span>
</div> </div>
<div class="image-section-content collapsed" id="img-content-${item.id}"> <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)"> <img src="${item.image_url}" class="preview-img" alt="Inquiry Image" style="cursor: pointer;" onclick="event.stopPropagation(); openImageModal(this.src)">
</div> </div>
</div> </div>
` : ''} ` : ''}
<div class="detail-a-section"> <div class="detail-a-section">
<h4 style="margin-top:0; margin-bottom:10px; color:#1e5149;">[조치 및 답변]</h4> <h4 style="margin-top:0; margin-bottom:10px; color:#1e5149;">[조치 및 답변]</h4>
<div id="reply-form-${item.id}" class="reply-edit-form readonly"> <div id="reply-form-${item.id}" class="reply-edit-form readonly">
<textarea id="reply-text-${item.id}" disabled placeholder="답변 내용이 없습니다.">${item.reply || ''}</textarea> <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; justify-content: space-between; align-items: center;">
<div style="display:flex; gap:15px; 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;"> <div class="filter-group" style="flex-direction:row; align-items:center; gap:8px;">
<label style="margin:0;">처리상태:</label> <label style="margin:0;">처리상태:</label>
<select id="reply-status-${item.id}" disabled style="padding:5px 10px;"> <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> <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> </select>
</div> </div>
<div class="filter-group" style="flex-direction:row; align-items:center; gap:8px;"> <div class="filter-group" style="flex-direction:row; align-items:center; gap:8px;">
<label style="margin:0;">처리자:</label> <label style="margin:0;">처리자:</label>
<input type="text" id="reply-handler-${item.id}" disabled value="${item.handler || ''}" placeholder="이름 입력" style="padding:5px 10px; width:100px;"> <input type="text" id="reply-handler-${item.id}" disabled value="${item.handler || ''}" placeholder="이름 입력" style="padding:5px 10px; width:100px;">
</div> </div>
</div> </div>
<div style="display:flex; gap:8px;"> <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-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-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-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> <button class="btn-cancel sync-btn" onclick="cancelEdit(${item.id})" style="background:#666; color:#fff; border:none;">취소</button>
</div> </div>
</div> </div>
${item.handled_date ? `<div style="margin-top:10px; font-size:12px; color:#888; text-align:right;">최종 수정일: ${item.handled_date}</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>
</div> </div>
</div> </div>
</td> </td>
</tr> </tr>
`).join(''); `).join('');
} }
function enableEdit(id) { function enableEdit(id) {
const form = document.getElementById(`reply-form-${id}`); const form = document.getElementById(`reply-form-${id}`);
form.classList.replace('readonly', 'editable'); form.classList.replace('readonly', 'editable');
const elements = [`reply-text-${id}`, `reply-status-${id}`, `reply-handler-${id}`]; const elements = [`reply-text-${id}`, `reply-status-${id}`, `reply-handler-${id}`];
elements.forEach(elId => document.getElementById(elId).disabled = false); elements.forEach(elId => document.getElementById(elId).disabled = false);
document.getElementById(`reply-text-${id}`).focus(); document.getElementById(`reply-text-${id}`).focus();
} }
async function cancelEdit(id) { async function cancelEdit(id) {
try { try {
const response = await fetch(`${API.INQUIRIES}/${id}`); const response = await fetch(`${API.INQUIRIES}/${id}`);
const item = await response.json(); const item = await response.json();
const txt = document.getElementById(`reply-text-${id}`); const txt = document.getElementById(`reply-text-${id}`);
const status = document.getElementById(`reply-status-${id}`); const status = document.getElementById(`reply-status-${id}`);
const handler = document.getElementById(`reply-handler-${id}`); const handler = document.getElementById(`reply-handler-${id}`);
txt.value = item.reply || ''; txt.value = item.reply || '';
status.value = item.status; status.value = item.status;
handler.value = item.handler || ''; handler.value = item.handler || '';
[txt, status, handler].forEach(el => el.disabled = true); [txt, status, handler].forEach(el => el.disabled = true);
document.getElementById(`reply-form-${id}`).classList.replace('editable', 'readonly'); document.getElementById(`reply-form-${id}`).classList.replace('editable', 'readonly');
} catch { loadInquiries(); } } catch { loadInquiries(); }
} }
async function saveReply(id) { async function saveReply(id) {
const reply = document.getElementById(`reply-text-${id}`).value; const reply = document.getElementById(`reply-text-${id}`).value;
const status = document.getElementById(`reply-status-${id}`).value; const status = document.getElementById(`reply-status-${id}`).value;
const handler = document.getElementById(`reply-handler-${id}`).value; const handler = document.getElementById(`reply-handler-${id}`).value;
if (!reply.trim() || !handler.trim()) return alert("내용과 처리자를 모두 입력해 주세요."); if (!reply.trim() || !handler.trim()) return alert("내용과 처리자를 모두 입력해 주세요.");
try { try {
const response = await fetch(`${API.INQUIRIES}/${id}/reply`, { const response = await fetch(`${API.INQUIRIES}/${id}/reply`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ reply, status, handler }) body: JSON.stringify({ reply, status, handler })
}); });
if ((await response.json()).success) { alert("저장되었습니다."); loadInquiries(); } if ((await response.json()).success) { alert("저장되었습니다."); loadInquiries(); }
} catch { alert("저장 중 오류가 발생했습니다."); } } catch { alert("저장 중 오류가 발생했습니다."); }
} }
async function deleteReply(id) { async function deleteReply(id) {
if (!confirm("답변을 삭제하시겠습니까?")) return; if (!confirm("답변을 삭제하시겠습니까?")) return;
try { try {
const response = await fetch(`${API.INQUIRIES}/${id}/reply`, { method: 'DELETE' }); const response = await fetch(`${API.INQUIRIES}/${id}/reply`, { method: 'DELETE' });
if ((await response.json()).success) { alert("삭제되었습니다."); loadInquiries(); } if ((await response.json()).success) { alert("삭제되었습니다."); loadInquiries(); }
} catch { alert("삭제 중 오류가 발생했습니다."); } } catch { alert("삭제 중 오류가 발생했습니다."); }
} }
function toggleAccordion(id) { function toggleAccordion(id) {
const detailRow = document.getElementById(`detail-${id}`); const detailRow = document.getElementById(`detail-${id}`);
if (!detailRow) return; if (!detailRow) return;
const inquiryRow = detailRow.previousElementSibling; const inquiryRow = detailRow.previousElementSibling;
const isActive = detailRow.classList.contains('active'); const isActive = detailRow.classList.contains('active');
document.querySelectorAll('.detail-row.active').forEach(row => { document.querySelectorAll('.detail-row.active').forEach(row => {
if (row.id !== `detail-${id}`) { if (row.id !== `detail-${id}`) {
row.classList.remove('active'); row.classList.remove('active');
if (row.previousElementSibling) row.previousElementSibling.classList.remove('active-row'); if (row.previousElementSibling) row.previousElementSibling.classList.remove('active-row');
} }
}); });
if (isActive) { if (isActive) {
detailRow.classList.remove('active'); detailRow.classList.remove('active');
inquiryRow.classList.remove('active-row'); inquiryRow.classList.remove('active-row');
} else { } else {
detailRow.classList.add('active'); detailRow.classList.add('active');
inquiryRow.classList.add('active-row'); inquiryRow.classList.add('active-row');
scrollToRow(inquiryRow); scrollToRow(inquiryRow);
} }
} }
function scrollToRow(row) { function scrollToRow(row) {
setTimeout(() => { setTimeout(() => {
const headerHeight = document.getElementById('stickyHeader').offsetHeight; const headerHeight = document.getElementById('stickyHeader').offsetHeight;
const totalOffset = 36 + headerHeight + 40; const totalOffset = 36 + headerHeight + 40;
const offsetPosition = (row.getBoundingClientRect().top + window.pageYOffset) - totalOffset; const offsetPosition = (row.getBoundingClientRect().top + window.pageYOffset) - totalOffset;
window.scrollTo({ top: offsetPosition, behavior: 'smooth' }); window.scrollTo({ top: offsetPosition, behavior: 'smooth' });
}, 100); }, 100);
} }
function updateStats(data) { function updateStats(data) {
const counts = { const counts = {
Total: data.length, Total: data.length,
Complete: data.filter(i => i.status === '완료').length, Complete: data.filter(i => i.status === '완료').length,
Working: data.filter(i => i.status === '작업 중').length, Working: data.filter(i => i.status === '작업 중').length,
Checking: data.filter(i => i.status === '확인 중').length, Checking: data.filter(i => i.status === '확인 중').length,
Pending: data.filter(i => i.status === '개발예정').length, Pending: data.filter(i => i.status === '개발예정').length,
Unconfirmed: data.filter(i => i.status === '미확인').length Unconfirmed: data.filter(i => i.status === '미확인').length
}; };
Object.keys(counts).forEach(k => { Object.keys(counts).forEach(k => {
const el = document.getElementById(`count${k}`); const el = document.getElementById(`count${k}`);
if (el) el.textContent = counts[k].toLocaleString(); if (el) el.textContent = counts[k].toLocaleString();
}); });
} }
function openImageModal(src) { function openImageModal(src) {
document.getElementById('modalImage').src = src; document.getElementById('modalImage').src = src;
ModalManager.open('imageModal'); ModalManager.open('imageModal');
} }
function toggleImageSection(id) { function toggleImageSection(id) {
const section = document.getElementById(`img-section-${id}`); const section = document.getElementById(`img-section-${id}`);
const content = document.getElementById(`img-content-${id}`); const content = document.getElementById(`img-content-${id}`);
const icon = section.querySelector('.toggle-icon'); const icon = section.querySelector('.toggle-icon');
const isCollapsed = content.classList.toggle('collapsed'); const isCollapsed = content.classList.toggle('collapsed');
section.classList.toggle('active', !isCollapsed); section.classList.toggle('active', !isCollapsed);
icon.textContent = isCollapsed ? '▼' : '▲'; icon.textContent = isCollapsed ? '▼' : '▲';
} }
document.addEventListener('DOMContentLoaded', loadInquiries); document.addEventListener('DOMContentLoaded', loadInquiries);
window.addEventListener('resize', initStickyHeader); window.addEventListener('resize', initStickyHeader);

View File

@@ -1,312 +1,312 @@
/** /**
* Project Master Overseas Mail Management JS * Project Master Overseas Mail Management JS
* 기능: 첨부파일 로드, AI 분석, 메일 목록 렌더링, 미리보기, 주소록 관리 * 기능: 첨부파일 로드, AI 분석, 메일 목록 렌더링, 미리보기, 주소록 관리
*/ */
let currentFiles = []; let currentFiles = [];
let editingIndex = -1; let editingIndex = -1;
const HIERARCHY = { const HIERARCHY = {
"행정": { "계약": ["계약관리", "기성관리", "업무지시서", "인원관리"], "업무관리": ["업무일지(2025)", "업무일지(2025년 이전)", "발주처 정기보고", "본사업무보고", "공사감독일지", "양식서류"] }, "행정": { "계약": ["계약관리", "기성관리", "업무지시서", "인원관리"], "업무관리": ["업무일지(2025)", "업무일지(2025년 이전)", "발주처 정기보고", "본사업무보고", "공사감독일지", "양식서류"] },
"설계성과품": { "시방서": ["공사시방서"], "설계도면": ["공통", "토공", "비탈면안전공", "배수공", "교량공", "포장공"], "수량산출서": ["토공", "배수공"], "내역서": ["단가산출서"], "보고서": ["실시설계보고서", "지반조사보고서"], "측량계산부": ["측량계산부"], "설계단계 수행협의": ["회의·협의"] }, "설계성과품": { "시방서": ["공사시방서"], "설계도면": ["공통", "토공", "비탈면안전공", "배수공", "교량공", "포장공"], "수량산출서": ["토공", "배수공"], "내역서": ["단가산출서"], "보고서": ["실시설계보고서", "지반조사보고서"], "측량계산부": ["측량계산부"], "설계단계 수행협의": ["회의·협의"] },
"시공검측": { "토공": ["검측 (깨기)", "검측 (노체)"], "배수공": ["검측 (V형측구)", "검측 (종배수관)"], "구조물공": ["검측 (평목교)"], "포장공": ["검측 (기층)"] }, "시공검측": { "토공": ["검측 (깨기)", "검측 (노체)"], "배수공": ["검측 (V형측구)", "검측 (종배수관)"], "구조물공": ["검측 (평목교)"], "포장공": ["검측 (기층)"] },
"설계변경": { "실정보고": ["토공", "배수공", "안전관리"], "기술지원 검토": ["토공", "구조물&부대공"] } "설계변경": { "실정보고": ["토공", "배수공", "안전관리"], "기술지원 검토": ["토공", "구조물&부대공"] }
}; };
const MAIL_SAMPLES = { const MAIL_SAMPLES = {
inbound: [ inbound: [
{ person: "라오스 농림부", email: "pany.s@lao.gov.la", time: "2026-03-05", title: "ITTC 교육센터 착공식 일정 협의", summary: "착공식 관련하여 정부 측 인사의 일정을 반영한 최종 공문을 송부합니다.", active: true }, { 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 } { person: "현대건설 (김철수 소장)", email: "cs.kim@hdec.co.kr", time: "2026-03-04", title: "[긴급] 어천-공주(4차) 하도급 변경계약 통보", summary: "철거공사 물량 변동에 따른 계약 금액 조정 건입니다. 검토 후 승인 부탁드립니다.", active: false }
], ],
outbound: [ outbound: [
{ person: "공사관리부 (본사)", email: "hq_pm@projectmaster.com", time: "2026-03-04", title: "어천-공주 2월 월간 공정보고서 제출", summary: "2월 한 달간의 주요 공정 및 예산 집행 현황 보고서입니다.", active: false } { person: "공사관리부 (본사)", email: "hq_pm@projectmaster.com", time: "2026-03-04", title: "어천-공주 2월 월간 공정보고서 제출", summary: "2월 한 달간의 주요 공정 및 예산 집행 현황 보고서입니다.", active: false }
], ],
drafts: [], deleted: [] drafts: [], deleted: []
}; };
let currentMailTab = 'inbound'; let currentMailTab = 'inbound';
let filteredMails = []; let filteredMails = [];
// --- 첨부파일 데이터 로드 및 렌더링 --- // --- 첨부파일 데이터 로드 및 렌더링 ---
async function loadAttachments() { async function loadAttachments() {
try { try {
const res = await fetch(API.ATTACHMENTS); const res = await fetch(API.ATTACHMENTS);
currentFiles = await res.json(); currentFiles = await res.json();
renderFiles(); renderFiles();
} catch (e) { console.error("Failed to load attachments:", e); } } catch (e) { console.error("Failed to load attachments:", e); }
} }
function renderFiles() { function renderFiles() {
const isAiActive = document.getElementById('aiToggle').checked; const isAiActive = document.getElementById('aiToggle').checked;
const container = document.getElementById('attachmentList'); const container = document.getElementById('attachmentList');
if (!container) return; if (!container) return;
container.innerHTML = ''; container.innerHTML = '';
currentFiles.forEach((file, index) => { currentFiles.forEach((file, index) => {
const item = document.createElement('div'); const item = document.createElement('div');
item.className = 'attachment-item-wrap'; item.className = 'attachment-item-wrap';
item.style.marginBottom = "8px"; item.style.marginBottom = "8px";
let pathText = "경로를 선택해주세요"; let pathText = "경로를 선택해주세요";
let modeClass = "manual-mode"; let modeClass = "manual-mode";
if (file.analysis) { if (file.analysis) {
const prefix = file.analysis.isManual ? "선택 경로: " : "추천: "; const prefix = file.analysis.isManual ? "선택 경로: " : "추천: ";
pathText = `${prefix}${file.analysis.suggested_path}`; pathText = `${prefix}${file.analysis.suggested_path}`;
modeClass = file.analysis.isManual ? "manual-mode" : "smart-mode"; modeClass = file.analysis.isManual ? "manual-mode" : "smart-mode";
} else if (isAiActive) { } else if (isAiActive) {
pathText = "AI 분석 대기 중..."; pathText = "AI 분석 대기 중...";
modeClass = "smart-mode"; modeClass = "smart-mode";
} }
item.innerHTML = ` item.innerHTML = `
<div class="attachment-item" onclick="showPreview(${index}, event)" style="position:relative;"> <div class="attachment-item" onclick="showPreview(${index}, event)" style="position:relative;">
<span class="file-icon" style="pointer-events:none;">📄</span> <span class="file-icon" style="pointer-events:none;">📄</span>
<div class="file-details" style="pointer-events:none;"> <div class="file-details" style="pointer-events:none;">
<div class="file-name" title="${file.name}">${file.name}</div> <div class="file-name" title="${file.name}">${file.name}</div>
<div class="file-size">${file.size}</div> <div class="file-size">${file.size}</div>
</div> </div>
<div class="btn-group" onclick="event.stopPropagation()" style="position:relative; z-index:2;"> <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> <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>` : ''} ${isAiActive ? `<button class="btn-upload btn-ai" onclick="startAnalysis(${index}, event)">AI 분석</button>` : ''}
<button class="btn-upload btn-normal" onclick="confirmUpload(${index}, event)">파일업로드</button> <button class="btn-upload btn-normal" onclick="confirmUpload(${index}, event)">파일업로드</button>
</div> </div>
</div> </div>
<div id="log-area-${index}" class="file-log-area"><div id="log-content-${index}"></div></div> <div id="log-area-${index}" class="file-log-area"><div id="log-content-${index}"></div></div>
`; `;
container.appendChild(item); container.appendChild(item);
}); });
} }
// --- AI 분석 실행 --- // --- AI 분석 실행 ---
async function startAnalysis(index, event) { async function startAnalysis(index, event) {
if (event) event.stopPropagation(); if (event) event.stopPropagation();
const file = currentFiles[index]; const file = currentFiles[index];
if (!file) return; if (!file) return;
// UI 상태 업데이트: 분석 중 표시 // UI 상태 업데이트: 분석 중 표시
const logArea = document.getElementById(`log-area-${index}`); const logArea = document.getElementById(`log-area-${index}`);
const logContent = document.getElementById(`log-content-${index}`); const logContent = document.getElementById(`log-content-${index}`);
if (logArea) logArea.classList.add('active'); if (logArea) logArea.classList.add('active');
if (logContent) { if (logContent) {
logContent.innerHTML = `<div class="ai-status-msg"> logContent.innerHTML = `<div class="ai-status-msg">
<span class="ai-loading-spinner"></span> <span class="ai-loading-spinner"></span>
AI가 문서를 정밀 분석 중입니다... AI가 문서를 정밀 분석 중입니다...
</div>`; </div>`;
} }
try { try {
const res = await fetch(`${API.ANALYZE_FILE}?filename=${encodeURIComponent(file.name)}`); const res = await fetch(`${API.ANALYZE_FILE}?filename=${encodeURIComponent(file.name)}`);
const result = await res.json(); const result = await res.json();
if (result.error) { if (result.error) {
if (logContent) logContent.innerHTML = `<div style="color:var(--error-color);">오류: ${result.error}</div>`; if (logContent) logContent.innerHTML = `<div style="color:var(--error-color);">오류: ${result.error}</div>`;
return; return;
} }
// 분석 결과 저장 및 UI 갱신 // 분석 결과 저장 및 UI 갱신
currentFiles[index].analysis = result.final_result; currentFiles[index].analysis = result.final_result;
currentFiles[index].analysis.isManual = false; currentFiles[index].analysis.isManual = false;
if (logContent) { if (logContent) {
logContent.innerHTML = ` logContent.innerHTML = `
<div class="ai-analysis-result"> <div class="ai-analysis-result">
<div style="font-weight:700; color:var(--primary-lv-6); margin-bottom:4px;">✨ AI 분석 완료</div> <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 style="font-size:11px; color:var(--text-sub); line-height:1.4;">${result.final_result.reason}</div>
</div> </div>
`; `;
} }
renderFiles(); renderFiles();
} catch (e) { } catch (e) {
console.error("AI Analysis failed:", e); console.error("AI Analysis failed:", e);
if (logContent) logContent.innerHTML = `<div style="color:var(--error-color);">분석 실패: 네트워크 오류가 발생했습니다.</div>`; if (logContent) logContent.innerHTML = `<div style="color:var(--error-color);">분석 실패: 네트워크 오류가 발생했습니다.</div>`;
} }
} }
// --- 미리보기 제어 --- // --- 미리보기 제어 ---
function showPreview(index, event) { function showPreview(index, event) {
if (event && (event.target.closest('.btn-group') || event.target.closest('.path-display'))) return; if (event && (event.target.closest('.btn-group') || event.target.closest('.path-display'))) return;
const file = currentFiles[index]; const file = currentFiles[index];
if (!file) return; if (!file) return;
const previewArea = document.getElementById('mailPreviewArea'); const previewArea = document.getElementById('mailPreviewArea');
const toggleIcon = document.getElementById('previewToggleIcon'); const toggleIcon = document.getElementById('previewToggleIcon');
const fullViewBtn = document.getElementById('fullViewBtn'); const fullViewBtn = document.getElementById('fullViewBtn');
const previewContainer = document.getElementById('previewContainer'); const previewContainer = document.getElementById('previewContainer');
if (previewArea) { if (previewArea) {
previewArea.classList.add('active'); previewArea.classList.add('active');
if (toggleIcon) toggleIcon.innerText = '▶'; if (toggleIcon) toggleIcon.innerText = '▶';
} }
const fileUrl = Utils.getSafeFileUrl(file.name); const fileUrl = Utils.getSafeFileUrl(file.name);
if (fullViewBtn) { if (fullViewBtn) {
fullViewBtn.style.display = 'block'; fullViewBtn.style.display = 'block';
fullViewBtn.onclick = () => window.open(fileUrl, 'PMFullView', 'width=1000,height=800'); fullViewBtn.onclick = () => window.open(fileUrl, 'PMFullView', 'width=1000,height=800');
} }
if (file.name.toLowerCase().endsWith('.pdf')) { if (file.name.toLowerCase().endsWith('.pdf')) {
previewContainer.innerHTML = `<iframe src="${fileUrl}#page=1" style="width:100%; height:100%; border:none;"></iframe>`; previewContainer.innerHTML = `<iframe src="${fileUrl}#page=1" style="width:100%; height:100%; border:none;"></iframe>`;
} else { } 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>`; 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')); document.querySelectorAll('.attachment-item').forEach(item => item.classList.remove('active'));
if (event?.currentTarget) event.currentTarget.classList.add('active'); if (event?.currentTarget) event.currentTarget.classList.add('active');
} }
function togglePreviewAuto() { function togglePreviewAuto() {
const area = document.getElementById('mailPreviewArea'); const area = document.getElementById('mailPreviewArea');
const icon = document.getElementById('previewToggleIcon'); const icon = document.getElementById('previewToggleIcon');
if (area) { if (area) {
const isActive = area.classList.toggle('active'); const isActive = area.classList.toggle('active');
if (icon) icon.innerText = isActive ? '▶' : '◀'; if (icon) icon.innerText = isActive ? '▶' : '◀';
} }
} }
// --- 메일 리스트 제어 --- // --- 메일 리스트 제어 ---
function renderMailList(tabType, mailsToShow = null) { function renderMailList(tabType, mailsToShow = null) {
currentMailTab = tabType; currentMailTab = tabType;
const container = document.querySelector('.mail-items-container'); const container = document.querySelector('.mail-items-container');
if (!container) return; if (!container) return;
const mails = mailsToShow || MAIL_SAMPLES[tabType] || []; const mails = mailsToShow || MAIL_SAMPLES[tabType] || [];
filteredMails = mails; filteredMails = mails;
updateBulkActionBar(); updateBulkActionBar();
container.innerHTML = mails.map((mail, idx) => ` container.innerHTML = mails.map((mail, idx) => `
<div class="mail-item ${mail.active ? 'active' : ''}" onclick="selectMailItem(this, ${idx})"> <div class="mail-item ${mail.active ? 'active' : ''}" onclick="selectMailItem(this, ${idx})">
<input type="checkbox" class="mail-item-checkbox" onclick="event.stopPropagation()" onchange="updateBulkActionBar()"> <input type="checkbox" class="mail-item-checkbox" onclick="event.stopPropagation()" onchange="updateBulkActionBar()">
<div class="mail-item-content"> <div class="mail-item-content">
<div class="flex-between" style="margin-bottom:6px;"> <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> <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 class="mail-item-info"><span class="mail-date">${mail.time}</span></div>
</div> </div>
<div style="font-weight:600; font-size:12px; margin-bottom:4px; color:#2d3748;">${mail.title}</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 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>
</div> </div>
`).join(''); `).join('');
const activeIdx = mails.findIndex(m => m.active); const activeIdx = mails.findIndex(m => m.active);
if (activeIdx !== -1) updateMailContent(mails[activeIdx]); if (activeIdx !== -1) updateMailContent(mails[activeIdx]);
} }
function selectMailItem(el, index) { function selectMailItem(el, index) {
document.querySelectorAll('.mail-item').forEach(item => item.classList.remove('active')); document.querySelectorAll('.mail-item').forEach(item => item.classList.remove('active'));
el.classList.add('active'); el.classList.add('active');
const mail = filteredMails[index]; const mail = filteredMails[index];
if (mail) updateMailContent(mail); if (mail) updateMailContent(mail);
} }
function updateMailContent(mail) { function updateMailContent(mail) {
const title = document.querySelector('.mail-content-header h2'); const title = document.querySelector('.mail-content-header h2');
if (title) title.innerText = mail.title; if (title) title.innerText = mail.title;
document.querySelector('.mail-body').innerHTML = mail.summary.replace(/\n/g, '<br>') + "<br><br>본 내용은 샘플 데이터입니다."; document.querySelector('.mail-body').innerHTML = mail.summary.replace(/\n/g, '<br>') + "<br><br>본 내용은 샘플 데이터입니다.";
} }
function switchMailTab(el, tabType) { function switchMailTab(el, tabType) {
document.querySelectorAll('.mail-tab').forEach(tab => tab.classList.remove('active')); document.querySelectorAll('.mail-tab').forEach(tab => tab.classList.remove('active'));
el.classList.add('active'); el.classList.add('active');
renderMailList(tabType); renderMailList(tabType);
} }
// --- 경로 선택 모달 --- // --- 경로 선택 모달 ---
function openPathModal(index, event) { function openPathModal(index, event) {
if (event) event.stopPropagation(); if (event) event.stopPropagation();
editingIndex = index; editingIndex = index;
const tabSelect = document.getElementById('tabSelect'); const tabSelect = document.getElementById('tabSelect');
if (tabSelect) { if (tabSelect) {
tabSelect.innerHTML = Object.keys(HIERARCHY).map(tab => `<option value="${tab}">${tab}</option>`).join(''); tabSelect.innerHTML = Object.keys(HIERARCHY).map(tab => `<option value="${tab}">${tab}</option>`).join('');
updateCategories(); updateCategories();
ModalManager.open('pathModal'); ModalManager.open('pathModal');
} }
} }
function updateCategories() { function updateCategories() {
const tab = document.getElementById('tabSelect').value; const tab = document.getElementById('tabSelect').value;
document.getElementById('categorySelect').innerHTML = Object.keys(HIERARCHY[tab]).map(cat => `<option value="${cat}">${cat}</option>`).join(''); document.getElementById('categorySelect').innerHTML = Object.keys(HIERARCHY[tab]).map(cat => `<option value="${cat}">${cat}</option>`).join('');
updateSubs(); updateSubs();
} }
function updateSubs() { function updateSubs() {
const tab = document.getElementById('tabSelect').value; const tab = document.getElementById('tabSelect').value;
const cat = document.getElementById('categorySelect').value; const cat = document.getElementById('categorySelect').value;
document.getElementById('subSelect').innerHTML = HIERARCHY[tab][cat].map(sub => `<option value="${sub}">${sub}</option>`).join(''); document.getElementById('subSelect').innerHTML = HIERARCHY[tab][cat].map(sub => `<option value="${sub}">${sub}</option>`).join('');
} }
function applyPathSelection() { function applyPathSelection() {
const path = `${document.getElementById('tabSelect').value} > ${document.getElementById('categorySelect').value} > ${document.getElementById('subSelect').value}`; const path = `${document.getElementById('tabSelect').value} > ${document.getElementById('categorySelect').value} > ${document.getElementById('subSelect').value}`;
if (!currentFiles[editingIndex].analysis) currentFiles[editingIndex].analysis = {}; if (!currentFiles[editingIndex].analysis) currentFiles[editingIndex].analysis = {};
currentFiles[editingIndex].analysis.suggested_path = path; currentFiles[editingIndex].analysis.suggested_path = path;
currentFiles[editingIndex].analysis.isManual = true; currentFiles[editingIndex].analysis.isManual = true;
renderFiles(); renderFiles();
ModalManager.close('pathModal'); ModalManager.close('pathModal');
} }
// --- 주소록 관리 --- // --- 주소록 관리 ---
let addressBookData = [ let addressBookData = [
{ name: "이태훈", dept: "PM Overseas / 선임연구원", email: "th.lee@projectmaster.com", phone: "010-1234-5678" }, { 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" } { name: "Pany S.", dept: "라오스 농림부 / 국장", email: "pany.s@lao.gov.la", phone: "+856-20-1234-5678" }
]; ];
let contactEditingIndex = -1; let contactEditingIndex = -1;
function openAddressBook() { renderAddressBook(); ModalManager.open('addressBookModal'); } function openAddressBook() { renderAddressBook(); ModalManager.open('addressBookModal'); }
function closeAddressBook() { ModalManager.close('addressBookModal'); } function closeAddressBook() { ModalManager.close('addressBookModal'); }
function renderAddressBook() { function renderAddressBook() {
const body = document.getElementById('addressBookBody'); const body = document.getElementById('addressBookBody');
if (!body) return; if (!body) return;
body.innerHTML = addressBookData.map((c, idx) => ` body.innerHTML = addressBookData.map((c, idx) => `
<tr> <tr>
<td><strong>${c.name}</strong></td><td>${c.dept}</td><td>${c.email}</td><td>${c.phone}</td> <td><strong>${c.name}</strong></td><td>${c.dept}</td><td>${c.email}</td><td>${c.phone}</td>
<td style="text-align:right;"> <td style="text-align:right;">
<button class="_button-xsmall" onclick="editContact(${idx})">수정</button> <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> <button class="_button-xsmall" style="color:var(--error-color); border-color:#feb2b2; background:#fff5f5;" onclick="deleteContact(${idx})">삭제</button>
</td> </td>
</tr>`).join(''); </tr>`).join('');
} }
function toggleAddContactForm() { function toggleAddContactForm() {
const form = document.getElementById('addContactForm'); const form = document.getElementById('addContactForm');
if (form.style.display === 'none') form.style.display = 'block'; if (form.style.display === 'none') form.style.display = 'block';
else { form.style.display = 'none'; contactEditingIndex = -1; } else { form.style.display = 'none'; contactEditingIndex = -1; }
} }
function editContact(index) { function editContact(index) {
const c = addressBookData[index]; const c = addressBookData[index];
contactEditingIndex = index; contactEditingIndex = index;
document.getElementById('newContactName').value = c.name; document.getElementById('newContactName').value = c.name;
document.getElementById('newContactDept').value = c.dept; document.getElementById('newContactDept').value = c.dept;
document.getElementById('newContactEmail').value = c.email; document.getElementById('newContactEmail').value = c.email;
document.getElementById('newContactPhone').value = c.phone; document.getElementById('newContactPhone').value = c.phone;
document.getElementById('addContactForm').style.display = 'block'; document.getElementById('addContactForm').style.display = 'block';
} }
function deleteContact(index) { function deleteContact(index) {
if (confirm(`'${addressBookData[index].name}'님을 삭제하시겠습니까?`)) { addressBookData.splice(index, 1); renderAddressBook(); } if (confirm(`'${addressBookData[index].name}'님을 삭제하시겠습니까?`)) { addressBookData.splice(index, 1); renderAddressBook(); }
} }
function addContact() { function addContact() {
const name = document.getElementById('newContactName').value; const name = document.getElementById('newContactName').value;
if (!name) return alert("이름을 입력해주세요."); if (!name) return alert("이름을 입력해주세요.");
const data = { name, dept: document.getElementById('newContactDept').value, email: document.getElementById('newContactEmail').value, phone: document.getElementById('newContactPhone').value }; const data = { name, dept: document.getElementById('newContactDept').value, email: document.getElementById('newContactEmail').value, phone: document.getElementById('newContactPhone').value };
if (contactEditingIndex > -1) addressBookData[contactEditingIndex] = data; if (contactEditingIndex > -1) addressBookData[contactEditingIndex] = data;
else addressBookData.push(data); else addressBookData.push(data);
renderAddressBook(); toggleAddContactForm(); renderAddressBook(); toggleAddContactForm();
} }
// --- 공통 액션 --- // --- 공통 액션 ---
function updateBulkActionBar() { function updateBulkActionBar() {
const count = document.querySelectorAll('.mail-item-checkbox:checked').length; const count = document.querySelectorAll('.mail-item-checkbox:checked').length;
const bar = document.getElementById('mailBulkActions'); const bar = document.getElementById('mailBulkActions');
if (count > 0) { bar.classList.add('active'); document.getElementById('selectedCount').innerText = `${count}개 선택됨`; } if (count > 0) { bar.classList.add('active'); document.getElementById('selectedCount').innerText = `${count}개 선택됨`; }
else bar.classList.remove('active'); else bar.classList.remove('active');
} }
// 초기화 // 초기화
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
loadAttachments(); loadAttachments();
renderMailList('inbound'); renderMailList('inbound');
}); });

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

View File

@@ -1,97 +1,97 @@
import numpy as np import numpy as np
from datetime import datetime from datetime import datetime
class SOIPredictionService: class SOIPredictionService:
"""학습형 시계열 예측 및 피처 추출 엔진""" """학습형 시계열 예측 및 피처 추출 엔진"""
@staticmethod @staticmethod
def get_historical_soi(cursor, project_id): def get_historical_avi(cursor, project_id):
"""DB에서 프로젝트의 과거 SOI 히스토리를 시퀀스로 추출""" """DB에서 프로젝트의 과거 AVI 히스토리를 시퀀스로 추출"""
cursor.execute(""" cursor.execute("""
SELECT crawl_date, file_count, recent_log SELECT crawl_date, file_count, recent_log
FROM projects_history FROM projects_history
WHERE project_id = %s WHERE project_id = %s
ORDER BY crawl_date ASC ORDER BY crawl_date ASC
""", (project_id,)) """, (project_id,))
return cursor.fetchall() return cursor.fetchall()
@staticmethod @staticmethod
def extract_vitality_features(history): def extract_vitality_features(history):
"""딥러닝 학습을 위한 4대 핵심 피처 추출 (Feature Engineering)""" """딥러닝 학습을 위한 4대 핵심 피처 추출 (Feature Engineering)"""
if len(history) < 2: if len(history) < 2:
return {"velocity": 0, "acceleration": 0, "consistency": 0.5, "density": 0.1} return {"velocity": 0, "acceleration": 0, "consistency": 0.5, "density": 0.1}
# 실제 데이터 구조에 맞게 보정 # 실제 데이터 구조에 맞게 보정
counts = [] counts = []
for h in history: for h in history:
try: try:
val = int(h['file_count']) if h['file_count'] is not None else 0 val = int(h['file_count']) if h['file_count'] is not None else 0
counts.append(val) counts.append(val)
except: except:
counts.append(0) counts.append(0)
# 1. 활동 속도 (Velocity) # 1. 활동 속도 (Velocity)
velocity = np.diff(counts).mean() if len(counts) > 1 else 0 velocity = np.diff(counts).mean() if len(counts) > 1 else 0
# 2. 활동 가속도 (Acceleration): 최근 활동이 빨라지는지 느려지는지 # 2. 활동 가속도 (Acceleration): 최근 활동이 빨라지는지 느려지는지
acceleration = np.diff(np.diff(counts)).mean() if len(counts) > 2 else 0 acceleration = np.diff(np.diff(counts)).mean() if len(counts) > 2 else 0
# 3. 로그 밀도 (Density): 전체 기간 대비 실제 로그 발생 비율 # 3. 로그 밀도 (Density): 전체 기간 대비 실제 로그 발생 비율
logs = [h['recent_log'] for h in history if h['recent_log'] and h['recent_log'] != "데이터 없음"] 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 density = len(logs) / len(history) if len(history) > 0 else 0
# 4. 관리 일관성 (Consistency): 업데이트 간격의 표준편차 (낮을수록 좋음) # 4. 관리 일관성 (Consistency): 업데이트 간격의 표준편차 (낮을수록 좋음)
# (현재 데이터는 일일 크롤링이므로 로그 텍스트 변화 시점을 기준으로 간격 계산 가능) # (현재 데이터는 일일 크롤링이므로 로그 텍스트 변화 시점을 기준으로 간격 계산 가능)
return { return {
"velocity": float(velocity), "velocity": float(velocity),
"acceleration": float(acceleration), "acceleration": float(acceleration),
"density": float(density), "density": float(density),
"sample_count": len(history) "sample_count": len(history)
} }
@staticmethod @staticmethod
def predict_future_soi(current_soi, history, days_ahead=14): def predict_future_avi(current_avi, history, days_ahead=14):
"""기존 점수와 시계열 피처를 결합하여 미래 점수 예측""" """기존 점수와 시계열 피처를 결합하여 미래 점수 예측"""
# 데이터가 너무 적으면 무조건 보수적 감쇄 (14일 기준 약 -2.1점) # 데이터가 너무 적으면 무조건 보수적 감쇄 (14일 기준 약 -2.1점)
if not history or len(history) < 3: if not history or len(history) < 3:
return round(max(0, min(100, current_soi - (0.15 * days_ahead))), 1) return round(max(0, min(100, current_avi - (0.15 * days_ahead))), 1)
features = SOIPredictionService.extract_vitality_features(history) features = SOIPredictionService.extract_vitality_features(history)
current_val = float(current_soi) current_val = float(current_avi)
# [정밀 정체 분석] # [정밀 정체 분석]
# 1. 파일 수 변화 확인 (최근 5개 샘플) # 1. 파일 수 변화 확인 (최근 5개 샘플)
recent_counts = [int(h['file_count'] or 0) for h in history[-5:]] recent_counts = [int(h['file_count'] or 0) for h in history[-5:]]
is_hard_stagnant = len(set(recent_counts)) <= 1 # 파일 수 변동이 전혀 없음 is_hard_stagnant = len(set(recent_counts)) <= 1 # 파일 수 변동이 전혀 없음
# 2. 최근 로그 상태 확인 # 2. 최근 로그 상태 확인
last_log = history[-1]['recent_log'] last_log = history[-1]['recent_log']
is_no_activity = last_log is None or last_log == "데이터 없음" or "폴더자동삭제" in last_log is_no_activity = last_log is None or last_log == "데이터 없음" or "폴더자동삭제" in last_log
# [모멘텀 산출 로직 개편] # [모멘텀 산출 로직 개편]
if is_hard_stagnant: if is_hard_stagnant:
# 파일 변화가 없다면 아무리 로그가 있어도 '유지 관리'일 뿐 '성장'이 아님 # 파일 변화가 없다면 아무리 로그가 있어도 '유지 관리'일 뿐 '성장'이 아님
# 오히려 시간이 갈수록 기술 부채와 데이터 노후화가 진행된다고 판단 (강력 패널티) # 오히려 시간이 갈수록 기술 부채와 데이터 노후화가 진행된다고 판단 (강력 패널티)
momentum_factor = -2.5 if is_no_activity else -1.0 momentum_factor = -2.5 if is_no_activity else -1.0
else: else:
# 실질적인 파일 수 변화(Velocity)가 있을 때만 긍정적 모멘텀 검토 # 실질적인 파일 수 변화(Velocity)가 있을 때만 긍정적 모멘텀 검토
v_gain = features['velocity'] * 0.5 v_gain = features['velocity'] * 0.5
d_gain = features['density'] * 0.8 d_gain = features['density'] * 0.8
momentum_factor = v_gain + d_gain - 0.5 # 기본적으로 하향 압력 부여 momentum_factor = v_gain + d_gain - 0.5 # 기본적으로 하향 압력 부여
# 예측 로직: 현재값 + 모멘텀 - (시간에 따른 자연 부식) # 예측 로직: 현재값 + 모멘텀 - (시간에 따른 자연 부식)
# 정체 시 momentum_factor가 -1.0~-2.5이므로 감쇄가 매우 빠름 # 정체 시 momentum_factor가 -1.0~-2.5이므로 감쇄가 매우 빠름
decay_constant = 0.08 decay_constant = 0.08
predicted = current_val + momentum_factor - (decay_constant * days_ahead) predicted = current_val + momentum_factor - (decay_constant * days_ahead)
# [최종 방어 로직] # [최종 방어 로직]
# 실질적 파일 증가(velocity > 0)가 포착되지 않았다면 예보는 현재값보다 클 수 없음 # 실질적 파일 증가(velocity > 0)가 포착되지 않았다면 예보는 현재값보다 클 수 없음
if features['velocity'] <= 0 and predicted > current_val: if features['velocity'] <= 0 and predicted > current_val:
predicted = current_val - 1.5 # 강제 하락 predicted = current_val - 1.5 # 강제 하락
# 사망 선고 (AVI가 이미 낮고 정체 중이면 0에 수렴하도록 가속) # 사망 선고 (AVI가 이미 낮고 정체 중이면 0에 수렴하도록 가속)
if current_val < 20 and is_hard_stagnant: if current_val < 20 and is_hard_stagnant:
predicted = max(0, predicted - 5.0) predicted = max(0, predicted - 5.0)
return round(max(0, min(100, predicted)), 1) return round(max(0, min(100, predicted)), 1)

View File

@@ -1,33 +1,33 @@
from sql_queries import DashboardQueries from sql_queries import DashboardQueries
class ProjectService: class ProjectService:
@staticmethod @staticmethod
def get_available_dates_logic(cursor): def get_available_dates_logic(cursor):
cursor.execute(DashboardQueries.GET_AVAILABLE_DATES) cursor.execute(DashboardQueries.GET_AVAILABLE_DATES)
rows = cursor.fetchall() rows = cursor.fetchall()
return [row['crawl_date'].strftime("%Y.%m.%d") for row in rows if row['crawl_date']] return [row['crawl_date'].strftime("%Y.%m.%d") for row in rows if row['crawl_date']]
@staticmethod @staticmethod
def get_project_data_logic(cursor, date_str): def get_project_data_logic(cursor, date_str):
target_date = date_str.replace(".", "-") if date_str and date_str != "-" else None target_date = date_str.replace(".", "-") if date_str and date_str != "-" else None
if not target_date: if not target_date:
cursor.execute(DashboardQueries.GET_LAST_CRAWL_DATE) cursor.execute(DashboardQueries.GET_LAST_CRAWL_DATE)
res = cursor.fetchone() res = cursor.fetchone()
target_date = res['last_date'] target_date = res['last_date']
if not target_date: if not target_date:
return {"projects": []} return {"projects": []}
cursor.execute(DashboardQueries.GET_PROJECT_LIST, (target_date,)) cursor.execute(DashboardQueries.GET_PROJECT_LIST, (target_date,))
rows = cursor.fetchall() rows = cursor.fetchall()
projects = [] projects = []
for r in rows: for r in rows:
name = r['short_nm'] if r['short_nm'] and r['short_nm'].strip() else r['project_nm'] name = r['short_nm'] if r['short_nm'] and r['short_nm'].strip() else r['project_nm']
projects.append([ projects.append([
name, r['department'], r['master'], name, r['department'], r['master'],
r['recent_log'], r['file_count'], r['recent_log'], r['file_count'],
r['continent'], r['country'] r['continent'], r['country']
]) ])
return {"projects": projects} return {"projects": projects}

View File

@@ -2,4 +2,14 @@ fastapi==0.110.0
uvicorn==0.29.0 uvicorn==0.29.0
playwright==1.42.0 playwright==1.42.0
python-dotenv==1.0.1 python-dotenv==1.0.1
pypdf==4.1.0 pypdf==4.1.0
pymysql
pandas
sqlalchemy
openpyxl
pytesseract
pdf2image
pillow
numpy
pydantic
jinja2

View File

@@ -1,10 +1,10 @@
from pydantic import BaseModel from pydantic import BaseModel
class AuthRequest(BaseModel): class AuthRequest(BaseModel):
user_id: str user_id: str
password: str password: str
class InquiryReplyRequest(BaseModel): class InquiryReplyRequest(BaseModel):
reply: str reply: str
status: str status: str
handler: str handler: str

364
server.py
View File

@@ -1,182 +1,182 @@
import os import os
import sys import sys
import asyncio import asyncio
import pymysql import pymysql
from fastapi import FastAPI, Request from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import StreamingResponse, FileResponse from fastapi.responses import StreamingResponse, FileResponse
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates from fastapi.templating import Jinja2Templates
from analyze import analyze_file_content from analyze import analyze_file_content
from crawler_service import run_crawler_service, crawl_stop_event from crawler_service import run_crawler_service, crawl_stop_event
from schemas import AuthRequest, InquiryReplyRequest from schemas import AuthRequest, InquiryReplyRequest
from inquiry_service import InquiryService from inquiry_service import InquiryService
from project_service import ProjectService from project_service import ProjectService
from analysis_service import AnalysisService from analysis_service import AnalysisService
# --- 환경 설정 --- # --- 환경 설정 ---
os.environ["PYTHONIOENCODING"] = "utf-8" os.environ["PYTHONIOENCODING"] = "utf-8"
TESSDATA_PREFIX = os.getenv("TESSDATA_PREFIX", r"C:\Users\User\AppData\Local\Programs\Tesseract-OCR\tessdata") TESSDATA_PREFIX = os.getenv("TESSDATA_PREFIX", r"C:\Users\User\AppData\Local\Programs\Tesseract-OCR\tessdata")
os.environ["TESSDATA_PREFIX"] = TESSDATA_PREFIX os.environ["TESSDATA_PREFIX"] = TESSDATA_PREFIX
app = FastAPI(title="Project Master Overseas API") app = FastAPI(title="Project Master Overseas API")
templates = Jinja2Templates(directory="templates") templates = Jinja2Templates(directory="templates")
# 정적 파일 마운트 # 정적 파일 마운트
app.mount("/style", StaticFiles(directory="style"), name="style") app.mount("/style", StaticFiles(directory="style"), name="style")
app.mount("/js", StaticFiles(directory="js"), name="js") app.mount("/js", StaticFiles(directory="js"), name="js")
app.mount("/sample_files", StaticFiles(directory="sample"), name="sample_files") app.mount("/sample_files", StaticFiles(directory="sample"), name="sample_files")
app.add_middleware( app.add_middleware(
CORSMiddleware, CORSMiddleware,
allow_origins=["*"], allow_origins=["*"],
allow_credentials=False, allow_credentials=False,
allow_methods=["*"], allow_methods=["*"],
allow_headers=["*"], allow_headers=["*"],
) )
# --- 유틸리티 함수 --- # --- 유틸리티 함수 ---
def get_db_connection(): def get_db_connection():
"""MySQL 데이터베이스 연결을 반환""" """MySQL 데이터베이스 연결을 반환"""
return pymysql.connect( return pymysql.connect(
host=os.getenv('DB_HOST', 'localhost'), host=os.getenv('DB_HOST', 'localhost'),
user=os.getenv('DB_USER', 'root'), user=os.getenv('DB_USER', 'root'),
password=os.getenv('DB_PASSWORD', '45278434'), password=os.getenv('DB_PASSWORD', '45278434'),
database=os.getenv('DB_NAME', 'PM_proto'), database=os.getenv('DB_NAME', 'PM_proto'),
charset='utf8mb4', charset='utf8mb4',
cursorclass=pymysql.cursors.DictCursor cursorclass=pymysql.cursors.DictCursor
) )
async def run_in_threadpool(func, *args): async def run_in_threadpool(func, *args):
"""동기 함수를 비차단 방식으로 실행""" """동기 함수를 비차단 방식으로 실행"""
loop = asyncio.get_event_loop() loop = asyncio.get_event_loop()
return await loop.run_in_executor(None, func, *args) return await loop.run_in_executor(None, func, *args)
# --- HTML 라우팅 --- # --- HTML 라우팅 ---
@app.get("/") @app.get("/")
async def root(request: Request): async def root(request: Request):
return templates.TemplateResponse("index.html", {"request": request}) return templates.TemplateResponse("index.html", {"request": request})
@app.get("/dashboard") @app.get("/dashboard")
async def get_dashboard(request: Request): async def get_dashboard(request: Request):
return templates.TemplateResponse("dashboard.html", {"request": request}) return templates.TemplateResponse("dashboard.html", {"request": request})
@app.get("/mailTest") @app.get("/mailTest")
async def get_mail_test(request: Request): async def get_mail_test(request: Request):
return templates.TemplateResponse("mailTest.html", {"request": request}) return templates.TemplateResponse("mailTest.html", {"request": request})
@app.get("/inquiries") @app.get("/inquiries")
async def get_inquiries_page(request: Request): async def get_inquiries_page(request: Request):
return templates.TemplateResponse("inquiries.html", {"request": request}) return templates.TemplateResponse("inquiries.html", {"request": request})
@app.get("/analysis") @app.get("/analysis")
async def get_analysis_page(request: Request): async def get_analysis_page(request: Request):
return templates.TemplateResponse("analysis.html", {"request": request}) return templates.TemplateResponse("analysis.html", {"request": request})
# --- 문의사항 API --- # --- 문의사항 API ---
@app.get("/api/inquiries") @app.get("/api/inquiries")
async def get_inquiries(pm_type: str = None, category: str = None, status: str = None, keyword: str = None): async def get_inquiries(pm_type: str = None, category: str = None, status: str = None, keyword: str = None):
try: try:
with get_db_connection() as conn: with get_db_connection() as conn:
with conn.cursor() as cursor: with conn.cursor() as cursor:
return InquiryService.get_inquiries_logic(cursor, pm_type, category, status, keyword) return InquiryService.get_inquiries_logic(cursor, pm_type, category, status, keyword)
except Exception as e: except Exception as e:
return {"error": str(e)} return {"error": str(e)}
@app.get("/api/inquiries/{id}") @app.get("/api/inquiries/{id}")
async def get_inquiry_detail(id: int): async def get_inquiry_detail(id: int):
try: try:
with get_db_connection() as conn: with get_db_connection() as conn:
with conn.cursor() as cursor: with conn.cursor() as cursor:
return InquiryService.get_inquiry_detail_logic(cursor, id) return InquiryService.get_inquiry_detail_logic(cursor, id)
except Exception as e: except Exception as e:
return {"error": str(e)} return {"error": str(e)}
@app.post("/api/inquiries/{id}/reply") @app.post("/api/inquiries/{id}/reply")
async def update_inquiry_reply(id: int, req: InquiryReplyRequest): async def update_inquiry_reply(id: int, req: InquiryReplyRequest):
try: try:
with get_db_connection() as conn: with get_db_connection() as conn:
with conn.cursor() as cursor: with conn.cursor() as cursor:
return InquiryService.update_inquiry_reply_logic(cursor, conn, id, req) return InquiryService.update_inquiry_reply_logic(cursor, conn, id, req)
except Exception as e: except Exception as e:
return {"error": str(e)} return {"error": str(e)}
@app.delete("/api/inquiries/{id}/reply") @app.delete("/api/inquiries/{id}/reply")
async def delete_inquiry_reply(id: int): async def delete_inquiry_reply(id: int):
try: try:
with get_db_connection() as conn: with get_db_connection() as conn:
with conn.cursor() as cursor: with conn.cursor() as cursor:
return InquiryService.delete_inquiry_reply_logic(cursor, conn, id) return InquiryService.delete_inquiry_reply_logic(cursor, conn, id)
except Exception as e: except Exception as e:
return {"error": str(e)} return {"error": str(e)}
# --- 프로젝트 및 히스토리 API --- # --- 프로젝트 및 히스토리 API ---
@app.get("/available-dates") @app.get("/available-dates")
async def get_available_dates(): async def get_available_dates():
try: try:
with get_db_connection() as conn: with get_db_connection() as conn:
with conn.cursor() as cursor: with conn.cursor() as cursor:
return ProjectService.get_available_dates_logic(cursor) return ProjectService.get_available_dates_logic(cursor)
except Exception as e: except Exception as e:
return {"error": str(e)} return {"error": str(e)}
@app.get("/project-data") @app.get("/project-data")
async def get_project_data(date: str = None): async def get_project_data(date: str = None):
try: try:
with get_db_connection() as conn: with get_db_connection() as conn:
with conn.cursor() as cursor: with conn.cursor() as cursor:
return ProjectService.get_project_data_logic(cursor, date) return ProjectService.get_project_data_logic(cursor, date)
except Exception as e: except Exception as e:
return {"error": str(e)} return {"error": str(e)}
# --- 분석 API (AnalysisService 연동) --- # --- 분석 API (AnalysisService 연동) ---
@app.get("/project-activity") @app.get("/project-activity")
async def get_project_activity(date: str = None): async def get_project_activity(date: str = None):
try: try:
with get_db_connection() as conn: with get_db_connection() as conn:
with conn.cursor() as cursor: with conn.cursor() as cursor:
return AnalysisService.get_project_activity_logic(cursor, date) return AnalysisService.get_project_activity_logic(cursor, date)
except Exception as e: except Exception as e:
return {"error": str(e)} return {"error": str(e)}
@app.get("/api/analysis/p-war") @app.get("/api/analysis/p-war")
async def get_p_war_analysis(): async def get_p_war_analysis():
try: try:
with get_db_connection() as conn: with get_db_connection() as conn:
with conn.cursor() as cursor: with conn.cursor() as cursor:
return AnalysisService.get_p_zsr_analysis_logic(cursor) return AnalysisService.get_p_zsr_analysis_logic(cursor)
except Exception as e: except Exception as e:
return {"error": str(e)} return {"error": str(e)}
# --- 수집 및 동기화 API --- # --- 수집 및 동기화 API ---
@app.post("/auth/crawl") @app.post("/auth/crawl")
async def auth_crawl(req: AuthRequest): async def auth_crawl(req: AuthRequest):
if req.user_id == os.getenv("PM_USER_ID") and req.password == os.getenv("PM_PASSWORD"): if req.user_id == os.getenv("PM_USER_ID") and req.password == os.getenv("PM_PASSWORD"):
return {"success": True} return {"success": True}
return {"success": False, "message": "크롤링을 할 수 없습니다."} return {"success": False, "message": "크롤링을 할 수 없습니다."}
@app.get("/sync") @app.get("/sync")
async def sync_data(): async def sync_data():
return StreamingResponse(run_crawler_service(), media_type="text_event-stream") return StreamingResponse(run_crawler_service(), media_type="text_event-stream")
@app.get("/stop-sync") @app.get("/stop-sync")
async def stop_sync(): async def stop_sync():
crawl_stop_event.set() crawl_stop_event.set()
return {"success": True} return {"success": True}
# --- 파일 및 첨부파일 API --- # --- 파일 및 첨부파일 API ---
@app.get("/attachments") @app.get("/attachments")
async def get_attachments(): async def get_attachments():
path = "sample" path = "sample"
if not os.path.exists(path): os.makedirs(path) 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"} 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))] for f in os.listdir(path) if os.path.isfile(os.path.join(path, f))]
@app.get("/analyze-file") @app.get("/analyze-file")
async def analyze_file(filename: str): async def analyze_file(filename: str):
return await run_in_threadpool(analyze_file_content, filename) return await run_in_threadpool(analyze_file_content, filename)
@app.get("/sample.png") @app.get("/sample.png")
async def get_sample_img(): async def get_sample_img():
return FileResponse("sample.png") 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: class InquiryQueries:
"""문의사항(Inquiries) 페이지 관련 쿼리""" """문의사항(Inquiries) 페이지 관련 쿼리"""
# 필터링을 위한 기본 쿼리 (WHERE 1=1 포함) # 필터링을 위한 기본 쿼리 (WHERE 1=1 포함)
SELECT_BASE = "SELECT * FROM inquiries WHERE 1=1" SELECT_BASE = "SELECT * FROM inquiries WHERE 1=1"
ORDER_BY_DESC = "ORDER BY no DESC" ORDER_BY_DESC = "ORDER BY no DESC"
# 상세 조회 # 상세 조회
SELECT_BY_ID = "SELECT * FROM inquiries WHERE id = %s" SELECT_BY_ID = "SELECT * FROM inquiries WHERE id = %s"
# 답변 업데이트 (handled_date 포함) # 답변 업데이트 (handled_date 포함)
UPDATE_REPLY = """ UPDATE_REPLY = """
UPDATE inquiries UPDATE inquiries
SET reply = %s, status = %s, handler = %s, handled_date = %s SET reply = %s, status = %s, handler = %s, handled_date = %s
WHERE id = %s WHERE id = %s
""" """
# 답변 삭제 (초기화) # 답변 삭제 (초기화)
DELETE_REPLY = """ DELETE_REPLY = """
UPDATE inquiries UPDATE inquiries
SET reply = '', status = '미확인', handled_date = '' SET reply = '', status = '미확인', handled_date = ''
WHERE id = %s WHERE id = %s
""" """
class DashboardQueries: class DashboardQueries:
"""대시보드(Dashboard) 및 프로젝트 현황 관련 쿼리""" """대시보드(Dashboard) 및 프로젝트 현황 관련 쿼리"""
# 가용 날짜 목록 조회 # 가용 날짜 목록 조회
GET_AVAILABLE_DATES = "SELECT DISTINCT crawl_date FROM projects_history ORDER BY crawl_date DESC" 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" GET_LAST_CRAWL_DATE = "SELECT MAX(crawl_date) as last_date FROM projects_history"
# 특정 날짜 프로젝트 데이터 JOIN 조회 # 특정 날짜(또는 그 이하 최신) 프로젝트 데이터 JOIN 조회
GET_PROJECT_LIST = """ GET_PROJECT_LIST = """
SELECT m.project_nm, m.short_nm, m.department, m.master, SELECT m.project_nm, m.short_nm, m.department, m.master,
h.recent_log, h.file_count, m.continent, m.country h.recent_log, h.file_count, m.continent, m.country
FROM projects_master m FROM projects_master m
LEFT JOIN projects_history h ON m.project_id = h.project_id AND h.crawl_date = %s LEFT JOIN projects_history h ON h.project_id = m.project_id AND h.crawl_date = (
ORDER BY m.project_id ASC SELECT MAX(crawl_date)
""" FROM projects_history
WHERE project_id = m.project_id AND crawl_date <= %s
# 활성도 분석을 위한 프로젝트 목록 조회 )
GET_PROJECT_LIST_FOR_ANALYSIS = """ ORDER BY m.project_id ASC
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 # 활성도 분석을 위한 프로젝트 목록 조회 (특정 날짜 이하 최신 데이터 기준)
""" GET_PROJECT_LIST_FOR_ANALYSIS = """
SELECT m.project_id, m.project_nm, m.short_nm, m.department, h.recent_log, h.file_count
class CrawlerQueries: FROM projects_master m
"""크롤러(Crawler) 데이터 동기화 관련 쿼리""" LEFT JOIN projects_history h ON h.project_id = m.project_id AND h.crawl_date = (
# 마스터 정보 UPSERT (INSERT OR UPDATE) SELECT MAX(crawl_date)
UPSERT_MASTER = """ FROM projects_history
INSERT INTO projects_master (project_id, project_nm, short_nm, master, continent, country) WHERE project_id = m.project_id AND crawl_date <= %s
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) class CrawlerQueries:
""" """크롤러(Crawler) 데이터 동기화 관련 쿼리"""
# 마스터 정보 UPSERT (INSERT OR UPDATE)
# 부서 정보 업데이트 UPSERT_MASTER = """
UPDATE_DEPARTMENT = "UPDATE projects_master SET department = %s WHERE project_id = %s" INSERT INTO projects_master (project_id, project_nm, short_nm, master, continent, country)
VALUES (%s, %s, %s, %s, %s, %s)
# 히스토리(로그/파일수) 저장 ON DUPLICATE KEY UPDATE
UPSERT_HISTORY = """ project_nm = VALUES(project_nm), short_nm = VALUES(short_nm),
INSERT INTO projects_history (project_id, crawl_date, recent_log, file_count) master = VALUES(master), continent = VALUES(continent), country = VALUES(country)
VALUES (%s, CURRENT_DATE(), %s, %s) """
ON DUPLICATE KEY UPDATE recent_log=VALUES(recent_log), file_count=VALUES(file_count)
""" # 부서 정보 업데이트
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 Project Master Analysis - Specific Styles
(Inherits base styles from common.css) (Inherits base styles from common.css)
========================================================================== */ ========================================================================== */
.analysis-content { .analysis-content {
padding: 24px; padding: 24px;
max-width: 1400px; max-width: 1400px;
margin: var(--topbar-h, 36px) auto 0; margin: var(--topbar-h, 36px) auto 0;
} }
/* AI Badge & Header */ /* AI Badge & Header */
.ai-badge { .ai-badge {
background: #6366f1; background: #6366f1;
color: white; color: white;
padding: 2px 10px; padding: 2px 10px;
border-radius: 20px; border-radius: 20px;
font-size: 11px; font-size: 11px;
font-weight: 800; font-weight: 800;
display: inline-block; display: inline-block;
margin-bottom: 8px; margin-bottom: 8px;
} }
.analysis-header { .analysis-header {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
margin-bottom: 24px; margin-bottom: 24px;
padding: 10px 0; padding: 10px 0;
border-bottom: 1px solid var(--border-color); 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 h2 { font-size: 22px; font-weight: 800; color: var(--text-main); margin-bottom: 4px; }
.analysis-header p { font-size: 13px; color: var(--text-sub); } .analysis-header p { font-size: 13px; color: var(--text-sub); }
/* Top Info Grid */ /* Top Info Grid */
.top-info-grid { .top-info-grid {
display: grid; display: grid;
grid-template-columns: 1fr 2.2fr; grid-template-columns: 1fr 2.2fr;
gap: 16px; gap: 16px;
margin-bottom: 24px; margin-bottom: 24px;
} }
.dl-model-info, .soi-deep-dive { .dl-model-info, .soi-deep-dive {
background: white; background: white;
border-radius: var(--radius-xl); border-radius: var(--radius-xl);
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
padding: 20px; padding: 20px;
box-shadow: var(--box-shadow); box-shadow: var(--box-shadow);
} }
.card-header { margin-bottom: 15px; display: flex; align-items: center; justify-content: space-between; } .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; } .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-desc-vertical { display: flex; flex-direction: column; gap: 12px; }
.model-item-vertical { display: flex; align-items: center; 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; } .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-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 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; } .soi-info-column p { font-size: 11.5px; color: var(--text-sub); line-height: 1.6; margin: 0; }
/* Chart Grid Layout */ /* Chart Grid Layout */
.analysis-charts-grid { .analysis-charts-grid {
display: grid; display: grid;
grid-template-columns: 1fr 1.8fr; grid-template-columns: 1fr 1.8fr;
gap: 20px; gap: 20px;
margin-bottom: 24px; margin-bottom: 24px;
} }
.chart-container-box { .chart-container-box {
background: white; background: white;
border-radius: var(--radius-xl); border-radius: var(--radius-xl);
padding: 20px; padding: 20px;
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
height: 360px; height: 360px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
box-shadow: var(--box-shadow); box-shadow: var(--box-shadow);
} }
.chart-container-box h5 { margin: 0 0 15px 0; font-size: 13px; font-weight: 700; color: var(--text-main); } .chart-container-box h5 { margin: 0 0 15px 0; font-size: 13px; font-weight: 700; color: var(--text-main); }
/* Timeline Analysis Card */ /* Timeline Analysis Card */
.analysis-card { .analysis-card {
background: white; background: white;
border-radius: var(--radius-xl); border-radius: var(--radius-xl);
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
box-shadow: var(--box-shadow); box-shadow: var(--box-shadow);
margin-bottom: 24px; margin-bottom: 24px;
overflow: hidden; overflow: hidden;
} }
.analysis-card .card-header { .analysis-card .card-header {
padding: 16px 24px; padding: 16px 24px;
background: #fff; background: #fff;
border-bottom: 1px solid var(--border-color); border-bottom: 1px solid var(--border-color);
} }
.analysis-card .card-body { padding: 24px; } .analysis-card .card-body { padding: 24px; }
/* SOI Guide Styles */ /* SOI Guide Styles */
.d-war-guide { .d-war-guide {
display: flex; display: flex;
gap: 10px; gap: 10px;
margin-bottom: 20px; margin-bottom: 20px;
padding: 12px; padding: 12px;
background: var(--bg-muted); background: var(--bg-muted);
border-radius: var(--radius-lg); border-radius: var(--radius-lg);
} }
.guide-item { .guide-item {
font-size: 11px; font-size: 11px;
font-weight: 700; font-weight: 700;
padding: 4px 10px; padding: 4px 10px;
border-radius: 4px; border-radius: 4px;
display: flex; display: flex;
align-items: center; align-items: center;
gap: 6px; gap: 6px;
} }
.guide-item.active-low { background: #dcfce7; color: #166534; } .guide-item.active-low { background: #dcfce7; color: #166534; }
.guide-item.warning-mid { background: #fef9c3; color: #854d0e; } .guide-item.warning-mid { background: #fef9c3; color: #854d0e; }
.guide-item.danger-high { background: #ffedd5; color: #9a3412; } .guide-item.danger-high { background: #ffedd5; color: #9a3412; }
.guide-item.hazard-critical { background: #fee2e2; color: #991b1b; } .guide-item.hazard-critical { background: #fee2e2; color: #991b1b; }
/* Data Table Customization */ /* Data Table Customization */
.table-scroll-wrapper { .table-scroll-wrapper {
overflow-x: auto; overflow-x: auto;
overflow-y: auto; overflow-y: auto;
max-height: 600px; max-height: 600px;
border-radius: var(--radius-lg); border-radius: var(--radius-lg);
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
background: white; background: white;
} }
.p-war-table { .p-war-table {
width: 100%; width: 100%;
border-collapse: separate; border-collapse: separate;
border-spacing: 0; border-spacing: 0;
table-layout: fixed; /* 컬럼 너비 고정 */ table-layout: fixed; /* 컬럼 너비 고정 */
} }
.p-war-table th { .p-war-table th {
position: sticky; position: sticky;
top: 0; top: 0;
z-index: 20; z-index: 20;
background: #f8fafc; background: #f8fafc;
padding: 16px 15px; padding: 16px 15px;
font-size: 12px; font-size: 12px;
font-weight: 800; font-weight: 800;
color: #475569; color: #475569;
border-bottom: 2px solid #e2e8f0; border-bottom: 2px solid #e2e8f0;
white-space: nowrap; white-space: nowrap;
} }
.p-war-table td { .p-war-table td {
padding: 14px 15px; padding: 14px 15px;
font-size: 13px; font-size: 13px;
border-bottom: 1px solid #f1f5f9; border-bottom: 1px solid #f1f5f9;
vertical-align: middle; 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(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(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(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(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(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(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(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 예보 */ .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 { cursor: pointer; transition: background 0.2s; }
.project-row:hover { background: var(--hover-bg) !important; } .project-row:hover { background: var(--hover-bg) !important; }
/* SOI Value Styling */ /* SOI Value Styling */
.p-war-value { font-weight: 800; font-family: 'Consolas', monospace; } .p-war-value { font-weight: 800; font-family: 'Consolas', monospace; }
/* Accordion Detail Styles */ /* Accordion Detail Styles */
.detail-row { display: none; background: #fafafa; } .detail-row { display: none; background: #fafafa; }
.detail-row.active { display: table-row; } .detail-row.active { display: table-row; }
.detail-container { padding: 20px 24px; } .detail-container { padding: 20px 24px; }
.formula-explanation-card { .formula-explanation-card {
background: white; background: white;
border-radius: var(--radius-lg); border-radius: var(--radius-lg);
padding: 24px; padding: 24px;
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
box-shadow: var(--box-shadow); box-shadow: var(--box-shadow);
} }
.formula-header { font-size: 13px; font-weight: 700; color: #6366f1; margin-bottom: 15px; } .formula-header { font-size: 13px; font-weight: 700; color: #6366f1; margin-bottom: 15px; }
/* Work Effort Section */ /* Work Effort Section */
.work-effort-section { background: var(--bg-muted); padding: 16px; border-radius: var(--radius-lg); margin-bottom: 20px; } .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-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; } .work-effort-bar-bg { width: 100%; height: 6px; background: #e2e8f0; border-radius: 3px; overflow: hidden; margin-bottom: 10px; }
/* Formula Steps Grid */ /* Formula Steps Grid */
.formula-steps-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; } .formula-steps-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; }
.formula-step { display: flex; gap: 12px; } .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-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; } .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; } .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; } .final-result-area { margin-top: 20px; padding-top: 15px; display: flex; justify-content: space-between; align-items: center; }
/* Modal Analysis Specific */ /* Modal Analysis Specific */
.modal-footer { .modal-footer {
padding: 16px 24px; padding: 16px 24px;
background: #fff; background: #fff;
border-top: 1px solid var(--border-color); border-top: 1px solid var(--border-color);
text-align: right; text-align: right;
display: flex; display: flex;
justify-content: flex-end; justify-content: flex-end;
} }
/* Formula & Badges */ /* Formula & Badges */
.formula-section { margin-bottom: 20px; } .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; } .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-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-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-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; } .badge-system { background: #fee2e2; color: #991b1b; padding: 2px 8px; border-radius: 4px; font-size: 11px; font-weight: 700; }
.highlight-var { color: #2563eb; } .highlight-var { color: #2563eb; }
.highlight-val { color: #059669; } .highlight-val { color: #059669; }
.highlight-penalty { color: #dc2626; } .highlight-penalty { color: #dc2626; }
.text-plus { color: #059669; font-weight: 700; } .text-plus { color: #059669; font-weight: 700; }
.text-minus { color: #dc2626; font-weight: 700; } .text-minus { color: #dc2626; font-weight: 700; }
.font-bold { font-weight: 700; } .font-bold { font-weight: 700; }

View File

@@ -1,170 +1,170 @@
:root { :root {
/* 1. Core Colors */ /* 1. Core Colors */
--primary-color: #1E5149; --primary-color: #1E5149;
--primary-hover: #163b36; --primary-hover: #163b36;
--primary-lv-0: #f0f7f4; --primary-lv-0: #f0f7f4;
--primary-lv-1: #e1eee9; --primary-lv-1: #e1eee9;
--primary-lv-8: #193833; --primary-lv-8: #193833;
--bg-default: #FFFFFF; --bg-default: #FFFFFF;
--bg-muted: #F9FAFB; --bg-muted: #F9FAFB;
--hover-bg: #F7FAFC; --hover-bg: #F7FAFC;
--text-main: #111827; --text-main: #111827;
--text-sub: #6B7280; --text-sub: #6B7280;
--error-color: #F21D0D; --error-color: #F21D0D;
--border-color: #E2E8F0; --border-color: #E2E8F0;
/* 2. Gradients */ /* 2. Gradients */
--header-gradient: linear-gradient(90deg, #193833 0%, #1e5149 100%); --header-gradient: linear-gradient(90deg, #193833 0%, #1e5149 100%);
--ai-gradient: linear-gradient(180deg, #da8cf1 0%, #8bb1f2 100%); --ai-gradient: linear-gradient(180deg, #da8cf1 0%, #8bb1f2 100%);
/* 3. Spacing & Radius */ /* 3. Spacing & Radius */
--space-xs: 4px; --space-xs: 4px;
--space-sm: 8px; --space-sm: 8px;
--space-md: 16px; --space-md: 16px;
--space-lg: 32px; --space-lg: 32px;
--radius-sm: 4px; --radius-sm: 4px;
--radius-md: 6px; --radius-md: 6px;
--radius-lg: 8px; --radius-lg: 8px;
--radius-xl: 12px; --radius-xl: 12px;
/* 4. Typography */ /* 4. Typography */
--fz-h1: 20px; --fz-h1: 20px;
--fz-h2: 16px; --fz-h2: 16px;
--fz-body: 13px; --fz-body: 13px;
--fz-small: 11px; --fz-small: 11px;
/* 5. Shadows */ /* 5. Shadows */
--box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.05); --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-lg: 0 10px 20px rgba(0, 0, 0, 0.1);
--box-shadow-modal: 0 25px 50px -12px rgba(0,0,0,0.5); --box-shadow-modal: 0 25px 50px -12px rgba(0,0,0,0.5);
/* 6. Layout Constants */ /* 6. Layout Constants */
--topbar-h: 36px; --topbar-h: 36px;
} }
/* Base Reset */ /* Base Reset */
* { margin: 0; padding: 0; box-sizing: border-box; } * { margin: 0; padding: 0; box-sizing: border-box; }
body { body {
font-family: 'Pretendard', -apple-system, sans-serif; font-family: 'Pretendard', -apple-system, sans-serif;
font-size: var(--fz-body); font-size: var(--fz-body);
color: var(--text-main); color: var(--text-main);
background: var(--bg-default); background: var(--bg-default);
min-height: 100vh; min-height: 100vh;
} }
/* Page Specific Overrides */ /* Page Specific Overrides */
body:has(.mail-wrapper) { height: 100vh; overflow: hidden; } body:has(.mail-wrapper) { height: 100vh; overflow: hidden; }
input, select, textarea, button { font-family: inherit; } input, select, textarea, button { font-family: inherit; }
a { text-decoration: none; color: inherit; } a { text-decoration: none; color: inherit; }
button { cursor: pointer; border: none; transition: all 0.2s ease; } button { cursor: pointer; border: none; transition: all 0.2s ease; }
/* Utilities: Layout & Text */ /* Utilities: Layout & Text */
.flex-center { display: flex; align-items: center; justify-content: center; } .flex-center { display: flex; align-items: center; justify-content: center; }
.flex-between { display: flex; align-items: center; justify-content: space-between; } .flex-between { display: flex; align-items: center; justify-content: space-between; }
.flex-column { display: flex; flex-direction: column; } .flex-column { display: flex; flex-direction: column; }
.text-truncate { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .text-truncate { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.w-full { width: 100%; } .w-full { width: 100%; }
.pointer { cursor: pointer; } .pointer { cursor: pointer; }
/* Components: Topbar */ /* Components: Topbar */
.topbar { .topbar {
width: 100%; width: 100%;
background: var(--header-gradient); background: var(--header-gradient);
color: #fff; color: #fff;
padding: 0 var(--space-lg); padding: 0 var(--space-lg);
position: fixed; position: fixed;
top: 0; top: 0;
height: var(--topbar-h); height: var(--topbar-h);
display: flex; display: flex;
align-items: center; align-items: center;
z-index: 2000; z-index: 2000;
} }
.topbar-header h2 { font-size: 16px; color: white; margin-right: 60px; font-weight: 700; } .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-list { display: flex; list-style: none; gap: var(--space-sm); }
.nav-item { .nav-item {
padding: 4px 12px; border-radius: var(--radius-sm); padding: 4px 12px; border-radius: var(--radius-sm);
color: rgba(255, 255, 255, 0.8); font-size: 14px; color: rgba(255, 255, 255, 0.8); font-size: 14px;
cursor: pointer; cursor: pointer;
} }
.nav-item:hover { background: var(--primary-lv-8); color: #fff; } .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; } .nav-item.active { background: var(--primary-lv-0); color: var(--primary-color) !important; font-weight: 700; }
/* Components: Modals */ /* Components: Modals */
.modal-overlay { .modal-overlay {
display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%; display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%;
background: rgba(0, 0, 0, 0.6); backdrop-filter: blur(4px); background: rgba(0, 0, 0, 0.6); backdrop-filter: blur(4px);
z-index: 3000; justify-content: center; align-items: center; z-index: 3000; justify-content: center; align-items: center;
} }
.modal-content { .modal-content {
background: white; padding: 24px; border-radius: var(--radius-xl); background: white; padding: 24px; border-radius: var(--radius-xl);
width: 90%; max-width: 500px; box-shadow: var(--box-shadow-modal); width: 90%; max-width: 500px; box-shadow: var(--box-shadow-modal);
} }
.modal-header { .modal-header {
display: flex; justify-content: space-between; align-items: center; display: flex; justify-content: space-between; align-items: center;
margin-bottom: 20px; border-bottom: 1px solid var(--border-color); padding-bottom: 12px; 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-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 { cursor: pointer; font-size: 24px; color: var(--text-sub); line-height: 1; transition: 0.2s; }
.modal-close:hover { color: var(--text-main); } .modal-close:hover { color: var(--text-main); }
/* Components: Data Tables */ /* Components: Data Tables */
.data-table { width: 100%; border-collapse: collapse; font-size: 12px; background: #fff; } .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, .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 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); } .data-table tr:hover { background: var(--hover-bg); }
/* Components: Standard Buttons */ /* Components: Standard Buttons */
.btn { .btn {
display: inline-flex; align-items: center; justify-content: center; gap: 8px; 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; padding: 8px 16px; border-radius: var(--radius-lg); font-weight: 600; font-size: 13px;
border: none; cursor: pointer; transition: all 0.2s ease; border: none; cursor: pointer; transition: all 0.2s ease;
} }
.btn-primary { background: var(--primary-color); color: #fff; } .btn-primary { background: var(--primary-color); color: #fff; }
.btn-primary:hover { background: var(--primary-hover); transform: translateY(-1px); } .btn-primary:hover { background: var(--primary-hover); transform: translateY(-1px); }
.btn-secondary { background: #f1f3f5; color: #495057; } .btn-secondary { background: #f1f3f5; color: #495057; }
.btn-secondary:hover { background: #e9ecef; } .btn-secondary:hover { background: #e9ecef; }
.btn-danger { background: #fee2e2; color: #dc2626; } .btn-danger { background: #fee2e2; color: #dc2626; }
.btn-danger:hover { background: #fecaca; } .btn-danger:hover { background: #fecaca; }
/* Compatibility Utils */ /* Compatibility Utils */
._button-xsmall { ._button-xsmall {
display: inline-flex; align-items: center; justify-content: center; 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); 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; 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-xsmall:hover { background: var(--bg-muted); border-color: var(--primary-color); color: var(--primary-color); }
._button-small { ._button-small {
display: inline-flex; align-items: center; justify-content: center; 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; padding: 6px 14px; font-size: 12px; background: var(--primary-color); color: #fff; border-radius: 6px; border: none; cursor: pointer;
} }
._button-medium { ._button-medium {
display: inline-flex; align-items: center; justify-content: center; 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; 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; } .sync-btn { background: var(--primary-color); color: #fff; padding: 8px 16px; border-radius: 8px; font-size: 13px; font-weight: 600; }
/* Badges & Status Colors */ /* Badges & Status Colors */
.badge { .badge {
padding: 2px 8px; border-radius: 4px; font-size: 10px; font-weight: 700; padding: 2px 8px; border-radius: 4px; font-size: 10px; font-weight: 700;
display: inline-block; background: var(--primary-lv-1); color: var(--primary-color); display: inline-block; background: var(--primary-lv-1); color: var(--primary-color);
} }
.status-complete { background: #e8f5e9; color: #2e7d32; } .status-complete { background: #e8f5e9; color: #2e7d32; }
.status-working { background: #fff8e1; color: #FFBF00; } .status-working { background: #fff8e1; color: #FFBF00; }
.status-checking { background: #e3f2fd; color: #1565c0; } .status-checking { background: #e3f2fd; color: #1565c0; }
.status-pending { background: #f5f5f5; color: #757575; } .status-pending { background: #f5f5f5; color: #757575; }
.status-error { background: #fee9e7; } .status-error { background: #fee9e7; }
.warning-text { color: #FFBF00; font-weight: 600; } .warning-text { color: #FFBF00; font-weight: 600; }
.error-text { color: #F21D0D !important; font-weight: 700; } .error-text { color: #F21D0D !important; font-weight: 700; }
/* Spinner */ /* Spinner */
.spinner { .spinner {
display: none; width: 16px; height: 16px; border: 2px solid rgba(255, 255, 255, .3); 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; border-radius: 50%; border-top-color: #fff; animation: spin 1s ease-in-out infinite;
} }
@keyframes spin { to { transform: rotate(360deg); } } @keyframes spin { to { transform: rotate(360deg); } }

View File

@@ -1,123 +1,123 @@
/* Dashboard Constants */ /* Dashboard Constants */
:root { :root {
--header-h: 56px; --header-h: 56px;
--activity-h: 110px; --activity-h: 110px;
--fixed-total-h: calc(var(--topbar-h) + var(--header-h) + var(--activity-h)); --fixed-total-h: calc(var(--topbar-h) + var(--header-h) + var(--activity-h));
} }
/* 1. Portal (Index) */ /* 1. Portal (Index) */
.portal-container { .portal-container {
display: flex; flex-direction: column; align-items: center; justify-content: center; 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); 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 { text-align: center; margin-bottom: 50px; }
.portal-header h1 { font-size: 28px; color: var(--primary-color); margin-bottom: 10px; font-weight: 800; } .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; } .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; } .button-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 30px; width: 100%; max-width: 800px; }
.portal-card { .portal-card {
background: #fff; border: 1px solid var(--border-color); border-radius: 12px; padding: 40px; 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); 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; 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: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 i { font-size: 48px; color: var(--primary-color); }
.portal-card h3 { font-size: 20px; color: var(--text-main); margin: 0; } .portal-card h3 { font-size: 20px; color: var(--text-main); margin: 0; }
.portal-card p { font-size: 14px; color: var(--text-sub); margin: 0; } .portal-card p { font-size: 14px; color: var(--text-sub); margin: 0; }
/* 2. Dashboard Header & Activity */ /* 2. Dashboard Header & Activity */
header { header {
position: fixed; top: var(--topbar-h); left: 0; right: 0; z-index: 1001; 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; 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; padding: 0 var(--space-lg); border-bottom: 1px solid #f5f5f5;
} }
.activity-dashboard-wrapper { .activity-dashboard-wrapper {
position: fixed; top: calc(var(--topbar-h) + var(--header-h)); left: 0; right: 0; z-index: 1000; 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); 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-dashboard { max-width: 1200px; margin: 0 auto; height: 100%; display: flex; gap: 15px; padding: 10px 32px 20px; }
.activity-card { .activity-card {
flex: 1; padding: 12px 15px; border-radius: var(--radius-lg); cursor: pointer; 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; 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:hover { transform: translateY(-2px); box-shadow: var(--box-shadow); }
.activity-card.active { background: #e8f5e9; } .activity-card.active { background: #e8f5e9; }
.activity-card.warning { background: #fff8e1; } .activity-card.warning { background: #fff8e1; }
.activity-card.stale { background: #ffebee; } .activity-card.stale { background: #ffebee; }
.activity-card.unknown { background: #f5f5f5; } .activity-card.unknown { background: #f5f5f5; }
.activity-card .label { font-size: 11px; font-weight: 600; opacity: 0.7; } .activity-card .label { font-size: 11px; font-weight: 600; opacity: 0.7; }
.activity-card .count { font-size: 20px; font-weight: 800; } .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; } .main-content { margin-top: var(--fixed-total-h); padding: var(--space-lg); max-width: 1400px; margin-left: auto; margin-right: auto; }
/* 3. Log Console */ /* 3. Log Console */
.log-console { .log-console {
position: sticky; top: var(--fixed-total-h); z-index: 999; position: sticky; top: var(--fixed-total-h); z-index: 999;
background: #000; color: #0f0; font-family: 'Consolas', monospace; padding: 15px; margin-bottom: 20px; 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); 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; } .log-console-header { color: #fff; border-bottom: 1px solid #333; margin-bottom: 10px; padding-bottom: 5px; font-weight: bold; }
/* 4. Auth Modal (Page Specific) */ /* 4. Auth Modal (Page Specific) */
.auth-modal-content { .auth-modal-content {
background: #fff; width: 440px; border-radius: 16px; padding: 40px; text-align: center; 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; 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 { 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 label { font-size: 12px; font-weight: 700; color: var(--text-main); }
.input-group input { .input-group input {
height: 48px; padding: 0 16px; border: 1px solid var(--border-color); border-radius: 8px; height: 48px; padding: 0 16px; border: 1px solid var(--border-color); border-radius: 8px;
font-size: 14px; background: #f9f9f9; width: 100%; font-size: 14px; background: #f9f9f9; width: 100%;
} }
.input-group input:focus { border-color: var(--primary-color); background: #fff; outline: none; } .input-group input:focus { border-color: var(--primary-color); background: #fff; outline: none; }
/* 5. Accordion & Data Tables */ /* 5. Accordion & Data Tables */
.accordion-list-header { .accordion-list-header {
position: sticky; top: var(--fixed-total-h); background: #fff; z-index: 900; position: sticky; top: var(--fixed-total-h); background: #fff; z-index: 900;
font-size: 11px; font-weight: 700; color: var(--text-sub); font-size: 11px; font-weight: 700; color: var(--text-sub);
padding: 12px 24px; border-bottom: 2px solid var(--primary-color); padding: 12px 24px; border-bottom: 2px solid var(--primary-color);
display: grid; grid-template-columns: 2.5fr 1fr 1fr 0.8fr 2fr; gap: 16px; display: grid; grid-template-columns: 2.5fr 1fr 1fr 0.8fr 2fr; gap: 16px;
} }
.accordion-header { .accordion-header {
display: grid; grid-template-columns: 2.5fr 1fr 1fr 0.8fr 2fr; gap: 16px; 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); 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:hover .accordion-header { background: var(--primary-lv-0); }
.accordion-item.active .accordion-header { background: var(--primary-lv-0); border-bottom: none; } .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-title { font-weight: 700; color: var(--primary-color); @extend .text-truncate; }
.repo-files { text-align: center; font-weight: 600; } .repo-files { text-align: center; font-weight: 600; }
.repo-log { font-size: 11px; color: var(--text-sub); @extend .text-truncate; } .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-body { display: none; padding: 24px; background: #fff; border-bottom: 1px solid var(--border-color); }
.accordion-item.active .accordion-body { display: block; } .accordion-item.active .accordion-body { display: block; }
.detail-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 32px; } .detail-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 32px; }
.detail-section h4 { .detail-section h4 {
font-size: 13px; margin-bottom: 12px; color: var(--text-main); font-size: 13px; margin-bottom: 12px; color: var(--text-main);
padding-left: 10px; font-weight: 700; padding-left: 10px; font-weight: 700;
} }
/* Personnel & Activity Tables */ /* Personnel & Activity Tables */
#personnel-table th:nth-child(1) { width: 25%; } #personnel-table th:nth-child(1) { width: 25%; }
#personnel-table th:nth-child(2) { width: 45%; } #personnel-table th:nth-child(2) { width: 45%; }
#activity-table th:nth-child(1) { width: 20%; } #activity-table th:nth-child(1) { width: 20%; }
#activity-table th:nth-child(2) { width: 50%; } #activity-table th:nth-child(2) { width: 50%; }
/* Location Groups */ /* Location Groups */
.continent-group, .country-group { margin-bottom: 15px; } .continent-group, .country-group { margin-bottom: 15px; }
.continent-header, .country-header { .continent-header, .country-header {
background: #fff; padding: 14px 20px; border: 1px solid var(--border-color); border-radius: 8px; 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; 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; } .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; } .country-header { font-size: 14px; color: var(--text-main); margin-top: 8px; }
.continent-body, .country-body { display: none; padding: 10px 0 10px 15px; } .continent-body, .country-body { display: none; padding: 10px 0 10px 15px; }
.active>.continent-body, .active>.country-body { display: block; } .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 { 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; } .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); } .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 */ /* 1. Layout & Board Structure */
.inquiry-board { .inquiry-board {
padding: 0 20px 32px 20px; padding: 0 20px 32px 20px;
max-width: 98%; max-width: 98%;
margin: 36px auto 0; margin: 36px auto 0;
} }
.board-sticky-header { .board-sticky-header {
position: sticky; position: sticky;
top: 36px; top: 36px;
background: #fff; background: #fff;
z-index: 1000; z-index: 1000;
padding: 15px 0 10px; padding: 15px 0 10px;
border-bottom: 1px solid #eee; border-bottom: 1px solid #eee;
} }
.board-header { .board-header {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: flex-end; align-items: flex-end;
margin-bottom: 20px; margin-bottom: 20px;
} }
/* 2. Stats Dashboard */ /* 2. Stats Dashboard */
.header-stats { .header-stats {
display: flex; display: flex;
gap: 12px; gap: 12px;
} }
.stat-item { .stat-item {
background: #fff; background: #fff;
border: 1px solid #eee; border: 1px solid #eee;
padding: 8px 16px; padding: 8px 16px;
border-radius: 8px; border-radius: 8px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
min-width: 80px; min-width: 80px;
box-shadow: 0 2px 4px rgba(0,0,0,0.02); box-shadow: 0 2px 4px rgba(0,0,0,0.02);
transition: transform 0.2s, box-shadow 0.2s; transition: transform 0.2s, box-shadow 0.2s;
} }
.stat-item:hover { .stat-item:hover {
transform: translateY(-2px); transform: translateY(-2px);
box-shadow: 0 4px 8px rgba(0,0,0,0.05); 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-label { font-size: 11px; font-weight: 600; color: #888; margin-bottom: 2px; }
.stat-value { font-size: 18px; font-weight: 700; color: #333; } .stat-value { font-size: 18px; font-weight: 700; color: #333; }
/* Status Border Colors */ /* Status Border Colors */
.stat-item.total { } .stat-item.total { }
.stat-item.total .stat-value { color: #1e5149; } .stat-item.total .stat-value { color: #1e5149; }
.stat-item.complete { } .stat-item.complete { }
.stat-item.complete .stat-value { color: #2e7d32; } .stat-item.complete .stat-value { color: #2e7d32; }
.stat-item.working { } .stat-item.working { }
.stat-item.working .stat-value { color: #1565c0; } .stat-item.working .stat-value { color: #1565c0; }
.stat-item.checking { } .stat-item.checking { }
.stat-item.checking .stat-value { color: #ef6c00; } .stat-item.checking .stat-value { color: #ef6c00; }
.stat-item.pending { } .stat-item.pending { }
.stat-item.pending .stat-value { color: #673ab7; } .stat-item.pending .stat-value { color: #673ab7; }
.stat-item.unconfirmed { } .stat-item.unconfirmed { }
.stat-item.unconfirmed .stat-value { color: #9e9e9e; } .stat-item.unconfirmed .stat-value { color: #9e9e9e; }
/* 3. Filters & Notice */ /* 3. Filters & Notice */
.notice-container { .notice-container {
background: #fdfdfd; background: #fdfdfd;
padding: 20px; padding: 20px;
border-radius: 8px; border-radius: 8px;
border: 1px solid #e0e0e0; border: 1px solid #e0e0e0;
margin-bottom: 24px; margin-bottom: 24px;
box-shadow: 0 2px 5px rgba(0,0,0,0.02); box-shadow: 0 2px 5px rgba(0,0,0,0.02);
} }
.filter-section { .filter-section {
display: flex; display: flex;
gap: 12px; gap: 12px;
background: #f8f9fa; background: #f8f9fa;
padding: 12px 16px; padding: 12px 16px;
border-radius: 8px; border-radius: 8px;
margin-top: 15px; margin-top: 15px;
} }
.filter-group { display: flex; flex-direction: column; gap: 4px; } .filter-group { display: flex; flex-direction: column; gap: 4px; }
.filter-group label { font-size: 12px; font-weight: 600; color: #666; } .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; } .filter-group select, .filter-group input { padding: 8px 12px; border: 1px solid #ddd; border-radius: 4px; font-size: 14px; }
/* 4. Table Styles */ /* 4. Table Styles */
.inquiry-table { .inquiry-table {
width: 100%; width: 100%;
background: #fff; background: #fff;
border-radius: 8px; border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08); box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
border-collapse: separate; border-collapse: separate;
border-spacing: 0; border-spacing: 0;
margin-top: 10px; margin-top: 10px;
} }
.inquiry-table thead th { .inquiry-table thead th {
position: sticky; position: sticky;
background: #f8f9fa; background: #f8f9fa;
padding: 14px 16px; padding: 14px 16px;
text-align: left; text-align: left;
font-size: 13px; font-size: 13px;
font-weight: 700; font-weight: 700;
color: #333; color: #333;
border-bottom: 2px solid #eee; border-bottom: 2px solid #eee;
z-index: 900; z-index: 900;
} }
/* 정렬 가능한 헤더 스타일 추가 */ /* 정렬 가능한 헤더 스타일 추가 */
.inquiry-table thead th.sortable { .inquiry-table thead th.sortable {
cursor: pointer; cursor: pointer;
user-select: none; user-select: none;
transition: background 0.2s; transition: background 0.2s;
white-space: nowrap; white-space: nowrap;
} }
.inquiry-table thead th.sortable .header-content { .inquiry-table thead th.sortable .header-content {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 4px; gap: 4px;
} }
.sort-icon { .sort-icon {
display: inline-flex; display: inline-flex;
flex-direction: column; flex-direction: column;
justify-content: center; justify-content: center;
width: 12px; width: 12px;
height: 12px; height: 12px;
font-size: 8px; font-size: 8px;
color: #ccc; color: #ccc;
line-height: 1; line-height: 1;
margin-left: 2px; margin-left: 2px;
} }
.inquiry-table thead th.active-sort { .inquiry-table thead th.active-sort {
color: #1e5149; color: #1e5149;
background: #f0f7f6; background: #f0f7f6;
} }
.inquiry-table thead th.active-sort .sort-icon { .inquiry-table thead th.active-sort .sort-icon {
color: #1e5149; color: #1e5149;
font-size: 10px; font-size: 10px;
} }
.inquiry-table td { .inquiry-table td {
padding: 14px 16px; padding: 14px 16px;
font-size: 13px; font-size: 13px;
border-bottom: 1px solid #eee; border-bottom: 1px solid #eee;
vertical-align: middle; vertical-align: middle;
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
} }
/* Table Row Hover & Active State */ /* Table Row Hover & Active State */
.inquiry-row:hover { background: #fcfcfc; cursor: pointer; } .inquiry-row:hover { background: #fcfcfc; cursor: pointer; }
.inquiry-row.active-row { background-color: #f0f7f6 !important; } .inquiry-row.active-row { background-color: #f0f7f6 !important; }
.inquiry-row.active-row td { font-weight: 600; color: #1e5149; border-bottom-color: transparent; } .inquiry-row.active-row td { font-weight: 600; color: #1e5149; border-bottom-color: transparent; }
/* Status Badges */ /* Status Badges */
.status-badge { padding: 4px 10px; border-radius: 20px; font-size: 11px; font-weight: 700; display: inline-block; } .status-badge { padding: 4px 10px; border-radius: 20px; font-size: 11px; font-weight: 700; display: inline-block; }
.status-complete { background: #e8f5e9; color: #2e7d32; } .status-complete { background: #e8f5e9; color: #2e7d32; }
.status-working { background: #e3f2fd; color: #1565c0; } .status-working { background: #e3f2fd; color: #1565c0; }
.status-checking { background: #fff3e0; color: #ef6c00; } .status-checking { background: #fff3e0; color: #ef6c00; }
.status-pending { background: #f5f5f5; color: #616161; } .status-pending { background: #f5f5f5; color: #616161; }
/* Table Columns Width & Truncation */ /* Table Columns Width & Truncation */
.inquiry-table td:nth-child(1) { max-width: 50px; } /* No */ .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(2) { max-width: 80px; text-align: center; } /* Image */
.inquiry-table td:nth-child(3) { max-width: 120px; } /* PM Type */ .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(4) { max-width: 100px; } /* Env */
.inquiry-table td:nth-child(5) { max-width: 150px; } /* Category */ .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(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(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(9) { max-width: 100px; } /* Author */
.inquiry-table td:nth-child(10) { max-width: 120px; } /* Date */ .inquiry-table td:nth-child(10) { max-width: 120px; } /* Date */
.inquiry-table td:nth-child(11) { max-width: 100px; } /* Status */ .inquiry-table td:nth-child(11) { max-width: 100px; } /* Status */
/* 5. Detail (Accordion) Styles */ /* 5. Detail (Accordion) Styles */
.detail-row { display: none; background: #fdfdfd; } .detail-row { display: none; background: #fdfdfd; }
.detail-row.active { display: table-row; } .detail-row.active { display: table-row; }
.detail-row td { max-width: none; white-space: normal; overflow: visible; } .detail-row td { max-width: none; white-space: normal; overflow: visible; }
.detail-container { .detail-container {
padding: 24px; padding: 24px;
background: #f9fafb; background: #f9fafb;
box-shadow: inset 0 4px 15px rgba(0,0,0,0.08); box-shadow: inset 0 4px 15px rgba(0,0,0,0.08);
position: relative; position: relative;
border-bottom: 2px solid #eee; border-bottom: 2px solid #eee;
} }
.detail-content-wrapper { .detail-content-wrapper {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 20px; gap: 20px;
border: 1px solid #e5e7eb; border: 1px solid #e5e7eb;
background: #fff; background: #fff;
padding: 25px; padding: 25px;
border-radius: 12px; border-radius: 12px;
box-shadow: 0 4px 10px rgba(0,0,0,0.03); box-shadow: 0 4px 10px rgba(0,0,0,0.03);
} }
.btn-close-accordion { .btn-close-accordion {
position: absolute; position: absolute;
top: 35px; top: 35px;
right: 45px; right: 45px;
background: #eee; background: #eee;
border: none; border: none;
padding: 6px 14px; padding: 6px 14px;
border-radius: 20px; border-radius: 20px;
font-size: 12px; font-size: 12px;
font-weight: 600; font-weight: 600;
color: #666; color: #666;
cursor: pointer; cursor: pointer;
z-index: 10; z-index: 10;
} }
.btn-close-accordion::after { content: "▲"; font-size: 10px; margin-left: 5px; } .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-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-label { font-weight: 700; color: #888; margin-right: 8px; }
.detail-q-section { background: #f8f9fa; padding: 20px; border-radius: 8px; } .detail-q-section { background: #f8f9fa; padding: 20px; border-radius: 8px; }
.detail-a-section { background: #f1f8f7; padding: 20px; border-radius: 8px; } .detail-a-section { background: #f1f8f7; padding: 20px; border-radius: 8px; }
/* 6. Image Preview & Foldable Section */ /* 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 { 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); } .img-thumbnail:hover { transform: scale(1.1); }
.no-img { font-size: 10px; color: #ccc; font-style: italic; } .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; } .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 { 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:hover { background: #e2e8f0; }
.image-section-header h4 { margin: 0; color: #1e5149; display: flex; align-items: center; gap: 8px; } .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 { padding: 20px; display: flex; justify-content: center; background: #fff; border-top: 1px solid #eee; }
.image-section-content.collapsed { display: none; } .image-section-content.collapsed { display: none; }
.toggle-icon { font-size: 12px; color: #64748b; transition: transform 0.3s; } .toggle-icon { font-size: 12px; color: #64748b; transition: transform 0.3s; }
.detail-image-section.active .toggle-icon { transform: rotate(180deg); } .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; } .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 */ /* 7. Forms & Reply */
.reply-edit-form textarea { .reply-edit-form textarea {
width: 100%; height: 120px; padding: 12px; border: 1px solid #ddd; border-radius: 6px; 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; 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 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.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 .btn-edit { display: none; }
.reply-edit-form.editable textarea { border-color: #1e5149; box-shadow: 0 0 0 2px rgba(30, 81, 73, 0.1); } .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 Manager Layout (Vertical Split) */
.mail-wrapper { .mail-wrapper {
display: flex; height: calc(100vh - var(--topbar-h)); display: flex; height: calc(100vh - var(--topbar-h));
margin-top: var(--topbar-h); background: #fff; overflow: hidden; margin-top: var(--topbar-h); background: #fff; overflow: hidden;
} }
.mail-list-area { .mail-list-area {
width: 400px; border-right: 1px solid var(--border-color); width: 400px; border-right: 1px solid var(--border-color);
display: flex; flex-direction: column; height: 100%; background: #fff; position: relative; display: flex; flex-direction: column; height: 100%; background: #fff; position: relative;
} }
/* 1. Tabs & Search */ /* 1. Tabs & Search */
.mail-tabs { display: flex; border-bottom: 1px solid var(--border-color); background: #f8f9fa; flex-shrink: 0; } .mail-tabs { display: flex; border-bottom: 1px solid var(--border-color); background: #f8f9fa; flex-shrink: 0; }
.mail-tab { .mail-tab {
flex: 1; padding: 12px 0; text-align: center; cursor: pointer; flex: 1; padding: 12px 0; text-align: center; cursor: pointer;
font-weight: 700; color: #a0aec0; font-size: 11px; transition: all 0.2s ease; 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; 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:hover { background: #edf2f7; color: var(--primary-color); }
.mail-tab.active { color: var(--primary-color); border-bottom: 2px solid var(--primary-color); background: #fff; } .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; } .search-bar { padding: 16px 24px; border-bottom: 1px solid var(--border-color); background: #fff; flex-shrink: 0; }
.mail-bulk-actions { .mail-bulk-actions {
display: none; padding: 8px 16px; background: #f7fafc; display: none; padding: 8px 16px; background: #f7fafc;
border-bottom: 1px solid var(--border-color); align-items: center; justify-content: space-between; font-size: 12px; border-bottom: 1px solid var(--border-color); align-items: center; justify-content: space-between; font-size: 12px;
} }
.mail-bulk-actions.active { display: flex; } .mail-bulk-actions.active { display: flex; }
/* 2. Mail Items */ /* 2. Mail Items */
.mail-items-container { flex: 1; overflow-y: auto; padding-bottom: 60px; } .mail-items-container { flex: 1; overflow-y: auto; padding-bottom: 60px; }
.mail-item { .mail-item {
padding: 16px; border-bottom: 1px solid var(--border-color); cursor: pointer; padding: 16px; border-bottom: 1px solid var(--border-color); cursor: pointer;
display: flex; align-items: flex-start; transition: 0.2s; display: flex; align-items: flex-start; transition: 0.2s;
} }
.mail-item:hover { background: var(--bg-muted); } .mail-item:hover { background: var(--bg-muted); }
.mail-item.active { background: var(--primary-lv-0); } .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-checkbox { width: 16px; height: 16px; cursor: pointer; margin-right: 12px; margin-top: 2px; }
.mail-item-content { flex: 1; min-width: 0; } .mail-item-content { flex: 1; min-width: 0; }
.mail-item-info { display: flex; align-items: center; gap: 12px; margin-bottom: 4px; } .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; } .mail-date { font-size: 11px; color: var(--text-sub); white-space: nowrap; }
.btn-mail-delete { .btn-mail-delete {
background: #f7fafc; border: 1px solid var(--border-color); color: #718096; background: #f7fafc; border: 1px solid var(--border-color); color: #718096;
font-size: 10px; padding: 2px 8px; border-radius: 4px; font-weight: 600; 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; } .btn-mail-delete:hover { color: var(--error-color); background: #fff5f5; border-color: #feb2b2; }
/* 3. Content Area */ /* 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-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-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; } .mail-body { padding: var(--space-lg); line-height: 1.6; min-height: 200px; }
/* 4. Attachments & AI */ /* 4. Attachments & AI */
.attachment-area { padding: var(--space-lg); border-top: 1px solid var(--border-color); background: var(--bg-muted); } .attachment-area { padding: var(--space-lg); border-top: 1px solid var(--border-color); background: var(--bg-muted); }
.attachment-item { .attachment-item {
display: flex; align-items: center; gap: var(--space-md); background: #fff; display: flex; align-items: center; gap: var(--space-md); background: #fff;
padding: 12px 20px; border-radius: var(--radius-lg); padding: 12px 20px; border-radius: var(--radius-lg);
border: 1px solid var(--border-color); margin-bottom: var(--space-sm); cursor: pointer; border: 1px solid var(--border-color); margin-bottom: var(--space-sm); cursor: pointer;
transition: 0.2s; transition: 0.2s;
} }
.attachment-item:hover { border-color: var(--primary-color); box-shadow: var(--box-shadow); } .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); } .attachment-item.active { background: var(--primary-lv-0); border-color: var(--primary-color); }
.file-details { flex: 1; min-width: 0; } .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-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); } .file-size { font-size: 11px; color: var(--text-sub); }
.btn-group { .btn-group {
display: flex; align-items: center; gap: 12px; flex-shrink: 0; justify-content: flex-end; display: flex; align-items: center; gap: 12px; flex-shrink: 0; justify-content: flex-end;
} }
.btn-upload { .btn-upload {
padding: 6px 14px; border-radius: 6px; font-size: 11px; font-weight: 700; padding: 6px 14px; border-radius: 6px; font-size: 11px; font-weight: 700;
color: #fff; border: none; cursor: pointer; transition: 0.2s; height: 32px; color: #fff; border: none; cursor: pointer; transition: 0.2s; height: 32px;
} }
.btn-ai { background: var(--ai-gradient); } .btn-ai { background: var(--ai-gradient); }
.btn-ai:hover { filter: brightness(1.1); transform: translateY(-1px); } .btn-ai:hover { filter: brightness(1.1); transform: translateY(-1px); }
.btn-normal { background: var(--primary-color); } .btn-normal { background: var(--primary-color); }
.btn-normal:hover { background: var(--primary-hover); transform: translateY(-1px); } .btn-normal:hover { background: var(--primary-hover); transform: translateY(-1px); }
.ai-recommend { .ai-recommend {
font-size: 11px; padding: 6px 12px; border-radius: 6px; font-weight: 600; font-size: 11px; padding: 6px 12px; border-radius: 6px; font-weight: 600;
cursor: pointer; transition: 0.2s; display: inline-block; cursor: pointer; transition: 0.2s; display: inline-block;
max-width: 250px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; 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.smart-mode { background: #eef2ff; color: #4338ca; border: 1px solid #c7d2fe; }
.ai-recommend.manual-mode { background: #f1f5f9; color: #475569; border: 1px dashed #cbd5e1; } .ai-recommend.manual-mode { background: #f1f5f9; color: #475569; border: 1px dashed #cbd5e1; }
.ai-recommend:hover { transform: scale(1.02); } .ai-recommend:hover { transform: scale(1.02); }
/* 5. Preview Area */ /* 5. Preview Area */
.mail-preview-area { .mail-preview-area {
width: 0; background: #f8f9fa; display: flex; flex-direction: column; width: 0; background: #f8f9fa; display: flex; flex-direction: column;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); position: relative; transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); position: relative;
border-left: 0 solid transparent; border-left: 0 solid transparent;
} }
.mail-preview-area.active { .mail-preview-area.active {
width: 600px; width: 600px;
border-left: 1px solid var(--border-color); border-left: 1px solid var(--border-color);
visibility: visible; visibility: visible;
} }
.preview-header { .preview-header {
height: 56px; padding: 0 24px; border-bottom: 1px solid var(--border-color); height: 56px; padding: 0 24px; border-bottom: 1px solid var(--border-color);
display: flex; align-items: center; justify-content: space-between; display: flex; align-items: center; justify-content: space-between;
background: #fff; flex-shrink: 0; background: #fff; flex-shrink: 0;
} }
.preview-header h3 { font-size: 15px; font-weight: 800; color: var(--primary-color); margin: 0; } .preview-header h3 { font-size: 15px; font-weight: 800; color: var(--primary-color); margin: 0; }
#fullViewBtn { #fullViewBtn {
background: var(--primary-lv-0) !important; background: var(--primary-lv-0) !important;
color: var(--primary-color) !important; color: var(--primary-color) !important;
border: 1px solid var(--primary-lv-1) !important; border: 1px solid var(--primary-lv-1) !important;
font-weight: 700 !important; font-weight: 700 !important;
padding: 4px 16px !important; padding: 4px 16px !important;
border-radius: 4px !important; border-radius: 4px !important;
font-size: 11px !important; font-size: 11px !important;
transition: 0.2s !important; transition: 0.2s !important;
} }
#fullViewBtn:hover { background: var(--primary-lv-1) !important; } #fullViewBtn:hover { background: var(--primary-lv-1) !important; }
.preview-toggle-handle { .preview-toggle-handle {
position: absolute; left: -20px; top: 50%; transform: translateY(-50%); position: absolute; left: -20px; top: 50%; transform: translateY(-50%);
width: 20px; height: 60px; background: var(--primary-color); color: #fff; width: 20px; height: 60px; background: var(--primary-color); color: #fff;
display: flex; align-items: center; justify-content: center; display: flex; align-items: center; justify-content: center;
border-radius: 8px 0 0 8px; font-size: 10px; cursor: pointer; 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; box-shadow: -2px 0 5px rgba(0,0,0,0.1); z-index: 100;
} }
.preview-toggle-handle:hover { background: var(--primary-hover); } .preview-toggle-handle:hover { background: var(--primary-hover); }
.a4-container { .a4-container {
flex: 1; padding: 30px; overflow-y: auto; background: #e9ecef; flex: 1; padding: 30px; overflow-y: auto; background: #e9ecef;
display: flex; justify-content: center; display: flex; justify-content: center;
} }
.a4-container iframe, .a4-container .preview-placeholder { .a4-container iframe, .a4-container .preview-placeholder {
width: 100%; height: 100%; background: #fff; width: 100%; height: 100%; background: #fff;
box-shadow: 0 4px 20px rgba(0,0,0,0.08); border-radius: 4px; box-shadow: 0 4px 20px rgba(0,0,0,0.08); border-radius: 4px;
} }
.preview-placeholder { .preview-placeholder {
display: flex; align-items: center; justify-content: center; display: flex; align-items: center; justify-content: center;
text-align: center; color: var(--text-sub); font-size: 13px; line-height: 1.6; 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.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; } .mail-preview-area > *:not(.preview-toggle-handle) { opacity: 0; visibility: hidden; pointer-events: none; transition: 0.2s; }
/* 6. Footer & Others */ /* 6. Footer & Others */
.address-book-footer { .address-book-footer {
position: absolute; bottom: 0; left: 0; width: 100%; padding: 12px 16px; 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; border-top: 1px solid var(--border-color); background: #fff; display: flex; gap: 8px; z-index: 5;
} }
.file-log-area { .file-log-area {
display: none; width: 100%; margin-top: 10px; background: #1a202c; display: none; width: 100%; margin-top: 10px; background: #1a202c;
border-radius: 4px; padding: 12px; font-family: monospace; font-size: 11px; color: #cbd5e0; border-radius: 4px; padding: 12px; font-family: monospace; font-size: 11px; color: #cbd5e0;
} }
.file-log-area.active { display: block; } .file-log-area.active { display: block; }
.log-success { color: #48bb78; font-weight: 700; } .log-success { color: #48bb78; font-weight: 700; }
.switch { position: relative; display: inline-block; width: 34px; height: 20px; } .switch { position: relative; display: inline-block; width: 34px; height: 20px; }
.switch input { opacity: 0; width: 0; height: 0; } .switch input { opacity: 0; width: 0; height: 0; }
.slider { .slider {
position: absolute; cursor: pointer; top: 0; left: 0; right: 0; bottom: 0; position: absolute; cursor: pointer; top: 0; left: 0; right: 0; bottom: 0;
background-color: #ccc; transition: .4s; border-radius: 20px; background-color: #ccc; transition: .4s; border-radius: 20px;
} }
.slider:before { .slider:before {
position: absolute; content: ""; height: 14px; width: 14px; left: 3px; bottom: 3px; position: absolute; content: ""; height: 14px; width: 14px; left: 3px; bottom: 3px;
background-color: white; transition: .4s; border-radius: 50%; background-color: white; transition: .4s; border-radius: 50%;
} }
input:checked+.slider { background: var(--ai-gradient); } input:checked+.slider { background: var(--ai-gradient); }
input:checked+.slider:before { transform: translateX(14px); } input:checked+.slider:before { transform: translateX(14px); }
/* Restore Path Selector Modal Specific Styles */ /* Restore Path Selector Modal Specific Styles */
.select-group { .select-group {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 8px; gap: 8px;
} }
.select-group label { .select-group label {
font-size: 12px; font-size: 12px;
font-weight: 700; font-weight: 700;
color: var(--text-main); color: var(--text-main);
} }
.modal-select { .modal-select {
width: 100%; width: 100%;
height: 44px; height: 44px;
padding: 0 15px; padding: 0 15px;
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
border-radius: 8px; border-radius: 8px;
background-color: #f9f9f9; background-color: #f9f9f9;
font-size: 14px; font-size: 14px;
color: #333; color: #333;
outline: none; outline: none;
transition: all 0.2s; transition: all 0.2s;
cursor: pointer; cursor: pointer;
appearance: none; 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-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-repeat: no-repeat;
background-position: right 15px center; background-position: right 15px center;
} }
.modal-select:focus { .modal-select:focus {
border-color: var(--primary-color); border-color: var(--primary-color);
background-color: #fff; background-color: #fff;
box-shadow: 0 0 0 3px rgba(30, 81, 73, 0.1); box-shadow: 0 0 0 3px rgba(30, 81, 73, 0.1);
} }
.modal-select option { .modal-select option {
padding: 10px; padding: 10px;
} }

View File

@@ -1,3 +1,3 @@
@import url('common.css'); @import url('common.css');
@import url('dashboard.css'); @import url('dashboard.css');
@import url('mail.css'); @import url('mail.css');

View File

@@ -1,139 +1,139 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="ko"> <html lang="ko">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>데이터 분석 - Project Master Sabermetrics</title> <title>데이터 분석 - Project Master Sabermetrics</title>
<link rel="stylesheet" href="style/common.css"> <link rel="stylesheet" href="style/common.css">
<link rel="stylesheet" href="style/analysis.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/chart.js"></script>
<script src="https://cdn.jsdelivr.net/npm/chartjs-plugin-datalabels@2"></script> <script src="https://cdn.jsdelivr.net/npm/chartjs-plugin-datalabels@2"></script>
</head> </head>
<body> <body>
<nav class="topbar"> <nav class="topbar">
<div class="topbar-header"> <div class="topbar-header">
<a href="/"> <a href="/">
<h2>Project Master Test</h2> <h2>Project Master Test</h2>
</a> </a>
</div> </div>
<ul class="nav-list"> <ul class="nav-list">
<li class="nav-item" onclick="location.href='/dashboard'">대시보드</li> <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='/inquiries'">문의사항</li>
<li class="nav-item" onclick="location.href='/mailTest'">메일관리</li> <li class="nav-item" onclick="location.href='/mailTest'">메일관리</li>
<li class="nav-item active" onclick="location.href='/analysis'">분석</li> <li class="nav-item active" onclick="location.href='/analysis'">분석</li>
</ul> </ul>
</nav> </nav>
<main class="analysis-content"> <main class="analysis-content">
<header class="analysis-header"> <header class="analysis-header">
<div class="title-group"> <div class="title-group">
<div class="ai-badge">AI Sabermetrics</div> <div class="ai-badge">AI Sabermetrics</div>
<h2>시스템 운영 자산 가치 분석</h2> <h2>시스템 운영 자산 가치 분석</h2>
<p>수집된 활동 로그 및 자산 데이터를 기반으로 한 통계적 활력 지표 (Beta)</p> <p>수집된 활동 로그 및 자산 데이터를 기반으로 한 통계적 활력 지표 (Beta)</p>
</div> </div>
<div class="analysis-actions"> <div class="analysis-actions">
<button class="btn btn-primary" onclick="location.reload()">데이터 갱신</button> <button class="btn btn-primary" onclick="location.reload()">데이터 갱신</button>
</div> </div>
</header> </header>
<!-- 상단 정보 영역 --> <!-- 상단 정보 영역 -->
<div class="top-info-grid"> <div class="top-info-grid">
<section class="dl-model-info"> <section class="dl-model-info">
<div class="card-header"> <div class="card-header">
<h4><i class="ai-icon">AI</i> Hybrid Prediction Engine</h4> <h4><i class="ai-icon">AI</i> Hybrid Prediction Engine</h4>
</div> </div>
<div class="card-body"> <div class="card-body">
<div class="model-desc-vertical"> <div class="model-desc-vertical">
<div class="model-item-vertical"> <div class="model-item-vertical">
<span class="model-tag">분석 모델</span> <span class="model-tag">분석 모델</span>
<p>최근 9회차 시계열의 Velocity 및 변화율 분석</p> <p>최근 9회차 시계열의 Velocity 및 변화율 분석</p>
</div> </div>
<div class="model-item-vertical"> <div class="model-item-vertical">
<span class="model-tag">가중치 로직</span> <span class="model-tag">가중치 로직</span>
<p>활동 시 '선형 유지', 정체 시 '지수 감쇄' 동적 적용</p> <p>활동 시 '선형 유지', 정체 시 '지수 감쇄' 동적 적용</p>
</div> </div>
</div> </div>
</div> </div>
</section> </section>
<section class="soi-deep-dive"> <section class="soi-deep-dive">
<div class="card-header"> <div class="card-header">
<h4><i class="info-icon">i</i> AI 위험 적응형 모델 (AAS) 기반 지표 정의</h4> <h4><i class="info-icon">i</i> AI 위험 적응형 모델 (AAS) 기반 지표 정의</h4>
</div> </div>
<div class="card-body"> <div class="card-body">
<div class="soi-info-columns"> <div class="soi-info-columns">
<div class="soi-info-column"> <div class="soi-info-column">
<h6>1. 자산 가치 변동 추적</h6> <h6>1. 자산 가치 변동 추적</h6>
<p>규모를 감지하여, 대형 프로젝트 정체 시 데이터 가치 하락 속도를 <strong>가속(Acceleration)</strong>시킵니다.</p> <p>규모를 감지하여, 대형 프로젝트 정체 시 데이터 가치 하락 속도를 <strong>가속(Acceleration)</strong>시킵니다.</p>
</div> </div>
<div class="soi-info-column"> <div class="soi-info-column">
<h6>2. 활동 시계열 관성 분석</h6> <h6>2. 활동 시계열 관성 분석</h6>
<p>최근 활동의 연속성을 분석하여, 단기 정체 시에도 과거의 <strong>운영 모멘텀</strong>을 반영하여 지수를 보정합니다.</p> <p>최근 활동의 연속성을 분석하여, 단기 정체 시에도 과거의 <strong>운영 모멘텀</strong>을 반영하여 지수를 보정합니다.</p>
</div> </div>
<div class="soi-info-column"> <div class="soi-info-column">
<h6>3. 동적 가치 계수</h6> <h6>3. 동적 가치 계수</h6>
<p>프로젝트마다 <strong>개별화된 감쇄 곡선</strong>을 생성하여 현장에 가장 밀착된 보존율을 제공합니다.</p> <p>프로젝트마다 <strong>개별화된 감쇄 곡선</strong>을 생성하여 현장에 가장 밀착된 보존율을 제공합니다.</p>
</div> </div>
</div> </div>
</div> </div>
</section> </section>
</div> </div>
<!-- 메인 분석 차트 영역 --> <!-- 메인 분석 차트 영역 -->
<div class="analysis-charts-grid"> <div class="analysis-charts-grid">
<div class="chart-container-box"> <div class="chart-container-box">
<h5>운영 활력 분포 (Activity Distribution)</h5> <h5>운영 활력 분포 (Activity Distribution)</h5>
<canvas id="statusChart"></canvas> <canvas id="statusChart"></canvas>
</div> </div>
<div class="chart-container-box"> <div class="chart-container-box">
<h5>자산 가치 전략 매트릭스 (Strategic Analysis)</h5> <h5>자산 가치 전략 매트릭스 (Strategic Analysis)</h5>
<canvas id="forecastChart"></canvas> <canvas id="forecastChart"></canvas>
</div> </div>
</div> </div>
<!-- 리더보드 영역 --> <!-- 리더보드 영역 -->
<div class="analysis-card timeline-analysis"> <div class="analysis-card timeline-analysis">
<div class="card-header"> <div class="card-header">
<div style="display: flex; flex-direction: column; gap: 4px;"> <div style="display: flex; flex-direction: column; gap: 4px;">
<h4>Project Activity Vitality Leaderboard (AVI Status)</h4> <h4>Project Activity Vitality Leaderboard (AVI Status)</h4>
<p style="font-size: 11px; color: #888; margin: 0;">운영 표준(AVI 70%) 대비 운영 활력 및 VCI 기여 리더보드</p> <p style="font-size: 11px; color: #888; margin: 0;">전체 포트폴리오 평균(0.0) 대비 운영 가치 기여(VCI) 리더보드</p>
</div> </div>
<div class="card-tools"> <div class="card-tools">
<span id="avg-system-info" style="font-size: 11px; color: #888;">* AVI (Activity Vitality Index)</span> <span id="avg-system-info" style="font-size: 11px; color: #888;">* AVI (Activity Vitality Index)</span>
</div> </div>
</div> </div>
<div class="card-body"> <div class="card-body">
<div class="d-war-guide"> <div class="d-war-guide">
<div class="guide-item active-low"><span>70%↑</span> 정상 운영</div> <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 warning-mid"><span>30~70%</span> 관리 주의</div>
<div class="guide-item danger-high"><span>10~30%</span> 위험 노출</div> <div class="guide-item danger-high"><span>10~30%</span> 위험 노출</div>
<div class="guide-item hazard-critical"><span>10%↓</span> 중단/방치</div> <div class="guide-item hazard-critical"><span>10%↓</span> 중단/방치</div>
</div> </div>
<div id="p-war-table-container"> <div id="p-war-table-container">
<!-- JS에 의해 동적으로 테이블 삽입 --> <!-- JS에 의해 동적으로 테이블 삽입 -->
</div> </div>
</div> </div>
</div> </div>
</main> </main>
<!-- 설명 모달 --> <!-- 설명 모달 -->
<div id="analysisModal" class="modal-overlay" onclick="if(event.target===this) closeAnalysisModal()"> <div id="analysisModal" class="modal-overlay" onclick="if(event.target===this) closeAnalysisModal()">
<div class="modal-content"> <div class="modal-content">
<div class="modal-header"> <div class="modal-header">
<h3 id="modalTitle">분석 상세</h3> <h3 id="modalTitle">분석 상세</h3>
<span class="modal-close" onclick="closeAnalysisModal()">&times;</span> <span class="modal-close" onclick="closeAnalysisModal()">&times;</span>
</div> </div>
<div class="modal-body" id="modalBody"> <div class="modal-body" id="modalBody">
<!-- 내용 동적 삽입 --> <!-- 내용 동적 삽입 -->
</div> </div>
</div> </div>
</div> </div>
<script src="js/common.js"></script> <script src="js/common.js"></script>
<script src="js/analysis.js"></script> <script src="js/analysis.js"></script>
</body> </body>
</html> </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> <!DOCTYPE html>
<html lang="ko"> <html lang="ko">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Project Master Overseas 관리자</title> <title>Project Master Overseas 관리자</title>
<link rel="stylesheet" as="style" crossorigin <link rel="stylesheet" as="style" crossorigin
href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/static/pretendard.min.css" /> 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/common.css">
<link rel="stylesheet" href="style/dashboard.css"> <link rel="stylesheet" href="style/dashboard.css">
</head> </head>
<body> <body>
<nav class="topbar"> <nav class="topbar">
<div class="topbar-header"> <div class="topbar-header">
<a href="/"> <a href="/">
<h2>Project Master Test</h2> <h2>Project Master Test</h2>
</a> </a>
</div> </div>
<ul class="nav-list"> <ul class="nav-list">
<li class="nav-item active" onclick="location.href='/dashboard'">대시보드</li> <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='/inquiries'">문의사항</li>
<li class="nav-item" onclick="location.href='/mailTest'">메일관리</li> <li class="nav-item" onclick="location.href='/mailTest'">메일관리</li>
<li class="nav-item" onclick="location.href='/analysis'">분석</li> <li class="nav-item" onclick="location.href='/analysis'">분석</li>
</ul> </ul>
</nav> </nav>
<main class="main-content"> <main class="main-content">
<header> <header>
<h2>프로젝트 현황</h2> <h2>프로젝트 현황</h2>
<div class="header-actions" style="display: flex; align-items: center; gap: 15px;"> <div class="header-actions" style="display: flex; align-items: center; gap: 15px;">
<div class="base-date-info">기준날짜: <strong id="baseDate">-</strong></div> <div class="base-date-info">기준날짜: <strong id="baseDate">-</strong></div>
<button id="syncBtn" class="sync-btn" onclick="syncData()"> <button id="syncBtn" class="sync-btn" onclick="syncData()">
<span class="spinner"></span> <span class="spinner"></span>
데이터 동기화 데이터 동기화
</button> </button>
<div class="admin-info">접속자: <strong>이태훈[전체관리자]</strong></div> <div class="admin-info">접속자: <strong>이태훈[전체관리자]</strong></div>
</div> </div>
</header> </header>
<!-- 프로젝트 활성도 대시보드 (전체 너비 래퍼) --> <!-- 프로젝트 활성도 대시보드 (전체 너비 래퍼) -->
<div class="activity-dashboard-wrapper"> <div class="activity-dashboard-wrapper">
<div id="activityDashboard" class="activity-dashboard"> <div id="activityDashboard" class="activity-dashboard">
<!-- JS에서 동적 삽입 --> <!-- JS에서 동적 삽입 -->
</div> </div>
</div> </div>
<!-- 실시간 로그 콘솔 (본문 내부로 복구) --> <!-- 실시간 로그 콘솔 (본문 내부로 복구) -->
<div id="logConsole" class="log-console" style="display:none;"> <div id="logConsole" class="log-console" style="display:none;">
<div class="log-console-header">실시간 수집 로그 [PM Overseas]</div> <div class="log-console-header">실시간 수집 로그 [PM Overseas]</div>
<div id="logBody"></div> <div id="logBody"></div>
</div> </div>
<div id="projectAccordion"> <div id="projectAccordion">
<!-- Multi-level Accordion items will be generated here --> <!-- Multi-level Accordion items will be generated here -->
</div> </div>
</main> </main>
<!-- 모달 레이어 (공통 규격 적용) --> <!-- 모달 레이어 (공통 규격 적용) -->
<div id="authModal" class="modal-overlay"> <div id="authModal" class="modal-overlay">
<div class="modal-content" style="max-width: 440px; padding: 40px; text-align: center;" onclick="event.stopPropagation()"> <div class="modal-content" style="max-width: 440px; padding: 40px; text-align: center;" onclick="event.stopPropagation()">
<div class="auth-header" style="margin-bottom: 32px;"> <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> <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> <h3 style="font-size: 20px; font-weight: 800; color: #111; margin-bottom: 8px;">크롤링 권한 인증</h3>
<p style="font-size: 13px; color: var(--text-sub);">시스템 동기화를 위해 관리자 계정으로 로그인하세요.</p> <p style="font-size: 13px; color: var(--text-sub);">시스템 동기화를 위해 관리자 계정으로 로그인하세요.</p>
</div> </div>
<div class="auth-body" style="display: flex; flex-direction: column; gap: 20px; text-align: left; margin-bottom: 32px;"> <div class="auth-body" style="display: flex; flex-direction: column; gap: 20px; text-align: left; margin-bottom: 32px;">
<div class="input-group"> <div class="input-group">
<label>관리자 아이디</label> <label>관리자 아이디</label>
<input type="text" id="authId" placeholder="아이디를 입력하세요"> <input type="text" id="authId" placeholder="아이디를 입력하세요">
</div> </div>
<div class="input-group"> <div class="input-group">
<label>비밀번호</label> <label>비밀번호</label>
<input type="password" id="authPw" placeholder="비밀번호를 입력하세요" <input type="password" id="authPw" placeholder="비밀번호를 입력하세요"
onkeyup="if(event.key==='Enter') submitAuth()"> onkeyup="if(event.key==='Enter') submitAuth()">
</div> </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 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>
<div class="auth-footer" style="display: grid; grid-template-columns: 1fr 1.5fr; gap: 12px;"> <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-secondary" style="height: 48px;" onclick="ModalManager.close('authModal')">취소</button>
<button class="btn btn-primary" style="height: 48px;" onclick="submitAuth()">인증 및 실행</button> <button class="btn btn-primary" style="height: 48px;" onclick="submitAuth()">인증 및 실행</button>
</div> </div>
</div> </div>
</div> </div>
<div id="activityDetailModal" class="modal-overlay" onclick="ModalManager.close('activityDetailModal')"> <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-content" style="max-width: 600px; padding: 0; overflow: hidden;" onclick="event.stopPropagation()">
<div class="modal-header" style="padding: 20px; margin-bottom: 0;"> <div class="modal-header" style="padding: 20px; margin-bottom: 0;">
<h3 id="modalTitle">상세 목록</h3> <h3 id="modalTitle">상세 목록</h3>
<span class="modal-close" onclick="ModalManager.close('activityDetailModal')">&times;</span> <span class="modal-close" onclick="ModalManager.close('activityDetailModal')">&times;</span>
</div> </div>
<div class="modal-body" style="padding: 20px; max-height: 70vh; overflow-y: auto;"> <div class="modal-body" style="padding: 20px; max-height: 70vh; overflow-y: auto;">
<table class="data-table"> <table class="data-table">
<thead> <thead>
<tr> <tr>
<th>프로젝트명</th> <th>프로젝트명</th>
<th>담당부서</th> <th>담당부서</th>
<th>담당자</th> <th>담당자</th>
</tr> </tr>
</thead> </thead>
<tbody id="modalTableBody"> <tbody id="modalTableBody">
<!-- JS에서 동적 삽입 --> <!-- JS에서 동적 삽입 -->
</tbody> </tbody>
</table> </table>
</div> </div>
<div style="padding: 16px 20px; border-top: 1px solid var(--border-color); text-align: right; background: #fdfdfd;"> <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> <button class="btn btn-secondary" onclick="ModalManager.close('activityDetailModal')">닫기</button>
</div> </div>
</div> </div>
</div> </div>
<script src="js/common.js"></script> <script src="js/common.js"></script>
<script src="js/dashboard.js"></script> <script src="js/dashboard.js"></script>
</body> </body>
</html> </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> <!DOCTYPE html>
<html lang="ko"> <html lang="ko">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Project Master Portal</title> <title>Project Master Portal</title>
<link rel="stylesheet" as="style" crossorigin <link rel="stylesheet" as="style" crossorigin
href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/static/pretendard.min.css" /> 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/common.css">
<link rel="stylesheet" href="style/dashboard.css"> <link rel="stylesheet" href="style/dashboard.css">
</head> </head>
<body> <body>
<nav class="topbar"> <nav class="topbar">
<div class="topbar-header"> <div class="topbar-header">
<a href="/"><h2>Project Master Test</h2></a> <a href="/"><h2>Project Master Test</h2></a>
</div> </div>
</nav> </nav>
<div class="portal-container"> <div class="portal-container">
<div class="portal-header"> <div class="portal-header">
<h1>Project Master 테스트</h1> <h1>Project Master 테스트</h1>
<p>원하시는 서비스에 접속하려면 아래 버튼을 클릭하세요.</p> <p>원하시는 서비스에 접속하려면 아래 버튼을 클릭하세요.</p>
</div> </div>
<div class="button-grid"> <div class="button-grid">
<a href="/dashboard" class="portal-card"> <a href="/dashboard" class="portal-card">
<div class="icon">📊</div> <div class="icon">📊</div>
<h2>관리자 페이지 테스트</h2> <h3>대시보드</h3>
<p>관리자 페이지 테스트 입니다.</p> <p>시스템 운영 현황 및 핵심 지표 요약</p>
</a> </a>
<a href="/mailTest" class="portal-card"> <a href="/inquiries" class="portal-card">
<div class="icon">✉️</div> <div class="icon">📝</div>
<h2>메일 테스트</h2> <h3>문의사항</h3>
<p>메일 기능 테스트 페이지입니다.</p> <p>프로젝트 관련 문의 및 기술 지원 관리</p>
</a> </a>
</div>
</div> <a href="/mailTest" class="portal-card">
<div class="icon">📧</div>
<script src="js/common.js"></script> <h3>메일관리</h3>
</body> <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> </html>

View File

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

View File

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

View File

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

View File

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