Fix Gitea MCP ESM resolution error, update configuration, and include project tests/migration files
This commit is contained in:
1616
.agents/git-mcp/package-lock.json
generated
Normal file
1616
.agents/git-mcp/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
16
.agents/git-mcp/package.json
Normal file
16
.agents/git-mcp/package.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"name": "git-mcp",
|
||||
"version": "1.0.0",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"test": "echo \"Error: no test specified\" && exit 1"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"description": "",
|
||||
"dependencies": {
|
||||
"@andrebuzeli/git-mcp": "^15.12.4",
|
||||
"zod": "^4.4.3"
|
||||
}
|
||||
}
|
||||
14
.agents/mcp_config.json
Normal file
14
.agents/mcp_config.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"gitea": {
|
||||
"command": "node",
|
||||
"args": [
|
||||
"/mnt/d/이태훈/19ProjectMaster/AICodeTest/.agents/git-mcp/node_modules/@andrebuzeli/git-mcp/src/index.js"
|
||||
],
|
||||
"env": {
|
||||
"GITEA_URL": "https://gitea.hmac.kr",
|
||||
"GITEA_TOKEN": "34a35034b9335b5129c8bfcd27e841d83f0aeaed"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
12
.env
12
.env
@@ -1,6 +1,6 @@
|
||||
PM_USER_ID=b21364
|
||||
PM_PASSWORD=b21364!.
|
||||
DB_HOST=localhost
|
||||
DB_USER=root
|
||||
DB_PASSWORD=45278434
|
||||
DB_NAME=PM_proto
|
||||
PM_USER_ID=b21364
|
||||
PM_PASSWORD=b21364!.
|
||||
DB_HOST=db
|
||||
DB_USER=root
|
||||
DB_PASSWORD=45278434
|
||||
DB_NAME=PM_proto
|
||||
|
||||
BIN
.gitignore
vendored
BIN
.gitignore
vendored
Binary file not shown.
@@ -1,81 +1,60 @@
|
||||
# 📊 시스템 운영 자산 가치 분석 보고서 (Sabermetrics Edition)
|
||||
|
||||
본 보고서는 야구의 통계 분석 기법인 **세이버메트릭스(Sabermetrics)**를 프로젝트 관리 시스템에 이식하여, 단순 활동량 측정을 넘어 **'실질적 자산 가치'**와 **'미래 운영 위험'**을 정밀 분석한 결과입니다.
|
||||
|
||||
---
|
||||
|
||||
## 1. 핵심 분석 지표 정의 (Core Metrics)
|
||||
|
||||
### 1.1 운영 활력 지수 (AVI, Activity Vitality Index)
|
||||
프로젝트가 현재 얼마나 '살아서 숨 쉬고 있는가'를 나타내는 생존 지수입니다.
|
||||
|
||||
* **산출 공식**: $AVI = exp(-\lambda \times days) \times Quality \times ECV \times 100$
|
||||
* **핵심 데이터**:
|
||||
* **정체 일수(days)**: 마지막 유의미한 파일 업데이트 이후 경과 시간.
|
||||
* **감쇄 계수($\lambda$)**: 기본 $0.04$에서 시작하여, 자산 규모(최대 $+0.04$)와 부서 정체율(최대 $+0.03$)을 동적으로 결합합니다.
|
||||
* **활동 품질(Quality)**: 파일 증분 활동($1.0$), 구조적 관리($0.7$), 단순 행정 로그($0.4$)로 차등 배점합니다.
|
||||
* **존재 신뢰도(ECV)**: 파일 수 $0$개($0.05$), $10$개 미만($0.4$) 등 유령 프로젝트에 패널티를 부여합니다.
|
||||
* **의미**: 100%에 가까울수록 실시간 가동 상태이며, 0%에 가까울수록 데이터 노후화가 완료된 '사망' 상태를 뜻합니다.
|
||||
|
||||
### 1.2 자산 가치 기여도 (VCI, Value Contribution Index)
|
||||
시스템 전체의 운영 표준 대비, 해당 프로젝트가 기여하고 있는 가치의 상대적 하중을 측정합니다.
|
||||
|
||||
* **산출 공식**: $VCI = (AVI - 70.0) \times (\frac{Files}{200} + 0.5)$
|
||||
* **핵심 로직**:
|
||||
* **건강 기준선(70.0%)**: 시스템 자산 가치를 유지하기 위한 최소 마지노선(Replacement Level)입니다.
|
||||
* **규모 가중치**: 파일 $200$개를 $1.0$ 가중치 기준으로 삼아, 대형 프로젝트일수록 시스템에 주는 충격을 기하급수적으로 반영합니다.
|
||||
* **의미**: 양수(+)는 가치 창출, 음수(-)는 시스템 기회비용을 갉아먹는 '가치 파괴' 상태임을 나타냅니다.
|
||||
|
||||
### 1.3 업무 집중도 (Job Focus)
|
||||
단순 관리 행위를 제외하고, 실제 성과물(파일)을 생산하는 데 얼마나 몰입했는지를 판별합니다.
|
||||
|
||||
* **산출 공식**: $Job Focus = \frac{\text{최근 히스토리 중 실제 파일 변동 발생 횟수}}{\text{전체 데이터 수집 횟수}} \times 100$
|
||||
* **의미**: 로그만 남기는 '보여주기식 활동'을 필터링하여 운영의 진정성을 확인합니다.
|
||||
|
||||
### 1.4 운영 일관성 지수 (OCI, Operational Consistency Index)
|
||||
프로젝트 관리의 '리듬'과 '성실도'를 측정하는 지표입니다.
|
||||
|
||||
* **산출 공식**: 최근 30일 데이터를 4개 주차로 분할하여 활동 여부 분석 (주차별 성실도 70% + 활동 밀도 30%)
|
||||
* **의미**: 특정 시점에 몰아치기식 작업을 하는 프로젝트보다, 매주 꾸준히 관리되는 프로젝트에 더 높은 신뢰 점수를 부여합니다.
|
||||
|
||||
---
|
||||
|
||||
## 2. 등급 체계 및 관리 가이드 (Grade System)
|
||||
|
||||
### 2.1 VCI 등급 (프로젝트 위상)
|
||||
| 등급 (Grade) | 점수 기준 | 운영 의미 및 관리 전략 |
|
||||
| :--- | :--- | :--- |
|
||||
| **Masterpiece** | +10.0 이상 | **최우량 자산**: 시스템 가치를 견인하는 핵심 프로젝트 |
|
||||
| **Blue Chip** | +2.0 ~ +10.0 | **우량 자산**: 꾸준한 활력으로 가치를 창출하는 핵심군 |
|
||||
| **Steady** | -2.0 ~ +2.0 | **안정 자산**: 표준 수준의 운영을 유지 중인 현상 유지군 |
|
||||
| **Underperform** | -10.0 ~ -2.0 | **저성과 자산**: 규모 대비 활력이 부족하여 가치 하락 중인 그룹 |
|
||||
| **Liability** | -10.0 이하 | **고위험 자산**: 시스템 가치를 훼손 중인 방치 프로젝트. 즉시 조치 필요 |
|
||||
|
||||
### 2.2 운영 일관성 (OCI) 판정
|
||||
* **정기적 (80%↑)**: 주 단위의 정기적 관리가 완벽히 이뤄지는 최우량 관리 상태.
|
||||
* **안정적 (50~80%)**: 간헐적 정체는 있으나 전반적인 관리 리듬을 유지하는 상태.
|
||||
* **간헐적 (20~50%)**: 관리 활동이 불규칙하며, 필요에 의한 일회성 작업 중심인 상태.
|
||||
* **불규칙 (20%↓)**: 장기 정체 중이거나 관리의 영속성을 확인하기 어려운 위험 상태.
|
||||
|
||||
---
|
||||
|
||||
## 3. 데이터 분석 프로세스 (Analysis Process)
|
||||
|
||||
1. **데이터 수집**: `projects_history` 테이블로부터 일별 파일 수 및 로그 텍스트를 추출합니다.
|
||||
2. **피처 추출**:
|
||||
* **Velocity**: 파일 수의 변화 속도 계산.
|
||||
* **Acceleration**: 활동의 가속/감속 여부 판별.
|
||||
* **Stagnation**: 마지막 활동 이후의 공백 기간 측정.
|
||||
3. **AI 시뮬레이션**: 추출된 피처를 AI 위험 적응형 모델 (AAS)에 입력하여 개별 프로젝트만의 **'위험 곡선'**을 생성합니다.
|
||||
4. **최종 판정**: AVI와 VCI를 결합하여 리더보드에 등급과 관리 가이드라인을 송출합니다.
|
||||
|
||||
---
|
||||
|
||||
## 4. 관리자 제언 (Action Plan)
|
||||
|
||||
* **VCI 음수 프로젝트 집중 관리**: 단순 활동량이 아닌 VCI가 낮은 대형 프로젝트부터 우선적으로 인력을 배치하거나 운영 정책을 재점검해야 합니다.
|
||||
* **AI Forecast 활용**: '활력 저하' 예보가 뜬 프로젝트는 실제 AVI가 급락하기 전 선제적인 조치(업무 독려, 파일 현행화)를 취할 수 있습니다.
|
||||
* **파일 수와 활력의 균형**: 파일 수가 많은데 활력(AVI)이 낮은 경우, 시스템 전체의 데이터 무결성을 해칠 수 있으므로 데이터 클렌징이나 아카이빙을 권고합니다.
|
||||
|
||||
---
|
||||
*본 분석 엔진은 Project Master Sabermetrics 알고리즘에 의해 자동 생성되었습니다.*
|
||||
# 📊 시스템 운영 자산 가치 분석 보고 (Sabermetrics Report)
|
||||
|
||||
본 보고서는 프로젝트 관리 시스템 내에서 수집된 활동 로그 및 자산 데이터를 통계적/AI 기법으로 분석하여, 각 프로젝트의 운영 활력과 조직 기여도를 정량화한 지표를 정의합니다.
|
||||
|
||||
---
|
||||
|
||||
## 1. 운영 활력 지수 (AVI, Activity Vitality Index)
|
||||
프로젝트가 현재 얼마나 건강하게 가동되고 있는지를 나타내는 **'디지털 자산 생존 지표'**입니다.
|
||||
|
||||
### 1.1 산출 공식
|
||||
$$AVI = e^{-\lambda \times Stagnant\_Days} \times Quality \times 100$$
|
||||
|
||||
### 1.2 3대 핵심 변수 상세 설명
|
||||
|
||||
#### ① 지수 감쇄 모델 ($e^{-\lambda \times t}$) : "가치의 시한폭탄"
|
||||
자산은 관리하지 않으면 시간이 흐를수록 가치가 기하급수적으로 소멸한다는 **'정보 휘발성'** 원리를 반영합니다.
|
||||
* **Stagnant Days (정체 일수)**: 마지막 유효 활동 로그 기록일로부터 오늘까지 경과된 날짜입니다.
|
||||
* **특징**: 정체 초기에는 점수가 빠르게 하락하다가, 시간이 지날수록 하락 폭이 둔화되며 0에 수렴합니다. 이는 관리가 중단된 직후의 정보 망실 위험이 가장 크다는 실무적 경험을 반영한 것입니다.
|
||||
|
||||
#### ② 위험 가속 계수 ($\lambda$) : "대형 자산의 높은 관리 비용"
|
||||
모든 프로젝트는 자산 규모에 따라 '늙어가는 속도'가 다릅니다.
|
||||
* **공식**: $\lambda = 0.04 + \log_{10}(Files + 1) \times 0.008$
|
||||
* **비즈니스 로직**: 파일이 많은 대형 프로젝트일수록 관리 부재 시 조직에 미치는 타격이 큽니다. 따라서 대형 프로젝트일수록 $\lambda$ 값이 커지며, 소형 프로젝트보다 **훨씬 빠른 속도로 AVI가 하락**하도록 설계되었습니다. (대형 프로젝트는 더 자주 관리해야 점수가 유지됨)
|
||||
|
||||
#### ③ 활동 품질 가중치 ($Quality$) : "행정과 실무의 구분"
|
||||
단순히 접속하거나 로그가 찍혔다고 해서 활력이 100% 회복되지 않습니다. AI가 로그 키워드를 분석하여 활동의 **'진정성'**을 평가합니다.
|
||||
* **High (1.0)**: **성과물 중심 활동** (파일 업로드, 수정, 등록, 업데이트 등)
|
||||
* **Medium (0.7)**: **구조적 유지 활동** (폴더 생성, 삭제, 이동 등)
|
||||
* **Low (0.4)**: **단순 행정 활동** (권한 변경, 메일 확인, 참가자 추가 등)
|
||||
|
||||
---
|
||||
|
||||
## 2. 자산 가치 기여도 (VCI, Value Contribution Index)
|
||||
야구의 **WAR(Wins Above Replacement)** 개념을 도입하여, 전체 포트폴리오 평균 대비 개별 프로젝트가 조직 가치에 얼마나 기여하는지 산출합니다.
|
||||
|
||||
### 2.1 산출 공식
|
||||
$$VCI = (Individual\_AVI - Portfolio\_Avg\_AVI) \times Asset\_Weight$$
|
||||
* **Asset Weight (파일 규모 가중치)**: $max(0.2, \frac{Individual\_Files}{Portfolio\_Avg\_Files})$
|
||||
|
||||
### 2.2 지표의 의미: "평균(0.0)을 기준으로 한 상대 평가"
|
||||
* **0.0 (평균)**: 조직 내 평균적인 관리 수준과 규모를 가진 표준 프로젝트.
|
||||
* **(+) 점수**: 평균 이상의 활력으로 조직의 디지털 자산 가치를 증대시키는 프로젝트.
|
||||
* **(-) 점수**: 평균 이하의 방치로 인해 조직에 잠재적 기회비용 손실을 입히는 리스크 프로젝트.
|
||||
* **상대 가중치**: 조직의 평균 파일 수보다 큰 프로젝트가 방치될 때 마이너스 점수가 더 가파르게 하락하여 **'우선 관리 대상'**을 명확히 식별합니다.
|
||||
|
||||
---
|
||||
|
||||
## 3. 강력한 예외 처리 (Hard Rules)
|
||||
|
||||
데이터의 신뢰도를 확보하기 위해 다음과 같은 **'사망 판정'** 규칙이 적용됩니다.
|
||||
1. **자동 삭제 패널티**: 최근 로그가 시스템에 의한 '폴더자동삭제'인 경우, AVI는 즉시 **0.1%**로 고정됩니다. (관리 포기 상태)
|
||||
2. **자산 부재 패널티 (ECV)**: 파일 개수가 0개인 경우 운영 일관성(OCI)은 **0.0점**이며, 파일 10개 미만은 최종 가중치에 **50% 패널티**를 적용하여 '껍데기 프로젝트'를 걸러냅니다.
|
||||
|
||||
---
|
||||
|
||||
## 4. 발표 및 분석 가이드 (Executive Summary)
|
||||
|
||||
* **AVI가 낮은 프로젝트**: "데이터가 낡아가고 있으니 즉시 최신 성과물을 업데이트하십시오."
|
||||
* **VCI가 음수(-)인 대형 프로젝트**: "조직에서 가장 중요한 자산임에도 불구하고 평균 이하로 방치되고 있습니다. **최우선 관리 대상**입니다."
|
||||
* **OCI가 낮은 프로젝트**: "활동은 있으나 불규칙합니다. 관리의 지속성을 확보하여 운영 리듬을 찾으십시오."
|
||||
|
||||
40
Dockerfile
Normal file
40
Dockerfile
Normal 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
68
PLAN.md
@@ -1,34 +1,34 @@
|
||||
# 데이터 분석 관리자 페이지 기획안
|
||||
|
||||
## 1. 프로젝트 개요
|
||||
본 프로젝트는 데이터 분석 프로세스 및 프로젝트 리소스를 통합 관리하기 위한 관리자 대시보드입니다. 사용자 인터랙션 관리부터 시스템 로그, 리소스 현황을 한눈에 파악하는 것을 목표로 합니다.
|
||||
|
||||
## 2. 주요 기능 상세
|
||||
|
||||
### ① 메일 관리 및 요구사항 시스템 (Mail & Inquiry Management) - [완료]
|
||||
- **UI/UX 고도화**: 리스트 영역 너비 확장(400px) 및 시각적 가독성 개선
|
||||
- **검색 및 필터링**: 키워드 및 기간별 메일 검색 기능 구현
|
||||
- **동적 연동**: 리스트 클릭 시 메일 본문 실시간 업데이트 구현
|
||||
- **메일 관리**: 개별 삭제 및 체크박스를 활용한 대량 삭제 기능 추가
|
||||
- **탭 시스템**: 수신/발신/임시/휴지통별 데이터 분류 및 동적 렌더링 적용
|
||||
|
||||
### ② 로그 관리 (Log Management)
|
||||
- **최근 로그**: 실시간으로 발생하는 시스템 및 분석 작업 로그 출력
|
||||
- **전체 로그**: 날짜별, 프로젝트별 필터링을 통한 로그 기록 조회 및 내보내기
|
||||
|
||||
### ③ 파일 관리 (File Management)
|
||||
- 프로젝트별 데이터셋, 분석 결과물 파일 개수 및 용량 통계
|
||||
- 파일 확장자별 구성 비율(CSV, JSON, Python 등) 시각화 지표 제공
|
||||
|
||||
### ④ 인원 관리 (Personnel Management)
|
||||
- 프로젝트 참여 인원 현황 조회
|
||||
- 사용자별 권한(관리자, 분석가, 뷰어) 부여 및 수정 기능
|
||||
|
||||
### ③ 공지사항 (Notice & Patch Notes)
|
||||
- 분석 모델 업데이트, 시스템 점검, 패치 내역 공유
|
||||
- 사용자 대상 공지사항 작성 및 게시판 관리
|
||||
|
||||
## 3. UI/UX 가이드라인
|
||||
- **Layout**: 좌측 내비게이션 바(Sidebar) + 상단 헤더(Header) + 중앙 컨텐츠 영역
|
||||
- **Theme**: 신뢰감을 주는 Dark Blue / White 톤의 깨끗한 디자인
|
||||
- **Responsiveness**: 다양한 해상도에 대응하는 반응형 레이아웃 구성
|
||||
# 데이터 분석 관리자 페이지 기획안
|
||||
|
||||
## 1. 프로젝트 개요
|
||||
본 프로젝트는 데이터 분석 프로세스 및 프로젝트 리소스를 통합 관리하기 위한 관리자 대시보드입니다. 사용자 인터랙션 관리부터 시스템 로그, 리소스 현황을 한눈에 파악하는 것을 목표로 합니다.
|
||||
|
||||
## 2. 주요 기능 상세
|
||||
|
||||
### ① 메일 관리 및 요구사항 시스템 (Mail & Inquiry Management) - [완료]
|
||||
- **UI/UX 고도화**: 리스트 영역 너비 확장(400px) 및 시각적 가독성 개선
|
||||
- **검색 및 필터링**: 키워드 및 기간별 메일 검색 기능 구현
|
||||
- **동적 연동**: 리스트 클릭 시 메일 본문 실시간 업데이트 구현
|
||||
- **메일 관리**: 개별 삭제 및 체크박스를 활용한 대량 삭제 기능 추가
|
||||
- **탭 시스템**: 수신/발신/임시/휴지통별 데이터 분류 및 동적 렌더링 적용
|
||||
|
||||
### ② 로그 관리 (Log Management)
|
||||
- **최근 로그**: 실시간으로 발생하는 시스템 및 분석 작업 로그 출력
|
||||
- **전체 로그**: 날짜별, 프로젝트별 필터링을 통한 로그 기록 조회 및 내보내기
|
||||
|
||||
### ③ 파일 관리 (File Management)
|
||||
- 프로젝트별 데이터셋, 분석 결과물 파일 개수 및 용량 통계
|
||||
- 파일 확장자별 구성 비율(CSV, JSON, Python 등) 시각화 지표 제공
|
||||
|
||||
### ④ 인원 관리 (Personnel Management)
|
||||
- 프로젝트 참여 인원 현황 조회
|
||||
- 사용자별 권한(관리자, 분석가, 뷰어) 부여 및 수정 기능
|
||||
|
||||
### ③ 공지사항 (Notice & Patch Notes)
|
||||
- 분석 모델 업데이트, 시스템 점검, 패치 내역 공유
|
||||
- 사용자 대상 공지사항 작성 및 게시판 관리
|
||||
|
||||
## 3. UI/UX 가이드라인
|
||||
- **Layout**: 좌측 내비게이션 바(Sidebar) + 상단 헤더(Header) + 중앙 컨텐츠 영역
|
||||
- **Theme**: 신뢰감을 주는 Dark Blue / White 톤의 깨끗한 디자인
|
||||
- **Responsiveness**: 다양한 해상도에 대응하는 반응형 레이아웃 구성
|
||||
|
||||
179
README.md
179
README.md
@@ -1,86 +1,93 @@
|
||||
# 🚀 서버 정책 (Server Policy)
|
||||
|
||||
**서버 구동 시 반드시 아래 명령어를 사용한다:**
|
||||
```bash
|
||||
uvicorn server:app --host 0.0.0.0 --port 8000 --reload
|
||||
```
|
||||
- **Host**: `0.0.0.0` (외부 접속 허용)
|
||||
- **Port**: `8000`
|
||||
- **Reload**: 코드 수정 시 자동 재시작 활성화
|
||||
|
||||
---
|
||||
|
||||
# 🤖 메일시스템 AI판단가이드 (AI Reasoning Guide)
|
||||
|
||||
AI는 파일을 분류할 때 단순한 키워드 매칭이 아닌, 아래의 **5단계 통합 추론 모델**을 사용하여 '실무자처럼' 생각하고 판단한다.
|
||||
|
||||
### 1단계: 전수 데이터 수집 (Holistic Reading)
|
||||
- **무제한 스캔**: 페이지 수에 관계없이 문서 전체를 전수 조사한다.
|
||||
- **무조건적 OCR**: 디지털 텍스트 유무와 상관없이 모든 페이지에 고해상도(300 DPI) OCR을 실행하여 이미지 속 도장, 수기, 표 데이터까지 완벽히 수집한다.
|
||||
|
||||
### 2단계: 파일명 가중치 적용 (Title Steering)
|
||||
- **파일명 = 보관 의도**: 사용자가 지은 파일명은 분류의 가장 강력한 '방향타'이다.
|
||||
- **최종 조율**: 본문의 데이터가 다른 도메인에 쏠려 있더라도, 파일명에 명확한 업무 용어(`실정보고`, `하도급` 등)가 있다면 이를 최종 분류의 가장 큰 무게추로 삼는다.
|
||||
|
||||
### 3단계: 문서의 물리적 틀(Format) 분석
|
||||
- **공문 골격 확인**: 문서의 시작(`수신/발신`)과 끝(`직인/끝.`)의 구조를 확인한다.
|
||||
- **껍데기 vs 알맹이**:
|
||||
- **공문 본체**: 골격이 완벽하고 뒤따르는 기술 데이터가 적은 경우 → **[공사관리 > 공문]**
|
||||
- **첨부 본체**: 공문 뒤에 대량의 산출서, 계약서, 도면이 붙어 있는 경우 → **[해당 기술 카테고리]** (공문은 전달 수단으로만 간주)
|
||||
|
||||
### 4단계: 비즈니스 도메인 상식 결합 (Common Sense)
|
||||
- **지명 교차 검증**: 파일명과 본문의 지명(어천, 공주, 대술, 정안 등)을 대조하여 정확한 프로젝트를 선택한다. (임의 기본값 지정 금지)
|
||||
- **실무 맥락 매칭**: '임대료/연장'은 사업비 성격의 '기타'로, '비계'는 '구조물'로 연결하는 등 건설 실무 상식을 추론에 반영한다.
|
||||
|
||||
### 5단계: 최종 지도 매칭 (Hierarchy Mapping)
|
||||
- 수집된 모든 정보를 종합하여 사용자가 정의한 **표준 분류 체계(Tab > Category > Sub)** 지도 위에서 가장 논리적이고 실무적인 위치를 최종 확정한다.
|
||||
|
||||
---
|
||||
|
||||
# 🛠️ 개발 및 관리 규칙 (Strict Development Rules)
|
||||
|
||||
1. **언어 설정**: 영어로 생각하되, 모든 답변은 **한국어**로 작성한다.
|
||||
2. **임의 수정 절대 금지 (Zero-Arbitrary Change)**:
|
||||
- 사용자가 명시적으로 지시한 부분 외에는 **단 한 줄의 코드도, 그 어떤 파일도 임의로 수정, 정리, 리팩토링하지 않는다.**
|
||||
- 지시받지 않은 다른 파트의 코드는 절대 건드리지 않으며, 영향 범위가 요청 범위를 벗어나지 않도록 '외과 수술식(Surgical) 수정'을 원칙으로 한다.
|
||||
3. **개선 작업 절차 (Test-First Approach)**:
|
||||
- 사용자가 개선(Refactoring, Optimization 등)을 지시한 경우, **수정 전 현재 시스템이 정상적으로 잘 작동하는지 먼저 전수 확인**한다.
|
||||
- 기존 동작 방식과 성능을 기준(Baseline)으로 삼고, 수정 후에도 **기존의 모든 기능이 무결하게 유지되는지 반드시 테스트하여 입증**한다.
|
||||
- 검증 결과를 바탕으로 "무엇을, 왜, 어떻게" 바꿀지 상세 보고 후, 사용자로부터 **'진행시켜'** 승인을 얻은 뒤에만 집행한다.
|
||||
4. **선보고 후승인**: 모든 기능 수정 및 코드 변경 전에는 예상 방안을 먼저 보고하고 승인 절차를 거친다.
|
||||
5. **로그 기록 철저**: 진행 상황(로그인, 수집, 오류 등)을 실시간 로그에 상세히 표시하여 투명성을 확보한다.
|
||||
|
||||
---
|
||||
|
||||
## 🎨 디자인 가이드 (Design System)
|
||||
|
||||
이 프로젝트는 `tokens.json`에 정의된 디자인 시스템을 준수합니다.
|
||||
|
||||
### 1. 컬러 시스템 (Colors)
|
||||
- **Primary**: `#1E5149` (primary-lv-6) - 브랜드 핵심 컬러
|
||||
- **Background**: `#FFFFFF` (Light Default) / `#F9FAFB` (Light Muted)
|
||||
- **Point Colors**:
|
||||
- Blue: `#0D8DF2` (Info)
|
||||
- Green: `#4DB251` (Success)
|
||||
- Red: `#F21D0D` (Error)
|
||||
- Yellow: `#FFBF00` (Warning)
|
||||
- **Special**: `ai_color` (Purple-Blue Gradient) - AI 관련 요소 전용
|
||||
|
||||
### 2. 타이포그래피 (Typography)
|
||||
- **Font Family**: `Pretendard`, `sans-serif`
|
||||
- **Scale**:
|
||||
- **H1**: 20px / ExtraBold (pretendard-0)
|
||||
- **H2**: 16px / SemiBold (pretendard-1)
|
||||
- **H3/H4**: 14px / SemiBold or Regular
|
||||
- **Body/P**: 12px / Regular (pretendard-2)
|
||||
|
||||
### 3. 레이아웃 및 간격 (Dimensions)
|
||||
- **Spacing Unit**: Base 4px (xs: 4px, sm: 8px, md: 16px, lg: 32px, xl: 64px)
|
||||
- **Border Radius**: sm: 4px, lg: 8px, xl: 16px
|
||||
- **Shadow**: `0 8px 24px rgba(0,0,0,0.16)` (box__drop-shadow)
|
||||
|
||||
### 4. 컴포넌트 규칙
|
||||
- **Buttons**: `borderRadius.lg (8px)` 적용, Primary 배경색 사용
|
||||
- **Cards**: `borderRadius.lg (8px)` 적용, Subtle Shadow 활용
|
||||
- **Topbar**: Height 36px, `headercolor` 그라데이션 적용 가능
|
||||
|
||||
# 🚀 서버 정책 (Server Policy)
|
||||
|
||||
**서버 구동 시 반드시 아래 Docker 기반 명령어를 사용하여 컨테이너를 가동한다:**
|
||||
|
||||
* **우분투(WSL2) 환경에서 구동 시:**
|
||||
```bash
|
||||
docker compose up --build -d
|
||||
```
|
||||
|
||||
* **윈도우 호스트(PowerShell/cmd) 환경에서 원격 구동 시:**
|
||||
```powershell
|
||||
wsl -u root -d Ubuntu bash -c "cd '/mnt/d/이태훈/19ProjectMaster/AICodeTest' && docker compose up --build -d"
|
||||
```
|
||||
|
||||
- **접속 포트**: `8000` (포트포워딩 설정 완료 시 호스트 IP `172.16.8.151:8000`으로 외부 접근 가능)
|
||||
- **실시간 반영**: 소스 코드 수정 시 볼륨 바인딩을 통해 컨테이너 서버에 실시간으로 즉시 동기화됨
|
||||
|
||||
---
|
||||
|
||||
# 🤖 메일시스템 AI판단가이드 (AI Reasoning Guide)
|
||||
|
||||
AI는 파일을 분류할 때 단순한 키워드 매칭이 아닌, 아래의 **5단계 통합 추론 모델**을 사용하여 '실무자처럼' 생각하고 판단한다.
|
||||
|
||||
### 1단계: 전수 데이터 수집 (Holistic Reading)
|
||||
- **무제한 스캔**: 페이지 수에 관계없이 문서 전체를 전수 조사한다.
|
||||
- **무조건적 OCR**: 디지털 텍스트 유무와 상관없이 모든 페이지에 고해상도(300 DPI) OCR을 실행하여 이미지 속 도장, 수기, 표 데이터까지 완벽히 수집한다.
|
||||
|
||||
### 2단계: 파일명 가중치 적용 (Title Steering)
|
||||
- **파일명 = 보관 의도**: 사용자가 지은 파일명은 분류의 가장 강력한 '방향타'이다.
|
||||
- **최종 조율**: 본문의 데이터가 다른 도메인에 쏠려 있더라도, 파일명에 명확한 업무 용어(`실정보고`, `하도급` 등)가 있다면 이를 최종 분류의 가장 큰 무게추로 삼는다.
|
||||
|
||||
### 3단계: 문서의 물리적 틀(Format) 분석
|
||||
- **공문 골격 확인**: 문서의 시작(`수신/발신`)과 끝(`직인/끝.`)의 구조를 확인한다.
|
||||
- **껍데기 vs 알맹이**:
|
||||
- **공문 본체**: 골격이 완벽하고 뒤따르는 기술 데이터가 적은 경우 → **[공사관리 > 공문]**
|
||||
- **첨부 본체**: 공문 뒤에 대량의 산출서, 계약서, 도면이 붙어 있는 경우 → **[해당 기술 카테고리]** (공문은 전달 수단으로만 간주)
|
||||
|
||||
### 4단계: 비즈니스 도메인 상식 결합 (Common Sense)
|
||||
- **지명 교차 검증**: 파일명과 본문의 지명(어천, 공주, 대술, 정안 등)을 대조하여 정확한 프로젝트를 선택한다. (임의 기본값 지정 금지)
|
||||
- **실무 맥락 매칭**: '임대료/연장'은 사업비 성격의 '기타'로, '비계'는 '구조물'로 연결하는 등 건설 실무 상식을 추론에 반영한다.
|
||||
|
||||
### 5단계: 최종 지도 매칭 (Hierarchy Mapping)
|
||||
- 수집된 모든 정보를 종합하여 사용자가 정의한 **표준 분류 체계(Tab > Category > Sub)** 지도 위에서 가장 논리적이고 실무적인 위치를 최종 확정한다.
|
||||
|
||||
---
|
||||
|
||||
# 🛠️ 개발 및 관리 규칙 (Strict Development Rules)
|
||||
|
||||
1. **언어 설정**: 영어로 생각하되, 모든 답변은 **한국어**로 작성한다.
|
||||
2. **임의 수정 절대 금지 (Zero-Arbitrary Change)**:
|
||||
- 사용자가 명시적으로 지시한 부분 외에는 **단 한 줄의 코드도, 그 어떤 파일도 임의로 수정, 정리, 리팩토링하지 않는다.**
|
||||
- 지시받지 않은 다른 파트의 코드는 절대 건드리지 않으며, 영향 범위가 요청 범위를 벗어나지 않도록 '외과 수술식(Surgical) 수정'을 원칙으로 한다.
|
||||
3. **개선 작업 절차 (Test-First Approach)**:
|
||||
- 사용자가 개선(Refactoring, Optimization 등)을 지시한 경우, **수정 전 현재 시스템이 정상적으로 잘 작동하는지 먼저 전수 확인**한다.
|
||||
- 기존 동작 방식과 성능을 기준(Baseline)으로 삼고, 수정 후에도 **기존의 모든 기능이 무결하게 유지되는지 반드시 테스트하여 입증**한다.
|
||||
- 검증 결과를 바탕으로 "무엇을, 왜, 어떻게" 바꿀지 상세 보고 후, 사용자로부터 **'진행시켜'** 승인을 얻은 뒤에만 집행한다.
|
||||
4. **선보고 후승인**: 모든 기능 수정 및 코드 변경 전에는 예상 방안을 먼저 보고하고 승인 절차를 거친다.
|
||||
5. **로그 기록 철저**: 진행 상황(로그인, 수집, 오류 등)을 실시간 로그에 상세히 표시하여 투명성을 확보한다.
|
||||
|
||||
---
|
||||
|
||||
## 🎨 디자인 가이드 (Design System)
|
||||
|
||||
이 프로젝트는 `tokens.json`에 정의된 디자인 시스템을 준수합니다.
|
||||
|
||||
### 1. 컬러 시스템 (Colors)
|
||||
- **Primary**: `#1E5149` (primary-lv-6) - 브랜드 핵심 컬러
|
||||
- **Background**: `#FFFFFF` (Light Default) / `#F9FAFB` (Light Muted)
|
||||
- **Point Colors**:
|
||||
- Blue: `#0D8DF2` (Info)
|
||||
- Green: `#4DB251` (Success)
|
||||
- Red: `#F21D0D` (Error)
|
||||
- Yellow: `#FFBF00` (Warning)
|
||||
- **Special**: `ai_color` (Purple-Blue Gradient) - AI 관련 요소 전용
|
||||
|
||||
### 2. 타이포그래피 (Typography)
|
||||
- **Font Family**: `Pretendard`, `sans-serif`
|
||||
- **Scale**:
|
||||
- **H1**: 20px / ExtraBold (pretendard-0)
|
||||
- **H2**: 16px / SemiBold (pretendard-1)
|
||||
- **H3/H4**: 14px / SemiBold or Regular
|
||||
- **Body/P**: 12px / Regular (pretendard-2)
|
||||
|
||||
### 3. 레이아웃 및 간격 (Dimensions)
|
||||
- **Spacing Unit**: Base 4px (xs: 4px, sm: 8px, md: 16px, lg: 32px, xl: 64px)
|
||||
- **Border Radius**: sm: 4px, lg: 8px, xl: 16px
|
||||
- **Shadow**: `0 8px 24px rgba(0,0,0,0.16)` (box__drop-shadow)
|
||||
|
||||
### 4. 컴포넌트 규칙
|
||||
- **Buttons**: `borderRadius.lg (8px)` 적용, Primary 배경색 사용
|
||||
- **Cards**: `borderRadius.lg (8px)` 적용, Subtle Shadow 활용
|
||||
- **Topbar**: Height 36px, `headercolor` 그라데이션 적용 가능
|
||||
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,198 +1,270 @@
|
||||
import re
|
||||
import math
|
||||
import statistics
|
||||
from datetime import datetime, timedelta
|
||||
from sql_queries import DashboardQueries
|
||||
from prediction_service import SOIPredictionService
|
||||
|
||||
class AnalysisService:
|
||||
"""프로젝트 통계 및 활동성 분석 전문 서비스"""
|
||||
|
||||
@staticmethod
|
||||
def calculate_operational_consistency(history_rows, days_stagnant):
|
||||
"""운영 일관성 지수(OCI) 산출 로직 (장기 정체 패널티 포함)
|
||||
최근 30일간 활동 리듬 분석 + 현재 방치 기간에 따른 강력한 감쇄
|
||||
"""
|
||||
if not history_rows or len(history_rows) < 2:
|
||||
return 0.0
|
||||
|
||||
# 1. 최근 30일 이력 기반 Base Score 산출
|
||||
now = datetime.now().date()
|
||||
recent_30 = [h for h in history_rows if (now - h['crawl_date']).days <= 30]
|
||||
|
||||
# 주차별 활동 여부 (4주)
|
||||
weeks_active = [False, False, False, False]
|
||||
for h in recent_30:
|
||||
days_ago = (now - h['crawl_date']).days
|
||||
week_idx = min(3, days_ago // 7)
|
||||
weeks_active[week_idx] = True
|
||||
|
||||
base_consistency = (sum(weeks_active) / 4) * 70
|
||||
|
||||
# 활동 밀도 (변화 발생일 비율)
|
||||
effort_days = 0
|
||||
for i in range(1, len(recent_30)):
|
||||
if recent_30[i]['file_count'] != recent_30[i-1]['file_count']:
|
||||
effort_days += 1
|
||||
|
||||
density_score = (effort_days / max(1, len(recent_30))) * 30
|
||||
base_oci = base_consistency + density_score
|
||||
|
||||
# 2. [핵심] 장기 정체 패널티 적용
|
||||
# 방치일이 100일 이상이면 OCI는 0점으로 수렴 (성실도 무효화)
|
||||
stagnation_factor = max(0, (100 - days_stagnant) / 100.0)
|
||||
|
||||
final_oci = base_oci * stagnation_factor
|
||||
|
||||
return round(final_oci, 1)
|
||||
|
||||
@staticmethod
|
||||
def calculate_activity_status(target_date_dt, log, file_count):
|
||||
"""개별 프로젝트의 활동 상태 및 방치일 산출"""
|
||||
status, days = "unknown", 999
|
||||
file_val = int(file_count) if file_count else 0
|
||||
has_log = log and log != "데이터 없음" and log != "X"
|
||||
|
||||
if file_val == 0:
|
||||
status = "unknown"
|
||||
elif has_log:
|
||||
if "폴더자동삭제" in log.replace(" ", ""):
|
||||
status = "stale"
|
||||
days = 999
|
||||
else:
|
||||
match = re.search(r'(\d{4})\.(\d{2})\.(\d{2})', log)
|
||||
if match:
|
||||
log_date = datetime.strptime(match.group(0), "%Y.%m.%d")
|
||||
diff = (target_date_dt - log_date).days
|
||||
status = "active" if diff <= 7 else "warning" if diff <= 14 else "stale"
|
||||
days = diff
|
||||
else:
|
||||
status = "stale"
|
||||
else:
|
||||
status = "stale"
|
||||
|
||||
return status, days
|
||||
|
||||
@staticmethod
|
||||
def get_project_activity_logic(cursor, date_str):
|
||||
"""활동도 분석 리포트 생성 로직"""
|
||||
if not date_str or date_str == "-":
|
||||
cursor.execute(DashboardQueries.GET_LAST_CRAWL_DATE)
|
||||
res = cursor.fetchone()
|
||||
target_date_val = res['last_date'] if res['last_date'] else datetime.now().date()
|
||||
else:
|
||||
target_date_val = datetime.strptime(date_str.replace(".", "-"), "%Y-%m-%d").date()
|
||||
|
||||
target_date_dt = datetime.combine(target_date_val, datetime.min.time())
|
||||
cursor.execute(DashboardQueries.GET_PROJECT_LIST_FOR_ANALYSIS, (target_date_val,))
|
||||
rows = cursor.fetchall()
|
||||
|
||||
analysis = {"summary": {"active": 0, "warning": 0, "stale": 0, "unknown": 0}, "details": []}
|
||||
for r in rows:
|
||||
status, days = AnalysisService.calculate_activity_status(target_date_dt, r['recent_log'], r['file_count'])
|
||||
analysis["summary"][status] += 1
|
||||
analysis["details"].append({"name": r['short_nm'] or r['project_nm'], "status": status, "days_ago": days})
|
||||
|
||||
return analysis
|
||||
|
||||
@staticmethod
|
||||
def get_p_zsr_analysis_logic(cursor):
|
||||
"""절대적 방치 실태 고발 및 운영 일관성(OCI) 분석 로직"""
|
||||
cursor.execute(DashboardQueries.GET_LAST_CRAWL_DATE)
|
||||
res_date = cursor.fetchone()
|
||||
if not res_date or not res_date['last_date']:
|
||||
return []
|
||||
last_date = res_date['last_date']
|
||||
|
||||
cursor.execute("""
|
||||
SELECT m.project_id, m.project_nm, m.short_nm, m.department, m.master,
|
||||
h.recent_log, h.file_count, m.continent, m.country
|
||||
FROM projects_master m
|
||||
LEFT JOIN projects_history h ON m.project_id = h.project_id AND h.crawl_date = %s
|
||||
ORDER BY m.project_id ASC
|
||||
""", (last_date,))
|
||||
projects = cursor.fetchall()
|
||||
|
||||
if not projects: return []
|
||||
|
||||
results = []
|
||||
total_soi = 0
|
||||
|
||||
for p in projects:
|
||||
file_count = int(p['file_count']) if p['file_count'] else 0
|
||||
log = p['recent_log']
|
||||
|
||||
# 방치일 계산
|
||||
days_stagnant = 14
|
||||
if log and log != "데이터 없음":
|
||||
match = re.search(r'(\d{4})\.(\d{2})\.(\d{2})', log)
|
||||
if match:
|
||||
log_date = datetime.strptime(match.group(0), "%Y.%m.%d").date()
|
||||
days_stagnant = (last_date - log_date).days
|
||||
|
||||
is_auto_delete = log and "폴더자동삭제" in log.replace(" ", "")
|
||||
|
||||
# AI-Hazard 추론 로직 (Dynamic Lambda)
|
||||
scale_impact = min(0.04, math.log10(file_count + 1) * 0.008) if file_count > 0 else 0
|
||||
ai_lambda = 0.04 + scale_impact
|
||||
|
||||
# 지수 감쇄 적용
|
||||
soi_score = math.exp(-ai_lambda * days_stagnant) * 100
|
||||
|
||||
# ECV 패널티
|
||||
existence_confidence = 1.0
|
||||
if file_count == 0: existence_confidence = 0.05
|
||||
elif file_count < 10: existence_confidence = 0.4
|
||||
|
||||
# Log Quality Scoring
|
||||
log_quality_factor = 1.0
|
||||
if log and log != "데이터 없음":
|
||||
if any(k in log for k in ["업로드", "수정", "등록", "변환", "파일", "업데이트"]): log_quality_factor = 1.0
|
||||
elif any(k in log for k in ["폴더", "생성", "삭제", "이동"]): log_quality_factor = 0.7
|
||||
elif any(k in log for k in ["참가자", "권한", "추가", "변경", "메일"]): log_quality_factor = 0.4
|
||||
else: log_quality_factor = 0.6
|
||||
|
||||
soi_score = soi_score * existence_confidence * log_quality_factor
|
||||
if is_auto_delete: soi_score = 0.1
|
||||
|
||||
# [운영 일관성 분석 (OCI)]
|
||||
history_rows = SOIPredictionService.get_historical_soi(cursor, p['project_id'])
|
||||
oci_score = AnalysisService.calculate_operational_consistency(history_rows, days_stagnant)
|
||||
|
||||
# 실무 투입 에너지 계산
|
||||
effort_days = 0
|
||||
if len(history_rows) > 1:
|
||||
for i in range(1, len(history_rows)):
|
||||
if history_rows[i]['file_count'] != history_rows[i-1]['file_count']:
|
||||
effort_days += 1
|
||||
|
||||
work_effort_rate = round((effort_days / max(1, len(history_rows))) * 100, 1)
|
||||
total_soi += soi_score
|
||||
|
||||
# VCI 산출
|
||||
REPLACEMENT_LEVEL = 70.0
|
||||
asset_weight = (file_count / 200.0) + 0.5
|
||||
p_war_score = (soi_score - REPLACEMENT_LEVEL) * asset_weight
|
||||
|
||||
results.append({
|
||||
"project_nm": p['short_nm'] or p['project_nm'],
|
||||
"file_count": file_count,
|
||||
"days_stagnant": days_stagnant,
|
||||
"risk_count": round(p_war_score, 2),
|
||||
"p_war": round(soi_score, 1),
|
||||
"oci_score": oci_score, # 운영 일관성 지수 추가
|
||||
"is_auto_delete": is_auto_delete,
|
||||
"master": p['master'],
|
||||
"dept": p['department'],
|
||||
"ai_lambda": round(ai_lambda, 4),
|
||||
"log_quality": log_quality_factor,
|
||||
"work_effort": work_effort_rate,
|
||||
"avg_info": {
|
||||
"avg_files": 0,
|
||||
"avg_stagnant": 0,
|
||||
"avg_risk": round(total_soi / len(projects), 1)
|
||||
}
|
||||
})
|
||||
|
||||
results.sort(key=lambda x: x['p_war'])
|
||||
return results
|
||||
import re
|
||||
import math
|
||||
import statistics
|
||||
from datetime import datetime, timedelta
|
||||
from sql_queries import DashboardQueries
|
||||
from prediction_service import SOIPredictionService
|
||||
|
||||
class AnalysisService:
|
||||
"""프로젝트 통계 및 활동성 분석 전문 서비스"""
|
||||
|
||||
@staticmethod
|
||||
def calculate_operational_consistency(history_rows, days_stagnant):
|
||||
"""운영 일관성 지수(OCI) 산출 로직 (자산 규모 및 장기 정체 패널티 포함)
|
||||
최근 30일간 활동 리듬 분석 + 현재 방치 기간에 따른 강력한 감쇄
|
||||
"""
|
||||
if not history_rows or len(history_rows) < 2:
|
||||
return 0.0
|
||||
|
||||
# [추가] 최신 상태 확인: 현재 로그가 '폴더자동삭제'면 점수 즉시 0점 (일수는 실제 일수 유지)
|
||||
latest_log = history_rows[-1].get('recent_log', '') or ''
|
||||
if latest_log and "폴더자동삭제" in latest_log.replace(" ", ""):
|
||||
return 0.0
|
||||
|
||||
# 1. 최근 30일 이력 기반 Base Score 산출
|
||||
now = datetime.now().date()
|
||||
recent_30 = [h for h in history_rows if (now - h['crawl_date']).days <= 30]
|
||||
|
||||
if not recent_30:
|
||||
return 0.0
|
||||
|
||||
# [추가] 자산 규모 확인: 파일이 0개면 운영 일관성 산출 자체가 무의미함
|
||||
max_files = max([int(h['file_count'] or 0) for h in recent_30])
|
||||
if max_files == 0:
|
||||
return 0.0
|
||||
|
||||
# 주차별 활동 여부 (4주) - 파일이 1개 이상 존재할 때만 유효 활동으로 인정
|
||||
weeks_active = [False, False, False, False]
|
||||
for h in recent_30:
|
||||
if int(h['file_count'] or 0) > 0:
|
||||
days_ago = (now - h['crawl_date']).days
|
||||
week_idx = min(3, days_ago // 7)
|
||||
weeks_active[week_idx] = True
|
||||
|
||||
base_consistency = (sum(weeks_active) / 4) * 70
|
||||
|
||||
# 활동 밀도 (변화 발생일 비율)
|
||||
effort_days = 0
|
||||
for i in range(1, len(recent_30)):
|
||||
# '폴더자동삭제' 로그가 포함된 날의 변화는 관리 노력으로 인정하지 않음
|
||||
log_content = recent_30[i].get('recent_log', '') or ''
|
||||
if "폴더자동삭제" in log_content.replace(" ", ""):
|
||||
continue
|
||||
|
||||
if recent_30[i]['file_count'] != recent_30[i-1]['file_count']:
|
||||
effort_days += 1
|
||||
|
||||
density_score = (effort_days / max(1, len(recent_30))) * 30
|
||||
base_oci = base_consistency + density_score
|
||||
|
||||
# 2. [핵심] 패널티 엔진 적용
|
||||
# A. 장기 정체 패널티: 방치일이 100일 이상이면 0점으로 수렴
|
||||
stagnation_factor = max(0, (100 - days_stagnant) / 100.0)
|
||||
|
||||
# B. 자산 부족 패널티 (Existence Confidence): 파일이 너무 적으면 관리 신뢰도 하락
|
||||
# 10개 미만은 50%만 인정, 그 이상은 점진적으로 100%까지 회복
|
||||
asset_confidence = 1.0
|
||||
if max_files < 10:
|
||||
asset_confidence = 0.5
|
||||
elif max_files < 30:
|
||||
asset_confidence = 0.8
|
||||
|
||||
final_oci = base_oci * stagnation_factor * asset_confidence
|
||||
|
||||
return round(final_oci, 1)
|
||||
|
||||
@staticmethod
|
||||
def calculate_activity_status(target_date_dt, log, file_count):
|
||||
"""개별 프로젝트의 활동 상태 및 방치일 산출 (현재 시각 기준 실질 방치일 산출)"""
|
||||
status, days = "unknown", 999
|
||||
file_val = int(file_count) if file_count else 0
|
||||
has_log = log and log != "데이터 없음" and log != "X"
|
||||
|
||||
# 실질적인 오늘 날짜를 기준으로 정체일 산출 (사용자 직관성 강화)
|
||||
now_dt = datetime.now()
|
||||
|
||||
if file_val == 0:
|
||||
status = "unknown"
|
||||
elif has_log:
|
||||
is_auto = "폴더자동삭제" in log.replace(" ", "")
|
||||
# 2자리 또는 4자리 연도 지원 정규식
|
||||
match = re.search(r'(\d{2,4})\.(\d{2})\.(\d{2})', log)
|
||||
if match:
|
||||
y, m, d = match.groups()
|
||||
# 2자리 연도 보정
|
||||
if len(y) == 2: y = "20" + y
|
||||
log_date = datetime.strptime(f"{y}.{m}.{d}", "%Y.%m.%d")
|
||||
|
||||
# 수집일(target_date_dt)이 아닌 현재 시점(now_dt) 기준으로 차이 계산
|
||||
diff = (now_dt - log_date).days
|
||||
days = diff
|
||||
# 상태 판정은 수집 시점의 target_date_dt를 기준으로 할지 검토 필요하나,
|
||||
# 사용자 요청에 따라 '이상한 계산'을 바로잡기 위해 현재 시점 기준 판정 적용
|
||||
status = "stale" if is_auto or diff > 14 else "warning" if diff > 7 else "active"
|
||||
else:
|
||||
status = "stale"
|
||||
days = 999
|
||||
else:
|
||||
status = "stale"
|
||||
|
||||
return status, days
|
||||
|
||||
@staticmethod
|
||||
def get_project_activity_logic(cursor, date_str):
|
||||
"""활동도 분석 리포트 생성 로직"""
|
||||
if not date_str or date_str == "-":
|
||||
cursor.execute(DashboardQueries.GET_LAST_CRAWL_DATE)
|
||||
res = cursor.fetchone()
|
||||
target_date_val = res['last_date'] if res['last_date'] else datetime.now().date()
|
||||
else:
|
||||
target_date_val = datetime.strptime(date_str.replace(".", "-"), "%Y-%m-%d").date()
|
||||
|
||||
target_date_dt = datetime.combine(target_date_val, datetime.min.time())
|
||||
cursor.execute(DashboardQueries.GET_PROJECT_LIST_FOR_ANALYSIS, (target_date_val,))
|
||||
rows = cursor.fetchall()
|
||||
|
||||
analysis = {"summary": {"active": 0, "warning": 0, "stale": 0, "unknown": 0}, "details": []}
|
||||
for r in rows:
|
||||
status, days = AnalysisService.calculate_activity_status(target_date_dt, r['recent_log'], r['file_count'])
|
||||
analysis["summary"][status] += 1
|
||||
analysis["details"].append({"name": r['short_nm'] or r['project_nm'], "status": status, "days_ago": days})
|
||||
|
||||
return analysis
|
||||
|
||||
@staticmethod
|
||||
def get_p_zsr_analysis_logic(cursor):
|
||||
"""절대적 방치 실태 고발 및 운영 일관성(OCI) 분석 로직"""
|
||||
cursor.execute(DashboardQueries.GET_LAST_CRAWL_DATE)
|
||||
res_date = cursor.fetchone()
|
||||
if not res_date or not res_date['last_date']:
|
||||
return []
|
||||
last_date = res_date['last_date']
|
||||
|
||||
# 특정 날짜(last_date) 이하의 각 프로젝트별 최신 데이터를 조인하도록 수정
|
||||
cursor.execute("""
|
||||
SELECT m.project_id, m.project_nm, m.short_nm, m.department, m.master,
|
||||
h.recent_log, h.file_count, m.continent, m.country
|
||||
FROM projects_master m
|
||||
LEFT JOIN projects_history h ON h.project_id = m.project_id AND h.crawl_date = (
|
||||
SELECT MAX(crawl_date)
|
||||
FROM projects_history
|
||||
WHERE project_id = m.project_id AND crawl_date <= %s
|
||||
)
|
||||
ORDER BY m.project_id ASC
|
||||
""", (last_date,))
|
||||
projects = cursor.fetchall()
|
||||
|
||||
if not projects: return []
|
||||
|
||||
results = []
|
||||
total_avi = 0
|
||||
total_files = 0
|
||||
project_data_list = []
|
||||
|
||||
# 1차 Pass: 개별 AVI 산출 및 전체 합계 집계
|
||||
now_dt = datetime.now()
|
||||
for p in projects:
|
||||
file_count = int(p['file_count']) if p['file_count'] else 0
|
||||
log = p['recent_log']
|
||||
|
||||
# 방치일 계산 (현재 시각 기준 동기화)
|
||||
days_stagnant = 14
|
||||
is_auto_delete = log and "폴더자동삭제" in log.replace(" ", "")
|
||||
|
||||
if log and log != "데이터 없음":
|
||||
match = re.search(r'(\d{2,4})\.(\d{2})\.(\d{2})', log)
|
||||
if match:
|
||||
y, m, d = match.groups()
|
||||
if len(y) == 2: y = "20" + y
|
||||
log_date = datetime.strptime(f"{y}.{m}.{d}", "%Y.%m.%d")
|
||||
days_stagnant = (now_dt - log_date).days
|
||||
elif is_auto_delete:
|
||||
days_stagnant = 999
|
||||
|
||||
# AI-Hazard 추론 로직 (Dynamic Lambda)
|
||||
scale_impact = min(0.04, math.log10(file_count + 1) * 0.008) if file_count > 0 else 0
|
||||
ai_lambda = 0.04 + scale_impact
|
||||
|
||||
# 지수 감쇄 적용
|
||||
avi_score = math.exp(-ai_lambda * days_stagnant) * 100
|
||||
|
||||
# ECV 패널티
|
||||
existence_confidence = 1.0
|
||||
if file_count == 0: existence_confidence = 0.05
|
||||
elif file_count < 10: existence_confidence = 0.4
|
||||
|
||||
# Log Quality Scoring (SWVW 모델 적용)
|
||||
from log_scorer import LogScorer
|
||||
log_quality_factor = LogScorer.get_score(log)
|
||||
|
||||
avi_score = avi_score * existence_confidence * log_quality_factor
|
||||
if is_auto_delete: avi_score = 0.1
|
||||
|
||||
total_avi += avi_score
|
||||
total_files += file_count
|
||||
project_data_list.append({
|
||||
"p": p,
|
||||
"avi_score": avi_score,
|
||||
"file_count": file_count,
|
||||
"days_stagnant": days_stagnant,
|
||||
"is_auto_delete": is_auto_delete,
|
||||
"log_quality": log_quality_factor,
|
||||
"ai_lambda": ai_lambda
|
||||
})
|
||||
|
||||
# 2차 Pass: 평균 기반 가치기여도(WAR) 산출
|
||||
num_projects = len(projects) if projects else 1
|
||||
avg_avi = total_avi / num_projects
|
||||
avg_files = total_files / num_projects
|
||||
|
||||
for item in project_data_list:
|
||||
p = item['p']
|
||||
avi_score = item['avi_score']
|
||||
file_count = item['file_count']
|
||||
|
||||
# [운영 일관성 분석 (OCI)]
|
||||
history_rows = SOIPredictionService.get_historical_avi(cursor, p['project_id'])
|
||||
oci_score = AnalysisService.calculate_operational_consistency(history_rows, item['days_stagnant'])
|
||||
|
||||
# 실무 투입 에너지 계산
|
||||
effort_days = 0
|
||||
if len(history_rows) > 1:
|
||||
for i in range(1, len(history_rows)):
|
||||
if history_rows[i]['file_count'] != history_rows[i-1]['file_count']:
|
||||
effort_days += 1
|
||||
|
||||
work_effort_rate = round((effort_days / max(1, len(history_rows))) * 100, 1)
|
||||
|
||||
# [VCI 산출 - 로그 기반 상대 가중치 모델 (수정)]
|
||||
# 1. 파일 규모 가중치를 로그(log10) 기반으로 변경하여 선형 폭주 방지
|
||||
# 2. 평균 파일 수 대비 상대적 규모를 반영하되, 최대 가중치를 2.5로 캡핑(Capping)
|
||||
if avg_files > 0:
|
||||
relative_size = math.log10(file_count + 1) / math.log10(avg_files + 1)
|
||||
else:
|
||||
relative_size = 1.0
|
||||
|
||||
asset_weight = min(2.5, max(0.2, relative_size))
|
||||
p_war_score = (avi_score - avg_avi) * asset_weight
|
||||
|
||||
results.append({
|
||||
"project_nm": p['short_nm'] or p['project_nm'],
|
||||
"file_count": file_count,
|
||||
"days_stagnant": item['days_stagnant'],
|
||||
"risk_count": round(p_war_score, 2), # WAR 기반 가치기여도 (평균 0)
|
||||
"p_war": round(avi_score, 1),
|
||||
"oci_score": oci_score,
|
||||
"is_auto_delete": item['is_auto_delete'],
|
||||
"master": p['master'],
|
||||
"dept": p['department'],
|
||||
"ai_lambda": round(item['ai_lambda'], 4),
|
||||
"log_quality": item['log_quality'],
|
||||
"work_effort": work_effort_rate,
|
||||
"avg_info": {
|
||||
"avg_files": round(avg_files, 1),
|
||||
"avg_stagnant": 0,
|
||||
"avg_risk": round(avg_avi, 1)
|
||||
}
|
||||
})
|
||||
|
||||
results.sort(key=lambda x: x['p_war'])
|
||||
return results
|
||||
|
||||
342
analyze.py
342
analyze.py
@@ -1,167 +1,175 @@
|
||||
import os
|
||||
import re
|
||||
import unicodedata
|
||||
from pypdf import PdfReader
|
||||
import pytesseract
|
||||
from pdf2image import convert_from_path
|
||||
|
||||
# 1. 시스템 설정
|
||||
TESSERACT_EXE = r'C:\Users\User\AppData\Local\Programs\Tesseract-OCR\tesseract.exe'
|
||||
TESSDATA_DIR = r'C:\Users\User\AppData\Local\Programs\Tesseract-OCR\tessdata'
|
||||
POPPLER_BIN = r'D:\이태훈\00크롬다운로드\poppler-25.12.0\Library\bin'
|
||||
|
||||
pytesseract.pytesseract.tesseract_cmd = TESSERACT_EXE
|
||||
os.environ["TESSDATA_PREFIX"] = TESSDATA_DIR
|
||||
OCR_AVAILABLE = os.path.exists(TESSERACT_EXE)
|
||||
|
||||
SYSTEM_HIERARCHY = {
|
||||
"행정": {
|
||||
"계약": ["계약관리", "기성관리", "업무지시서", "인원관리"],
|
||||
"업무관리": ["업무일지(2025)", "업무일지(2025년 이전)", "발주처 정기보고", "본사업무보고", "공사감독일지", "양식서류"]
|
||||
},
|
||||
"설계성과품": {
|
||||
"시방서": ["공사시방서", "장비 반입허가 검토서"],
|
||||
"설계도면": ["공통", "토공", "비탈면안전공", "배수공", "교량공", "포장공", "교통안전시설공", "부대공", "용지공 & 기타공"],
|
||||
"수량산출서": ["토공", "비탈면안전공", "배수공", "교량공", "포장공", "교통안전시설공", "부대공", "용지공 & 기타공"],
|
||||
"내역서": ["단가산출서"],
|
||||
"보고서": ["실시설계보고서", "지반조사보고서", "구조계산서", "수리 및 전기계산서", "기타보고서", "기술자문 및 심의"],
|
||||
"측량계산부": ["측량계산부"],
|
||||
"설계단계 수행협의": ["회의·협의"]
|
||||
},
|
||||
"시공성과품": {
|
||||
"설계도면": ["공통", "토공", "비탈면안전공", "배수공", "교량공", "포장공", "교통안전시설공", "부대공", "용지공 & 기타공"]
|
||||
},
|
||||
"시공검측": {
|
||||
"토공": ["검측 (깨기)", "검측 (연약지반)", "검측 (발파)", "검측 (노체)", "검측 (노상)", "검측 (토취장)"],
|
||||
"배수공": ["검측 (V형측구)", "검측 (산마루측구)", "검측 (U형측구)", "검측 (U형측구)(안)", "검측 (L형측구, J형측구)", "검측 (도수로)", "검측 (도수로)(안)", "검측 (횡배수관)", "검측 (종배수관)", "검측 (맹암거)", "검측 (통로암거)", "검측 (수로암거)", "검측 (호안공)", "검측 (옹벽공)", "검측 (용수개거)"],
|
||||
"구조물공": ["검측 (평목교-거더, 부대공)", "검측 (평목교)(안)", "검측 (개착터널, 생태통로)"],
|
||||
"포장공": ["검측 (기층, 보조기층)"],
|
||||
"부대공": ["검측 (환경)", "검측 (지장가옥,건물 철거)", "검측 (방음벽 등)"],
|
||||
"비탈면안전공": ["검측 (식생보호공)", "검측 (구조물보호공)"],
|
||||
"교통안전시설공": ["검측 (낙석방지책)"],
|
||||
"검측 양식서류": ["검측 양식서류"]
|
||||
},
|
||||
"설계변경": {
|
||||
"실정보고(어천~공주)": ["토공", "배수공", "교량공(평목교)", "구조물공", "포장공", "교통안전공", "부대공", "전기공사", "미확정공", "안전관리", "환경관리", "품질관리", "자재관리", "지장물", "기타"],
|
||||
"실정보고(대술~정안)": ["토공", "배수공", "비탈면안전공", "포장공", "부대공", "안전관리", "환경관리", "자재관리", "기타"],
|
||||
"기술지원 검토": ["토공", "배수공", "교량공(평목교)", "구조물&부대공", "기타"],
|
||||
"시공계획(어천~공주)": ["토공", "배수공", "교량공(평목교)", "구조물&부대&포장&교통안전공", "환경 및 품질관리"]
|
||||
},
|
||||
"공사관리": {
|
||||
"공정·일정": ["공정표", "월간 공정보고", "작업일보"],
|
||||
"품질 관리": ["품질시험계획서", "품질시험 실적보고", "콘크리트 타설현황[어천~공주(4차)]", "품질관리비 사용내역", "균열관리", "품질관리 양식서류"],
|
||||
"안전 관리": ["안전관리계획서", "안전관리 실적보고", "위험성 평가", "사전작업허가서", "안전관리비 사용내역", "안전관리수준평가", "안전관리 양식서류"],
|
||||
"환경 관리": ["환경영향평가", "사전재해영향성검토", "유지관리 및 보수점검", "환경보전비 사용내역", "건설폐기물 관리"],
|
||||
"자재 관리 (관급)": ["자재구매요청 (레미콘, 철근)", "자재구매요청 (그 외)", "납품기한", "계약 변경", "자재 반입·수불 관리", "자재관리 양식서류"],
|
||||
"자재 관리 (사급)": ["자재공급원 승인", "자재 반입·수불 관리", "자재 검수·확인"],
|
||||
"점검 (정리중)": ["내부점검", "외부점검"],
|
||||
"공문": ["접수(수신)", "발송(발신)", "하도급", "인력", "방침"]
|
||||
},
|
||||
"민원관리": {
|
||||
"민원(어천~공주)": ["처리대장", "보상", "공사일반", "환경분쟁"],
|
||||
"실정보고(어천~공주)": ["민원"],
|
||||
"실정보고(대술~정안)": ["민원"]
|
||||
}
|
||||
}
|
||||
|
||||
def analyze_flow_reasoning(filename, all_text_list):
|
||||
"""
|
||||
본문의 전수 조사 결과에 파일명의 '의도 가중치'를 더해 최종 추론
|
||||
"""
|
||||
full_text = " ".join(all_text_list)
|
||||
clean_ctx = full_text.replace(" ", "").replace("\n", "").lower()
|
||||
fn_clean = filename.replace(" ", "").lower()
|
||||
|
||||
# 1. 도메인별 기본 점수 (본문 전수 조사 - 평등하게)
|
||||
scores = {
|
||||
"official": sum(clean_ctx.count(k) for k in ["수신:", "발신:", "경유:", "시행일자", "귀하", "드립니다", "바랍니다"]),
|
||||
"contract": sum(clean_ctx.count(k) for k in ["계약서", "하도급", "외주", "도급", "인감", "사업자"]),
|
||||
"hr": sum(clean_ctx.count(k) for k in ["이탈계", "인력", "기술자", "안전관리자", "재직증명", "배치"]),
|
||||
"change": sum(clean_ctx.count(k) for k in ["실정보고", "설계변경", "변경보고", "추가반영"]),
|
||||
"technical": sum(clean_ctx.count(k) for k in ["일위대가", "산출근거", "집계표", "물량산출", "단가", "내역", "도면", "dwg"])
|
||||
}
|
||||
|
||||
# 2. 파일명에 대한 '방향타' 가중치 부여 (Final Push)
|
||||
# 본문 데이터가 아무리 많아도 파일명의 의도를 존중하기 위해 7배 가중치
|
||||
if "실정" in fn_clean or "변경" in fn_clean: scores["change"] += 50 # 본문 50회 언급과 맞먹는 가중치
|
||||
if "계약" in fn_clean or "하도급" in fn_clean: scores["contract"] += 50
|
||||
if "인력" in fn_clean or "이탈" in fn_clean: scores["hr"] += 50
|
||||
if "단가" in fn_clean or "수량" in fn_clean or "도면" in fn_clean: scores["technical"] += 50
|
||||
if "제출" in fn_clean or "건" in fn_clean: scores["official"] += 30
|
||||
|
||||
# 3. 종합 농도에 따른 최종 도메인 선정
|
||||
dominant_domain = max(scores, key=scores.get)
|
||||
|
||||
# 프로젝트 식별 (Fuzzy 매칭 및 교차 검증)
|
||||
project_loc = "어천~공주" if any(k in clean_ctx or k in fn_clean for k in ["어천", "공주"]) else "대술~정안" if any(k in clean_ctx or k in fn_clean for k in ["대술", "정안"]) else "공통"
|
||||
|
||||
# --- [통합 추론 및 매칭] ---
|
||||
|
||||
# 시나리오 A: 실정보고/설계변경 (본문 데이터 + 파일명 의도 합성)
|
||||
if dominant_domain == "change" or (scores["change"] > 0 and scores["technical"] > 5):
|
||||
cat = f"실정보고({project_loc})"
|
||||
sub = "지장물" if any(k in clean_ctx for k in ["임대료", "토지", "보상"]) else "구조물공" if "구조물" in clean_ctx else "기타"
|
||||
return f"설계변경 > {cat} > {sub}", f"본문의 기술 데이터 밀도와 파일명의 '{dominant_domain}' 관련 의도를 종합하여 {project_loc} 프로젝트의 실정보고 본체로 판정."
|
||||
|
||||
# 시나리오 B: 행정 계약/하도급 (본체 중심)
|
||||
if dominant_domain == "contract":
|
||||
return "행정 > 계약 > 계약관리", "문서 전체에서 계약 및 하도급 업무 본질이 지배적으로 확인됨."
|
||||
|
||||
# 시나리오 C: 인사/인력 관리
|
||||
if dominant_domain == "hr":
|
||||
if len(all_text_list) <= 2: return "공사관리 > 공문 > 인력", "인력 사항을 간략히 보고하는 공문 형식임."
|
||||
return "행정 > 계약 > 인원관리", "다량의 인력 증빙 데이터가 포함된 행정 서류임."
|
||||
|
||||
# 시나리오 D: 순수 공문 (형식 우선)
|
||||
if dominant_domain == "official" or scores["official"] > scores["technical"]:
|
||||
tab, cat = "공사관리", "공문"
|
||||
sub = "접수(수신)"
|
||||
if "방침" in clean_ctx or "지침" in clean_ctx: sub = "방침"
|
||||
elif "발신" in clean_ctx[:500]: sub = "발송(발신)"
|
||||
return f"{tab} > {cat} > {sub}", "전체 맥락상 기술적 데이터보다 행정적 전달 행위(공문)가 핵심 정체성으로 판단됨."
|
||||
|
||||
# 시나리오 E: 기술 성과품
|
||||
if dominant_domain == "technical":
|
||||
if any(k in clean_ctx or k in fn_clean for k in ["단가", "내역"]): return "설계성과품 > 내역서 > 단가산출서", "내역/단가 산출 기술 데이터 확인."
|
||||
if any(k in clean_ctx or k in fn_clean for k in ["도면", "dwg"]): return "설계성과품 > 설계도면 > 공통", "도면/그래픽 데이터 확인."
|
||||
return "설계성과품 > 수량산출서 > 토공", "수량/물량 산출 데이터 확인."
|
||||
|
||||
return "행정 > 업무관리 > 양식서류", "일반 행정 및 기타 양식 서류로 분류함."
|
||||
|
||||
def analyze_file_content(filename: str):
|
||||
try:
|
||||
file_path = os.path.join("sample", filename)
|
||||
text_by_pages = []
|
||||
if filename.lower().endswith(".pdf"):
|
||||
reader = PdfReader(file_path)
|
||||
for i in range(len(reader.pages)):
|
||||
page_text = reader.pages[i].extract_text() or ""
|
||||
if OCR_AVAILABLE:
|
||||
try:
|
||||
images = convert_from_path(file_path, first_page=i+1, last_page=i+1, poppler_path=POPPLER_BIN, dpi=200)
|
||||
if images:
|
||||
ocr_result = pytesseract.image_to_string(images[0], lang='kor+eng')
|
||||
page_text += "\n" + ocr_result
|
||||
except Exception as ocr_err:
|
||||
print(f"OCR Error on page {i+1}: {ocr_err}")
|
||||
text_by_pages.append(page_text)
|
||||
elif filename.lower().endswith(('.xlsx', '.xls')):
|
||||
import pandas as pd
|
||||
df = pd.read_excel(file_path)
|
||||
text_by_pages.append(df.to_string())
|
||||
else: text_by_pages.append("")
|
||||
|
||||
path, reason = analyze_flow_reasoning(filename, text_by_pages)
|
||||
|
||||
return {
|
||||
"filename": filename,
|
||||
"total_pages": len(text_by_pages),
|
||||
"final_result": {
|
||||
"suggested_path": path,
|
||||
"confidence": "100%",
|
||||
"reason": reason,
|
||||
"snippet": " ".join(text_by_pages)[:1500]
|
||||
}
|
||||
}
|
||||
except Exception as e:
|
||||
return {"error": str(e), "filename": filename}
|
||||
import os
|
||||
import re
|
||||
import unicodedata
|
||||
from pypdf import PdfReader
|
||||
import pytesseract
|
||||
from pdf2image import convert_from_path
|
||||
|
||||
# 1. 시스템 설정
|
||||
# OS에 따른 경로 설정 (Docker/Linux 환경 대응)
|
||||
if os.name == 'posix': # Linux (Docker)
|
||||
TESSERACT_EXE = 'tesseract'
|
||||
TESSDATA_DIR = '/usr/share/tesseract-ocr/4.00/tessdata' # 일반적인 리눅스 경로
|
||||
# 리눅스에서는 보통 패스에 있으므로 별도 설정 불필요할 수 있음
|
||||
pytesseract.pytesseract.tesseract_cmd = TESSERACT_EXE
|
||||
POPPLER_BIN = None
|
||||
OCR_AVAILABLE = True # Dockerfile에서 설치함
|
||||
else: # Windows
|
||||
TESSERACT_EXE = r'C:\Users\User\AppData\Local\Programs\Tesseract-OCR\tesseract.exe'
|
||||
TESSDATA_DIR = r'C:\Users\User\AppData\Local\Programs\Tesseract-OCR\tessdata'
|
||||
POPPLER_BIN = r'D:\이태훈\00크롬다운로드\poppler-25.12.0\Library\bin'
|
||||
pytesseract.pytesseract.tesseract_cmd = TESSERACT_EXE
|
||||
os.environ["TESSDATA_PREFIX"] = TESSDATA_DIR
|
||||
OCR_AVAILABLE = os.path.exists(TESSERACT_EXE)
|
||||
|
||||
SYSTEM_HIERARCHY = {
|
||||
"행정": {
|
||||
"계약": ["계약관리", "기성관리", "업무지시서", "인원관리"],
|
||||
"업무관리": ["업무일지(2025)", "업무일지(2025년 이전)", "발주처 정기보고", "본사업무보고", "공사감독일지", "양식서류"]
|
||||
},
|
||||
"설계성과품": {
|
||||
"시방서": ["공사시방서", "장비 반입허가 검토서"],
|
||||
"설계도면": ["공통", "토공", "비탈면안전공", "배수공", "교량공", "포장공", "교통안전시설공", "부대공", "용지공 & 기타공"],
|
||||
"수량산출서": ["토공", "비탈면안전공", "배수공", "교량공", "포장공", "교통안전시설공", "부대공", "용지공 & 기타공"],
|
||||
"내역서": ["단가산출서"],
|
||||
"보고서": ["실시설계보고서", "지반조사보고서", "구조계산서", "수리 및 전기계산서", "기타보고서", "기술자문 및 심의"],
|
||||
"측량계산부": ["측량계산부"],
|
||||
"설계단계 수행협의": ["회의·협의"]
|
||||
},
|
||||
"시공성과품": {
|
||||
"설계도면": ["공통", "토공", "비탈면안전공", "배수공", "교량공", "포장공", "교통안전시설공", "부대공", "용지공 & 기타공"]
|
||||
},
|
||||
"시공검측": {
|
||||
"토공": ["검측 (깨기)", "검측 (연약지반)", "검측 (발파)", "검측 (노체)", "검측 (노상)", "검측 (토취장)"],
|
||||
"배수공": ["검측 (V형측구)", "검측 (산마루측구)", "검측 (U형측구)", "검측 (U형측구)(안)", "검측 (L형측구, J형측구)", "검측 (도수로)", "검측 (도수로)(안)", "검측 (횡배수관)", "검측 (종배수관)", "검측 (맹암거)", "검측 (통로암거)", "검측 (수로암거)", "검측 (호안공)", "검측 (옹벽공)", "검측 (용수개거)"],
|
||||
"구조물공": ["검측 (평목교-거더, 부대공)", "검측 (평목교)(안)", "검측 (개착터널, 생태통로)"],
|
||||
"포장공": ["검측 (기층, 보조기층)"],
|
||||
"부대공": ["검측 (환경)", "검측 (지장가옥,건물 철거)", "검측 (방음벽 등)"],
|
||||
"비탈면안전공": ["검측 (식생보호공)", "검측 (구조물보호공)"],
|
||||
"교통안전시설공": ["검측 (낙석방지책)"],
|
||||
"검측 양식서류": ["검측 양식서류"]
|
||||
},
|
||||
"설계변경": {
|
||||
"실정보고(어천~공주)": ["토공", "배수공", "교량공(평목교)", "구조물공", "포장공", "교통안전공", "부대공", "전기공사", "미확정공", "안전관리", "환경관리", "품질관리", "자재관리", "지장물", "기타"],
|
||||
"실정보고(대술~정안)": ["토공", "배수공", "비탈면안전공", "포장공", "부대공", "안전관리", "환경관리", "자재관리", "기타"],
|
||||
"기술지원 검토": ["토공", "배수공", "교량공(평목교)", "구조물&부대공", "기타"],
|
||||
"시공계획(어천~공주)": ["토공", "배수공", "교량공(평목교)", "구조물&부대&포장&교통안전공", "환경 및 품질관리"]
|
||||
},
|
||||
"공사관리": {
|
||||
"공정·일정": ["공정표", "월간 공정보고", "작업일보"],
|
||||
"품질 관리": ["품질시험계획서", "품질시험 실적보고", "콘크리트 타설현황[어천~공주(4차)]", "품질관리비 사용내역", "균열관리", "품질관리 양식서류"],
|
||||
"안전 관리": ["안전관리계획서", "안전관리 실적보고", "위험성 평가", "사전작업허가서", "안전관리비 사용내역", "안전관리수준평가", "안전관리 양식서류"],
|
||||
"환경 관리": ["환경영향평가", "사전재해영향성검토", "유지관리 및 보수점검", "환경보전비 사용내역", "건설폐기물 관리"],
|
||||
"자재 관리 (관급)": ["자재구매요청 (레미콘, 철근)", "자재구매요청 (그 외)", "납품기한", "계약 변경", "자재 반입·수불 관리", "자재관리 양식서류"],
|
||||
"자재 관리 (사급)": ["자재공급원 승인", "자재 반입·수불 관리", "자재 검수·확인"],
|
||||
"점검 (정리중)": ["내부점검", "외부점검"],
|
||||
"공문": ["접수(수신)", "발송(발신)", "하도급", "인력", "방침"]
|
||||
},
|
||||
"민원관리": {
|
||||
"민원(어천~공주)": ["처리대장", "보상", "공사일반", "환경분쟁"],
|
||||
"실정보고(어천~공주)": ["민원"],
|
||||
"실정보고(대술~정안)": ["민원"]
|
||||
}
|
||||
}
|
||||
|
||||
def analyze_flow_reasoning(filename, all_text_list):
|
||||
"""
|
||||
본문의 전수 조사 결과에 파일명의 '의도 가중치'를 더해 최종 추론
|
||||
"""
|
||||
full_text = " ".join(all_text_list)
|
||||
clean_ctx = full_text.replace(" ", "").replace("\n", "").lower()
|
||||
fn_clean = filename.replace(" ", "").lower()
|
||||
|
||||
# 1. 도메인별 기본 점수 (본문 전수 조사 - 평등하게)
|
||||
scores = {
|
||||
"official": sum(clean_ctx.count(k) for k in ["수신:", "발신:", "경유:", "시행일자", "귀하", "드립니다", "바랍니다"]),
|
||||
"contract": sum(clean_ctx.count(k) for k in ["계약서", "하도급", "외주", "도급", "인감", "사업자"]),
|
||||
"hr": sum(clean_ctx.count(k) for k in ["이탈계", "인력", "기술자", "안전관리자", "재직증명", "배치"]),
|
||||
"change": sum(clean_ctx.count(k) for k in ["실정보고", "설계변경", "변경보고", "추가반영"]),
|
||||
"technical": sum(clean_ctx.count(k) for k in ["일위대가", "산출근거", "집계표", "물량산출", "단가", "내역", "도면", "dwg"])
|
||||
}
|
||||
|
||||
# 2. 파일명에 대한 '방향타' 가중치 부여 (Final Push)
|
||||
# 본문 데이터가 아무리 많아도 파일명의 의도를 존중하기 위해 7배 가중치
|
||||
if "실정" in fn_clean or "변경" in fn_clean: scores["change"] += 50 # 본문 50회 언급과 맞먹는 가중치
|
||||
if "계약" in fn_clean or "하도급" in fn_clean: scores["contract"] += 50
|
||||
if "인력" in fn_clean or "이탈" in fn_clean: scores["hr"] += 50
|
||||
if "단가" in fn_clean or "수량" in fn_clean or "도면" in fn_clean: scores["technical"] += 50
|
||||
if "제출" in fn_clean or "건" in fn_clean: scores["official"] += 30
|
||||
|
||||
# 3. 종합 농도에 따른 최종 도메인 선정
|
||||
dominant_domain = max(scores, key=scores.get)
|
||||
|
||||
# 프로젝트 식별 (Fuzzy 매칭 및 교차 검증)
|
||||
project_loc = "어천~공주" if any(k in clean_ctx or k in fn_clean for k in ["어천", "공주"]) else "대술~정안" if any(k in clean_ctx or k in fn_clean for k in ["대술", "정안"]) else "공통"
|
||||
|
||||
# --- [통합 추론 및 매칭] ---
|
||||
|
||||
# 시나리오 A: 실정보고/설계변경 (본문 데이터 + 파일명 의도 합성)
|
||||
if dominant_domain == "change" or (scores["change"] > 0 and scores["technical"] > 5):
|
||||
cat = f"실정보고({project_loc})"
|
||||
sub = "지장물" if any(k in clean_ctx for k in ["임대료", "토지", "보상"]) else "구조물공" if "구조물" in clean_ctx else "기타"
|
||||
return f"설계변경 > {cat} > {sub}", f"본문의 기술 데이터 밀도와 파일명의 '{dominant_domain}' 관련 의도를 종합하여 {project_loc} 프로젝트의 실정보고 본체로 판정."
|
||||
|
||||
# 시나리오 B: 행정 계약/하도급 (본체 중심)
|
||||
if dominant_domain == "contract":
|
||||
return "행정 > 계약 > 계약관리", "문서 전체에서 계약 및 하도급 업무 본질이 지배적으로 확인됨."
|
||||
|
||||
# 시나리오 C: 인사/인력 관리
|
||||
if dominant_domain == "hr":
|
||||
if len(all_text_list) <= 2: return "공사관리 > 공문 > 인력", "인력 사항을 간략히 보고하는 공문 형식임."
|
||||
return "행정 > 계약 > 인원관리", "다량의 인력 증빙 데이터가 포함된 행정 서류임."
|
||||
|
||||
# 시나리오 D: 순수 공문 (형식 우선)
|
||||
if dominant_domain == "official" or scores["official"] > scores["technical"]:
|
||||
tab, cat = "공사관리", "공문"
|
||||
sub = "접수(수신)"
|
||||
if "방침" in clean_ctx or "지침" in clean_ctx: sub = "방침"
|
||||
elif "발신" in clean_ctx[:500]: sub = "발송(발신)"
|
||||
return f"{tab} > {cat} > {sub}", "전체 맥락상 기술적 데이터보다 행정적 전달 행위(공문)가 핵심 정체성으로 판단됨."
|
||||
|
||||
# 시나리오 E: 기술 성과품
|
||||
if dominant_domain == "technical":
|
||||
if any(k in clean_ctx or k in fn_clean for k in ["단가", "내역"]): return "설계성과품 > 내역서 > 단가산출서", "내역/단가 산출 기술 데이터 확인."
|
||||
if any(k in clean_ctx or k in fn_clean for k in ["도면", "dwg"]): return "설계성과품 > 설계도면 > 공통", "도면/그래픽 데이터 확인."
|
||||
return "설계성과품 > 수량산출서 > 토공", "수량/물량 산출 데이터 확인."
|
||||
|
||||
return "행정 > 업무관리 > 양식서류", "일반 행정 및 기타 양식 서류로 분류함."
|
||||
|
||||
def analyze_file_content(filename: str):
|
||||
try:
|
||||
file_path = os.path.join("sample", filename)
|
||||
text_by_pages = []
|
||||
if filename.lower().endswith(".pdf"):
|
||||
reader = PdfReader(file_path)
|
||||
for i in range(len(reader.pages)):
|
||||
page_text = reader.pages[i].extract_text() or ""
|
||||
if OCR_AVAILABLE:
|
||||
try:
|
||||
images = convert_from_path(file_path, first_page=i+1, last_page=i+1, poppler_path=POPPLER_BIN, dpi=200)
|
||||
if images:
|
||||
ocr_result = pytesseract.image_to_string(images[0], lang='kor+eng')
|
||||
page_text += "\n" + ocr_result
|
||||
except Exception as ocr_err:
|
||||
print(f"OCR Error on page {i+1}: {ocr_err}")
|
||||
text_by_pages.append(page_text)
|
||||
elif filename.lower().endswith(('.xlsx', '.xls')):
|
||||
import pandas as pd
|
||||
df = pd.read_excel(file_path)
|
||||
text_by_pages.append(df.to_string())
|
||||
else: text_by_pages.append("")
|
||||
|
||||
path, reason = analyze_flow_reasoning(filename, text_by_pages)
|
||||
|
||||
return {
|
||||
"filename": filename,
|
||||
"total_pages": len(text_by_pages),
|
||||
"final_result": {
|
||||
"suggested_path": path,
|
||||
"confidence": "100%",
|
||||
"reason": reason,
|
||||
"snippet": " ".join(text_by_pages)[:1500]
|
||||
}
|
||||
}
|
||||
except Exception as e:
|
||||
return {"error": str(e), "filename": filename}
|
||||
|
||||
48
analyze_logs_pattern.py
Normal file
48
analyze_logs_pattern.py
Normal 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
29
check_tables.py
Normal 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
29
clear_test_db.py
Normal 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
45
clone_db.py
Normal 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()
|
||||
@@ -1,258 +1,258 @@
|
||||
import os
|
||||
import re
|
||||
import asyncio
|
||||
import json
|
||||
import traceback
|
||||
import sys
|
||||
import threading
|
||||
import queue
|
||||
import pymysql
|
||||
from datetime import datetime
|
||||
from playwright.async_api import async_playwright
|
||||
from dotenv import load_dotenv
|
||||
from sql_queries import CrawlerQueries
|
||||
|
||||
load_dotenv(override=True)
|
||||
|
||||
# 글로벌 중단 제어용 이벤트
|
||||
crawl_stop_event = threading.Event()
|
||||
|
||||
def get_db_connection():
|
||||
"""MySQL 데이터베이스 연결을 반환 (환경변수 기반)"""
|
||||
return pymysql.connect(
|
||||
host=os.getenv('DB_HOST', 'localhost'),
|
||||
user=os.getenv('DB_USER', 'root'),
|
||||
password=os.getenv('DB_PASSWORD', '45278434'),
|
||||
database=os.getenv('DB_NAME', 'PM_proto'),
|
||||
charset='utf8mb4',
|
||||
cursorclass=pymysql.cursors.DictCursor
|
||||
)
|
||||
|
||||
def clean_date_string(date_str):
|
||||
if not date_str: return ""
|
||||
match = re.search(r'(\d{2})[./-](\d{2})[./-](\d{2})', date_str)
|
||||
if match: return f"20{match.group(1)}.{match.group(2)}.{match.group(3)}"
|
||||
return date_str[:10].replace("-", ".")
|
||||
|
||||
def parse_log_id(log_id):
|
||||
if not log_id or "_" not in log_id: return log_id
|
||||
try:
|
||||
parts = log_id.split('_')
|
||||
if len(parts) >= 4:
|
||||
date_part = clean_date_string(parts[1])
|
||||
activity = parts[3].strip()
|
||||
activity = re.sub(r'\(.*?\)', '', activity).strip()
|
||||
return f"{date_part}, {activity}"
|
||||
except: pass
|
||||
return log_id
|
||||
|
||||
def crawler_thread_worker(msg_queue, user_id, password):
|
||||
crawl_stop_event.clear()
|
||||
if sys.platform == 'win32':
|
||||
asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy())
|
||||
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
|
||||
async def run():
|
||||
async with async_playwright() as p:
|
||||
browser = None
|
||||
try:
|
||||
msg_queue.put(json.dumps({'type': 'log', 'message': '브라우저 엔진 가동 (전 기능 복구 모드)...'}))
|
||||
browser = await p.chromium.launch(headless=True, args=[
|
||||
"--no-sandbox",
|
||||
"--disable-dev-shm-usage",
|
||||
"--disable-blink-features=AutomationControlled"
|
||||
])
|
||||
context = await browser.new_context(
|
||||
viewport={'width': 1600, 'height': 900},
|
||||
user_agent="Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36"
|
||||
)
|
||||
|
||||
captured_data = {"tree": None, "_is_root_archive": False, "project_list": [], "last_project_data": None}
|
||||
|
||||
async def global_interceptor(response):
|
||||
url = response.url
|
||||
try:
|
||||
if "getAllList" in url:
|
||||
data = await response.json()
|
||||
captured_data["project_list"] = data.get("data", [])
|
||||
elif "getTreeObject" in url:
|
||||
is_root = False
|
||||
if "params[resourcePath]=" in url:
|
||||
path_val = url.split("params[resourcePath]=")[1].split("&")[0]
|
||||
if path_val in ["%2F", "/"]: is_root = True
|
||||
if is_root:
|
||||
captured_data["tree"] = await response.json()
|
||||
captured_data["_is_root_archive"] = True
|
||||
elif "getData" in url and "overview" in url:
|
||||
captured_data["last_project_data"] = await response.json()
|
||||
except: pass
|
||||
|
||||
context.on("response", global_interceptor)
|
||||
page = await context.new_page()
|
||||
await page.goto("https://overseas.projectmastercloud.com/dashboard", wait_until="domcontentloaded")
|
||||
|
||||
# 로그인
|
||||
if await page.locator("#login-by-id").is_visible(timeout=10000):
|
||||
await page.click("#login-by-id")
|
||||
await page.fill("#user_id", user_id)
|
||||
await page.fill("#user_pw", password)
|
||||
await page.click("#login-btn")
|
||||
|
||||
await page.wait_for_selector("h4.list__contents_aria_group_body_list_item_label", timeout=60000)
|
||||
await asyncio.sleep(3)
|
||||
|
||||
# [Phase 1] DB 마스터 정보 동기화
|
||||
if captured_data["project_list"]:
|
||||
conn = get_db_connection()
|
||||
try:
|
||||
with conn.cursor() as cursor:
|
||||
for p_info in captured_data["project_list"]:
|
||||
cursor.execute(CrawlerQueries.UPSERT_MASTER, (p_info.get("project_id"), p_info.get("project_nm"),
|
||||
p_info.get("short_nm", "").strip(), p_info.get("master"),
|
||||
p_info.get("large_class"), p_info.get("mid_class")))
|
||||
conn.commit()
|
||||
msg_queue.put(json.dumps({'type': 'log', 'message': 'DB 마스터 정보 동기화 완료.'}))
|
||||
finally: conn.close()
|
||||
|
||||
# [Phase 2] 수집 루프
|
||||
names = await page.locator("h4.list__contents_aria_group_body_list_item_label").all_inner_texts()
|
||||
project_names = list(dict.fromkeys([n.strip() for n in names if n.strip()]))
|
||||
count = len(project_names)
|
||||
|
||||
for i, project_name in enumerate(project_names):
|
||||
if crawl_stop_event.is_set():
|
||||
msg_queue.put(json.dumps({'type': 'log', 'message': '>>> 중단 신호 감지: 종료합니다.'}))
|
||||
break
|
||||
|
||||
msg_queue.put(json.dumps({'type': 'log', 'message': f'[{i+1}/{count}] {project_name} 수집 시작'}))
|
||||
p_match = next((p for p in captured_data["project_list"] if p.get('project_nm') == project_name or p.get('short_nm', '').strip() == project_name), None)
|
||||
current_p_id = p_match.get('project_id') if p_match else None
|
||||
captured_data["tree"] = None; captured_data["_is_root_archive"] = False
|
||||
|
||||
try:
|
||||
# 1. 프로젝트 진입 (좌표 클릭)
|
||||
target_el = page.locator(f"h4.list__contents_aria_group_body_list_item_label:has-text('{project_name}')").first
|
||||
await target_el.scroll_into_view_if_needed()
|
||||
box = await target_el.bounding_box()
|
||||
if box: await page.mouse.click(box['x'] + 5, box['y'] + 5)
|
||||
else: await target_el.click(force=True)
|
||||
|
||||
await page.wait_for_selector("text=활동로그", timeout=30000)
|
||||
|
||||
# [부서 정보 수집] getData 응답 대기 및 DB 업데이트
|
||||
for _ in range(10):
|
||||
if captured_data.get("last_project_data"): break
|
||||
await asyncio.sleep(0.5)
|
||||
|
||||
last_data = captured_data.get("last_project_data")
|
||||
if last_data:
|
||||
if isinstance(last_data, list) and len(last_data) > 0:
|
||||
last_data = last_data[0]
|
||||
|
||||
if isinstance(last_data, dict):
|
||||
proj_data = last_data.get("data", {})
|
||||
if isinstance(proj_data, list) and len(proj_data) > 0:
|
||||
proj_data = proj_data[0]
|
||||
|
||||
if isinstance(proj_data, dict):
|
||||
dept = proj_data.get("department")
|
||||
p_id = proj_data.get("project_id")
|
||||
if dept and p_id:
|
||||
with get_db_connection() as conn:
|
||||
with conn.cursor() as cursor:
|
||||
cursor.execute(CrawlerQueries.UPDATE_DEPARTMENT, (dept, p_id))
|
||||
conn.commit()
|
||||
captured_data["last_project_data"] = None # 초기화
|
||||
|
||||
await asyncio.sleep(2)
|
||||
|
||||
recent_log = "데이터 없음"; file_count = 0
|
||||
|
||||
# 2. 활동로그 (날짜 필터 적용 버전)
|
||||
modal_opened = False
|
||||
for _ in range(3):
|
||||
await page.get_by_text("활동로그").first.click()
|
||||
try:
|
||||
await page.wait_for_selector("article.archive-modal", timeout=5000)
|
||||
modal_opened = True; break
|
||||
except: await asyncio.sleep(1)
|
||||
|
||||
if modal_opened:
|
||||
# 날짜 필터 2020-01-01 적용
|
||||
inputs = await page.locator("article.archive-modal input").all()
|
||||
for inp in inputs:
|
||||
if (await inp.get_attribute("type")) == "date":
|
||||
await inp.fill("2020-01-01"); break
|
||||
|
||||
apply_btn = page.locator("article.archive-modal").get_by_text("적용").first
|
||||
if await apply_btn.is_visible():
|
||||
await apply_btn.click()
|
||||
await asyncio.sleep(5)
|
||||
log_elements = await page.locator("article.archive-modal div[id*='_']").all()
|
||||
if log_elements:
|
||||
recent_log = parse_log_id(await log_elements[0].get_attribute("id"))
|
||||
await page.keyboard.press("Escape")
|
||||
|
||||
# 3. 구성 수집 (API Fetch 방식 - 팝업 없음)
|
||||
await page.evaluate("""() => {
|
||||
const baseUrl = window.location.origin + window.location.pathname.split('/').slice(0, 2).join('/');
|
||||
fetch(`${baseUrl}/archive/getTreeObject?params[storageType]=CLOUD¶ms[resourcePath]=/`);
|
||||
}""")
|
||||
for _ in range(30):
|
||||
if captured_data["_is_root_archive"]: break
|
||||
await asyncio.sleep(0.5)
|
||||
|
||||
if captured_data["tree"]:
|
||||
tree_data = captured_data["tree"]
|
||||
if isinstance(tree_data, list) and len(tree_data) > 0:
|
||||
tree_data = tree_data[0]
|
||||
|
||||
if isinstance(tree_data, dict):
|
||||
tree = tree_data.get('currentTreeObject', tree_data)
|
||||
if isinstance(tree, dict):
|
||||
total = len(tree.get("file", {}))
|
||||
folders = tree.get("folder", {})
|
||||
if isinstance(folders, dict):
|
||||
for f in folders.values(): total += int(f.get("filesCount", 0))
|
||||
file_count = total
|
||||
|
||||
# 4. DB 실시간 저장
|
||||
if current_p_id:
|
||||
with get_db_connection() as conn:
|
||||
with conn.cursor() as cursor:
|
||||
cursor.execute(CrawlerQueries.UPSERT_HISTORY, (current_p_id, recent_log, file_count))
|
||||
conn.commit()
|
||||
msg_queue.put(json.dumps({'type': 'log', 'message': f' - [성공] 로그: {recent_log[:20]}... / 파일: {file_count}개'}))
|
||||
|
||||
await page.goto("https://overseas.projectmastercloud.com/dashboard", wait_until="domcontentloaded")
|
||||
|
||||
except Exception as e:
|
||||
msg_queue.put(json.dumps({'type': 'log', 'message': f' - {project_name} 실패: {str(e)}'}))
|
||||
await page.goto("https://overseas.projectmastercloud.com/dashboard")
|
||||
|
||||
msg_queue.put(json.dumps({'type': 'done', 'data': []}))
|
||||
|
||||
except Exception as e:
|
||||
msg_queue.put(json.dumps({'type': 'log', 'message': f'치명적 오류: {str(e)}'}))
|
||||
finally:
|
||||
if browser: await browser.close()
|
||||
msg_queue.put(None)
|
||||
|
||||
loop.run_until_complete(run())
|
||||
loop.close()
|
||||
|
||||
async def run_crawler_service():
|
||||
msg_queue = queue.Queue()
|
||||
thread = threading.Thread(target=crawler_thread_worker, args=(msg_queue, os.getenv("PM_USER_ID"), os.getenv("PM_PASSWORD")))
|
||||
thread.start()
|
||||
while True:
|
||||
try:
|
||||
msg = await asyncio.to_thread(msg_queue.get, timeout=1.0)
|
||||
if msg is None: break
|
||||
yield f"data: {msg}\n\n"
|
||||
except queue.Empty:
|
||||
if not thread.is_alive(): break
|
||||
await asyncio.sleep(0.1)
|
||||
thread.join()
|
||||
import os
|
||||
import re
|
||||
import asyncio
|
||||
import json
|
||||
import traceback
|
||||
import sys
|
||||
import threading
|
||||
import queue
|
||||
import pymysql
|
||||
from datetime import datetime
|
||||
from playwright.async_api import async_playwright
|
||||
from dotenv import load_dotenv
|
||||
from sql_queries import CrawlerQueries
|
||||
|
||||
load_dotenv(override=True)
|
||||
|
||||
# 글로벌 중단 제어용 이벤트
|
||||
crawl_stop_event = threading.Event()
|
||||
|
||||
def get_db_connection():
|
||||
"""MySQL 데이터베이스 연결을 반환 (환경변수 기반)"""
|
||||
return pymysql.connect(
|
||||
host=os.getenv('DB_HOST', 'localhost'),
|
||||
user=os.getenv('DB_USER', 'root'),
|
||||
password=os.getenv('DB_PASSWORD', '45278434'),
|
||||
database=os.getenv('DB_NAME', 'PM_proto'),
|
||||
charset='utf8mb4',
|
||||
cursorclass=pymysql.cursors.DictCursor
|
||||
)
|
||||
|
||||
def clean_date_string(date_str):
|
||||
if not date_str: return ""
|
||||
match = re.search(r'(\d{2})[./-](\d{2})[./-](\d{2})', date_str)
|
||||
if match: return f"20{match.group(1)}.{match.group(2)}.{match.group(3)}"
|
||||
return date_str[:10].replace("-", ".")
|
||||
|
||||
def parse_log_id(log_id):
|
||||
if not log_id or "_" not in log_id: return log_id
|
||||
try:
|
||||
parts = log_id.split('_')
|
||||
if len(parts) >= 4:
|
||||
date_part = clean_date_string(parts[1])
|
||||
activity = parts[3].strip()
|
||||
activity = re.sub(r'\(.*?\)', '', activity).strip()
|
||||
return f"{date_part}, {activity}"
|
||||
except: pass
|
||||
return log_id
|
||||
|
||||
def crawler_thread_worker(msg_queue, user_id, password):
|
||||
crawl_stop_event.clear()
|
||||
if sys.platform == 'win32':
|
||||
asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy())
|
||||
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
|
||||
async def run():
|
||||
async with async_playwright() as p:
|
||||
browser = None
|
||||
try:
|
||||
msg_queue.put(json.dumps({'type': 'log', 'message': '브라우저 엔진 가동 (전 기능 복구 모드)...'}))
|
||||
browser = await p.chromium.launch(headless=True, args=[
|
||||
"--no-sandbox",
|
||||
"--disable-dev-shm-usage",
|
||||
"--disable-blink-features=AutomationControlled"
|
||||
])
|
||||
context = await browser.new_context(
|
||||
viewport={'width': 1600, 'height': 900},
|
||||
user_agent="Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36"
|
||||
)
|
||||
|
||||
captured_data = {"tree": None, "_is_root_archive": False, "project_list": [], "last_project_data": None}
|
||||
|
||||
async def global_interceptor(response):
|
||||
url = response.url
|
||||
try:
|
||||
if "getAllList" in url:
|
||||
data = await response.json()
|
||||
captured_data["project_list"] = data.get("data", [])
|
||||
elif "getTreeObject" in url:
|
||||
is_root = False
|
||||
if "params[resourcePath]=" in url:
|
||||
path_val = url.split("params[resourcePath]=")[1].split("&")[0]
|
||||
if path_val in ["%2F", "/"]: is_root = True
|
||||
if is_root:
|
||||
captured_data["tree"] = await response.json()
|
||||
captured_data["_is_root_archive"] = True
|
||||
elif "getData" in url and "overview" in url:
|
||||
captured_data["last_project_data"] = await response.json()
|
||||
except: pass
|
||||
|
||||
context.on("response", global_interceptor)
|
||||
page = await context.new_page()
|
||||
await page.goto("https://overseas.projectmastercloud.com/dashboard", wait_until="domcontentloaded")
|
||||
|
||||
# 로그인
|
||||
if await page.locator("#login-by-id").is_visible(timeout=10000):
|
||||
await page.click("#login-by-id")
|
||||
await page.fill("#user_id", user_id)
|
||||
await page.fill("#user_pw", password)
|
||||
await page.click("#login-btn")
|
||||
|
||||
await page.wait_for_selector("h4.list__contents_aria_group_body_list_item_label", timeout=60000)
|
||||
await asyncio.sleep(3)
|
||||
|
||||
# [Phase 1] DB 마스터 정보 동기화
|
||||
if captured_data["project_list"]:
|
||||
conn = get_db_connection()
|
||||
try:
|
||||
with conn.cursor() as cursor:
|
||||
for p_info in captured_data["project_list"]:
|
||||
cursor.execute(CrawlerQueries.UPSERT_MASTER, (p_info.get("project_id"), p_info.get("project_nm"),
|
||||
p_info.get("short_nm", "").strip(), p_info.get("master"),
|
||||
p_info.get("large_class"), p_info.get("mid_class")))
|
||||
conn.commit()
|
||||
msg_queue.put(json.dumps({'type': 'log', 'message': 'DB 마스터 정보 동기화 완료.'}))
|
||||
finally: conn.close()
|
||||
|
||||
# [Phase 2] 수집 루프
|
||||
names = await page.locator("h4.list__contents_aria_group_body_list_item_label").all_inner_texts()
|
||||
project_names = list(dict.fromkeys([n.strip() for n in names if n.strip()]))
|
||||
count = len(project_names)
|
||||
|
||||
for i, project_name in enumerate(project_names):
|
||||
if crawl_stop_event.is_set():
|
||||
msg_queue.put(json.dumps({'type': 'log', 'message': '>>> 중단 신호 감지: 종료합니다.'}))
|
||||
break
|
||||
|
||||
msg_queue.put(json.dumps({'type': 'log', 'message': f'[{i+1}/{count}] {project_name} 수집 시작'}))
|
||||
p_match = next((p for p in captured_data["project_list"] if p.get('project_nm') == project_name or p.get('short_nm', '').strip() == project_name), None)
|
||||
current_p_id = p_match.get('project_id') if p_match else None
|
||||
captured_data["tree"] = None; captured_data["_is_root_archive"] = False
|
||||
|
||||
try:
|
||||
# 1. 프로젝트 진입 (좌표 클릭)
|
||||
target_el = page.locator(f"h4.list__contents_aria_group_body_list_item_label:has-text('{project_name}')").first
|
||||
await target_el.scroll_into_view_if_needed()
|
||||
box = await target_el.bounding_box()
|
||||
if box: await page.mouse.click(box['x'] + 5, box['y'] + 5)
|
||||
else: await target_el.click(force=True)
|
||||
|
||||
await page.wait_for_selector("text=활동로그", timeout=30000)
|
||||
|
||||
# [부서 정보 수집] getData 응답 대기 및 DB 업데이트
|
||||
for _ in range(10):
|
||||
if captured_data.get("last_project_data"): break
|
||||
await asyncio.sleep(0.5)
|
||||
|
||||
last_data = captured_data.get("last_project_data")
|
||||
if last_data:
|
||||
if isinstance(last_data, list) and len(last_data) > 0:
|
||||
last_data = last_data[0]
|
||||
|
||||
if isinstance(last_data, dict):
|
||||
proj_data = last_data.get("data", {})
|
||||
if isinstance(proj_data, list) and len(proj_data) > 0:
|
||||
proj_data = proj_data[0]
|
||||
|
||||
if isinstance(proj_data, dict):
|
||||
dept = proj_data.get("department")
|
||||
p_id = proj_data.get("project_id")
|
||||
if dept and p_id:
|
||||
with get_db_connection() as conn:
|
||||
with conn.cursor() as cursor:
|
||||
cursor.execute(CrawlerQueries.UPDATE_DEPARTMENT, (dept, p_id))
|
||||
conn.commit()
|
||||
captured_data["last_project_data"] = None # 초기화
|
||||
|
||||
await asyncio.sleep(2)
|
||||
|
||||
recent_log = "데이터 없음"; file_count = 0
|
||||
|
||||
# 2. 활동로그 (날짜 필터 적용 버전)
|
||||
modal_opened = False
|
||||
for _ in range(3):
|
||||
await page.get_by_text("활동로그").first.click()
|
||||
try:
|
||||
await page.wait_for_selector("article.archive-modal", timeout=5000)
|
||||
modal_opened = True; break
|
||||
except: await asyncio.sleep(1)
|
||||
|
||||
if modal_opened:
|
||||
# 날짜 필터 2020-01-01 적용
|
||||
inputs = await page.locator("article.archive-modal input").all()
|
||||
for inp in inputs:
|
||||
if (await inp.get_attribute("type")) == "date":
|
||||
await inp.fill("2020-01-01"); break
|
||||
|
||||
apply_btn = page.locator("article.archive-modal").get_by_text("적용").first
|
||||
if await apply_btn.is_visible():
|
||||
await apply_btn.click()
|
||||
await asyncio.sleep(5)
|
||||
log_elements = await page.locator("article.archive-modal div[id*='_']").all()
|
||||
if log_elements:
|
||||
recent_log = parse_log_id(await log_elements[0].get_attribute("id"))
|
||||
await page.keyboard.press("Escape")
|
||||
|
||||
# 3. 구성 수집 (API Fetch 방식 - 팝업 없음)
|
||||
await page.evaluate("""() => {
|
||||
const baseUrl = window.location.origin + window.location.pathname.split('/').slice(0, 2).join('/');
|
||||
fetch(`${baseUrl}/archive/getTreeObject?params[storageType]=CLOUD¶ms[resourcePath]=/`);
|
||||
}""")
|
||||
for _ in range(30):
|
||||
if captured_data["_is_root_archive"]: break
|
||||
await asyncio.sleep(0.5)
|
||||
|
||||
if captured_data["tree"]:
|
||||
tree_data = captured_data["tree"]
|
||||
if isinstance(tree_data, list) and len(tree_data) > 0:
|
||||
tree_data = tree_data[0]
|
||||
|
||||
if isinstance(tree_data, dict):
|
||||
tree = tree_data.get('currentTreeObject', tree_data)
|
||||
if isinstance(tree, dict):
|
||||
total = len(tree.get("file", {}))
|
||||
folders = tree.get("folder", {})
|
||||
if isinstance(folders, dict):
|
||||
for f in folders.values(): total += int(f.get("filesCount", 0))
|
||||
file_count = total
|
||||
|
||||
# 4. DB 실시간 저장
|
||||
if current_p_id:
|
||||
with get_db_connection() as conn:
|
||||
with conn.cursor() as cursor:
|
||||
cursor.execute(CrawlerQueries.UPSERT_HISTORY, (current_p_id, recent_log, file_count))
|
||||
conn.commit()
|
||||
msg_queue.put(json.dumps({'type': 'log', 'message': f' - [성공] 로그: {recent_log[:20]}... / 파일: {file_count}개'}))
|
||||
|
||||
await page.goto("https://overseas.projectmastercloud.com/dashboard", wait_until="domcontentloaded")
|
||||
|
||||
except Exception as e:
|
||||
msg_queue.put(json.dumps({'type': 'log', 'message': f' - {project_name} 실패: {str(e)}'}))
|
||||
await page.goto("https://overseas.projectmastercloud.com/dashboard")
|
||||
|
||||
msg_queue.put(json.dumps({'type': 'done', 'data': []}))
|
||||
|
||||
except Exception as e:
|
||||
msg_queue.put(json.dumps({'type': 'log', 'message': f'치명적 오류: {str(e)}'}))
|
||||
finally:
|
||||
if browser: await browser.close()
|
||||
msg_queue.put(None)
|
||||
|
||||
loop.run_until_complete(run())
|
||||
loop.close()
|
||||
|
||||
async def run_crawler_service():
|
||||
msg_queue = queue.Queue()
|
||||
thread = threading.Thread(target=crawler_thread_worker, args=(msg_queue, os.getenv("PM_USER_ID"), os.getenv("PM_PASSWORD")))
|
||||
thread.start()
|
||||
while True:
|
||||
try:
|
||||
msg = await asyncio.to_thread(msg_queue.get, timeout=1.0)
|
||||
if msg is None: break
|
||||
yield f"data: {msg}\n\n"
|
||||
except queue.Empty:
|
||||
if not thread.is_alive(): break
|
||||
await asyncio.sleep(0.1)
|
||||
thread.join()
|
||||
|
||||
273
crawler_service_test.py
Normal file
273
crawler_service_test.py
Normal 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¶ms[resourcePath]=/`);
|
||||
}""")
|
||||
for _ in range(30):
|
||||
if captured_data["_is_root_archive"]: break
|
||||
await asyncio.sleep(0.5)
|
||||
|
||||
actual_count = 0
|
||||
if captured_data["tree"]:
|
||||
tree_data = captured_data["tree"]
|
||||
if isinstance(tree_data, list) and len(tree_data) > 0: tree_data = tree_data[0]
|
||||
if isinstance(tree_data, dict):
|
||||
tree = tree_data.get('currentTreeObject', tree_data)
|
||||
if isinstance(tree, dict):
|
||||
# 원본 파일 수 합산 로직
|
||||
total = len(tree.get("file", {}))
|
||||
folders = tree.get("folder", {})
|
||||
if isinstance(folders, dict):
|
||||
for f in folders.values(): total += int(f.get("filesCount", 0))
|
||||
actual_count = total
|
||||
|
||||
# 3. 활동로그 전수 수집 (하이브리드 방식: 최상단 우선 확보 + 전수 스크롤)
|
||||
all_logs = []
|
||||
await page.get_by_text("활동로그").first.click()
|
||||
if await page.wait_for_selector("article.archive-modal", timeout=10000):
|
||||
# 날짜 필터 적용 (2020-01-01)
|
||||
inputs = await page.locator("article.archive-modal input").all()
|
||||
for inp in inputs:
|
||||
if (await inp.get_attribute("type")) == "date": await inp.fill("2020-01-01"); break
|
||||
|
||||
apply_btn = page.locator("article.archive-modal").get_by_text("적용").first
|
||||
if await apply_btn.is_visible():
|
||||
await apply_btn.click()
|
||||
# [핵심] 첫 번째 로그가 나타날 때까지 명시적 대기 (최대 10초)
|
||||
try:
|
||||
await page.wait_for_selector("article.archive-modal div[id*='_']", timeout=10000)
|
||||
except: pass
|
||||
await asyncio.sleep(2)
|
||||
|
||||
# (1) 최상단 로그 즉시 확보 (안전장치)
|
||||
first_log_el = await page.locator("article.archive-modal div[id*='_']").first.get_attribute("id")
|
||||
if first_log_el:
|
||||
first_log_text = parse_log_id(first_log_el)
|
||||
if ", " in first_log_text:
|
||||
d, a = first_log_text.split(", ", 1)
|
||||
all_logs.append({'date': d, 'activity': a})
|
||||
|
||||
# (2) 전수 수집을 위한 무한 스크롤 및 지정된 클래스 내 ID 수집
|
||||
last_count = len(all_logs)
|
||||
for _ in range(20):
|
||||
# 스크롤 수행 (사용자가 지정한 log-body 클래스 기준)
|
||||
await page.evaluate("""() => {
|
||||
const body = document.querySelector('.log-item-wrap.log-body.scrollbar.scroll-container') ||
|
||||
document.querySelector('article.archive-modal .modal-body') ||
|
||||
document.querySelector('article.archive-modal');
|
||||
if (body) body.scrollTop = body.scrollHeight;
|
||||
}""")
|
||||
await asyncio.sleep(1.5)
|
||||
|
||||
# 사용자 지정 클래스 내의 모든 div ID 수집
|
||||
# .log-item-wrap.log-body.scrollbar.scroll-container 내부의 div들을 타겟팅
|
||||
selector = ".log-item-wrap.log-body.scrollbar.scroll-container div"
|
||||
current_elements = await page.locator(selector).all()
|
||||
|
||||
# 만약 지정된 클래스로 검색되지 않을 경우 기존 div[id*='_']를 백업으로 사용
|
||||
if not current_elements:
|
||||
current_elements = await page.locator("article.archive-modal div[id*='_']").all()
|
||||
|
||||
seen_ids = {f"{log['date']}, {log['activity']}" for log in all_logs}
|
||||
for el in current_elements:
|
||||
log_id = await el.get_attribute("id")
|
||||
if not log_id: continue
|
||||
|
||||
log_text = parse_log_id(log_id)
|
||||
if ", " in log_text and log_text not in seen_ids:
|
||||
d, a = log_text.split(", ", 1)
|
||||
all_logs.append({'date': d, 'activity': a})
|
||||
seen_ids.add(log_text)
|
||||
|
||||
if len(all_logs) == last_count: break
|
||||
last_count = len(all_logs)
|
||||
|
||||
if not all_logs:
|
||||
msg_queue.put(json.dumps({'type': 'log', 'message': f' - [주의] {project_name}: 수집된 로그가 없습니다.'}))
|
||||
|
||||
await page.keyboard.press("Escape")
|
||||
|
||||
# 4. 파일 수 추론 (전수 보존 모드)
|
||||
history_map = {}
|
||||
curr_calc_count = actual_count
|
||||
|
||||
if all_logs:
|
||||
# 오늘 날짜 강제 주입 대신 수집된 로그의 실제 날짜 사용
|
||||
for log in all_logs:
|
||||
d_db = log['date'].replace(".", "-")
|
||||
act = log['activity']
|
||||
if d_db not in history_map:
|
||||
history_map[d_db] = {"log": act, "count": curr_calc_count}
|
||||
|
||||
if "업로드" in act: curr_calc_count -= 1
|
||||
elif "삭제" in act: curr_calc_count += 1
|
||||
if curr_calc_count < 0: curr_calc_count = 0
|
||||
history_map[d_db]["count"] = curr_calc_count
|
||||
else:
|
||||
# 로그가 전혀 없을 경우에만 기본값 생성
|
||||
today_str = datetime.now().strftime("%Y-%m-%d")
|
||||
history_map[today_str] = {"log": "기존 상태 유지 (활동 없음)", "count": actual_count}
|
||||
|
||||
# 5. DB 저장
|
||||
if current_p_id:
|
||||
with get_db_connection() as conn:
|
||||
with conn.cursor() as cursor:
|
||||
for date_key, data in history_map.items():
|
||||
cursor.execute(CrawlerQueries.UPSERT_HISTORY_WITH_DATE,
|
||||
(current_p_id, date_key, f"{date_key.replace('-', '.')}, {data['log']}", data['count']))
|
||||
conn.commit()
|
||||
msg_queue.put(json.dumps({'type': 'log', 'message': f' - [성공] 실측 {actual_count}개 기준 시계열 적재 완료'}))
|
||||
|
||||
await page.goto("https://overseas.projectmastercloud.com/dashboard", wait_until="domcontentloaded")
|
||||
|
||||
except Exception as e:
|
||||
msg_queue.put(json.dumps({'type': 'log', 'message': f' - {project_name} 에러: {str(e)}'}))
|
||||
await page.goto("https://overseas.projectmastercloud.com/dashboard")
|
||||
|
||||
msg_queue.put(json.dumps({'type': 'done', 'data': []}))
|
||||
|
||||
except Exception as e:
|
||||
msg_queue.put(json.dumps({'type': 'log', 'message': f'치명적 오류: {str(e)}'}))
|
||||
finally:
|
||||
if browser: await browser.close()
|
||||
msg_queue.put(None)
|
||||
|
||||
loop.run_until_complete(run())
|
||||
loop.close()
|
||||
|
||||
async def run_crawler_service():
|
||||
msg_queue = queue.Queue()
|
||||
thread = threading.Thread(target=crawler_thread_worker, args=(msg_queue, os.getenv("PM_USER_ID"), os.getenv("PM_PASSWORD")))
|
||||
thread.start()
|
||||
while True:
|
||||
try:
|
||||
msg = await asyncio.to_thread(msg_queue.get, timeout=1.0)
|
||||
if msg is None: break
|
||||
yield f"data: {msg}\n\n"
|
||||
except queue.Empty:
|
||||
if not thread.is_alive(): break
|
||||
await asyncio.sleep(0.1)
|
||||
thread.join()
|
||||
40
docker-compose.yml
Normal file
40
docker-compose.yml
Normal file
@@ -0,0 +1,40 @@
|
||||
services:
|
||||
db:
|
||||
image: mariadb:10.6
|
||||
container_name: aicode-db
|
||||
restart: always
|
||||
environment:
|
||||
MYSQL_ROOT_PASSWORD: "45278434"
|
||||
ports:
|
||||
- "3307:3306"
|
||||
volumes:
|
||||
- db-data:/var/lib/mysql
|
||||
|
||||
web:
|
||||
# 현재 폴더의 Dockerfile을 사용하여 빌드
|
||||
build: .
|
||||
# 컨테이너 이름 설정
|
||||
container_name: aicode-server
|
||||
# 포트 포워딩 (호스트 8000 -> 컨테이너 8000)
|
||||
ports:
|
||||
- "8000:8000"
|
||||
# 소스 코드 수정 시 실시간 반영 (볼륨 마운트)
|
||||
volumes:
|
||||
- .:/app
|
||||
# 환경 변수 설정
|
||||
environment:
|
||||
- PYTHONUNBUFFERED=1
|
||||
- TESSDATA_PREFIX=/usr/share/tesseract-ocr/5/tessdata
|
||||
# 호스트 PC의 IP로의 라우팅을 위한 게이트웨이 설정
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
# DB 구동 완료 대기
|
||||
depends_on:
|
||||
- db
|
||||
# 컨테이너 종료 시 자동 재시작
|
||||
restart: always
|
||||
|
||||
volumes:
|
||||
db-data:
|
||||
|
||||
|
||||
@@ -1,42 +1,42 @@
|
||||
from datetime import datetime
|
||||
from sql_queries import InquiryQueries
|
||||
|
||||
class InquiryService:
|
||||
@staticmethod
|
||||
def get_inquiries_logic(cursor, pm_type=None, category=None, status=None, keyword=None):
|
||||
sql = InquiryQueries.SELECT_BASE
|
||||
params = []
|
||||
if pm_type:
|
||||
sql += " AND pm_type = %s"
|
||||
params.append(pm_type)
|
||||
if category:
|
||||
sql += " AND category = %s"
|
||||
params.append(category)
|
||||
if status:
|
||||
sql += " AND status = %s"
|
||||
params.append(status)
|
||||
if keyword:
|
||||
sql += " AND (content LIKE %s OR author LIKE %s OR project_nm LIKE %s)"
|
||||
params.extend([f"%{keyword}%", f"%{keyword}%", f"%{keyword}%"])
|
||||
|
||||
sql += f" {InquiryQueries.ORDER_BY_DESC}"
|
||||
cursor.execute(sql, params)
|
||||
return cursor.fetchall()
|
||||
|
||||
@staticmethod
|
||||
def get_inquiry_detail_logic(cursor, inquiry_id):
|
||||
cursor.execute(InquiryQueries.SELECT_BY_ID, (inquiry_id,))
|
||||
return cursor.fetchone()
|
||||
|
||||
@staticmethod
|
||||
def update_inquiry_reply_logic(cursor, conn, inquiry_id, req):
|
||||
handled_date = datetime.now().strftime("%Y.%m.%d")
|
||||
cursor.execute(InquiryQueries.UPDATE_REPLY, (req.reply, req.status, req.handler, handled_date, inquiry_id))
|
||||
conn.commit()
|
||||
return {"success": True}
|
||||
|
||||
@staticmethod
|
||||
def delete_inquiry_reply_logic(cursor, conn, inquiry_id):
|
||||
cursor.execute(InquiryQueries.DELETE_REPLY, (inquiry_id,))
|
||||
conn.commit()
|
||||
return {"success": True}
|
||||
from datetime import datetime
|
||||
from sql_queries import InquiryQueries
|
||||
|
||||
class InquiryService:
|
||||
@staticmethod
|
||||
def get_inquiries_logic(cursor, pm_type=None, category=None, status=None, keyword=None):
|
||||
sql = InquiryQueries.SELECT_BASE
|
||||
params = []
|
||||
if pm_type:
|
||||
sql += " AND pm_type = %s"
|
||||
params.append(pm_type)
|
||||
if category:
|
||||
sql += " AND category = %s"
|
||||
params.append(category)
|
||||
if status:
|
||||
sql += " AND status = %s"
|
||||
params.append(status)
|
||||
if keyword:
|
||||
sql += " AND (content LIKE %s OR author LIKE %s OR project_nm LIKE %s)"
|
||||
params.extend([f"%{keyword}%", f"%{keyword}%", f"%{keyword}%"])
|
||||
|
||||
sql += f" {InquiryQueries.ORDER_BY_DESC}"
|
||||
cursor.execute(sql, params)
|
||||
return cursor.fetchall()
|
||||
|
||||
@staticmethod
|
||||
def get_inquiry_detail_logic(cursor, inquiry_id):
|
||||
cursor.execute(InquiryQueries.SELECT_BY_ID, (inquiry_id,))
|
||||
return cursor.fetchone()
|
||||
|
||||
@staticmethod
|
||||
def update_inquiry_reply_logic(cursor, conn, inquiry_id, req):
|
||||
handled_date = datetime.now().strftime("%Y.%m.%d")
|
||||
cursor.execute(InquiryQueries.UPDATE_REPLY, (req.reply, req.status, req.handler, handled_date, inquiry_id))
|
||||
conn.commit()
|
||||
return {"success": True}
|
||||
|
||||
@staticmethod
|
||||
def delete_inquiry_reply_logic(cursor, conn, inquiry_id):
|
||||
cursor.execute(InquiryQueries.DELETE_REPLY, (inquiry_id,))
|
||||
conn.commit()
|
||||
return {"success": True}
|
||||
|
||||
948
js/analysis.js
948
js/analysis.js
@@ -1,463 +1,485 @@
|
||||
/**
|
||||
* Project Master Analysis JS
|
||||
* AVI (Activity Vitality Index) & VCI (Value Contribution Index) 분석 엔진
|
||||
* OCI (Operational Consistency Index) 통합 버전
|
||||
*/
|
||||
|
||||
// Chart.js 플러그인 전역 등록
|
||||
if (typeof ChartDataLabels !== 'undefined') {
|
||||
Chart.register(ChartDataLabels);
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
console.log("Business Analysis Engine initialized...");
|
||||
loadProjectAnalysisData();
|
||||
});
|
||||
|
||||
async function loadProjectAnalysisData() {
|
||||
try {
|
||||
const response = await fetch('/api/analysis/p-war');
|
||||
const data = await response.json();
|
||||
if (data.error) throw new Error(data.error);
|
||||
|
||||
renderVitalityLeaderboard(data);
|
||||
renderValueCharts(data);
|
||||
|
||||
if (data.length > 0 && data[0].avg_info) {
|
||||
const avg = data[0].avg_info;
|
||||
const infoEl = document.getElementById('avg-system-info');
|
||||
if (infoEl) infoEl.textContent = `* 시스템 종합 자산 건전도: ${avg.avg_risk}% (운영 표준 70.0% 대비)`;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("분석 데이터 로딩 실패:", e);
|
||||
}
|
||||
}
|
||||
|
||||
function getStatusInfo(avi, isAutoDelete) {
|
||||
if (isAutoDelete || avi < 10) return { label: '사망', class: 'badge-system', key: 'dead' };
|
||||
if (avi < 30) return { label: '위험 노출', class: 'badge-danger', key: 'danger' };
|
||||
if (avi < 70) return { label: '관리 주의', class: 'badge-warning', key: 'warning' };
|
||||
return { label: '정상 운영', class: 'badge-active', key: 'active' };
|
||||
}
|
||||
|
||||
function getVciGrade(vci) {
|
||||
if (vci >= 10) return { label: 'Masterpiece', class: 'grade-mvp', desc: '시스템 가치를 견인하는 최우량 핵심 자산' };
|
||||
if (vci >= 2) return { label: 'Blue Chip', class: 'grade-allstar', desc: '꾸준한 활력으로 가치를 창출하는 우량 자산' };
|
||||
if (vci >= -2) return { label: 'Steady', class: 'grade-starter', desc: '표준 수준의 운영을 유지 중인 안정 자산' };
|
||||
if (vci >= -10) return { label: 'Underperform', class: 'grade-bench', desc: '규모 대비 활력 부족으로 가치가 하락 중인 자산' };
|
||||
return { label: 'Liability', class: 'grade-out', desc: '가치를 훼손 중인 고위험 방치 자산' };
|
||||
}
|
||||
|
||||
function renderValueCharts(data) {
|
||||
if (!data || data.length === 0) return;
|
||||
|
||||
// 1. 운영 활력 분포 (Doughnut)
|
||||
try {
|
||||
const stats = { active: [], warning: [], danger: [], dead: [] };
|
||||
data.forEach(p => {
|
||||
const status = getStatusInfo(p.p_war, p.is_auto_delete);
|
||||
stats[status.key].push(p);
|
||||
});
|
||||
|
||||
const statusCtx = document.getElementById('statusChart').getContext('2d');
|
||||
if (window.myStatusChart) window.myStatusChart.destroy();
|
||||
|
||||
window.myStatusChart = new Chart(statusCtx, {
|
||||
type: 'doughnut',
|
||||
data: {
|
||||
labels: ['정상 운영', '관리 주의', '위험 노출', '사망'],
|
||||
datasets: [{
|
||||
data: [stats.active.length, stats.warning.length, stats.danger.length, stats.dead.length],
|
||||
backgroundColor: ['#1E5149', '#22c55e', '#f59e0b', '#ef4444'],
|
||||
borderWidth: 0
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
layout: { padding: 15 },
|
||||
plugins: {
|
||||
legend: { position: 'right', labels: { boxWidth: 10, font: { size: 11, weight: '700' }, usePointStyle: true } },
|
||||
datalabels: { display: false }
|
||||
},
|
||||
cutout: '65%',
|
||||
onClick: (e, elements) => {
|
||||
if (elements.length > 0) {
|
||||
const idx = elements[0].index;
|
||||
openProjectListModal(['정상 운영', '관리 주의', '위험 노출', '사망'][idx], stats[['active', 'warning', 'danger', 'dead'][idx]]);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (err) { console.error("도넛 차트 에러:", err); }
|
||||
|
||||
// 2. 전략적 자산 매트릭스 (Scatter) - 정밀 복구
|
||||
try {
|
||||
const sortedByAVI = [...data].sort((a, b) => b.p_war - a.p_war);
|
||||
const top5Ids = sortedByAVI.slice(0, 5).map(p => p.project_nm);
|
||||
const bottom5Ids = sortedByAVI.slice(-5).map(p => p.project_nm);
|
||||
const largeProjects = data.filter(p => p.file_count > 450).map(p => p.project_nm);
|
||||
const vipProjectNames = new Set([...top5Ids, ...bottom5Ids, ...largeProjects]);
|
||||
|
||||
const scatterData = data.map(p => {
|
||||
const vci = p.risk_count || 0;
|
||||
const absVci = Math.abs(vci);
|
||||
return {
|
||||
x: Math.min(500, p.file_count),
|
||||
y: p.p_war,
|
||||
label: p.project_nm,
|
||||
isVip: vipProjectNames.has(p.project_nm),
|
||||
vci: vci,
|
||||
radius: Math.max(5, Math.min(25, 5 + (absVci / 10)))
|
||||
};
|
||||
});
|
||||
|
||||
const vitalityCtx = document.getElementById('forecastChart').getContext('2d');
|
||||
if (window.myVitalityChart) window.myVitalityChart.destroy();
|
||||
|
||||
window.myVitalityChart = new Chart(vitalityCtx, {
|
||||
type: 'scatter',
|
||||
data: {
|
||||
datasets: [{
|
||||
data: scatterData,
|
||||
backgroundColor: (ctx) => {
|
||||
const p = ctx.raw;
|
||||
if (!p) return '#94a3b8';
|
||||
if (p.x >= 250 && p.y >= 50) return '#1E5149';
|
||||
if (p.x < 250 && p.y >= 50) return '#22c55e';
|
||||
if (p.x < 250 && p.y < 50) return '#94a3b8';
|
||||
return '#ef4444';
|
||||
},
|
||||
pointRadius: (ctx) => ctx.raw ? ctx.raw.radius : 5,
|
||||
hoverRadius: (ctx) => (ctx.raw ? ctx.raw.radius : 5) + 3
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
layout: { padding: { top: 30, right: 45, left: 10, bottom: 10 } },
|
||||
scales: {
|
||||
x: {
|
||||
type: 'linear', min: 0, max: 500,
|
||||
title: { display: true, text: '자산 규모 (파일 수)', font: { size: 11, weight: '700' } },
|
||||
grid: { display: false }
|
||||
},
|
||||
y: {
|
||||
min: 0, max: 100,
|
||||
title: { display: true, text: '운영 활력 (AVI %)', font: { size: 11, weight: '700' } },
|
||||
grid: { display: false }
|
||||
}
|
||||
},
|
||||
plugins: {
|
||||
legend: { display: false },
|
||||
datalabels: {
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.8)',
|
||||
borderRadius: 4, padding: 4,
|
||||
font: { size: 10, weight: '800' },
|
||||
formatter: (v) => v ? v.label : '',
|
||||
display: (ctx) => ctx.raw && ctx.raw.isVip,
|
||||
clip: false
|
||||
},
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
label: (ctx) => ` [${ctx.raw.label}] AVI: ${ctx.raw.y.toFixed(1)}% | VCI: ${ctx.raw.vci.toFixed(1)}`
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
plugins: [{
|
||||
id: 'quadrants',
|
||||
beforeDraw: (chart) => {
|
||||
const { ctx, chartArea: { left, top, right, bottom }, scales: { x, y } } = chart;
|
||||
const midX = x.getPixelForValue(250);
|
||||
const midY = y.getPixelForValue(50);
|
||||
ctx.save();
|
||||
ctx.fillStyle = 'rgba(34, 197, 94, 0.03)'; ctx.fillRect(left, top, midX - left, midY - top);
|
||||
ctx.fillStyle = 'rgba(30, 81, 73, 0.03)'; ctx.fillRect(midX, top, right - midX, midY - top);
|
||||
ctx.fillStyle = 'rgba(148, 163, 184, 0.03)'; ctx.fillRect(left, midY, midX - left, bottom - midY);
|
||||
ctx.fillStyle = 'rgba(239, 68, 68, 0.05)'; ctx.fillRect(midX, midY, right - midX, bottom - midY);
|
||||
ctx.lineWidth = 2; ctx.strokeStyle = 'rgba(0,0,0,0.1)'; ctx.beginPath();
|
||||
ctx.moveTo(midX, top); ctx.lineTo(midX, bottom); ctx.moveTo(left, midY); ctx.lineTo(right, midY); ctx.stroke();
|
||||
ctx.font = 'bold 12px Pretendard'; ctx.textAlign = 'center'; ctx.fillStyle = 'rgba(0,0,0,0.2)';
|
||||
ctx.fillText('활력 양호', (left + midX) / 2, (top + midY) / 2);
|
||||
ctx.fillText('핵심 가치', (midX + right) / 2, (top + midY) / 2);
|
||||
ctx.fillText('정체/소규모', (left + midX) / 2, (midY + bottom) / 2);
|
||||
ctx.fillStyle = 'rgba(239, 68, 68, 0.4)'; ctx.fillText('자산 손실 위험', (midX + right) / 2, (midY + bottom) / 2);
|
||||
ctx.restore();
|
||||
}
|
||||
}]
|
||||
});
|
||||
} catch (err) { console.error("전략 매트릭스 에러:", err); }
|
||||
}
|
||||
|
||||
function renderVitalityLeaderboard(data) {
|
||||
const container = document.getElementById('p-war-table-container');
|
||||
if (!container) return;
|
||||
const sortedData = [...data].sort((a, b) => a.p_war - b.p_war);
|
||||
|
||||
container.innerHTML = `
|
||||
<div class="table-scroll-wrapper">
|
||||
<table class="data-table p-war-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width: 250px;">프로젝트명</th>
|
||||
<th>파일 수</th>
|
||||
<th>정체 일수</th>
|
||||
<th>상태 판정</th>
|
||||
<th style="text-align:right;">가치 기여 (VCI) <button class="btn-help" onclick="event.stopPropagation(); openAnalysisModal('vci')">?</button></th>
|
||||
<th>운영 활력 (AVI) <button class="btn-help" onclick="event.stopPropagation(); openAnalysisModal('avi')">?</button></th>
|
||||
<th style="text-align:center;">업무 집중도 <button class="btn-help" onclick="event.stopPropagation(); openAnalysisModal('focus')">?</button></th>
|
||||
<th>운영 일관성 (OCI) <button class="btn-help" onclick="event.stopPropagation(); openAnalysisModal('oci')">?</button></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${sortedData.map((p, idx) => {
|
||||
const status = getStatusInfo(p.p_war, p.is_auto_delete);
|
||||
const avi = p.p_war;
|
||||
const vci = p.risk_count;
|
||||
const oci = p.oci_score || 0;
|
||||
const rowId = `project-${idx}`;
|
||||
const grade = getVciGrade(vci);
|
||||
|
||||
let rhythmLabel = oci >= 80 ? "정기적" : oci >= 50 ? "안정적" : oci >= 20 ? "간헐적" : "불규칙";
|
||||
let rhythmColor = oci >= 80 ? "#059669" : oci >= 50 ? "#1e5149" : oci >= 20 ? "#f59e0b" : "#dc2626";
|
||||
|
||||
// 존재 신뢰도 패널티 (ECV) 상세 설명 복구
|
||||
let ecvText = "100% (데이터 실체 검증)";
|
||||
let ecvClass = "highlight-val";
|
||||
let ecvDesc = `현재 ${p.file_count}개의 유효 성과물이 확인됩니다. 시스템적으로 실체가 완벽히 존재하는 상태입니다.`;
|
||||
if (p.file_count === 0) {
|
||||
ecvText = "5% (유령 프로젝트 판명)";
|
||||
ecvClass = "highlight-penalty";
|
||||
ecvDesc = "데이터가 전무하여 프로젝트의 디지털 실체가 없습니다. 모든 분석에서 최하위 패널티가 적용됩니다.";
|
||||
} else if (p.file_count < 10) {
|
||||
ecvText = "40% (형식적 껍데기 판명)";
|
||||
ecvClass = "highlight-penalty";
|
||||
ecvDesc = "최소 수준의 문서만 존재하며, 실질적인 운영 가치를 인정하기 어려운 소규모 상태입니다.";
|
||||
}
|
||||
|
||||
// 활동 품질 텍스트 복구
|
||||
const qualityLabel = p.log_quality >= 1.0 ? '성과물 중심의 <b>실무 활동</b>' : p.log_quality >= 0.7 ? '구조 관리를 위한 <b>시스템 활동</b>' : '단순 행정 기반의 <b>형식 활동</b>';
|
||||
const qualityDetail = p.log_quality >= 1.0 ? '최근 로그에서 파일 업로드/수정 등 가치 증분 활동이 명확히 포착되었습니다.' : p.log_quality >= 0.7 ? '폴더 생성/이동 등 구조적 관리는 이뤄지고 있으나, 직접적 결과물 생산은 부족합니다.' : '메일 확인, 권한 변경 등 시스템 유지성 활동 위주로 파악되어 품질 가중치가 낮게 적용되었습니다.';
|
||||
|
||||
return `
|
||||
<tr class="project-row ${status.key === 'danger' ? 'row-danger' : status.key === 'warning' ? 'row-warning' : ''}" onclick="toggleProjectDetail('${rowId}')">
|
||||
<td class="font-bold">${p.project_nm}</td>
|
||||
<td>${p.file_count.toLocaleString()}개</td>
|
||||
<td>${p.days_stagnant}일</td>
|
||||
<td><span class="${status.class}">${status.label}</span></td>
|
||||
<td style="text-align:right; font-weight:800; color:${vci >= 0 ? '#059669' : '#dc2626'};">
|
||||
${vci > 0 ? '+' : ''}${vci.toFixed(1)}
|
||||
</td>
|
||||
<td class="p-war-value ${avi >= 70 ? 'text-plus' : 'text-minus'}">${avi.toFixed(1)}%</td>
|
||||
<td style="text-align:center;">
|
||||
<div style="display:flex; flex-direction:column; align-items:center; gap:4px;">
|
||||
<span style="font-weight:800; font-size:12px; color:${p.work_effort >= 70 ? '#059669' : p.work_effort <= 30 ? '#dc2626' : '#6366f1'};">${p.work_effort}%</span>
|
||||
<div style="width:40px; height:4px; background:#f1f5f9; border-radius:2px; overflow:hidden;">
|
||||
<div style="width:${p.work_effort}%; height:100%; background:${p.work_effort >= 70 ? '#059669' : p.work_effort <= 30 ? '#dc2626' : '#6366f1'};"></div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td style="text-align:center;">
|
||||
<div style="display:flex; align-items:center; justify-content:center; gap:8px;">
|
||||
<span style="font-weight:800; font-size:13px; color:${rhythmColor};">${oci}%</span>
|
||||
<span style="font-size:10px; padding:2px 6px; border-radius:10px; background:${rhythmColor}15; color:${rhythmColor}; border:1px solid ${rhythmColor}30;">${rhythmLabel}</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr id="detail-${rowId}" class="detail-row">
|
||||
<td colspan="8">
|
||||
<div class="detail-container">
|
||||
<div class="formula-explanation-card">
|
||||
<div class="formula-header">⚙️ AI 위험 적응형 모델(AAS) 기반 인과관계 분석</div>
|
||||
|
||||
<div style="display: flex; gap: 20px; margin-bottom: 20px;">
|
||||
<div class="work-effort-section" style="flex: 1; margin-bottom: 0;">
|
||||
<div class="work-effort-header">
|
||||
<span style="font-size: 13px; font-weight: 800; color: #1e5149;">📊 실질 업무 집중도 (Job Focus)</span>
|
||||
<span style="font-size: 14px; font-weight: 800; color: ${p.work_effort >= 70 ? '#059669' : p.work_effort <= 30 ? '#dc2626' : '#6366f1'};">${p.work_effort}%</span>
|
||||
</div>
|
||||
<div class="work-effort-bar-bg"><div style="width: ${p.work_effort}%; height: 100%; background: ${p.work_effort >= 70 ? '#059669' : p.work_effort <= 30 ? '#dc2626' : '#6366f1'};"></div></div>
|
||||
<div style="font-size: 11px; color: #64748b; line-height: 1.5;">최근 30회 수집 이력 중 단순 로그 갱신이 아닌 <b>실제 성과물의 변동</b>이 포착된 날의 비율입니다. 이는 운영의 '진정성'을 보여주는 핵심 지표입니다.</div>
|
||||
</div>
|
||||
<div style="flex: 1; background: #f8fafc; border-radius: 8px; padding: 16px; border: 1px solid #e2e8f0; display: flex; align-items: center; gap: 15px;">
|
||||
<div style="text-align: center;">
|
||||
<div style="font-size: 10px; color: #64748b; font-weight: 700; margin-bottom: 2px;">VCI GRADE</div>
|
||||
<div class="grade-badge ${grade.class}" style="padding: 4px 12px; border-radius: 6px; font-weight: 900; font-size: 14px; display: inline-block;">${grade.label}</div>
|
||||
</div>
|
||||
<div style="font-size: 12px; color: #475569; line-height: 1.4; font-weight: 600;">${grade.desc}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="formula-steps-grid">
|
||||
<div class="formula-step">
|
||||
<div class="step-num">1</div>
|
||||
<div class="step-content">
|
||||
<div class="step-title">동적 위험 계수(λ) 산출</div>
|
||||
<div style="font-size:11px; color:#64748b; margin-bottom:5px;">프로젝트 규모가 클수록 정보 망실 시의 충격을 반영하여 데이터의 하락 속도가 가속됩니다. 현재 <b>λ=${p.ai_lambda.toFixed(4)}</b>는 귀하의 자산 규모가 정밀하게 투영된 결과입니다.</div>
|
||||
<div class="math-logic">Dynamic λ = <span class="highlight-var">${p.ai_lambda.toFixed(4)}</span></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="formula-step">
|
||||
<div class="step-num">4</div>
|
||||
<div class="step-content">
|
||||
<div class="step-title">활동 품질 검증 (Quality)</div>
|
||||
<div class="step-desc" style="font-size:11px; margin-bottom:5px;">최근 로그 분석 결과 <b>${qualityLabel}</b>으로 판명되었습니다. ${qualityDetail}</div>
|
||||
<div class="math-logic">Quality Factor = <span class="${p.log_quality < 0.7 ? 'highlight-penalty' : 'highlight-val'}">${(p.log_quality * 100).toFixed(0)}%</span></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="formula-step">
|
||||
<div class="step-num">2</div>
|
||||
<div class="step-content">
|
||||
<div class="step-title">방치 시간 감쇄 적용</div>
|
||||
<div style="font-size:11px; color:#64748b; margin-bottom:5px;">마지막 유효 활동 이후 <b>${p.days_stagnant}일</b>간의 누적 정체 시간은 지수 감쇄 곡선을 따라 데이터의 최신성과 가치를 상쇄시켰습니다.</div>
|
||||
<div class="math-logic">Decay Result = <span class="highlight-val">${((avi / (p.file_count === 0 ? 0.05 : p.file_count < 10 ? 0.4 : 1) / p.log_quality) || 0).toFixed(1)}%</span></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="formula-step">
|
||||
<div class="step-num">3</div>
|
||||
<div class="step-content">
|
||||
<div class="step-title">존재 진정성 (ECV)</div>
|
||||
<div style="font-size:11px; color:#64748b; margin-bottom:5px;">${ecvDesc} 파일 수 자체가 분석의 데이터 진정성을 보정하는 핵심 팩터로 작용합니다.</div>
|
||||
<div class="math-logic">Entity Factor = <span class="${ecvClass}">${ecvText}</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="margin-top: 20px; padding-top: 15px; border-top: 2px solid #1e5149; text-align: right; display: flex; justify-content: space-between; align-items: center;">
|
||||
<div style="text-align: left; max-width: 70%;">
|
||||
<div style="font-size: 13px; font-weight: 800; color: ${vci >= 0 ? '#059669' : '#dc2626'}; margin-bottom: 4px;">
|
||||
가치 기여도 (VCI) 진단: ${vci >= 0 ? '+' : ''}${vci.toFixed(2)}
|
||||
</div>
|
||||
<div style="font-size: 11px; color: #64748b; line-height: 1.5;">
|
||||
현재 프로젝트는 운영 표준(AVI 70%) 대비 <b>${Math.abs(avi - 70).toFixed(1)}%p ${avi >= 70 ? '상회' : '하회'}</b>하고 있으며,
|
||||
<b>${p.file_count}개</b>의 자산 규모에 따른 <b>${((p.file_count / 200) + 0.5).toFixed(2)}배</b>의 가중치가 적용되었습니다.
|
||||
이는 시스템 전체 관점에서 <b>${vci >= 0 ? '순자산 가치를 증대' : '잠재적 기회비용을 손실'}</b>시키고 있는 상태로 분석됩니다.
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<span style="font-size: 13px; color: #64748b; font-weight: 700;">최종 AVI: </span>
|
||||
<span style="color: #1e5149; font-size: 22px; font-weight: 900;">${avi.toFixed(1)}%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>`;
|
||||
}).join('')}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function toggleProjectDetail(rowId) {
|
||||
const container = document.querySelector('.table-scroll-wrapper');
|
||||
const mainRow = document.querySelector(`tr[onclick*="toggleProjectDetail('${rowId}')"]`);
|
||||
const detailRow = document.getElementById(`detail-${rowId}`);
|
||||
|
||||
if (detailRow && container) {
|
||||
if (!detailRow.classList.contains('active')) {
|
||||
document.querySelectorAll('.detail-row').forEach(row => row.classList.remove('active'));
|
||||
detailRow.classList.add('active');
|
||||
|
||||
// 정밀 스크롤 이동 로직 복구
|
||||
setTimeout(() => {
|
||||
const headerH = container.querySelector('thead').offsetHeight || 45;
|
||||
const targetScrollTop = mainRow.offsetTop - headerH;
|
||||
container.scrollTo({ top: targetScrollTop, behavior: 'smooth' });
|
||||
}, 100);
|
||||
} else {
|
||||
detailRow.classList.remove('active');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function openProjectListModal(label, projects) {
|
||||
const modal = document.getElementById('analysisModal');
|
||||
const title = document.getElementById('modalTitle');
|
||||
const body = document.getElementById('modalBody');
|
||||
title.innerText = `[${label}] 프로젝트 리스트 (${projects.length}건)`;
|
||||
body.innerHTML = `
|
||||
<div class="table-scroll-wrapper" style="max-height: 400px;">
|
||||
<table class="data-table">
|
||||
<thead><tr><th>프로젝트명</th><th>관리자</th><th>정체일</th><th>AVI</th></tr></thead>
|
||||
<tbody>${projects.map(p => `<tr><td class="font-bold">${p.project_nm}</td><td>${p.master || '-'}</td><td>${p.days_stagnant}일</td><td style="font-weight:700; color:#1e5149;">${p.p_war.toFixed(1)}%</td></tr>`).join('')}</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="modal-footer"><button class="btn btn-primary" onclick="closeAnalysisModal()">닫기</button></div>`;
|
||||
modal.style.display = 'flex';
|
||||
}
|
||||
|
||||
function openAnalysisModal(type) {
|
||||
const modal = document.getElementById('analysisModal');
|
||||
const title = document.getElementById('modalTitle');
|
||||
const body = document.getElementById('modalBody');
|
||||
|
||||
if (type === 'avi') {
|
||||
title.innerText = '운영 활력 지수 (AVI) 등급 가이드';
|
||||
body.innerHTML = `
|
||||
<div class="formula-box" style="margin-bottom:15px;">AVI = exp(-λ × days) × Quality × 100</div>
|
||||
<p style="font-size:13px; color:#64748b; margin-bottom:15px;">자산의 가동 상태와 생존율을 나타내는 지표입니다.</p>
|
||||
<table class="data-table" style="font-size:12px;">
|
||||
<thead><tr style="background:#f8fafc;"><th>지수 (AVI)</th><th>등급</th><th>운영 상태</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td>90%↑</td><td style="font-weight:900; color:#059669;">Live</td><td>실시간 성과물이 도출되는 최상급 가동</td></tr>
|
||||
<tr><td>70~90%</td><td style="font-weight:900; color:#1e5149;">Stable</td><td>주기적 업데이트가 이뤄지는 표준 안정</td></tr>
|
||||
<tr><td>30~70%</td><td style="font-weight:900; color:#f59e0b;">Idle</td><td>관리가 필요한 유휴/정체 상태</td></tr>
|
||||
<tr><td>10~30%</td><td style="font-weight:900; color:#dc2626;">Risk</td><td>자산 가치 소멸 직전의 위험 상태</td></tr>
|
||||
<tr><td>10%↓</td><td style="font-weight:900; color:#64748b;">Frozen</td><td>운영이 중단된 사망/방치 상태</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="modal-footer"><button class="btn btn-primary" onclick="closeAnalysisModal()">닫기</button></div>`;
|
||||
} else if (type === 'vci') {
|
||||
title.innerText = '자산 가치 기여도 (VCI) 등급 가이드';
|
||||
body.innerHTML = `
|
||||
<div class="formula-box" style="margin-bottom:15px;">VCI = (AVI - 70.0) × (Files / 200 + 0.5)</div>
|
||||
<p style="font-size:13px; color:#64748b; margin-bottom:15px;">운영 표준(AVI 70%) 대비 자산 가치 기여도에 따른 프로젝트 위상 분류입니다.</p>
|
||||
<table class="data-table" style="font-size:12px;">
|
||||
<thead><tr style="background:#f8fafc;"><th>점수 (VCI)</th><th>등급</th><th>운영 의미</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td>+10.0↑</td><td style="font-weight:900; color:#6366f1;">Masterpiece</td><td>시스템 가치를 견인하는 최우량 핵심 자산</td></tr>
|
||||
<tr><td>+2.0 ~ +10.0</td><td style="font-weight:900; color:#059669;">Blue Chip</td><td>꾸준한 활력으로 가치를 창출하는 우량 자산</td></tr>
|
||||
<tr><td>-2.0 ~ +2.0</td><td style="font-weight:900; color:#475569;">Steady</td><td>표준 수준의 운영을 유지 중인 안정 자산</td></tr>
|
||||
<tr><td>-10.0 ~ -2.0</td><td style="font-weight:900; color:#f59e0b;">Underperform</td><td>규모 대비 활력 부족으로 가치 하락 중인 자산</td></tr>
|
||||
<tr><td>-10.0↓</td><td style="font-weight:900; color:#dc2626;">Liability</td><td>가치를 훼손 중인 고위험 방치 자산</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="modal-footer"><button class="btn btn-primary" onclick="closeAnalysisModal()">닫기</button></div>`;
|
||||
} else if (type === 'oci') {
|
||||
title.innerText = '운영 일관성 지수 (OCI) 분석 가이드';
|
||||
body.innerHTML = `
|
||||
<div style="background:#f0fdf4; padding:15px; border-radius:8px; margin-bottom:15px;">
|
||||
<strong style="color:#166534; display:block; margin-bottom:5px;">"얼마나 꾸준하게 관리되고 있는가?"</strong>
|
||||
<p style="font-size:12.5px; color:#166534; margin:0;">미래 예측이 아닌, 최근 30일간의 <b>활동 리듬</b>과 <b>관리의 규칙성</b>을 분석하여 성실도를 점수화합니다.</p>
|
||||
</div>
|
||||
<table class="data-table" style="font-size:12px;">
|
||||
<thead><tr style="background:#f8fafc;"><th>분석 결과</th><th>일관성 등급</th><th>관리 신뢰도</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td style="color:#059669;">80%↑</td><td style="font-weight:900; color:#059669;">매우 우수</td><td>주 단위의 정기적 관리가 완벽히 이뤄짐</td></tr>
|
||||
<tr><td style="color:#1e5149;">50~80%</td><td style="font-weight:900; color:#1e5149;">양호</td><td>간헐적 정체는 있으나 꾸준히 관리됨</td></tr>
|
||||
<tr><td style="color:#f59e0b;">20~50%</td><td style="font-weight:900; color:#f59e0b;">주의</td><td>돌발적 활동 위주, 관리의 리듬이 깨짐</td></tr>
|
||||
<tr><td style="color:#dc2626;">20%↓</td><td style="font-weight:900; color:#dc2626;">매우 불량</td><td>장기 정체 중이거나 관리 의지 확인 불가</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="modal-footer"><button class="btn btn-primary" onclick="closeAnalysisModal()">닫기</button></div>`;
|
||||
} else {
|
||||
title.innerText = '업무 집중도 (Job Focus) 등급 가이드';
|
||||
body.innerHTML = `
|
||||
<p style="font-size:13px; color:#64748b; margin-bottom:15px;">최근 수집 로그 중 단순 행정 로그를 제외하고 실질적인 성과물(파일) 변동이 포착된 비율입니다.</p>
|
||||
<table class="data-table" style="font-size:12px;">
|
||||
<thead><tr style="background:#f8fafc;"><th>비율 (%)</th><th>등급</th><th>활동 성격</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td>80%↑</td><td style="font-weight:900; color:#6366f1;">Intensive</td><td>성과물 위주의 고밀도 집중 작업</td></tr>
|
||||
<tr><td>50~80%</td><td style="font-weight:900; color:#059669;">Active</td><td>성과와 관리가 균형 잡힌 원활한 실행</td></tr>
|
||||
<tr><td>20~50%</td><td style="font-weight:900; color:#f59e0b;">Maintenance</td><td>설정/행정 등 단순 관리 중심의 작업</td></tr>
|
||||
<tr><td>20%↓</td><td style="font-weight:900; color:#dc2626;">Surface</td><td>실체적 변화가 적은 형식적 로그 중심</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="modal-footer"><button class="btn btn-primary" onclick="closeAnalysisModal()">닫기</button></div>`;
|
||||
}
|
||||
modal.style.display = 'flex';
|
||||
}
|
||||
|
||||
function closeAnalysisModal() { document.getElementById('analysisModal').style.display = 'none'; }
|
||||
/**
|
||||
* Project Master Analysis JS
|
||||
* AVI (Activity Vitality Index) & VCI (Value Contribution Index) 분석 엔진
|
||||
* OCI (Operational Consistency Index) 통합 버전
|
||||
*/
|
||||
|
||||
// Chart.js 플러그인 전역 등록
|
||||
if (typeof ChartDataLabels !== 'undefined') {
|
||||
Chart.register(ChartDataLabels);
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
console.log("Business Analysis Engine initialized...");
|
||||
loadProjectAnalysisData();
|
||||
});
|
||||
|
||||
async function loadProjectAnalysisData() {
|
||||
try {
|
||||
const response = await fetch('/api/analysis/p-war');
|
||||
const data = await response.json();
|
||||
if (data.error) throw new Error(data.error);
|
||||
|
||||
renderVitalityLeaderboard(data);
|
||||
renderValueCharts(data);
|
||||
|
||||
if (data.length > 0 && data[0].avg_info) {
|
||||
const avg = data[0].avg_info;
|
||||
const infoEl = document.getElementById('avg-system-info');
|
||||
if (infoEl) infoEl.textContent = `* 시스템 종합 운영 활력(AVI): ${avg.avg_risk}% (평균 관리 수준)`;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("분석 데이터 로딩 실패:", e);
|
||||
}
|
||||
}
|
||||
|
||||
function getStatusInfo(avi, isAutoDelete) {
|
||||
if (isAutoDelete || avi < 10) return { label: '사망', class: 'badge-system', key: 'dead' };
|
||||
if (avi < 30) return { label: '위험 노출', class: 'badge-danger', key: 'danger' };
|
||||
if (avi < 70) return { label: '관리 주의', class: 'badge-warning', key: 'warning' };
|
||||
return { label: '정상 운영', class: 'badge-active', key: 'active' };
|
||||
}
|
||||
|
||||
function getVciGrade(vci) {
|
||||
if (vci >= 10) return { label: 'Masterpiece', class: 'grade-mvp', desc: '시스템 가치를 견인하는 최우량 핵심 자산' };
|
||||
if (vci >= 2) return { label: 'Blue Chip', class: 'grade-allstar', desc: '꾸준한 활력으로 가치를 창출하는 우량 자산' };
|
||||
if (vci >= -2) return { label: 'Steady', class: 'grade-starter', desc: '표준 수준의 운영을 유지 중인 안정 자산' };
|
||||
if (vci >= -10) return { label: 'Underperform', class: 'grade-bench', desc: '규모 대비 활력 부족으로 가치가 하락 중인 자산' };
|
||||
return { label: 'Liability', class: 'grade-out', desc: '가치를 훼손 중인 고위험 방치 자산' };
|
||||
}
|
||||
|
||||
function renderValueCharts(data) {
|
||||
if (!data || data.length === 0) return;
|
||||
|
||||
// 1. 운영 활력 분포 (Doughnut)
|
||||
try {
|
||||
const stats = { active: [], warning: [], danger: [], dead: [] };
|
||||
data.forEach(p => {
|
||||
const status = getStatusInfo(p.p_war, p.is_auto_delete);
|
||||
stats[status.key].push(p);
|
||||
});
|
||||
|
||||
const statusCtx = document.getElementById('statusChart').getContext('2d');
|
||||
if (window.myStatusChart) window.myStatusChart.destroy();
|
||||
|
||||
window.myStatusChart = new Chart(statusCtx, {
|
||||
type: 'doughnut',
|
||||
data: {
|
||||
labels: ['정상 운영', '관리 주의', '위험 노출', '사망'],
|
||||
datasets: [{
|
||||
data: [stats.active.length, stats.warning.length, stats.danger.length, stats.dead.length],
|
||||
backgroundColor: ['#1E5149', '#22c55e', '#f59e0b', '#ef4444'],
|
||||
borderWidth: 0
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
layout: { padding: 15 },
|
||||
plugins: {
|
||||
legend: { position: 'right', labels: { boxWidth: 10, font: { size: 11, weight: '700' }, usePointStyle: true } },
|
||||
datalabels: { display: false }
|
||||
},
|
||||
cutout: '65%',
|
||||
onClick: (e, elements) => {
|
||||
if (elements.length > 0) {
|
||||
const idx = elements[0].index;
|
||||
openProjectListModal(['정상 운영', '관리 주의', '위험 노출', '사망'][idx], stats[['active', 'warning', 'danger', 'dead'][idx]]);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (err) { console.error("도넛 차트 에러:", err); }
|
||||
|
||||
// 2. 전략적 자산 매트릭스 (Scatter) - 정밀 복구
|
||||
try {
|
||||
const sortedByAVI = [...data].sort((a, b) => b.p_war - a.p_war);
|
||||
const top5Ids = sortedByAVI.slice(0, 5).map(p => p.project_nm);
|
||||
const bottom5Ids = sortedByAVI.slice(-5).map(p => p.project_nm);
|
||||
const largeProjects = data.filter(p => p.file_count > 450).map(p => p.project_nm);
|
||||
const vipProjectNames = new Set([...top5Ids, ...bottom5Ids, ...largeProjects]);
|
||||
|
||||
const scatterData = data.map(p => {
|
||||
const vci = p.risk_count || 0;
|
||||
const absVci = Math.abs(vci);
|
||||
return {
|
||||
x: Math.min(500, p.file_count),
|
||||
y: p.p_war,
|
||||
label: p.project_nm,
|
||||
isVip: vipProjectNames.has(p.project_nm),
|
||||
vci: vci,
|
||||
radius: Math.max(5, Math.min(25, 5 + (absVci / 10)))
|
||||
};
|
||||
});
|
||||
|
||||
const vitalityCtx = document.getElementById('forecastChart').getContext('2d');
|
||||
if (window.myVitalityChart) window.myVitalityChart.destroy();
|
||||
|
||||
window.myVitalityChart = new Chart(vitalityCtx, {
|
||||
type: 'scatter',
|
||||
data: {
|
||||
datasets: [{
|
||||
data: scatterData,
|
||||
backgroundColor: (ctx) => {
|
||||
const p = ctx.raw;
|
||||
if (!p) return '#94a3b8';
|
||||
if (p.x >= 250 && p.y >= 50) return '#1E5149';
|
||||
if (p.x < 250 && p.y >= 50) return '#22c55e';
|
||||
if (p.x < 250 && p.y < 50) return '#94a3b8';
|
||||
return '#ef4444';
|
||||
},
|
||||
pointRadius: (ctx) => ctx.raw ? ctx.raw.radius : 5,
|
||||
hoverRadius: (ctx) => (ctx.raw ? ctx.raw.radius : 5) + 3
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
layout: { padding: { top: 30, right: 45, left: 10, bottom: 10 } },
|
||||
scales: {
|
||||
x: {
|
||||
type: 'linear', min: 0, max: 500,
|
||||
title: { display: true, text: '자산 규모 (파일 수)', font: { size: 11, weight: '700' } },
|
||||
grid: { display: false }
|
||||
},
|
||||
y: {
|
||||
min: 0, max: 100,
|
||||
title: { display: true, text: '운영 활력 (AVI %)', font: { size: 11, weight: '700' } },
|
||||
grid: { display: false }
|
||||
}
|
||||
},
|
||||
plugins: {
|
||||
legend: { display: false },
|
||||
datalabels: {
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.8)',
|
||||
borderRadius: 4, padding: 4,
|
||||
font: { size: 10, weight: '800' },
|
||||
formatter: (v) => v ? v.label : '',
|
||||
display: (ctx) => ctx.raw && ctx.raw.isVip,
|
||||
clip: false
|
||||
},
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
label: (ctx) => ` [${ctx.raw.label}] AVI: ${ctx.raw.y.toFixed(1)}% | VCI: ${ctx.raw.vci.toFixed(1)}`
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
plugins: [{
|
||||
id: 'quadrants',
|
||||
beforeDraw: (chart) => {
|
||||
const { ctx, chartArea: { left, top, right, bottom }, scales: { x, y } } = chart;
|
||||
const midX = x.getPixelForValue(250);
|
||||
const midY = y.getPixelForValue(50);
|
||||
ctx.save();
|
||||
ctx.fillStyle = 'rgba(34, 197, 94, 0.03)'; ctx.fillRect(left, top, midX - left, midY - top);
|
||||
ctx.fillStyle = 'rgba(30, 81, 73, 0.03)'; ctx.fillRect(midX, top, right - midX, midY - top);
|
||||
ctx.fillStyle = 'rgba(148, 163, 184, 0.03)'; ctx.fillRect(left, midY, midX - left, bottom - midY);
|
||||
ctx.fillStyle = 'rgba(239, 68, 68, 0.05)'; ctx.fillRect(midX, midY, right - midX, bottom - midY);
|
||||
ctx.lineWidth = 2; ctx.strokeStyle = 'rgba(0,0,0,0.1)'; ctx.beginPath();
|
||||
ctx.moveTo(midX, top); ctx.lineTo(midX, bottom); ctx.moveTo(left, midY); ctx.lineTo(right, midY); ctx.stroke();
|
||||
ctx.font = 'bold 12px Pretendard'; ctx.textAlign = 'center'; ctx.fillStyle = 'rgba(0,0,0,0.2)';
|
||||
ctx.fillText('활력 양호', (left + midX) / 2, (top + midY) / 2);
|
||||
ctx.fillText('핵심 가치', (midX + right) / 2, (top + midY) / 2);
|
||||
ctx.fillText('정체/소규모', (left + midX) / 2, (midY + bottom) / 2);
|
||||
ctx.fillStyle = 'rgba(239, 68, 68, 0.4)'; ctx.fillText('자산 손실 위험', (midX + right) / 2, (midY + bottom) / 2);
|
||||
ctx.restore();
|
||||
}
|
||||
}]
|
||||
});
|
||||
} catch (err) { console.error("전략 매트릭스 에러:", err); }
|
||||
}
|
||||
|
||||
function renderVitalityLeaderboard(data) {
|
||||
const container = document.getElementById('p-war-table-container');
|
||||
if (!container) return;
|
||||
const sortedData = [...data].sort((a, b) => a.p_war - b.p_war);
|
||||
|
||||
container.innerHTML = `
|
||||
<div class="table-scroll-wrapper">
|
||||
<table class="data-table p-war-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width: 250px;">프로젝트명</th>
|
||||
<th>파일 수</th>
|
||||
<th>정체 일수</th>
|
||||
<th>상태 판정</th>
|
||||
<th style="text-align:right;">가치 기여 (VCI) <button class="btn-help" onclick="event.stopPropagation(); openAnalysisModal('vci')">?</button></th>
|
||||
<th>운영 활력 (AVI) <button class="btn-help" onclick="event.stopPropagation(); openAnalysisModal('avi')">?</button></th>
|
||||
<th style="text-align:center;">업무 집중도 <button class="btn-help" onclick="event.stopPropagation(); openAnalysisModal('focus')">?</button></th>
|
||||
<th>운영 일관성 (OCI) <button class="btn-help" onclick="event.stopPropagation(); openAnalysisModal('oci')">?</button></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${sortedData.map((p, idx) => {
|
||||
const status = getStatusInfo(p.p_war, p.is_auto_delete);
|
||||
const avi = p.p_war;
|
||||
const vci = p.risk_count;
|
||||
const oci = p.oci_score || 0;
|
||||
const rowId = `project-${idx}`;
|
||||
const grade = getVciGrade(vci);
|
||||
|
||||
let rhythmLabel = oci >= 80 ? "정기적" : oci >= 50 ? "안정적" : oci >= 20 ? "간헐적" : "불규칙";
|
||||
let rhythmColor = oci >= 80 ? "#059669" : oci >= 50 ? "#1e5149" : oci >= 20 ? "#f59e0b" : "#dc2626";
|
||||
|
||||
// 존재 신뢰도 패널티 (ECV) 상세 설명 복구
|
||||
let ecvText = "100% (데이터 실체 검증)";
|
||||
let ecvClass = "highlight-val";
|
||||
let ecvDesc = `현재 ${p.file_count}개의 유효 성과물이 확인됩니다. 시스템적으로 실체가 완벽히 존재하는 상태입니다.`;
|
||||
if (p.file_count === 0) {
|
||||
ecvText = "5% (유령 프로젝트 판명)";
|
||||
ecvClass = "highlight-penalty";
|
||||
ecvDesc = "데이터가 전무하여 프로젝트의 디지털 실체가 없습니다. 모든 분석에서 최하위 패널티가 적용됩니다.";
|
||||
} else if (p.file_count < 10) {
|
||||
ecvText = "40% (형식적 껍데기 판명)";
|
||||
ecvClass = "highlight-penalty";
|
||||
ecvDesc = "최소 수준의 문서만 존재하며, 실질적인 운영 가치를 인정하기 어려운 소규모 상태입니다.";
|
||||
}
|
||||
|
||||
// 활동 품질 텍스트 복구
|
||||
const qualityLabel = p.log_quality >= 1.0 ? '성과물 중심의 <b>실무 활동</b>' : p.log_quality >= 0.7 ? '구조 관리를 위한 <b>시스템 활동</b>' : '단순 행정 기반의 <b>형식 활동</b>';
|
||||
const qualityDetail = p.log_quality >= 1.0 ? '최근 로그에서 파일 업로드/수정 등 가치 증분 활동이 명확히 포착되었습니다.' : p.log_quality >= 0.7 ? '폴더 생성/이동 등 구조적 관리는 이뤄지고 있으나, 직접적 결과물 생산은 부족합니다.' : '메일 확인, 권한 변경 등 시스템 유지성 활동 위주로 파악되어 품질 가중치가 낮게 적용되었습니다.';
|
||||
|
||||
return `
|
||||
<tr class="project-row ${status.key === 'danger' ? 'row-danger' : status.key === 'warning' ? 'row-warning' : ''}" onclick="toggleProjectDetail('${rowId}')">
|
||||
<td class="font-bold">${p.project_nm}</td>
|
||||
<td>${p.file_count.toLocaleString()}개</td>
|
||||
<td>${p.days_stagnant}일</td>
|
||||
<td><span class="${status.class}">${status.label}</span></td>
|
||||
<td style="text-align:right; font-weight:800; color:${vci >= 0 ? '#059669' : '#dc2626'};">
|
||||
${vci > 0 ? '+' : ''}${vci.toFixed(1)}
|
||||
</td>
|
||||
<td class="p-war-value ${avi >= 70 ? 'text-plus' : 'text-minus'}">${avi.toFixed(1)}%</td>
|
||||
<td style="text-align:center;">
|
||||
<div style="display:flex; flex-direction:column; align-items:center; gap:4px;">
|
||||
<span style="font-weight:800; font-size:12px; color:${p.work_effort >= 70 ? '#059669' : p.work_effort <= 30 ? '#dc2626' : '#6366f1'};">${p.work_effort}%</span>
|
||||
<div style="width:40px; height:4px; background:#f1f5f9; border-radius:2px; overflow:hidden;">
|
||||
<div style="width:${p.work_effort}%; height:100%; background:${p.work_effort >= 70 ? '#059669' : p.work_effort <= 30 ? '#dc2626' : '#6366f1'};"></div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td style="text-align:center;">
|
||||
<div style="display:flex; align-items:center; justify-content:center; gap:8px;">
|
||||
<span style="font-weight:800; font-size:13px; color:${rhythmColor};">${oci}%</span>
|
||||
<span style="font-size:10px; padding:2px 6px; border-radius:10px; background:${rhythmColor}15; color:${rhythmColor}; border:1px solid ${rhythmColor}30;">${rhythmLabel}</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr id="detail-${rowId}" class="detail-row">
|
||||
<td colspan="8">
|
||||
<div class="detail-container">
|
||||
<div class="formula-explanation-card">
|
||||
<div class="formula-header">⚙️ AI 위험 적응형 모델(AAS) 기반 인과관계 분석</div>
|
||||
|
||||
<div style="display: flex; gap: 20px; margin-bottom: 20px;">
|
||||
<div class="work-effort-section" style="flex: 1; margin-bottom: 0;">
|
||||
<div class="work-effort-header">
|
||||
<span style="font-size: 13px; font-weight: 800; color: #1e5149;">📊 실질 업무 집중도 (Job Focus)</span>
|
||||
<span style="font-size: 14px; font-weight: 800; color: ${p.work_effort >= 70 ? '#059669' : p.work_effort <= 30 ? '#dc2626' : '#6366f1'};">${p.work_effort}%</span>
|
||||
</div>
|
||||
<div class="work-effort-bar-bg"><div style="width: ${p.work_effort}%; height: 100%; background: ${p.work_effort >= 70 ? '#059669' : p.work_effort <= 30 ? '#dc2626' : '#6366f1'};"></div></div>
|
||||
<div style="font-size: 11px; color: #64748b; line-height: 1.5;">최근 30회 수집 이력 중 단순 로그 갱신이 아닌 <b>실제 성과물의 변동</b>이 포착된 날의 비율입니다. 이는 운영의 '진정성'을 보여주는 핵심 지표입니다.</div>
|
||||
</div>
|
||||
<div style="flex: 1; background: #f8fafc; border-radius: 8px; padding: 16px; border: 1px solid #e2e8f0; display: flex; align-items: center; gap: 15px;">
|
||||
<div style="text-align: center;">
|
||||
<div style="font-size: 10px; color: #64748b; font-weight: 700; margin-bottom: 2px;">VCI GRADE</div>
|
||||
<div class="grade-badge ${grade.class}" style="padding: 4px 12px; border-radius: 6px; font-weight: 900; font-size: 14px; display: inline-block;">${grade.label}</div>
|
||||
</div>
|
||||
<div style="font-size: 12px; color: #475569; line-height: 1.4; font-weight: 600;">${grade.desc}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="formula-steps-grid">
|
||||
<div class="formula-step">
|
||||
<div class="step-num">1</div>
|
||||
<div class="step-content">
|
||||
<div class="step-title">동적 위험 계수(λ) 산출</div>
|
||||
<div style="font-size:11px; color:#64748b; margin-bottom:5px;">프로젝트 규모가 클수록 정보 망실 시의 충격을 반영하여 데이터의 하락 속도가 가속됩니다. 현재 <b>λ=${p.ai_lambda.toFixed(4)}</b>는 귀하의 자산 규모가 정밀하게 투영된 결과입니다.</div>
|
||||
<div class="math-logic">Dynamic λ = <span class="highlight-var">${p.ai_lambda.toFixed(4)}</span></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="formula-step">
|
||||
<div class="step-num">4</div>
|
||||
<div class="step-content">
|
||||
<div class="step-title">활동 품질 검증 (Quality)</div>
|
||||
<div class="step-desc" style="font-size:11px; margin-bottom:5px;">최근 로그 분석 결과 <b>${qualityLabel}</b>으로 판명되었습니다. ${qualityDetail}</div>
|
||||
<div class="math-logic">Quality Factor = <span class="${p.log_quality < 0.7 ? 'highlight-penalty' : 'highlight-val'}">${(p.log_quality * 100).toFixed(0)}%</span></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="formula-step">
|
||||
<div class="step-num">2</div>
|
||||
<div class="step-content">
|
||||
<div class="step-title">방치 시간 감쇄 적용</div>
|
||||
<div style="font-size:11px; color:#64748b; margin-bottom:5px;">마지막 유효 활동 이후 <b>${p.days_stagnant}일</b>간의 누적 정체 시간은 지수 감쇄 곡선을 따라 데이터의 최신성과 가치를 상쇄시켰습니다.</div>
|
||||
<div class="math-logic">Decay Result = <span class="highlight-val">${((avi / (p.file_count === 0 ? 0.05 : p.file_count < 10 ? 0.4 : 1) / p.log_quality) || 0).toFixed(1)}%</span></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="formula-step">
|
||||
<div class="step-num">3</div>
|
||||
<div class="step-content">
|
||||
<div class="step-title">존재 진정성 (ECV)</div>
|
||||
<div style="font-size:11px; color:#64748b; margin-bottom:5px;">${ecvDesc} 파일 수 자체가 분석의 데이터 진정성을 보정하는 핵심 팩터로 작용합니다.</div>
|
||||
<div class="math-logic">Entity Factor = <span class="${ecvClass}">${ecvText}</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="margin-top: 20px; padding-top: 15px; border-top: 2px solid #1e5149; text-align: right; display: flex; justify-content: space-between; align-items: center;">
|
||||
<div style="text-align: left; max-width: 70%;">
|
||||
<div style="display: flex; align-items: center; gap: 8px; margin-bottom: 4px;">
|
||||
<div style="font-size: 13px; font-weight: 800; color: ${vci >= 0 ? '#059669' : '#dc2626'};">
|
||||
가치 기여도 (VCI) 진단: ${vci >= 0 ? '+' : ''}${vci.toFixed(2)}
|
||||
</div>
|
||||
<div style="font-size: 10px; color: #94a3b8; background: #f8fafc; padding: 2px 6px; border-radius: 4px; border: 1px solid #e2e8f0;">
|
||||
조직 평균 자산: ${p.avg_info.avg_files}개
|
||||
</div>
|
||||
</div>
|
||||
<div style="font-size: 11px; color: #64748b; line-height: 1.5;">
|
||||
현재 프로젝트는 <b>포트폴리오 평균 관리 수준</b> 대비 <b>${Math.abs(vci / Math.max(0.2, p.file_count / p.avg_info.avg_files)).toFixed(1)}%p ${vci >= 0 ? '상회' : '하회'}</b>하고 있으며,
|
||||
<b>${p.file_count}개</b>의 자산 규모에 따른 <b>${Math.max(0.2, p.file_count / p.avg_info.avg_files).toFixed(2)}배</b>의 상대 가중치가 적용되었습니다.
|
||||
이는 시스템 전체 관점에서 <b>${vci >= 0 ? '순자산 가치를 증대' : '잠재적 기회비용을 손실'}</b>시키고 있는 상태로 분석됩니다.
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<span style="font-size: 13px; color: #64748b; font-weight: 700;">최종 AVI: </span>
|
||||
<span style="color: #1e5149; font-size: 22px; font-weight: 900;">${avi.toFixed(1)}%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>`;
|
||||
}).join('')}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function toggleProjectDetail(rowId) {
|
||||
const container = document.querySelector('.table-scroll-wrapper');
|
||||
const mainRow = document.querySelector(`tr[onclick*="toggleProjectDetail('${rowId}')"]`);
|
||||
const detailRow = document.getElementById(`detail-${rowId}`);
|
||||
|
||||
if (detailRow && container) {
|
||||
if (!detailRow.classList.contains('active')) {
|
||||
document.querySelectorAll('.detail-row').forEach(row => row.classList.remove('active'));
|
||||
detailRow.classList.add('active');
|
||||
|
||||
// 정밀 스크롤 이동 로직 복구
|
||||
setTimeout(() => {
|
||||
const headerH = container.querySelector('thead').offsetHeight || 45;
|
||||
const targetScrollTop = mainRow.offsetTop - headerH;
|
||||
container.scrollTo({ top: targetScrollTop, behavior: 'smooth' });
|
||||
}, 100);
|
||||
} else {
|
||||
detailRow.classList.remove('active');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function openProjectListModal(label, projects) {
|
||||
const modal = document.getElementById('analysisModal');
|
||||
const title = document.getElementById('modalTitle');
|
||||
const body = document.getElementById('modalBody');
|
||||
title.innerText = `[${label}] 프로젝트 리스트 (${projects.length}건)`;
|
||||
body.innerHTML = `
|
||||
<div class="table-scroll-wrapper" style="max-height: 400px;">
|
||||
<table class="data-table">
|
||||
<thead><tr><th>프로젝트명</th><th>관리자</th><th>정체일</th><th>AVI</th></tr></thead>
|
||||
<tbody>${projects.map(p => `<tr><td class="font-bold">${p.project_nm}</td><td>${p.master || '-'}</td><td>${p.days_stagnant}일</td><td style="font-weight:700; color:#1e5149;">${p.p_war.toFixed(1)}%</td></tr>`).join('')}</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="modal-footer"><button class="btn btn-primary" onclick="closeAnalysisModal()">닫기</button></div>`;
|
||||
modal.style.display = 'flex';
|
||||
}
|
||||
|
||||
function openAnalysisModal(type) {
|
||||
const modal = document.getElementById('analysisModal');
|
||||
const title = document.getElementById('modalTitle');
|
||||
const body = document.getElementById('modalBody');
|
||||
|
||||
if (type === 'avi') {
|
||||
title.innerText = '운영 활력 지수 (AVI) 분석 가이드';
|
||||
body.innerHTML = `
|
||||
<div class="formula-box" style="margin-bottom:15px; background:#f1f5f9; padding:12px; border-radius:8px; font-family:'Consolas', monospace; font-weight:700; text-align:center;">
|
||||
AVI = exp(-λ × Stagnant Days) × Quality × 100
|
||||
</div>
|
||||
<div style="margin-bottom:15px; font-size:13px; line-height:1.6; color:#334155;">
|
||||
<p style="margin-bottom:8px;"><b>운영 활력 지수(AVI)</b>는 프로젝트가 현재 얼마나 건강하게 가동되고 있는지를 나타내는 '디지털 생존 지표'입니다.</p>
|
||||
<ul style="padding-left:18px; margin-bottom:10px;">
|
||||
<li style="margin-bottom:4px;"><b>지수 감쇄(Exponential Decay)</b>: 마지막 활동 이후 정체 기간이 길어질수록 자산의 최신성과 가치는 기하급수적으로 하락합니다.</li>
|
||||
<li style="margin-bottom:4px;"><b>위험 가속 계수(λ)</b>: 자산 규모(파일 수)가 클수록 관리 부재 시의 정보 망실 위험이 크다고 판단하여, 더 가파른 감쇄 곡선을 적용합니다.</li>
|
||||
<li style="margin-bottom:4px;"><b>활동 품질(Quality Factor)</b>: 단순 행정 로그(권한 변경 등)보다 실무 성과물(파일 업로드 등)이 발생했을 때 지수 복원력을 더 높게 부여합니다.</li>
|
||||
</ul>
|
||||
<p style="margin:0; font-weight:700; color:#1e5149;">※ 70% 미만 하락 시, 해당 프로젝트의 데이터 노후화 및 관리 방치 위험이 시작된 것으로 간주합니다.</p>
|
||||
</div>
|
||||
<table class="data-table" style="font-size:12px; width:100%;">
|
||||
<thead><tr style="background:#f8fafc;"><th>지수 (AVI)</th><th>등급</th><th>운영 상태</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td class="font-bold">90%↑</td><td style="font-weight:900; color:#059669;">Live</td><td>실시간 성과물이 도출되는 최상급 가동 상태</td></tr>
|
||||
<tr><td class="font-bold">70~90%</td><td style="font-weight:900; color:#1e5149;">Stable</td><td>주기적 업데이트가 이뤄지는 표준 안정 상태</td></tr>
|
||||
<tr><td class="font-bold">30~70%</td><td style="font-weight:900; color:#f59e0b;">Idle</td><td>활력이 저하되어 관리가 필요한 정체 상태</td></tr>
|
||||
<tr><td class="font-bold">10~30%</td><td style="font-weight:900; color:#dc2626;">Risk</td><td>데이터 노후화 및 자산 가치 소멸 위험 상태</td></tr>
|
||||
<tr><td class="font-bold">10%↓</td><td style="font-weight:900; color:#64748b;">Frozen</td><td>운영이 중단된 사망/방치 상태</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="modal-footer"><button class="btn btn-primary" onclick="closeAnalysisModal()">닫기</button></div>`;
|
||||
} else if (type === 'vci') {
|
||||
title.innerText = '자산 가치 기여도 (VCI) 분석 가이드';
|
||||
body.innerHTML = `
|
||||
<p style="margin-bottom:12px; font-size:13.5px;">VCI는 야구의 <b>WAR(Wins Above Replacement)</b> 개념을 도입하여, 개별 프로젝트가 전체 포트폴리오 평균 대비 얼마나 조직의 가치에 기여하는지 산출한 지표입니다.</p>
|
||||
<div class="formula-box" style="margin-bottom:15px; background:#f1f5f9; padding:12px; border-radius:8px; font-family:'Consolas', monospace; font-weight:700; text-align:center;">
|
||||
VCI = (현재 AVI - 전체 평균 AVI) × (파일 규모 가중치)
|
||||
</div>
|
||||
<p style="margin-bottom:15px; font-size:13px; line-height:1.6;">
|
||||
• <b>0.0 (평균)</b>: 우리 조직의 평균적인 관리 수준을 유지 중인 상태<br>
|
||||
• <b>(+) 점수</b>: 평균 이상의 활력으로 조직의 디지털 자산 가치를 증대시킴<br>
|
||||
• <b>(-) 점수</b>: 평균 이하의 방치로 인해 잠재적 기회비용 손실 발생 중
|
||||
</p>
|
||||
<table class="data-table" style="font-size:12px; width:100%;">
|
||||
<thead><tr style="background:#f8fafc;"><th>점수 (VCI)</th><th>등급</th><th>운영 의미</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td>+10.0↑</td><td style="font-weight:900; color:#6366f1;">Masterpiece</td><td>시스템 가치를 견인하는 최우량 핵심 자산</td></tr>
|
||||
<tr><td>+2.0 ~ +10.0</td><td style="font-weight:900; color:#059669;">Blue Chip</td><td>꾸준한 활력으로 가치를 창출하는 우량 자산</td></tr>
|
||||
<tr><td>-2.0 ~ +2.0</td><td style="font-weight:900; color:#475569;">Steady</td><td>평균 수준의 운영을 유지 중인 안정 자산</td></tr>
|
||||
<tr><td>-10.0 ~ -2.0</td><td style="font-weight:900; color:#f59e0b;">Underperform</td><td>평균 대비 활력 부족으로 가치 하락 중인 자산</td></tr>
|
||||
<tr><td>-10.0↓</td><td style="font-weight:900; color:#dc2626;">Liability</td><td>가치를 훼손 중인 고위험 방치 자산</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="modal-footer"><button class="btn btn-primary" onclick="closeAnalysisModal()">닫기</button></div>`;
|
||||
} else if (type === 'oci') {
|
||||
title.innerText = '운영 일관성 지수 (OCI) 분석 가이드';
|
||||
body.innerHTML = `
|
||||
<div style="background:#f0fdf4; padding:15px; border-radius:8px; margin-bottom:15px;">
|
||||
<strong style="color:#166534; display:block; margin-bottom:5px;">"얼마나 꾸준하게 관리되고 있는가?"</strong>
|
||||
<p style="font-size:12.5px; color:#166534; margin:0;">미래 예측이 아닌, 최근 30일간의 <b>활동 리듬</b>과 <b>관리의 규칙성</b>을 분석하여 성실도를 점수화합니다.</p>
|
||||
</div>
|
||||
<table class="data-table" style="font-size:12px;">
|
||||
<thead><tr style="background:#f8fafc;"><th>분석 결과</th><th>일관성 등급</th><th>관리 신뢰도</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td style="color:#059669;">80%↑</td><td style="font-weight:900; color:#059669;">매우 우수</td><td>주 단위의 정기적 관리가 완벽히 이뤄짐</td></tr>
|
||||
<tr><td style="color:#1e5149;">50~80%</td><td style="font-weight:900; color:#1e5149;">양호</td><td>간헐적 정체는 있으나 꾸준히 관리됨</td></tr>
|
||||
<tr><td style="color:#f59e0b;">20~50%</td><td style="font-weight:900; color:#f59e0b;">주의</td><td>돌발적 활동 위주, 관리의 리듬이 깨짐</td></tr>
|
||||
<tr><td style="color:#dc2626;">20%↓</td><td style="font-weight:900; color:#dc2626;">매우 불량</td><td>장기 정체 중이거나 관리 의지 확인 불가</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="modal-footer"><button class="btn btn-primary" onclick="closeAnalysisModal()">닫기</button></div>`;
|
||||
} else {
|
||||
title.innerText = '업무 집중도 (Job Focus) 등급 가이드';
|
||||
body.innerHTML = `
|
||||
<p style="font-size:13px; color:#64748b; margin-bottom:15px;">최근 수집 로그 중 단순 행정 로그를 제외하고 실질적인 성과물(파일) 변동이 포착된 비율입니다.</p>
|
||||
<table class="data-table" style="font-size:12px;">
|
||||
<thead><tr style="background:#f8fafc;"><th>비율 (%)</th><th>등급</th><th>활동 성격</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td>80%↑</td><td style="font-weight:900; color:#6366f1;">Intensive</td><td>성과물 위주의 고밀도 집중 작업</td></tr>
|
||||
<tr><td>50~80%</td><td style="font-weight:900; color:#059669;">Active</td><td>성과와 관리가 균형 잡힌 원활한 실행</td></tr>
|
||||
<tr><td>20~50%</td><td style="font-weight:900; color:#f59e0b;">Maintenance</td><td>설정/행정 등 단순 관리 중심의 작업</td></tr>
|
||||
<tr><td>20%↓</td><td style="font-weight:900; color:#dc2626;">Surface</td><td>실체적 변화가 적은 형식적 로그 중심</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="modal-footer"><button class="btn btn-primary" onclick="closeAnalysisModal()">닫기</button></div>`;
|
||||
}
|
||||
modal.style.display = 'flex';
|
||||
}
|
||||
|
||||
function closeAnalysisModal() { document.getElementById('analysisModal').style.display = 'none'; }
|
||||
|
||||
@@ -1,178 +1,178 @@
|
||||
function renderPWarLeaderboard(data) {
|
||||
const container = document.getElementById('p-war-table-container');
|
||||
if (!container) return;
|
||||
|
||||
const sortedData = [...data].sort((a, b) => a.p_war - b.p_war);
|
||||
|
||||
container.innerHTML = `
|
||||
<div class="table-scroll-wrapper">
|
||||
<table class="data-table p-war-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="position: sticky; top: 0; z-index: 10; width: 280px;">프로젝트명</th>
|
||||
<th style="position: sticky; top: 0; z-index: 10;">파일 수</th>
|
||||
<th style="position: sticky; top: 0; z-index: 10;">방치일</th>
|
||||
<th style="position: sticky; top: 0; z-index: 10;">상태 판정</th>
|
||||
<th style="position: sticky; top: 0; z-index: 10;">
|
||||
활력 지수 (AVI) <button class="btn-help" onclick="event.stopPropagation(); openAnalysisModal('avi')">?</button>
|
||||
</th>
|
||||
<th style="position: sticky; top: 0; z-index: 10; text-align:right;">가치 기여 (VCI)</th>
|
||||
<th style="position: sticky; top: 0; z-index: 10; text-align:center;">업무 집중도</th>
|
||||
<th style="position: sticky; top: 0; z-index: 10;">
|
||||
운영 일관성 (OCI) <button class="btn-help" onclick="event.stopPropagation(); openAnalysisModal('oci')">?</button>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${sortedData.map((p, idx) => {
|
||||
const status = getStatusInfo(p.p_war, p.is_auto_delete);
|
||||
const avi = p.p_war;
|
||||
const vci = p.risk_count;
|
||||
const oci = p.oci_score || 0;
|
||||
const rowId = `project-${idx}`;
|
||||
|
||||
let rhythmLabel = "";
|
||||
let rhythmColor = "";
|
||||
if (oci >= 80) { rhythmLabel = "정기적"; rhythmColor = "#059669"; }
|
||||
else if (oci >= 50) { rhythmLabel = "안정적"; rhythmColor = "#1e5149"; }
|
||||
else if (oci >= 20) { rhythmLabel = "간헐적"; rhythmColor = "#f59e0b"; }
|
||||
else { rhythmLabel = "불규칙"; rhythmColor = "#dc2626"; }
|
||||
|
||||
// 존재 신뢰도 패널티 (ECV) 텍스트 준비
|
||||
let ecvText = "100% (데이터 신뢰)";
|
||||
let ecvClass = "highlight-val";
|
||||
let ecvDesc = "충분한 성과물이 존재합니다.";
|
||||
if (p.file_count === 0) {
|
||||
ecvText = "5% (유령 프로젝트)";
|
||||
ecvClass = "highlight-penalty";
|
||||
ecvDesc = "성과물이 전무하여 시스템 가치가 소멸되었습니다.";
|
||||
} else if (p.file_count < 10) {
|
||||
ecvText = "40% (소규모 껍데기)";
|
||||
ecvClass = "highlight-penalty";
|
||||
ecvDesc = "최소 수준의 데이터만 존재하여 가치가 낮게 평가됩니다.";
|
||||
}
|
||||
|
||||
// 활동 품질 텍스트 준비
|
||||
const qualityLabel = p.log_quality >= 1.0 ? '성과물 직결 <b>실무 활동</b>' : p.log_quality >= 0.7 ? '시스템 <b>구조적 활동</b>' : '단순 <b>행정적 활동</b>';
|
||||
|
||||
return `
|
||||
<tr class="project-row ${status.key === 'danger' ? 'row-danger' : status.key === 'warning' ? 'row-warning' : ''}"
|
||||
onclick="toggleProjectDetail('${rowId}')">
|
||||
<td class="font-bold">${p.project_nm}</td>
|
||||
<td>${p.file_count.toLocaleString()}개</td>
|
||||
<td>${p.days_stagnant}일</td>
|
||||
<td><span class="${status.class}">${status.label}</span></td>
|
||||
<td class="p-war-value ${avi >= 70 ? 'text-plus' : 'text-minus'}">
|
||||
${avi.toFixed(1)}%
|
||||
</td>
|
||||
<td style="text-align:right; font-weight:700; color:${vci >= 0 ? '#059669' : '#dc2626'};">
|
||||
${vci >= 0 ? '+' : ''}${vci.toFixed(2)}
|
||||
</td>
|
||||
<td style="text-align:center;">
|
||||
<div style="display:flex; flex-direction:column; align-items:center; gap:4px;">
|
||||
<span style="font-weight:800; font-size:12px; color:${p.work_effort >= 70 ? '#059669' : p.work_effort <= 30 ? '#dc2626' : '#6366f1'};">
|
||||
${p.work_effort}%
|
||||
</span>
|
||||
<div style="width:40px; height:4px; background:#f1f5f9; border-radius:2px; overflow:hidden;">
|
||||
<div style="width:${p.work_effort}%; height:100%; background:${p.work_effort >= 70 ? '#059669' : p.work_effort <= 30 ? '#dc2626' : '#6366f1'}; transition: width 0.5s;"></div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td style="text-align:center;">
|
||||
<div style="display:flex; align-items:center; justify-content:center; gap:8px;">
|
||||
<span style="font-weight:800; font-size:13px; color:${rhythmColor};">
|
||||
${oci}%
|
||||
</span>
|
||||
<span style="font-size:10px; padding:2px 6px; border-radius:10px; background:${rhythmColor}15; color:${rhythmColor}; border:1px solid ${rhythmColor}30;">
|
||||
${rhythmLabel}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr id="detail-${rowId}" class="detail-row">
|
||||
<td colspan="8">
|
||||
<div class="detail-container">
|
||||
<div class="formula-explanation-card">
|
||||
<div style="font-size: 13px; font-weight: 700; color: #6366f1; margin-bottom: 15px;">
|
||||
⚙️ AI 위험 적응형 모델(AAS) 산출 시뮬레이션
|
||||
</div>
|
||||
|
||||
<!-- 업무 집중도 분석 (상단 배치) -->
|
||||
<div style="background: #f8fafc; padding: 15px; border-radius: 8px; margin-bottom: 20px; border: 1px solid #eef2f6;">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px;">
|
||||
<span style="font-size: 13px; font-weight: 800; color: #1e5149;">📊 실질 업무 집중도 분석 (Job Focus)</span>
|
||||
<span style="font-size: 14px; font-weight: 800; color: ${p.work_effort >= 70 ? '#059669' : p.work_effort <= 30 ? '#dc2626' : '#6366f1'};">
|
||||
집중도 ${p.work_effort}%
|
||||
</span>
|
||||
</div>
|
||||
<div style="width: 100%; height: 6px; background: #e2e8f0; border-radius: 3px; overflow: hidden; margin-bottom: 10px;">
|
||||
<div style="width: ${p.work_effort}%; height: 100%; background: ${p.work_effort >= 70 ? '#059669' : p.work_effort <= 30 ? '#dc2626' : '#6366f1'}; transition: width 0.5s;"></div>
|
||||
</div>
|
||||
<div style="font-size: 11.5px; color: #64748b; line-height: 1.5;">
|
||||
최근 30개 수집 이력 중 단순 로그 갱신이 아닌 <b>실제 파일 수의 변동</b>이 포착된 날의 비율입니다.
|
||||
현재 이 프로젝트는 <b>${p.work_effort >= 70 ? '매우 밀도 높은 실무' : p.work_effort <= 30 ? '형식적 관리 위주의 정체' : '간헐적인 성과물'}</b> 상태를 보이고 있습니다.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 수식 단계 2x2 그리드 -->
|
||||
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 20px;">
|
||||
<div class="formula-step">
|
||||
<div class="step-num">1</div>
|
||||
<div class="step-content">
|
||||
<div class="step-title">동적 위험 계수(λ) 산출</div>
|
||||
<div style="font-size:11px; color:#64748b; margin-bottom:5px;">자산 규모(${p.file_count}개) 및 부서 위험도를 합산한 하락 속도입니다.</div>
|
||||
<div class="math-logic">λ = <span class="highlight-var">${p.ai_lambda.toFixed(4)}</span></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="formula-step">
|
||||
<div class="step-num">4</div>
|
||||
<div class="step-content">
|
||||
<div class="step-title">활동 품질 검증 (Quality)</div>
|
||||
<div class="step-desc" style="font-size:11px; margin-bottom:5px;">
|
||||
최근 로그 분석 결과 ${qualityLabel}으로 판명되었습니다.
|
||||
</div>
|
||||
<div class="math-logic">Factor = <span class="${p.log_quality < 0.7 ? 'highlight-penalty' : 'highlight-val'}">${(p.log_quality * 100).toFixed(0)}%</span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="formula-step">
|
||||
<div class="step-num">2</div>
|
||||
<div class="step-content">
|
||||
<div class="step-title">방치 시간 감쇄 적용</div>
|
||||
<div style="font-size:11px; color:#64748b; margin-bottom:5px;">${p.days_stagnant}일간의 정체로 인한 가치 보존율입니다.</div>
|
||||
<div class="math-logic">Result = <span class="highlight-val">${((avi / (p.file_count === 0 ? 0.05 : p.file_count < 10 ? 0.4 : 1) / p.log_quality) || 0).toFixed(1)}%</span></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="formula-step">
|
||||
<div class="step-num">3</div>
|
||||
<div class="step-content">
|
||||
<div class="step-title">존재 진정성 (ECV)</div>
|
||||
<div style="font-size:11px; color:#64748b; margin-bottom:5px;">${ecvDesc}</div>
|
||||
<div class="math-logic">Factor = <span class="${ecvClass}">${ecvText}</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="margin-top: 20px; padding-top: 15px; border-top: 2px solid #1e5149; text-align: right; display: flex; justify-content: space-between; align-items: center;">
|
||||
<div style="text-align: left;">
|
||||
<div style="font-size: 12px; font-weight: 700; color: ${vci >= 0 ? '#059669' : '#dc2626'};">
|
||||
가치 기여도 (VCI): ${vci >= 0 ? '+' : ''}${vci.toFixed(2)}
|
||||
</div>
|
||||
<div style="font-size: 10px; color: #94a3b8;">* AVI 70% 대비 프로젝트의 실질적 자산 하중 반영</div>
|
||||
</div>
|
||||
<div>
|
||||
<span style="font-size: 13px; color: #64748b; font-weight: 700;">최종 AVI: </span>
|
||||
<span style="color: #1e5149; font-size: 22px; font-weight: 900;">${avi.toFixed(1)}%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
}).join('')}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
function renderPWarLeaderboard(data) {
|
||||
const container = document.getElementById('p-war-table-container');
|
||||
if (!container) return;
|
||||
|
||||
const sortedData = [...data].sort((a, b) => a.p_war - b.p_war);
|
||||
|
||||
container.innerHTML = `
|
||||
<div class="table-scroll-wrapper">
|
||||
<table class="data-table p-war-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="position: sticky; top: 0; z-index: 10; width: 280px;">프로젝트명</th>
|
||||
<th style="position: sticky; top: 0; z-index: 10;">파일 수</th>
|
||||
<th style="position: sticky; top: 0; z-index: 10;">방치일</th>
|
||||
<th style="position: sticky; top: 0; z-index: 10;">상태 판정</th>
|
||||
<th style="position: sticky; top: 0; z-index: 10;">
|
||||
활력 지수 (AVI) <button class="btn-help" onclick="event.stopPropagation(); openAnalysisModal('avi')">?</button>
|
||||
</th>
|
||||
<th style="position: sticky; top: 0; z-index: 10; text-align:right;">가치 기여 (VCI)</th>
|
||||
<th style="position: sticky; top: 0; z-index: 10; text-align:center;">업무 집중도</th>
|
||||
<th style="position: sticky; top: 0; z-index: 10;">
|
||||
운영 일관성 (OCI) <button class="btn-help" onclick="event.stopPropagation(); openAnalysisModal('oci')">?</button>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${sortedData.map((p, idx) => {
|
||||
const status = getStatusInfo(p.p_war, p.is_auto_delete);
|
||||
const avi = p.p_war;
|
||||
const vci = p.risk_count;
|
||||
const oci = p.oci_score || 0;
|
||||
const rowId = `project-${idx}`;
|
||||
|
||||
let rhythmLabel = "";
|
||||
let rhythmColor = "";
|
||||
if (oci >= 80) { rhythmLabel = "정기적"; rhythmColor = "#059669"; }
|
||||
else if (oci >= 50) { rhythmLabel = "안정적"; rhythmColor = "#1e5149"; }
|
||||
else if (oci >= 20) { rhythmLabel = "간헐적"; rhythmColor = "#f59e0b"; }
|
||||
else { rhythmLabel = "불규칙"; rhythmColor = "#dc2626"; }
|
||||
|
||||
// 존재 신뢰도 패널티 (ECV) 텍스트 준비
|
||||
let ecvText = "100% (데이터 신뢰)";
|
||||
let ecvClass = "highlight-val";
|
||||
let ecvDesc = "충분한 성과물이 존재합니다.";
|
||||
if (p.file_count === 0) {
|
||||
ecvText = "5% (유령 프로젝트)";
|
||||
ecvClass = "highlight-penalty";
|
||||
ecvDesc = "성과물이 전무하여 시스템 가치가 소멸되었습니다.";
|
||||
} else if (p.file_count < 10) {
|
||||
ecvText = "40% (소규모 껍데기)";
|
||||
ecvClass = "highlight-penalty";
|
||||
ecvDesc = "최소 수준의 데이터만 존재하여 가치가 낮게 평가됩니다.";
|
||||
}
|
||||
|
||||
// 활동 품질 텍스트 준비
|
||||
const qualityLabel = p.log_quality >= 1.0 ? '성과물 직결 <b>실무 활동</b>' : p.log_quality >= 0.7 ? '시스템 <b>구조적 활동</b>' : '단순 <b>행정적 활동</b>';
|
||||
|
||||
return `
|
||||
<tr class="project-row ${status.key === 'danger' ? 'row-danger' : status.key === 'warning' ? 'row-warning' : ''}"
|
||||
onclick="toggleProjectDetail('${rowId}')">
|
||||
<td class="font-bold">${p.project_nm}</td>
|
||||
<td>${p.file_count.toLocaleString()}개</td>
|
||||
<td>${p.days_stagnant}일</td>
|
||||
<td><span class="${status.class}">${status.label}</span></td>
|
||||
<td class="p-war-value ${avi >= 70 ? 'text-plus' : 'text-minus'}">
|
||||
${avi.toFixed(1)}%
|
||||
</td>
|
||||
<td style="text-align:right; font-weight:700; color:${vci >= 0 ? '#059669' : '#dc2626'};">
|
||||
${vci >= 0 ? '+' : ''}${vci.toFixed(2)}
|
||||
</td>
|
||||
<td style="text-align:center;">
|
||||
<div style="display:flex; flex-direction:column; align-items:center; gap:4px;">
|
||||
<span style="font-weight:800; font-size:12px; color:${p.work_effort >= 70 ? '#059669' : p.work_effort <= 30 ? '#dc2626' : '#6366f1'};">
|
||||
${p.work_effort}%
|
||||
</span>
|
||||
<div style="width:40px; height:4px; background:#f1f5f9; border-radius:2px; overflow:hidden;">
|
||||
<div style="width:${p.work_effort}%; height:100%; background:${p.work_effort >= 70 ? '#059669' : p.work_effort <= 30 ? '#dc2626' : '#6366f1'}; transition: width 0.5s;"></div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td style="text-align:center;">
|
||||
<div style="display:flex; align-items:center; justify-content:center; gap:8px;">
|
||||
<span style="font-weight:800; font-size:13px; color:${rhythmColor};">
|
||||
${oci}%
|
||||
</span>
|
||||
<span style="font-size:10px; padding:2px 6px; border-radius:10px; background:${rhythmColor}15; color:${rhythmColor}; border:1px solid ${rhythmColor}30;">
|
||||
${rhythmLabel}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr id="detail-${rowId}" class="detail-row">
|
||||
<td colspan="8">
|
||||
<div class="detail-container">
|
||||
<div class="formula-explanation-card">
|
||||
<div style="font-size: 13px; font-weight: 700; color: #6366f1; margin-bottom: 15px;">
|
||||
⚙️ AI 위험 적응형 모델(AAS) 산출 시뮬레이션
|
||||
</div>
|
||||
|
||||
<!-- 업무 집중도 분석 (상단 배치) -->
|
||||
<div style="background: #f8fafc; padding: 15px; border-radius: 8px; margin-bottom: 20px; border: 1px solid #eef2f6;">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px;">
|
||||
<span style="font-size: 13px; font-weight: 800; color: #1e5149;">📊 실질 업무 집중도 분석 (Job Focus)</span>
|
||||
<span style="font-size: 14px; font-weight: 800; color: ${p.work_effort >= 70 ? '#059669' : p.work_effort <= 30 ? '#dc2626' : '#6366f1'};">
|
||||
집중도 ${p.work_effort}%
|
||||
</span>
|
||||
</div>
|
||||
<div style="width: 100%; height: 6px; background: #e2e8f0; border-radius: 3px; overflow: hidden; margin-bottom: 10px;">
|
||||
<div style="width: ${p.work_effort}%; height: 100%; background: ${p.work_effort >= 70 ? '#059669' : p.work_effort <= 30 ? '#dc2626' : '#6366f1'}; transition: width 0.5s;"></div>
|
||||
</div>
|
||||
<div style="font-size: 11.5px; color: #64748b; line-height: 1.5;">
|
||||
최근 30개 수집 이력 중 단순 로그 갱신이 아닌 <b>실제 파일 수의 변동</b>이 포착된 날의 비율입니다.
|
||||
현재 이 프로젝트는 <b>${p.work_effort >= 70 ? '매우 밀도 높은 실무' : p.work_effort <= 30 ? '형식적 관리 위주의 정체' : '간헐적인 성과물'}</b> 상태를 보이고 있습니다.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 수식 단계 2x2 그리드 -->
|
||||
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 20px;">
|
||||
<div class="formula-step">
|
||||
<div class="step-num">1</div>
|
||||
<div class="step-content">
|
||||
<div class="step-title">동적 위험 계수(λ) 산출</div>
|
||||
<div style="font-size:11px; color:#64748b; margin-bottom:5px;">자산 규모(${p.file_count}개) 및 부서 위험도를 합산한 하락 속도입니다.</div>
|
||||
<div class="math-logic">λ = <span class="highlight-var">${p.ai_lambda.toFixed(4)}</span></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="formula-step">
|
||||
<div class="step-num">4</div>
|
||||
<div class="step-content">
|
||||
<div class="step-title">활동 품질 검증 (Quality)</div>
|
||||
<div class="step-desc" style="font-size:11px; margin-bottom:5px;">
|
||||
최근 로그 분석 결과 ${qualityLabel}으로 판명되었습니다.
|
||||
</div>
|
||||
<div class="math-logic">Factor = <span class="${p.log_quality < 0.7 ? 'highlight-penalty' : 'highlight-val'}">${(p.log_quality * 100).toFixed(0)}%</span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="formula-step">
|
||||
<div class="step-num">2</div>
|
||||
<div class="step-content">
|
||||
<div class="step-title">방치 시간 감쇄 적용</div>
|
||||
<div style="font-size:11px; color:#64748b; margin-bottom:5px;">${p.days_stagnant}일간의 정체로 인한 가치 보존율입니다.</div>
|
||||
<div class="math-logic">Result = <span class="highlight-val">${((avi / (p.file_count === 0 ? 0.05 : p.file_count < 10 ? 0.4 : 1) / p.log_quality) || 0).toFixed(1)}%</span></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="formula-step">
|
||||
<div class="step-num">3</div>
|
||||
<div class="step-content">
|
||||
<div class="step-title">존재 진정성 (ECV)</div>
|
||||
<div style="font-size:11px; color:#64748b; margin-bottom:5px;">${ecvDesc}</div>
|
||||
<div class="math-logic">Factor = <span class="${ecvClass}">${ecvText}</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="margin-top: 20px; padding-top: 15px; border-top: 2px solid #1e5149; text-align: right; display: flex; justify-content: space-between; align-items: center;">
|
||||
<div style="text-align: left;">
|
||||
<div style="font-size: 12px; font-weight: 700; color: ${vci >= 0 ? '#059669' : '#dc2626'};">
|
||||
가치 기여도 (VCI): ${vci >= 0 ? '+' : ''}${vci.toFixed(2)}
|
||||
</div>
|
||||
<div style="font-size: 10px; color: #94a3b8;">* AVI 70% 대비 프로젝트의 실질적 자산 하중 반영</div>
|
||||
</div>
|
||||
<div>
|
||||
<span style="font-size: 13px; color: #64748b; font-weight: 700;">최종 AVI: </span>
|
||||
<span style="color: #1e5149; font-size: 22px; font-weight: 900;">${avi.toFixed(1)}%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
}).join('')}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
485
js/analysis_test.js
Normal file
485
js/analysis_test.js
Normal 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'; }
|
||||
156
js/common.js
156
js/common.js
@@ -1,78 +1,78 @@
|
||||
/**
|
||||
* Project Master Overseas Common JS
|
||||
* 공통 네비게이션, 통합 모달 관리, 유틸리티
|
||||
*/
|
||||
|
||||
// --- 공통 상수 ---
|
||||
const API = {
|
||||
INQUIRIES: '/api/inquiries',
|
||||
PROJECT_DATA: '/project-data',
|
||||
PROJECT_ACTIVITY: '/project-activity',
|
||||
AVAILABLE_DATES: '/available-dates',
|
||||
SYNC: '/sync',
|
||||
STOP_SYNC: '/stop-sync',
|
||||
AUTH_CRAWL: '/auth/crawl',
|
||||
ANALYZE_FILE: '/analyze-file',
|
||||
ATTACHMENTS: '/attachments'
|
||||
};
|
||||
|
||||
// --- 네비게이션 ---
|
||||
function navigateTo(path) {
|
||||
location.href = path;
|
||||
}
|
||||
|
||||
// --- 통합 모달 관리자 ---
|
||||
const ModalManager = {
|
||||
open(modalId) {
|
||||
const modal = document.getElementById(modalId);
|
||||
if (modal) {
|
||||
modal.style.display = 'flex';
|
||||
// 포커스 자동 이동 (ID 입력란이 있으면)
|
||||
const firstInput = modal.querySelector('input');
|
||||
if (firstInput) firstInput.focus();
|
||||
}
|
||||
},
|
||||
close(modalId) {
|
||||
const modal = document.getElementById(modalId);
|
||||
if (modal) modal.style.display = 'none';
|
||||
},
|
||||
closeAll() {
|
||||
document.querySelectorAll('.modal-overlay').forEach(m => m.style.display = 'none');
|
||||
}
|
||||
};
|
||||
|
||||
// --- 유틸리티 함수 ---
|
||||
const Utils = {
|
||||
formatDate(dateStr) {
|
||||
if (!dateStr) return '-';
|
||||
return dateStr.replace(/-/g, '.');
|
||||
},
|
||||
|
||||
// 상태별 CSS 클래스 매핑
|
||||
getStatusClass(status) {
|
||||
const map = {
|
||||
'완료': 'status-complete',
|
||||
'작업 중': 'status-working',
|
||||
'확인 중': 'status-checking',
|
||||
'정상': 'active',
|
||||
'주의': 'warning',
|
||||
'방치': 'stale',
|
||||
'데이터 없음': 'unknown'
|
||||
};
|
||||
return map[status] || 'status-pending';
|
||||
},
|
||||
|
||||
// 한글 파일명 인코딩 안전 처리
|
||||
getSafeFileUrl(filename) {
|
||||
return `/sample_files/${encodeURIComponent(filename)}`;
|
||||
}
|
||||
};
|
||||
|
||||
// --- 전역 이벤트 ---
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape') ModalManager.closeAll();
|
||||
});
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
console.log("Common module initialized.");
|
||||
});
|
||||
/**
|
||||
* Project Master Overseas Common JS
|
||||
* 공통 네비게이션, 통합 모달 관리, 유틸리티
|
||||
*/
|
||||
|
||||
// --- 공통 상수 ---
|
||||
const API = {
|
||||
INQUIRIES: '/api/inquiries',
|
||||
PROJECT_DATA: '/project-data',
|
||||
PROJECT_ACTIVITY: '/project-activity',
|
||||
AVAILABLE_DATES: '/available-dates',
|
||||
SYNC: '/sync',
|
||||
STOP_SYNC: '/stop-sync',
|
||||
AUTH_CRAWL: '/auth/crawl',
|
||||
ANALYZE_FILE: '/analyze-file',
|
||||
ATTACHMENTS: '/attachments'
|
||||
};
|
||||
|
||||
// --- 네비게이션 ---
|
||||
function navigateTo(path) {
|
||||
location.href = path;
|
||||
}
|
||||
|
||||
// --- 통합 모달 관리자 ---
|
||||
const ModalManager = {
|
||||
open(modalId) {
|
||||
const modal = document.getElementById(modalId);
|
||||
if (modal) {
|
||||
modal.style.display = 'flex';
|
||||
// 포커스 자동 이동 (ID 입력란이 있으면)
|
||||
const firstInput = modal.querySelector('input');
|
||||
if (firstInput) firstInput.focus();
|
||||
}
|
||||
},
|
||||
close(modalId) {
|
||||
const modal = document.getElementById(modalId);
|
||||
if (modal) modal.style.display = 'none';
|
||||
},
|
||||
closeAll() {
|
||||
document.querySelectorAll('.modal-overlay').forEach(m => m.style.display = 'none');
|
||||
}
|
||||
};
|
||||
|
||||
// --- 유틸리티 함수 ---
|
||||
const Utils = {
|
||||
formatDate(dateStr) {
|
||||
if (!dateStr) return '-';
|
||||
return dateStr.replace(/-/g, '.');
|
||||
},
|
||||
|
||||
// 상태별 CSS 클래스 매핑
|
||||
getStatusClass(status) {
|
||||
const map = {
|
||||
'완료': 'status-complete',
|
||||
'작업 중': 'status-working',
|
||||
'확인 중': 'status-checking',
|
||||
'정상': 'active',
|
||||
'주의': 'warning',
|
||||
'방치': 'stale',
|
||||
'데이터 없음': 'unknown'
|
||||
};
|
||||
return map[status] || 'status-pending';
|
||||
},
|
||||
|
||||
// 한글 파일명 인코딩 안전 처리
|
||||
getSafeFileUrl(filename) {
|
||||
return `/sample_files/${encodeURIComponent(filename)}`;
|
||||
}
|
||||
};
|
||||
|
||||
// --- 전역 이벤트 ---
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape') ModalManager.closeAll();
|
||||
});
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
console.log("Common module initialized.");
|
||||
});
|
||||
|
||||
474
js/dashboard.js
474
js/dashboard.js
@@ -1,237 +1,237 @@
|
||||
/**
|
||||
* Project Master Overseas Dashboard JS
|
||||
* 기능: 데이터 로드, 활성도 분석, 인증 모달 제어, 크롤링 동기화 및 중단
|
||||
*/
|
||||
|
||||
// --- 글로벌 상태 관리 ---
|
||||
let rawData = [];
|
||||
let projectActivityDetails = [];
|
||||
let isCrawling = false;
|
||||
|
||||
const CONTINENT_ORDER = { "아시아": 1, "아프리카": 2, "아메리카": 3, "지사": 4 };
|
||||
|
||||
// --- 초기화 ---
|
||||
async function init() {
|
||||
console.log("Dashboard Initializing...");
|
||||
if (!document.getElementById('projectAccordion')) return;
|
||||
|
||||
await loadAvailableDates();
|
||||
await loadDataByDate();
|
||||
}
|
||||
|
||||
// --- 데이터 통신 및 로드 ---
|
||||
async function loadAvailableDates() {
|
||||
try {
|
||||
const response = await fetch(API.AVAILABLE_DATES);
|
||||
const dates = await response.json();
|
||||
if (dates?.length > 0) {
|
||||
const selectHtml = `
|
||||
<select id="dateSelector" onchange="loadDataByDate(this.value)"
|
||||
style="margin-left:10px; border:none; background:none; font-weight:700; cursor:pointer; font-family:inherit; color:inherit;">
|
||||
${dates.map(d => `<option value="${d}">${d}</option>`).join('')}
|
||||
</select>`;
|
||||
const baseDateStrong = document.getElementById('baseDate');
|
||||
if (baseDateStrong) baseDateStrong.innerHTML = selectHtml;
|
||||
}
|
||||
} catch (e) { console.error("날짜 로드 실패:", e); }
|
||||
}
|
||||
|
||||
async function loadDataByDate(selectedDate = "") {
|
||||
try {
|
||||
await loadActivityAnalysis(selectedDate);
|
||||
const url = selectedDate ? `${API.PROJECT_DATA}?date=${selectedDate}` : `${API.PROJECT_DATA}?t=${Date.now()}`;
|
||||
const response = await fetch(url);
|
||||
const data = await response.json();
|
||||
if (data.error) throw new Error(data.error);
|
||||
rawData = data.projects || [];
|
||||
renderDashboard(rawData);
|
||||
} catch (e) {
|
||||
console.error("데이터 로드 실패:", e);
|
||||
alert("데이터를 가져오는 데 실패했습니다.");
|
||||
}
|
||||
}
|
||||
|
||||
async function loadActivityAnalysis(date = "") {
|
||||
const dashboard = document.getElementById('activityDashboard');
|
||||
if (!dashboard) return;
|
||||
try {
|
||||
const url = date ? `${API.PROJECT_ACTIVITY}?date=${date}` : API.PROJECT_ACTIVITY;
|
||||
const response = await fetch(url);
|
||||
const data = await response.json();
|
||||
if (data.error) return;
|
||||
const { summary, details } = data;
|
||||
projectActivityDetails = details;
|
||||
dashboard.innerHTML = `
|
||||
<div class="activity-card active" onclick="showActivityDetails('active')">
|
||||
<div class="label">정상 (7일 이내)</div><div class="count">${summary.active}</div>
|
||||
</div>
|
||||
<div class="activity-card warning" onclick="showActivityDetails('warning')">
|
||||
<div class="label">주의 (14일 이내)</div><div class="count">${summary.warning}</div>
|
||||
</div>
|
||||
<div class="activity-card stale" onclick="showActivityDetails('stale')">
|
||||
<div class="label">방치 (14일 초과 / 폴더자동삭제)</div><div class="count">${summary.stale}</div>
|
||||
</div>
|
||||
<div class="activity-card unknown" onclick="showActivityDetails('unknown')">
|
||||
<div class="label">데이터 없음 (파일 0개)</div><div class="count">${summary.unknown}</div>
|
||||
</div>`;
|
||||
} catch (e) { console.error("분석 로드 실패:", e); }
|
||||
}
|
||||
|
||||
// --- 렌더링 엔진 ---
|
||||
function renderDashboard(data) {
|
||||
const container = document.getElementById('projectAccordion');
|
||||
container.innerHTML = '';
|
||||
const grouped = groupData(data);
|
||||
Object.keys(grouped).sort((a, b) => (CONTINENT_ORDER[a] || 99) - (CONTINENT_ORDER[b] || 99)).forEach(continent => {
|
||||
const continentDiv = document.createElement('div');
|
||||
continentDiv.className = 'continent-group active';
|
||||
let html = `<div class="continent-header" onclick="toggleGroup(this)"><span>${continent}</span><span class="toggle-icon">▼</span></div><div class="continent-body">`;
|
||||
Object.keys(grouped[continent]).sort().forEach(country => {
|
||||
html += `<div class="country-group active"><div class="country-header" onclick="toggleGroup(this)"><span>${country}</span><span class="toggle-icon">▼</span></div><div class="country-body"><div class="accordion-container">
|
||||
<div class="accordion-list-header"><div>프로젝트명</div><div>담당부서</div><div>담당자</div><div style="text-align:center;">파일수</div><div>최근로그</div></div>
|
||||
${grouped[continent][country].sort((a, b) => a[0].localeCompare(b[0])).map(p => createProjectHtml(p)).join('')}</div></div></div>`;
|
||||
});
|
||||
html += `</div>`;
|
||||
continentDiv.innerHTML = html;
|
||||
container.appendChild(continentDiv);
|
||||
});
|
||||
}
|
||||
|
||||
function groupData(data) {
|
||||
const res = {};
|
||||
data.forEach(item => {
|
||||
const c1 = item[5] || "기타", c2 = item[6] || "미분류";
|
||||
if (!res[c1]) res[c1] = {};
|
||||
if (!res[c1][c2]) res[c1][c2] = [];
|
||||
res[c1][c2].push(item);
|
||||
});
|
||||
return res;
|
||||
}
|
||||
|
||||
function createProjectHtml(p) {
|
||||
const [name, dept, admin, logRaw, files] = p;
|
||||
const recentLog = (!logRaw || logRaw === "X" || logRaw === "데이터 없음") ? "기록 없음" : logRaw;
|
||||
const logTime = recentLog !== "기록 없음" ? recentLog.split(',')[0] : "기록 없음";
|
||||
|
||||
const isStaleLog = recentLog.replace(/\s/g, "").includes("폴더자동삭제");
|
||||
const isNoFiles = (files === 0 || files === null);
|
||||
const statusClass = isNoFiles ? "status-error" : "";
|
||||
|
||||
let logStyleClass = "";
|
||||
if (isStaleLog) logStyleClass = "error-text";
|
||||
else if (recentLog === "기록 없음") logStyleClass = "warning-text";
|
||||
|
||||
const logBoldStyle = isStaleLog ? 'font-weight: 800;' : '';
|
||||
|
||||
return `
|
||||
<div class="accordion-item ${statusClass}">
|
||||
<div class="accordion-header" onclick="toggleAccordion(this)">
|
||||
<div class="repo-title" title="${name}">${name}</div><div class="repo-dept">${dept}</div><div class="repo-admin">${admin}</div><div class="repo-files ${isNoFiles ? 'error-text' : ''}">${files || 0}</div><div class="repo-log ${logStyleClass}" style="${logBoldStyle}" title="${recentLog}">${recentLog}</div>
|
||||
</div>
|
||||
<div class="accordion-body">
|
||||
<div class="detail-grid">
|
||||
<div class="detail-section">
|
||||
<h4>참여 인원 상세</h4>
|
||||
<table class="data-table">
|
||||
<thead><tr><th>이름</th><th>소속</th><th>권한</th></tr></thead>
|
||||
<tbody><tr><td>${admin}</td><td>${dept}</td><td>관리자</td></tr></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="detail-section">
|
||||
<h4>최근 활동</h4>
|
||||
<table class="data-table">
|
||||
<thead><tr><th>유형</th><th>내용</th><th>일시</th></tr></thead>
|
||||
<tbody><tr><td><span class="badge">로그</span></td><td>동기화 완료</td><td>${logTime}</td></tr></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// --- 이벤트 핸들러 ---
|
||||
function toggleGroup(h) { h.parentElement.classList.toggle('active'); }
|
||||
function toggleAccordion(h) {
|
||||
const item = h.parentElement;
|
||||
item.parentElement.querySelectorAll('.accordion-item').forEach(el => { if (el !== item) el.classList.remove('active'); });
|
||||
item.classList.toggle('active');
|
||||
}
|
||||
|
||||
function showActivityDetails(status) {
|
||||
const names = { active: '정상', warning: '주의', stale: '방치', unknown: '데이터 없음' };
|
||||
const filtered = (projectActivityDetails || []).filter(d => d.status === status);
|
||||
document.getElementById('modalTitle').innerText = `${names[status]} 목록 (${filtered.length}개)`;
|
||||
document.getElementById('modalTableBody').innerHTML = filtered.map(p => {
|
||||
const o = rawData.find(r => r[0] === p.name);
|
||||
return `<tr class="modal-row" onclick="scrollToProject('${p.name}')"><td><strong>${p.name}</strong></td><td>${o ? o[1] : "-"}</td><td>${o ? o[2] : "-"}</td></tr>`;
|
||||
}).join('');
|
||||
ModalManager.open('activityDetailModal');
|
||||
}
|
||||
|
||||
function scrollToProject(name) {
|
||||
ModalManager.close('activityDetailModal');
|
||||
const target = Array.from(document.querySelectorAll('.repo-title')).find(t => t.innerText.trim() === name.trim())?.closest('.accordion-header');
|
||||
if (target) {
|
||||
let p = target.parentElement;
|
||||
while (p && p !== document.body) {
|
||||
if (p.classList.contains('continent-group') || p.classList.contains('country-group')) p.classList.add('active');
|
||||
p = p.parentElement;
|
||||
}
|
||||
target.parentElement.classList.add('active');
|
||||
const pos = target.getBoundingClientRect().top + window.pageYOffset - 260;
|
||||
window.scrollTo({ top: pos, behavior: 'smooth' });
|
||||
target.style.backgroundColor = 'var(--primary-lv-1)';
|
||||
setTimeout(() => target.style.backgroundColor = '', 2000);
|
||||
}
|
||||
}
|
||||
|
||||
// --- 크롤링 및 인증 제어 ---
|
||||
async function syncData() {
|
||||
if (isCrawling) {
|
||||
if (confirm("크롤링을 중단하시겠습니까?")) {
|
||||
const res = await fetch(API.STOP_SYNC);
|
||||
if ((await res.json()).success) document.getElementById('syncBtn').innerText = "중단 요청 중...";
|
||||
}
|
||||
return;
|
||||
}
|
||||
document.getElementById('authId').value = '';
|
||||
document.getElementById('authPw').value = '';
|
||||
document.getElementById('authErrorMessage').style.display = 'none';
|
||||
ModalManager.open('authModal');
|
||||
}
|
||||
|
||||
async function submitAuth() {
|
||||
const id = document.getElementById('authId').value, pw = document.getElementById('authPw').value, err = document.getElementById('authErrorMessage');
|
||||
try {
|
||||
const res = await fetch(API.AUTH_CRAWL, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ user_id: id, password: pw }) });
|
||||
const data = await res.json();
|
||||
if (data.success) { ModalManager.close('authModal'); startCrawlProcess(); }
|
||||
else { err.innerText = "크롤링을 할 수 없습니다."; err.style.display = 'block'; }
|
||||
} catch { err.innerText = "서버 연결 실패"; err.style.display = 'block'; }
|
||||
}
|
||||
|
||||
async function startCrawlProcess() {
|
||||
isCrawling = true;
|
||||
const btn = document.getElementById('syncBtn'), logC = document.getElementById('logConsole'), logB = document.getElementById('logBody');
|
||||
btn.classList.add('loading'); btn.style.backgroundColor = 'var(--error-color)'; btn.innerHTML = `<span class="spinner"></span> 크롤링 중단`;
|
||||
logC.style.display = 'block'; logB.innerHTML = '<div style="color:#aaa; margin-bottom:10px;">>>> 엔진 초기화 중...</div>';
|
||||
try {
|
||||
const res = await fetch(API.SYNC);
|
||||
const reader = res.body.getReader(), decoder = new TextDecoder();
|
||||
while (true) {
|
||||
const { done, value } = await reader.read(); if (done) break;
|
||||
decoder.decode(value).split('\n').forEach(line => {
|
||||
if (line.startsWith('data: ')) {
|
||||
const p = JSON.parse(line.substring(6));
|
||||
if (p.type === 'log') {
|
||||
const div = document.createElement('div'); div.innerText = `[${new Date().toLocaleTimeString()}] ${p.message}`;
|
||||
logB.appendChild(div); logC.scrollTop = logC.scrollHeight;
|
||||
} else if (p.type === 'done') { init(); alert(`동기화 종료`); logC.style.display = 'none'; }
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch { alert("스트림 끊김"); }
|
||||
finally { isCrawling = false; btn.classList.remove('loading'); btn.style.backgroundColor = ''; btn.innerHTML = `<span class="spinner"></span> 데이터 동기화 (크롤링)`; }
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', init);
|
||||
/**
|
||||
* Project Master Overseas Dashboard JS
|
||||
* 기능: 데이터 로드, 활성도 분석, 인증 모달 제어, 크롤링 동기화 및 중단
|
||||
*/
|
||||
|
||||
// --- 글로벌 상태 관리 ---
|
||||
let rawData = [];
|
||||
let projectActivityDetails = [];
|
||||
let isCrawling = false;
|
||||
|
||||
const CONTINENT_ORDER = { "아시아": 1, "아프리카": 2, "아메리카": 3, "지사": 4 };
|
||||
|
||||
// --- 초기화 ---
|
||||
async function init() {
|
||||
console.log("Dashboard Initializing...");
|
||||
if (!document.getElementById('projectAccordion')) return;
|
||||
|
||||
await loadAvailableDates();
|
||||
await loadDataByDate();
|
||||
}
|
||||
|
||||
// --- 데이터 통신 및 로드 ---
|
||||
async function loadAvailableDates() {
|
||||
try {
|
||||
const response = await fetch(API.AVAILABLE_DATES);
|
||||
const dates = await response.json();
|
||||
if (dates?.length > 0) {
|
||||
const selectHtml = `
|
||||
<select id="dateSelector" onchange="loadDataByDate(this.value)"
|
||||
style="margin-left:10px; border:none; background:none; font-weight:700; cursor:pointer; font-family:inherit; color:inherit;">
|
||||
${dates.map(d => `<option value="${d}">${d}</option>`).join('')}
|
||||
</select>`;
|
||||
const baseDateStrong = document.getElementById('baseDate');
|
||||
if (baseDateStrong) baseDateStrong.innerHTML = selectHtml;
|
||||
}
|
||||
} catch (e) { console.error("날짜 로드 실패:", e); }
|
||||
}
|
||||
|
||||
async function loadDataByDate(selectedDate = "") {
|
||||
try {
|
||||
await loadActivityAnalysis(selectedDate);
|
||||
const url = selectedDate ? `${API.PROJECT_DATA}?date=${selectedDate}` : `${API.PROJECT_DATA}?t=${Date.now()}`;
|
||||
const response = await fetch(url);
|
||||
const data = await response.json();
|
||||
if (data.error) throw new Error(data.error);
|
||||
rawData = data.projects || [];
|
||||
renderDashboard(rawData);
|
||||
} catch (e) {
|
||||
console.error("데이터 로드 실패:", e);
|
||||
alert("데이터를 가져오는 데 실패했습니다.");
|
||||
}
|
||||
}
|
||||
|
||||
async function loadActivityAnalysis(date = "") {
|
||||
const dashboard = document.getElementById('activityDashboard');
|
||||
if (!dashboard) return;
|
||||
try {
|
||||
const url = date ? `${API.PROJECT_ACTIVITY}?date=${date}` : API.PROJECT_ACTIVITY;
|
||||
const response = await fetch(url);
|
||||
const data = await response.json();
|
||||
if (data.error) return;
|
||||
const { summary, details } = data;
|
||||
projectActivityDetails = details;
|
||||
dashboard.innerHTML = `
|
||||
<div class="activity-card active" onclick="showActivityDetails('active')">
|
||||
<div class="label">정상 (7일 이내)</div><div class="count">${summary.active}</div>
|
||||
</div>
|
||||
<div class="activity-card warning" onclick="showActivityDetails('warning')">
|
||||
<div class="label">주의 (14일 이내)</div><div class="count">${summary.warning}</div>
|
||||
</div>
|
||||
<div class="activity-card stale" onclick="showActivityDetails('stale')">
|
||||
<div class="label">방치 (14일 초과 / 폴더자동삭제)</div><div class="count">${summary.stale}</div>
|
||||
</div>
|
||||
<div class="activity-card unknown" onclick="showActivityDetails('unknown')">
|
||||
<div class="label">데이터 없음 (파일 0개)</div><div class="count">${summary.unknown}</div>
|
||||
</div>`;
|
||||
} catch (e) { console.error("분석 로드 실패:", e); }
|
||||
}
|
||||
|
||||
// --- 렌더링 엔진 ---
|
||||
function renderDashboard(data) {
|
||||
const container = document.getElementById('projectAccordion');
|
||||
container.innerHTML = '';
|
||||
const grouped = groupData(data);
|
||||
Object.keys(grouped).sort((a, b) => (CONTINENT_ORDER[a] || 99) - (CONTINENT_ORDER[b] || 99)).forEach(continent => {
|
||||
const continentDiv = document.createElement('div');
|
||||
continentDiv.className = 'continent-group active';
|
||||
let html = `<div class="continent-header" onclick="toggleGroup(this)"><span>${continent}</span><span class="toggle-icon">▼</span></div><div class="continent-body">`;
|
||||
Object.keys(grouped[continent]).sort().forEach(country => {
|
||||
html += `<div class="country-group active"><div class="country-header" onclick="toggleGroup(this)"><span>${country}</span><span class="toggle-icon">▼</span></div><div class="country-body"><div class="accordion-container">
|
||||
<div class="accordion-list-header"><div>프로젝트명</div><div>담당부서</div><div>담당자</div><div style="text-align:center;">파일수</div><div>최근로그</div></div>
|
||||
${grouped[continent][country].sort((a, b) => a[0].localeCompare(b[0])).map(p => createProjectHtml(p)).join('')}</div></div></div>`;
|
||||
});
|
||||
html += `</div>`;
|
||||
continentDiv.innerHTML = html;
|
||||
container.appendChild(continentDiv);
|
||||
});
|
||||
}
|
||||
|
||||
function groupData(data) {
|
||||
const res = {};
|
||||
data.forEach(item => {
|
||||
const c1 = item[5] || "기타", c2 = item[6] || "미분류";
|
||||
if (!res[c1]) res[c1] = {};
|
||||
if (!res[c1][c2]) res[c1][c2] = [];
|
||||
res[c1][c2].push(item);
|
||||
});
|
||||
return res;
|
||||
}
|
||||
|
||||
function createProjectHtml(p) {
|
||||
const [name, dept, admin, logRaw, files] = p;
|
||||
const recentLog = (!logRaw || logRaw === "X" || logRaw === "데이터 없음") ? "기록 없음" : logRaw;
|
||||
const logTime = recentLog !== "기록 없음" ? recentLog.split(',')[0] : "기록 없음";
|
||||
|
||||
const isStaleLog = recentLog.replace(/\s/g, "").includes("폴더자동삭제");
|
||||
const isNoFiles = (files === 0 || files === null);
|
||||
const statusClass = isNoFiles ? "status-error" : "";
|
||||
|
||||
let logStyleClass = "";
|
||||
if (isStaleLog) logStyleClass = "error-text";
|
||||
else if (recentLog === "기록 없음") logStyleClass = "warning-text";
|
||||
|
||||
const logBoldStyle = isStaleLog ? 'font-weight: 800;' : '';
|
||||
|
||||
return `
|
||||
<div class="accordion-item ${statusClass}">
|
||||
<div class="accordion-header" onclick="toggleAccordion(this)">
|
||||
<div class="repo-title" title="${name}">${name}</div><div class="repo-dept">${dept}</div><div class="repo-admin">${admin}</div><div class="repo-files ${isNoFiles ? 'error-text' : ''}">${files || 0}</div><div class="repo-log ${logStyleClass}" style="${logBoldStyle}" title="${recentLog}">${recentLog}</div>
|
||||
</div>
|
||||
<div class="accordion-body">
|
||||
<div class="detail-grid">
|
||||
<div class="detail-section">
|
||||
<h4>참여 인원 상세</h4>
|
||||
<table class="data-table">
|
||||
<thead><tr><th>이름</th><th>소속</th><th>권한</th></tr></thead>
|
||||
<tbody><tr><td>${admin}</td><td>${dept}</td><td>관리자</td></tr></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="detail-section">
|
||||
<h4>최근 활동</h4>
|
||||
<table class="data-table">
|
||||
<thead><tr><th>유형</th><th>내용</th><th>일시</th></tr></thead>
|
||||
<tbody><tr><td><span class="badge">로그</span></td><td>동기화 완료</td><td>${logTime}</td></tr></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// --- 이벤트 핸들러 ---
|
||||
function toggleGroup(h) { h.parentElement.classList.toggle('active'); }
|
||||
function toggleAccordion(h) {
|
||||
const item = h.parentElement;
|
||||
item.parentElement.querySelectorAll('.accordion-item').forEach(el => { if (el !== item) el.classList.remove('active'); });
|
||||
item.classList.toggle('active');
|
||||
}
|
||||
|
||||
function showActivityDetails(status) {
|
||||
const names = { active: '정상', warning: '주의', stale: '방치', unknown: '데이터 없음' };
|
||||
const filtered = (projectActivityDetails || []).filter(d => d.status === status);
|
||||
document.getElementById('modalTitle').innerText = `${names[status]} 목록 (${filtered.length}개)`;
|
||||
document.getElementById('modalTableBody').innerHTML = filtered.map(p => {
|
||||
const o = rawData.find(r => r[0] === p.name);
|
||||
return `<tr class="modal-row" onclick="scrollToProject('${p.name}')"><td><strong>${p.name}</strong></td><td>${o ? o[1] : "-"}</td><td>${o ? o[2] : "-"}</td></tr>`;
|
||||
}).join('');
|
||||
ModalManager.open('activityDetailModal');
|
||||
}
|
||||
|
||||
function scrollToProject(name) {
|
||||
ModalManager.close('activityDetailModal');
|
||||
const target = Array.from(document.querySelectorAll('.repo-title')).find(t => t.innerText.trim() === name.trim())?.closest('.accordion-header');
|
||||
if (target) {
|
||||
let p = target.parentElement;
|
||||
while (p && p !== document.body) {
|
||||
if (p.classList.contains('continent-group') || p.classList.contains('country-group')) p.classList.add('active');
|
||||
p = p.parentElement;
|
||||
}
|
||||
target.parentElement.classList.add('active');
|
||||
const pos = target.getBoundingClientRect().top + window.pageYOffset - 260;
|
||||
window.scrollTo({ top: pos, behavior: 'smooth' });
|
||||
target.style.backgroundColor = 'var(--primary-lv-1)';
|
||||
setTimeout(() => target.style.backgroundColor = '', 2000);
|
||||
}
|
||||
}
|
||||
|
||||
// --- 크롤링 및 인증 제어 ---
|
||||
async function syncData() {
|
||||
if (isCrawling) {
|
||||
if (confirm("크롤링을 중단하시겠습니까?")) {
|
||||
const res = await fetch(API.STOP_SYNC);
|
||||
if ((await res.json()).success) document.getElementById('syncBtn').innerText = "중단 요청 중...";
|
||||
}
|
||||
return;
|
||||
}
|
||||
document.getElementById('authId').value = '';
|
||||
document.getElementById('authPw').value = '';
|
||||
document.getElementById('authErrorMessage').style.display = 'none';
|
||||
ModalManager.open('authModal');
|
||||
}
|
||||
|
||||
async function submitAuth() {
|
||||
const id = document.getElementById('authId').value, pw = document.getElementById('authPw').value, err = document.getElementById('authErrorMessage');
|
||||
try {
|
||||
const res = await fetch(API.AUTH_CRAWL, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ user_id: id, password: pw }) });
|
||||
const data = await res.json();
|
||||
if (data.success) { ModalManager.close('authModal'); startCrawlProcess(); }
|
||||
else { err.innerText = "크롤링을 할 수 없습니다."; err.style.display = 'block'; }
|
||||
} catch { err.innerText = "서버 연결 실패"; err.style.display = 'block'; }
|
||||
}
|
||||
|
||||
async function startCrawlProcess() {
|
||||
isCrawling = true;
|
||||
const btn = document.getElementById('syncBtn'), logC = document.getElementById('logConsole'), logB = document.getElementById('logBody');
|
||||
btn.classList.add('loading'); btn.style.backgroundColor = 'var(--error-color)'; btn.innerHTML = `<span class="spinner"></span> 크롤링 중단`;
|
||||
logC.style.display = 'block'; logB.innerHTML = '<div style="color:#aaa; margin-bottom:10px;">>>> 엔진 초기화 중...</div>';
|
||||
try {
|
||||
const res = await fetch(API.SYNC);
|
||||
const reader = res.body.getReader(), decoder = new TextDecoder();
|
||||
while (true) {
|
||||
const { done, value } = await reader.read(); if (done) break;
|
||||
decoder.decode(value).split('\n').forEach(line => {
|
||||
if (line.startsWith('data: ')) {
|
||||
const p = JSON.parse(line.substring(6));
|
||||
if (p.type === 'log') {
|
||||
const div = document.createElement('div'); div.innerText = `[${new Date().toLocaleTimeString()}] ${p.message}`;
|
||||
logB.appendChild(div); logC.scrollTop = logC.scrollHeight;
|
||||
} else if (p.type === 'done') { init(); alert(`동기화 종료`); logC.style.display = 'none'; }
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch { alert("스트림 끊김"); }
|
||||
finally { isCrawling = false; btn.classList.remove('loading'); btn.style.backgroundColor = ''; btn.innerHTML = `<span class="spinner"></span> 데이터 동기화 (크롤링)`; }
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', init);
|
||||
|
||||
245
js/dashboard_test.js
Normal file
245
js/dashboard_test.js
Normal 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);
|
||||
628
js/inquiries.js
628
js/inquiries.js
@@ -1,314 +1,314 @@
|
||||
/**
|
||||
* Project Master Overseas Inquiries JS
|
||||
* 기능: 문의사항 로드, 필터링, 답변 관리, 아코디언 및 이미지 모달
|
||||
*/
|
||||
|
||||
// --- 초기화 ---
|
||||
let allInquiries = [];
|
||||
let currentSort = { field: 'no', direction: 'desc' };
|
||||
|
||||
async function loadInquiries() {
|
||||
initStickyHeader();
|
||||
|
||||
const pmType = document.getElementById('filterPmType').value;
|
||||
const category = document.getElementById('filterCategory').value;
|
||||
const keyword = document.getElementById('searchKeyword').value;
|
||||
|
||||
const params = new URLSearchParams({
|
||||
pm_type: pmType,
|
||||
category: category,
|
||||
keyword: keyword
|
||||
});
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API.INQUIRIES}?${params}`);
|
||||
allInquiries = await response.json();
|
||||
|
||||
refreshInquiryBoard();
|
||||
} catch (e) {
|
||||
console.error("데이터 로딩 중 오류 발생:", e);
|
||||
}
|
||||
}
|
||||
|
||||
function refreshInquiryBoard() {
|
||||
const status = document.getElementById('filterStatus').value;
|
||||
|
||||
// 1. 상태 필터링
|
||||
let filteredData = status ? allInquiries.filter(item => item.status === status) : [...allInquiries];
|
||||
|
||||
// 2. 정렬 적용
|
||||
filteredData = sortData(filteredData);
|
||||
|
||||
// 3. 통계 및 리스트 렌더링
|
||||
updateStats(allInquiries);
|
||||
updateSortUI();
|
||||
renderInquiryList(filteredData);
|
||||
}
|
||||
|
||||
function handleSort(field) {
|
||||
if (currentSort.field === field) {
|
||||
currentSort.direction = currentSort.direction === 'asc' ? 'desc' : 'asc';
|
||||
} else {
|
||||
currentSort.field = field;
|
||||
currentSort.direction = 'asc';
|
||||
}
|
||||
refreshInquiryBoard();
|
||||
}
|
||||
|
||||
function sortData(data) {
|
||||
const { field, direction } = currentSort;
|
||||
const modifier = direction === 'asc' ? 1 : -1;
|
||||
|
||||
return data.sort((a, b) => {
|
||||
let valA = a[field];
|
||||
let valB = b[field];
|
||||
|
||||
// 숫자형 변환 시도 (No 필드 등)
|
||||
if (field === 'no' || !isNaN(valA)) {
|
||||
valA = Number(valA);
|
||||
valB = Number(valB);
|
||||
}
|
||||
|
||||
// null/undefined 처리
|
||||
if (valA === null || valA === undefined) valA = "";
|
||||
if (valB === null || valB === undefined) valB = "";
|
||||
|
||||
if (valA < valB) return -1 * modifier;
|
||||
if (valA > valB) return 1 * modifier;
|
||||
return 0;
|
||||
});
|
||||
}
|
||||
|
||||
function updateSortUI() {
|
||||
// 모든 헤더 클래스 및 아이콘 초기화
|
||||
document.querySelectorAll('.inquiry-table thead th.sortable').forEach(th => {
|
||||
th.classList.remove('active-sort');
|
||||
const icon = th.querySelector('.sort-icon');
|
||||
if (icon) {
|
||||
// 레이아웃 시프트 방지를 위해 투명한 기본 아이콘(또는 공백) 유지
|
||||
icon.textContent = "▲";
|
||||
icon.style.opacity = "0";
|
||||
}
|
||||
});
|
||||
|
||||
// 현재 정렬된 헤더 강조 및 아이콘 표시
|
||||
const activeTh = document.querySelector(`.inquiry-table thead th[onclick*="'${currentSort.field}'"]`);
|
||||
if (activeTh) {
|
||||
activeTh.classList.add('active-sort');
|
||||
const icon = activeTh.querySelector('.sort-icon');
|
||||
if (icon) {
|
||||
icon.textContent = currentSort.direction === 'asc' ? "▲" : "▼";
|
||||
icon.style.opacity = "1";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function initStickyHeader() {
|
||||
const header = document.getElementById('stickyHeader');
|
||||
const thead = document.querySelector('.inquiry-table thead');
|
||||
if (header && thead) {
|
||||
const headerHeight = header.offsetHeight;
|
||||
const totalOffset = 36 + headerHeight;
|
||||
document.querySelectorAll('.inquiry-table thead th').forEach(th => {
|
||||
th.style.top = totalOffset + 'px';
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function renderInquiryList(data) {
|
||||
const tbody = document.getElementById('inquiryList');
|
||||
tbody.innerHTML = data.map(item => `
|
||||
<tr class="inquiry-row" onclick="toggleAccordion(${item.id})">
|
||||
<td title="${item.no}">${item.no}</td>
|
||||
<td style="text-align:center;">
|
||||
${item.image_url ? `<img src="${item.image_url}" class="img-thumbnail" alt="thumbnail">` : '<span class="no-img">없음</span>'}
|
||||
</td>
|
||||
<td title="${item.pm_type}">${item.pm_type}</td>
|
||||
<td title="${item.browser || 'Chrome'}">${item.browser || 'Chrome'}</td>
|
||||
<td title="${item.category}">${item.category}</td>
|
||||
<td title="${item.project_nm}">${item.project_nm}</td>
|
||||
<td class="content-preview" title="${item.content}">${item.content}</td>
|
||||
<td title="${item.author}">${item.author}</td>
|
||||
<td title="${item.reg_date}">${item.reg_date}</td>
|
||||
<td class="content-preview" title="${item.reply || ''}" style="color: #1e5149; font-weight: 500;">${item.reply || '-'}</td>
|
||||
<td><span class="status-badge ${Utils.getStatusClass(item.status)}">${item.status}</span></td>
|
||||
</tr>
|
||||
<tr id="detail-${item.id}" class="detail-row">
|
||||
<td colspan="11">
|
||||
<div class="detail-container">
|
||||
<button class="btn-close-accordion" onclick="toggleAccordion(${item.id})">접기</button>
|
||||
<div class="detail-content-wrapper">
|
||||
<div class="detail-meta-grid">
|
||||
<div><span class="detail-label">작성자:</span> ${item.author}</div>
|
||||
<div><span class="detail-label">등록일:</span> ${item.reg_date}</div>
|
||||
<div><span class="detail-label">시스템:</span> ${item.pm_type}</div>
|
||||
<div><span class="detail-label">환경:</span> ${item.browser || 'Chrome'} / ${item.device || 'PC'}</div>
|
||||
</div>
|
||||
|
||||
<div class="detail-q-section">
|
||||
<h4 style="margin-top:0; margin-bottom:10px; color:#1e5149;">[질문 내용]</h4>
|
||||
<div style="line-height:1.6; white-space: pre-wrap;">${item.content}</div>
|
||||
</div>
|
||||
|
||||
${item.image_url ? `
|
||||
<div class="detail-image-section" id="img-section-${item.id}">
|
||||
<div class="image-section-header" onclick="toggleImageSection(${item.id})">
|
||||
<h4>
|
||||
<span>🖼️</span> [첨부 이미지]
|
||||
<span style="font-size:11px; color:#888; font-weight:normal;">(클릭 시 크게 보기)</span>
|
||||
</h4>
|
||||
<span class="toggle-icon">▼</span>
|
||||
</div>
|
||||
<div class="image-section-content collapsed" id="img-content-${item.id}">
|
||||
<img src="${item.image_url}" class="preview-img" alt="Inquiry Image" style="cursor: pointer;" onclick="event.stopPropagation(); openImageModal(this.src)">
|
||||
</div>
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
<div class="detail-a-section">
|
||||
<h4 style="margin-top:0; margin-bottom:10px; color:#1e5149;">[조치 및 답변]</h4>
|
||||
<div id="reply-form-${item.id}" class="reply-edit-form readonly">
|
||||
<textarea id="reply-text-${item.id}" disabled placeholder="답변 내용이 없습니다.">${item.reply || ''}</textarea>
|
||||
<div style="display:flex; justify-content: space-between; align-items: center;">
|
||||
<div style="display:flex; gap:15px; align-items:center;">
|
||||
<div class="filter-group" style="flex-direction:row; align-items:center; gap:8px;">
|
||||
<label style="margin:0;">처리상태:</label>
|
||||
<select id="reply-status-${item.id}" disabled style="padding:5px 10px;">
|
||||
<option value="완료" ${item.status === '완료' ? 'selected' : ''}>완료</option>
|
||||
<option value="작업 중" ${item.status === '작업 중' ? 'selected' : ''}>작업 중</option>
|
||||
<option value="확인 중" ${item.status === '확인 중' ? 'selected' : ''}>확인 중</option>
|
||||
<option value="개발예정" ${item.status === '개발예정' ? 'selected' : ''}>개발예정</option>
|
||||
<option value="미확인" ${item.status === '미확인' ? 'selected' : ''}>미확인</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="filter-group" style="flex-direction:row; align-items:center; gap:8px;">
|
||||
<label style="margin:0;">처리자:</label>
|
||||
<input type="text" id="reply-handler-${item.id}" disabled value="${item.handler || ''}" placeholder="이름 입력" style="padding:5px 10px; width:100px;">
|
||||
</div>
|
||||
</div>
|
||||
<div style="display:flex; gap:8px;">
|
||||
<button class="btn-edit sync-btn" onclick="enableEdit(${item.id})" style="background:#1e5149; color:#fff; border:none;">${item.reply ? '수정하기' : '답변작성'}</button>
|
||||
<button class="btn-save sync-btn" onclick="saveReply(${item.id})" style="background:#1e5149; color:#fff; border:none;">저장</button>
|
||||
<button class="btn-delete sync-btn" onclick="deleteReply(${item.id})" style="background:#f44336; color:#fff; border:none;">삭제</button>
|
||||
<button class="btn-cancel sync-btn" onclick="cancelEdit(${item.id})" style="background:#666; color:#fff; border:none;">취소</button>
|
||||
</div>
|
||||
</div>
|
||||
${item.handled_date ? `<div style="margin-top:10px; font-size:12px; color:#888; text-align:right;">최종 수정일: ${item.handled_date}</div>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
function enableEdit(id) {
|
||||
const form = document.getElementById(`reply-form-${id}`);
|
||||
form.classList.replace('readonly', 'editable');
|
||||
const elements = [`reply-text-${id}`, `reply-status-${id}`, `reply-handler-${id}`];
|
||||
elements.forEach(elId => document.getElementById(elId).disabled = false);
|
||||
document.getElementById(`reply-text-${id}`).focus();
|
||||
}
|
||||
|
||||
async function cancelEdit(id) {
|
||||
try {
|
||||
const response = await fetch(`${API.INQUIRIES}/${id}`);
|
||||
const item = await response.json();
|
||||
const txt = document.getElementById(`reply-text-${id}`);
|
||||
const status = document.getElementById(`reply-status-${id}`);
|
||||
const handler = document.getElementById(`reply-handler-${id}`);
|
||||
txt.value = item.reply || '';
|
||||
status.value = item.status;
|
||||
handler.value = item.handler || '';
|
||||
[txt, status, handler].forEach(el => el.disabled = true);
|
||||
document.getElementById(`reply-form-${id}`).classList.replace('editable', 'readonly');
|
||||
} catch { loadInquiries(); }
|
||||
}
|
||||
|
||||
async function saveReply(id) {
|
||||
const reply = document.getElementById(`reply-text-${id}`).value;
|
||||
const status = document.getElementById(`reply-status-${id}`).value;
|
||||
const handler = document.getElementById(`reply-handler-${id}`).value;
|
||||
if (!reply.trim() || !handler.trim()) return alert("내용과 처리자를 모두 입력해 주세요.");
|
||||
try {
|
||||
const response = await fetch(`${API.INQUIRIES}/${id}/reply`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ reply, status, handler })
|
||||
});
|
||||
if ((await response.json()).success) { alert("저장되었습니다."); loadInquiries(); }
|
||||
} catch { alert("저장 중 오류가 발생했습니다."); }
|
||||
}
|
||||
|
||||
async function deleteReply(id) {
|
||||
if (!confirm("답변을 삭제하시겠습니까?")) return;
|
||||
try {
|
||||
const response = await fetch(`${API.INQUIRIES}/${id}/reply`, { method: 'DELETE' });
|
||||
if ((await response.json()).success) { alert("삭제되었습니다."); loadInquiries(); }
|
||||
} catch { alert("삭제 중 오류가 발생했습니다."); }
|
||||
}
|
||||
|
||||
function toggleAccordion(id) {
|
||||
const detailRow = document.getElementById(`detail-${id}`);
|
||||
if (!detailRow) return;
|
||||
const inquiryRow = detailRow.previousElementSibling;
|
||||
const isActive = detailRow.classList.contains('active');
|
||||
|
||||
document.querySelectorAll('.detail-row.active').forEach(row => {
|
||||
if (row.id !== `detail-${id}`) {
|
||||
row.classList.remove('active');
|
||||
if (row.previousElementSibling) row.previousElementSibling.classList.remove('active-row');
|
||||
}
|
||||
});
|
||||
|
||||
if (isActive) {
|
||||
detailRow.classList.remove('active');
|
||||
inquiryRow.classList.remove('active-row');
|
||||
} else {
|
||||
detailRow.classList.add('active');
|
||||
inquiryRow.classList.add('active-row');
|
||||
scrollToRow(inquiryRow);
|
||||
}
|
||||
}
|
||||
|
||||
function scrollToRow(row) {
|
||||
setTimeout(() => {
|
||||
const headerHeight = document.getElementById('stickyHeader').offsetHeight;
|
||||
const totalOffset = 36 + headerHeight + 40;
|
||||
const offsetPosition = (row.getBoundingClientRect().top + window.pageYOffset) - totalOffset;
|
||||
window.scrollTo({ top: offsetPosition, behavior: 'smooth' });
|
||||
}, 100);
|
||||
}
|
||||
|
||||
function updateStats(data) {
|
||||
const counts = {
|
||||
Total: data.length,
|
||||
Complete: data.filter(i => i.status === '완료').length,
|
||||
Working: data.filter(i => i.status === '작업 중').length,
|
||||
Checking: data.filter(i => i.status === '확인 중').length,
|
||||
Pending: data.filter(i => i.status === '개발예정').length,
|
||||
Unconfirmed: data.filter(i => i.status === '미확인').length
|
||||
};
|
||||
Object.keys(counts).forEach(k => {
|
||||
const el = document.getElementById(`count${k}`);
|
||||
if (el) el.textContent = counts[k].toLocaleString();
|
||||
});
|
||||
}
|
||||
|
||||
function openImageModal(src) {
|
||||
document.getElementById('modalImage').src = src;
|
||||
ModalManager.open('imageModal');
|
||||
}
|
||||
|
||||
function toggleImageSection(id) {
|
||||
const section = document.getElementById(`img-section-${id}`);
|
||||
const content = document.getElementById(`img-content-${id}`);
|
||||
const icon = section.querySelector('.toggle-icon');
|
||||
const isCollapsed = content.classList.toggle('collapsed');
|
||||
section.classList.toggle('active', !isCollapsed);
|
||||
icon.textContent = isCollapsed ? '▼' : '▲';
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', loadInquiries);
|
||||
window.addEventListener('resize', initStickyHeader);
|
||||
/**
|
||||
* Project Master Overseas Inquiries JS
|
||||
* 기능: 문의사항 로드, 필터링, 답변 관리, 아코디언 및 이미지 모달
|
||||
*/
|
||||
|
||||
// --- 초기화 ---
|
||||
let allInquiries = [];
|
||||
let currentSort = { field: 'no', direction: 'desc' };
|
||||
|
||||
async function loadInquiries() {
|
||||
initStickyHeader();
|
||||
|
||||
const pmType = document.getElementById('filterPmType').value;
|
||||
const category = document.getElementById('filterCategory').value;
|
||||
const keyword = document.getElementById('searchKeyword').value;
|
||||
|
||||
const params = new URLSearchParams({
|
||||
pm_type: pmType,
|
||||
category: category,
|
||||
keyword: keyword
|
||||
});
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API.INQUIRIES}?${params}`);
|
||||
allInquiries = await response.json();
|
||||
|
||||
refreshInquiryBoard();
|
||||
} catch (e) {
|
||||
console.error("데이터 로딩 중 오류 발생:", e);
|
||||
}
|
||||
}
|
||||
|
||||
function refreshInquiryBoard() {
|
||||
const status = document.getElementById('filterStatus').value;
|
||||
|
||||
// 1. 상태 필터링
|
||||
let filteredData = status ? allInquiries.filter(item => item.status === status) : [...allInquiries];
|
||||
|
||||
// 2. 정렬 적용
|
||||
filteredData = sortData(filteredData);
|
||||
|
||||
// 3. 통계 및 리스트 렌더링
|
||||
updateStats(allInquiries);
|
||||
updateSortUI();
|
||||
renderInquiryList(filteredData);
|
||||
}
|
||||
|
||||
function handleSort(field) {
|
||||
if (currentSort.field === field) {
|
||||
currentSort.direction = currentSort.direction === 'asc' ? 'desc' : 'asc';
|
||||
} else {
|
||||
currentSort.field = field;
|
||||
currentSort.direction = 'asc';
|
||||
}
|
||||
refreshInquiryBoard();
|
||||
}
|
||||
|
||||
function sortData(data) {
|
||||
const { field, direction } = currentSort;
|
||||
const modifier = direction === 'asc' ? 1 : -1;
|
||||
|
||||
return data.sort((a, b) => {
|
||||
let valA = a[field];
|
||||
let valB = b[field];
|
||||
|
||||
// 숫자형 변환 시도 (No 필드 등)
|
||||
if (field === 'no' || !isNaN(valA)) {
|
||||
valA = Number(valA);
|
||||
valB = Number(valB);
|
||||
}
|
||||
|
||||
// null/undefined 처리
|
||||
if (valA === null || valA === undefined) valA = "";
|
||||
if (valB === null || valB === undefined) valB = "";
|
||||
|
||||
if (valA < valB) return -1 * modifier;
|
||||
if (valA > valB) return 1 * modifier;
|
||||
return 0;
|
||||
});
|
||||
}
|
||||
|
||||
function updateSortUI() {
|
||||
// 모든 헤더 클래스 및 아이콘 초기화
|
||||
document.querySelectorAll('.inquiry-table thead th.sortable').forEach(th => {
|
||||
th.classList.remove('active-sort');
|
||||
const icon = th.querySelector('.sort-icon');
|
||||
if (icon) {
|
||||
// 레이아웃 시프트 방지를 위해 투명한 기본 아이콘(또는 공백) 유지
|
||||
icon.textContent = "▲";
|
||||
icon.style.opacity = "0";
|
||||
}
|
||||
});
|
||||
|
||||
// 현재 정렬된 헤더 강조 및 아이콘 표시
|
||||
const activeTh = document.querySelector(`.inquiry-table thead th[onclick*="'${currentSort.field}'"]`);
|
||||
if (activeTh) {
|
||||
activeTh.classList.add('active-sort');
|
||||
const icon = activeTh.querySelector('.sort-icon');
|
||||
if (icon) {
|
||||
icon.textContent = currentSort.direction === 'asc' ? "▲" : "▼";
|
||||
icon.style.opacity = "1";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function initStickyHeader() {
|
||||
const header = document.getElementById('stickyHeader');
|
||||
const thead = document.querySelector('.inquiry-table thead');
|
||||
if (header && thead) {
|
||||
const headerHeight = header.offsetHeight;
|
||||
const totalOffset = 36 + headerHeight;
|
||||
document.querySelectorAll('.inquiry-table thead th').forEach(th => {
|
||||
th.style.top = totalOffset + 'px';
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function renderInquiryList(data) {
|
||||
const tbody = document.getElementById('inquiryList');
|
||||
tbody.innerHTML = data.map(item => `
|
||||
<tr class="inquiry-row" onclick="toggleAccordion(${item.id})">
|
||||
<td title="${item.no}">${item.no}</td>
|
||||
<td style="text-align:center;">
|
||||
${item.image_url ? `<img src="${item.image_url}" class="img-thumbnail" alt="thumbnail">` : '<span class="no-img">없음</span>'}
|
||||
</td>
|
||||
<td title="${item.pm_type}">${item.pm_type}</td>
|
||||
<td title="${item.browser || 'Chrome'}">${item.browser || 'Chrome'}</td>
|
||||
<td title="${item.category}">${item.category}</td>
|
||||
<td title="${item.project_nm}">${item.project_nm}</td>
|
||||
<td class="content-preview" title="${item.content}">${item.content}</td>
|
||||
<td title="${item.author}">${item.author}</td>
|
||||
<td title="${item.reg_date}">${item.reg_date}</td>
|
||||
<td class="content-preview" title="${item.reply || ''}" style="color: #1e5149; font-weight: 500;">${item.reply || '-'}</td>
|
||||
<td><span class="status-badge ${Utils.getStatusClass(item.status)}">${item.status}</span></td>
|
||||
</tr>
|
||||
<tr id="detail-${item.id}" class="detail-row">
|
||||
<td colspan="11">
|
||||
<div class="detail-container">
|
||||
<button class="btn-close-accordion" onclick="toggleAccordion(${item.id})">접기</button>
|
||||
<div class="detail-content-wrapper">
|
||||
<div class="detail-meta-grid">
|
||||
<div><span class="detail-label">작성자:</span> ${item.author}</div>
|
||||
<div><span class="detail-label">등록일:</span> ${item.reg_date}</div>
|
||||
<div><span class="detail-label">시스템:</span> ${item.pm_type}</div>
|
||||
<div><span class="detail-label">환경:</span> ${item.browser || 'Chrome'} / ${item.device || 'PC'}</div>
|
||||
</div>
|
||||
|
||||
<div class="detail-q-section">
|
||||
<h4 style="margin-top:0; margin-bottom:10px; color:#1e5149;">[질문 내용]</h4>
|
||||
<div style="line-height:1.6; white-space: pre-wrap;">${item.content}</div>
|
||||
</div>
|
||||
|
||||
${item.image_url ? `
|
||||
<div class="detail-image-section" id="img-section-${item.id}">
|
||||
<div class="image-section-header" onclick="toggleImageSection(${item.id})">
|
||||
<h4>
|
||||
<span>🖼️</span> [첨부 이미지]
|
||||
<span style="font-size:11px; color:#888; font-weight:normal;">(클릭 시 크게 보기)</span>
|
||||
</h4>
|
||||
<span class="toggle-icon">▼</span>
|
||||
</div>
|
||||
<div class="image-section-content collapsed" id="img-content-${item.id}">
|
||||
<img src="${item.image_url}" class="preview-img" alt="Inquiry Image" style="cursor: pointer;" onclick="event.stopPropagation(); openImageModal(this.src)">
|
||||
</div>
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
<div class="detail-a-section">
|
||||
<h4 style="margin-top:0; margin-bottom:10px; color:#1e5149;">[조치 및 답변]</h4>
|
||||
<div id="reply-form-${item.id}" class="reply-edit-form readonly">
|
||||
<textarea id="reply-text-${item.id}" disabled placeholder="답변 내용이 없습니다.">${item.reply || ''}</textarea>
|
||||
<div style="display:flex; justify-content: space-between; align-items: center;">
|
||||
<div style="display:flex; gap:15px; align-items:center;">
|
||||
<div class="filter-group" style="flex-direction:row; align-items:center; gap:8px;">
|
||||
<label style="margin:0;">처리상태:</label>
|
||||
<select id="reply-status-${item.id}" disabled style="padding:5px 10px;">
|
||||
<option value="완료" ${item.status === '완료' ? 'selected' : ''}>완료</option>
|
||||
<option value="작업 중" ${item.status === '작업 중' ? 'selected' : ''}>작업 중</option>
|
||||
<option value="확인 중" ${item.status === '확인 중' ? 'selected' : ''}>확인 중</option>
|
||||
<option value="개발예정" ${item.status === '개발예정' ? 'selected' : ''}>개발예정</option>
|
||||
<option value="미확인" ${item.status === '미확인' ? 'selected' : ''}>미확인</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="filter-group" style="flex-direction:row; align-items:center; gap:8px;">
|
||||
<label style="margin:0;">처리자:</label>
|
||||
<input type="text" id="reply-handler-${item.id}" disabled value="${item.handler || ''}" placeholder="이름 입력" style="padding:5px 10px; width:100px;">
|
||||
</div>
|
||||
</div>
|
||||
<div style="display:flex; gap:8px;">
|
||||
<button class="btn-edit sync-btn" onclick="enableEdit(${item.id})" style="background:#1e5149; color:#fff; border:none;">${item.reply ? '수정하기' : '답변작성'}</button>
|
||||
<button class="btn-save sync-btn" onclick="saveReply(${item.id})" style="background:#1e5149; color:#fff; border:none;">저장</button>
|
||||
<button class="btn-delete sync-btn" onclick="deleteReply(${item.id})" style="background:#f44336; color:#fff; border:none;">삭제</button>
|
||||
<button class="btn-cancel sync-btn" onclick="cancelEdit(${item.id})" style="background:#666; color:#fff; border:none;">취소</button>
|
||||
</div>
|
||||
</div>
|
||||
${item.handled_date ? `<div style="margin-top:10px; font-size:12px; color:#888; text-align:right;">최종 수정일: ${item.handled_date}</div>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
function enableEdit(id) {
|
||||
const form = document.getElementById(`reply-form-${id}`);
|
||||
form.classList.replace('readonly', 'editable');
|
||||
const elements = [`reply-text-${id}`, `reply-status-${id}`, `reply-handler-${id}`];
|
||||
elements.forEach(elId => document.getElementById(elId).disabled = false);
|
||||
document.getElementById(`reply-text-${id}`).focus();
|
||||
}
|
||||
|
||||
async function cancelEdit(id) {
|
||||
try {
|
||||
const response = await fetch(`${API.INQUIRIES}/${id}`);
|
||||
const item = await response.json();
|
||||
const txt = document.getElementById(`reply-text-${id}`);
|
||||
const status = document.getElementById(`reply-status-${id}`);
|
||||
const handler = document.getElementById(`reply-handler-${id}`);
|
||||
txt.value = item.reply || '';
|
||||
status.value = item.status;
|
||||
handler.value = item.handler || '';
|
||||
[txt, status, handler].forEach(el => el.disabled = true);
|
||||
document.getElementById(`reply-form-${id}`).classList.replace('editable', 'readonly');
|
||||
} catch { loadInquiries(); }
|
||||
}
|
||||
|
||||
async function saveReply(id) {
|
||||
const reply = document.getElementById(`reply-text-${id}`).value;
|
||||
const status = document.getElementById(`reply-status-${id}`).value;
|
||||
const handler = document.getElementById(`reply-handler-${id}`).value;
|
||||
if (!reply.trim() || !handler.trim()) return alert("내용과 처리자를 모두 입력해 주세요.");
|
||||
try {
|
||||
const response = await fetch(`${API.INQUIRIES}/${id}/reply`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ reply, status, handler })
|
||||
});
|
||||
if ((await response.json()).success) { alert("저장되었습니다."); loadInquiries(); }
|
||||
} catch { alert("저장 중 오류가 발생했습니다."); }
|
||||
}
|
||||
|
||||
async function deleteReply(id) {
|
||||
if (!confirm("답변을 삭제하시겠습니까?")) return;
|
||||
try {
|
||||
const response = await fetch(`${API.INQUIRIES}/${id}/reply`, { method: 'DELETE' });
|
||||
if ((await response.json()).success) { alert("삭제되었습니다."); loadInquiries(); }
|
||||
} catch { alert("삭제 중 오류가 발생했습니다."); }
|
||||
}
|
||||
|
||||
function toggleAccordion(id) {
|
||||
const detailRow = document.getElementById(`detail-${id}`);
|
||||
if (!detailRow) return;
|
||||
const inquiryRow = detailRow.previousElementSibling;
|
||||
const isActive = detailRow.classList.contains('active');
|
||||
|
||||
document.querySelectorAll('.detail-row.active').forEach(row => {
|
||||
if (row.id !== `detail-${id}`) {
|
||||
row.classList.remove('active');
|
||||
if (row.previousElementSibling) row.previousElementSibling.classList.remove('active-row');
|
||||
}
|
||||
});
|
||||
|
||||
if (isActive) {
|
||||
detailRow.classList.remove('active');
|
||||
inquiryRow.classList.remove('active-row');
|
||||
} else {
|
||||
detailRow.classList.add('active');
|
||||
inquiryRow.classList.add('active-row');
|
||||
scrollToRow(inquiryRow);
|
||||
}
|
||||
}
|
||||
|
||||
function scrollToRow(row) {
|
||||
setTimeout(() => {
|
||||
const headerHeight = document.getElementById('stickyHeader').offsetHeight;
|
||||
const totalOffset = 36 + headerHeight + 40;
|
||||
const offsetPosition = (row.getBoundingClientRect().top + window.pageYOffset) - totalOffset;
|
||||
window.scrollTo({ top: offsetPosition, behavior: 'smooth' });
|
||||
}, 100);
|
||||
}
|
||||
|
||||
function updateStats(data) {
|
||||
const counts = {
|
||||
Total: data.length,
|
||||
Complete: data.filter(i => i.status === '완료').length,
|
||||
Working: data.filter(i => i.status === '작업 중').length,
|
||||
Checking: data.filter(i => i.status === '확인 중').length,
|
||||
Pending: data.filter(i => i.status === '개발예정').length,
|
||||
Unconfirmed: data.filter(i => i.status === '미확인').length
|
||||
};
|
||||
Object.keys(counts).forEach(k => {
|
||||
const el = document.getElementById(`count${k}`);
|
||||
if (el) el.textContent = counts[k].toLocaleString();
|
||||
});
|
||||
}
|
||||
|
||||
function openImageModal(src) {
|
||||
document.getElementById('modalImage').src = src;
|
||||
ModalManager.open('imageModal');
|
||||
}
|
||||
|
||||
function toggleImageSection(id) {
|
||||
const section = document.getElementById(`img-section-${id}`);
|
||||
const content = document.getElementById(`img-content-${id}`);
|
||||
const icon = section.querySelector('.toggle-icon');
|
||||
const isCollapsed = content.classList.toggle('collapsed');
|
||||
section.classList.toggle('active', !isCollapsed);
|
||||
icon.textContent = isCollapsed ? '▼' : '▲';
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', loadInquiries);
|
||||
window.addEventListener('resize', initStickyHeader);
|
||||
|
||||
624
js/mail.js
624
js/mail.js
@@ -1,312 +1,312 @@
|
||||
/**
|
||||
* Project Master Overseas Mail Management JS
|
||||
* 기능: 첨부파일 로드, AI 분석, 메일 목록 렌더링, 미리보기, 주소록 관리
|
||||
*/
|
||||
|
||||
let currentFiles = [];
|
||||
let editingIndex = -1;
|
||||
|
||||
const HIERARCHY = {
|
||||
"행정": { "계약": ["계약관리", "기성관리", "업무지시서", "인원관리"], "업무관리": ["업무일지(2025)", "업무일지(2025년 이전)", "발주처 정기보고", "본사업무보고", "공사감독일지", "양식서류"] },
|
||||
"설계성과품": { "시방서": ["공사시방서"], "설계도면": ["공통", "토공", "비탈면안전공", "배수공", "교량공", "포장공"], "수량산출서": ["토공", "배수공"], "내역서": ["단가산출서"], "보고서": ["실시설계보고서", "지반조사보고서"], "측량계산부": ["측량계산부"], "설계단계 수행협의": ["회의·협의"] },
|
||||
"시공검측": { "토공": ["검측 (깨기)", "검측 (노체)"], "배수공": ["검측 (V형측구)", "검측 (종배수관)"], "구조물공": ["검측 (평목교)"], "포장공": ["검측 (기층)"] },
|
||||
"설계변경": { "실정보고": ["토공", "배수공", "안전관리"], "기술지원 검토": ["토공", "구조물&부대공"] }
|
||||
};
|
||||
|
||||
const MAIL_SAMPLES = {
|
||||
inbound: [
|
||||
{ person: "라오스 농림부", email: "pany.s@lao.gov.la", time: "2026-03-05", title: "ITTC 교육센터 착공식 일정 협의", summary: "착공식 관련하여 정부 측 인사의 일정을 반영한 최종 공문을 송부합니다.", active: true },
|
||||
{ person: "현대건설 (김철수 소장)", email: "cs.kim@hdec.co.kr", time: "2026-03-04", title: "[긴급] 어천-공주(4차) 하도급 변경계약 통보", summary: "철거공사 물량 변동에 따른 계약 금액 조정 건입니다. 검토 후 승인 부탁드립니다.", active: false }
|
||||
],
|
||||
outbound: [
|
||||
{ person: "공사관리부 (본사)", email: "hq_pm@projectmaster.com", time: "2026-03-04", title: "어천-공주 2월 월간 공정보고서 제출", summary: "2월 한 달간의 주요 공정 및 예산 집행 현황 보고서입니다.", active: false }
|
||||
],
|
||||
drafts: [], deleted: []
|
||||
};
|
||||
|
||||
let currentMailTab = 'inbound';
|
||||
let filteredMails = [];
|
||||
|
||||
// --- 첨부파일 데이터 로드 및 렌더링 ---
|
||||
async function loadAttachments() {
|
||||
try {
|
||||
const res = await fetch(API.ATTACHMENTS);
|
||||
currentFiles = await res.json();
|
||||
renderFiles();
|
||||
} catch (e) { console.error("Failed to load attachments:", e); }
|
||||
}
|
||||
|
||||
function renderFiles() {
|
||||
const isAiActive = document.getElementById('aiToggle').checked;
|
||||
const container = document.getElementById('attachmentList');
|
||||
if (!container) return;
|
||||
container.innerHTML = '';
|
||||
|
||||
currentFiles.forEach((file, index) => {
|
||||
const item = document.createElement('div');
|
||||
item.className = 'attachment-item-wrap';
|
||||
item.style.marginBottom = "8px";
|
||||
|
||||
let pathText = "경로를 선택해주세요";
|
||||
let modeClass = "manual-mode";
|
||||
|
||||
if (file.analysis) {
|
||||
const prefix = file.analysis.isManual ? "선택 경로: " : "추천: ";
|
||||
pathText = `${prefix}${file.analysis.suggested_path}`;
|
||||
modeClass = file.analysis.isManual ? "manual-mode" : "smart-mode";
|
||||
} else if (isAiActive) {
|
||||
pathText = "AI 분석 대기 중...";
|
||||
modeClass = "smart-mode";
|
||||
}
|
||||
|
||||
item.innerHTML = `
|
||||
<div class="attachment-item" onclick="showPreview(${index}, event)" style="position:relative;">
|
||||
<span class="file-icon" style="pointer-events:none;">📄</span>
|
||||
<div class="file-details" style="pointer-events:none;">
|
||||
<div class="file-name" title="${file.name}">${file.name}</div>
|
||||
<div class="file-size">${file.size}</div>
|
||||
</div>
|
||||
<div class="btn-group" onclick="event.stopPropagation()" style="position:relative; z-index:2;">
|
||||
<span id="recommend-${index}" class="ai-recommend path-display ${modeClass}" onclick="openPathModal(${index}, event)">${pathText}</span>
|
||||
${isAiActive ? `<button class="btn-upload btn-ai" onclick="startAnalysis(${index}, event)">AI 분석</button>` : ''}
|
||||
<button class="btn-upload btn-normal" onclick="confirmUpload(${index}, event)">파일업로드</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="log-area-${index}" class="file-log-area"><div id="log-content-${index}"></div></div>
|
||||
`;
|
||||
container.appendChild(item);
|
||||
});
|
||||
}
|
||||
|
||||
// --- AI 분석 실행 ---
|
||||
async function startAnalysis(index, event) {
|
||||
if (event) event.stopPropagation();
|
||||
const file = currentFiles[index];
|
||||
if (!file) return;
|
||||
|
||||
// UI 상태 업데이트: 분석 중 표시
|
||||
const logArea = document.getElementById(`log-area-${index}`);
|
||||
const logContent = document.getElementById(`log-content-${index}`);
|
||||
if (logArea) logArea.classList.add('active');
|
||||
if (logContent) {
|
||||
logContent.innerHTML = `<div class="ai-status-msg">
|
||||
<span class="ai-loading-spinner"></span>
|
||||
AI가 문서를 정밀 분석 중입니다...
|
||||
</div>`;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API.ANALYZE_FILE}?filename=${encodeURIComponent(file.name)}`);
|
||||
const result = await res.json();
|
||||
|
||||
if (result.error) {
|
||||
if (logContent) logContent.innerHTML = `<div style="color:var(--error-color);">오류: ${result.error}</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
// 분석 결과 저장 및 UI 갱신
|
||||
currentFiles[index].analysis = result.final_result;
|
||||
currentFiles[index].analysis.isManual = false;
|
||||
|
||||
if (logContent) {
|
||||
logContent.innerHTML = `
|
||||
<div class="ai-analysis-result">
|
||||
<div style="font-weight:700; color:var(--primary-lv-6); margin-bottom:4px;">✨ AI 분석 완료</div>
|
||||
<div style="font-size:11px; color:var(--text-sub); line-height:1.4;">${result.final_result.reason}</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
renderFiles();
|
||||
} catch (e) {
|
||||
console.error("AI Analysis failed:", e);
|
||||
if (logContent) logContent.innerHTML = `<div style="color:var(--error-color);">분석 실패: 네트워크 오류가 발생했습니다.</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
// --- 미리보기 제어 ---
|
||||
function showPreview(index, event) {
|
||||
if (event && (event.target.closest('.btn-group') || event.target.closest('.path-display'))) return;
|
||||
const file = currentFiles[index];
|
||||
if (!file) return;
|
||||
|
||||
const previewArea = document.getElementById('mailPreviewArea');
|
||||
const toggleIcon = document.getElementById('previewToggleIcon');
|
||||
const fullViewBtn = document.getElementById('fullViewBtn');
|
||||
const previewContainer = document.getElementById('previewContainer');
|
||||
|
||||
if (previewArea) {
|
||||
previewArea.classList.add('active');
|
||||
if (toggleIcon) toggleIcon.innerText = '▶';
|
||||
}
|
||||
|
||||
const fileUrl = Utils.getSafeFileUrl(file.name);
|
||||
if (fullViewBtn) {
|
||||
fullViewBtn.style.display = 'block';
|
||||
fullViewBtn.onclick = () => window.open(fileUrl, 'PMFullView', 'width=1000,height=800');
|
||||
}
|
||||
|
||||
if (file.name.toLowerCase().endsWith('.pdf')) {
|
||||
previewContainer.innerHTML = `<iframe src="${fileUrl}#page=1" style="width:100%; height:100%; border:none;"></iframe>`;
|
||||
} else {
|
||||
previewContainer.innerHTML = `<div class="flex-column flex-center" style="height:100%; padding:20px; text-align:center;"><img src="/sample.png" style="max-width:80%; max-height:60%; margin-bottom:20px;"><div style="font-weight:700; color:var(--primary-color);">${file.name}</div></div>`;
|
||||
}
|
||||
|
||||
document.querySelectorAll('.attachment-item').forEach(item => item.classList.remove('active'));
|
||||
if (event?.currentTarget) event.currentTarget.classList.add('active');
|
||||
}
|
||||
|
||||
function togglePreviewAuto() {
|
||||
const area = document.getElementById('mailPreviewArea');
|
||||
const icon = document.getElementById('previewToggleIcon');
|
||||
if (area) {
|
||||
const isActive = area.classList.toggle('active');
|
||||
if (icon) icon.innerText = isActive ? '▶' : '◀';
|
||||
}
|
||||
}
|
||||
|
||||
// --- 메일 리스트 제어 ---
|
||||
function renderMailList(tabType, mailsToShow = null) {
|
||||
currentMailTab = tabType;
|
||||
const container = document.querySelector('.mail-items-container');
|
||||
if (!container) return;
|
||||
|
||||
const mails = mailsToShow || MAIL_SAMPLES[tabType] || [];
|
||||
filteredMails = mails;
|
||||
updateBulkActionBar();
|
||||
|
||||
container.innerHTML = mails.map((mail, idx) => `
|
||||
<div class="mail-item ${mail.active ? 'active' : ''}" onclick="selectMailItem(this, ${idx})">
|
||||
<input type="checkbox" class="mail-item-checkbox" onclick="event.stopPropagation()" onchange="updateBulkActionBar()">
|
||||
<div class="mail-item-content">
|
||||
<div class="flex-between" style="margin-bottom:6px;">
|
||||
<span style="font-weight:700; font-size:14px; color:${mail.active ? 'var(--primary-color)' : 'var(--text-main)'};">${mail.person}</span>
|
||||
<div class="mail-item-info"><span class="mail-date">${mail.time}</span></div>
|
||||
</div>
|
||||
<div style="font-weight:600; font-size:12px; margin-bottom:4px; color:#2d3748;">${mail.title}</div>
|
||||
<div class="text-truncate" style="-webkit-line-clamp:2; display:-webkit-box; -webkit-box-orient:vertical; white-space:normal; font-size:11px; color:var(--text-sub);">${mail.summary}</div>
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
|
||||
const activeIdx = mails.findIndex(m => m.active);
|
||||
if (activeIdx !== -1) updateMailContent(mails[activeIdx]);
|
||||
}
|
||||
|
||||
function selectMailItem(el, index) {
|
||||
document.querySelectorAll('.mail-item').forEach(item => item.classList.remove('active'));
|
||||
el.classList.add('active');
|
||||
const mail = filteredMails[index];
|
||||
if (mail) updateMailContent(mail);
|
||||
}
|
||||
|
||||
function updateMailContent(mail) {
|
||||
const title = document.querySelector('.mail-content-header h2');
|
||||
if (title) title.innerText = mail.title;
|
||||
document.querySelector('.mail-body').innerHTML = mail.summary.replace(/\n/g, '<br>') + "<br><br>본 내용은 샘플 데이터입니다.";
|
||||
}
|
||||
|
||||
function switchMailTab(el, tabType) {
|
||||
document.querySelectorAll('.mail-tab').forEach(tab => tab.classList.remove('active'));
|
||||
el.classList.add('active');
|
||||
renderMailList(tabType);
|
||||
}
|
||||
|
||||
// --- 경로 선택 모달 ---
|
||||
function openPathModal(index, event) {
|
||||
if (event) event.stopPropagation();
|
||||
editingIndex = index;
|
||||
const tabSelect = document.getElementById('tabSelect');
|
||||
if (tabSelect) {
|
||||
tabSelect.innerHTML = Object.keys(HIERARCHY).map(tab => `<option value="${tab}">${tab}</option>`).join('');
|
||||
updateCategories();
|
||||
ModalManager.open('pathModal');
|
||||
}
|
||||
}
|
||||
|
||||
function updateCategories() {
|
||||
const tab = document.getElementById('tabSelect').value;
|
||||
document.getElementById('categorySelect').innerHTML = Object.keys(HIERARCHY[tab]).map(cat => `<option value="${cat}">${cat}</option>`).join('');
|
||||
updateSubs();
|
||||
}
|
||||
|
||||
function updateSubs() {
|
||||
const tab = document.getElementById('tabSelect').value;
|
||||
const cat = document.getElementById('categorySelect').value;
|
||||
document.getElementById('subSelect').innerHTML = HIERARCHY[tab][cat].map(sub => `<option value="${sub}">${sub}</option>`).join('');
|
||||
}
|
||||
|
||||
function applyPathSelection() {
|
||||
const path = `${document.getElementById('tabSelect').value} > ${document.getElementById('categorySelect').value} > ${document.getElementById('subSelect').value}`;
|
||||
if (!currentFiles[editingIndex].analysis) currentFiles[editingIndex].analysis = {};
|
||||
currentFiles[editingIndex].analysis.suggested_path = path;
|
||||
currentFiles[editingIndex].analysis.isManual = true;
|
||||
renderFiles();
|
||||
ModalManager.close('pathModal');
|
||||
}
|
||||
|
||||
// --- 주소록 관리 ---
|
||||
let addressBookData = [
|
||||
{ name: "이태훈", dept: "PM Overseas / 선임연구원", email: "th.lee@projectmaster.com", phone: "010-1234-5678" },
|
||||
{ name: "Pany S.", dept: "라오스 농림부 / 국장", email: "pany.s@lao.gov.la", phone: "+856-20-1234-5678" }
|
||||
];
|
||||
let contactEditingIndex = -1;
|
||||
|
||||
function openAddressBook() { renderAddressBook(); ModalManager.open('addressBookModal'); }
|
||||
function closeAddressBook() { ModalManager.close('addressBookModal'); }
|
||||
|
||||
function renderAddressBook() {
|
||||
const body = document.getElementById('addressBookBody');
|
||||
if (!body) return;
|
||||
body.innerHTML = addressBookData.map((c, idx) => `
|
||||
<tr>
|
||||
<td><strong>${c.name}</strong></td><td>${c.dept}</td><td>${c.email}</td><td>${c.phone}</td>
|
||||
<td style="text-align:right;">
|
||||
<button class="_button-xsmall" onclick="editContact(${idx})">수정</button>
|
||||
<button class="_button-xsmall" style="color:var(--error-color); border-color:#feb2b2; background:#fff5f5;" onclick="deleteContact(${idx})">삭제</button>
|
||||
</td>
|
||||
</tr>`).join('');
|
||||
}
|
||||
|
||||
function toggleAddContactForm() {
|
||||
const form = document.getElementById('addContactForm');
|
||||
if (form.style.display === 'none') form.style.display = 'block';
|
||||
else { form.style.display = 'none'; contactEditingIndex = -1; }
|
||||
}
|
||||
|
||||
function editContact(index) {
|
||||
const c = addressBookData[index];
|
||||
contactEditingIndex = index;
|
||||
document.getElementById('newContactName').value = c.name;
|
||||
document.getElementById('newContactDept').value = c.dept;
|
||||
document.getElementById('newContactEmail').value = c.email;
|
||||
document.getElementById('newContactPhone').value = c.phone;
|
||||
document.getElementById('addContactForm').style.display = 'block';
|
||||
}
|
||||
|
||||
function deleteContact(index) {
|
||||
if (confirm(`'${addressBookData[index].name}'님을 삭제하시겠습니까?`)) { addressBookData.splice(index, 1); renderAddressBook(); }
|
||||
}
|
||||
|
||||
function addContact() {
|
||||
const name = document.getElementById('newContactName').value;
|
||||
if (!name) return alert("이름을 입력해주세요.");
|
||||
const data = { name, dept: document.getElementById('newContactDept').value, email: document.getElementById('newContactEmail').value, phone: document.getElementById('newContactPhone').value };
|
||||
if (contactEditingIndex > -1) addressBookData[contactEditingIndex] = data;
|
||||
else addressBookData.push(data);
|
||||
renderAddressBook(); toggleAddContactForm();
|
||||
}
|
||||
|
||||
// --- 공통 액션 ---
|
||||
function updateBulkActionBar() {
|
||||
const count = document.querySelectorAll('.mail-item-checkbox:checked').length;
|
||||
const bar = document.getElementById('mailBulkActions');
|
||||
if (count > 0) { bar.classList.add('active'); document.getElementById('selectedCount').innerText = `${count}개 선택됨`; }
|
||||
else bar.classList.remove('active');
|
||||
}
|
||||
|
||||
// 초기화
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
loadAttachments();
|
||||
renderMailList('inbound');
|
||||
});
|
||||
/**
|
||||
* Project Master Overseas Mail Management JS
|
||||
* 기능: 첨부파일 로드, AI 분석, 메일 목록 렌더링, 미리보기, 주소록 관리
|
||||
*/
|
||||
|
||||
let currentFiles = [];
|
||||
let editingIndex = -1;
|
||||
|
||||
const HIERARCHY = {
|
||||
"행정": { "계약": ["계약관리", "기성관리", "업무지시서", "인원관리"], "업무관리": ["업무일지(2025)", "업무일지(2025년 이전)", "발주처 정기보고", "본사업무보고", "공사감독일지", "양식서류"] },
|
||||
"설계성과품": { "시방서": ["공사시방서"], "설계도면": ["공통", "토공", "비탈면안전공", "배수공", "교량공", "포장공"], "수량산출서": ["토공", "배수공"], "내역서": ["단가산출서"], "보고서": ["실시설계보고서", "지반조사보고서"], "측량계산부": ["측량계산부"], "설계단계 수행협의": ["회의·협의"] },
|
||||
"시공검측": { "토공": ["검측 (깨기)", "검측 (노체)"], "배수공": ["검측 (V형측구)", "검측 (종배수관)"], "구조물공": ["검측 (평목교)"], "포장공": ["검측 (기층)"] },
|
||||
"설계변경": { "실정보고": ["토공", "배수공", "안전관리"], "기술지원 검토": ["토공", "구조물&부대공"] }
|
||||
};
|
||||
|
||||
const MAIL_SAMPLES = {
|
||||
inbound: [
|
||||
{ person: "라오스 농림부", email: "pany.s@lao.gov.la", time: "2026-03-05", title: "ITTC 교육센터 착공식 일정 협의", summary: "착공식 관련하여 정부 측 인사의 일정을 반영한 최종 공문을 송부합니다.", active: true },
|
||||
{ person: "현대건설 (김철수 소장)", email: "cs.kim@hdec.co.kr", time: "2026-03-04", title: "[긴급] 어천-공주(4차) 하도급 변경계약 통보", summary: "철거공사 물량 변동에 따른 계약 금액 조정 건입니다. 검토 후 승인 부탁드립니다.", active: false }
|
||||
],
|
||||
outbound: [
|
||||
{ person: "공사관리부 (본사)", email: "hq_pm@projectmaster.com", time: "2026-03-04", title: "어천-공주 2월 월간 공정보고서 제출", summary: "2월 한 달간의 주요 공정 및 예산 집행 현황 보고서입니다.", active: false }
|
||||
],
|
||||
drafts: [], deleted: []
|
||||
};
|
||||
|
||||
let currentMailTab = 'inbound';
|
||||
let filteredMails = [];
|
||||
|
||||
// --- 첨부파일 데이터 로드 및 렌더링 ---
|
||||
async function loadAttachments() {
|
||||
try {
|
||||
const res = await fetch(API.ATTACHMENTS);
|
||||
currentFiles = await res.json();
|
||||
renderFiles();
|
||||
} catch (e) { console.error("Failed to load attachments:", e); }
|
||||
}
|
||||
|
||||
function renderFiles() {
|
||||
const isAiActive = document.getElementById('aiToggle').checked;
|
||||
const container = document.getElementById('attachmentList');
|
||||
if (!container) return;
|
||||
container.innerHTML = '';
|
||||
|
||||
currentFiles.forEach((file, index) => {
|
||||
const item = document.createElement('div');
|
||||
item.className = 'attachment-item-wrap';
|
||||
item.style.marginBottom = "8px";
|
||||
|
||||
let pathText = "경로를 선택해주세요";
|
||||
let modeClass = "manual-mode";
|
||||
|
||||
if (file.analysis) {
|
||||
const prefix = file.analysis.isManual ? "선택 경로: " : "추천: ";
|
||||
pathText = `${prefix}${file.analysis.suggested_path}`;
|
||||
modeClass = file.analysis.isManual ? "manual-mode" : "smart-mode";
|
||||
} else if (isAiActive) {
|
||||
pathText = "AI 분석 대기 중...";
|
||||
modeClass = "smart-mode";
|
||||
}
|
||||
|
||||
item.innerHTML = `
|
||||
<div class="attachment-item" onclick="showPreview(${index}, event)" style="position:relative;">
|
||||
<span class="file-icon" style="pointer-events:none;">📄</span>
|
||||
<div class="file-details" style="pointer-events:none;">
|
||||
<div class="file-name" title="${file.name}">${file.name}</div>
|
||||
<div class="file-size">${file.size}</div>
|
||||
</div>
|
||||
<div class="btn-group" onclick="event.stopPropagation()" style="position:relative; z-index:2;">
|
||||
<span id="recommend-${index}" class="ai-recommend path-display ${modeClass}" onclick="openPathModal(${index}, event)">${pathText}</span>
|
||||
${isAiActive ? `<button class="btn-upload btn-ai" onclick="startAnalysis(${index}, event)">AI 분석</button>` : ''}
|
||||
<button class="btn-upload btn-normal" onclick="confirmUpload(${index}, event)">파일업로드</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="log-area-${index}" class="file-log-area"><div id="log-content-${index}"></div></div>
|
||||
`;
|
||||
container.appendChild(item);
|
||||
});
|
||||
}
|
||||
|
||||
// --- AI 분석 실행 ---
|
||||
async function startAnalysis(index, event) {
|
||||
if (event) event.stopPropagation();
|
||||
const file = currentFiles[index];
|
||||
if (!file) return;
|
||||
|
||||
// UI 상태 업데이트: 분석 중 표시
|
||||
const logArea = document.getElementById(`log-area-${index}`);
|
||||
const logContent = document.getElementById(`log-content-${index}`);
|
||||
if (logArea) logArea.classList.add('active');
|
||||
if (logContent) {
|
||||
logContent.innerHTML = `<div class="ai-status-msg">
|
||||
<span class="ai-loading-spinner"></span>
|
||||
AI가 문서를 정밀 분석 중입니다...
|
||||
</div>`;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API.ANALYZE_FILE}?filename=${encodeURIComponent(file.name)}`);
|
||||
const result = await res.json();
|
||||
|
||||
if (result.error) {
|
||||
if (logContent) logContent.innerHTML = `<div style="color:var(--error-color);">오류: ${result.error}</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
// 분석 결과 저장 및 UI 갱신
|
||||
currentFiles[index].analysis = result.final_result;
|
||||
currentFiles[index].analysis.isManual = false;
|
||||
|
||||
if (logContent) {
|
||||
logContent.innerHTML = `
|
||||
<div class="ai-analysis-result">
|
||||
<div style="font-weight:700; color:var(--primary-lv-6); margin-bottom:4px;">✨ AI 분석 완료</div>
|
||||
<div style="font-size:11px; color:var(--text-sub); line-height:1.4;">${result.final_result.reason}</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
renderFiles();
|
||||
} catch (e) {
|
||||
console.error("AI Analysis failed:", e);
|
||||
if (logContent) logContent.innerHTML = `<div style="color:var(--error-color);">분석 실패: 네트워크 오류가 발생했습니다.</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
// --- 미리보기 제어 ---
|
||||
function showPreview(index, event) {
|
||||
if (event && (event.target.closest('.btn-group') || event.target.closest('.path-display'))) return;
|
||||
const file = currentFiles[index];
|
||||
if (!file) return;
|
||||
|
||||
const previewArea = document.getElementById('mailPreviewArea');
|
||||
const toggleIcon = document.getElementById('previewToggleIcon');
|
||||
const fullViewBtn = document.getElementById('fullViewBtn');
|
||||
const previewContainer = document.getElementById('previewContainer');
|
||||
|
||||
if (previewArea) {
|
||||
previewArea.classList.add('active');
|
||||
if (toggleIcon) toggleIcon.innerText = '▶';
|
||||
}
|
||||
|
||||
const fileUrl = Utils.getSafeFileUrl(file.name);
|
||||
if (fullViewBtn) {
|
||||
fullViewBtn.style.display = 'block';
|
||||
fullViewBtn.onclick = () => window.open(fileUrl, 'PMFullView', 'width=1000,height=800');
|
||||
}
|
||||
|
||||
if (file.name.toLowerCase().endsWith('.pdf')) {
|
||||
previewContainer.innerHTML = `<iframe src="${fileUrl}#page=1" style="width:100%; height:100%; border:none;"></iframe>`;
|
||||
} else {
|
||||
previewContainer.innerHTML = `<div class="flex-column flex-center" style="height:100%; padding:20px; text-align:center;"><img src="/sample.png" style="max-width:80%; max-height:60%; margin-bottom:20px;"><div style="font-weight:700; color:var(--primary-color);">${file.name}</div></div>`;
|
||||
}
|
||||
|
||||
document.querySelectorAll('.attachment-item').forEach(item => item.classList.remove('active'));
|
||||
if (event?.currentTarget) event.currentTarget.classList.add('active');
|
||||
}
|
||||
|
||||
function togglePreviewAuto() {
|
||||
const area = document.getElementById('mailPreviewArea');
|
||||
const icon = document.getElementById('previewToggleIcon');
|
||||
if (area) {
|
||||
const isActive = area.classList.toggle('active');
|
||||
if (icon) icon.innerText = isActive ? '▶' : '◀';
|
||||
}
|
||||
}
|
||||
|
||||
// --- 메일 리스트 제어 ---
|
||||
function renderMailList(tabType, mailsToShow = null) {
|
||||
currentMailTab = tabType;
|
||||
const container = document.querySelector('.mail-items-container');
|
||||
if (!container) return;
|
||||
|
||||
const mails = mailsToShow || MAIL_SAMPLES[tabType] || [];
|
||||
filteredMails = mails;
|
||||
updateBulkActionBar();
|
||||
|
||||
container.innerHTML = mails.map((mail, idx) => `
|
||||
<div class="mail-item ${mail.active ? 'active' : ''}" onclick="selectMailItem(this, ${idx})">
|
||||
<input type="checkbox" class="mail-item-checkbox" onclick="event.stopPropagation()" onchange="updateBulkActionBar()">
|
||||
<div class="mail-item-content">
|
||||
<div class="flex-between" style="margin-bottom:6px;">
|
||||
<span style="font-weight:700; font-size:14px; color:${mail.active ? 'var(--primary-color)' : 'var(--text-main)'};">${mail.person}</span>
|
||||
<div class="mail-item-info"><span class="mail-date">${mail.time}</span></div>
|
||||
</div>
|
||||
<div style="font-weight:600; font-size:12px; margin-bottom:4px; color:#2d3748;">${mail.title}</div>
|
||||
<div class="text-truncate" style="-webkit-line-clamp:2; display:-webkit-box; -webkit-box-orient:vertical; white-space:normal; font-size:11px; color:var(--text-sub);">${mail.summary}</div>
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
|
||||
const activeIdx = mails.findIndex(m => m.active);
|
||||
if (activeIdx !== -1) updateMailContent(mails[activeIdx]);
|
||||
}
|
||||
|
||||
function selectMailItem(el, index) {
|
||||
document.querySelectorAll('.mail-item').forEach(item => item.classList.remove('active'));
|
||||
el.classList.add('active');
|
||||
const mail = filteredMails[index];
|
||||
if (mail) updateMailContent(mail);
|
||||
}
|
||||
|
||||
function updateMailContent(mail) {
|
||||
const title = document.querySelector('.mail-content-header h2');
|
||||
if (title) title.innerText = mail.title;
|
||||
document.querySelector('.mail-body').innerHTML = mail.summary.replace(/\n/g, '<br>') + "<br><br>본 내용은 샘플 데이터입니다.";
|
||||
}
|
||||
|
||||
function switchMailTab(el, tabType) {
|
||||
document.querySelectorAll('.mail-tab').forEach(tab => tab.classList.remove('active'));
|
||||
el.classList.add('active');
|
||||
renderMailList(tabType);
|
||||
}
|
||||
|
||||
// --- 경로 선택 모달 ---
|
||||
function openPathModal(index, event) {
|
||||
if (event) event.stopPropagation();
|
||||
editingIndex = index;
|
||||
const tabSelect = document.getElementById('tabSelect');
|
||||
if (tabSelect) {
|
||||
tabSelect.innerHTML = Object.keys(HIERARCHY).map(tab => `<option value="${tab}">${tab}</option>`).join('');
|
||||
updateCategories();
|
||||
ModalManager.open('pathModal');
|
||||
}
|
||||
}
|
||||
|
||||
function updateCategories() {
|
||||
const tab = document.getElementById('tabSelect').value;
|
||||
document.getElementById('categorySelect').innerHTML = Object.keys(HIERARCHY[tab]).map(cat => `<option value="${cat}">${cat}</option>`).join('');
|
||||
updateSubs();
|
||||
}
|
||||
|
||||
function updateSubs() {
|
||||
const tab = document.getElementById('tabSelect').value;
|
||||
const cat = document.getElementById('categorySelect').value;
|
||||
document.getElementById('subSelect').innerHTML = HIERARCHY[tab][cat].map(sub => `<option value="${sub}">${sub}</option>`).join('');
|
||||
}
|
||||
|
||||
function applyPathSelection() {
|
||||
const path = `${document.getElementById('tabSelect').value} > ${document.getElementById('categorySelect').value} > ${document.getElementById('subSelect').value}`;
|
||||
if (!currentFiles[editingIndex].analysis) currentFiles[editingIndex].analysis = {};
|
||||
currentFiles[editingIndex].analysis.suggested_path = path;
|
||||
currentFiles[editingIndex].analysis.isManual = true;
|
||||
renderFiles();
|
||||
ModalManager.close('pathModal');
|
||||
}
|
||||
|
||||
// --- 주소록 관리 ---
|
||||
let addressBookData = [
|
||||
{ name: "이태훈", dept: "PM Overseas / 선임연구원", email: "th.lee@projectmaster.com", phone: "010-1234-5678" },
|
||||
{ name: "Pany S.", dept: "라오스 농림부 / 국장", email: "pany.s@lao.gov.la", phone: "+856-20-1234-5678" }
|
||||
];
|
||||
let contactEditingIndex = -1;
|
||||
|
||||
function openAddressBook() { renderAddressBook(); ModalManager.open('addressBookModal'); }
|
||||
function closeAddressBook() { ModalManager.close('addressBookModal'); }
|
||||
|
||||
function renderAddressBook() {
|
||||
const body = document.getElementById('addressBookBody');
|
||||
if (!body) return;
|
||||
body.innerHTML = addressBookData.map((c, idx) => `
|
||||
<tr>
|
||||
<td><strong>${c.name}</strong></td><td>${c.dept}</td><td>${c.email}</td><td>${c.phone}</td>
|
||||
<td style="text-align:right;">
|
||||
<button class="_button-xsmall" onclick="editContact(${idx})">수정</button>
|
||||
<button class="_button-xsmall" style="color:var(--error-color); border-color:#feb2b2; background:#fff5f5;" onclick="deleteContact(${idx})">삭제</button>
|
||||
</td>
|
||||
</tr>`).join('');
|
||||
}
|
||||
|
||||
function toggleAddContactForm() {
|
||||
const form = document.getElementById('addContactForm');
|
||||
if (form.style.display === 'none') form.style.display = 'block';
|
||||
else { form.style.display = 'none'; contactEditingIndex = -1; }
|
||||
}
|
||||
|
||||
function editContact(index) {
|
||||
const c = addressBookData[index];
|
||||
contactEditingIndex = index;
|
||||
document.getElementById('newContactName').value = c.name;
|
||||
document.getElementById('newContactDept').value = c.dept;
|
||||
document.getElementById('newContactEmail').value = c.email;
|
||||
document.getElementById('newContactPhone').value = c.phone;
|
||||
document.getElementById('addContactForm').style.display = 'block';
|
||||
}
|
||||
|
||||
function deleteContact(index) {
|
||||
if (confirm(`'${addressBookData[index].name}'님을 삭제하시겠습니까?`)) { addressBookData.splice(index, 1); renderAddressBook(); }
|
||||
}
|
||||
|
||||
function addContact() {
|
||||
const name = document.getElementById('newContactName').value;
|
||||
if (!name) return alert("이름을 입력해주세요.");
|
||||
const data = { name, dept: document.getElementById('newContactDept').value, email: document.getElementById('newContactEmail').value, phone: document.getElementById('newContactPhone').value };
|
||||
if (contactEditingIndex > -1) addressBookData[contactEditingIndex] = data;
|
||||
else addressBookData.push(data);
|
||||
renderAddressBook(); toggleAddContactForm();
|
||||
}
|
||||
|
||||
// --- 공통 액션 ---
|
||||
function updateBulkActionBar() {
|
||||
const count = document.querySelectorAll('.mail-item-checkbox:checked').length;
|
||||
const bar = document.getElementById('mailBulkActions');
|
||||
if (count > 0) { bar.classList.add('active'); document.getElementById('selectedCount').innerText = `${count}개 선택됨`; }
|
||||
else bar.classList.remove('active');
|
||||
}
|
||||
|
||||
// 초기화
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
loadAttachments();
|
||||
renderMailList('inbound');
|
||||
});
|
||||
|
||||
42
log_analysis_result.txt
Normal file
42
log_analysis_result.txt
Normal file
@@ -0,0 +1,42 @@
|
||||
[Raw Log Samples]
|
||||
- 2026.03.30, 폴더 삭제
|
||||
- 2025.12.07, 파일 업로드
|
||||
- 2025.12.01, 파일 다운로드
|
||||
- 2025.11.26, 파일 다운로드
|
||||
- 2025.11.24, 파일 이름 변경
|
||||
- 2025.11.20, 부관리자 권한 추가
|
||||
- 2025.11.19, 보안참여자 권한 추가
|
||||
- 2025.11.03, 부관리자 권한 추가
|
||||
- 2025.10.17, 일반참여자 권한 추가
|
||||
- 2025.10.14, 참관자 권한 추가
|
||||
- 2025.09.25, 일반참여자 권한 추가
|
||||
- 2025.09.24, 부관리자 권한 추가
|
||||
- 2025.09.23, 부관리자 권한 추가
|
||||
- 2026.01.29, 폴더 삭제
|
||||
- 2025.12.12, 폴더 자동 삭제
|
||||
- 2025.11.26, 폴더 이름 변경
|
||||
- 2025.11.21, 참관자 권한 추가
|
||||
- 2025.10.30, 폴더 삭제
|
||||
- 2025.09.25, 부관리자 권한 추가
|
||||
- 2026.03.25, PDF 변환
|
||||
|
||||
[Log Patterns Frequency]
|
||||
(46) [DATE], 파일 업로드
|
||||
(35) [DATE], 파일 다운로드
|
||||
(28) [DATE], PDF 변환
|
||||
(21) [DATE], 폴더 삭제
|
||||
(12) [DATE], 폴더 자동 삭제
|
||||
(12) [DATE], 폴더 이름 변경
|
||||
(11) [DATE], 부관리자 권한 추가
|
||||
(11) [DATE], 휴지통으로 이동
|
||||
(9) [DATE], 파일 이름 변경
|
||||
(9) [DATE], 새 폴더 생성
|
||||
(6) [DATE], 보안참여자 권한 추가
|
||||
(6) [DATE], AI요약
|
||||
(5) [DATE], 일반참여자 권한 추가
|
||||
(4) [DATE], 참관자 권한 추가
|
||||
(3) [DATE], 부관리자 권한 삭제
|
||||
(2) [DATE], 참관자 권한 삭제
|
||||
(1) [DATE], 폴더 권한 설정
|
||||
(1) [DATE], 일반참여자 권한 삭제
|
||||
(1) [DATE], 첨부파일 추가
|
||||
63
log_scorer.py
Normal file
63
log_scorer.py
Normal file
@@ -0,0 +1,63 @@
|
||||
import re
|
||||
|
||||
class LogScorer:
|
||||
"""로그 텍스트의 시맨틱 가치를 판별하여 점수화하는 모듈 (SWVW)"""
|
||||
|
||||
# 업무 가치 범주별 가중치 정의
|
||||
# 1.0: 핵심 의사결정/계약, 0.7: 지능형/권한관리, 0.4: 일반관리, 0.1: 단순활동
|
||||
WEIGHT_MAP = {
|
||||
"CORE": 1.0, # 설계변경, 실정보고, 계약, 정산 등 (추후 확장 대비)
|
||||
"INTELLIGENT": 0.7, # AI요약, PDF변환, 분석 등
|
||||
"AUTH": 0.6, # 권한 추가, 보안 설정 등
|
||||
"MGMT": 0.4, # 업로드, 수정, 이름 변경 등
|
||||
"SIMPLE": 0.2, # 다운로드, 삭제, 생성 등
|
||||
"AUTO": 0.0 # 자동 삭제, 시스템 로그 등
|
||||
}
|
||||
|
||||
# 범주별 키워드 정의 (실무 문맥 반영)
|
||||
KEYWORDS = {
|
||||
"CORE": ["보고", "계약", "정산", "설계", "검토", "승인", "공문", "통보"],
|
||||
"INTELLIGENT": ["AI", "요약", "변환", "PDF"],
|
||||
"AUTH": ["권한", "참여자", "관리자", "보안"],
|
||||
"MGMT": ["업로드", "수정", "변경", "첨부", "추가"],
|
||||
"SIMPLE": ["다운로드", "생성", "이동", "삭제"],
|
||||
"AUTO": ["자동", "시스템"]
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def get_score(cls, log_text):
|
||||
"""로그 텍스트를 분석하여 0.0 ~ 1.0 사이의 가치 점수를 반환"""
|
||||
if not log_text or log_text == "데이터 없음":
|
||||
return 0.0
|
||||
|
||||
# 날짜 부분 제거 (예: "2024.03.01, " 제거)
|
||||
clean_log = re.sub(r'^\d{2,4}\.\d{2}\.\d{2},\s*', '', log_text)
|
||||
|
||||
# 1. 특정 키워드 매칭을 통한 기본 범주 판별
|
||||
for category, keywords in cls.KEYWORDS.items():
|
||||
if any(k in clean_log for k in keywords):
|
||||
# '자동 삭제'는 별도 처리
|
||||
if category == "SIMPLE" and "자동" in clean_log:
|
||||
return cls.WEIGHT_MAP["AUTO"]
|
||||
return cls.WEIGHT_MAP[category]
|
||||
|
||||
return 0.3 # 기본값 (분류되지 않은 활동)
|
||||
|
||||
@classmethod
|
||||
def calculate_work_density(cls, logs):
|
||||
"""로그 목록을 받아 평균 업무 밀도 산출"""
|
||||
if not logs: return 0.0
|
||||
scores = [cls.get_score(log) for log in logs]
|
||||
return sum(scores) / len(scores)
|
||||
|
||||
# 테스트 코드
|
||||
if __name__ == "__main__":
|
||||
test_logs = [
|
||||
"2026.03.30, 하도급 계약 통보서 검토",
|
||||
"2026.03.25, AI요약 완료",
|
||||
"2026.03.20, 부관리자 권한 추가",
|
||||
"2026.03.15, 파일 업로드",
|
||||
"2026.03.10, 폴더 자동 삭제"
|
||||
]
|
||||
for log in test_logs:
|
||||
print(f"Log: {log} => Score: {LogScorer.get_score(log)}")
|
||||
124
migrate_to_docker.py
Normal file
124
migrate_to_docker.py
Normal file
@@ -0,0 +1,124 @@
|
||||
import pymysql
|
||||
import os
|
||||
import sys
|
||||
|
||||
def migrate():
|
||||
# Source DB (Windows Host) connection details
|
||||
# We can use the IP from current DB_HOST or fallback to 172.26.208.1
|
||||
src_host = os.getenv('DB_HOST', '172.26.208.1')
|
||||
src_user = os.getenv('DB_USER', 'root')
|
||||
src_password = os.getenv('DB_PASSWORD', '45278434')
|
||||
|
||||
# Target DB (Docker container) connection details
|
||||
# Inside container, 'db' refers to the MariaDB service.
|
||||
# Note: we connect to target's internal port 3306.
|
||||
tgt_host = 'db'
|
||||
tgt_user = 'root'
|
||||
tgt_password = '45278434'
|
||||
|
||||
databases = ['PM_proto', 'PM_proto_test']
|
||||
|
||||
print(f"Starting migration from Source ({src_host}:3306) to Target ({tgt_host}:3306)...")
|
||||
|
||||
try:
|
||||
# Establish connection to source
|
||||
src_conn = pymysql.connect(
|
||||
host=src_host,
|
||||
user=src_user,
|
||||
password=src_password,
|
||||
charset='utf8mb4'
|
||||
)
|
||||
print("Connected to Source Database successfully.")
|
||||
except Exception as e:
|
||||
print(f"Failed to connect to Source Database: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
try:
|
||||
# Establish connection to target
|
||||
tgt_conn = pymysql.connect(
|
||||
host=tgt_host,
|
||||
user=tgt_user,
|
||||
password=tgt_password,
|
||||
charset='utf8mb4'
|
||||
)
|
||||
print("Connected to Target Database successfully.")
|
||||
except Exception as e:
|
||||
print(f"Failed to connect to Target Database: {e}")
|
||||
src_conn.close()
|
||||
sys.exit(1)
|
||||
|
||||
try:
|
||||
with src_conn.cursor() as src_cur, tgt_conn.cursor() as tgt_cur:
|
||||
# Disable foreign key checks to prevent dependency order issues during creation
|
||||
tgt_cur.execute("SET FOREIGN_KEY_CHECKS = 0")
|
||||
for db_name in databases:
|
||||
print(f"\n--- Migrating Database: {db_name} ---")
|
||||
|
||||
# Check if database exists in source
|
||||
src_cur.execute(f"SHOW DATABASES LIKE '{db_name}'")
|
||||
if not src_cur.fetchone():
|
||||
print(f"Database {db_name} does not exist in source. Skipping.")
|
||||
continue
|
||||
|
||||
# Create database on target
|
||||
tgt_cur.execute(f"CREATE DATABASE IF NOT EXISTS `{db_name}` CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci")
|
||||
print(f"Created database {db_name} on target (or already exists).")
|
||||
|
||||
# Switch to database
|
||||
src_cur.execute(f"USE `{db_name}`")
|
||||
tgt_cur.execute(f"USE `{db_name}`")
|
||||
|
||||
# Get list of tables
|
||||
src_cur.execute("SHOW TABLES")
|
||||
tables = [r[0] for r in src_cur.fetchall()]
|
||||
print(f"Tables to migrate: {tables}")
|
||||
|
||||
for table in tables:
|
||||
print(f" Migrating table: {table}...")
|
||||
|
||||
# Get CREATE TABLE statement
|
||||
src_cur.execute(f"SHOW CREATE TABLE `{table}`")
|
||||
create_stmt = src_cur.fetchone()[1]
|
||||
# Replace MySQL 8.0 specific collation with MariaDB compatible collation
|
||||
create_stmt = create_stmt.replace('utf8mb4_0900_ai_ci', 'utf8mb4_general_ci')
|
||||
|
||||
# Drop table on target if exists
|
||||
tgt_cur.execute(f"DROP TABLE IF EXISTS `{table}`")
|
||||
|
||||
# Create table on target
|
||||
tgt_cur.execute(create_stmt)
|
||||
|
||||
# Get data from source
|
||||
src_cur.execute(f"SELECT * FROM `{table}`")
|
||||
rows = src_cur.fetchall()
|
||||
|
||||
if not rows:
|
||||
print(f" Table {table} is empty.")
|
||||
continue
|
||||
|
||||
# Get columns list
|
||||
src_cur.execute(f"DESCRIBE `{table}`")
|
||||
cols = [r[0] for r in src_cur.fetchall()]
|
||||
|
||||
# Insert data into target
|
||||
col_names = ", ".join([f"`{c}`" for c in cols])
|
||||
placeholders = ", ".join(["%s"] * len(cols))
|
||||
insert_stmt = f"INSERT INTO `{table}` ({col_names}) VALUES ({placeholders})"
|
||||
|
||||
tgt_cur.executemany(insert_stmt, rows)
|
||||
tgt_conn.commit()
|
||||
print(f" Successfully migrated {len(rows)} rows into {table}.")
|
||||
|
||||
# Re-enable foreign key checks after completion
|
||||
with tgt_conn.cursor() as tgt_cur:
|
||||
tgt_cur.execute("SET FOREIGN_KEY_CHECKS = 1")
|
||||
print("\nMigration completed successfully!")
|
||||
except Exception as e:
|
||||
print(f"Error during migration: {e}")
|
||||
tgt_conn.rollback()
|
||||
finally:
|
||||
src_conn.close()
|
||||
tgt_conn.close()
|
||||
|
||||
if __name__ == "__main__":
|
||||
migrate()
|
||||
@@ -1,97 +1,97 @@
|
||||
import numpy as np
|
||||
from datetime import datetime
|
||||
|
||||
class SOIPredictionService:
|
||||
"""학습형 시계열 예측 및 피처 추출 엔진"""
|
||||
|
||||
@staticmethod
|
||||
def get_historical_soi(cursor, project_id):
|
||||
"""DB에서 프로젝트의 과거 SOI 히스토리를 시퀀스로 추출"""
|
||||
cursor.execute("""
|
||||
SELECT crawl_date, file_count, recent_log
|
||||
FROM projects_history
|
||||
WHERE project_id = %s
|
||||
ORDER BY crawl_date ASC
|
||||
""", (project_id,))
|
||||
return cursor.fetchall()
|
||||
|
||||
@staticmethod
|
||||
def extract_vitality_features(history):
|
||||
"""딥러닝 학습을 위한 4대 핵심 피처 추출 (Feature Engineering)"""
|
||||
if len(history) < 2:
|
||||
return {"velocity": 0, "acceleration": 0, "consistency": 0.5, "density": 0.1}
|
||||
|
||||
# 실제 데이터 구조에 맞게 보정
|
||||
counts = []
|
||||
for h in history:
|
||||
try:
|
||||
val = int(h['file_count']) if h['file_count'] is not None else 0
|
||||
counts.append(val)
|
||||
except:
|
||||
counts.append(0)
|
||||
|
||||
# 1. 활동 속도 (Velocity)
|
||||
velocity = np.diff(counts).mean() if len(counts) > 1 else 0
|
||||
|
||||
# 2. 활동 가속도 (Acceleration): 최근 활동이 빨라지는지 느려지는지
|
||||
acceleration = np.diff(np.diff(counts)).mean() if len(counts) > 2 else 0
|
||||
|
||||
# 3. 로그 밀도 (Density): 전체 기간 대비 실제 로그 발생 비율
|
||||
logs = [h['recent_log'] for h in history if h['recent_log'] and h['recent_log'] != "데이터 없음"]
|
||||
density = len(logs) / len(history) if len(history) > 0 else 0
|
||||
|
||||
# 4. 관리 일관성 (Consistency): 업데이트 간격의 표준편차 (낮을수록 좋음)
|
||||
# (현재 데이터는 일일 크롤링이므로 로그 텍스트 변화 시점을 기준으로 간격 계산 가능)
|
||||
|
||||
return {
|
||||
"velocity": float(velocity),
|
||||
"acceleration": float(acceleration),
|
||||
"density": float(density),
|
||||
"sample_count": len(history)
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def predict_future_soi(current_soi, history, days_ahead=14):
|
||||
"""기존 점수와 시계열 피처를 결합하여 미래 점수 예측"""
|
||||
# 데이터가 너무 적으면 무조건 보수적 감쇄 (14일 기준 약 -2.1점)
|
||||
if not history or len(history) < 3:
|
||||
return round(max(0, min(100, current_soi - (0.15 * days_ahead))), 1)
|
||||
|
||||
features = SOIPredictionService.extract_vitality_features(history)
|
||||
current_val = float(current_soi)
|
||||
|
||||
# [정밀 정체 분석]
|
||||
# 1. 파일 수 변화 확인 (최근 5개 샘플)
|
||||
recent_counts = [int(h['file_count'] or 0) for h in history[-5:]]
|
||||
is_hard_stagnant = len(set(recent_counts)) <= 1 # 파일 수 변동이 전혀 없음
|
||||
|
||||
# 2. 최근 로그 상태 확인
|
||||
last_log = history[-1]['recent_log']
|
||||
is_no_activity = last_log is None or last_log == "데이터 없음" or "폴더자동삭제" in last_log
|
||||
|
||||
# [모멘텀 산출 로직 개편]
|
||||
if is_hard_stagnant:
|
||||
# 파일 변화가 없다면 아무리 로그가 있어도 '유지 관리'일 뿐 '성장'이 아님
|
||||
# 오히려 시간이 갈수록 기술 부채와 데이터 노후화가 진행된다고 판단 (강력 패널티)
|
||||
momentum_factor = -2.5 if is_no_activity else -1.0
|
||||
else:
|
||||
# 실질적인 파일 수 변화(Velocity)가 있을 때만 긍정적 모멘텀 검토
|
||||
v_gain = features['velocity'] * 0.5
|
||||
d_gain = features['density'] * 0.8
|
||||
momentum_factor = v_gain + d_gain - 0.5 # 기본적으로 하향 압력 부여
|
||||
|
||||
# 예측 로직: 현재값 + 모멘텀 - (시간에 따른 자연 부식)
|
||||
# 정체 시 momentum_factor가 -1.0~-2.5이므로 감쇄가 매우 빠름
|
||||
decay_constant = 0.08
|
||||
predicted = current_val + momentum_factor - (decay_constant * days_ahead)
|
||||
|
||||
# [최종 방어 로직]
|
||||
# 실질적 파일 증가(velocity > 0)가 포착되지 않았다면 예보는 현재값보다 클 수 없음
|
||||
if features['velocity'] <= 0 and predicted > current_val:
|
||||
predicted = current_val - 1.5 # 강제 하락
|
||||
|
||||
# 사망 선고 (AVI가 이미 낮고 정체 중이면 0에 수렴하도록 가속)
|
||||
if current_val < 20 and is_hard_stagnant:
|
||||
predicted = max(0, predicted - 5.0)
|
||||
|
||||
return round(max(0, min(100, predicted)), 1)
|
||||
import numpy as np
|
||||
from datetime import datetime
|
||||
|
||||
class SOIPredictionService:
|
||||
"""학습형 시계열 예측 및 피처 추출 엔진"""
|
||||
|
||||
@staticmethod
|
||||
def get_historical_avi(cursor, project_id):
|
||||
"""DB에서 프로젝트의 과거 AVI 히스토리를 시퀀스로 추출"""
|
||||
cursor.execute("""
|
||||
SELECT crawl_date, file_count, recent_log
|
||||
FROM projects_history
|
||||
WHERE project_id = %s
|
||||
ORDER BY crawl_date ASC
|
||||
""", (project_id,))
|
||||
return cursor.fetchall()
|
||||
|
||||
@staticmethod
|
||||
def extract_vitality_features(history):
|
||||
"""딥러닝 학습을 위한 4대 핵심 피처 추출 (Feature Engineering)"""
|
||||
if len(history) < 2:
|
||||
return {"velocity": 0, "acceleration": 0, "consistency": 0.5, "density": 0.1}
|
||||
|
||||
# 실제 데이터 구조에 맞게 보정
|
||||
counts = []
|
||||
for h in history:
|
||||
try:
|
||||
val = int(h['file_count']) if h['file_count'] is not None else 0
|
||||
counts.append(val)
|
||||
except:
|
||||
counts.append(0)
|
||||
|
||||
# 1. 활동 속도 (Velocity)
|
||||
velocity = np.diff(counts).mean() if len(counts) > 1 else 0
|
||||
|
||||
# 2. 활동 가속도 (Acceleration): 최근 활동이 빨라지는지 느려지는지
|
||||
acceleration = np.diff(np.diff(counts)).mean() if len(counts) > 2 else 0
|
||||
|
||||
# 3. 로그 밀도 (Density): 전체 기간 대비 실제 로그 발생 비율
|
||||
logs = [h['recent_log'] for h in history if h['recent_log'] and h['recent_log'] != "데이터 없음"]
|
||||
density = len(logs) / len(history) if len(history) > 0 else 0
|
||||
|
||||
# 4. 관리 일관성 (Consistency): 업데이트 간격의 표준편차 (낮을수록 좋음)
|
||||
# (현재 데이터는 일일 크롤링이므로 로그 텍스트 변화 시점을 기준으로 간격 계산 가능)
|
||||
|
||||
return {
|
||||
"velocity": float(velocity),
|
||||
"acceleration": float(acceleration),
|
||||
"density": float(density),
|
||||
"sample_count": len(history)
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def predict_future_avi(current_avi, history, days_ahead=14):
|
||||
"""기존 점수와 시계열 피처를 결합하여 미래 점수 예측"""
|
||||
# 데이터가 너무 적으면 무조건 보수적 감쇄 (14일 기준 약 -2.1점)
|
||||
if not history or len(history) < 3:
|
||||
return round(max(0, min(100, current_avi - (0.15 * days_ahead))), 1)
|
||||
|
||||
features = SOIPredictionService.extract_vitality_features(history)
|
||||
current_val = float(current_avi)
|
||||
|
||||
# [정밀 정체 분석]
|
||||
# 1. 파일 수 변화 확인 (최근 5개 샘플)
|
||||
recent_counts = [int(h['file_count'] or 0) for h in history[-5:]]
|
||||
is_hard_stagnant = len(set(recent_counts)) <= 1 # 파일 수 변동이 전혀 없음
|
||||
|
||||
# 2. 최근 로그 상태 확인
|
||||
last_log = history[-1]['recent_log']
|
||||
is_no_activity = last_log is None or last_log == "데이터 없음" or "폴더자동삭제" in last_log
|
||||
|
||||
# [모멘텀 산출 로직 개편]
|
||||
if is_hard_stagnant:
|
||||
# 파일 변화가 없다면 아무리 로그가 있어도 '유지 관리'일 뿐 '성장'이 아님
|
||||
# 오히려 시간이 갈수록 기술 부채와 데이터 노후화가 진행된다고 판단 (강력 패널티)
|
||||
momentum_factor = -2.5 if is_no_activity else -1.0
|
||||
else:
|
||||
# 실질적인 파일 수 변화(Velocity)가 있을 때만 긍정적 모멘텀 검토
|
||||
v_gain = features['velocity'] * 0.5
|
||||
d_gain = features['density'] * 0.8
|
||||
momentum_factor = v_gain + d_gain - 0.5 # 기본적으로 하향 압력 부여
|
||||
|
||||
# 예측 로직: 현재값 + 모멘텀 - (시간에 따른 자연 부식)
|
||||
# 정체 시 momentum_factor가 -1.0~-2.5이므로 감쇄가 매우 빠름
|
||||
decay_constant = 0.08
|
||||
predicted = current_val + momentum_factor - (decay_constant * days_ahead)
|
||||
|
||||
# [최종 방어 로직]
|
||||
# 실질적 파일 증가(velocity > 0)가 포착되지 않았다면 예보는 현재값보다 클 수 없음
|
||||
if features['velocity'] <= 0 and predicted > current_val:
|
||||
predicted = current_val - 1.5 # 강제 하락
|
||||
|
||||
# 사망 선고 (AVI가 이미 낮고 정체 중이면 0에 수렴하도록 가속)
|
||||
if current_val < 20 and is_hard_stagnant:
|
||||
predicted = max(0, predicted - 5.0)
|
||||
|
||||
return round(max(0, min(100, predicted)), 1)
|
||||
|
||||
@@ -1,33 +1,33 @@
|
||||
from sql_queries import DashboardQueries
|
||||
|
||||
class ProjectService:
|
||||
@staticmethod
|
||||
def get_available_dates_logic(cursor):
|
||||
cursor.execute(DashboardQueries.GET_AVAILABLE_DATES)
|
||||
rows = cursor.fetchall()
|
||||
return [row['crawl_date'].strftime("%Y.%m.%d") for row in rows if row['crawl_date']]
|
||||
|
||||
@staticmethod
|
||||
def get_project_data_logic(cursor, date_str):
|
||||
target_date = date_str.replace(".", "-") if date_str and date_str != "-" else None
|
||||
|
||||
if not target_date:
|
||||
cursor.execute(DashboardQueries.GET_LAST_CRAWL_DATE)
|
||||
res = cursor.fetchone()
|
||||
target_date = res['last_date']
|
||||
|
||||
if not target_date:
|
||||
return {"projects": []}
|
||||
|
||||
cursor.execute(DashboardQueries.GET_PROJECT_LIST, (target_date,))
|
||||
rows = cursor.fetchall()
|
||||
|
||||
projects = []
|
||||
for r in rows:
|
||||
name = r['short_nm'] if r['short_nm'] and r['short_nm'].strip() else r['project_nm']
|
||||
projects.append([
|
||||
name, r['department'], r['master'],
|
||||
r['recent_log'], r['file_count'],
|
||||
r['continent'], r['country']
|
||||
])
|
||||
return {"projects": projects}
|
||||
from sql_queries import DashboardQueries
|
||||
|
||||
class ProjectService:
|
||||
@staticmethod
|
||||
def get_available_dates_logic(cursor):
|
||||
cursor.execute(DashboardQueries.GET_AVAILABLE_DATES)
|
||||
rows = cursor.fetchall()
|
||||
return [row['crawl_date'].strftime("%Y.%m.%d") for row in rows if row['crawl_date']]
|
||||
|
||||
@staticmethod
|
||||
def get_project_data_logic(cursor, date_str):
|
||||
target_date = date_str.replace(".", "-") if date_str and date_str != "-" else None
|
||||
|
||||
if not target_date:
|
||||
cursor.execute(DashboardQueries.GET_LAST_CRAWL_DATE)
|
||||
res = cursor.fetchone()
|
||||
target_date = res['last_date']
|
||||
|
||||
if not target_date:
|
||||
return {"projects": []}
|
||||
|
||||
cursor.execute(DashboardQueries.GET_PROJECT_LIST, (target_date,))
|
||||
rows = cursor.fetchall()
|
||||
|
||||
projects = []
|
||||
for r in rows:
|
||||
name = r['short_nm'] if r['short_nm'] and r['short_nm'].strip() else r['project_nm']
|
||||
projects.append([
|
||||
name, r['department'], r['master'],
|
||||
r['recent_log'], r['file_count'],
|
||||
r['continent'], r['country']
|
||||
])
|
||||
return {"projects": projects}
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
fastapi==0.110.0
|
||||
uvicorn==0.29.0
|
||||
playwright==1.42.0
|
||||
python-dotenv==1.0.1
|
||||
pypdf==4.1.0
|
||||
fastapi==0.110.0
|
||||
uvicorn==0.29.0
|
||||
playwright==1.42.0
|
||||
python-dotenv==1.0.1
|
||||
pypdf==4.1.0
|
||||
pymysql
|
||||
jinja2
|
||||
pytesseract
|
||||
pdf2image
|
||||
numpy
|
||||
pandas
|
||||
openpyxl
|
||||
20
schemas.py
20
schemas.py
@@ -1,10 +1,10 @@
|
||||
from pydantic import BaseModel
|
||||
|
||||
class AuthRequest(BaseModel):
|
||||
user_id: str
|
||||
password: str
|
||||
|
||||
class InquiryReplyRequest(BaseModel):
|
||||
reply: str
|
||||
status: str
|
||||
handler: str
|
||||
from pydantic import BaseModel
|
||||
|
||||
class AuthRequest(BaseModel):
|
||||
user_id: str
|
||||
password: str
|
||||
|
||||
class InquiryReplyRequest(BaseModel):
|
||||
reply: str
|
||||
status: str
|
||||
handler: str
|
||||
|
||||
369
server.py
369
server.py
@@ -1,182 +1,187 @@
|
||||
import os
|
||||
import sys
|
||||
import asyncio
|
||||
import pymysql
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import StreamingResponse, FileResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.templating import Jinja2Templates
|
||||
|
||||
from analyze import analyze_file_content
|
||||
from crawler_service import run_crawler_service, crawl_stop_event
|
||||
from schemas import AuthRequest, InquiryReplyRequest
|
||||
from inquiry_service import InquiryService
|
||||
from project_service import ProjectService
|
||||
from analysis_service import AnalysisService
|
||||
|
||||
# --- 환경 설정 ---
|
||||
os.environ["PYTHONIOENCODING"] = "utf-8"
|
||||
TESSDATA_PREFIX = os.getenv("TESSDATA_PREFIX", r"C:\Users\User\AppData\Local\Programs\Tesseract-OCR\tessdata")
|
||||
os.environ["TESSDATA_PREFIX"] = TESSDATA_PREFIX
|
||||
|
||||
app = FastAPI(title="Project Master Overseas API")
|
||||
templates = Jinja2Templates(directory="templates")
|
||||
|
||||
# 정적 파일 마운트
|
||||
app.mount("/style", StaticFiles(directory="style"), name="style")
|
||||
app.mount("/js", StaticFiles(directory="js"), name="js")
|
||||
app.mount("/sample_files", StaticFiles(directory="sample"), name="sample_files")
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_credentials=False,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
# --- 유틸리티 함수 ---
|
||||
def get_db_connection():
|
||||
"""MySQL 데이터베이스 연결을 반환"""
|
||||
return pymysql.connect(
|
||||
host=os.getenv('DB_HOST', 'localhost'),
|
||||
user=os.getenv('DB_USER', 'root'),
|
||||
password=os.getenv('DB_PASSWORD', '45278434'),
|
||||
database=os.getenv('DB_NAME', 'PM_proto'),
|
||||
charset='utf8mb4',
|
||||
cursorclass=pymysql.cursors.DictCursor
|
||||
)
|
||||
|
||||
async def run_in_threadpool(func, *args):
|
||||
"""동기 함수를 비차단 방식으로 실행"""
|
||||
loop = asyncio.get_event_loop()
|
||||
return await loop.run_in_executor(None, func, *args)
|
||||
|
||||
# --- HTML 라우팅 ---
|
||||
@app.get("/")
|
||||
async def root(request: Request):
|
||||
return templates.TemplateResponse("index.html", {"request": request})
|
||||
|
||||
@app.get("/dashboard")
|
||||
async def get_dashboard(request: Request):
|
||||
return templates.TemplateResponse("dashboard.html", {"request": request})
|
||||
|
||||
@app.get("/mailTest")
|
||||
async def get_mail_test(request: Request):
|
||||
return templates.TemplateResponse("mailTest.html", {"request": request})
|
||||
|
||||
@app.get("/inquiries")
|
||||
async def get_inquiries_page(request: Request):
|
||||
return templates.TemplateResponse("inquiries.html", {"request": request})
|
||||
|
||||
@app.get("/analysis")
|
||||
async def get_analysis_page(request: Request):
|
||||
return templates.TemplateResponse("analysis.html", {"request": request})
|
||||
|
||||
# --- 문의사항 API ---
|
||||
@app.get("/api/inquiries")
|
||||
async def get_inquiries(pm_type: str = None, category: str = None, status: str = None, keyword: str = None):
|
||||
try:
|
||||
with get_db_connection() as conn:
|
||||
with conn.cursor() as cursor:
|
||||
return InquiryService.get_inquiries_logic(cursor, pm_type, category, status, keyword)
|
||||
except Exception as e:
|
||||
return {"error": str(e)}
|
||||
|
||||
@app.get("/api/inquiries/{id}")
|
||||
async def get_inquiry_detail(id: int):
|
||||
try:
|
||||
with get_db_connection() as conn:
|
||||
with conn.cursor() as cursor:
|
||||
return InquiryService.get_inquiry_detail_logic(cursor, id)
|
||||
except Exception as e:
|
||||
return {"error": str(e)}
|
||||
|
||||
@app.post("/api/inquiries/{id}/reply")
|
||||
async def update_inquiry_reply(id: int, req: InquiryReplyRequest):
|
||||
try:
|
||||
with get_db_connection() as conn:
|
||||
with conn.cursor() as cursor:
|
||||
return InquiryService.update_inquiry_reply_logic(cursor, conn, id, req)
|
||||
except Exception as e:
|
||||
return {"error": str(e)}
|
||||
|
||||
@app.delete("/api/inquiries/{id}/reply")
|
||||
async def delete_inquiry_reply(id: int):
|
||||
try:
|
||||
with get_db_connection() as conn:
|
||||
with conn.cursor() as cursor:
|
||||
return InquiryService.delete_inquiry_reply_logic(cursor, conn, id)
|
||||
except Exception as e:
|
||||
return {"error": str(e)}
|
||||
|
||||
# --- 프로젝트 및 히스토리 API ---
|
||||
@app.get("/available-dates")
|
||||
async def get_available_dates():
|
||||
try:
|
||||
with get_db_connection() as conn:
|
||||
with conn.cursor() as cursor:
|
||||
return ProjectService.get_available_dates_logic(cursor)
|
||||
except Exception as e:
|
||||
return {"error": str(e)}
|
||||
|
||||
@app.get("/project-data")
|
||||
async def get_project_data(date: str = None):
|
||||
try:
|
||||
with get_db_connection() as conn:
|
||||
with conn.cursor() as cursor:
|
||||
return ProjectService.get_project_data_logic(cursor, date)
|
||||
except Exception as e:
|
||||
return {"error": str(e)}
|
||||
|
||||
# --- 분석 API (AnalysisService 연동) ---
|
||||
@app.get("/project-activity")
|
||||
async def get_project_activity(date: str = None):
|
||||
try:
|
||||
with get_db_connection() as conn:
|
||||
with conn.cursor() as cursor:
|
||||
return AnalysisService.get_project_activity_logic(cursor, date)
|
||||
except Exception as e:
|
||||
return {"error": str(e)}
|
||||
|
||||
@app.get("/api/analysis/p-war")
|
||||
async def get_p_war_analysis():
|
||||
try:
|
||||
with get_db_connection() as conn:
|
||||
with conn.cursor() as cursor:
|
||||
return AnalysisService.get_p_zsr_analysis_logic(cursor)
|
||||
except Exception as e:
|
||||
return {"error": str(e)}
|
||||
|
||||
# --- 수집 및 동기화 API ---
|
||||
@app.post("/auth/crawl")
|
||||
async def auth_crawl(req: AuthRequest):
|
||||
if req.user_id == os.getenv("PM_USER_ID") and req.password == os.getenv("PM_PASSWORD"):
|
||||
return {"success": True}
|
||||
return {"success": False, "message": "크롤링을 할 수 없습니다."}
|
||||
|
||||
@app.get("/sync")
|
||||
async def sync_data():
|
||||
return StreamingResponse(run_crawler_service(), media_type="text_event-stream")
|
||||
|
||||
@app.get("/stop-sync")
|
||||
async def stop_sync():
|
||||
crawl_stop_event.set()
|
||||
return {"success": True}
|
||||
|
||||
# --- 파일 및 첨부파일 API ---
|
||||
@app.get("/attachments")
|
||||
async def get_attachments():
|
||||
path = "sample"
|
||||
if not os.path.exists(path): os.makedirs(path)
|
||||
return [{"name": f, "size": f"{os.path.getsize(os.path.join(path, f))/1024:.1f} KB"}
|
||||
for f in os.listdir(path) if os.path.isfile(os.path.join(path, f))]
|
||||
|
||||
@app.get("/analyze-file")
|
||||
async def analyze_file(filename: str):
|
||||
return await run_in_threadpool(analyze_file_content, filename)
|
||||
|
||||
@app.get("/sample.png")
|
||||
async def get_sample_img():
|
||||
return FileResponse("sample.png")
|
||||
import os
|
||||
import sys
|
||||
import asyncio
|
||||
import pymysql
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import StreamingResponse, FileResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.templating import Jinja2Templates
|
||||
|
||||
from analyze import analyze_file_content
|
||||
from crawler_service import run_crawler_service, crawl_stop_event
|
||||
from schemas import AuthRequest, InquiryReplyRequest
|
||||
from inquiry_service import InquiryService
|
||||
from project_service import ProjectService
|
||||
from analysis_service import AnalysisService
|
||||
|
||||
# --- 환경 설정 ---
|
||||
os.environ["PYTHONIOENCODING"] = "utf-8"
|
||||
if os.name == 'posix': # Linux (Docker)
|
||||
DEFAULT_TESSDATA = "/usr/share/tesseract-ocr/4.00/tessdata"
|
||||
else: # Windows
|
||||
DEFAULT_TESSDATA = r"C:\Users\User\AppData\Local\Programs\Tesseract-OCR\tessdata"
|
||||
|
||||
TESSDATA_PREFIX = os.getenv("TESSDATA_PREFIX", DEFAULT_TESSDATA)
|
||||
os.environ["TESSDATA_PREFIX"] = TESSDATA_PREFIX
|
||||
|
||||
app = FastAPI(title="Project Master Overseas API")
|
||||
templates = Jinja2Templates(directory="templates")
|
||||
|
||||
# 정적 파일 마운트
|
||||
app.mount("/style", StaticFiles(directory="style"), name="style")
|
||||
app.mount("/js", StaticFiles(directory="js"), name="js")
|
||||
app.mount("/sample_files", StaticFiles(directory="sample"), name="sample_files")
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_credentials=False,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
# --- 유틸리티 함수 ---
|
||||
def get_db_connection():
|
||||
"""MySQL 데이터베이스 연결을 반환"""
|
||||
return pymysql.connect(
|
||||
host=os.getenv('DB_HOST', 'localhost'),
|
||||
user=os.getenv('DB_USER', 'root'),
|
||||
password=os.getenv('DB_PASSWORD', '45278434'),
|
||||
database=os.getenv('DB_NAME', 'PM_proto'),
|
||||
charset='utf8mb4',
|
||||
cursorclass=pymysql.cursors.DictCursor
|
||||
)
|
||||
|
||||
async def run_in_threadpool(func, *args):
|
||||
"""동기 함수를 비차단 방식으로 실행"""
|
||||
loop = asyncio.get_event_loop()
|
||||
return await loop.run_in_executor(None, func, *args)
|
||||
|
||||
# --- HTML 라우팅 ---
|
||||
@app.get("/")
|
||||
async def root(request: Request):
|
||||
return templates.TemplateResponse("index.html", {"request": request})
|
||||
|
||||
@app.get("/dashboard")
|
||||
async def get_dashboard(request: Request):
|
||||
return templates.TemplateResponse("dashboard.html", {"request": request})
|
||||
|
||||
@app.get("/mailTest")
|
||||
async def get_mail_test(request: Request):
|
||||
return templates.TemplateResponse("mailTest.html", {"request": request})
|
||||
|
||||
@app.get("/inquiries")
|
||||
async def get_inquiries_page(request: Request):
|
||||
return templates.TemplateResponse("inquiries.html", {"request": request})
|
||||
|
||||
@app.get("/analysis")
|
||||
async def get_analysis_page(request: Request):
|
||||
return templates.TemplateResponse("analysis.html", {"request": request})
|
||||
|
||||
# --- 문의사항 API ---
|
||||
@app.get("/api/inquiries")
|
||||
async def get_inquiries(pm_type: str = None, category: str = None, status: str = None, keyword: str = None):
|
||||
try:
|
||||
with get_db_connection() as conn:
|
||||
with conn.cursor() as cursor:
|
||||
return InquiryService.get_inquiries_logic(cursor, pm_type, category, status, keyword)
|
||||
except Exception as e:
|
||||
return {"error": str(e)}
|
||||
|
||||
@app.get("/api/inquiries/{id}")
|
||||
async def get_inquiry_detail(id: int):
|
||||
try:
|
||||
with get_db_connection() as conn:
|
||||
with conn.cursor() as cursor:
|
||||
return InquiryService.get_inquiry_detail_logic(cursor, id)
|
||||
except Exception as e:
|
||||
return {"error": str(e)}
|
||||
|
||||
@app.post("/api/inquiries/{id}/reply")
|
||||
async def update_inquiry_reply(id: int, req: InquiryReplyRequest):
|
||||
try:
|
||||
with get_db_connection() as conn:
|
||||
with conn.cursor() as cursor:
|
||||
return InquiryService.update_inquiry_reply_logic(cursor, conn, id, req)
|
||||
except Exception as e:
|
||||
return {"error": str(e)}
|
||||
|
||||
@app.delete("/api/inquiries/{id}/reply")
|
||||
async def delete_inquiry_reply(id: int):
|
||||
try:
|
||||
with get_db_connection() as conn:
|
||||
with conn.cursor() as cursor:
|
||||
return InquiryService.delete_inquiry_reply_logic(cursor, conn, id)
|
||||
except Exception as e:
|
||||
return {"error": str(e)}
|
||||
|
||||
# --- 프로젝트 및 히스토리 API ---
|
||||
@app.get("/available-dates")
|
||||
async def get_available_dates():
|
||||
try:
|
||||
with get_db_connection() as conn:
|
||||
with conn.cursor() as cursor:
|
||||
return ProjectService.get_available_dates_logic(cursor)
|
||||
except Exception as e:
|
||||
return {"error": str(e)}
|
||||
|
||||
@app.get("/project-data")
|
||||
async def get_project_data(date: str = None):
|
||||
try:
|
||||
with get_db_connection() as conn:
|
||||
with conn.cursor() as cursor:
|
||||
return ProjectService.get_project_data_logic(cursor, date)
|
||||
except Exception as e:
|
||||
return {"error": str(e)}
|
||||
|
||||
# --- 분석 API (AnalysisService 연동) ---
|
||||
@app.get("/project-activity")
|
||||
async def get_project_activity(date: str = None):
|
||||
try:
|
||||
with get_db_connection() as conn:
|
||||
with conn.cursor() as cursor:
|
||||
return AnalysisService.get_project_activity_logic(cursor, date)
|
||||
except Exception as e:
|
||||
return {"error": str(e)}
|
||||
|
||||
@app.get("/api/analysis/p-war")
|
||||
async def get_p_war_analysis():
|
||||
try:
|
||||
with get_db_connection() as conn:
|
||||
with conn.cursor() as cursor:
|
||||
return AnalysisService.get_p_zsr_analysis_logic(cursor)
|
||||
except Exception as e:
|
||||
return {"error": str(e)}
|
||||
|
||||
# --- 수집 및 동기화 API ---
|
||||
@app.post("/auth/crawl")
|
||||
async def auth_crawl(req: AuthRequest):
|
||||
if req.user_id == os.getenv("PM_USER_ID") and req.password == os.getenv("PM_PASSWORD"):
|
||||
return {"success": True}
|
||||
return {"success": False, "message": "크롤링을 할 수 없습니다."}
|
||||
|
||||
@app.get("/sync")
|
||||
async def sync_data():
|
||||
return StreamingResponse(run_crawler_service(), media_type="text_event-stream")
|
||||
|
||||
@app.get("/stop-sync")
|
||||
async def stop_sync():
|
||||
crawl_stop_event.set()
|
||||
return {"success": True}
|
||||
|
||||
# --- 파일 및 첨부파일 API ---
|
||||
@app.get("/attachments")
|
||||
async def get_attachments():
|
||||
path = "sample"
|
||||
if not os.path.exists(path): os.makedirs(path)
|
||||
return [{"name": f, "size": f"{os.path.getsize(os.path.join(path, f))/1024:.1f} KB"}
|
||||
for f in os.listdir(path) if os.path.isfile(os.path.join(path, f))]
|
||||
|
||||
@app.get("/analyze-file")
|
||||
async def analyze_file(filename: str):
|
||||
return await run_in_threadpool(analyze_file_content, filename)
|
||||
|
||||
@app.get("/sample.png")
|
||||
async def get_sample_img():
|
||||
return FileResponse("sample.png")
|
||||
|
||||
190
server_test.py
Normal file
190
server_test.py
Normal 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)
|
||||
149
sql_queries.py
149
sql_queries.py
@@ -1,67 +1,82 @@
|
||||
class InquiryQueries:
|
||||
"""문의사항(Inquiries) 페이지 관련 쿼리"""
|
||||
# 필터링을 위한 기본 쿼리 (WHERE 1=1 포함)
|
||||
SELECT_BASE = "SELECT * FROM inquiries WHERE 1=1"
|
||||
ORDER_BY_DESC = "ORDER BY no DESC"
|
||||
|
||||
# 상세 조회
|
||||
SELECT_BY_ID = "SELECT * FROM inquiries WHERE id = %s"
|
||||
|
||||
# 답변 업데이트 (handled_date 포함)
|
||||
UPDATE_REPLY = """
|
||||
UPDATE inquiries
|
||||
SET reply = %s, status = %s, handler = %s, handled_date = %s
|
||||
WHERE id = %s
|
||||
"""
|
||||
|
||||
# 답변 삭제 (초기화)
|
||||
DELETE_REPLY = """
|
||||
UPDATE inquiries
|
||||
SET reply = '', status = '미확인', handled_date = ''
|
||||
WHERE id = %s
|
||||
"""
|
||||
|
||||
class DashboardQueries:
|
||||
"""대시보드(Dashboard) 및 프로젝트 현황 관련 쿼리"""
|
||||
# 가용 날짜 목록 조회
|
||||
GET_AVAILABLE_DATES = "SELECT DISTINCT crawl_date FROM projects_history ORDER BY crawl_date DESC"
|
||||
|
||||
# 최신 수집 날짜 조회
|
||||
GET_LAST_CRAWL_DATE = "SELECT MAX(crawl_date) as last_date FROM projects_history"
|
||||
|
||||
# 특정 날짜 프로젝트 데이터 JOIN 조회
|
||||
GET_PROJECT_LIST = """
|
||||
SELECT m.project_nm, m.short_nm, m.department, m.master,
|
||||
h.recent_log, h.file_count, m.continent, m.country
|
||||
FROM projects_master m
|
||||
LEFT JOIN projects_history h ON m.project_id = h.project_id AND h.crawl_date = %s
|
||||
ORDER BY m.project_id ASC
|
||||
"""
|
||||
|
||||
# 활성도 분석을 위한 프로젝트 목록 조회
|
||||
GET_PROJECT_LIST_FOR_ANALYSIS = """
|
||||
SELECT m.project_id, m.project_nm, m.short_nm, m.department, h.recent_log, h.file_count
|
||||
FROM projects_master m
|
||||
LEFT JOIN projects_history h ON m.project_id = h.project_id AND h.crawl_date = %s
|
||||
"""
|
||||
|
||||
class CrawlerQueries:
|
||||
"""크롤러(Crawler) 데이터 동기화 관련 쿼리"""
|
||||
# 마스터 정보 UPSERT (INSERT OR UPDATE)
|
||||
UPSERT_MASTER = """
|
||||
INSERT INTO projects_master (project_id, project_nm, short_nm, master, continent, country)
|
||||
VALUES (%s, %s, %s, %s, %s, %s)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
project_nm = VALUES(project_nm), short_nm = VALUES(short_nm),
|
||||
master = VALUES(master), continent = VALUES(continent), country = VALUES(country)
|
||||
"""
|
||||
|
||||
# 부서 정보 업데이트
|
||||
UPDATE_DEPARTMENT = "UPDATE projects_master SET department = %s WHERE project_id = %s"
|
||||
|
||||
# 히스토리(로그/파일수) 저장
|
||||
UPSERT_HISTORY = """
|
||||
INSERT INTO projects_history (project_id, crawl_date, recent_log, file_count)
|
||||
VALUES (%s, CURRENT_DATE(), %s, %s)
|
||||
ON DUPLICATE KEY UPDATE recent_log=VALUES(recent_log), file_count=VALUES(file_count)
|
||||
"""
|
||||
class InquiryQueries:
|
||||
"""문의사항(Inquiries) 페이지 관련 쿼리"""
|
||||
# 필터링을 위한 기본 쿼리 (WHERE 1=1 포함)
|
||||
SELECT_BASE = "SELECT * FROM inquiries WHERE 1=1"
|
||||
ORDER_BY_DESC = "ORDER BY no DESC"
|
||||
|
||||
# 상세 조회
|
||||
SELECT_BY_ID = "SELECT * FROM inquiries WHERE id = %s"
|
||||
|
||||
# 답변 업데이트 (handled_date 포함)
|
||||
UPDATE_REPLY = """
|
||||
UPDATE inquiries
|
||||
SET reply = %s, status = %s, handler = %s, handled_date = %s
|
||||
WHERE id = %s
|
||||
"""
|
||||
|
||||
# 답변 삭제 (초기화)
|
||||
DELETE_REPLY = """
|
||||
UPDATE inquiries
|
||||
SET reply = '', status = '미확인', handled_date = ''
|
||||
WHERE id = %s
|
||||
"""
|
||||
|
||||
class DashboardQueries:
|
||||
"""대시보드(Dashboard) 및 프로젝트 현황 관련 쿼리"""
|
||||
# 가용 날짜 목록 조회
|
||||
GET_AVAILABLE_DATES = "SELECT DISTINCT crawl_date FROM projects_history ORDER BY crawl_date DESC"
|
||||
|
||||
# 최신 수집 날짜 조회
|
||||
GET_LAST_CRAWL_DATE = "SELECT MAX(crawl_date) as last_date FROM projects_history"
|
||||
|
||||
# 특정 날짜(또는 그 이하 최신) 프로젝트 데이터 JOIN 조회
|
||||
GET_PROJECT_LIST = """
|
||||
SELECT m.project_nm, m.short_nm, m.department, m.master,
|
||||
h.recent_log, h.file_count, m.continent, m.country
|
||||
FROM projects_master m
|
||||
LEFT JOIN projects_history h ON h.project_id = m.project_id AND h.crawl_date = (
|
||||
SELECT MAX(crawl_date)
|
||||
FROM projects_history
|
||||
WHERE project_id = m.project_id AND crawl_date <= %s
|
||||
)
|
||||
ORDER BY m.project_id ASC
|
||||
"""
|
||||
|
||||
# 활성도 분석을 위한 프로젝트 목록 조회 (특정 날짜 이하 최신 데이터 기준)
|
||||
GET_PROJECT_LIST_FOR_ANALYSIS = """
|
||||
SELECT m.project_id, m.project_nm, m.short_nm, m.department, h.recent_log, h.file_count
|
||||
FROM projects_master m
|
||||
LEFT JOIN projects_history h ON h.project_id = m.project_id AND h.crawl_date = (
|
||||
SELECT MAX(crawl_date)
|
||||
FROM projects_history
|
||||
WHERE project_id = m.project_id AND crawl_date <= %s
|
||||
)
|
||||
"""
|
||||
|
||||
class CrawlerQueries:
|
||||
"""크롤러(Crawler) 데이터 동기화 관련 쿼리"""
|
||||
# 마스터 정보 UPSERT (INSERT OR UPDATE)
|
||||
UPSERT_MASTER = """
|
||||
INSERT INTO projects_master (project_id, project_nm, short_nm, master, continent, country)
|
||||
VALUES (%s, %s, %s, %s, %s, %s)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
project_nm = VALUES(project_nm), short_nm = VALUES(short_nm),
|
||||
master = VALUES(master), continent = VALUES(continent), country = VALUES(country)
|
||||
"""
|
||||
|
||||
# 부서 정보 업데이트
|
||||
UPDATE_DEPARTMENT = "UPDATE projects_master SET department = %s WHERE project_id = %s"
|
||||
|
||||
# 히스토리(로그/파일수) 저장 (날짜 지정형)
|
||||
UPSERT_HISTORY_WITH_DATE = """
|
||||
INSERT INTO projects_history (project_id, crawl_date, recent_log, file_count)
|
||||
VALUES (%s, %s, %s, %s)
|
||||
ON DUPLICATE KEY UPDATE recent_log=VALUES(recent_log), file_count=VALUES(file_count)
|
||||
"""
|
||||
|
||||
# 히스토리(로그/파일수) 저장 (기본형 - 오늘 날짜)
|
||||
UPSERT_HISTORY = """
|
||||
INSERT INTO projects_history (project_id, crawl_date, recent_log, file_count)
|
||||
VALUES (%s, CURDATE(), %s, %s)
|
||||
ON DUPLICATE KEY UPDATE recent_log=VALUES(recent_log), file_count=VALUES(file_count)
|
||||
"""
|
||||
|
||||
@@ -1,233 +1,233 @@
|
||||
/* ==========================================================================
|
||||
Project Master Analysis - Specific Styles
|
||||
(Inherits base styles from common.css)
|
||||
========================================================================== */
|
||||
|
||||
.analysis-content {
|
||||
padding: 24px;
|
||||
max-width: 1400px;
|
||||
margin: var(--topbar-h, 36px) auto 0;
|
||||
}
|
||||
|
||||
/* AI Badge & Header */
|
||||
.ai-badge {
|
||||
background: #6366f1;
|
||||
color: white;
|
||||
padding: 2px 10px;
|
||||
border-radius: 20px;
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
display: inline-block;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.analysis-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 24px;
|
||||
padding: 10px 0;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.analysis-header h2 { font-size: 22px; font-weight: 800; color: var(--text-main); margin-bottom: 4px; }
|
||||
.analysis-header p { font-size: 13px; color: var(--text-sub); }
|
||||
|
||||
/* Top Info Grid */
|
||||
.top-info-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 2.2fr;
|
||||
gap: 16px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.dl-model-info, .soi-deep-dive {
|
||||
background: white;
|
||||
border-radius: var(--radius-xl);
|
||||
border: 1px solid var(--border-color);
|
||||
padding: 20px;
|
||||
box-shadow: var(--box-shadow);
|
||||
}
|
||||
|
||||
.card-header { margin-bottom: 15px; display: flex; align-items: center; justify-content: space-between; }
|
||||
.card-header h4 { font-size: 14px; font-weight: 800; color: var(--primary-color); margin: 0; }
|
||||
|
||||
.model-desc-vertical { display: flex; flex-direction: column; gap: 12px; }
|
||||
.model-item-vertical { display: flex; align-items: center; gap: 12px; }
|
||||
.model-tag { background: var(--bg-muted); color: var(--text-sub); padding: 2px 8px; border-radius: 4px; font-size: 10px; font-weight: 700; }
|
||||
|
||||
.soi-info-columns { display: grid; grid-template-columns: repeat(3, 1fr); gap: 20px; }
|
||||
.soi-info-column h6 { font-size: 12px; font-weight: 800; color: var(--primary-color); margin: 0 0 8px 0; }
|
||||
.soi-info-column p { font-size: 11.5px; color: var(--text-sub); line-height: 1.6; margin: 0; }
|
||||
|
||||
/* Chart Grid Layout */
|
||||
.analysis-charts-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1.8fr;
|
||||
gap: 20px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.chart-container-box {
|
||||
background: white;
|
||||
border-radius: var(--radius-xl);
|
||||
padding: 20px;
|
||||
border: 1px solid var(--border-color);
|
||||
height: 360px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-shadow: var(--box-shadow);
|
||||
}
|
||||
|
||||
.chart-container-box h5 { margin: 0 0 15px 0; font-size: 13px; font-weight: 700; color: var(--text-main); }
|
||||
|
||||
/* Timeline Analysis Card */
|
||||
.analysis-card {
|
||||
background: white;
|
||||
border-radius: var(--radius-xl);
|
||||
border: 1px solid var(--border-color);
|
||||
box-shadow: var(--box-shadow);
|
||||
margin-bottom: 24px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.analysis-card .card-header {
|
||||
padding: 16px 24px;
|
||||
background: #fff;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.analysis-card .card-body { padding: 24px; }
|
||||
|
||||
/* SOI Guide Styles */
|
||||
.d-war-guide {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-bottom: 20px;
|
||||
padding: 12px;
|
||||
background: var(--bg-muted);
|
||||
border-radius: var(--radius-lg);
|
||||
}
|
||||
|
||||
.guide-item {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
padding: 4px 10px;
|
||||
border-radius: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.guide-item.active-low { background: #dcfce7; color: #166534; }
|
||||
.guide-item.warning-mid { background: #fef9c3; color: #854d0e; }
|
||||
.guide-item.danger-high { background: #ffedd5; color: #9a3412; }
|
||||
.guide-item.hazard-critical { background: #fee2e2; color: #991b1b; }
|
||||
|
||||
/* Data Table Customization */
|
||||
.table-scroll-wrapper {
|
||||
overflow-x: auto;
|
||||
overflow-y: auto;
|
||||
max-height: 600px;
|
||||
border-radius: var(--radius-lg);
|
||||
border: 1px solid var(--border-color);
|
||||
background: white;
|
||||
}
|
||||
|
||||
.p-war-table {
|
||||
width: 100%;
|
||||
border-collapse: separate;
|
||||
border-spacing: 0;
|
||||
table-layout: fixed; /* 컬럼 너비 고정 */
|
||||
}
|
||||
|
||||
.p-war-table th {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 20;
|
||||
background: #f8fafc;
|
||||
padding: 16px 15px;
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
color: #475569;
|
||||
border-bottom: 2px solid #e2e8f0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.p-war-table td {
|
||||
padding: 14px 15px;
|
||||
font-size: 13px;
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
/* 컬럼별 너비 정의 */
|
||||
.p-war-table th:nth-child(1), .p-war-table td:nth-child(1) { width: 28%; text-align: left; } /* 프로젝트명 */
|
||||
.p-war-table th:nth-child(2), .p-war-table td:nth-child(2) { width: 10%; text-align: right; } /* 파일 수 */
|
||||
.p-war-table th:nth-child(3), .p-war-table td:nth-child(3) { width: 10%; text-align: right; } /* 방치일 */
|
||||
.p-war-table th:nth-child(4), .p-war-table td:nth-child(4) { width: 10%; text-align: center; } /* 상태 판정 */
|
||||
.p-war-table th:nth-child(5), .p-war-table td:nth-child(5) { width: 14%; text-align: right; } /* P-WAR+ */
|
||||
.p-war-table th:nth-child(6), .p-war-table td:nth-child(6) { width: 12%; text-align: right; } /* 현재 SOI */
|
||||
.p-war-table th:nth-child(7), .p-war-table td:nth-child(7) { width: 12%; text-align: center; } /* 실무 투입 */
|
||||
.p-war-table th:nth-child(8), .p-war-table td:nth-child(8) { width: 14%; text-align: center; } /* AI 예보 */
|
||||
|
||||
.project-row { cursor: pointer; transition: background 0.2s; }
|
||||
.project-row:hover { background: var(--hover-bg) !important; }
|
||||
|
||||
/* SOI Value Styling */
|
||||
.p-war-value { font-weight: 800; font-family: 'Consolas', monospace; }
|
||||
|
||||
/* Accordion Detail Styles */
|
||||
.detail-row { display: none; background: #fafafa; }
|
||||
.detail-row.active { display: table-row; }
|
||||
.detail-container { padding: 20px 24px; }
|
||||
|
||||
.formula-explanation-card {
|
||||
background: white;
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 24px;
|
||||
border: 1px solid var(--border-color);
|
||||
box-shadow: var(--box-shadow);
|
||||
}
|
||||
|
||||
.formula-header { font-size: 13px; font-weight: 700; color: #6366f1; margin-bottom: 15px; }
|
||||
|
||||
/* Work Effort Section */
|
||||
.work-effort-section { background: var(--bg-muted); padding: 16px; border-radius: var(--radius-lg); margin-bottom: 20px; }
|
||||
.work-effort-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; }
|
||||
.work-effort-bar-bg { width: 100%; height: 6px; background: #e2e8f0; border-radius: 3px; overflow: hidden; margin-bottom: 10px; }
|
||||
|
||||
/* Formula Steps Grid */
|
||||
.formula-steps-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; }
|
||||
.formula-step { display: flex; gap: 12px; }
|
||||
.step-num { background: var(--primary-color); color: white; width: 20px; height: 20px; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 11px; font-weight: 800; flex-shrink: 0; }
|
||||
.step-title { font-size: 12px; font-weight: 700; color: var(--text-main); margin-bottom: 4px; }
|
||||
.math-logic { font-family: 'Consolas', monospace; background: var(--bg-muted); padding: 4px 8px; border-radius: 4px; font-weight: 700; color: var(--text-main); font-size: 12px; display: inline-block; }
|
||||
|
||||
.final-result-area { margin-top: 20px; padding-top: 15px; display: flex; justify-content: space-between; align-items: center; }
|
||||
|
||||
/* Modal Analysis Specific */
|
||||
.modal-footer {
|
||||
padding: 16px 24px;
|
||||
background: #fff;
|
||||
border-top: 1px solid var(--border-color);
|
||||
text-align: right;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
/* Formula & Badges */
|
||||
.formula-section { margin-bottom: 20px; }
|
||||
.formula-box { background: var(--primary-lv-0); color: var(--primary-color); padding: 15px; border-radius: var(--radius-lg); font-weight: 800; text-align: center; font-family: monospace; font-size: 16px; }
|
||||
|
||||
.badge-active { background: #dcfce7; color: #166534; padding: 2px 8px; border-radius: 4px; font-size: 11px; font-weight: 700; }
|
||||
.badge-warning { background: #fef9c3; color: #854d0e; padding: 2px 8px; border-radius: 4px; font-size: 11px; font-weight: 700; }
|
||||
.badge-danger { background: #ffedd5; color: #9a3412; padding: 2px 8px; border-radius: 4px; font-size: 11px; font-weight: 700; }
|
||||
.badge-system { background: #fee2e2; color: #991b1b; padding: 2px 8px; border-radius: 4px; font-size: 11px; font-weight: 700; }
|
||||
|
||||
.highlight-var { color: #2563eb; }
|
||||
.highlight-val { color: #059669; }
|
||||
.highlight-penalty { color: #dc2626; }
|
||||
.text-plus { color: #059669; font-weight: 700; }
|
||||
.text-minus { color: #dc2626; font-weight: 700; }
|
||||
.font-bold { font-weight: 700; }
|
||||
/* ==========================================================================
|
||||
Project Master Analysis - Specific Styles
|
||||
(Inherits base styles from common.css)
|
||||
========================================================================== */
|
||||
|
||||
.analysis-content {
|
||||
padding: 24px;
|
||||
max-width: 1400px;
|
||||
margin: var(--topbar-h, 36px) auto 0;
|
||||
}
|
||||
|
||||
/* AI Badge & Header */
|
||||
.ai-badge {
|
||||
background: #6366f1;
|
||||
color: white;
|
||||
padding: 2px 10px;
|
||||
border-radius: 20px;
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
display: inline-block;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.analysis-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 24px;
|
||||
padding: 10px 0;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.analysis-header h2 { font-size: 22px; font-weight: 800; color: var(--text-main); margin-bottom: 4px; }
|
||||
.analysis-header p { font-size: 13px; color: var(--text-sub); }
|
||||
|
||||
/* Top Info Grid */
|
||||
.top-info-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 2.2fr;
|
||||
gap: 16px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.dl-model-info, .soi-deep-dive {
|
||||
background: white;
|
||||
border-radius: var(--radius-xl);
|
||||
border: 1px solid var(--border-color);
|
||||
padding: 20px;
|
||||
box-shadow: var(--box-shadow);
|
||||
}
|
||||
|
||||
.card-header { margin-bottom: 15px; display: flex; align-items: center; justify-content: space-between; }
|
||||
.card-header h4 { font-size: 14px; font-weight: 800; color: var(--primary-color); margin: 0; }
|
||||
|
||||
.model-desc-vertical { display: flex; flex-direction: column; gap: 12px; }
|
||||
.model-item-vertical { display: flex; align-items: center; gap: 12px; }
|
||||
.model-tag { background: var(--bg-muted); color: var(--text-sub); padding: 2px 8px; border-radius: 4px; font-size: 10px; font-weight: 700; }
|
||||
|
||||
.soi-info-columns { display: grid; grid-template-columns: repeat(3, 1fr); gap: 20px; }
|
||||
.soi-info-column h6 { font-size: 12px; font-weight: 800; color: var(--primary-color); margin: 0 0 8px 0; }
|
||||
.soi-info-column p { font-size: 11.5px; color: var(--text-sub); line-height: 1.6; margin: 0; }
|
||||
|
||||
/* Chart Grid Layout */
|
||||
.analysis-charts-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1.8fr;
|
||||
gap: 20px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.chart-container-box {
|
||||
background: white;
|
||||
border-radius: var(--radius-xl);
|
||||
padding: 20px;
|
||||
border: 1px solid var(--border-color);
|
||||
height: 360px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-shadow: var(--box-shadow);
|
||||
}
|
||||
|
||||
.chart-container-box h5 { margin: 0 0 15px 0; font-size: 13px; font-weight: 700; color: var(--text-main); }
|
||||
|
||||
/* Timeline Analysis Card */
|
||||
.analysis-card {
|
||||
background: white;
|
||||
border-radius: var(--radius-xl);
|
||||
border: 1px solid var(--border-color);
|
||||
box-shadow: var(--box-shadow);
|
||||
margin-bottom: 24px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.analysis-card .card-header {
|
||||
padding: 16px 24px;
|
||||
background: #fff;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.analysis-card .card-body { padding: 24px; }
|
||||
|
||||
/* SOI Guide Styles */
|
||||
.d-war-guide {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-bottom: 20px;
|
||||
padding: 12px;
|
||||
background: var(--bg-muted);
|
||||
border-radius: var(--radius-lg);
|
||||
}
|
||||
|
||||
.guide-item {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
padding: 4px 10px;
|
||||
border-radius: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.guide-item.active-low { background: #dcfce7; color: #166534; }
|
||||
.guide-item.warning-mid { background: #fef9c3; color: #854d0e; }
|
||||
.guide-item.danger-high { background: #ffedd5; color: #9a3412; }
|
||||
.guide-item.hazard-critical { background: #fee2e2; color: #991b1b; }
|
||||
|
||||
/* Data Table Customization */
|
||||
.table-scroll-wrapper {
|
||||
overflow-x: auto;
|
||||
overflow-y: auto;
|
||||
max-height: 600px;
|
||||
border-radius: var(--radius-lg);
|
||||
border: 1px solid var(--border-color);
|
||||
background: white;
|
||||
}
|
||||
|
||||
.p-war-table {
|
||||
width: 100%;
|
||||
border-collapse: separate;
|
||||
border-spacing: 0;
|
||||
table-layout: fixed; /* 컬럼 너비 고정 */
|
||||
}
|
||||
|
||||
.p-war-table th {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 20;
|
||||
background: #f8fafc;
|
||||
padding: 16px 15px;
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
color: #475569;
|
||||
border-bottom: 2px solid #e2e8f0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.p-war-table td {
|
||||
padding: 14px 15px;
|
||||
font-size: 13px;
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
/* 컬럼별 너비 정의 */
|
||||
.p-war-table th:nth-child(1), .p-war-table td:nth-child(1) { width: 28%; text-align: left; } /* 프로젝트명 */
|
||||
.p-war-table th:nth-child(2), .p-war-table td:nth-child(2) { width: 10%; text-align: right; } /* 파일 수 */
|
||||
.p-war-table th:nth-child(3), .p-war-table td:nth-child(3) { width: 10%; text-align: right; } /* 방치일 */
|
||||
.p-war-table th:nth-child(4), .p-war-table td:nth-child(4) { width: 10%; text-align: center; } /* 상태 판정 */
|
||||
.p-war-table th:nth-child(5), .p-war-table td:nth-child(5) { width: 14%; text-align: right; } /* P-WAR+ */
|
||||
.p-war-table th:nth-child(6), .p-war-table td:nth-child(6) { width: 12%; text-align: right; } /* 현재 SOI */
|
||||
.p-war-table th:nth-child(7), .p-war-table td:nth-child(7) { width: 12%; text-align: center; } /* 실무 투입 */
|
||||
.p-war-table th:nth-child(8), .p-war-table td:nth-child(8) { width: 14%; text-align: center; } /* AI 예보 */
|
||||
|
||||
.project-row { cursor: pointer; transition: background 0.2s; }
|
||||
.project-row:hover { background: var(--hover-bg) !important; }
|
||||
|
||||
/* SOI Value Styling */
|
||||
.p-war-value { font-weight: 800; font-family: 'Consolas', monospace; }
|
||||
|
||||
/* Accordion Detail Styles */
|
||||
.detail-row { display: none; background: #fafafa; }
|
||||
.detail-row.active { display: table-row; }
|
||||
.detail-container { padding: 20px 24px; }
|
||||
|
||||
.formula-explanation-card {
|
||||
background: white;
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 24px;
|
||||
border: 1px solid var(--border-color);
|
||||
box-shadow: var(--box-shadow);
|
||||
}
|
||||
|
||||
.formula-header { font-size: 13px; font-weight: 700; color: #6366f1; margin-bottom: 15px; }
|
||||
|
||||
/* Work Effort Section */
|
||||
.work-effort-section { background: var(--bg-muted); padding: 16px; border-radius: var(--radius-lg); margin-bottom: 20px; }
|
||||
.work-effort-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; }
|
||||
.work-effort-bar-bg { width: 100%; height: 6px; background: #e2e8f0; border-radius: 3px; overflow: hidden; margin-bottom: 10px; }
|
||||
|
||||
/* Formula Steps Grid */
|
||||
.formula-steps-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; }
|
||||
.formula-step { display: flex; gap: 12px; }
|
||||
.step-num { background: var(--primary-color); color: white; width: 20px; height: 20px; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 11px; font-weight: 800; flex-shrink: 0; }
|
||||
.step-title { font-size: 12px; font-weight: 700; color: var(--text-main); margin-bottom: 4px; }
|
||||
.math-logic { font-family: 'Consolas', monospace; background: var(--bg-muted); padding: 4px 8px; border-radius: 4px; font-weight: 700; color: var(--text-main); font-size: 12px; display: inline-block; }
|
||||
|
||||
.final-result-area { margin-top: 20px; padding-top: 15px; display: flex; justify-content: space-between; align-items: center; }
|
||||
|
||||
/* Modal Analysis Specific */
|
||||
.modal-footer {
|
||||
padding: 16px 24px;
|
||||
background: #fff;
|
||||
border-top: 1px solid var(--border-color);
|
||||
text-align: right;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
/* Formula & Badges */
|
||||
.formula-section { margin-bottom: 20px; }
|
||||
.formula-box { background: var(--primary-lv-0); color: var(--primary-color); padding: 15px; border-radius: var(--radius-lg); font-weight: 800; text-align: center; font-family: monospace; font-size: 16px; }
|
||||
|
||||
.badge-active { background: #dcfce7; color: #166534; padding: 2px 8px; border-radius: 4px; font-size: 11px; font-weight: 700; }
|
||||
.badge-warning { background: #fef9c3; color: #854d0e; padding: 2px 8px; border-radius: 4px; font-size: 11px; font-weight: 700; }
|
||||
.badge-danger { background: #ffedd5; color: #9a3412; padding: 2px 8px; border-radius: 4px; font-size: 11px; font-weight: 700; }
|
||||
.badge-system { background: #fee2e2; color: #991b1b; padding: 2px 8px; border-radius: 4px; font-size: 11px; font-weight: 700; }
|
||||
|
||||
.highlight-var { color: #2563eb; }
|
||||
.highlight-val { color: #059669; }
|
||||
.highlight-penalty { color: #dc2626; }
|
||||
.text-plus { color: #059669; font-weight: 700; }
|
||||
.text-minus { color: #dc2626; font-weight: 700; }
|
||||
.font-bold { font-weight: 700; }
|
||||
|
||||
340
style/common.css
340
style/common.css
@@ -1,170 +1,170 @@
|
||||
:root {
|
||||
/* 1. Core Colors */
|
||||
--primary-color: #1E5149;
|
||||
--primary-hover: #163b36;
|
||||
--primary-lv-0: #f0f7f4;
|
||||
--primary-lv-1: #e1eee9;
|
||||
--primary-lv-8: #193833;
|
||||
|
||||
--bg-default: #FFFFFF;
|
||||
--bg-muted: #F9FAFB;
|
||||
--hover-bg: #F7FAFC;
|
||||
|
||||
--text-main: #111827;
|
||||
--text-sub: #6B7280;
|
||||
--error-color: #F21D0D;
|
||||
--border-color: #E2E8F0;
|
||||
|
||||
/* 2. Gradients */
|
||||
--header-gradient: linear-gradient(90deg, #193833 0%, #1e5149 100%);
|
||||
--ai-gradient: linear-gradient(180deg, #da8cf1 0%, #8bb1f2 100%);
|
||||
|
||||
/* 3. Spacing & Radius */
|
||||
--space-xs: 4px;
|
||||
--space-sm: 8px;
|
||||
--space-md: 16px;
|
||||
--space-lg: 32px;
|
||||
--radius-sm: 4px;
|
||||
--radius-md: 6px;
|
||||
--radius-lg: 8px;
|
||||
--radius-xl: 12px;
|
||||
|
||||
/* 4. Typography */
|
||||
--fz-h1: 20px;
|
||||
--fz-h2: 16px;
|
||||
--fz-body: 13px;
|
||||
--fz-small: 11px;
|
||||
|
||||
/* 5. Shadows */
|
||||
--box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.05);
|
||||
--box-shadow-lg: 0 10px 20px rgba(0, 0, 0, 0.1);
|
||||
--box-shadow-modal: 0 25px 50px -12px rgba(0,0,0,0.5);
|
||||
|
||||
/* 6. Layout Constants */
|
||||
--topbar-h: 36px;
|
||||
}
|
||||
|
||||
/* Base Reset */
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
font-family: 'Pretendard', -apple-system, sans-serif;
|
||||
font-size: var(--fz-body);
|
||||
color: var(--text-main);
|
||||
background: var(--bg-default);
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* Page Specific Overrides */
|
||||
body:has(.mail-wrapper) { height: 100vh; overflow: hidden; }
|
||||
|
||||
input, select, textarea, button { font-family: inherit; }
|
||||
a { text-decoration: none; color: inherit; }
|
||||
button { cursor: pointer; border: none; transition: all 0.2s ease; }
|
||||
|
||||
/* Utilities: Layout & Text */
|
||||
.flex-center { display: flex; align-items: center; justify-content: center; }
|
||||
.flex-between { display: flex; align-items: center; justify-content: space-between; }
|
||||
.flex-column { display: flex; flex-direction: column; }
|
||||
.text-truncate { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.w-full { width: 100%; }
|
||||
.pointer { cursor: pointer; }
|
||||
|
||||
/* Components: Topbar */
|
||||
.topbar {
|
||||
width: 100%;
|
||||
background: var(--header-gradient);
|
||||
color: #fff;
|
||||
padding: 0 var(--space-lg);
|
||||
position: fixed;
|
||||
top: 0;
|
||||
height: var(--topbar-h);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
z-index: 2000;
|
||||
}
|
||||
.topbar-header h2 { font-size: 16px; color: white; margin-right: 60px; font-weight: 700; }
|
||||
.nav-list { display: flex; list-style: none; gap: var(--space-sm); }
|
||||
.nav-item {
|
||||
padding: 4px 12px; border-radius: var(--radius-sm);
|
||||
color: rgba(255, 255, 255, 0.8); font-size: 14px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.nav-item:hover { background: var(--primary-lv-8); color: #fff; }
|
||||
.nav-item.active { background: var(--primary-lv-0); color: var(--primary-color) !important; font-weight: 700; }
|
||||
|
||||
/* Components: Modals */
|
||||
.modal-overlay {
|
||||
display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%;
|
||||
background: rgba(0, 0, 0, 0.6); backdrop-filter: blur(4px);
|
||||
z-index: 3000; justify-content: center; align-items: center;
|
||||
}
|
||||
.modal-content {
|
||||
background: white; padding: 24px; border-radius: var(--radius-xl);
|
||||
width: 90%; max-width: 500px; box-shadow: var(--box-shadow-modal);
|
||||
}
|
||||
.modal-header {
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
margin-bottom: 20px; border-bottom: 1px solid var(--border-color); padding-bottom: 12px;
|
||||
}
|
||||
.modal-header h3 { margin: 0; font-size: 16px; color: var(--primary-color); font-weight: 700; }
|
||||
.modal-close { cursor: pointer; font-size: 24px; color: var(--text-sub); line-height: 1; transition: 0.2s; }
|
||||
.modal-close:hover { color: var(--text-main); }
|
||||
|
||||
/* Components: Data Tables */
|
||||
.data-table { width: 100%; border-collapse: collapse; font-size: 12px; background: #fff; }
|
||||
.data-table th, .data-table td { padding: 12px 10px; border-bottom: 1px solid var(--border-color); text-align: left; }
|
||||
.data-table th { color: var(--text-sub); font-weight: 700; background: var(--bg-muted); font-size: 11px; text-transform: uppercase; }
|
||||
.data-table tr:hover { background: var(--hover-bg); }
|
||||
|
||||
/* Components: Standard Buttons */
|
||||
.btn {
|
||||
display: inline-flex; align-items: center; justify-content: center; gap: 8px;
|
||||
padding: 8px 16px; border-radius: var(--radius-lg); font-weight: 600; font-size: 13px;
|
||||
border: none; cursor: pointer; transition: all 0.2s ease;
|
||||
}
|
||||
.btn-primary { background: var(--primary-color); color: #fff; }
|
||||
.btn-primary:hover { background: var(--primary-hover); transform: translateY(-1px); }
|
||||
.btn-secondary { background: #f1f3f5; color: #495057; }
|
||||
.btn-secondary:hover { background: #e9ecef; }
|
||||
.btn-danger { background: #fee2e2; color: #dc2626; }
|
||||
.btn-danger:hover { background: #fecaca; }
|
||||
|
||||
/* Compatibility Utils */
|
||||
._button-xsmall {
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
padding: 4px 10px; font-size: 11px; font-weight: 600; border-radius: 4px; border: 1px solid var(--border-color);
|
||||
background: #fff; color: var(--text-main); cursor: pointer; transition: 0.2s;
|
||||
}
|
||||
._button-xsmall:hover { background: var(--bg-muted); border-color: var(--primary-color); color: var(--primary-color); }
|
||||
|
||||
._button-small {
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
padding: 6px 14px; font-size: 12px; background: var(--primary-color); color: #fff; border-radius: 6px; border: none; cursor: pointer;
|
||||
}
|
||||
._button-medium {
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
padding: 10px 20px; background: var(--primary-color); color: #fff; border-radius: 6px; font-weight: 700; border: none; cursor: pointer;
|
||||
}
|
||||
.sync-btn { background: var(--primary-color); color: #fff; padding: 8px 16px; border-radius: 8px; font-size: 13px; font-weight: 600; }
|
||||
|
||||
/* Badges & Status Colors */
|
||||
.badge {
|
||||
padding: 2px 8px; border-radius: 4px; font-size: 10px; font-weight: 700;
|
||||
display: inline-block; background: var(--primary-lv-1); color: var(--primary-color);
|
||||
}
|
||||
|
||||
.status-complete { background: #e8f5e9; color: #2e7d32; }
|
||||
.status-working { background: #fff8e1; color: #FFBF00; }
|
||||
.status-checking { background: #e3f2fd; color: #1565c0; }
|
||||
.status-pending { background: #f5f5f5; color: #757575; }
|
||||
.status-error { background: #fee9e7; }
|
||||
|
||||
.warning-text { color: #FFBF00; font-weight: 600; }
|
||||
.error-text { color: #F21D0D !important; font-weight: 700; }
|
||||
|
||||
/* Spinner */
|
||||
.spinner {
|
||||
display: none; width: 16px; height: 16px; border: 2px solid rgba(255, 255, 255, .3);
|
||||
border-radius: 50%; border-top-color: #fff; animation: spin 1s ease-in-out infinite;
|
||||
}
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
:root {
|
||||
/* 1. Core Colors */
|
||||
--primary-color: #1E5149;
|
||||
--primary-hover: #163b36;
|
||||
--primary-lv-0: #f0f7f4;
|
||||
--primary-lv-1: #e1eee9;
|
||||
--primary-lv-8: #193833;
|
||||
|
||||
--bg-default: #FFFFFF;
|
||||
--bg-muted: #F9FAFB;
|
||||
--hover-bg: #F7FAFC;
|
||||
|
||||
--text-main: #111827;
|
||||
--text-sub: #6B7280;
|
||||
--error-color: #F21D0D;
|
||||
--border-color: #E2E8F0;
|
||||
|
||||
/* 2. Gradients */
|
||||
--header-gradient: linear-gradient(90deg, #193833 0%, #1e5149 100%);
|
||||
--ai-gradient: linear-gradient(180deg, #da8cf1 0%, #8bb1f2 100%);
|
||||
|
||||
/* 3. Spacing & Radius */
|
||||
--space-xs: 4px;
|
||||
--space-sm: 8px;
|
||||
--space-md: 16px;
|
||||
--space-lg: 32px;
|
||||
--radius-sm: 4px;
|
||||
--radius-md: 6px;
|
||||
--radius-lg: 8px;
|
||||
--radius-xl: 12px;
|
||||
|
||||
/* 4. Typography */
|
||||
--fz-h1: 20px;
|
||||
--fz-h2: 16px;
|
||||
--fz-body: 13px;
|
||||
--fz-small: 11px;
|
||||
|
||||
/* 5. Shadows */
|
||||
--box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.05);
|
||||
--box-shadow-lg: 0 10px 20px rgba(0, 0, 0, 0.1);
|
||||
--box-shadow-modal: 0 25px 50px -12px rgba(0,0,0,0.5);
|
||||
|
||||
/* 6. Layout Constants */
|
||||
--topbar-h: 36px;
|
||||
}
|
||||
|
||||
/* Base Reset */
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
font-family: 'Pretendard', -apple-system, sans-serif;
|
||||
font-size: var(--fz-body);
|
||||
color: var(--text-main);
|
||||
background: var(--bg-default);
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* Page Specific Overrides */
|
||||
body:has(.mail-wrapper) { height: 100vh; overflow: hidden; }
|
||||
|
||||
input, select, textarea, button { font-family: inherit; }
|
||||
a { text-decoration: none; color: inherit; }
|
||||
button { cursor: pointer; border: none; transition: all 0.2s ease; }
|
||||
|
||||
/* Utilities: Layout & Text */
|
||||
.flex-center { display: flex; align-items: center; justify-content: center; }
|
||||
.flex-between { display: flex; align-items: center; justify-content: space-between; }
|
||||
.flex-column { display: flex; flex-direction: column; }
|
||||
.text-truncate { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.w-full { width: 100%; }
|
||||
.pointer { cursor: pointer; }
|
||||
|
||||
/* Components: Topbar */
|
||||
.topbar {
|
||||
width: 100%;
|
||||
background: var(--header-gradient);
|
||||
color: #fff;
|
||||
padding: 0 var(--space-lg);
|
||||
position: fixed;
|
||||
top: 0;
|
||||
height: var(--topbar-h);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
z-index: 2000;
|
||||
}
|
||||
.topbar-header h2 { font-size: 16px; color: white; margin-right: 60px; font-weight: 700; }
|
||||
.nav-list { display: flex; list-style: none; gap: var(--space-sm); }
|
||||
.nav-item {
|
||||
padding: 4px 12px; border-radius: var(--radius-sm);
|
||||
color: rgba(255, 255, 255, 0.8); font-size: 14px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.nav-item:hover { background: var(--primary-lv-8); color: #fff; }
|
||||
.nav-item.active { background: var(--primary-lv-0); color: var(--primary-color) !important; font-weight: 700; }
|
||||
|
||||
/* Components: Modals */
|
||||
.modal-overlay {
|
||||
display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%;
|
||||
background: rgba(0, 0, 0, 0.6); backdrop-filter: blur(4px);
|
||||
z-index: 3000; justify-content: center; align-items: center;
|
||||
}
|
||||
.modal-content {
|
||||
background: white; padding: 24px; border-radius: var(--radius-xl);
|
||||
width: 90%; max-width: 500px; box-shadow: var(--box-shadow-modal);
|
||||
}
|
||||
.modal-header {
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
margin-bottom: 20px; border-bottom: 1px solid var(--border-color); padding-bottom: 12px;
|
||||
}
|
||||
.modal-header h3 { margin: 0; font-size: 16px; color: var(--primary-color); font-weight: 700; }
|
||||
.modal-close { cursor: pointer; font-size: 24px; color: var(--text-sub); line-height: 1; transition: 0.2s; }
|
||||
.modal-close:hover { color: var(--text-main); }
|
||||
|
||||
/* Components: Data Tables */
|
||||
.data-table { width: 100%; border-collapse: collapse; font-size: 12px; background: #fff; }
|
||||
.data-table th, .data-table td { padding: 12px 10px; border-bottom: 1px solid var(--border-color); text-align: left; }
|
||||
.data-table th { color: var(--text-sub); font-weight: 700; background: var(--bg-muted); font-size: 11px; text-transform: uppercase; }
|
||||
.data-table tr:hover { background: var(--hover-bg); }
|
||||
|
||||
/* Components: Standard Buttons */
|
||||
.btn {
|
||||
display: inline-flex; align-items: center; justify-content: center; gap: 8px;
|
||||
padding: 8px 16px; border-radius: var(--radius-lg); font-weight: 600; font-size: 13px;
|
||||
border: none; cursor: pointer; transition: all 0.2s ease;
|
||||
}
|
||||
.btn-primary { background: var(--primary-color); color: #fff; }
|
||||
.btn-primary:hover { background: var(--primary-hover); transform: translateY(-1px); }
|
||||
.btn-secondary { background: #f1f3f5; color: #495057; }
|
||||
.btn-secondary:hover { background: #e9ecef; }
|
||||
.btn-danger { background: #fee2e2; color: #dc2626; }
|
||||
.btn-danger:hover { background: #fecaca; }
|
||||
|
||||
/* Compatibility Utils */
|
||||
._button-xsmall {
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
padding: 4px 10px; font-size: 11px; font-weight: 600; border-radius: 4px; border: 1px solid var(--border-color);
|
||||
background: #fff; color: var(--text-main); cursor: pointer; transition: 0.2s;
|
||||
}
|
||||
._button-xsmall:hover { background: var(--bg-muted); border-color: var(--primary-color); color: var(--primary-color); }
|
||||
|
||||
._button-small {
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
padding: 6px 14px; font-size: 12px; background: var(--primary-color); color: #fff; border-radius: 6px; border: none; cursor: pointer;
|
||||
}
|
||||
._button-medium {
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
padding: 10px 20px; background: var(--primary-color); color: #fff; border-radius: 6px; font-weight: 700; border: none; cursor: pointer;
|
||||
}
|
||||
.sync-btn { background: var(--primary-color); color: #fff; padding: 8px 16px; border-radius: 8px; font-size: 13px; font-weight: 600; }
|
||||
|
||||
/* Badges & Status Colors */
|
||||
.badge {
|
||||
padding: 2px 8px; border-radius: 4px; font-size: 10px; font-weight: 700;
|
||||
display: inline-block; background: var(--primary-lv-1); color: var(--primary-color);
|
||||
}
|
||||
|
||||
.status-complete { background: #e8f5e9; color: #2e7d32; }
|
||||
.status-working { background: #fff8e1; color: #FFBF00; }
|
||||
.status-checking { background: #e3f2fd; color: #1565c0; }
|
||||
.status-pending { background: #f5f5f5; color: #757575; }
|
||||
.status-error { background: #fee9e7; }
|
||||
|
||||
.warning-text { color: #FFBF00; font-weight: 600; }
|
||||
.error-text { color: #F21D0D !important; font-weight: 700; }
|
||||
|
||||
/* Spinner */
|
||||
.spinner {
|
||||
display: none; width: 16px; height: 16px; border: 2px solid rgba(255, 255, 255, .3);
|
||||
border-radius: 50%; border-top-color: #fff; animation: spin 1s ease-in-out infinite;
|
||||
}
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
|
||||
@@ -1,123 +1,123 @@
|
||||
/* Dashboard Constants */
|
||||
:root {
|
||||
--header-h: 56px;
|
||||
--activity-h: 110px;
|
||||
--fixed-total-h: calc(var(--topbar-h) + var(--header-h) + var(--activity-h));
|
||||
}
|
||||
|
||||
/* 1. Portal (Index) */
|
||||
.portal-container {
|
||||
display: flex; flex-direction: column; align-items: center; justify-content: center;
|
||||
height: calc(100vh - var(--topbar-h)); background: var(--bg-muted); padding: var(--space-lg); margin-top: var(--topbar-h);
|
||||
}
|
||||
.portal-header { text-align: center; margin-bottom: 50px; }
|
||||
.portal-header h1 { font-size: 28px; color: var(--primary-color); margin-bottom: 10px; font-weight: 800; }
|
||||
.portal-header p { color: var(--text-sub); font-size: 15px; }
|
||||
|
||||
.button-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 30px; width: 100%; max-width: 800px; }
|
||||
.portal-card {
|
||||
background: #fff; border: 1px solid var(--border-color); border-radius: 12px; padding: 40px;
|
||||
text-align: center; transition: all 0.3s ease; width: 100%; box-shadow: var(--box-shadow);
|
||||
display: flex; flex-direction: column; align-items: center; gap: 20px;
|
||||
}
|
||||
.portal-card:hover { transform: translateY(-8px); border-color: var(--primary-color); box-shadow: var(--box-shadow-lg); }
|
||||
.portal-card i { font-size: 48px; color: var(--primary-color); }
|
||||
.portal-card h3 { font-size: 20px; color: var(--text-main); margin: 0; }
|
||||
.portal-card p { font-size: 14px; color: var(--text-sub); margin: 0; }
|
||||
|
||||
/* 2. Dashboard Header & Activity */
|
||||
header {
|
||||
position: fixed; top: var(--topbar-h); left: 0; right: 0; z-index: 1001;
|
||||
background: #fff; height: var(--header-h); display: flex; justify-content: space-between; align-items: center;
|
||||
padding: 0 var(--space-lg); border-bottom: 1px solid #f5f5f5;
|
||||
}
|
||||
|
||||
.activity-dashboard-wrapper {
|
||||
position: fixed; top: calc(var(--topbar-h) + var(--header-h)); left: 0; right: 0; z-index: 1000;
|
||||
background: #fff; height: var(--activity-h); border-bottom: 1px solid var(--border-color); box-shadow: 0 4px 6px rgba(0,0,0,0.03);
|
||||
}
|
||||
|
||||
.activity-dashboard { max-width: 1200px; margin: 0 auto; height: 100%; display: flex; gap: 15px; padding: 10px 32px 20px; }
|
||||
.activity-card {
|
||||
flex: 1; padding: 12px 15px; border-radius: var(--radius-lg); cursor: pointer;
|
||||
display: flex; flex-direction: column; justify-content: center; gap: 2px; border-left: 5px solid transparent;
|
||||
}
|
||||
.activity-card:hover { transform: translateY(-2px); box-shadow: var(--box-shadow); }
|
||||
.activity-card.active { background: #e8f5e9; }
|
||||
.activity-card.warning { background: #fff8e1; }
|
||||
.activity-card.stale { background: #ffebee; }
|
||||
.activity-card.unknown { background: #f5f5f5; }
|
||||
.activity-card .label { font-size: 11px; font-weight: 600; opacity: 0.7; }
|
||||
.activity-card .count { font-size: 20px; font-weight: 800; }
|
||||
|
||||
.main-content { margin-top: var(--fixed-total-h); padding: var(--space-lg); max-width: 1400px; margin-left: auto; margin-right: auto; }
|
||||
|
||||
/* 3. Log Console */
|
||||
.log-console {
|
||||
position: sticky; top: var(--fixed-total-h); z-index: 999;
|
||||
background: #000; color: #0f0; font-family: 'Consolas', monospace; padding: 15px; margin-bottom: 20px;
|
||||
border-radius: 4px; max-height: 250px; overflow-y: auto; font-size: 12px; line-height: 1.5; box-shadow: 0 10px 20px rgba(0,0,0,0.2);
|
||||
}
|
||||
.log-console-header { color: #fff; border-bottom: 1px solid #333; margin-bottom: 10px; padding-bottom: 5px; font-weight: bold; }
|
||||
|
||||
/* 4. Auth Modal (Page Specific) */
|
||||
.auth-modal-content {
|
||||
background: #fff; width: 440px; border-radius: 16px; padding: 40px; text-align: center;
|
||||
box-shadow: var(--box-shadow-modal); display: flex; flex-direction: column; gap: 32px;
|
||||
}
|
||||
.input-group { display: flex; flex-direction: column; gap: 8px; text-align: left; }
|
||||
.input-group label { font-size: 12px; font-weight: 700; color: var(--text-main); }
|
||||
.input-group input {
|
||||
height: 48px; padding: 0 16px; border: 1px solid var(--border-color); border-radius: 8px;
|
||||
font-size: 14px; background: #f9f9f9; width: 100%;
|
||||
}
|
||||
.input-group input:focus { border-color: var(--primary-color); background: #fff; outline: none; }
|
||||
|
||||
/* 5. Accordion & Data Tables */
|
||||
.accordion-list-header {
|
||||
position: sticky; top: var(--fixed-total-h); background: #fff; z-index: 900;
|
||||
font-size: 11px; font-weight: 700; color: var(--text-sub);
|
||||
padding: 12px 24px; border-bottom: 2px solid var(--primary-color);
|
||||
display: grid; grid-template-columns: 2.5fr 1fr 1fr 0.8fr 2fr; gap: 16px;
|
||||
}
|
||||
|
||||
.accordion-header {
|
||||
display: grid; grid-template-columns: 2.5fr 1fr 1fr 0.8fr 2fr; gap: 16px;
|
||||
padding: 12px 24px; align-items: center; cursor: pointer; border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
.accordion-item:hover .accordion-header { background: var(--primary-lv-0); }
|
||||
.accordion-item.active .accordion-header { background: var(--primary-lv-0); border-bottom: none; }
|
||||
|
||||
.repo-title { font-weight: 700; color: var(--primary-color); @extend .text-truncate; }
|
||||
.repo-files { text-align: center; font-weight: 600; }
|
||||
.repo-log { font-size: 11px; color: var(--text-sub); @extend .text-truncate; }
|
||||
|
||||
.accordion-body { display: none; padding: 24px; background: #fff; border-bottom: 1px solid var(--border-color); }
|
||||
.accordion-item.active .accordion-body { display: block; }
|
||||
|
||||
.detail-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 32px; }
|
||||
.detail-section h4 {
|
||||
font-size: 13px; margin-bottom: 12px; color: var(--text-main);
|
||||
padding-left: 10px; font-weight: 700;
|
||||
}
|
||||
|
||||
/* Personnel & Activity Tables */
|
||||
#personnel-table th:nth-child(1) { width: 25%; }
|
||||
#personnel-table th:nth-child(2) { width: 45%; }
|
||||
#activity-table th:nth-child(1) { width: 20%; }
|
||||
#activity-table th:nth-child(2) { width: 50%; }
|
||||
|
||||
/* Location Groups */
|
||||
.continent-group, .country-group { margin-bottom: 15px; }
|
||||
.continent-header, .country-header {
|
||||
background: #fff; padding: 14px 20px; border: 1px solid var(--border-color); border-radius: 8px;
|
||||
display: flex; justify-content: space-between; align-items: center; cursor: pointer; font-weight: 700;
|
||||
}
|
||||
.continent-header { background: var(--primary-color); color: white; border: none; font-size: 15px; }
|
||||
.country-header { font-size: 14px; color: var(--text-main); margin-top: 8px; }
|
||||
.continent-body, .country-body { display: none; padding: 10px 0 10px 15px; }
|
||||
.active>.continent-body, .active>.country-body { display: block; }
|
||||
|
||||
.admin-info { font-size: 12px; color: var(--text-sub); margin-left: 16px; padding: 6px 12px; background: #f8f9fa; border-radius: 4px; border: 1px solid var(--border-color); }
|
||||
.admin-info strong { color: var(--primary-color); font-weight: 700; }
|
||||
.base-date-info { font-size: 13px; color: var(--text-sub); background: #fdfdfd; padding: 6px 15px; border-radius: 6px; border: 1px solid var(--border-color); }
|
||||
/* Dashboard Constants */
|
||||
:root {
|
||||
--header-h: 56px;
|
||||
--activity-h: 110px;
|
||||
--fixed-total-h: calc(var(--topbar-h) + var(--header-h) + var(--activity-h));
|
||||
}
|
||||
|
||||
/* 1. Portal (Index) */
|
||||
.portal-container {
|
||||
display: flex; flex-direction: column; align-items: center; justify-content: center;
|
||||
height: calc(100vh - var(--topbar-h)); background: var(--bg-muted); padding: var(--space-lg); margin-top: var(--topbar-h);
|
||||
}
|
||||
.portal-header { text-align: center; margin-bottom: 50px; }
|
||||
.portal-header h1 { font-size: 28px; color: var(--primary-color); margin-bottom: 10px; font-weight: 800; }
|
||||
.portal-header p { color: var(--text-sub); font-size: 15px; }
|
||||
|
||||
.button-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 30px; width: 100%; max-width: 800px; }
|
||||
.portal-card {
|
||||
background: #fff; border: 1px solid var(--border-color); border-radius: 12px; padding: 40px;
|
||||
text-align: center; transition: all 0.3s ease; width: 100%; box-shadow: var(--box-shadow);
|
||||
display: flex; flex-direction: column; align-items: center; gap: 20px;
|
||||
}
|
||||
.portal-card:hover { transform: translateY(-8px); border-color: var(--primary-color); box-shadow: var(--box-shadow-lg); }
|
||||
.portal-card i { font-size: 48px; color: var(--primary-color); }
|
||||
.portal-card h3 { font-size: 20px; color: var(--text-main); margin: 0; }
|
||||
.portal-card p { font-size: 14px; color: var(--text-sub); margin: 0; }
|
||||
|
||||
/* 2. Dashboard Header & Activity */
|
||||
header {
|
||||
position: fixed; top: var(--topbar-h); left: 0; right: 0; z-index: 1001;
|
||||
background: #fff; height: var(--header-h); display: flex; justify-content: space-between; align-items: center;
|
||||
padding: 0 var(--space-lg); border-bottom: 1px solid #f5f5f5;
|
||||
}
|
||||
|
||||
.activity-dashboard-wrapper {
|
||||
position: fixed; top: calc(var(--topbar-h) + var(--header-h)); left: 0; right: 0; z-index: 1000;
|
||||
background: #fff; height: var(--activity-h); border-bottom: 1px solid var(--border-color); box-shadow: 0 4px 6px rgba(0,0,0,0.03);
|
||||
}
|
||||
|
||||
.activity-dashboard { max-width: 1200px; margin: 0 auto; height: 100%; display: flex; gap: 15px; padding: 10px 32px 20px; }
|
||||
.activity-card {
|
||||
flex: 1; padding: 12px 15px; border-radius: var(--radius-lg); cursor: pointer;
|
||||
display: flex; flex-direction: column; justify-content: center; gap: 2px; border-left: 5px solid transparent;
|
||||
}
|
||||
.activity-card:hover { transform: translateY(-2px); box-shadow: var(--box-shadow); }
|
||||
.activity-card.active { background: #e8f5e9; }
|
||||
.activity-card.warning { background: #fff8e1; }
|
||||
.activity-card.stale { background: #ffebee; }
|
||||
.activity-card.unknown { background: #f5f5f5; }
|
||||
.activity-card .label { font-size: 11px; font-weight: 600; opacity: 0.7; }
|
||||
.activity-card .count { font-size: 20px; font-weight: 800; }
|
||||
|
||||
.main-content { margin-top: var(--fixed-total-h); padding: var(--space-lg); max-width: 1400px; margin-left: auto; margin-right: auto; }
|
||||
|
||||
/* 3. Log Console */
|
||||
.log-console {
|
||||
position: sticky; top: var(--fixed-total-h); z-index: 999;
|
||||
background: #000; color: #0f0; font-family: 'Consolas', monospace; padding: 15px; margin-bottom: 20px;
|
||||
border-radius: 4px; max-height: 250px; overflow-y: auto; font-size: 12px; line-height: 1.5; box-shadow: 0 10px 20px rgba(0,0,0,0.2);
|
||||
}
|
||||
.log-console-header { color: #fff; border-bottom: 1px solid #333; margin-bottom: 10px; padding-bottom: 5px; font-weight: bold; }
|
||||
|
||||
/* 4. Auth Modal (Page Specific) */
|
||||
.auth-modal-content {
|
||||
background: #fff; width: 440px; border-radius: 16px; padding: 40px; text-align: center;
|
||||
box-shadow: var(--box-shadow-modal); display: flex; flex-direction: column; gap: 32px;
|
||||
}
|
||||
.input-group { display: flex; flex-direction: column; gap: 8px; text-align: left; }
|
||||
.input-group label { font-size: 12px; font-weight: 700; color: var(--text-main); }
|
||||
.input-group input {
|
||||
height: 48px; padding: 0 16px; border: 1px solid var(--border-color); border-radius: 8px;
|
||||
font-size: 14px; background: #f9f9f9; width: 100%;
|
||||
}
|
||||
.input-group input:focus { border-color: var(--primary-color); background: #fff; outline: none; }
|
||||
|
||||
/* 5. Accordion & Data Tables */
|
||||
.accordion-list-header {
|
||||
position: sticky; top: var(--fixed-total-h); background: #fff; z-index: 900;
|
||||
font-size: 11px; font-weight: 700; color: var(--text-sub);
|
||||
padding: 12px 24px; border-bottom: 2px solid var(--primary-color);
|
||||
display: grid; grid-template-columns: 2.5fr 1fr 1fr 0.8fr 2fr; gap: 16px;
|
||||
}
|
||||
|
||||
.accordion-header {
|
||||
display: grid; grid-template-columns: 2.5fr 1fr 1fr 0.8fr 2fr; gap: 16px;
|
||||
padding: 12px 24px; align-items: center; cursor: pointer; border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
.accordion-item:hover .accordion-header { background: var(--primary-lv-0); }
|
||||
.accordion-item.active .accordion-header { background: var(--primary-lv-0); border-bottom: none; }
|
||||
|
||||
.repo-title { font-weight: 700; color: var(--primary-color); @extend .text-truncate; }
|
||||
.repo-files { text-align: center; font-weight: 600; }
|
||||
.repo-log { font-size: 11px; color: var(--text-sub); @extend .text-truncate; }
|
||||
|
||||
.accordion-body { display: none; padding: 24px; background: #fff; border-bottom: 1px solid var(--border-color); }
|
||||
.accordion-item.active .accordion-body { display: block; }
|
||||
|
||||
.detail-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 32px; }
|
||||
.detail-section h4 {
|
||||
font-size: 13px; margin-bottom: 12px; color: var(--text-main);
|
||||
padding-left: 10px; font-weight: 700;
|
||||
}
|
||||
|
||||
/* Personnel & Activity Tables */
|
||||
#personnel-table th:nth-child(1) { width: 25%; }
|
||||
#personnel-table th:nth-child(2) { width: 45%; }
|
||||
#activity-table th:nth-child(1) { width: 20%; }
|
||||
#activity-table th:nth-child(2) { width: 50%; }
|
||||
|
||||
/* Location Groups */
|
||||
.continent-group, .country-group { margin-bottom: 15px; }
|
||||
.continent-header, .country-header {
|
||||
background: #fff; padding: 14px 20px; border: 1px solid var(--border-color); border-radius: 8px;
|
||||
display: flex; justify-content: space-between; align-items: center; cursor: pointer; font-weight: 700;
|
||||
}
|
||||
.continent-header { background: var(--primary-color); color: white; border: none; font-size: 15px; }
|
||||
.country-header { font-size: 14px; color: var(--text-main); margin-top: 8px; }
|
||||
.continent-body, .country-body { display: none; padding: 10px 0 10px 15px; }
|
||||
.active>.continent-body, .active>.country-body { display: block; }
|
||||
|
||||
.admin-info { font-size: 12px; color: var(--text-sub); margin-left: 16px; padding: 6px 12px; background: #f8f9fa; border-radius: 4px; border: 1px solid var(--border-color); }
|
||||
.admin-info strong { color: var(--primary-color); font-weight: 700; }
|
||||
.base-date-info { font-size: 13px; color: var(--text-sub); background: #fdfdfd; padding: 6px 15px; border-radius: 6px; border: 1px solid var(--border-color); }
|
||||
|
||||
@@ -1,251 +1,251 @@
|
||||
/* 1. Layout & Board Structure */
|
||||
.inquiry-board {
|
||||
padding: 0 20px 32px 20px;
|
||||
max-width: 98%;
|
||||
margin: 36px auto 0;
|
||||
}
|
||||
|
||||
.board-sticky-header {
|
||||
position: sticky;
|
||||
top: 36px;
|
||||
background: #fff;
|
||||
z-index: 1000;
|
||||
padding: 15px 0 10px;
|
||||
border-bottom: 1px solid #eee;
|
||||
}
|
||||
|
||||
.board-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-end;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
/* 2. Stats Dashboard */
|
||||
.header-stats {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
background: #fff;
|
||||
border: 1px solid #eee;
|
||||
padding: 8px 16px;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
min-width: 80px;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.02);
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.stat-item:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 8px rgba(0,0,0,0.05);
|
||||
}
|
||||
|
||||
.stat-label { font-size: 11px; font-weight: 600; color: #888; margin-bottom: 2px; }
|
||||
.stat-value { font-size: 18px; font-weight: 700; color: #333; }
|
||||
|
||||
/* Status Border Colors */
|
||||
.stat-item.total { }
|
||||
.stat-item.total .stat-value { color: #1e5149; }
|
||||
.stat-item.complete { }
|
||||
.stat-item.complete .stat-value { color: #2e7d32; }
|
||||
.stat-item.working { }
|
||||
.stat-item.working .stat-value { color: #1565c0; }
|
||||
.stat-item.checking { }
|
||||
.stat-item.checking .stat-value { color: #ef6c00; }
|
||||
.stat-item.pending { }
|
||||
.stat-item.pending .stat-value { color: #673ab7; }
|
||||
.stat-item.unconfirmed { }
|
||||
.stat-item.unconfirmed .stat-value { color: #9e9e9e; }
|
||||
|
||||
/* 3. Filters & Notice */
|
||||
.notice-container {
|
||||
background: #fdfdfd;
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #e0e0e0;
|
||||
margin-bottom: 24px;
|
||||
box-shadow: 0 2px 5px rgba(0,0,0,0.02);
|
||||
}
|
||||
|
||||
.filter-section {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
background: #f8f9fa;
|
||||
padding: 12px 16px;
|
||||
border-radius: 8px;
|
||||
margin-top: 15px;
|
||||
}
|
||||
|
||||
.filter-group { display: flex; flex-direction: column; gap: 4px; }
|
||||
.filter-group label { font-size: 12px; font-weight: 600; color: #666; }
|
||||
.filter-group select, .filter-group input { padding: 8px 12px; border: 1px solid #ddd; border-radius: 4px; font-size: 14px; }
|
||||
|
||||
/* 4. Table Styles */
|
||||
.inquiry-table {
|
||||
width: 100%;
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
|
||||
border-collapse: separate;
|
||||
border-spacing: 0;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.inquiry-table thead th {
|
||||
position: sticky;
|
||||
background: #f8f9fa;
|
||||
padding: 14px 16px;
|
||||
text-align: left;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: #333;
|
||||
border-bottom: 2px solid #eee;
|
||||
z-index: 900;
|
||||
}
|
||||
|
||||
/* 정렬 가능한 헤더 스타일 추가 */
|
||||
.inquiry-table thead th.sortable {
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
transition: background 0.2s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.inquiry-table thead th.sortable .header-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.sort-icon {
|
||||
display: inline-flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
font-size: 8px;
|
||||
color: #ccc;
|
||||
line-height: 1;
|
||||
margin-left: 2px;
|
||||
}
|
||||
|
||||
.inquiry-table thead th.active-sort {
|
||||
color: #1e5149;
|
||||
background: #f0f7f6;
|
||||
}
|
||||
|
||||
.inquiry-table thead th.active-sort .sort-icon {
|
||||
color: #1e5149;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.inquiry-table td {
|
||||
padding: 14px 16px;
|
||||
font-size: 13px;
|
||||
border-bottom: 1px solid #eee;
|
||||
vertical-align: middle;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* Table Row Hover & Active State */
|
||||
.inquiry-row:hover { background: #fcfcfc; cursor: pointer; }
|
||||
.inquiry-row.active-row { background-color: #f0f7f6 !important; }
|
||||
.inquiry-row.active-row td { font-weight: 600; color: #1e5149; border-bottom-color: transparent; }
|
||||
|
||||
/* Status Badges */
|
||||
.status-badge { padding: 4px 10px; border-radius: 20px; font-size: 11px; font-weight: 700; display: inline-block; }
|
||||
.status-complete { background: #e8f5e9; color: #2e7d32; }
|
||||
.status-working { background: #e3f2fd; color: #1565c0; }
|
||||
.status-checking { background: #fff3e0; color: #ef6c00; }
|
||||
.status-pending { background: #f5f5f5; color: #616161; }
|
||||
|
||||
/* Table Columns Width & Truncation */
|
||||
.inquiry-table td:nth-child(1) { max-width: 50px; } /* No */
|
||||
.inquiry-table td:nth-child(2) { max-width: 80px; text-align: center; } /* Image */
|
||||
.inquiry-table td:nth-child(3) { max-width: 120px; } /* PM Type */
|
||||
.inquiry-table td:nth-child(4) { max-width: 100px; } /* Env */
|
||||
.inquiry-table td:nth-child(5) { max-width: 150px; } /* Category */
|
||||
.inquiry-table td:nth-child(6) { max-width: 200px; } /* Project */
|
||||
.inquiry-table td:nth-child(7), .inquiry-table td:nth-child(8) { max-width: 400px; } /* Content & Reply */
|
||||
.inquiry-table td:nth-child(9) { max-width: 100px; } /* Author */
|
||||
.inquiry-table td:nth-child(10) { max-width: 120px; } /* Date */
|
||||
.inquiry-table td:nth-child(11) { max-width: 100px; } /* Status */
|
||||
|
||||
/* 5. Detail (Accordion) Styles */
|
||||
.detail-row { display: none; background: #fdfdfd; }
|
||||
.detail-row.active { display: table-row; }
|
||||
.detail-row td { max-width: none; white-space: normal; overflow: visible; }
|
||||
|
||||
.detail-container {
|
||||
padding: 24px;
|
||||
background: #f9fafb;
|
||||
box-shadow: inset 0 4px 15px rgba(0,0,0,0.08);
|
||||
position: relative;
|
||||
border-bottom: 2px solid #eee;
|
||||
}
|
||||
|
||||
.detail-content-wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
border: 1px solid #e5e7eb;
|
||||
background: #fff;
|
||||
padding: 25px;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 4px 10px rgba(0,0,0,0.03);
|
||||
}
|
||||
|
||||
.btn-close-accordion {
|
||||
position: absolute;
|
||||
top: 35px;
|
||||
right: 45px;
|
||||
background: #eee;
|
||||
border: none;
|
||||
padding: 6px 14px;
|
||||
border-radius: 20px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #666;
|
||||
cursor: pointer;
|
||||
z-index: 10;
|
||||
}
|
||||
.btn-close-accordion::after { content: "▲"; font-size: 10px; margin-left: 5px; }
|
||||
|
||||
.detail-meta-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 15px; margin-bottom: 15px; font-size: 13px; color: #666; }
|
||||
.detail-label { font-weight: 700; color: #888; margin-right: 8px; }
|
||||
|
||||
.detail-q-section { background: #f8f9fa; padding: 20px; border-radius: 8px; }
|
||||
.detail-a-section { background: #f1f8f7; padding: 20px; border-radius: 8px; }
|
||||
|
||||
/* 6. Image Preview & Foldable Section */
|
||||
.img-thumbnail { width: 32px; height: 32px; border-radius: 4px; object-fit: cover; border: 1px solid #ddd; cursor: pointer; transition: transform 0.2s; }
|
||||
.img-thumbnail:hover { transform: scale(1.1); }
|
||||
.no-img { font-size: 10px; color: #ccc; font-style: italic; }
|
||||
|
||||
.detail-image-section { margin-bottom: 20px; background: #f9fafb; border-radius: 8px; border: 1px solid #e5e7eb; overflow: hidden; }
|
||||
.image-section-header { padding: 12px 16px; background: #f1f5f9; display: flex; justify-content: space-between; align-items: center; cursor: pointer; }
|
||||
.image-section-header:hover { background: #e2e8f0; }
|
||||
.image-section-header h4 { margin: 0; color: #1e5149; display: flex; align-items: center; gap: 8px; }
|
||||
.image-section-content { padding: 20px; display: flex; justify-content: center; background: #fff; border-top: 1px solid #eee; }
|
||||
.image-section-content.collapsed { display: none; }
|
||||
.toggle-icon { font-size: 12px; color: #64748b; transition: transform 0.3s; }
|
||||
.detail-image-section.active .toggle-icon { transform: rotate(180deg); }
|
||||
|
||||
.preview-img { max-width: 100%; max-height: 400px; border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.1); object-fit: contain; }
|
||||
|
||||
/* 7. Forms & Reply */
|
||||
.reply-edit-form textarea {
|
||||
width: 100%; height: 120px; padding: 12px; border: 1px solid #ddd; border-radius: 6px;
|
||||
font-family: inherit; font-size: 14px; margin-bottom: 15px; resize: none; background: #fff;
|
||||
}
|
||||
.reply-edit-form textarea:disabled, .reply-edit-form select:disabled, .reply-edit-form input:disabled { background: #fcfcfc; color: #666; border-color: #eee; }
|
||||
.reply-edit-form.readonly .btn-save, .reply-edit-form.readonly .btn-delete, .reply-edit-form.readonly .btn-cancel { display: none; }
|
||||
.reply-edit-form.editable .btn-edit { display: none; }
|
||||
.reply-edit-form.editable textarea { border-color: #1e5149; box-shadow: 0 0 0 2px rgba(30, 81, 73, 0.1); }
|
||||
/* 1. Layout & Board Structure */
|
||||
.inquiry-board {
|
||||
padding: 0 20px 32px 20px;
|
||||
max-width: 98%;
|
||||
margin: 36px auto 0;
|
||||
}
|
||||
|
||||
.board-sticky-header {
|
||||
position: sticky;
|
||||
top: 36px;
|
||||
background: #fff;
|
||||
z-index: 1000;
|
||||
padding: 15px 0 10px;
|
||||
border-bottom: 1px solid #eee;
|
||||
}
|
||||
|
||||
.board-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-end;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
/* 2. Stats Dashboard */
|
||||
.header-stats {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
background: #fff;
|
||||
border: 1px solid #eee;
|
||||
padding: 8px 16px;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
min-width: 80px;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.02);
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.stat-item:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 8px rgba(0,0,0,0.05);
|
||||
}
|
||||
|
||||
.stat-label { font-size: 11px; font-weight: 600; color: #888; margin-bottom: 2px; }
|
||||
.stat-value { font-size: 18px; font-weight: 700; color: #333; }
|
||||
|
||||
/* Status Border Colors */
|
||||
.stat-item.total { }
|
||||
.stat-item.total .stat-value { color: #1e5149; }
|
||||
.stat-item.complete { }
|
||||
.stat-item.complete .stat-value { color: #2e7d32; }
|
||||
.stat-item.working { }
|
||||
.stat-item.working .stat-value { color: #1565c0; }
|
||||
.stat-item.checking { }
|
||||
.stat-item.checking .stat-value { color: #ef6c00; }
|
||||
.stat-item.pending { }
|
||||
.stat-item.pending .stat-value { color: #673ab7; }
|
||||
.stat-item.unconfirmed { }
|
||||
.stat-item.unconfirmed .stat-value { color: #9e9e9e; }
|
||||
|
||||
/* 3. Filters & Notice */
|
||||
.notice-container {
|
||||
background: #fdfdfd;
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #e0e0e0;
|
||||
margin-bottom: 24px;
|
||||
box-shadow: 0 2px 5px rgba(0,0,0,0.02);
|
||||
}
|
||||
|
||||
.filter-section {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
background: #f8f9fa;
|
||||
padding: 12px 16px;
|
||||
border-radius: 8px;
|
||||
margin-top: 15px;
|
||||
}
|
||||
|
||||
.filter-group { display: flex; flex-direction: column; gap: 4px; }
|
||||
.filter-group label { font-size: 12px; font-weight: 600; color: #666; }
|
||||
.filter-group select, .filter-group input { padding: 8px 12px; border: 1px solid #ddd; border-radius: 4px; font-size: 14px; }
|
||||
|
||||
/* 4. Table Styles */
|
||||
.inquiry-table {
|
||||
width: 100%;
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
|
||||
border-collapse: separate;
|
||||
border-spacing: 0;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.inquiry-table thead th {
|
||||
position: sticky;
|
||||
background: #f8f9fa;
|
||||
padding: 14px 16px;
|
||||
text-align: left;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: #333;
|
||||
border-bottom: 2px solid #eee;
|
||||
z-index: 900;
|
||||
}
|
||||
|
||||
/* 정렬 가능한 헤더 스타일 추가 */
|
||||
.inquiry-table thead th.sortable {
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
transition: background 0.2s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.inquiry-table thead th.sortable .header-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.sort-icon {
|
||||
display: inline-flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
font-size: 8px;
|
||||
color: #ccc;
|
||||
line-height: 1;
|
||||
margin-left: 2px;
|
||||
}
|
||||
|
||||
.inquiry-table thead th.active-sort {
|
||||
color: #1e5149;
|
||||
background: #f0f7f6;
|
||||
}
|
||||
|
||||
.inquiry-table thead th.active-sort .sort-icon {
|
||||
color: #1e5149;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.inquiry-table td {
|
||||
padding: 14px 16px;
|
||||
font-size: 13px;
|
||||
border-bottom: 1px solid #eee;
|
||||
vertical-align: middle;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* Table Row Hover & Active State */
|
||||
.inquiry-row:hover { background: #fcfcfc; cursor: pointer; }
|
||||
.inquiry-row.active-row { background-color: #f0f7f6 !important; }
|
||||
.inquiry-row.active-row td { font-weight: 600; color: #1e5149; border-bottom-color: transparent; }
|
||||
|
||||
/* Status Badges */
|
||||
.status-badge { padding: 4px 10px; border-radius: 20px; font-size: 11px; font-weight: 700; display: inline-block; }
|
||||
.status-complete { background: #e8f5e9; color: #2e7d32; }
|
||||
.status-working { background: #e3f2fd; color: #1565c0; }
|
||||
.status-checking { background: #fff3e0; color: #ef6c00; }
|
||||
.status-pending { background: #f5f5f5; color: #616161; }
|
||||
|
||||
/* Table Columns Width & Truncation */
|
||||
.inquiry-table td:nth-child(1) { max-width: 50px; } /* No */
|
||||
.inquiry-table td:nth-child(2) { max-width: 80px; text-align: center; } /* Image */
|
||||
.inquiry-table td:nth-child(3) { max-width: 120px; } /* PM Type */
|
||||
.inquiry-table td:nth-child(4) { max-width: 100px; } /* Env */
|
||||
.inquiry-table td:nth-child(5) { max-width: 150px; } /* Category */
|
||||
.inquiry-table td:nth-child(6) { max-width: 200px; } /* Project */
|
||||
.inquiry-table td:nth-child(7), .inquiry-table td:nth-child(8) { max-width: 400px; } /* Content & Reply */
|
||||
.inquiry-table td:nth-child(9) { max-width: 100px; } /* Author */
|
||||
.inquiry-table td:nth-child(10) { max-width: 120px; } /* Date */
|
||||
.inquiry-table td:nth-child(11) { max-width: 100px; } /* Status */
|
||||
|
||||
/* 5. Detail (Accordion) Styles */
|
||||
.detail-row { display: none; background: #fdfdfd; }
|
||||
.detail-row.active { display: table-row; }
|
||||
.detail-row td { max-width: none; white-space: normal; overflow: visible; }
|
||||
|
||||
.detail-container {
|
||||
padding: 24px;
|
||||
background: #f9fafb;
|
||||
box-shadow: inset 0 4px 15px rgba(0,0,0,0.08);
|
||||
position: relative;
|
||||
border-bottom: 2px solid #eee;
|
||||
}
|
||||
|
||||
.detail-content-wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
border: 1px solid #e5e7eb;
|
||||
background: #fff;
|
||||
padding: 25px;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 4px 10px rgba(0,0,0,0.03);
|
||||
}
|
||||
|
||||
.btn-close-accordion {
|
||||
position: absolute;
|
||||
top: 35px;
|
||||
right: 45px;
|
||||
background: #eee;
|
||||
border: none;
|
||||
padding: 6px 14px;
|
||||
border-radius: 20px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #666;
|
||||
cursor: pointer;
|
||||
z-index: 10;
|
||||
}
|
||||
.btn-close-accordion::after { content: "▲"; font-size: 10px; margin-left: 5px; }
|
||||
|
||||
.detail-meta-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 15px; margin-bottom: 15px; font-size: 13px; color: #666; }
|
||||
.detail-label { font-weight: 700; color: #888; margin-right: 8px; }
|
||||
|
||||
.detail-q-section { background: #f8f9fa; padding: 20px; border-radius: 8px; }
|
||||
.detail-a-section { background: #f1f8f7; padding: 20px; border-radius: 8px; }
|
||||
|
||||
/* 6. Image Preview & Foldable Section */
|
||||
.img-thumbnail { width: 32px; height: 32px; border-radius: 4px; object-fit: cover; border: 1px solid #ddd; cursor: pointer; transition: transform 0.2s; }
|
||||
.img-thumbnail:hover { transform: scale(1.1); }
|
||||
.no-img { font-size: 10px; color: #ccc; font-style: italic; }
|
||||
|
||||
.detail-image-section { margin-bottom: 20px; background: #f9fafb; border-radius: 8px; border: 1px solid #e5e7eb; overflow: hidden; }
|
||||
.image-section-header { padding: 12px 16px; background: #f1f5f9; display: flex; justify-content: space-between; align-items: center; cursor: pointer; }
|
||||
.image-section-header:hover { background: #e2e8f0; }
|
||||
.image-section-header h4 { margin: 0; color: #1e5149; display: flex; align-items: center; gap: 8px; }
|
||||
.image-section-content { padding: 20px; display: flex; justify-content: center; background: #fff; border-top: 1px solid #eee; }
|
||||
.image-section-content.collapsed { display: none; }
|
||||
.toggle-icon { font-size: 12px; color: #64748b; transition: transform 0.3s; }
|
||||
.detail-image-section.active .toggle-icon { transform: rotate(180deg); }
|
||||
|
||||
.preview-img { max-width: 100%; max-height: 400px; border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.1); object-fit: contain; }
|
||||
|
||||
/* 7. Forms & Reply */
|
||||
.reply-edit-form textarea {
|
||||
width: 100%; height: 120px; padding: 12px; border: 1px solid #ddd; border-radius: 6px;
|
||||
font-family: inherit; font-size: 14px; margin-bottom: 15px; resize: none; background: #fff;
|
||||
}
|
||||
.reply-edit-form textarea:disabled, .reply-edit-form select:disabled, .reply-edit-form input:disabled { background: #fcfcfc; color: #666; border-color: #eee; }
|
||||
.reply-edit-form.readonly .btn-save, .reply-edit-form.readonly .btn-delete, .reply-edit-form.readonly .btn-cancel { display: none; }
|
||||
.reply-edit-form.editable .btn-edit { display: none; }
|
||||
.reply-edit-form.editable textarea { border-color: #1e5149; box-shadow: 0 0 0 2px rgba(30, 81, 73, 0.1); }
|
||||
|
||||
438
style/mail.css
438
style/mail.css
@@ -1,219 +1,219 @@
|
||||
/* Mail Manager Layout (Vertical Split) */
|
||||
.mail-wrapper {
|
||||
display: flex; height: calc(100vh - var(--topbar-h));
|
||||
margin-top: var(--topbar-h); background: #fff; overflow: hidden;
|
||||
}
|
||||
|
||||
.mail-list-area {
|
||||
width: 400px; border-right: 1px solid var(--border-color);
|
||||
display: flex; flex-direction: column; height: 100%; background: #fff; position: relative;
|
||||
}
|
||||
|
||||
/* 1. Tabs & Search */
|
||||
.mail-tabs { display: flex; border-bottom: 1px solid var(--border-color); background: #f8f9fa; flex-shrink: 0; }
|
||||
.mail-tab {
|
||||
flex: 1; padding: 12px 0; text-align: center; cursor: pointer;
|
||||
font-weight: 700; color: #a0aec0; font-size: 11px; transition: all 0.2s ease;
|
||||
border-bottom: 2px solid transparent; display: flex; align-items: center; justify-content: center; gap: 6px;
|
||||
}
|
||||
.mail-tab:hover { background: #edf2f7; color: var(--primary-color); }
|
||||
.mail-tab.active { color: var(--primary-color); border-bottom: 2px solid var(--primary-color); background: #fff; }
|
||||
|
||||
.search-bar { padding: 16px 24px; border-bottom: 1px solid var(--border-color); background: #fff; flex-shrink: 0; }
|
||||
|
||||
.mail-bulk-actions {
|
||||
display: none; padding: 8px 16px; background: #f7fafc;
|
||||
border-bottom: 1px solid var(--border-color); align-items: center; justify-content: space-between; font-size: 12px;
|
||||
}
|
||||
.mail-bulk-actions.active { display: flex; }
|
||||
|
||||
/* 2. Mail Items */
|
||||
.mail-items-container { flex: 1; overflow-y: auto; padding-bottom: 60px; }
|
||||
.mail-item {
|
||||
padding: 16px; border-bottom: 1px solid var(--border-color); cursor: pointer;
|
||||
display: flex; align-items: flex-start; transition: 0.2s;
|
||||
}
|
||||
.mail-item:hover { background: var(--bg-muted); }
|
||||
.mail-item.active { background: var(--primary-lv-0); }
|
||||
|
||||
.mail-item-checkbox { width: 16px; height: 16px; cursor: pointer; margin-right: 12px; margin-top: 2px; }
|
||||
.mail-item-content { flex: 1; min-width: 0; }
|
||||
.mail-item-info { display: flex; align-items: center; gap: 12px; margin-bottom: 4px; }
|
||||
.mail-date { font-size: 11px; color: var(--text-sub); white-space: nowrap; }
|
||||
|
||||
.btn-mail-delete {
|
||||
background: #f7fafc; border: 1px solid var(--border-color); color: #718096;
|
||||
font-size: 10px; padding: 2px 8px; border-radius: 4px; font-weight: 600;
|
||||
}
|
||||
.btn-mail-delete:hover { color: var(--error-color); background: #fff5f5; border-color: #feb2b2; }
|
||||
|
||||
/* 3. Content Area */
|
||||
.mail-content-area { flex: 1; display: flex; flex-direction: column; overflow-y: auto; border-right: 1px solid var(--border-color); }
|
||||
.mail-content-header { padding: var(--space-lg); border-bottom: 1px solid var(--border-color); }
|
||||
.mail-body { padding: var(--space-lg); line-height: 1.6; min-height: 200px; }
|
||||
|
||||
/* 4. Attachments & AI */
|
||||
.attachment-area { padding: var(--space-lg); border-top: 1px solid var(--border-color); background: var(--bg-muted); }
|
||||
.attachment-item {
|
||||
display: flex; align-items: center; gap: var(--space-md); background: #fff;
|
||||
padding: 12px 20px; border-radius: var(--radius-lg);
|
||||
border: 1px solid var(--border-color); margin-bottom: var(--space-sm); cursor: pointer;
|
||||
transition: 0.2s;
|
||||
}
|
||||
.attachment-item:hover { border-color: var(--primary-color); box-shadow: var(--box-shadow); }
|
||||
.attachment-item.active { background: var(--primary-lv-0); border-color: var(--primary-color); }
|
||||
|
||||
.file-details { flex: 1; min-width: 0; }
|
||||
.file-name { font-size: 13px; font-weight: 700; max-width: 450px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.file-size { font-size: 11px; color: var(--text-sub); }
|
||||
|
||||
.btn-group {
|
||||
display: flex; align-items: center; gap: 12px; flex-shrink: 0; justify-content: flex-end;
|
||||
}
|
||||
|
||||
.btn-upload {
|
||||
padding: 6px 14px; border-radius: 6px; font-size: 11px; font-weight: 700;
|
||||
color: #fff; border: none; cursor: pointer; transition: 0.2s; height: 32px;
|
||||
}
|
||||
|
||||
.btn-ai { background: var(--ai-gradient); }
|
||||
.btn-ai:hover { filter: brightness(1.1); transform: translateY(-1px); }
|
||||
|
||||
.btn-normal { background: var(--primary-color); }
|
||||
.btn-normal:hover { background: var(--primary-hover); transform: translateY(-1px); }
|
||||
|
||||
.ai-recommend {
|
||||
font-size: 11px; padding: 6px 12px; border-radius: 6px; font-weight: 600;
|
||||
cursor: pointer; transition: 0.2s; display: inline-block;
|
||||
max-width: 250px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
}
|
||||
.ai-recommend.smart-mode { background: #eef2ff; color: #4338ca; border: 1px solid #c7d2fe; }
|
||||
.ai-recommend.manual-mode { background: #f1f5f9; color: #475569; border: 1px dashed #cbd5e1; }
|
||||
.ai-recommend:hover { transform: scale(1.02); }
|
||||
|
||||
/* 5. Preview Area */
|
||||
.mail-preview-area {
|
||||
width: 0; background: #f8f9fa; display: flex; flex-direction: column;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); position: relative;
|
||||
border-left: 0 solid transparent;
|
||||
}
|
||||
|
||||
.mail-preview-area.active {
|
||||
width: 600px;
|
||||
border-left: 1px solid var(--border-color);
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
.preview-header {
|
||||
height: 56px; padding: 0 24px; border-bottom: 1px solid var(--border-color);
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
background: #fff; flex-shrink: 0;
|
||||
}
|
||||
|
||||
.preview-header h3 { font-size: 15px; font-weight: 800; color: var(--primary-color); margin: 0; }
|
||||
|
||||
#fullViewBtn {
|
||||
background: var(--primary-lv-0) !important;
|
||||
color: var(--primary-color) !important;
|
||||
border: 1px solid var(--primary-lv-1) !important;
|
||||
font-weight: 700 !important;
|
||||
padding: 4px 16px !important;
|
||||
border-radius: 4px !important;
|
||||
font-size: 11px !important;
|
||||
transition: 0.2s !important;
|
||||
}
|
||||
#fullViewBtn:hover { background: var(--primary-lv-1) !important; }
|
||||
|
||||
.preview-toggle-handle {
|
||||
position: absolute; left: -20px; top: 50%; transform: translateY(-50%);
|
||||
width: 20px; height: 60px; background: var(--primary-color); color: #fff;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
border-radius: 8px 0 0 8px; font-size: 10px; cursor: pointer;
|
||||
box-shadow: -2px 0 5px rgba(0,0,0,0.1); z-index: 100;
|
||||
}
|
||||
.preview-toggle-handle:hover { background: var(--primary-hover); }
|
||||
|
||||
.a4-container {
|
||||
flex: 1; padding: 30px; overflow-y: auto; background: #e9ecef;
|
||||
display: flex; justify-content: center;
|
||||
}
|
||||
|
||||
.a4-container iframe, .a4-container .preview-placeholder {
|
||||
width: 100%; height: 100%; background: #fff;
|
||||
box-shadow: 0 4px 20px rgba(0,0,0,0.08); border-radius: 4px;
|
||||
}
|
||||
|
||||
.preview-placeholder {
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
text-align: center; color: var(--text-sub); font-size: 13px; line-height: 1.6;
|
||||
}
|
||||
|
||||
.mail-preview-area.active > * { opacity: 1 !important; visibility: visible !important; pointer-events: auto !important; }
|
||||
.mail-preview-area > *:not(.preview-toggle-handle) { opacity: 0; visibility: hidden; pointer-events: none; transition: 0.2s; }
|
||||
|
||||
/* 6. Footer & Others */
|
||||
.address-book-footer {
|
||||
position: absolute; bottom: 0; left: 0; width: 100%; padding: 12px 16px;
|
||||
border-top: 1px solid var(--border-color); background: #fff; display: flex; gap: 8px; z-index: 5;
|
||||
}
|
||||
|
||||
.file-log-area {
|
||||
display: none; width: 100%; margin-top: 10px; background: #1a202c;
|
||||
border-radius: 4px; padding: 12px; font-family: monospace; font-size: 11px; color: #cbd5e0;
|
||||
}
|
||||
.file-log-area.active { display: block; }
|
||||
.log-success { color: #48bb78; font-weight: 700; }
|
||||
|
||||
.switch { position: relative; display: inline-block; width: 34px; height: 20px; }
|
||||
.switch input { opacity: 0; width: 0; height: 0; }
|
||||
.slider {
|
||||
position: absolute; cursor: pointer; top: 0; left: 0; right: 0; bottom: 0;
|
||||
background-color: #ccc; transition: .4s; border-radius: 20px;
|
||||
}
|
||||
.slider:before {
|
||||
position: absolute; content: ""; height: 14px; width: 14px; left: 3px; bottom: 3px;
|
||||
background-color: white; transition: .4s; border-radius: 50%;
|
||||
}
|
||||
input:checked+.slider { background: var(--ai-gradient); }
|
||||
input:checked+.slider:before { transform: translateX(14px); }
|
||||
|
||||
/* Restore Path Selector Modal Specific Styles */
|
||||
.select-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.select-group label {
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: var(--text-main);
|
||||
}
|
||||
|
||||
.modal-select {
|
||||
width: 100%;
|
||||
height: 44px;
|
||||
padding: 0 15px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
background-color: #f9f9f9;
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
outline: none;
|
||||
transition: all 0.2s;
|
||||
cursor: pointer;
|
||||
appearance: none;
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%23666' d='M6 8L2 4h8L6 8z'/%3E%3C/svg%3E");
|
||||
background-repeat: no-repeat;
|
||||
background-position: right 15px center;
|
||||
}
|
||||
|
||||
.modal-select:focus {
|
||||
border-color: var(--primary-color);
|
||||
background-color: #fff;
|
||||
box-shadow: 0 0 0 3px rgba(30, 81, 73, 0.1);
|
||||
}
|
||||
|
||||
.modal-select option {
|
||||
padding: 10px;
|
||||
}
|
||||
/* Mail Manager Layout (Vertical Split) */
|
||||
.mail-wrapper {
|
||||
display: flex; height: calc(100vh - var(--topbar-h));
|
||||
margin-top: var(--topbar-h); background: #fff; overflow: hidden;
|
||||
}
|
||||
|
||||
.mail-list-area {
|
||||
width: 400px; border-right: 1px solid var(--border-color);
|
||||
display: flex; flex-direction: column; height: 100%; background: #fff; position: relative;
|
||||
}
|
||||
|
||||
/* 1. Tabs & Search */
|
||||
.mail-tabs { display: flex; border-bottom: 1px solid var(--border-color); background: #f8f9fa; flex-shrink: 0; }
|
||||
.mail-tab {
|
||||
flex: 1; padding: 12px 0; text-align: center; cursor: pointer;
|
||||
font-weight: 700; color: #a0aec0; font-size: 11px; transition: all 0.2s ease;
|
||||
border-bottom: 2px solid transparent; display: flex; align-items: center; justify-content: center; gap: 6px;
|
||||
}
|
||||
.mail-tab:hover { background: #edf2f7; color: var(--primary-color); }
|
||||
.mail-tab.active { color: var(--primary-color); border-bottom: 2px solid var(--primary-color); background: #fff; }
|
||||
|
||||
.search-bar { padding: 16px 24px; border-bottom: 1px solid var(--border-color); background: #fff; flex-shrink: 0; }
|
||||
|
||||
.mail-bulk-actions {
|
||||
display: none; padding: 8px 16px; background: #f7fafc;
|
||||
border-bottom: 1px solid var(--border-color); align-items: center; justify-content: space-between; font-size: 12px;
|
||||
}
|
||||
.mail-bulk-actions.active { display: flex; }
|
||||
|
||||
/* 2. Mail Items */
|
||||
.mail-items-container { flex: 1; overflow-y: auto; padding-bottom: 60px; }
|
||||
.mail-item {
|
||||
padding: 16px; border-bottom: 1px solid var(--border-color); cursor: pointer;
|
||||
display: flex; align-items: flex-start; transition: 0.2s;
|
||||
}
|
||||
.mail-item:hover { background: var(--bg-muted); }
|
||||
.mail-item.active { background: var(--primary-lv-0); }
|
||||
|
||||
.mail-item-checkbox { width: 16px; height: 16px; cursor: pointer; margin-right: 12px; margin-top: 2px; }
|
||||
.mail-item-content { flex: 1; min-width: 0; }
|
||||
.mail-item-info { display: flex; align-items: center; gap: 12px; margin-bottom: 4px; }
|
||||
.mail-date { font-size: 11px; color: var(--text-sub); white-space: nowrap; }
|
||||
|
||||
.btn-mail-delete {
|
||||
background: #f7fafc; border: 1px solid var(--border-color); color: #718096;
|
||||
font-size: 10px; padding: 2px 8px; border-radius: 4px; font-weight: 600;
|
||||
}
|
||||
.btn-mail-delete:hover { color: var(--error-color); background: #fff5f5; border-color: #feb2b2; }
|
||||
|
||||
/* 3. Content Area */
|
||||
.mail-content-area { flex: 1; display: flex; flex-direction: column; overflow-y: auto; border-right: 1px solid var(--border-color); }
|
||||
.mail-content-header { padding: var(--space-lg); border-bottom: 1px solid var(--border-color); }
|
||||
.mail-body { padding: var(--space-lg); line-height: 1.6; min-height: 200px; }
|
||||
|
||||
/* 4. Attachments & AI */
|
||||
.attachment-area { padding: var(--space-lg); border-top: 1px solid var(--border-color); background: var(--bg-muted); }
|
||||
.attachment-item {
|
||||
display: flex; align-items: center; gap: var(--space-md); background: #fff;
|
||||
padding: 12px 20px; border-radius: var(--radius-lg);
|
||||
border: 1px solid var(--border-color); margin-bottom: var(--space-sm); cursor: pointer;
|
||||
transition: 0.2s;
|
||||
}
|
||||
.attachment-item:hover { border-color: var(--primary-color); box-shadow: var(--box-shadow); }
|
||||
.attachment-item.active { background: var(--primary-lv-0); border-color: var(--primary-color); }
|
||||
|
||||
.file-details { flex: 1; min-width: 0; }
|
||||
.file-name { font-size: 13px; font-weight: 700; max-width: 450px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.file-size { font-size: 11px; color: var(--text-sub); }
|
||||
|
||||
.btn-group {
|
||||
display: flex; align-items: center; gap: 12px; flex-shrink: 0; justify-content: flex-end;
|
||||
}
|
||||
|
||||
.btn-upload {
|
||||
padding: 6px 14px; border-radius: 6px; font-size: 11px; font-weight: 700;
|
||||
color: #fff; border: none; cursor: pointer; transition: 0.2s; height: 32px;
|
||||
}
|
||||
|
||||
.btn-ai { background: var(--ai-gradient); }
|
||||
.btn-ai:hover { filter: brightness(1.1); transform: translateY(-1px); }
|
||||
|
||||
.btn-normal { background: var(--primary-color); }
|
||||
.btn-normal:hover { background: var(--primary-hover); transform: translateY(-1px); }
|
||||
|
||||
.ai-recommend {
|
||||
font-size: 11px; padding: 6px 12px; border-radius: 6px; font-weight: 600;
|
||||
cursor: pointer; transition: 0.2s; display: inline-block;
|
||||
max-width: 250px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
}
|
||||
.ai-recommend.smart-mode { background: #eef2ff; color: #4338ca; border: 1px solid #c7d2fe; }
|
||||
.ai-recommend.manual-mode { background: #f1f5f9; color: #475569; border: 1px dashed #cbd5e1; }
|
||||
.ai-recommend:hover { transform: scale(1.02); }
|
||||
|
||||
/* 5. Preview Area */
|
||||
.mail-preview-area {
|
||||
width: 0; background: #f8f9fa; display: flex; flex-direction: column;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); position: relative;
|
||||
border-left: 0 solid transparent;
|
||||
}
|
||||
|
||||
.mail-preview-area.active {
|
||||
width: 600px;
|
||||
border-left: 1px solid var(--border-color);
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
.preview-header {
|
||||
height: 56px; padding: 0 24px; border-bottom: 1px solid var(--border-color);
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
background: #fff; flex-shrink: 0;
|
||||
}
|
||||
|
||||
.preview-header h3 { font-size: 15px; font-weight: 800; color: var(--primary-color); margin: 0; }
|
||||
|
||||
#fullViewBtn {
|
||||
background: var(--primary-lv-0) !important;
|
||||
color: var(--primary-color) !important;
|
||||
border: 1px solid var(--primary-lv-1) !important;
|
||||
font-weight: 700 !important;
|
||||
padding: 4px 16px !important;
|
||||
border-radius: 4px !important;
|
||||
font-size: 11px !important;
|
||||
transition: 0.2s !important;
|
||||
}
|
||||
#fullViewBtn:hover { background: var(--primary-lv-1) !important; }
|
||||
|
||||
.preview-toggle-handle {
|
||||
position: absolute; left: -20px; top: 50%; transform: translateY(-50%);
|
||||
width: 20px; height: 60px; background: var(--primary-color); color: #fff;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
border-radius: 8px 0 0 8px; font-size: 10px; cursor: pointer;
|
||||
box-shadow: -2px 0 5px rgba(0,0,0,0.1); z-index: 100;
|
||||
}
|
||||
.preview-toggle-handle:hover { background: var(--primary-hover); }
|
||||
|
||||
.a4-container {
|
||||
flex: 1; padding: 30px; overflow-y: auto; background: #e9ecef;
|
||||
display: flex; justify-content: center;
|
||||
}
|
||||
|
||||
.a4-container iframe, .a4-container .preview-placeholder {
|
||||
width: 100%; height: 100%; background: #fff;
|
||||
box-shadow: 0 4px 20px rgba(0,0,0,0.08); border-radius: 4px;
|
||||
}
|
||||
|
||||
.preview-placeholder {
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
text-align: center; color: var(--text-sub); font-size: 13px; line-height: 1.6;
|
||||
}
|
||||
|
||||
.mail-preview-area.active > * { opacity: 1 !important; visibility: visible !important; pointer-events: auto !important; }
|
||||
.mail-preview-area > *:not(.preview-toggle-handle) { opacity: 0; visibility: hidden; pointer-events: none; transition: 0.2s; }
|
||||
|
||||
/* 6. Footer & Others */
|
||||
.address-book-footer {
|
||||
position: absolute; bottom: 0; left: 0; width: 100%; padding: 12px 16px;
|
||||
border-top: 1px solid var(--border-color); background: #fff; display: flex; gap: 8px; z-index: 5;
|
||||
}
|
||||
|
||||
.file-log-area {
|
||||
display: none; width: 100%; margin-top: 10px; background: #1a202c;
|
||||
border-radius: 4px; padding: 12px; font-family: monospace; font-size: 11px; color: #cbd5e0;
|
||||
}
|
||||
.file-log-area.active { display: block; }
|
||||
.log-success { color: #48bb78; font-weight: 700; }
|
||||
|
||||
.switch { position: relative; display: inline-block; width: 34px; height: 20px; }
|
||||
.switch input { opacity: 0; width: 0; height: 0; }
|
||||
.slider {
|
||||
position: absolute; cursor: pointer; top: 0; left: 0; right: 0; bottom: 0;
|
||||
background-color: #ccc; transition: .4s; border-radius: 20px;
|
||||
}
|
||||
.slider:before {
|
||||
position: absolute; content: ""; height: 14px; width: 14px; left: 3px; bottom: 3px;
|
||||
background-color: white; transition: .4s; border-radius: 50%;
|
||||
}
|
||||
input:checked+.slider { background: var(--ai-gradient); }
|
||||
input:checked+.slider:before { transform: translateX(14px); }
|
||||
|
||||
/* Restore Path Selector Modal Specific Styles */
|
||||
.select-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.select-group label {
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: var(--text-main);
|
||||
}
|
||||
|
||||
.modal-select {
|
||||
width: 100%;
|
||||
height: 44px;
|
||||
padding: 0 15px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
background-color: #f9f9f9;
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
outline: none;
|
||||
transition: all 0.2s;
|
||||
cursor: pointer;
|
||||
appearance: none;
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%23666' d='M6 8L2 4h8L6 8z'/%3E%3C/svg%3E");
|
||||
background-repeat: no-repeat;
|
||||
background-position: right 15px center;
|
||||
}
|
||||
|
||||
.modal-select:focus {
|
||||
border-color: var(--primary-color);
|
||||
background-color: #fff;
|
||||
box-shadow: 0 0 0 3px rgba(30, 81, 73, 0.1);
|
||||
}
|
||||
|
||||
.modal-select option {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
@import url('common.css');
|
||||
@import url('dashboard.css');
|
||||
@import url('mail.css');
|
||||
@import url('common.css');
|
||||
@import url('dashboard.css');
|
||||
@import url('mail.css');
|
||||
|
||||
@@ -1,139 +1,139 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>데이터 분석 - Project Master Sabermetrics</title>
|
||||
<link rel="stylesheet" href="style/common.css">
|
||||
<link rel="stylesheet" href="style/analysis.css">
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/chartjs-plugin-datalabels@2"></script>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<nav class="topbar">
|
||||
<div class="topbar-header">
|
||||
<a href="/">
|
||||
<h2>Project Master Test</h2>
|
||||
</a>
|
||||
</div>
|
||||
<ul class="nav-list">
|
||||
<li class="nav-item" onclick="location.href='/dashboard'">대시보드</li>
|
||||
<li class="nav-item" onclick="location.href='/inquiries'">문의사항</li>
|
||||
<li class="nav-item" onclick="location.href='/mailTest'">메일관리</li>
|
||||
<li class="nav-item active" onclick="location.href='/analysis'">분석</li>
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
<main class="analysis-content">
|
||||
<header class="analysis-header">
|
||||
<div class="title-group">
|
||||
<div class="ai-badge">AI Sabermetrics</div>
|
||||
<h2>시스템 운영 자산 가치 분석</h2>
|
||||
<p>수집된 활동 로그 및 자산 데이터를 기반으로 한 통계적 활력 지표 (Beta)</p>
|
||||
</div>
|
||||
<div class="analysis-actions">
|
||||
<button class="btn btn-primary" onclick="location.reload()">데이터 갱신</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- 상단 정보 영역 -->
|
||||
<div class="top-info-grid">
|
||||
<section class="dl-model-info">
|
||||
<div class="card-header">
|
||||
<h4><i class="ai-icon">AI</i> Hybrid Prediction Engine</h4>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="model-desc-vertical">
|
||||
<div class="model-item-vertical">
|
||||
<span class="model-tag">분석 모델</span>
|
||||
<p>최근 9회차 시계열의 Velocity 및 변화율 분석</p>
|
||||
</div>
|
||||
<div class="model-item-vertical">
|
||||
<span class="model-tag">가중치 로직</span>
|
||||
<p>활동 시 '선형 유지', 정체 시 '지수 감쇄' 동적 적용</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="soi-deep-dive">
|
||||
<div class="card-header">
|
||||
<h4><i class="info-icon">i</i> AI 위험 적응형 모델 (AAS) 기반 지표 정의</h4>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="soi-info-columns">
|
||||
<div class="soi-info-column">
|
||||
<h6>1. 자산 가치 변동 추적</h6>
|
||||
<p>규모를 감지하여, 대형 프로젝트 정체 시 데이터 가치 하락 속도를 <strong>가속(Acceleration)</strong>시킵니다.</p>
|
||||
</div>
|
||||
<div class="soi-info-column">
|
||||
<h6>2. 활동 시계열 관성 분석</h6>
|
||||
<p>최근 활동의 연속성을 분석하여, 단기 정체 시에도 과거의 <strong>운영 모멘텀</strong>을 반영하여 지수를 보정합니다.</p>
|
||||
</div>
|
||||
<div class="soi-info-column">
|
||||
<h6>3. 동적 가치 계수</h6>
|
||||
<p>프로젝트마다 <strong>개별화된 감쇄 곡선</strong>을 생성하여 현장에 가장 밀착된 보존율을 제공합니다.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<!-- 메인 분석 차트 영역 -->
|
||||
<div class="analysis-charts-grid">
|
||||
<div class="chart-container-box">
|
||||
<h5>운영 활력 분포 (Activity Distribution)</h5>
|
||||
<canvas id="statusChart"></canvas>
|
||||
</div>
|
||||
<div class="chart-container-box">
|
||||
<h5>자산 가치 전략 매트릭스 (Strategic Analysis)</h5>
|
||||
<canvas id="forecastChart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 리더보드 영역 -->
|
||||
<div class="analysis-card timeline-analysis">
|
||||
<div class="card-header">
|
||||
<div style="display: flex; flex-direction: column; gap: 4px;">
|
||||
<h4>Project Activity Vitality Leaderboard (AVI Status)</h4>
|
||||
<p style="font-size: 11px; color: #888; margin: 0;">운영 표준(AVI 70%) 대비 운영 활력 및 VCI 기여 리더보드</p>
|
||||
</div>
|
||||
<div class="card-tools">
|
||||
<span id="avg-system-info" style="font-size: 11px; color: #888;">* AVI (Activity Vitality Index)</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="d-war-guide">
|
||||
<div class="guide-item active-low"><span>70%↑</span> 정상 운영</div>
|
||||
<div class="guide-item warning-mid"><span>30~70%</span> 관리 주의</div>
|
||||
<div class="guide-item danger-high"><span>10~30%</span> 위험 노출</div>
|
||||
<div class="guide-item hazard-critical"><span>10%↓</span> 중단/방치</div>
|
||||
</div>
|
||||
|
||||
<div id="p-war-table-container">
|
||||
<!-- JS에 의해 동적으로 테이블 삽입 -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- 설명 모달 -->
|
||||
<div id="analysisModal" class="modal-overlay" onclick="if(event.target===this) closeAnalysisModal()">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h3 id="modalTitle">분석 상세</h3>
|
||||
<span class="modal-close" onclick="closeAnalysisModal()">×</span>
|
||||
</div>
|
||||
<div class="modal-body" id="modalBody">
|
||||
<!-- 내용 동적 삽입 -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="js/common.js"></script>
|
||||
<script src="js/analysis.js"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>데이터 분석 - Project Master Sabermetrics</title>
|
||||
<link rel="stylesheet" href="style/common.css">
|
||||
<link rel="stylesheet" href="style/analysis.css">
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/chartjs-plugin-datalabels@2"></script>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<nav class="topbar">
|
||||
<div class="topbar-header">
|
||||
<a href="/">
|
||||
<h2>Project Master Test</h2>
|
||||
</a>
|
||||
</div>
|
||||
<ul class="nav-list">
|
||||
<li class="nav-item" onclick="location.href='/dashboard'">대시보드</li>
|
||||
<li class="nav-item" onclick="location.href='/inquiries'">문의사항</li>
|
||||
<li class="nav-item" onclick="location.href='/mailTest'">메일관리</li>
|
||||
<li class="nav-item active" onclick="location.href='/analysis'">분석</li>
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
<main class="analysis-content">
|
||||
<header class="analysis-header">
|
||||
<div class="title-group">
|
||||
<div class="ai-badge">AI Sabermetrics</div>
|
||||
<h2>시스템 운영 자산 가치 분석</h2>
|
||||
<p>수집된 활동 로그 및 자산 데이터를 기반으로 한 통계적 활력 지표 (Beta)</p>
|
||||
</div>
|
||||
<div class="analysis-actions">
|
||||
<button class="btn btn-primary" onclick="location.reload()">데이터 갱신</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- 상단 정보 영역 -->
|
||||
<div class="top-info-grid">
|
||||
<section class="dl-model-info">
|
||||
<div class="card-header">
|
||||
<h4><i class="ai-icon">AI</i> Hybrid Prediction Engine</h4>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="model-desc-vertical">
|
||||
<div class="model-item-vertical">
|
||||
<span class="model-tag">분석 모델</span>
|
||||
<p>최근 9회차 시계열의 Velocity 및 변화율 분석</p>
|
||||
</div>
|
||||
<div class="model-item-vertical">
|
||||
<span class="model-tag">가중치 로직</span>
|
||||
<p>활동 시 '선형 유지', 정체 시 '지수 감쇄' 동적 적용</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="soi-deep-dive">
|
||||
<div class="card-header">
|
||||
<h4><i class="info-icon">i</i> AI 위험 적응형 모델 (AAS) 기반 지표 정의</h4>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="soi-info-columns">
|
||||
<div class="soi-info-column">
|
||||
<h6>1. 자산 가치 변동 추적</h6>
|
||||
<p>규모를 감지하여, 대형 프로젝트 정체 시 데이터 가치 하락 속도를 <strong>가속(Acceleration)</strong>시킵니다.</p>
|
||||
</div>
|
||||
<div class="soi-info-column">
|
||||
<h6>2. 활동 시계열 관성 분석</h6>
|
||||
<p>최근 활동의 연속성을 분석하여, 단기 정체 시에도 과거의 <strong>운영 모멘텀</strong>을 반영하여 지수를 보정합니다.</p>
|
||||
</div>
|
||||
<div class="soi-info-column">
|
||||
<h6>3. 동적 가치 계수</h6>
|
||||
<p>프로젝트마다 <strong>개별화된 감쇄 곡선</strong>을 생성하여 현장에 가장 밀착된 보존율을 제공합니다.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<!-- 메인 분석 차트 영역 -->
|
||||
<div class="analysis-charts-grid">
|
||||
<div class="chart-container-box">
|
||||
<h5>운영 활력 분포 (Activity Distribution)</h5>
|
||||
<canvas id="statusChart"></canvas>
|
||||
</div>
|
||||
<div class="chart-container-box">
|
||||
<h5>자산 가치 전략 매트릭스 (Strategic Analysis)</h5>
|
||||
<canvas id="forecastChart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 리더보드 영역 -->
|
||||
<div class="analysis-card timeline-analysis">
|
||||
<div class="card-header">
|
||||
<div style="display: flex; flex-direction: column; gap: 4px;">
|
||||
<h4>Project Activity Vitality Leaderboard (AVI Status)</h4>
|
||||
<p style="font-size: 11px; color: #888; margin: 0;">전체 포트폴리오 평균(0.0) 대비 운영 가치 기여(VCI) 리더보드</p>
|
||||
</div>
|
||||
<div class="card-tools">
|
||||
<span id="avg-system-info" style="font-size: 11px; color: #888;">* AVI (Activity Vitality Index)</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="d-war-guide">
|
||||
<div class="guide-item active-low"><span>70%↑</span> 정상 운영</div>
|
||||
<div class="guide-item warning-mid"><span>30~70%</span> 관리 주의</div>
|
||||
<div class="guide-item danger-high"><span>10~30%</span> 위험 노출</div>
|
||||
<div class="guide-item hazard-critical"><span>10%↓</span> 중단/방치</div>
|
||||
</div>
|
||||
|
||||
<div id="p-war-table-container">
|
||||
<!-- JS에 의해 동적으로 테이블 삽입 -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- 설명 모달 -->
|
||||
<div id="analysisModal" class="modal-overlay" onclick="if(event.target===this) closeAnalysisModal()">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h3 id="modalTitle">분석 상세</h3>
|
||||
<span class="modal-close" onclick="closeAnalysisModal()">×</span>
|
||||
</div>
|
||||
<div class="modal-body" id="modalBody">
|
||||
<!-- 내용 동적 삽입 -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="js/common.js"></script>
|
||||
<script src="js/analysis.js"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
|
||||
139
templates/analysis_test.html
Normal file
139
templates/analysis_test.html
Normal 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()">×</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>
|
||||
@@ -1,118 +1,118 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Project Master Overseas 관리자</title>
|
||||
<link rel="stylesheet" as="style" crossorigin
|
||||
href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/static/pretendard.min.css" />
|
||||
<link rel="stylesheet" href="style/common.css">
|
||||
<link rel="stylesheet" href="style/dashboard.css">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
|
||||
<nav class="topbar">
|
||||
<div class="topbar-header">
|
||||
<a href="/">
|
||||
<h2>Project Master Test</h2>
|
||||
</a>
|
||||
</div>
|
||||
<ul class="nav-list">
|
||||
<li class="nav-item active" onclick="location.href='/dashboard'">대시보드</li>
|
||||
<li class="nav-item" onclick="location.href='/inquiries'">문의사항</li>
|
||||
<li class="nav-item" onclick="location.href='/mailTest'">메일관리</li>
|
||||
<li class="nav-item" onclick="location.href='/analysis'">분석</li>
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
<main class="main-content">
|
||||
<header>
|
||||
<h2>프로젝트 현황</h2>
|
||||
<div class="header-actions" style="display: flex; align-items: center; gap: 15px;">
|
||||
<div class="base-date-info">기준날짜: <strong id="baseDate">-</strong></div>
|
||||
<button id="syncBtn" class="sync-btn" onclick="syncData()">
|
||||
<span class="spinner"></span>
|
||||
데이터 동기화
|
||||
</button>
|
||||
<div class="admin-info">접속자: <strong>이태훈[전체관리자]</strong></div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- 프로젝트 활성도 대시보드 (전체 너비 래퍼) -->
|
||||
<div class="activity-dashboard-wrapper">
|
||||
<div id="activityDashboard" class="activity-dashboard">
|
||||
<!-- JS에서 동적 삽입 -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 실시간 로그 콘솔 (본문 내부로 복구) -->
|
||||
<div id="logConsole" class="log-console" style="display:none;">
|
||||
<div class="log-console-header">실시간 수집 로그 [PM Overseas]</div>
|
||||
<div id="logBody"></div>
|
||||
</div>
|
||||
|
||||
<div id="projectAccordion">
|
||||
<!-- Multi-level Accordion items will be generated here -->
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- 모달 레이어 (공통 규격 적용) -->
|
||||
<div id="authModal" class="modal-overlay">
|
||||
<div class="modal-content" style="max-width: 440px; padding: 40px; text-align: center;" onclick="event.stopPropagation()">
|
||||
<div class="auth-header" style="margin-bottom: 32px;">
|
||||
<i class="fas fa-lock" style="font-size: 32px; color: var(--primary-color); margin-bottom: 16px; display: block;"></i>
|
||||
<h3 style="font-size: 20px; font-weight: 800; color: #111; margin-bottom: 8px;">크롤링 권한 인증</h3>
|
||||
<p style="font-size: 13px; color: var(--text-sub);">시스템 동기화를 위해 관리자 계정으로 로그인하세요.</p>
|
||||
</div>
|
||||
<div class="auth-body" style="display: flex; flex-direction: column; gap: 20px; text-align: left; margin-bottom: 32px;">
|
||||
<div class="input-group">
|
||||
<label>관리자 아이디</label>
|
||||
<input type="text" id="authId" placeholder="아이디를 입력하세요">
|
||||
</div>
|
||||
<div class="input-group">
|
||||
<label>비밀번호</label>
|
||||
<input type="password" id="authPw" placeholder="비밀번호를 입력하세요"
|
||||
onkeyup="if(event.key==='Enter') submitAuth()">
|
||||
</div>
|
||||
<div id="authErrorMessage" class="error-text" style="display:none; color: var(--error-color); font-size: 12px; font-weight: 600; text-align: center; margin-top: -10px;">크롤링을 할 수 없습니다.</div>
|
||||
</div>
|
||||
<div class="auth-footer" style="display: grid; grid-template-columns: 1fr 1.5fr; gap: 12px;">
|
||||
<button class="btn btn-secondary" style="height: 48px;" onclick="ModalManager.close('authModal')">취소</button>
|
||||
<button class="btn btn-primary" style="height: 48px;" onclick="submitAuth()">인증 및 실행</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="activityDetailModal" class="modal-overlay" onclick="ModalManager.close('activityDetailModal')">
|
||||
<div class="modal-content" style="max-width: 600px; padding: 0; overflow: hidden;" onclick="event.stopPropagation()">
|
||||
<div class="modal-header" style="padding: 20px; margin-bottom: 0;">
|
||||
<h3 id="modalTitle">상세 목록</h3>
|
||||
<span class="modal-close" onclick="ModalManager.close('activityDetailModal')">×</span>
|
||||
</div>
|
||||
<div class="modal-body" style="padding: 20px; max-height: 70vh; overflow-y: auto;">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>프로젝트명</th>
|
||||
<th>담당부서</th>
|
||||
<th>담당자</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="modalTableBody">
|
||||
<!-- JS에서 동적 삽입 -->
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div style="padding: 16px 20px; border-top: 1px solid var(--border-color); text-align: right; background: #fdfdfd;">
|
||||
<button class="btn btn-secondary" onclick="ModalManager.close('activityDetailModal')">닫기</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="js/common.js"></script>
|
||||
<script src="js/dashboard.js"></script>
|
||||
</body>
|
||||
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Project Master Overseas 관리자</title>
|
||||
<link rel="stylesheet" as="style" crossorigin
|
||||
href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/static/pretendard.min.css" />
|
||||
<link rel="stylesheet" href="style/common.css">
|
||||
<link rel="stylesheet" href="style/dashboard.css">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
|
||||
<nav class="topbar">
|
||||
<div class="topbar-header">
|
||||
<a href="/">
|
||||
<h2>Project Master Test</h2>
|
||||
</a>
|
||||
</div>
|
||||
<ul class="nav-list">
|
||||
<li class="nav-item active" onclick="location.href='/dashboard'">대시보드</li>
|
||||
<li class="nav-item" onclick="location.href='/inquiries'">문의사항</li>
|
||||
<li class="nav-item" onclick="location.href='/mailTest'">메일관리</li>
|
||||
<li class="nav-item" onclick="location.href='/analysis'">분석</li>
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
<main class="main-content">
|
||||
<header>
|
||||
<h2>프로젝트 현황</h2>
|
||||
<div class="header-actions" style="display: flex; align-items: center; gap: 15px;">
|
||||
<div class="base-date-info">기준날짜: <strong id="baseDate">-</strong></div>
|
||||
<button id="syncBtn" class="sync-btn" onclick="syncData()">
|
||||
<span class="spinner"></span>
|
||||
데이터 동기화
|
||||
</button>
|
||||
<div class="admin-info">접속자: <strong>이태훈[전체관리자]</strong></div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- 프로젝트 활성도 대시보드 (전체 너비 래퍼) -->
|
||||
<div class="activity-dashboard-wrapper">
|
||||
<div id="activityDashboard" class="activity-dashboard">
|
||||
<!-- JS에서 동적 삽입 -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 실시간 로그 콘솔 (본문 내부로 복구) -->
|
||||
<div id="logConsole" class="log-console" style="display:none;">
|
||||
<div class="log-console-header">실시간 수집 로그 [PM Overseas]</div>
|
||||
<div id="logBody"></div>
|
||||
</div>
|
||||
|
||||
<div id="projectAccordion">
|
||||
<!-- Multi-level Accordion items will be generated here -->
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- 모달 레이어 (공통 규격 적용) -->
|
||||
<div id="authModal" class="modal-overlay">
|
||||
<div class="modal-content" style="max-width: 440px; padding: 40px; text-align: center;" onclick="event.stopPropagation()">
|
||||
<div class="auth-header" style="margin-bottom: 32px;">
|
||||
<i class="fas fa-lock" style="font-size: 32px; color: var(--primary-color); margin-bottom: 16px; display: block;"></i>
|
||||
<h3 style="font-size: 20px; font-weight: 800; color: #111; margin-bottom: 8px;">크롤링 권한 인증</h3>
|
||||
<p style="font-size: 13px; color: var(--text-sub);">시스템 동기화를 위해 관리자 계정으로 로그인하세요.</p>
|
||||
</div>
|
||||
<div class="auth-body" style="display: flex; flex-direction: column; gap: 20px; text-align: left; margin-bottom: 32px;">
|
||||
<div class="input-group">
|
||||
<label>관리자 아이디</label>
|
||||
<input type="text" id="authId" placeholder="아이디를 입력하세요">
|
||||
</div>
|
||||
<div class="input-group">
|
||||
<label>비밀번호</label>
|
||||
<input type="password" id="authPw" placeholder="비밀번호를 입력하세요"
|
||||
onkeyup="if(event.key==='Enter') submitAuth()">
|
||||
</div>
|
||||
<div id="authErrorMessage" class="error-text" style="display:none; color: var(--error-color); font-size: 12px; font-weight: 600; text-align: center; margin-top: -10px;">크롤링을 할 수 없습니다.</div>
|
||||
</div>
|
||||
<div class="auth-footer" style="display: grid; grid-template-columns: 1fr 1.5fr; gap: 12px;">
|
||||
<button class="btn btn-secondary" style="height: 48px;" onclick="ModalManager.close('authModal')">취소</button>
|
||||
<button class="btn btn-primary" style="height: 48px;" onclick="submitAuth()">인증 및 실행</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="activityDetailModal" class="modal-overlay" onclick="ModalManager.close('activityDetailModal')">
|
||||
<div class="modal-content" style="max-width: 600px; padding: 0; overflow: hidden;" onclick="event.stopPropagation()">
|
||||
<div class="modal-header" style="padding: 20px; margin-bottom: 0;">
|
||||
<h3 id="modalTitle">상세 목록</h3>
|
||||
<span class="modal-close" onclick="ModalManager.close('activityDetailModal')">×</span>
|
||||
</div>
|
||||
<div class="modal-body" style="padding: 20px; max-height: 70vh; overflow-y: auto;">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>프로젝트명</th>
|
||||
<th>담당부서</th>
|
||||
<th>담당자</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="modalTableBody">
|
||||
<!-- JS에서 동적 삽입 -->
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div style="padding: 16px 20px; border-top: 1px solid var(--border-color); text-align: right; background: #fdfdfd;">
|
||||
<button class="btn btn-secondary" onclick="ModalManager.close('activityDetailModal')">닫기</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="js/common.js"></script>
|
||||
<script src="js/dashboard.js"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
118
templates/dashboard_test.html
Normal file
118
templates/dashboard_test.html
Normal 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')">×</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>
|
||||
@@ -1,46 +1,58 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Project Master Portal</title>
|
||||
<link rel="stylesheet" as="style" crossorigin
|
||||
href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/static/pretendard.min.css" />
|
||||
<link rel="stylesheet" href="style/common.css">
|
||||
<link rel="stylesheet" href="style/dashboard.css">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
|
||||
<nav class="topbar">
|
||||
<div class="topbar-header">
|
||||
<a href="/"><h2>Project Master Test</h2></a>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="portal-container">
|
||||
<div class="portal-header">
|
||||
<h1>Project Master 테스트</h1>
|
||||
<p>원하시는 서비스에 접속하려면 아래 버튼을 클릭하세요.</p>
|
||||
</div>
|
||||
|
||||
<div class="button-grid">
|
||||
<a href="/dashboard" class="portal-card">
|
||||
<div class="icon">📊</div>
|
||||
<h2>관리자 페이지 테스트</h2>
|
||||
<p>관리자 페이지 테스트 입니다.</p>
|
||||
</a>
|
||||
|
||||
<a href="/mailTest" class="portal-card">
|
||||
<div class="icon">✉️</div>
|
||||
<h2>메일 테스트</h2>
|
||||
<p>메일 기능 테스트 페이지입니다.</p>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="js/common.js"></script>
|
||||
</body>
|
||||
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Project Master Portal</title>
|
||||
<link rel="stylesheet" as="style" crossorigin
|
||||
href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/static/pretendard.min.css" />
|
||||
<link rel="stylesheet" href="style/common.css">
|
||||
<link rel="stylesheet" href="style/dashboard.css">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
|
||||
<nav class="topbar">
|
||||
<div class="topbar-header">
|
||||
<a href="/"><h2>Project Master Test</h2></a>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="portal-container">
|
||||
<div class="portal-header">
|
||||
<h1>Project Master 테스트</h1>
|
||||
<p>원하시는 서비스에 접속하려면 아래 버튼을 클릭하세요.</p>
|
||||
</div>
|
||||
|
||||
<div class="button-grid">
|
||||
<a href="/dashboard" class="portal-card">
|
||||
<div class="icon">📊</div>
|
||||
<h3>대시보드</h3>
|
||||
<p>시스템 운영 현황 및 핵심 지표 요약</p>
|
||||
</a>
|
||||
|
||||
<a href="/inquiries" class="portal-card">
|
||||
<div class="icon">📝</div>
|
||||
<h3>문의사항</h3>
|
||||
<p>프로젝트 관련 문의 및 기술 지원 관리</p>
|
||||
</a>
|
||||
|
||||
<a href="/mailTest" class="portal-card">
|
||||
<div class="icon">📧</div>
|
||||
<h3>메일관리</h3>
|
||||
<p>수집 메일 분석 및 첨부파일 분류 현황</p>
|
||||
</a>
|
||||
|
||||
<a href="/analysis" class="portal-card">
|
||||
<div class="icon">📈</div>
|
||||
<h3>분석 (Sabermetrics)</h3>
|
||||
<p>AI 기반 운영 활력 및 자산 가치 정밀 분석</p>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="js/common.js"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -1,194 +1,194 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>문의사항 관리 - Project Master</title>
|
||||
<link rel="stylesheet" as="style" crossorigin
|
||||
href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/static/pretendard.min.css" />
|
||||
<link rel="stylesheet" href="style/common.css">
|
||||
<link rel="stylesheet" href="style/dashboard.css">
|
||||
<link rel="stylesheet" href="style/inquiries.css">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<nav class="topbar">
|
||||
<div class="topbar-header">
|
||||
<a href="/">
|
||||
<h2>Project Master Test</h2>
|
||||
</a>
|
||||
</div>
|
||||
<ul class="nav-list">
|
||||
<li class="nav-item" onclick="location.href='/dashboard'">대시보드</li>
|
||||
<li class="nav-item active" onclick="location.href='/inquiries'">문의사항</li>
|
||||
<li class="nav-item" onclick="location.href='/mailTest'">메일관리</li>
|
||||
<li class="nav-item" onclick="location.href='/analysis'">분석</li>
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
<main class="inquiry-board">
|
||||
<div id="stickyHeader" class="board-sticky-header">
|
||||
<div class="board-header">
|
||||
<div>
|
||||
<h2>문의사항</h2>
|
||||
<p style="font-size: 13px; color: #666; margin-top: 4px;">시스템 운영 관련 불편사항 및 개선 요청 관리</p>
|
||||
</div>
|
||||
<div class="header-stats" id="headerStats">
|
||||
<div class="stat-item total">
|
||||
<span class="stat-label">전체</span>
|
||||
<span class="stat-value" id="countTotal">0</span>
|
||||
</div>
|
||||
<div class="stat-item complete">
|
||||
<span class="stat-label">완료</span>
|
||||
<span class="stat-value" id="countComplete">0</span>
|
||||
</div>
|
||||
<div class="stat-item working">
|
||||
<span class="stat-label">작업 중</span>
|
||||
<span class="stat-value" id="countWorking">0</span>
|
||||
</div>
|
||||
<div class="stat-item checking">
|
||||
<span class="stat-label">확인 중</span>
|
||||
<span class="stat-value" id="countChecking">0</span>
|
||||
</div>
|
||||
<div class="stat-item pending">
|
||||
<span class="stat-label">개발예정</span>
|
||||
<span class="stat-value" id="countPending">0</span>
|
||||
</div>
|
||||
<div class="stat-item unconfirmed">
|
||||
<span class="stat-label">미확인</span>
|
||||
<span class="stat-value" id="countUnconfirmed">0</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 시트 상단 공지 영역 재현 (통합 박스) -->
|
||||
<div class="notice-container">
|
||||
<div style="margin-bottom: 12px; display: flex; gap: 20px;">
|
||||
<div style="flex: 1;">
|
||||
<h4
|
||||
style="color: #d32f2f; margin-bottom: 5px; font-size: 14px; display: flex; align-items: center; gap: 6px;">
|
||||
<span style="font-size: 16px;">📢</span> <공지>
|
||||
</h4>
|
||||
<ul style="font-size: 12px; line-height: 1.6; color: #444; padding-left: 20px; margin: 0;">
|
||||
<li>파워포인트 파일에 읽기전용포함(제한)글꼴이 있는 경우 컨버팅이 되지 않습니다. 제한된 글꼴 제거 후 업로드 권장.</li>
|
||||
<li>이미지는 '문의사항 이미지' 시트에 문의사항 번호와 함께 업로드 부탁드립니다.</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div style="flex: 2; border-left: 1px dashed #ddd; padding-left: 20px;">
|
||||
<h4
|
||||
style="color: #1976d2; margin-bottom: 5px; font-size: 14px; display: flex; align-items: center; gap: 6px;">
|
||||
<span style="font-size: 16px;">📂</span> <이전 문의사항 요약>
|
||||
</h4>
|
||||
<div
|
||||
style="display: grid; grid-template-columns: 1fr 1fr; gap: 10px; font-size: 11px; color: #666; line-height: 1.4;">
|
||||
<div><strong>1. 폴더트리:</strong> 하위폴더 권한, 숨김 기능 등 (완료)</div>
|
||||
<div><strong>2. 뷰어:</strong> 변환 오류, 특정 문서 깨짐 등 (완료)</div>
|
||||
<div><strong>3. 업로드/다운로드:</strong> 대용량 전송 오류 등 (완료)</div>
|
||||
<div><strong>4. 기타:</strong> 정렬, 화면 개선 등 (완료)</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="filter-section">
|
||||
<div class="filter-group">
|
||||
<label>시스템(PM 종류)</label>
|
||||
<select id="filterPmType" onchange="loadInquiries()">
|
||||
<option value="">전체</option>
|
||||
<option value="Overseas">Overseas</option>
|
||||
<option value="기술개발센터">기술개발센터</option>
|
||||
<option value="장헌">장헌</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="filter-group">
|
||||
<label>구분(카테고리)</label>
|
||||
<select id="filterCategory" onchange="loadInquiries()">
|
||||
<option value="">전체</option>
|
||||
<option value="업로드">업로드</option>
|
||||
<option value="다운로드">다운로드</option>
|
||||
<option value="폴더트리">폴더트리</option>
|
||||
<option value="뷰어">뷰어</option>
|
||||
<option value="기타">기타</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="filter-group">
|
||||
<label>처리여부</label>
|
||||
<select id="filterStatus" onchange="loadInquiries()">
|
||||
<option value="">전체</option>
|
||||
<option value="완료">완료</option>
|
||||
<option value="작업 중">작업 중</option>
|
||||
<option value="확인 중">확인 중</option>
|
||||
<option value="개발예정">개발예정</option>
|
||||
<option value="미확인">미확인</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="filter-group">
|
||||
<label>검색(내용/작성자/프로젝트)</label>
|
||||
<input type="text" id="searchKeyword" placeholder="검색어 입력 후 Enter..."
|
||||
onkeyup="if(event.key==='Enter') loadInquiries()" style="width: 250px;">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<table class="inquiry-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th width="50" class="sortable" onclick="handleSort('no')">
|
||||
<div class="header-content">No <span id="sort-no" class="sort-icon">▼</span></div>
|
||||
</th>
|
||||
<th width="80">이미지</th>
|
||||
<th width="120" class="sortable" onclick="handleSort('pm_type')">
|
||||
<div class="header-content">PM 종류 <span id="sort-pm_type" class="sort-icon"></span></div>
|
||||
</th>
|
||||
<th width="100" class="sortable" onclick="handleSort('browser')">
|
||||
<div class="header-content">환경 <span id="sort-browser" class="sort-icon"></span></div>
|
||||
</th>
|
||||
<th width="150" class="sortable" onclick="handleSort('category')">
|
||||
<div class="header-content">구분 <span id="sort-category" class="sort-icon"></span></div>
|
||||
</th>
|
||||
<th class="sortable" onclick="handleSort('project_nm')">
|
||||
<div class="header-content">프로젝트 <span id="sort-project_nm" class="sort-icon"></span></div>
|
||||
</th>
|
||||
<th width="400">문의내용</th>
|
||||
<th width="100" class="sortable" onclick="handleSort('author')">
|
||||
<div class="header-content">작성자 <span id="sort-author" class="sort-icon"></span></div>
|
||||
</th>
|
||||
<th width="120" class="sortable" onclick="handleSort('reg_date')">
|
||||
<div class="header-content">날짜 <span id="sort-reg_date" class="sort-icon"></span></div>
|
||||
</th>
|
||||
<th width="400">답변내용</th>
|
||||
<th width="100" class="sortable" onclick="handleSort('status')">
|
||||
<div class="header-content">상태 <span id="sort-status" class="sort-icon"></span></div>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="inquiryList">
|
||||
<!-- Data will be loaded here -->
|
||||
</tbody>
|
||||
</table>
|
||||
</main>
|
||||
|
||||
<!-- 이미지 크게 보기 모달 (디자인 가이드 - 화이트 계열 및 우측 하단 닫기 적용) -->
|
||||
<div id="imageModal" class="modal-overlay" onclick="ModalManager.close('imageModal')">
|
||||
<div class="modal-content" style="max-width: 960px; width: 92%; padding: 0; overflow: hidden; border-radius: 12px; border: 1px solid #e2e8f0; background: #fff;" onclick="event.stopPropagation()">
|
||||
<div class="modal-header" style="padding: 16px 24px; margin-bottom: 0; border-bottom: 1px solid #f1f5f9; background: #fff;">
|
||||
<h3 style="color: #1e5149; font-weight: 700; font-size: 16px;">첨부 이미지 확대 보기</h3>
|
||||
<span class="modal-close" onclick="ModalManager.close('imageModal')" style="font-size: 24px; color: #94a3b8;">×</span>
|
||||
</div>
|
||||
<div style="padding: 32px; background: #fff; display: flex; justify-content: center; align-items: center; min-height: 300px; max-height: 75vh; overflow: auto;">
|
||||
<img id="modalImage" style="max-width: 100%; height: auto; border-radius: 8px; box-shadow: 0 10px 25px -5px rgba(0,0,0,0.1), 0 8px 10px -6px rgba(0,0,0,0.1);">
|
||||
</div>
|
||||
<div style="padding: 16px 24px; border-top: 1px solid #f1f5f9; text-align: right; background: #fff;">
|
||||
<button class="_button-medium" style="background: #1e5149; color: #fff; border: none; padding: 10px 28px; border-radius: 8px; cursor: pointer; transition: background 0.2s;"
|
||||
onmouseover="this.style.background='#163b36'" onmouseout="this.style.background='#1e5149'"
|
||||
onclick="ModalManager.close('imageModal')">닫기</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="js/common.js"></script>
|
||||
<script src="js/inquiries.js"></script>
|
||||
</body>
|
||||
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>문의사항 관리 - Project Master</title>
|
||||
<link rel="stylesheet" as="style" crossorigin
|
||||
href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/static/pretendard.min.css" />
|
||||
<link rel="stylesheet" href="style/common.css">
|
||||
<link rel="stylesheet" href="style/dashboard.css">
|
||||
<link rel="stylesheet" href="style/inquiries.css">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<nav class="topbar">
|
||||
<div class="topbar-header">
|
||||
<a href="/">
|
||||
<h2>Project Master Test</h2>
|
||||
</a>
|
||||
</div>
|
||||
<ul class="nav-list">
|
||||
<li class="nav-item" onclick="location.href='/dashboard'">대시보드</li>
|
||||
<li class="nav-item active" onclick="location.href='/inquiries'">문의사항</li>
|
||||
<li class="nav-item" onclick="location.href='/mailTest'">메일관리</li>
|
||||
<li class="nav-item" onclick="location.href='/analysis'">분석</li>
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
<main class="inquiry-board">
|
||||
<div id="stickyHeader" class="board-sticky-header">
|
||||
<div class="board-header">
|
||||
<div>
|
||||
<h2>문의사항</h2>
|
||||
<p style="font-size: 13px; color: #666; margin-top: 4px;">시스템 운영 관련 불편사항 및 개선 요청 관리</p>
|
||||
</div>
|
||||
<div class="header-stats" id="headerStats">
|
||||
<div class="stat-item total">
|
||||
<span class="stat-label">전체</span>
|
||||
<span class="stat-value" id="countTotal">0</span>
|
||||
</div>
|
||||
<div class="stat-item complete">
|
||||
<span class="stat-label">완료</span>
|
||||
<span class="stat-value" id="countComplete">0</span>
|
||||
</div>
|
||||
<div class="stat-item working">
|
||||
<span class="stat-label">작업 중</span>
|
||||
<span class="stat-value" id="countWorking">0</span>
|
||||
</div>
|
||||
<div class="stat-item checking">
|
||||
<span class="stat-label">확인 중</span>
|
||||
<span class="stat-value" id="countChecking">0</span>
|
||||
</div>
|
||||
<div class="stat-item pending">
|
||||
<span class="stat-label">개발예정</span>
|
||||
<span class="stat-value" id="countPending">0</span>
|
||||
</div>
|
||||
<div class="stat-item unconfirmed">
|
||||
<span class="stat-label">미확인</span>
|
||||
<span class="stat-value" id="countUnconfirmed">0</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 시트 상단 공지 영역 재현 (통합 박스) -->
|
||||
<div class="notice-container">
|
||||
<div style="margin-bottom: 12px; display: flex; gap: 20px;">
|
||||
<div style="flex: 1;">
|
||||
<h4
|
||||
style="color: #d32f2f; margin-bottom: 5px; font-size: 14px; display: flex; align-items: center; gap: 6px;">
|
||||
<span style="font-size: 16px;">📢</span> <공지>
|
||||
</h4>
|
||||
<ul style="font-size: 12px; line-height: 1.6; color: #444; padding-left: 20px; margin: 0;">
|
||||
<li>파워포인트 파일에 읽기전용포함(제한)글꼴이 있는 경우 컨버팅이 되지 않습니다. 제한된 글꼴 제거 후 업로드 권장.</li>
|
||||
<li>이미지는 '문의사항 이미지' 시트에 문의사항 번호와 함께 업로드 부탁드립니다.</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div style="flex: 2; border-left: 1px dashed #ddd; padding-left: 20px;">
|
||||
<h4
|
||||
style="color: #1976d2; margin-bottom: 5px; font-size: 14px; display: flex; align-items: center; gap: 6px;">
|
||||
<span style="font-size: 16px;">📂</span> <이전 문의사항 요약>
|
||||
</h4>
|
||||
<div
|
||||
style="display: grid; grid-template-columns: 1fr 1fr; gap: 10px; font-size: 11px; color: #666; line-height: 1.4;">
|
||||
<div><strong>1. 폴더트리:</strong> 하위폴더 권한, 숨김 기능 등 (완료)</div>
|
||||
<div><strong>2. 뷰어:</strong> 변환 오류, 특정 문서 깨짐 등 (완료)</div>
|
||||
<div><strong>3. 업로드/다운로드:</strong> 대용량 전송 오류 등 (완료)</div>
|
||||
<div><strong>4. 기타:</strong> 정렬, 화면 개선 등 (완료)</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="filter-section">
|
||||
<div class="filter-group">
|
||||
<label>시스템(PM 종류)</label>
|
||||
<select id="filterPmType" onchange="loadInquiries()">
|
||||
<option value="">전체</option>
|
||||
<option value="Overseas">Overseas</option>
|
||||
<option value="기술개발센터">기술개발센터</option>
|
||||
<option value="장헌">장헌</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="filter-group">
|
||||
<label>구분(카테고리)</label>
|
||||
<select id="filterCategory" onchange="loadInquiries()">
|
||||
<option value="">전체</option>
|
||||
<option value="업로드">업로드</option>
|
||||
<option value="다운로드">다운로드</option>
|
||||
<option value="폴더트리">폴더트리</option>
|
||||
<option value="뷰어">뷰어</option>
|
||||
<option value="기타">기타</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="filter-group">
|
||||
<label>처리여부</label>
|
||||
<select id="filterStatus" onchange="loadInquiries()">
|
||||
<option value="">전체</option>
|
||||
<option value="완료">완료</option>
|
||||
<option value="작업 중">작업 중</option>
|
||||
<option value="확인 중">확인 중</option>
|
||||
<option value="개발예정">개발예정</option>
|
||||
<option value="미확인">미확인</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="filter-group">
|
||||
<label>검색(내용/작성자/프로젝트)</label>
|
||||
<input type="text" id="searchKeyword" placeholder="검색어 입력 후 Enter..."
|
||||
onkeyup="if(event.key==='Enter') loadInquiries()" style="width: 250px;">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<table class="inquiry-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th width="50" class="sortable" onclick="handleSort('no')">
|
||||
<div class="header-content">No <span id="sort-no" class="sort-icon">▼</span></div>
|
||||
</th>
|
||||
<th width="80">이미지</th>
|
||||
<th width="120" class="sortable" onclick="handleSort('pm_type')">
|
||||
<div class="header-content">PM 종류 <span id="sort-pm_type" class="sort-icon"></span></div>
|
||||
</th>
|
||||
<th width="100" class="sortable" onclick="handleSort('browser')">
|
||||
<div class="header-content">환경 <span id="sort-browser" class="sort-icon"></span></div>
|
||||
</th>
|
||||
<th width="150" class="sortable" onclick="handleSort('category')">
|
||||
<div class="header-content">구분 <span id="sort-category" class="sort-icon"></span></div>
|
||||
</th>
|
||||
<th class="sortable" onclick="handleSort('project_nm')">
|
||||
<div class="header-content">프로젝트 <span id="sort-project_nm" class="sort-icon"></span></div>
|
||||
</th>
|
||||
<th width="400">문의내용</th>
|
||||
<th width="100" class="sortable" onclick="handleSort('author')">
|
||||
<div class="header-content">작성자 <span id="sort-author" class="sort-icon"></span></div>
|
||||
</th>
|
||||
<th width="120" class="sortable" onclick="handleSort('reg_date')">
|
||||
<div class="header-content">날짜 <span id="sort-reg_date" class="sort-icon"></span></div>
|
||||
</th>
|
||||
<th width="400">답변내용</th>
|
||||
<th width="100" class="sortable" onclick="handleSort('status')">
|
||||
<div class="header-content">상태 <span id="sort-status" class="sort-icon"></span></div>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="inquiryList">
|
||||
<!-- Data will be loaded here -->
|
||||
</tbody>
|
||||
</table>
|
||||
</main>
|
||||
|
||||
<!-- 이미지 크게 보기 모달 (디자인 가이드 - 화이트 계열 및 우측 하단 닫기 적용) -->
|
||||
<div id="imageModal" class="modal-overlay" onclick="ModalManager.close('imageModal')">
|
||||
<div class="modal-content" style="max-width: 960px; width: 92%; padding: 0; overflow: hidden; border-radius: 12px; border: 1px solid #e2e8f0; background: #fff;" onclick="event.stopPropagation()">
|
||||
<div class="modal-header" style="padding: 16px 24px; margin-bottom: 0; border-bottom: 1px solid #f1f5f9; background: #fff;">
|
||||
<h3 style="color: #1e5149; font-weight: 700; font-size: 16px;">첨부 이미지 확대 보기</h3>
|
||||
<span class="modal-close" onclick="ModalManager.close('imageModal')" style="font-size: 24px; color: #94a3b8;">×</span>
|
||||
</div>
|
||||
<div style="padding: 32px; background: #fff; display: flex; justify-content: center; align-items: center; min-height: 300px; max-height: 75vh; overflow: auto;">
|
||||
<img id="modalImage" style="max-width: 100%; height: auto; border-radius: 8px; box-shadow: 0 10px 25px -5px rgba(0,0,0,0.1), 0 8px 10px -6px rgba(0,0,0,0.1);">
|
||||
</div>
|
||||
<div style="padding: 16px 24px; border-top: 1px solid #f1f5f9; text-align: right; background: #fff;">
|
||||
<button class="_button-medium" style="background: #1e5149; color: #fff; border: none; padding: 10px 28px; border-radius: 8px; cursor: pointer; transition: background 0.2s;"
|
||||
onmouseover="this.style.background='#163b36'" onmouseout="this.style.background='#1e5149'"
|
||||
onclick="ModalManager.close('imageModal')">닫기</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="js/common.js"></script>
|
||||
<script src="js/inquiries.js"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -1,144 +1,144 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Project Mail Manager</title>
|
||||
<link rel="stylesheet" as="style" crossorigin
|
||||
href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/static/pretendard.min.css" />
|
||||
<link rel="stylesheet" href="style/common.css">
|
||||
<link rel="stylesheet" href="style/mail.css">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
{% include 'modals/path_selector.html' %}
|
||||
|
||||
<nav class="topbar">
|
||||
<div class="topbar-header">
|
||||
<a href="/">
|
||||
<h2>Project Master Test</h2>
|
||||
</a>
|
||||
</div>
|
||||
<ul class="nav-list">
|
||||
<li class="nav-item" onclick="location.href='/dashboard'">대시보드</li>
|
||||
<li class="nav-item" onclick="location.href='/inquiries'">문의사항</li>
|
||||
<li class="nav-item active" onclick="location.href='/mailTest'">메일관리</li>
|
||||
<li class="nav-item" onclick="location.href='/analysis'">분석</li>
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
<div class="mail-wrapper">
|
||||
<!-- 메일 리스트 영역 (사이드바 삭제됨) -->
|
||||
<section class="mail-list-area">
|
||||
<div class="mail-tabs">
|
||||
<div class="mail-tab active" onclick="switchMailTab(this, 'inbound')">📥 수신</div>
|
||||
<div class="mail-tab" onclick="switchMailTab(this, 'outbound')">📤 발신</div>
|
||||
<div class="mail-tab" onclick="switchMailTab(this, 'drafts')">📝 임시</div>
|
||||
<div class="mail-tab" onclick="switchMailTab(this, 'deleted')">🗑️ 휴지통</div>
|
||||
</div>
|
||||
<div class="search-bar" style="display:flex; flex-direction:column; gap:8px;">
|
||||
<input type="text" style="height: 32px; width:100%;" placeholder="제목, 내용, 기관 검색...">
|
||||
|
||||
<select style="height: 32px; width:100%; padding:4px; font-size:12px;">
|
||||
<option>모든 상대기관</option>
|
||||
<option>라오스 농림부</option>
|
||||
<option>베트남 전력청</option>
|
||||
</select>
|
||||
|
||||
<div class="flex-center" style="gap:4px; width:100%;">
|
||||
<input type="date" id="startDate"
|
||||
style="flex:1; height:32px; padding:4px; font-size:11px; border:1px solid var(--border-color); border-radius:4px;">
|
||||
<span style="font-size:12px; color:var(--text-sub);">~</span>
|
||||
<input type="date" id="endDate"
|
||||
style="flex:1; height:32px; padding:4px; font-size:11px; border:1px solid var(--border-color); border-radius:4px;">
|
||||
</div>
|
||||
|
||||
<div class="flex-center" style="gap:8px; margin-top:4px;">
|
||||
<button class="btn-confirm" style="flex:1; height:32px; font-size:12px; background:var(--primary-color); color:#fff; display:flex; align-items:center; justify-content:center;" onclick="searchMails()">검색</button>
|
||||
<button class="btn-confirm" style="flex:1; height:32px; font-size:12px; background:#fff; color:var(--text-sub); border:1px solid var(--border-color); display:flex; align-items:center; justify-content:center;" onclick="resetSearch()">초기화</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 선택 삭제 액션바 -->
|
||||
<div id="mailBulkActions" class="mail-bulk-actions">
|
||||
<div class="flex-center" style="gap:8px;">
|
||||
<input type="checkbox" id="selectAllMails" onclick="toggleSelectAll(this)">
|
||||
<span id="selectedCount">0개 선택됨</span>
|
||||
</div>
|
||||
<button class="_button-xsmall" style="background:#fff5f5; color:#e53e3e; border:1px solid #feb2b2;" onclick="deleteSelectedMails()">선택 삭제</button>
|
||||
</div>
|
||||
|
||||
<div class="mail-items-container">
|
||||
<!-- JavaScript (renderMailList)에서 렌더링됨 -->
|
||||
</div>
|
||||
<!-- 메일쓰기 및 주소록 버튼 하단 고정 -->
|
||||
<div class="address-book-footer flex-center" style="gap:8px;">
|
||||
<button class="btn-confirm"
|
||||
style="background:var(--primary-color); color:#fff; font-size:12px; padding:8px; flex:1;"
|
||||
onclick="alert('메일 쓰기 창을 엽니다.')">✍️ 메일쓰기</button>
|
||||
<button class="btn-confirm"
|
||||
style="background:#fff; color:var(--primary-color); border:1px solid var(--primary-color); font-size:12px; padding:8px; flex:1;"
|
||||
onclick="openAddressBook()">📘 주소록</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 메일 본문 영역 -->
|
||||
<section class="mail-content-area">
|
||||
<div class="mail-content-header">
|
||||
<h2 style="font-size:18px; color:var(--primary-color); margin-bottom:8px;">ITTC 교육센터 착공식 일정 협의 요청</h2>
|
||||
<div style="font-size:12px; color:var(--text-sub);"><strong>보낸사람</strong> pany.s@lao.gov.la (라오스 농림부)
|
||||
</div>
|
||||
<div style="font-size:12px; color:var(--text-sub);"><strong>날짜</strong> 2026년 2월 26일 14:30</div>
|
||||
</div>
|
||||
<div class="mail-body">
|
||||
안녕하세요. 이태훈 선임연구원님.<br><br>
|
||||
라오스 ITTC 관개 교육센터 착공식과 관련하여 정부 측 인사의 일정을 반영한 최종 공문을 송부합니다.<br>
|
||||
</div>
|
||||
|
||||
<div class="attachment-area">
|
||||
<div class="flex-between" style="margin-bottom:12px;">
|
||||
<div style="font-weight:700; font-size:13px;">첨부파일 리스트</div>
|
||||
<div class="ai-toggle-wrap">
|
||||
<span class="ai-label">AI 판단</span>
|
||||
<label class="switch">
|
||||
<input type="checkbox" id="aiToggle" onchange="renderFiles()">
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div id="attachmentList"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 우측 미리보기 영역 -->
|
||||
<aside class="mail-preview-area" id="mailPreviewArea">
|
||||
<!-- 토글 핸들 버튼 -->
|
||||
<div class="preview-toggle-handle" onclick="togglePreviewAuto()">
|
||||
<span id="previewToggleIcon">▶</span>
|
||||
</div>
|
||||
|
||||
<div class="preview-header">
|
||||
<div class="flex-center" style="gap:8px;">
|
||||
<h3>미리보기</h3>
|
||||
<span style="font-size:10px; color:var(--text-sub); font-weight:400;">최대 10페이지까지만 표시됩니다.</span>
|
||||
</div>
|
||||
<div class="flex-center" style="gap:12px;">
|
||||
<button id="fullViewBtn" class="_button-xsmall"
|
||||
style="background:var(--primary-lv-0); color:#111111; border:none; padding:4px 12px; height:24px; cursor:pointer; display:none;">전체보기</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="a4-container" id="previewContainer">
|
||||
<div class="preview-placeholder">파일을 클릭하면<br>미리보기가 표시됩니다.</div>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
{% include 'modals/address_book.html' %}
|
||||
|
||||
<script src="js/common.js"></script>
|
||||
<script src="js/mail.js"></script>
|
||||
</body>
|
||||
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Project Mail Manager</title>
|
||||
<link rel="stylesheet" as="style" crossorigin
|
||||
href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/static/pretendard.min.css" />
|
||||
<link rel="stylesheet" href="style/common.css">
|
||||
<link rel="stylesheet" href="style/mail.css">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
{% include 'modals/path_selector.html' %}
|
||||
|
||||
<nav class="topbar">
|
||||
<div class="topbar-header">
|
||||
<a href="/">
|
||||
<h2>Project Master Test</h2>
|
||||
</a>
|
||||
</div>
|
||||
<ul class="nav-list">
|
||||
<li class="nav-item" onclick="location.href='/dashboard'">대시보드</li>
|
||||
<li class="nav-item" onclick="location.href='/inquiries'">문의사항</li>
|
||||
<li class="nav-item active" onclick="location.href='/mailTest'">메일관리</li>
|
||||
<li class="nav-item" onclick="location.href='/analysis'">분석</li>
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
<div class="mail-wrapper">
|
||||
<!-- 메일 리스트 영역 (사이드바 삭제됨) -->
|
||||
<section class="mail-list-area">
|
||||
<div class="mail-tabs">
|
||||
<div class="mail-tab active" onclick="switchMailTab(this, 'inbound')">📥 수신</div>
|
||||
<div class="mail-tab" onclick="switchMailTab(this, 'outbound')">📤 발신</div>
|
||||
<div class="mail-tab" onclick="switchMailTab(this, 'drafts')">📝 임시</div>
|
||||
<div class="mail-tab" onclick="switchMailTab(this, 'deleted')">🗑️ 휴지통</div>
|
||||
</div>
|
||||
<div class="search-bar" style="display:flex; flex-direction:column; gap:8px;">
|
||||
<input type="text" style="height: 32px; width:100%;" placeholder="제목, 내용, 기관 검색...">
|
||||
|
||||
<select style="height: 32px; width:100%; padding:4px; font-size:12px;">
|
||||
<option>모든 상대기관</option>
|
||||
<option>라오스 농림부</option>
|
||||
<option>베트남 전력청</option>
|
||||
</select>
|
||||
|
||||
<div class="flex-center" style="gap:4px; width:100%;">
|
||||
<input type="date" id="startDate"
|
||||
style="flex:1; height:32px; padding:4px; font-size:11px; border:1px solid var(--border-color); border-radius:4px;">
|
||||
<span style="font-size:12px; color:var(--text-sub);">~</span>
|
||||
<input type="date" id="endDate"
|
||||
style="flex:1; height:32px; padding:4px; font-size:11px; border:1px solid var(--border-color); border-radius:4px;">
|
||||
</div>
|
||||
|
||||
<div class="flex-center" style="gap:8px; margin-top:4px;">
|
||||
<button class="btn-confirm" style="flex:1; height:32px; font-size:12px; background:var(--primary-color); color:#fff; display:flex; align-items:center; justify-content:center;" onclick="searchMails()">검색</button>
|
||||
<button class="btn-confirm" style="flex:1; height:32px; font-size:12px; background:#fff; color:var(--text-sub); border:1px solid var(--border-color); display:flex; align-items:center; justify-content:center;" onclick="resetSearch()">초기화</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 선택 삭제 액션바 -->
|
||||
<div id="mailBulkActions" class="mail-bulk-actions">
|
||||
<div class="flex-center" style="gap:8px;">
|
||||
<input type="checkbox" id="selectAllMails" onclick="toggleSelectAll(this)">
|
||||
<span id="selectedCount">0개 선택됨</span>
|
||||
</div>
|
||||
<button class="_button-xsmall" style="background:#fff5f5; color:#e53e3e; border:1px solid #feb2b2;" onclick="deleteSelectedMails()">선택 삭제</button>
|
||||
</div>
|
||||
|
||||
<div class="mail-items-container">
|
||||
<!-- JavaScript (renderMailList)에서 렌더링됨 -->
|
||||
</div>
|
||||
<!-- 메일쓰기 및 주소록 버튼 하단 고정 -->
|
||||
<div class="address-book-footer flex-center" style="gap:8px;">
|
||||
<button class="btn-confirm"
|
||||
style="background:var(--primary-color); color:#fff; font-size:12px; padding:8px; flex:1;"
|
||||
onclick="alert('메일 쓰기 창을 엽니다.')">✍️ 메일쓰기</button>
|
||||
<button class="btn-confirm"
|
||||
style="background:#fff; color:var(--primary-color); border:1px solid var(--primary-color); font-size:12px; padding:8px; flex:1;"
|
||||
onclick="openAddressBook()">📘 주소록</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 메일 본문 영역 -->
|
||||
<section class="mail-content-area">
|
||||
<div class="mail-content-header">
|
||||
<h2 style="font-size:18px; color:var(--primary-color); margin-bottom:8px;">ITTC 교육센터 착공식 일정 협의 요청</h2>
|
||||
<div style="font-size:12px; color:var(--text-sub);"><strong>보낸사람</strong> pany.s@lao.gov.la (라오스 농림부)
|
||||
</div>
|
||||
<div style="font-size:12px; color:var(--text-sub);"><strong>날짜</strong> 2026년 2월 26일 14:30</div>
|
||||
</div>
|
||||
<div class="mail-body">
|
||||
안녕하세요. 이태훈 선임연구원님.<br><br>
|
||||
라오스 ITTC 관개 교육센터 착공식과 관련하여 정부 측 인사의 일정을 반영한 최종 공문을 송부합니다.<br>
|
||||
</div>
|
||||
|
||||
<div class="attachment-area">
|
||||
<div class="flex-between" style="margin-bottom:12px;">
|
||||
<div style="font-weight:700; font-size:13px;">첨부파일 리스트</div>
|
||||
<div class="ai-toggle-wrap">
|
||||
<span class="ai-label">AI 판단</span>
|
||||
<label class="switch">
|
||||
<input type="checkbox" id="aiToggle" onchange="renderFiles()">
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div id="attachmentList"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 우측 미리보기 영역 -->
|
||||
<aside class="mail-preview-area" id="mailPreviewArea">
|
||||
<!-- 토글 핸들 버튼 -->
|
||||
<div class="preview-toggle-handle" onclick="togglePreviewAuto()">
|
||||
<span id="previewToggleIcon">▶</span>
|
||||
</div>
|
||||
|
||||
<div class="preview-header">
|
||||
<div class="flex-center" style="gap:8px;">
|
||||
<h3>미리보기</h3>
|
||||
<span style="font-size:10px; color:var(--text-sub); font-weight:400;">최대 10페이지까지만 표시됩니다.</span>
|
||||
</div>
|
||||
<div class="flex-center" style="gap:12px;">
|
||||
<button id="fullViewBtn" class="_button-xsmall"
|
||||
style="background:var(--primary-lv-0); color:#111111; border:none; padding:4px 12px; height:24px; cursor:pointer; display:none;">전체보기</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="a4-container" id="previewContainer">
|
||||
<div class="preview-placeholder">파일을 클릭하면<br>미리보기가 표시됩니다.</div>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
{% include 'modals/address_book.html' %}
|
||||
|
||||
<script src="js/common.js"></script>
|
||||
<script src="js/mail.js"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -1,62 +1,62 @@
|
||||
<!-- 주소록 모달 (공통 규격 적용) -->
|
||||
<div id="addressBookModal" class="modal-overlay" onclick="ModalManager.close('addressBookModal')">
|
||||
<div class="modal-content" style="max-width: 850px;" onclick="event.stopPropagation()">
|
||||
<div class="modal-header">
|
||||
<h3>공사 관계자 주소록</h3>
|
||||
<div class="flex-center" style="gap:10px;">
|
||||
<button class="btn btn-primary" onclick="toggleAddContactForm()">+ 추가하기</button>
|
||||
<span class="modal-close" onclick="ModalManager.close('addressBookModal')">×</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 주소록 추가 폼 (기본 숨김) -->
|
||||
<div id="addContactForm"
|
||||
style="display:none; background:var(--bg-muted); padding:20px; border-radius:12px; margin-bottom:20px; border:1px solid var(--border-color);">
|
||||
<div style="display:grid; grid-template-columns: 1fr 1fr; gap:12px; margin-bottom:15px;">
|
||||
<div class="input-group">
|
||||
<label style="font-size:11px; margin-bottom:4px; display:block;">성명</label>
|
||||
<input type="text" id="newContactName" placeholder="성명 입력" style="height:36px; padding:0 12px; border:1px solid #ddd; border-radius:6px; width:100%; font-size:13px;">
|
||||
</div>
|
||||
<div class="input-group">
|
||||
<label style="font-size:11px; margin-bottom:4px; display:block;">소속/직위</label>
|
||||
<input type="text" id="newContactDept" placeholder="소속/직위 입력" style="height:36px; padding:0 12px; border:1px solid #ddd; border-radius:6px; width:100%; font-size:13px;">
|
||||
</div>
|
||||
<div class="input-group">
|
||||
<label style="font-size:11px; margin-bottom:4px; display:block;">이메일</label>
|
||||
<input type="text" id="newContactEmail" placeholder="이메일 입력" style="height:36px; padding:0 12px; border:1px solid #ddd; border-radius:6px; width:100%; font-size:13px;">
|
||||
</div>
|
||||
<div class="input-group">
|
||||
<label style="font-size:11px; margin-bottom:4px; display:block;">연락처</label>
|
||||
<input type="text" id="newContactPhone" placeholder="연락처 입력" style="height:36px; padding:0 12px; border:1px solid #ddd; border-radius:6px; width:100%; font-size:13px;">
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-center" style="gap:10px;">
|
||||
<button class="btn btn-primary" style="flex:1;" onclick="addContact()">저장</button>
|
||||
<button class="btn btn-secondary" style="flex:1;" onclick="toggleAddContactForm()">취소</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="search-bar" style="background:#fff; padding:0 0 15px 0;">
|
||||
<input type="text" placeholder="이름, 부서, 연락처 검색..." style="width:100%; height: 36px; padding:0 12px; border:1px solid var(--border-color); border-radius:6px;">
|
||||
</div>
|
||||
<div style="max-height: 400px; overflow-y: auto; border:1px solid var(--border-color); border-radius:8px;">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>성명</th>
|
||||
<th>소속/직위</th>
|
||||
<th>이메일</th>
|
||||
<th>연락처</th>
|
||||
<th style="text-align:right; padding-right:15px;">관리</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="addressBookBody">
|
||||
<!-- 동적으로 렌더링됨 -->
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div style="margin-top:20px; text-align:right;">
|
||||
<button class="btn btn-secondary" style="padding: 8px 32px;" onclick="ModalManager.close('addressBookModal')">닫기</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 주소록 모달 (공통 규격 적용) -->
|
||||
<div id="addressBookModal" class="modal-overlay" onclick="ModalManager.close('addressBookModal')">
|
||||
<div class="modal-content" style="max-width: 850px;" onclick="event.stopPropagation()">
|
||||
<div class="modal-header">
|
||||
<h3>공사 관계자 주소록</h3>
|
||||
<div class="flex-center" style="gap:10px;">
|
||||
<button class="btn btn-primary" onclick="toggleAddContactForm()">+ 추가하기</button>
|
||||
<span class="modal-close" onclick="ModalManager.close('addressBookModal')">×</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 주소록 추가 폼 (기본 숨김) -->
|
||||
<div id="addContactForm"
|
||||
style="display:none; background:var(--bg-muted); padding:20px; border-radius:12px; margin-bottom:20px; border:1px solid var(--border-color);">
|
||||
<div style="display:grid; grid-template-columns: 1fr 1fr; gap:12px; margin-bottom:15px;">
|
||||
<div class="input-group">
|
||||
<label style="font-size:11px; margin-bottom:4px; display:block;">성명</label>
|
||||
<input type="text" id="newContactName" placeholder="성명 입력" style="height:36px; padding:0 12px; border:1px solid #ddd; border-radius:6px; width:100%; font-size:13px;">
|
||||
</div>
|
||||
<div class="input-group">
|
||||
<label style="font-size:11px; margin-bottom:4px; display:block;">소속/직위</label>
|
||||
<input type="text" id="newContactDept" placeholder="소속/직위 입력" style="height:36px; padding:0 12px; border:1px solid #ddd; border-radius:6px; width:100%; font-size:13px;">
|
||||
</div>
|
||||
<div class="input-group">
|
||||
<label style="font-size:11px; margin-bottom:4px; display:block;">이메일</label>
|
||||
<input type="text" id="newContactEmail" placeholder="이메일 입력" style="height:36px; padding:0 12px; border:1px solid #ddd; border-radius:6px; width:100%; font-size:13px;">
|
||||
</div>
|
||||
<div class="input-group">
|
||||
<label style="font-size:11px; margin-bottom:4px; display:block;">연락처</label>
|
||||
<input type="text" id="newContactPhone" placeholder="연락처 입력" style="height:36px; padding:0 12px; border:1px solid #ddd; border-radius:6px; width:100%; font-size:13px;">
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-center" style="gap:10px;">
|
||||
<button class="btn btn-primary" style="flex:1;" onclick="addContact()">저장</button>
|
||||
<button class="btn btn-secondary" style="flex:1;" onclick="toggleAddContactForm()">취소</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="search-bar" style="background:#fff; padding:0 0 15px 0;">
|
||||
<input type="text" placeholder="이름, 부서, 연락처 검색..." style="width:100%; height: 36px; padding:0 12px; border:1px solid var(--border-color); border-radius:6px;">
|
||||
</div>
|
||||
<div style="max-height: 400px; overflow-y: auto; border:1px solid var(--border-color); border-radius:8px;">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>성명</th>
|
||||
<th>소속/직위</th>
|
||||
<th>이메일</th>
|
||||
<th>연락처</th>
|
||||
<th style="text-align:right; padding-right:15px;">관리</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="addressBookBody">
|
||||
<!-- 동적으로 렌더링됨 -->
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div style="margin-top:20px; text-align:right;">
|
||||
<button class="btn btn-secondary" style="padding: 8px 32px;" onclick="ModalManager.close('addressBookModal')">닫기</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,24 +1,24 @@
|
||||
<!-- 경로 선택 모달 (공통 규격 적용) -->
|
||||
<div id="pathModal" class="modal-overlay" onclick="ModalManager.close('pathModal')">
|
||||
<div class="modal-content" style="max-width: 500px;" onclick="event.stopPropagation()">
|
||||
<div class="modal-header">
|
||||
<h3>파일 보관 경로 선택</h3>
|
||||
<span class="modal-close" onclick="ModalManager.close('pathModal')">×</span>
|
||||
</div>
|
||||
<div style="display: flex; flex-direction: column; gap: 16px; margin-bottom: 24px;">
|
||||
<div class="select-group" style="margin-bottom: 0;">
|
||||
<label>탭 (Tab)</label>
|
||||
<select id="tabSelect" class="modal-select" onchange="updateCategories()"></select>
|
||||
</div>
|
||||
<div class="select-group" style="margin-bottom: 0;">
|
||||
<label>카테고리 (Category)</label>
|
||||
<select id="categorySelect" class="modal-select" onchange="updateSubs()"></select>
|
||||
</div>
|
||||
<div class="select-group" style="margin-bottom: 0;">
|
||||
<label>서브카테고리 (Sub-Category)</label>
|
||||
<select id="subSelect" class="modal-select"></select>
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn btn-primary" style="width: 100%; height: 44px;" onclick="applyPathSelection()">경로 확정하기</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 경로 선택 모달 (공통 규격 적용) -->
|
||||
<div id="pathModal" class="modal-overlay" onclick="ModalManager.close('pathModal')">
|
||||
<div class="modal-content" style="max-width: 500px;" onclick="event.stopPropagation()">
|
||||
<div class="modal-header">
|
||||
<h3>파일 보관 경로 선택</h3>
|
||||
<span class="modal-close" onclick="ModalManager.close('pathModal')">×</span>
|
||||
</div>
|
||||
<div style="display: flex; flex-direction: column; gap: 16px; margin-bottom: 24px;">
|
||||
<div class="select-group" style="margin-bottom: 0;">
|
||||
<label>탭 (Tab)</label>
|
||||
<select id="tabSelect" class="modal-select" onchange="updateCategories()"></select>
|
||||
</div>
|
||||
<div class="select-group" style="margin-bottom: 0;">
|
||||
<label>카테고리 (Category)</label>
|
||||
<select id="categorySelect" class="modal-select" onchange="updateSubs()"></select>
|
||||
</div>
|
||||
<div class="select-group" style="margin-bottom: 0;">
|
||||
<label>서브카테고리 (Sub-Category)</label>
|
||||
<select id="subSelect" class="modal-select"></select>
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn btn-primary" style="width: 100%; height: 44px;" onclick="applyPathSelection()">경로 확정하기</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
2456
tokens.json
2456
tokens.json
File diff suppressed because it is too large
Load Diff
28
verify_swvw.py
Normal file
28
verify_swvw.py
Normal 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()
|
||||
Reference in New Issue
Block a user