Compare commits

...

17 Commits

Author SHA1 Message Date
0953f49db1 feat: implement P-WAR system analysis and inquiries sorting functionality 2026-03-19 17:59:50 +09:00
faa7c8e221 feat: 메일 시스템 AI 분석 기능 활성화 및 startAnalysis 함수 구현 2026-03-18 18:07:44 +09:00
840e7dab34 refactor: 시스템 전반 코드 리팩토링 및 문의사항 UI 개선 2026-03-17 17:49:17 +09:00
d0b33edea8 refactor: SQL 쿼리 관리 모듈화 및 메일 관리 UI/UX 고도화 2026-03-17 14:27:25 +09:00
74f11d3bd4 feat: 대시보드 경고 및 위험 알림 스타일 복구 2026-03-16 17:41:46 +09:00
5ddaad6a2e feat: 문의사항(Q&A) 게시판 구현 및 대시보드 활성도 분석 로직 개선
- [server.py] 문의사항 API(목록, 상세, 답변 저장/삭제) 및 라우트 추가
- [templates/inquiries.html] 문의사항 게시판 HTML 구조 구현 (Sticky 헤더, 아코디언 상세)
- [js/inquiries.js] 비동기 데이터 로드, 아코디언 토글, 답변 편집 로직 구현
- [style/inquiries.css] 와이드 레이아웃, Sticky 헤더, 아코디언 및 상태 배지 스타일 적용
- [server.py, js/dashboard.js] '폴더 자동 삭제' 로그 발생 시 14일 경과 여부와 관계없이 '방치'로 분류하도록 로직 수정
- [templates/dashboard.html] 대시보드 내 문의사항 탭 활성화 및 링크 연동
2026-03-12 18:01:51 +09:00
600c54c1f0 feat: 크롤러 부서 정보 수집 추가 및 대시보드 데이터 정확도 개선
- getData API 가로채기 기능을 통한 부서(department) 자동 수집 구현
- 파일 0개 기준의 "데이터 없음" 분류 로직 최적화 (LEFT JOIN 적용)
- 관리자 권한 인증 모달 스타일 복구 및 UI 정밀 조정
- 중복 등록 프로젝트(sm-25-032-phlinfra) DB 정리 및 테스트 파일 삭제
2026-03-11 17:52:12 +09:00
9f06857bea feat: 프로젝트 활성도 분석 시스템 및 크롤링 인증/중단 기능 구현 - DB 연결 최적화, 활성도 위젯 및 내비게이션, 관리자 인증 모달, 중단 기능, UI 레이아웃 최적화, 코드 리팩토링 및 파일 정리 2026-03-11 14:03:26 +09:00
4a995c11f4 feat: MySQL DB 정규화(Master/History) 및 시계열 데이터 수집 시스템 통합
1. 마스터/히스토리 테이블 분리 및 마이그레이션 완료\n2. 날짜별 데이터 축적 및 대시보드 필터링 기능 추가\n3. Playwright 수집 로직(날짜필터, 좌표클릭, 정밀합산) 완전 복구
2026-03-10 16:24:13 +09:00
743cce543b feat: MySQL DB 연동 및 크롤링 로직 정상화 (ID 매칭 및 데이터 정밀화) 2026-03-10 14:11:49 +09:00
9369e18eb8 feat: 대시보드 데이터 파싱 로직 고도화 및 크롤링 서비스 개선
- 대시보드: 8컬럼 형식의 sheet.csv를 안정적으로 지원하도록 파싱 로직 개선

- 크롤러: Playwright 기반 크롤링 엔진 고도화 및 실시간 로그 전송 기능 강화

- UI/UX: 대시보드 동기화 버튼 및 헤더 레이아웃 최적화
2026-03-06 18:10:19 +09:00
eebd3a89e5 docs: PLAN.md 메일 관리 시스템 고도화 완료 업데이트 2026-03-05 18:05:00 +09:00
25f6ee2055 feat: 메일 관리 시스템 UX 개선 및 기능 고도화
- UI/UX: 메일 리스트 영역 너비 확장(400px), 검색 및 날짜 선택 영역 여백 조정
- 기능: 탭별 샘플 데이터 동적 렌더링, 메일 검색 및 초기화 기능 구현
- 기능: 메일 선택 시 본문 내용 동적 업데이트 연동
- 기능: 메일 개별 삭제(텍스트 버튼) 및 체크박스를 이용한 다중 삭제 기능 추가
- UI: 메일 쓰기 및 주소록 버튼 하단 고정 레이아웃 적용
2026-03-05 18:03:59 +09:00
d246b08799 feat: 메일 관리 UI 개편 및 시스템 구조 최적화
- UI/UX: 메일 관리 레이아웃 고도화 및 미리보기 토글 핸들 도입
- 기능: 주소록 CRUD 기능 추가 및 모달 인터페이스 개선
- 구조: CSS 파일 기능별 분리 및 Jinja2 템플릿 엔진 도입
- 백엔드: OCR 비동기 처리 및 CSV 파싱(BOM) 안정화
- 데이터: 2026.03.04 기준 최신 프로젝트 현황 업데이트
2026-03-04 17:58:54 +09:00
ff9146cfee 메일 분석시스템 보완 2026-02-27 17:52:34 +09:00
9bb2ecd703 README.md - 디자인 가이드 추가 2026-02-26 18:03:12 +09:00
6d71f94ca8 style - 디자인 가이드 적용
crawler_api.py - 클릭방식으로 변환
README.md - 디자인 가이드 추가
analyze.md - 텍스트 비교 방식으로 분석
2026-02-26 17:47:16 +09:00
50 changed files with 5054 additions and 993 deletions

6
.env Normal file
View File

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

14
.gemini/settings.json Normal file
View File

@@ -0,0 +1,14 @@
{
"mcpServers": {
"gitea": {
"command": "npx",
"args": [
"@andrebuzeli/git-mcp@latest"
],
"env": {
"GITEA_HOST": "https://gitea.hmac.kr",
"GITEA_ACCESS_TOKEN": "34a35034b9335b5129c8bfcd27e841d83f0aeaed"
}
}
}
}

BIN
.gitignore vendored Normal file

Binary file not shown.

View File

@@ -5,9 +5,12 @@
## 2. 주요 기능 상세 ## 2. 주요 기능 상세
### ① 문의 및 요구사항 관리 (Inquiry Management) ### ① 메일 관리 및 요구사항 시스템 (Mail & Inquiry Management) - [완료]
- 사용자의 분석 요청 및 시스템 문의 사항 리스트업 - **UI/UX 고도화**: 리스트 영역 너비 확장(400px) 및 시각적 가독성 개선
- 상태값(대기, 처리중, 완료) 관리 및 답변 등록 기능 - **검색 및 필터링**: 키워드 및 기간별 메일 검색 기능 구현
- **동적 연동**: 리스트 클릭 시 메일 본문 실시간 업데이트 구현
- **메일 관리**: 개별 삭제 및 체크박스를 활용한 대량 삭제 기능 추가
- **탭 시스템**: 수신/발신/임시/휴지통별 데이터 분류 및 동적 렌더링 적용
### ② 로그 관리 (Log Management) ### ② 로그 관리 (Log Management)
- **최근 로그**: 실시간으로 발생하는 시스템 및 분석 작업 로그 출력 - **최근 로그**: 실시간으로 발생하는 시스템 및 분석 작업 로그 출력

View File

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

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

167
analyze.py Normal file
View File

@@ -0,0 +1,167 @@
import os
import re
import unicodedata
from pypdf import PdfReader
import pytesseract
from pdf2image import convert_from_path
# 1. 시스템 설정
TESSERACT_EXE = r'C:\Users\User\AppData\Local\Programs\Tesseract-OCR\tesseract.exe'
TESSDATA_DIR = r'C:\Users\User\AppData\Local\Programs\Tesseract-OCR\tessdata'
POPPLER_BIN = r'D:\이태훈\00크롬다운로드\poppler-25.12.0\Library\bin'
pytesseract.pytesseract.tesseract_cmd = TESSERACT_EXE
os.environ["TESSDATA_PREFIX"] = TESSDATA_DIR
OCR_AVAILABLE = os.path.exists(TESSERACT_EXE)
SYSTEM_HIERARCHY = {
"행정": {
"계약": ["계약관리", "기성관리", "업무지시서", "인원관리"],
"업무관리": ["업무일지(2025)", "업무일지(2025년 이전)", "발주처 정기보고", "본사업무보고", "공사감독일지", "양식서류"]
},
"설계성과품": {
"시방서": ["공사시방서", "장비 반입허가 검토서"],
"설계도면": ["공통", "토공", "비탈면안전공", "배수공", "교량공", "포장공", "교통안전시설공", "부대공", "용지공 & 기타공"],
"수량산출서": ["토공", "비탈면안전공", "배수공", "교량공", "포장공", "교통안전시설공", "부대공", "용지공 & 기타공"],
"내역서": ["단가산출서"],
"보고서": ["실시설계보고서", "지반조사보고서", "구조계산서", "수리 및 전기계산서", "기타보고서", "기술자문 및 심의"],
"측량계산부": ["측량계산부"],
"설계단계 수행협의": ["회의·협의"]
},
"시공성과품": {
"설계도면": ["공통", "토공", "비탈면안전공", "배수공", "교량공", "포장공", "교통안전시설공", "부대공", "용지공 & 기타공"]
},
"시공검측": {
"토공": ["검측 (깨기)", "검측 (연약지반)", "검측 (발파)", "검측 (노체)", "검측 (노상)", "검측 (토취장)"],
"배수공": ["검측 (V형측구)", "검측 (산마루측구)", "검측 (U형측구)", "검측 (U형측구)(안)", "검측 (L형측구, J형측구)", "검측 (도수로)", "검측 (도수로)(안)", "검측 (횡배수관)", "검측 (종배수관)", "검측 (맹암거)", "검측 (통로암거)", "검측 (수로암거)", "검측 (호안공)", "검측 (옹벽공)", "검측 (용수개거)"],
"구조물공": ["검측 (평목교-거더, 부대공)", "검측 (평목교)(안)", "검측 (개착터널, 생태통로)"],
"포장공": ["검측 (기층, 보조기층)"],
"부대공": ["검측 (환경)", "검측 (지장가옥,건물 철거)", "검측 (방음벽 등)"],
"비탈면안전공": ["검측 (식생보호공)", "검측 (구조물보호공)"],
"교통안전시설공": ["검측 (낙석방지책)"],
"검측 양식서류": ["검측 양식서류"]
},
"설계변경": {
"실정보고(어천~공주)": ["토공", "배수공", "교량공(평목교)", "구조물공", "포장공", "교통안전공", "부대공", "전기공사", "미확정공", "안전관리", "환경관리", "품질관리", "자재관리", "지장물", "기타"],
"실정보고(대술~정안)": ["토공", "배수공", "비탈면안전공", "포장공", "부대공", "안전관리", "환경관리", "자재관리", "기타"],
"기술지원 검토": ["토공", "배수공", "교량공(평목교)", "구조물&부대공", "기타"],
"시공계획(어천~공주)": ["토공", "배수공", "교량공(평목교)", "구조물&부대&포장&교통안전공", "환경 및 품질관리"]
},
"공사관리": {
"공정·일정": ["공정표", "월간 공정보고", "작업일보"],
"품질 관리": ["품질시험계획서", "품질시험 실적보고", "콘크리트 타설현황[어천~공주(4차)]", "품질관리비 사용내역", "균열관리", "품질관리 양식서류"],
"안전 관리": ["안전관리계획서", "안전관리 실적보고", "위험성 평가", "사전작업허가서", "안전관리비 사용내역", "안전관리수준평가", "안전관리 양식서류"],
"환경 관리": ["환경영향평가", "사전재해영향성검토", "유지관리 및 보수점검", "환경보전비 사용내역", "건설폐기물 관리"],
"자재 관리 (관급)": ["자재구매요청 (레미콘, 철근)", "자재구매요청 (그 외)", "납품기한", "계약 변경", "자재 반입·수불 관리", "자재관리 양식서류"],
"자재 관리 (사급)": ["자재공급원 승인", "자재 반입·수불 관리", "자재 검수·확인"],
"점검 (정리중)": ["내부점검", "외부점검"],
"공문": ["접수(수신)", "발송(발신)", "하도급", "인력", "방침"]
},
"민원관리": {
"민원(어천~공주)": ["처리대장", "보상", "공사일반", "환경분쟁"],
"실정보고(어천~공주)": ["민원"],
"실정보고(대술~정안)": ["민원"]
}
}
def analyze_flow_reasoning(filename, all_text_list):
"""
본문의 전수 조사 결과에 파일명의 '의도 가중치'를 더해 최종 추론
"""
full_text = " ".join(all_text_list)
clean_ctx = full_text.replace(" ", "").replace("\n", "").lower()
fn_clean = filename.replace(" ", "").lower()
# 1. 도메인별 기본 점수 (본문 전수 조사 - 평등하게)
scores = {
"official": sum(clean_ctx.count(k) for k in ["수신:", "발신:", "경유:", "시행일자", "귀하", "드립니다", "바랍니다"]),
"contract": sum(clean_ctx.count(k) for k in ["계약서", "하도급", "외주", "도급", "인감", "사업자"]),
"hr": sum(clean_ctx.count(k) for k in ["이탈계", "인력", "기술자", "안전관리자", "재직증명", "배치"]),
"change": sum(clean_ctx.count(k) for k in ["실정보고", "설계변경", "변경보고", "추가반영"]),
"technical": sum(clean_ctx.count(k) for k in ["일위대가", "산출근거", "집계표", "물량산출", "단가", "내역", "도면", "dwg"])
}
# 2. 파일명에 대한 '방향타' 가중치 부여 (Final Push)
# 본문 데이터가 아무리 많아도 파일명의 의도를 존중하기 위해 7배 가중치
if "실정" in fn_clean or "변경" in fn_clean: scores["change"] += 50 # 본문 50회 언급과 맞먹는 가중치
if "계약" in fn_clean or "하도급" in fn_clean: scores["contract"] += 50
if "인력" in fn_clean or "이탈" in fn_clean: scores["hr"] += 50
if "단가" in fn_clean or "수량" in fn_clean or "도면" in fn_clean: scores["technical"] += 50
if "제출" in fn_clean or "" in fn_clean: scores["official"] += 30
# 3. 종합 농도에 따른 최종 도메인 선정
dominant_domain = max(scores, key=scores.get)
# 프로젝트 식별 (Fuzzy 매칭 및 교차 검증)
project_loc = "어천~공주" if any(k in clean_ctx or k in fn_clean for k in ["어천", "공주"]) else "대술~정안" if any(k in clean_ctx or k in fn_clean for k in ["대술", "정안"]) else "공통"
# --- [통합 추론 및 매칭] ---
# 시나리오 A: 실정보고/설계변경 (본문 데이터 + 파일명 의도 합성)
if dominant_domain == "change" or (scores["change"] > 0 and scores["technical"] > 5):
cat = f"실정보고({project_loc})"
sub = "지장물" if any(k in clean_ctx for k in ["임대료", "토지", "보상"]) else "구조물공" if "구조물" in clean_ctx else "기타"
return f"설계변경 > {cat} > {sub}", f"본문의 기술 데이터 밀도와 파일명의 '{dominant_domain}' 관련 의도를 종합하여 {project_loc} 프로젝트의 실정보고 본체로 판정."
# 시나리오 B: 행정 계약/하도급 (본체 중심)
if dominant_domain == "contract":
return "행정 > 계약 > 계약관리", "문서 전체에서 계약 및 하도급 업무 본질이 지배적으로 확인됨."
# 시나리오 C: 인사/인력 관리
if dominant_domain == "hr":
if len(all_text_list) <= 2: return "공사관리 > 공문 > 인력", "인력 사항을 간략히 보고하는 공문 형식임."
return "행정 > 계약 > 인원관리", "다량의 인력 증빙 데이터가 포함된 행정 서류임."
# 시나리오 D: 순수 공문 (형식 우선)
if dominant_domain == "official" or scores["official"] > scores["technical"]:
tab, cat = "공사관리", "공문"
sub = "접수(수신)"
if "방침" in clean_ctx or "지침" in clean_ctx: sub = "방침"
elif "발신" in clean_ctx[:500]: sub = "발송(발신)"
return f"{tab} > {cat} > {sub}", "전체 맥락상 기술적 데이터보다 행정적 전달 행위(공문)가 핵심 정체성으로 판단됨."
# 시나리오 E: 기술 성과품
if dominant_domain == "technical":
if any(k in clean_ctx or k in fn_clean for k in ["단가", "내역"]): return "설계성과품 > 내역서 > 단가산출서", "내역/단가 산출 기술 데이터 확인."
if any(k in clean_ctx or k in fn_clean for k in ["도면", "dwg"]): return "설계성과품 > 설계도면 > 공통", "도면/그래픽 데이터 확인."
return "설계성과품 > 수량산출서 > 토공", "수량/물량 산출 데이터 확인."
return "행정 > 업무관리 > 양식서류", "일반 행정 및 기타 양식 서류로 분류함."
def analyze_file_content(filename: str):
try:
file_path = os.path.join("sample", filename)
text_by_pages = []
if filename.lower().endswith(".pdf"):
reader = PdfReader(file_path)
for i in range(len(reader.pages)):
page_text = reader.pages[i].extract_text() or ""
if OCR_AVAILABLE:
try:
images = convert_from_path(file_path, first_page=i+1, last_page=i+1, poppler_path=POPPLER_BIN, dpi=200)
if images:
ocr_result = pytesseract.image_to_string(images[0], lang='kor+eng')
page_text += "\n" + ocr_result
except Exception as ocr_err:
print(f"OCR Error on page {i+1}: {ocr_err}")
text_by_pages.append(page_text)
elif filename.lower().endswith(('.xlsx', '.xls')):
import pandas as pd
df = pd.read_excel(file_path)
text_by_pages.append(df.to_string())
else: text_by_pages.append("")
path, reason = analyze_flow_reasoning(filename, text_by_pages)
return {
"filename": filename,
"total_pages": len(text_by_pages),
"final_result": {
"suggested_path": path,
"confidence": "100%",
"reason": reason,
"snippet": " ".join(text_by_pages)[:1500]
}
}
except Exception as e:
return {"error": str(e), "filename": filename}

View File

@@ -1,206 +0,0 @@
import os
import re
import asyncio
import json
import traceback
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import StreamingResponse, FileResponse
from fastapi.staticfiles import StaticFiles
from playwright.async_api import async_playwright
from dotenv import load_dotenv
load_dotenv()
app = FastAPI()
# Mount static files (css, images etc)
app.mount("/style", StaticFiles(directory="style"), name="style")
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=False,
allow_methods=["*"],
allow_headers=["*"],
)
@app.get("/")
async def get_dashboard():
return FileResponse("dashboard.html")
@app.get("/sync")
async def sync_data():
async def event_generator():
user_id = os.getenv("PM_USER_ID")
password = os.getenv("PM_PASSWORD")
if not user_id or not password:
yield f"data: {json.dumps({'type': 'log', 'message': '오류: .env 파일에 계정 정보가 없습니다.'})}\n\n"
return
results = []
async with async_playwright() as p:
yield f"data: {json.dumps({'type': 'log', 'message': '브라우저 실행 중...'})}\n\n"
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': 1920, 'height': 1080},
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"
)
page = await context.new_page()
try:
yield f"data: {json.dumps({'type': 'log', 'message': '사이트 접속 및 로그인 중...'})}\n\n"
await page.goto("https://overseas.projectmastercloud.com/", wait_until="domcontentloaded")
await page.click("#login-by-id", timeout=10000)
await page.fill("#user_id", user_id)
await page.fill("#user_pw", password)
await page.click("#login-btn")
yield f"data: {json.dumps({'type': 'log', 'message': '대시보드 목록 대기 중...'})}\n\n"
await page.wait_for_selector("h4.list__contents_aria_group_body_list_item_label", timeout=60000)
locators = page.locator("h4.list__contents_aria_group_body_list_item_label")
count = await locators.count()
yield f"data: {json.dumps({'type': 'log', 'message': f'{count}개의 프로젝트 발견. 수집 시작.'})}\n\n"
for i in range(count):
try:
proj = page.locator("h4.list__contents_aria_group_body_list_item_label").nth(i)
project_name = (await proj.inner_text()).strip()
yield f"data: {json.dumps({'type': 'log', 'message': f'[{i+1}/{count}] {project_name} - 시작'})}\n\n"
await proj.scroll_into_view_if_needed()
await proj.click(force=True)
# 프로젝트 로딩 대기 (Gitea 방식: 물리적 대기)
await asyncio.sleep(5)
await page.wait_for_selector("div.footer", state="visible", timeout=20000)
recent_log = "기존데이터유지"
file_count = 0
# 1단계: 활동로그 수집 (Gitea 방식 복구 + 정밀 셀렉터)
try:
log_btn_sel = "body > div.footer > div.left > div.wrap.log-wrap > div.title.text"
log_btn = page.locator(log_btn_sel).first
if await log_btn.is_visible(timeout=5000):
yield f"data: {json.dumps({'type': 'log', 'message': ' - [로그] 창 열기 시도...'})}\n\n"
await log_btn.click(force=True)
await asyncio.sleep(5) # 로딩 충분히 대기
modal_sel = "article.archive-modal"
if await page.locator(modal_sel).is_visible():
yield f"data: {json.dumps({'type': 'log', 'message': ' - [로그] 모달 발견. 데이터 추출 중...'})}\n\n"
# 사용자 제공 정밀 셀렉터 기반 추출
date_sel = "body > article.archive-modal > div > div > div.modal-body > div.log-wrap > div.log-item-wrap.log-body.scrollbar.scroll-container > div.date > div.text"
user_sel = "body > article.archive-modal > div > div > div.modal-body > div.log-wrap > div.log-item-wrap.log-body.scrollbar.scroll-container > div.user > div.text"
act_sel = "body > article.archive-modal > div > div > div.modal-body > div.log-wrap > div.log-item-wrap.log-body.scrollbar.scroll-container > div.activity > div.text"
# 데이터가 나타날 때까지 반복 대기
success_log = False
for _ in range(10):
if await page.locator(date_sel).count() > 0:
raw_date = (await page.locator(date_sel).first.inner_text()).strip()
if raw_date and "활동시간" not in raw_date:
success_log = True
break
await asyncio.sleep(1)
if success_log:
user_name = (await page.locator(user_sel).first.inner_text()).strip()
activity = (await page.locator(act_sel).first.inner_text()).strip()
formatted_date = re.sub(r'[-/]', '.', raw_date)[:10]
recent_log = f"{formatted_date}, {user_name}, {activity}"
yield f"data: {json.dumps({'type': 'log', 'message': f' - [로그] 성공: {recent_log[:30]}...'})}\n\n"
else:
yield f"data: {json.dumps({'type': 'log', 'message': ' - [로그] 데이터 추출 실패'})}\n\n"
await page.click("article.archive-modal div.close", timeout=3000)
await asyncio.sleep(1.5)
except Exception as e:
yield f"data: {json.dumps({'type': 'log', 'message': f' - [로그] 오류: {str(e)[:20]}'})}\n\n"
# 2단계: 구성(파일 수) 수집 (Gitea 순회 방식 복구 + 대기 시간 대폭 연장)
try:
sitemap_btn_sel = "body > div.footer > div.left > div.wrap.site-map-wrap"
sitemap_btn = page.locator(sitemap_btn_sel).first
if await sitemap_btn.is_visible(timeout=5000):
yield f"data: {json.dumps({'type': 'log', 'message': ' - [구성] 진입 시도...'})}\n\n"
await sitemap_btn.click(force=True)
# Gitea 방식: context.pages 직접 뒤져서 팝업 찾기
popup_page = None
for _ in range(30): # 최대 15초 대기
for p_item in context.pages:
try:
if "composition" in p_item.url:
popup_page = p_item
break
except: pass
if popup_page: break
await asyncio.sleep(0.5)
if popup_page:
yield f"data: {json.dumps({'type': 'log', 'message': ' - [구성] 창 발견. 데이터 로딩 대기 (최대 80초)...'})}\n\n"
target_selector = "#composition-list h6"
success_comp = False
# 최대 80초간 끝까지 대기
for _ in range(80):
h6_count = await popup_page.locator(target_selector).count()
if h6_count > 5: # 일정 개수 이상의 목록이 나타나면 로딩 시작으로 간주
success_comp = True
break
await asyncio.sleep(1)
if success_comp:
yield f"data: {json.dumps({'type': 'log', 'message': ' - [구성] 데이터 감지됨. 15초간 최종 렌더링 대기...'})}\n\n"
await asyncio.sleep(15) # 완전한 로딩을 위한 강제 대기
# 유연한 데이터 수집
locators_h6 = popup_page.locator(target_selector)
h6_count = await locators_h6.count()
current_total = 0
for j in range(h6_count):
text = (await locators_h6.nth(j).inner_text()).strip()
nums = re.findall(r'\d+', text.split('\n')[-1])
if nums:
val = int(nums[0])
if val < 5000: current_total += val
file_count = current_total
yield f"data: {json.dumps({'type': 'log', 'message': f' - [구성] 성공 ({file_count}개)'})}\n\n"
else:
yield f"data: {json.dumps({'type': 'log', 'message': ' - [구성] 로딩 타임아웃'})}\n\n"
await popup_page.close()
else:
yield f"data: {json.dumps({'type': 'log', 'message': ' - [구성] 팝업창 발견 실패'})}\n\n"
except Exception as e:
yield f"data: {json.dumps({'type': 'log', 'message': f' - [구성] 오류: {str(e)[:20]}'})}\n\n"
results.append({"projectName": project_name, "recentLog": recent_log, "fileCount": file_count})
# 홈 복귀
await page.locator("div.header div.title div").first.click(force=True)
await page.wait_for_selector("h4.list__contents_aria_group_body_list_item_label", timeout=20000)
await asyncio.sleep(2)
except Exception:
await page.goto("https://overseas.projectmastercloud.com/dashboard", wait_until="domcontentloaded")
yield f"data: {json.dumps({'type': 'done', 'data': results})}\n\n"
except Exception as e:
yield f"data: {json.dumps({'type': 'log', 'message': f'치명적 오류: {str(e)}'})}\n\n"
finally:
await browser.close()
return StreamingResponse(event_generator(), media_type="text_event-stream")

258
crawler_service.py Normal file
View File

@@ -0,0 +1,258 @@
import os
import re
import asyncio
import json
import traceback
import sys
import threading
import queue
import pymysql
from datetime import datetime
from playwright.async_api import async_playwright
from dotenv import load_dotenv
from sql_queries import CrawlerQueries
load_dotenv(override=True)
# 글로벌 중단 제어용 이벤트
crawl_stop_event = threading.Event()
def get_db_connection():
"""MySQL 데이터베이스 연결을 반환 (환경변수 기반)"""
return pymysql.connect(
host=os.getenv('DB_HOST', 'localhost'),
user=os.getenv('DB_USER', 'root'),
password=os.getenv('DB_PASSWORD', '45278434'),
database=os.getenv('DB_NAME', 'PM_proto'),
charset='utf8mb4',
cursorclass=pymysql.cursors.DictCursor
)
def clean_date_string(date_str):
if not date_str: return ""
match = re.search(r'(\d{2})[./-](\d{2})[./-](\d{2})', date_str)
if match: return f"20{match.group(1)}.{match.group(2)}.{match.group(3)}"
return date_str[:10].replace("-", ".")
def parse_log_id(log_id):
if not log_id or "_" not in log_id: return log_id
try:
parts = log_id.split('_')
if len(parts) >= 4:
date_part = clean_date_string(parts[1])
activity = parts[3].strip()
activity = re.sub(r'\(.*?\)', '', activity).strip()
return f"{date_part}, {activity}"
except: pass
return log_id
def crawler_thread_worker(msg_queue, user_id, password):
crawl_stop_event.clear()
if sys.platform == 'win32':
asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy())
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
async def run():
async with async_playwright() as p:
browser = None
try:
msg_queue.put(json.dumps({'type': 'log', 'message': '브라우저 엔진 가동 (전 기능 복구 모드)...'}))
browser = await p.chromium.launch(headless=True, args=[
"--no-sandbox",
"--disable-dev-shm-usage",
"--disable-blink-features=AutomationControlled"
])
context = await browser.new_context(
viewport={'width': 1600, 'height': 900},
user_agent="Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36"
)
captured_data = {"tree": None, "_is_root_archive": False, "project_list": [], "last_project_data": None}
async def global_interceptor(response):
url = response.url
try:
if "getAllList" in url:
data = await response.json()
captured_data["project_list"] = data.get("data", [])
elif "getTreeObject" in url:
is_root = False
if "params[resourcePath]=" in url:
path_val = url.split("params[resourcePath]=")[1].split("&")[0]
if path_val in ["%2F", "/"]: is_root = True
if is_root:
captured_data["tree"] = await response.json()
captured_data["_is_root_archive"] = True
elif "getData" in url and "overview" in url:
captured_data["last_project_data"] = await response.json()
except: pass
context.on("response", global_interceptor)
page = await context.new_page()
await page.goto("https://overseas.projectmastercloud.com/dashboard", wait_until="domcontentloaded")
# 로그인
if await page.locator("#login-by-id").is_visible(timeout=10000):
await page.click("#login-by-id")
await page.fill("#user_id", user_id)
await page.fill("#user_pw", password)
await page.click("#login-btn")
await page.wait_for_selector("h4.list__contents_aria_group_body_list_item_label", timeout=60000)
await asyncio.sleep(3)
# [Phase 1] DB 마스터 정보 동기화
if captured_data["project_list"]:
conn = get_db_connection()
try:
with conn.cursor() as cursor:
for p_info in captured_data["project_list"]:
cursor.execute(CrawlerQueries.UPSERT_MASTER, (p_info.get("project_id"), p_info.get("project_nm"),
p_info.get("short_nm", "").strip(), p_info.get("master"),
p_info.get("large_class"), p_info.get("mid_class")))
conn.commit()
msg_queue.put(json.dumps({'type': 'log', 'message': 'DB 마스터 정보 동기화 완료.'}))
finally: conn.close()
# [Phase 2] 수집 루프
names = await page.locator("h4.list__contents_aria_group_body_list_item_label").all_inner_texts()
project_names = list(dict.fromkeys([n.strip() for n in names if n.strip()]))
count = len(project_names)
for i, project_name in enumerate(project_names):
if crawl_stop_event.is_set():
msg_queue.put(json.dumps({'type': 'log', 'message': '>>> 중단 신호 감지: 종료합니다.'}))
break
msg_queue.put(json.dumps({'type': 'log', 'message': f'[{i+1}/{count}] {project_name} 수집 시작'}))
p_match = next((p for p in captured_data["project_list"] if p.get('project_nm') == project_name or p.get('short_nm', '').strip() == project_name), None)
current_p_id = p_match.get('project_id') if p_match else None
captured_data["tree"] = None; captured_data["_is_root_archive"] = False
try:
# 1. 프로젝트 진입 (좌표 클릭)
target_el = page.locator(f"h4.list__contents_aria_group_body_list_item_label:has-text('{project_name}')").first
await target_el.scroll_into_view_if_needed()
box = await target_el.bounding_box()
if box: await page.mouse.click(box['x'] + 5, box['y'] + 5)
else: await target_el.click(force=True)
await page.wait_for_selector("text=활동로그", timeout=30000)
# [부서 정보 수집] getData 응답 대기 및 DB 업데이트
for _ in range(10):
if captured_data.get("last_project_data"): break
await asyncio.sleep(0.5)
last_data = captured_data.get("last_project_data")
if last_data:
if isinstance(last_data, list) and len(last_data) > 0:
last_data = last_data[0]
if isinstance(last_data, dict):
proj_data = last_data.get("data", {})
if isinstance(proj_data, list) and len(proj_data) > 0:
proj_data = proj_data[0]
if isinstance(proj_data, dict):
dept = proj_data.get("department")
p_id = proj_data.get("project_id")
if dept and p_id:
with get_db_connection() as conn:
with conn.cursor() as cursor:
cursor.execute(CrawlerQueries.UPDATE_DEPARTMENT, (dept, p_id))
conn.commit()
captured_data["last_project_data"] = None # 초기화
await asyncio.sleep(2)
recent_log = "데이터 없음"; file_count = 0
# 2. 활동로그 (날짜 필터 적용 버전)
modal_opened = False
for _ in range(3):
await page.get_by_text("활동로그").first.click()
try:
await page.wait_for_selector("article.archive-modal", timeout=5000)
modal_opened = True; break
except: await asyncio.sleep(1)
if modal_opened:
# 날짜 필터 2020-01-01 적용
inputs = await page.locator("article.archive-modal input").all()
for inp in inputs:
if (await inp.get_attribute("type")) == "date":
await inp.fill("2020-01-01"); break
apply_btn = page.locator("article.archive-modal").get_by_text("적용").first
if await apply_btn.is_visible():
await apply_btn.click()
await asyncio.sleep(5)
log_elements = await page.locator("article.archive-modal div[id*='_']").all()
if log_elements:
recent_log = parse_log_id(await log_elements[0].get_attribute("id"))
await page.keyboard.press("Escape")
# 3. 구성 수집 (API Fetch 방식 - 팝업 없음)
await page.evaluate("""() => {
const baseUrl = window.location.origin + window.location.pathname.split('/').slice(0, 2).join('/');
fetch(`${baseUrl}/archive/getTreeObject?params[storageType]=CLOUD&params[resourcePath]=/`);
}""")
for _ in range(30):
if captured_data["_is_root_archive"]: break
await asyncio.sleep(0.5)
if captured_data["tree"]:
tree_data = captured_data["tree"]
if isinstance(tree_data, list) and len(tree_data) > 0:
tree_data = tree_data[0]
if isinstance(tree_data, dict):
tree = tree_data.get('currentTreeObject', tree_data)
if isinstance(tree, dict):
total = len(tree.get("file", {}))
folders = tree.get("folder", {})
if isinstance(folders, dict):
for f in folders.values(): total += int(f.get("filesCount", 0))
file_count = total
# 4. DB 실시간 저장
if current_p_id:
with get_db_connection() as conn:
with conn.cursor() as cursor:
cursor.execute(CrawlerQueries.UPSERT_HISTORY, (current_p_id, recent_log, file_count))
conn.commit()
msg_queue.put(json.dumps({'type': 'log', 'message': f' - [성공] 로그: {recent_log[:20]}... / 파일: {file_count}'}))
await page.goto("https://overseas.projectmastercloud.com/dashboard", wait_until="domcontentloaded")
except Exception as e:
msg_queue.put(json.dumps({'type': 'log', 'message': f' - {project_name} 실패: {str(e)}'}))
await page.goto("https://overseas.projectmastercloud.com/dashboard")
msg_queue.put(json.dumps({'type': 'done', 'data': []}))
except Exception as e:
msg_queue.put(json.dumps({'type': 'log', 'message': f'치명적 오류: {str(e)}'}))
finally:
if browser: await browser.close()
msg_queue.put(None)
loop.run_until_complete(run())
loop.close()
async def run_crawler_service():
msg_queue = queue.Queue()
thread = threading.Thread(target=crawler_thread_worker, args=(msg_queue, os.getenv("PM_USER_ID"), os.getenv("PM_PASSWORD")))
thread.start()
while True:
try:
msg = await asyncio.to_thread(msg_queue.get, timeout=1.0)
if msg is None: break
yield f"data: {msg}\n\n"
except queue.Empty:
if not thread.is_alive(): break
await asyncio.sleep(0.1)
thread.join()

View File

@@ -1,367 +0,0 @@
<!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/style.css">
</head>
<body>
<nav class="topbar">
<div class="topbar-header">
<h2>Project Master Overseas</h2>
</div>
<ul class="nav-list">
<li class="nav-item active">대시보드</li>
<li class="nav-item">문의사항 <span class="badge" style="background:#FFFFFF;color:var(--primary-color); border-radius:10px; font-weight: bold; padding: 2px 5px;">12</span></li>
<li class="nav-item">로그관리</li>
<li class="nav-item">파일관리</li>
<li class="nav-item">인원관리</li>
<li class="nav-item">공지사항</li>
</ul>
</nav>
<main class="main-content">
<header>
<div style="display:flex; align-items:center;">
<h1>대시보드 현황</h1>
</div>
<div style="display:flex; align-items:center;">
<button id="syncBtn" class="sync-btn" onclick="syncData()">
<span class="spinner"></span>
데이터 동기화 (크롤링)
</button>
<div class="admin-info">접속자: <strong>이태훈[전체관리자]</strong></div>
</div>
</header>
<!-- 실시간 로그 콘솔 추가 -->
<div id="logConsole" style="display:none; background:#000; color:#0f0; font-family:monospace; padding:15px; margin-bottom:20px; border-radius:4px; max-height:200px; overflow-y:auto; font-size:12px; line-height:1.5;">
<div style="color:#fff; border-bottom:1px solid #333; margin-bottom:10px; padding-bottom:5px; font-weight:bold;">실시간 수집 로그 [PM Overseas]</div>
<div id="logBody"></div>
</div>
<div id="projectAccordion">
<!-- Multi-level Accordion items will be generated here -->
</div>
</main>
<script>
const rawData = [
["라오스 ITTC 관개 교육센터 PMC", "수자원1부", "방노성", "2026.01.29, 폴더 삭제", 16],
["라오스 비엔티안 메콩강 관리 2차 DD", "수자원1부", "방노성", "2025.12.07, 파일업로드", 260],
["미얀마 만달레이 철도 개량 감리 CS", "철도사업부", "김태헌", "2025.11.17, 폴더이름변경", 298],
["베트남 푸옥호아 양수 발전 FS", "수력부", "이철호", "2026.02.23, 폴더이름변경", 139],
["사우디아라비아 아시르 지잔 고속도로 FS", "도로부", "공태원", "2026.02.09, 파일다운로드", 73],
["우즈베키스탄 타슈켄트 철도 FS", "철도사업부", "김태헌", "2026.02.05, 파일업로드", 51],
["우즈베키스탄 지방 도로 복원 MP", "도로부", "장진영", "X", 0],
["이라크 Habbaniyah Shuaiba AirBase PD", "도로부", "강동구", "X", 0],
["캄보디아 반테 민체이 관개 홍수저감 MP", "수자원1부", "이대주", "2025.12.07, 파일업로드", 44],
["캄보디아 시엠립 하수처리 개선 DD", "물환경사업1부", "변역근", "2026.02.06, AI 요약", 221],
["메콩유역 수자원 관리 기후적응 MP", "수자원1부", "정귀한", "X", 0],
["키르기스스탄 잘랄아바드 상수도 계획 MP", "물환경사업1부", "변기상", "2026.02.12, 파일업로드", 60],
["파키스탄 CAREC 도로 감리 DD", "도로부", "황효섭", "X", 0],
["파키스탄 펀잡 홍수 방재 PMC", "수자원1부", "방노성", "2025.12.08, 폴더삭제", 0],
["파키스탄 KP 아보타바드 상수도 PMC", "물환경사업2부", "변기상", "2026.02.12, 파일업로드", 234],
["필리핀 홍수 관리 Package5B MP", "수자원1부", "이희철", "2025.12.02, 폴더이름변경", 14],
["필리핀 PGN 해상교량 BID2 IDC", "구조부", "이상희", "2026.02.11, 파일다운로드", 631],
["필리핀 홍수 복원 InFRA2 DD", "수자원1부", "이대주", "2025.12.01, 폴더삭제", 6],
["가나 테치만 상수도 확장 DS", "물환경사업2부", "-", "X", 0],
["기니 벼 재배단지 PMC", "수자원1부", "이대주", "2025.12.08, 파일업로드", 43],
["우간다 벼 재배단지 PMC", "수자원1부", "방노성", "2025.12.08, 파일업로드", 52],
["우간다 부수쿠마 분뇨 자원화 2단계 PMC", "물환경사업2부", "변기상", "2026.02.05, 파일업로드", 9],
["에티오피아 지하수 관개 환경설계 DD", "물환경사업2부", "변기상", "X", 0],
["에티오피아 도도타군 관개 PMC", "수자원1부", "방노성", "2025.12.01, 폴더이름변경", 144],
["에티오피아 Adeaa-Becho 지하수 관개 MP", "수자원1부", "방노성", "2025.11.21, 파일업로드", 146],
["탄자니아 Iringa 상하수도 개선 CS", "물환경사업1부", "백운영", "2026.02.03, 폴더 생성", 0],
["탄자니아 Dodoma 하수 설계감리 DD", "물환경사업2부", "변기상", "2026.02.04, 폴더삭제", 32],
["탄자니아 잔지바르 쌀 생산 PMC", "수자원1부", "방노성", "2025.12.08, 파일 업로드", 23],
["탄자니아 도도마 유수율 상수도개선 PMC", "물환경사업1부", "박순석", "2026.02.12, 부관리자권한추가", 35],
["아르헨티나 SALDEORO 수력발전 28MW DD", "플랜트1부", "양정모", "X", 0],
["온두라스 LaPaz Danli 상수도 CS", "물환경사업2부", "-", "2026.01.29, 파일 삭제", 60],
["볼리비아 에스꼬마 차라짜니 도로 CS", "도로부", "전홍찬", "2026.02.06, 파일업로드", 1],
["볼리비아 마모레 교량도로 FS", "도로부", "황효섭", "2026.02.06, 파일업로드", 120],
["볼리비아 Bombeo-Colomi 도로설계 DD", "도로부", "황효섭", "2025.12.05, 파일삭제", 48],
["콜롬비아 AI 폐기물 FS", "플랜트1부", "서재희", "X", 0],
["파라과이 도로 통행료 현대화 MP", "교통계획부", "오제훈", "X", 0],
["페루 Barranca 상하수도 확장 DD", "물환경사업2부", "변기상", "2025.11.14, 파일업로드", 44],
["엘살바도르 태평양 철도 FS", "철도사업부", "김태헌", "2026.02.04, 파일이름변경", 102],
["필리핀 사무소", "해외사업부", "한형남", "2026.02.23, 파일업로드", 813]
];
const continentMap = {
"라오스": "아시아", "미얀마": "아시아", "베트남": "아시아", "사우디아라비아": "아시아",
"우즈베키스탄": "아시아", "이라크": "아시아", "캄보디아": "아시아",
"키르기스스탄": "아시아", "파키스탄": "아시아", "필리핀": "아시아",
"아르헨티나": "아메리카", "온두라스": "아메리카", "볼리비아": "아메리카", "콜롬비아": "아메리카",
"파라과이": "아메리카", "페루": "아메리카", "엘살바도르": "아메리카",
"가나": "아프리카", "기니": "아프리카", "우간다": "아프리카", "에티오피아": "아프리카", "탄자니아": "아프리카"
};
const continentOrder = {
"아시아": 1,
"아프리카": 2,
"아메리카": 3,
"지사": 4
};
function init() {
const container = document.getElementById('projectAccordion');
const groupedData = {};
// 1. 데이터 파싱 및 그룹화
rawData.forEach((item, index) => {
const projectName = item[0];
let continent = "";
let country = "";
if (projectName.endsWith("사무소")) {
continent = "지사";
country = projectName.split(" ")[0];
} else if (projectName.startsWith("메콩유역")) {
country = "캄보디아";
continent = "아시아";
} else {
country = projectName.split(" ")[0];
continent = continentMap[country] || "기타";
}
if (!groupedData[continent]) groupedData[continent] = {};
if (!groupedData[continent][country]) groupedData[continent][country] = [];
groupedData[continent][country].push({ item, index });
});
// 2. 대륙 정렬 (아시아 - 아프리카 - 아메리카 - 지사)
const sortedContinents = Object.keys(groupedData).sort((a, b) => (continentOrder[a] || 99) - (continentOrder[b] || 99));
// 3. HTML 생성
sortedContinents.forEach(continent => {
const continentGroup = document.createElement('div');
continentGroup.className = 'continent-group';
let continentHtml = `
<div class="continent-header" onclick="toggleGroup(this)">
<span>${continent}</span>
<span class="toggle-icon">▼</span>
</div>
<div class="continent-body">
`;
const sortedCountries = Object.keys(groupedData[continent]).sort((a, b) => a.localeCompare(b));
sortedCountries.forEach(country => {
continentHtml += `
<div class="country-group">
<div class="country-header" onclick="toggleGroup(this)">
<span>${country}</span>
<span class="toggle-icon">▼</span>
</div>
<div class="country-body">
<div class="accordion-container">
`;
const sortedProjects = groupedData[continent][country].sort((a, b) => a.item[0].localeCompare(b.item[0]));
sortedProjects.forEach(({item, index}) => {
const projectName = item[0];
const dept = item[1];
const admin = item[2];
const recentLogRaw = item[3];
const fileCount = item[4];
const personnelCount = Math.floor(Math.random()*15)+3; // 인원은 시트에 없으므로 임의 할당 유지
const recentLog = recentLogRaw === "X" ? "기록 없음" : recentLogRaw;
const logTime = recentLog !== "기록 없음" ? recentLog.split(',')[0] : "기록 없음";
continentHtml += `
<div class="accordion-item">
<div class="accordion-header" onclick="toggleAccordion(this)">
<div>
<span class="header-label">프로젝트 명</span>
<span class="header-value" title="${projectName}">${projectName}</span>
</div>
<div>
<span class="header-label">담당부서</span>
<span class="header-value">${dept}</span>
</div>
<div>
<span class="header-label">관리자</span>
<span class="header-value">${admin}</span>
</div>
<div>
<span class="header-label">파일 수</span>
<span class="header-value">${fileCount}</span>
</div>
<div>
<span class="header-label">인원</span>
<span class="header-value">${personnelCount}명</span>
</div>
<div>
<span class="header-label">최근 로그</span>
<span class="header-value" style="color:var(--text-sub); font-size:11px;" title="${recentLog}">${recentLog}</span>
</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>
<tr><td>김철수</td><td>${dept}</td><td>부관리자</td></tr>
<tr><td>박지민</td><td>${dept}</td><td>일반참여자</td></tr>
<tr><td>최유리</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>
<tr><td><span class="badge" style="background:var(--hover-bg); border: 1px solid var(--border-color); color:var(--primary-color);">문의</span></td><td>프로젝트 접근 권한 요청</td><td>2026-02-23</td></tr>
<tr><td><span class="badge" style="background:var(--primary-color); color:white;">파일</span></td><td>설계도면 v2.pdf 업로드</td><td>2026-02-22</td></tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
`;
});
continentHtml += `
</div>
</div>
</div>
`;
});
continentHtml += `
</div>
`;
continentGroup.innerHTML = continentHtml;
container.appendChild(continentGroup);
});
const allContinents = container.querySelectorAll('.continent-group');
allContinents.forEach(continent => {
continent.classList.add('active');
continent.querySelector('.continent-header .toggle-icon').textContent = '▲';
});
const allCountries = container.querySelectorAll('.country-group');
allCountries.forEach(country => {
country.classList.add('active');
country.querySelector('.country-header .toggle-icon').textContent = '▲';
});
}
function toggleGroup(header) {
const group = header.parentElement;
const icon = header.querySelector('.toggle-icon');
group.classList.toggle('active');
if (group.classList.contains('active')) {
icon.textContent = '▲';
} else {
icon.textContent = '▼';
}
}
function toggleAccordion(header) {
const item = header.parentElement;
const container = item.parentElement;
const allItems = container.querySelectorAll('.accordion-item');
allItems.forEach(el => {
if(el !== item) el.classList.remove('active');
});
item.classList.toggle('active');
}
async function syncData() {
const btn = document.getElementById('syncBtn');
const logConsole = document.getElementById('logConsole');
const logBody = document.getElementById('logBody');
btn.classList.add('loading');
btn.innerHTML = `<span class="spinner"></span> 동기화 중 (진행 상황 확인 중...)`;
btn.disabled = true;
logConsole.style.display = 'block';
logBody.innerHTML = ''; // 이전 로그 삭제
function addLog(msg) {
const logItem = document.createElement('div');
logItem.innerText = `[${new Date().toLocaleTimeString()}] ${msg}`;
logBody.appendChild(logItem);
logConsole.scrollTop = logConsole.scrollHeight; // 자동 스크롤
}
try {
const response = await fetch(`/sync`);
const reader = response.body.getReader();
const decoder = new TextDecoder();
while (true) {
const { done, value } = await reader.read();
if (done) break;
const chunk = decoder.decode(value);
const lines = chunk.split('\n');
for (const line of lines) {
if (line.startsWith('data: ')) {
const payload = JSON.parse(line.substring(6));
if (payload.type === 'log') {
addLog(payload.message);
} else if (payload.type === 'done') {
const newData = payload.data;
newData.forEach(scrapedItem => {
const target = rawData.find(item =>
item[0].replace(/\s/g,'').includes(scrapedItem.projectName.replace(/\s/g,'')) ||
scrapedItem.projectName.replace(/\s/g,'').includes(item[0].replace(/\s/g,''))
);
if (target) {
// 기존 데이터 유지 마커 확인
if (scrapedItem.recentLog !== "기존데이터유지") {
target[3] = scrapedItem.recentLog;
}
target[4] = scrapedItem.fileCount;
}
});
document.getElementById('projectAccordion').innerHTML = '';
init();
addLog(">>> 모든 동기화 작업이 완료되었습니다!");
alert(`${newData.length}개 프로젝트 동기화 완료!`);
logConsole.style.display = 'none'; // 성공 시 콘솔 숨김
}
}
}
}
} catch (e) {
addLog(`오류 발생: ${e.message}`);
alert("서버 연결 실패. 백엔드 서버가 실행 중인지 확인하세요.");
console.error(e);
} finally {
btn.classList.remove('loading');
btn.innerHTML = `<span class="spinner"></span> 데이터 동기화 (크롤링)`;
btn.disabled = false;
}
}
init();
</script>
</body>
</html>

122
js/analysis.js Normal file
View File

@@ -0,0 +1,122 @@
/**
* Project Master Analysis JS
* P-WAR (Project Performance Above Replacement) 분석 엔진
*/
document.addEventListener('DOMContentLoaded', () => {
console.log("Analysis engine initialized...");
loadPWarData();
});
async function loadPWarData() {
try {
const response = await fetch('/api/analysis/p-war');
const data = await response.json();
if (data.error) throw new Error(data.error);
updateSummaryMetrics(data);
renderPWarLeaderboard(data);
renderRiskSignals(data);
// 시스템 평균 정보 표시
if (data.length > 0 && data[0].avg_info) {
const avg = data[0].avg_info;
document.getElementById('avg-system-info').textContent =
`* 0.0 = 시스템 평균 (파일 ${avg.avg_files.toLocaleString()}개 / 방치 ${avg.avg_stagnant}일 / 리스크 ${avg.avg_risk}건)`;
}
} catch (e) {
console.error("분석 데이터 로딩 실패:", e);
}
}
function updateSummaryMetrics(data) {
// 1. 평균 P-WAR 산출
const avgPWar = data.reduce((acc, cur) => acc + cur.p_war, 0) / data.length;
document.querySelector('.metric-card.sra .value').textContent = avgPWar.toFixed(2);
// 2. 고위험 좀비 프로젝트 비율 (P-WAR < -1.0 기준)
const zombieCount = data.filter(p => p.p_war < -1.0).length;
const zombieRate = (zombieCount / data.length) * 100;
document.querySelector('.metric-card.stability .value').textContent = `${zombieRate.toFixed(1)}%`;
// 3. 총 활성 리소스 규모
const totalActiveFiles = data.filter(p => p.p_war > 0).reduce((acc, cur) => acc + cur.file_count, 0);
document.querySelector('.metric-card.piso .value').textContent = (totalActiveFiles / 1000).toFixed(1) + "k";
// 4. 방치 리스크 총합
const totalRisks = data.reduce((acc, cur) => acc + cur.risk_count, 0);
document.querySelector('.metric-card.iwar .value').textContent = totalRisks;
}
function renderPWarLeaderboard(data) {
const container = document.querySelector('.timeline-analysis .card-body');
const sortedData = [...data].sort((a, b) => b.p_war - a.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: 250px;">프로젝트명</th>
<th style="position: sticky; top: 0; z-index: 10; width: 140px;">관리 상태</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;">P-WAR (기여도)</th>
</tr>
</thead>
<tbody>
${sortedData.map(p => {
let statusBadge = "";
if (p.is_auto_delete) {
statusBadge = '<span class="badge-system">잠김예정 프로젝트</span>';
} else if (p.p_war > 0) {
statusBadge = '<span class="badge-active">운영 중</span>';
} else if (p.p_war <= -0.3) {
statusBadge = '<span class="badge-danger">방치-삭제대상</span>';
} else {
statusBadge = '<span class="badge-warning">위험군</span>';
}
return `
<tr class="${p.is_auto_delete || p.p_war <= -0.3 ? 'row-danger' : p.p_war < 0 ? 'row-warning' : ''}">
<td class="font-bold">${p.project_nm}</td>
<td>${statusBadge}</td>
<td>${p.file_count.toLocaleString()}</td>
<td>${p.days_stagnant}</td>
<td>${p.risk_count}</td>
<td class="p-war-value ${p.p_war >= 0 ? 'text-plus' : 'text-minus'}">
${p.p_war > 0 ? '+' : ''}${p.p_war}
</td>
</tr>
`;
}).join('')}
</tbody>
</table>
</div>
`;
}
function renderRiskSignals(data) {
const container = document.querySelector('.risk-signal-list');
// 1. 시스템 삭제(잠김예정) 프로젝트 우선 추출
const autoDeleted = data.filter(p => p.is_auto_delete).slice(0, 3);
// 2. 그 외 P-WAR가 낮은 순(음수)으로 추출
const highRiskProjects = data.filter(p => p.p_war < -1.0 && !p.is_auto_delete).slice(0, 5 - autoDeleted.length);
const combined = [...autoDeleted, ...highRiskProjects];
container.innerHTML = combined.map(p => `
<div class="risk-item high">
<div class="risk-project">${p.project_nm} (${p.master})</div>
<div class="risk-reason">
${p.is_auto_delete ? '[잠김예정] 활동 부재로 인한 시스템 자동 삭제 발생' : `P-WAR ${p.p_war} (대체 수준 이하 정체)`}
</div>
<div class="risk-status">위험</div>
</div>
`).join('');
}

78
js/common.js Normal file
View File

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

237
js/dashboard.js Normal file
View File

@@ -0,0 +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);

314
js/inquiries.js Normal file
View File

@@ -0,0 +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);

312
js/mail.js Normal file
View File

@@ -0,0 +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');
});

6
package-lock.json generated Normal file
View File

@@ -0,0 +1,6 @@
{
"name": "AICodeTest",
"lockfileVersion": 3,
"requires": true,
"packages": {}
}

View File

@@ -2,3 +2,4 @@ fastapi==0.110.0
uvicorn==0.29.0 uvicorn==0.29.0
playwright==1.42.0 playwright==1.42.0
python-dotenv==1.0.1 python-dotenv==1.0.1
pypdf==4.1.0

360
server.py Normal file
View File

@@ -0,0 +1,360 @@
import os
import sys
import re
import asyncio
import pymysql
from datetime import datetime
from pydantic import BaseModel
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 sql_queries import InquiryQueries, DashboardQueries
# --- 환경 설정 ---
os.environ["PYTHONIOENCODING"] = "utf-8"
# Tesseract 경로는 환경에 따라 다를 수 있으므로 환경변수 우선 사용 권장
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=["*"],
)
# --- 데이터 모델 ---
class AuthRequest(BaseModel):
user_id: str
password: str
# --- 유틸리티 함수 ---
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})
class InquiryReplyRequest(BaseModel):
reply: str
status: str
handler: str
# --- 문의사항 API ---
@app.get("/api/inquiries")
async def get_inquiries(pm_type: str = None, category: str = None, status: str = None, keyword: str = None):
# ... (existing code)
try:
with get_db_connection() as conn:
with conn.cursor() as cursor:
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()
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:
cursor.execute(InquiryQueries.SELECT_BY_ID, (id,))
return cursor.fetchone()
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:
handled_date = datetime.now().strftime("%Y.%m.%d")
cursor.execute(InquiryQueries.UPDATE_REPLY, (req.reply, req.status, req.handler, handled_date, id))
conn.commit()
return {"success": True}
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:
cursor.execute(InquiryQueries.DELETE_REPLY, (id,))
conn.commit()
return {"success": True}
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:
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']]
except Exception as e:
return {"error": str(e)}
@app.get("/project-data")
async def get_project_data(date: str = None):
"""특정 날짜의 프로젝트 정보 JOIN 반환"""
try:
target_date = date.replace(".", "-") if date and date != "-" else None
with get_db_connection() as conn:
with conn.cursor() as cursor:
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}
except Exception as e:
return {"error": str(e)}
@app.get("/project-activity")
async def get_project_activity(date: str = None):
"""활성도 분석 API"""
try:
with get_db_connection() as conn:
with conn.cursor() as cursor:
if not date or date == "-":
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.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:
log, files = r['recent_log'], r['file_count']
status, days = "unknown", 999
# 파일 수 정수 변환 (데이터가 없거나 0이면 0)
file_val = int(files) if files else 0
has_log = log and log != "데이터 없음" and log != "X"
if file_val == 0:
# [핵심] 파일이 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:
diff = (target_date_dt - datetime.strptime(match.group(0), "%Y.%m.%d")).days
status = "active" if diff <= 7 else "warning" if diff <= 14 else "stale"
days = diff
else:
status = "stale"
else:
# 파일은 있지만 로그가 없는 경우
status = "stale"
analysis["summary"][status] += 1
analysis["details"].append({"name": r['short_nm'] or r['project_nm'], "status": status, "days_ago": days})
return analysis
except Exception as e:
return {"error": str(e)}
@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}
@app.get("/api/analysis/p-war")
async def get_p_war_analysis():
"""P-WAR(Project Performance Above Replacement) 분석 API - 실제 평균 기반"""
try:
with get_db_connection() as conn:
with conn.cursor() as cursor:
cursor.execute(DashboardQueries.GET_LAST_CRAWL_DATE)
last_date = cursor.fetchone()['last_date']
cursor.execute(DashboardQueries.GET_PROJECT_LIST, (last_date,))
projects = cursor.fetchall()
cursor.execute("SELECT project_nm, COUNT(*) as cnt FROM inquiries WHERE status != '완료' GROUP BY project_nm")
inquiry_risks = {row['project_nm']: row['cnt'] for row in cursor.fetchall()}
import math
temp_data = []
total_files = 0
total_stagnant = 0
total_risk = 0
count = len(projects)
if count == 0: return []
# 1. 1차 순회: 전체 합계 계산 (평균 산출용)
for p in projects:
file_count = int(p['file_count']) if p['file_count'] else 0
log = p['recent_log']
days_stagnant = 10
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
risk_count = inquiry_risks.get(p['project_nm'], 0)
total_files += file_count
total_stagnant += days_stagnant
total_risk += risk_count
temp_data.append((p, file_count, days_stagnant, risk_count))
# 2. 시스템 실제 평균(Mean) 산출
avg_files = total_files / count
avg_stagnant = 5 # 사용자 요청에 따라 방치 기준을 5일로 강제 고정 (엄격한 판정)
avg_risk = total_risk / count
# 3. 평균 수준의 프로젝트 가치(V_avg) 정의
v_rep = ( (1 / (1 + avg_stagnant)) * math.log10(avg_files + 1) ) - (avg_risk * 0.5)
results = []
# 4. 2차 순회: P-WAR 산출 (개별 가치 - 평균 가치)
for p, f_cnt, d_stg, r_cnt in temp_data:
name = p['short_nm'] or p['project_nm']
log = p['recent_log'] or ""
is_auto_delete = "폴더자동삭제" in log.replace(" ", "")
activity_factor = 1 / (1 + d_stg)
scale_factor = math.log10(f_cnt + 1)
v_project = (activity_factor * scale_factor) - (r_cnt * 0.5)
# [추가] 폴더 자동 삭제 페널티 부여 (실질적 관리 부재)
if is_auto_delete:
v_project -= 1.5
p_war = v_project - v_rep
results.append({
"project_nm": name,
"file_count": f_cnt,
"days_stagnant": d_stg,
"risk_count": r_cnt,
"p_war": round(p_war, 3),
"is_auto_delete": is_auto_delete,
"master": p['master'],
"dept": p['department'],
"avg_info": {
"avg_files": round(avg_files, 1),
"avg_stagnant": round(avg_stagnant, 1),
"avg_risk": round(avg_risk, 2)
}
})
results.sort(key=lambda x: x['p_war'])
return results
except Exception as e:
return {"error": str(e)}
@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")

67
sql_queries.py Normal file
View File

@@ -0,0 +1,67 @@
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, 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)
"""

301
style/analysis.css Normal file
View File

@@ -0,0 +1,301 @@
/* Analysis Page Styles */
.analysis-content {
padding: 24px;
max-width: 1400px;
margin: var(--topbar-h, 36px) auto 0;
}
.analysis-header {
display: flex;
justify-content: space-between;
align-items: flex-end;
padding: 10px 0 30px 0;
margin-bottom: 10px;
}
.ai-badge {
display: inline-block;
padding: 4px 12px;
border-radius: 20px;
background: var(--ai-color, linear-gradient(135deg, #6366f1 0%, #a855f7 100%));
color: #fff;
font-size: 11px;
font-weight: 700;
margin-bottom: 10px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.analysis-header h2 { font-size: 24px; font-weight: 800; color: #111; margin: 0; }
.analysis-header p { font-size: 13px; color: #666; margin-top: 6px; }
.btn-refresh {
padding: 10px 20px;
background: #fff;
border: 1px solid #ddd;
border-radius: 8px;
font-size: 13px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
}
.btn-refresh:hover { background: #f8f9fa; border-color: #bbb; }
/* 1. Metrics Grid */
.metrics-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 20px;
margin-bottom: 30px;
}
.metric-card {
background: #fff;
padding: 24px;
border-radius: 16px;
border: 1px solid #eef0f2;
box-shadow: 0 4px 20px rgba(0,0,0,0.04);
display: flex;
flex-direction: column;
gap: 12px;
}
.metric-card .label {
font-size: 12px;
font-weight: 600;
color: #888;
display: flex;
align-items: center;
gap: 5px;
position: relative; /* 툴팁 배치를 위해 추가 */
}
/* 툴팁 스타일 추가 */
.metric-card .label:hover::after {
content: attr(data-tooltip);
position: absolute;
bottom: 100%;
left: 0;
width: 220px;
padding: 12px;
background: #1e293b;
color: #fff;
font-size: 11px;
font-weight: 400;
line-height: 1.5;
border-radius: 8px;
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1);
z-index: 10;
margin-bottom: 10px;
pointer-events: none;
white-space: normal;
}
.metric-card .label:hover::before {
content: '';
position: absolute;
bottom: 100%;
left: 20px;
border: 6px solid transparent;
border-top-color: #1e293b;
margin-bottom: -2px;
z-index: 10;
}
.info-icon { width: 14px; height: 14px; border-radius: 50%; background: #eee; display: inline-flex; align-items: center; justify-content: center; font-size: 9px; cursor: help; font-style: normal; }
.metric-card .value { font-size: 32px; font-weight: 800; color: #1e5149; margin: 0; }
.trend { font-size: 11px; font-weight: 700; }
.trend.up { color: #d32f2f; }
.trend.down { color: #1976d2; }
.trend.steady { color: #666; }
/* 2. Main Grid Layout */
.analysis-main-grid {
display: grid;
grid-template-columns: 2fr 1fr;
gap: 24px;
margin-bottom: 24px;
}
.analysis-card {
background: #fff;
border-radius: 16px;
border: 1px solid #eef2f6;
box-shadow: 0 4px 20px rgba(0,0,0,0.04);
overflow: hidden;
}
.card-header {
padding: 20px 24px;
border-bottom: 1px solid #f1f5f9;
display: flex;
justify-content: space-between;
align-items: center;
}
.card-header h4 { margin: 0; font-size: 15px; font-weight: 700; color: #334155; }
.card-body { padding: 24px; }
/* 테이블 스크롤 래퍼 */
.table-scroll-wrapper {
max-height: 600px;
overflow-y: auto;
border-radius: 8px;
border: 1px solid #eef2f6;
}
/* 스크롤바 커스텀 */
.table-scroll-wrapper::-webkit-scrollbar { width: 6px; }
.table-scroll-wrapper::-webkit-scrollbar-track { background: #f8fafc; }
.table-scroll-wrapper::-webkit-scrollbar-thumb { background: #cbd5e1; border-radius: 3px; }
.table-scroll-wrapper::-webkit-scrollbar-thumb:hover { background: #94a3b8; }
.chart-placeholder {
height: 300px;
background: #f8fafc;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
color: #94a3b8;
border: 1px dashed #e2e8f0;
}
/* D-WAR 테이블 스타일 추가 */
.d-war-table { width: 100%; border-radius: 12px; overflow: hidden; }
.d-war-table th { background: #f1f5f9; color: #475569; font-size: 11px; padding: 12px; }
.d-war-table td { padding: 14px 12px; border-bottom: 1px solid #f1f5f9; }
.d-war-value { font-weight: 800; color: #1e5149; text-align: center; font-size: 15px; }
.p-war-value { font-weight: 800; text-align: center; font-size: 15px; }
.text-plus { color: #1d4ed8; }
.text-minus { color: #dc2626; }
/* 관리 상태 배지 스타일 */
.badge-system {
display: inline-block;
padding: 4px 10px;
background: #450a0a;
color: #fecaca;
border: 1px solid #7f1d1d;
font-size: 11px;
font-weight: 800;
border-radius: 6px;
white-space: nowrap;
}
.badge-active {
display: inline-block;
padding: 4px 10px;
background: #f0fdf4;
color: #166534;
border: 1px solid #dcfce7;
font-size: 11px;
font-weight: 700;
border-radius: 6px;
white-space: nowrap;
}
.badge-warning {
display: inline-block;
padding: 4px 10px;
background: #fffbeb;
color: #92400e;
border: 1px solid #fef3c7;
font-size: 11px;
font-weight: 700;
border-radius: 6px;
white-space: nowrap;
}
.badge-danger {
display: inline-block;
padding: 4px 10px;
background: #fef2f2;
color: #991b1b;
border: 1px solid #fee2e2;
font-size: 11px;
font-weight: 700;
border-radius: 6px;
white-space: nowrap;
}
/* 행 강조 스타일 수정 */
.row-danger { background: #fff1f2 !important; }
.row-warning { background: #fffaf0 !important; }
.row-success { background: #f0fdf4 !important; }
.font-bold { font-weight: 700; }
/* P-WAR 가이드 스타일 */
.d-war-guide {
display: flex;
gap: 20px;
margin-bottom: 20px;
padding: 12px 20px;
background: #f8fafc;
border-radius: 8px;
border: 1px solid #e2e8f0;
}
.guide-item {
font-size: 12px;
font-weight: 600;
color: #64748b;
display: flex;
align-items: center;
gap: 8px;
}
.guide-item span {
padding: 2px 8px;
border-radius: 4px;
font-size: 11px;
color: #fff;
}
.active-low span { background: #2563eb; }
.warning-mid span { background: #22c55e; }
.danger-high span { background: #f59e0b; }
.hazard-critical span { background: #ef4444; }
/* 3. Risk Signal List */
.risk-signal-list { display: flex; flex-direction: column; gap: 12px; }
.risk-item {
padding: 16px;
border-radius: 12px;
display: grid;
grid-template-columns: 1fr 40px;
gap: 4px;
position: relative;
}
.risk-project { font-size: 13px; font-weight: 700; color: #1e293b; }
.risk-reason { font-size: 11px; color: #64748b; margin-top: 4px; }
.risk-status {
grid-row: span 2;
display: flex;
align-items: center;
justify-content: center;
font-size: 11px;
font-weight: 800;
border-radius: 8px;
}
.risk-item.high { background: #fff1f2; border-left: 4px solid #f43f5e; }
.risk-item.high .risk-status { color: #f43f5e; }
.risk-item.warning { background: #fffbeb; border-left: 4px solid #f59e0b; }
.risk-item.warning .risk-status { color: #f59e0b; }
.risk-item.safe { background: #f0fdf4; border-left: 4px solid #22c55e; }
.risk-item.safe .risk-status { color: #22c55e; }
/* 4. Factor Section */
.factor-grid { display: flex; flex-direction: column; gap: 16px; }
.factor-item { display: grid; grid-template-columns: 200px 1fr 60px; align-items: center; gap: 20px; }
.factor-name { font-size: 13px; font-weight: 600; color: #475569; }
.factor-bar-wrapper { height: 8px; background: #f1f5f9; border-radius: 4px; overflow: hidden; }
.factor-bar { height: 100%; background: var(--ai-color, #6366f1); border-radius: 4px; }
.factor-value { font-size: 12px; font-weight: 700; color: #1e5149; text-align: right; }

169
style/common.css Normal file
View File

@@ -0,0 +1,169 @@
: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;
}
.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); } }

123
style/dashboard.css Normal file
View File

@@ -0,0 +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; border-left-color: #4DB251; }
.activity-card.warning { background: #fff8e1; border-left-color: #FFBF00; }
.activity-card.stale { background: #ffebee; border-left-color: var(--error-color); }
.activity-card.unknown { background: #f5f5f5; border-left-color: #9e9e9e; }
.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);
border-left: 3px solid var(--primary-color); 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); }

252
style/inquiries.css Normal file
View File

@@ -0,0 +1,252 @@
/* 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 { border-top: 3px solid #1e5149; }
.stat-item.total .stat-value { color: #1e5149; }
.stat-item.complete { border-top: 3px solid #2e7d32; }
.stat-item.complete .stat-value { color: #2e7d32; }
.stat-item.working { border-top: 3px solid #1565c0; }
.stat-item.working .stat-value { color: #1565c0; }
.stat-item.checking { border-top: 3px solid #ef6c00; }
.stat-item.checking .stat-value { color: #ef6c00; }
.stat-item.pending { border-top: 3px solid #673ab7; }
.stat-item.pending .stat-value { color: #673ab7; }
.stat-item.unconfirmed { border-top: 3px solid #9e9e9e; }
.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;
border-left: 6px solid #1e5149;
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; border-left: 5px solid #1e5149; }
/* 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); }

219
style/mail.css Normal file
View File

@@ -0,0 +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); border-left: 4px solid var(--primary-color); }
.mail-item-checkbox { width: 16px; height: 16px; cursor: pointer; margin-right: 12px; margin-top: 2px; }
.mail-item-content { flex: 1; min-width: 0; }
.mail-item-info { display: flex; align-items: center; gap: 12px; margin-bottom: 4px; }
.mail-date { font-size: 11px; color: var(--text-sub); white-space: nowrap; }
.btn-mail-delete {
background: #f7fafc; border: 1px solid var(--border-color); color: #718096;
font-size: 10px; padding: 2px 8px; border-radius: 4px; font-weight: 600;
}
.btn-mail-delete:hover { color: var(--error-color); background: #fff5f5; border-color: #feb2b2; }
/* 3. Content Area */
.mail-content-area { flex: 1; display: flex; flex-direction: column; overflow-y: auto; border-right: 1px solid var(--border-color); }
.mail-content-header { padding: var(--space-lg); border-bottom: 1px solid var(--border-color); }
.mail-body { padding: var(--space-lg); line-height: 1.6; min-height: 200px; }
/* 4. Attachments & AI */
.attachment-area { padding: var(--space-lg); border-top: 1px solid var(--border-color); background: var(--bg-muted); }
.attachment-item {
display: flex; align-items: center; gap: var(--space-md); background: #fff;
padding: 12px 20px; border-radius: var(--radius-lg);
border: 1px solid var(--border-color); margin-bottom: var(--space-sm); cursor: pointer;
transition: 0.2s;
}
.attachment-item:hover { border-color: var(--primary-color); box-shadow: var(--box-shadow); }
.attachment-item.active { background: var(--primary-lv-0); border-color: var(--primary-color); }
.file-details { flex: 1; min-width: 0; }
.file-name { font-size: 13px; font-weight: 700; max-width: 450px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.file-size { font-size: 11px; color: var(--text-sub); }
.btn-group {
display: flex; align-items: center; gap: 12px; flex-shrink: 0; justify-content: flex-end;
}
.btn-upload {
padding: 6px 14px; border-radius: 6px; font-size: 11px; font-weight: 700;
color: #fff; border: none; cursor: pointer; transition: 0.2s; height: 32px;
}
.btn-ai { background: var(--ai-gradient); }
.btn-ai:hover { filter: brightness(1.1); transform: translateY(-1px); }
.btn-normal { background: var(--primary-color); }
.btn-normal:hover { background: var(--primary-hover); transform: translateY(-1px); }
.ai-recommend {
font-size: 11px; padding: 6px 12px; border-radius: 6px; font-weight: 600;
cursor: pointer; transition: 0.2s; display: inline-block;
max-width: 250px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.ai-recommend.smart-mode { background: #eef2ff; color: #4338ca; border: 1px solid #c7d2fe; }
.ai-recommend.manual-mode { background: #f1f5f9; color: #475569; border: 1px dashed #cbd5e1; }
.ai-recommend:hover { transform: scale(1.02); }
/* 5. Preview Area */
.mail-preview-area {
width: 0; background: #f8f9fa; display: flex; flex-direction: column;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); position: relative;
border-left: 0 solid transparent;
}
.mail-preview-area.active {
width: 600px;
border-left: 1px solid var(--border-color);
visibility: visible;
}
.preview-header {
height: 56px; padding: 0 24px; border-bottom: 1px solid var(--border-color);
display: flex; align-items: center; justify-content: space-between;
background: #fff; flex-shrink: 0;
}
.preview-header h3 { font-size: 15px; font-weight: 800; color: var(--primary-color); margin: 0; }
#fullViewBtn {
background: var(--primary-lv-0) !important;
color: var(--primary-color) !important;
border: 1px solid var(--primary-lv-1) !important;
font-weight: 700 !important;
padding: 4px 16px !important;
border-radius: 4px !important;
font-size: 11px !important;
transition: 0.2s !important;
}
#fullViewBtn:hover { background: var(--primary-lv-1) !important; }
.preview-toggle-handle {
position: absolute; left: -20px; top: 50%; transform: translateY(-50%);
width: 20px; height: 60px; background: var(--primary-color); color: #fff;
display: flex; align-items: center; justify-content: center;
border-radius: 8px 0 0 8px; font-size: 10px; cursor: pointer;
box-shadow: -2px 0 5px rgba(0,0,0,0.1); z-index: 100;
}
.preview-toggle-handle:hover { background: var(--primary-hover); }
.a4-container {
flex: 1; padding: 30px; overflow-y: auto; background: #e9ecef;
display: flex; justify-content: center;
}
.a4-container iframe, .a4-container .preview-placeholder {
width: 100%; height: 100%; background: #fff;
box-shadow: 0 4px 20px rgba(0,0,0,0.08); border-radius: 4px;
}
.preview-placeholder {
display: flex; align-items: center; justify-content: center;
text-align: center; color: var(--text-sub); font-size: 13px; line-height: 1.6;
}
.mail-preview-area.active > * { opacity: 1 !important; visibility: visible !important; pointer-events: auto !important; }
.mail-preview-area > *:not(.preview-toggle-handle) { opacity: 0; visibility: hidden; pointer-events: none; transition: 0.2s; }
/* 6. Footer & Others */
.address-book-footer {
position: absolute; bottom: 0; left: 0; width: 100%; padding: 12px 16px;
border-top: 1px solid var(--border-color); background: #fff; display: flex; gap: 8px; z-index: 5;
}
.file-log-area {
display: none; width: 100%; margin-top: 10px; background: #1a202c;
border-radius: 4px; padding: 12px; font-family: monospace; font-size: 11px; color: #cbd5e0;
}
.file-log-area.active { display: block; }
.log-success { color: #48bb78; font-weight: 700; }
.switch { position: relative; display: inline-block; width: 34px; height: 20px; }
.switch input { opacity: 0; width: 0; height: 0; }
.slider {
position: absolute; cursor: pointer; top: 0; left: 0; right: 0; bottom: 0;
background-color: #ccc; transition: .4s; border-radius: 20px;
}
.slider:before {
position: absolute; content: ""; height: 14px; width: 14px; left: 3px; bottom: 3px;
background-color: white; transition: .4s; border-radius: 50%;
}
input:checked+.slider { background: var(--ai-gradient); }
input:checked+.slider:before { transform: translateX(14px); }
/* Restore Path Selector Modal Specific Styles */
.select-group {
display: flex;
flex-direction: column;
gap: 8px;
}
.select-group label {
font-size: 12px;
font-weight: 700;
color: var(--text-main);
}
.modal-select {
width: 100%;
height: 44px;
padding: 0 15px;
border: 1px solid var(--border-color);
border-radius: 8px;
background-color: #f9f9f9;
font-size: 14px;
color: #333;
outline: none;
transition: all 0.2s;
cursor: pointer;
appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%23666' d='M6 8L2 4h8L6 8z'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 15px center;
}
.modal-select:focus {
border-color: var(--primary-color);
background-color: #fff;
box-shadow: 0 0 0 3px rgba(30, 81, 73, 0.1);
}
.modal-select option {
padding: 10px;
}

View File

@@ -1,412 +1,3 @@
:root { @import url('common.css');
--primary-color: #1E5149; @import url('dashboard.css');
--bg-color: #FFFFFF; @import url('mail.css');
--text-main: #222222;
--text-sub: #666666;
--border-color: #E5E7EB;
/* 매우 연한 회색 라인 */
--hover-bg: #F9FAFB;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Pretendard', sans-serif;
font-size: 13px;
color: var(--text-main);
background-color: var(--bg-color);
display: flex;
min-height: 100vh;
}
/* Topbar */
.topbar {
width: 100%;
background-color: #1E5149;
/* sample.png 탑바 다크 슬레이트 배경색 */
color: #FFFFFF;
padding: 0 1.5rem;
position: fixed;
top: 0;
left: 0;
height: 36px;
display: flex;
align-items: center;
z-index: 100;
}
.topbar-header {
margin-right: 2.5rem;
}
.topbar-header h2 {
font-size: 15px;
font-weight: 600;
letter-spacing: -0.3px;
}
.nav-list {
list-style: none;
display: flex;
align-items: center;
height: 100%;
}
.nav-item {
padding: 0 1rem;
height: 28px;
border-radius: 4px;
margin: 0 2px;
cursor: pointer;
transition: all 0.2s;
color: rgba(255, 255, 255, 0.7);
display: flex;
justify-content: center;
align-items: center;
gap: 6px;
font-size: 13px;
}
.nav-item:hover,
.nav-item.active {
background-color: #E9EEED;
color: #1E5149;
font-weight: 500;
}
/* Main Content */
.main-content {
margin-top: 36px;
flex: 1;
padding: 2rem 2.5rem;
width: 100%;
max-width: 1400px;
margin-left: auto;
margin-right: auto;
}
header {
margin-bottom: 2rem;
display: flex;
justify-content: space-between;
align-items: flex-end;
padding-bottom: 0.8rem;
border-bottom: 1px solid var(--border-color);
/* 선 굵기와 색상 얇게 */
}
header h1 {
font-size: 18px;
font-weight: 700;
color: var(--primary-color);
}
.admin-info {
color: var(--text-sub);
font-size: 12px;
}
/* Multi-level Accordion (Minimalist/No Box Design) */
.continent-group {
margin-bottom: 3rem;
}
.continent-header {
color: var(--text-main);
padding: 0.5rem 0;
font-size: 16px;
font-weight: 700;
cursor: pointer;
display: flex;
justify-content: flex-start;
align-items: center;
border-bottom: 2px solid var(--text-main);
margin-bottom: 1rem;
}
.continent-body {
display: none;
}
.continent-group.active > .continent-body {
display: block;
}
.country-group {
margin-bottom: 2rem;
padding-bottom: 2rem;
border-bottom: 1px dashed var(--border-color); /* 국가 사이 구분선 */
}
.country-group:last-child {
margin-bottom: 1rem;
padding-bottom: 0;
border-bottom: none;
}
.country-header {
color: var(--primary-color);
padding: 0.5rem 0;
font-size: 14px;
font-weight: 700;
cursor: pointer;
display: flex;
justify-content: flex-start;
align-items: center;
margin-bottom: 0.5rem;
}
.country-body {
display: none;
padding-left: 0.5rem; /* Slight indent instead of borders */
}
.country-group.active > .country-body {
display: block;
}
.toggle-icon {
font-size: 10px;
margin-left: 8px;
color: #999;
}
/* Accordion Styles (Projects - Row Based) */
.accordion-container {
display: flex;
flex-direction: column;
width: 100%;
}
.accordion-item {
border-bottom: 1px solid var(--border-color);
}
.accordion-item:last-child {
border-bottom: none;
}
.accordion-header {
display: grid;
grid-template-columns: 2.5fr 1fr 1fr 1fr 1fr 2fr;
gap: 1rem;
padding: 1rem 0;
cursor: pointer;
align-items: center;
background-color: transparent;
transition: opacity 0.2s;
}
.accordion-item:hover .accordion-header {
opacity: 0.7;
}
.accordion-item.active .accordion-header {
/* No border-bottom or background change for active */
}
.accordion-header > div {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.header-label {
font-size: 11px;
color: var(--text-sub);
margin-bottom: 3px;
display: block;
font-weight: 400;
}
.header-value {
font-weight: 500;
font-size: 13px;
color: var(--text-main);
}
.accordion-body {
display: none;
padding: 1.5rem 0;
background-color: transparent;
}
.accordion-item.active .accordion-body {
display: block;
}
.detail-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 3rem;
}
.detail-section h4 {
margin-bottom: 1rem;
color: var(--text-main);
font-size: 13px;
font-weight: 600;
border-bottom: 1px solid var(--border-color);
padding-bottom: 0.5rem;
}
/* Table Styles - Super Minimal Line Style */
.data-table {
width: 100%;
border-collapse: collapse;
}
.data-table th,
.data-table td {
padding: 8px 4px;
border-bottom: 1px solid var(--border-color);
text-align: left;
}
.data-table th {
color: var(--text-sub);
font-weight: 400;
font-size: 12px;
}
.data-table td {
font-size: 12px;
color: var(--text-main);
}
.data-table tr:last-child td {
border-bottom: none;
}
/* General Utilities */
.badge {
background: #EEEEEE;
color: #555555;
padding: 2px 6px;
border-radius: 2px;
/* 라운드 거의 없앰 */
font-size: 11px;
font-weight: 500;
}
.status-up {
color: #D32F2F;
font-weight: 500;
}
.status-down {
color: #1976D2;
font-weight: 500;
}
/* Sync Button */
.sync-btn {
background-color: var(--primary-color);
color: #FFFFFF;
border: 1px solid var(--primary-color);
padding: 6px 14px;
border-radius: 4px;
font-size: 12px;
font-weight: 600;
cursor: pointer;
transition: background-color 0.2s, opacity 0.2s;
margin-right: 1rem;
display: flex;
align-items: center;
gap: 6px;
}
.sync-btn:hover {
background-color: #153A34;
}
.sync-btn:disabled {
background-color: #A0B2AF;
border-color: #A0B2AF;
cursor: not-allowed;
}
/* Spinner */
.spinner {
display: none;
width: 12px;
height: 12px;
border: 2px solid rgba(255,255,255,0.3);
border-radius: 50%;
border-top-color: #fff;
animation: spin 1s ease-in-out infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.sync-btn.loading .spinner {
display: inline-block;
}
/* --- Responsive Design --- */
@media screen and (max-width: 1024px) {
.accordion-header {
grid-template-columns: 2fr 1fr 1fr 1fr 1fr 1.5fr;
}
}
@media screen and (max-width: 768px) {
.topbar {
overflow-x: auto;
white-space: nowrap;
padding: 0 1rem;
}
/* 스크롤바 숨김 */
.topbar::-webkit-scrollbar {
display: none;
}
.main-content {
padding: 1.5rem 1rem;
}
header {
flex-direction: column;
align-items: flex-start;
gap: 0.5rem;
}
.detail-grid {
grid-template-columns: 1fr;
gap: 1.5rem;
}
/* 모바일에서 아코디언 헤더를 다단으로 배치 */
.accordion-header {
grid-template-columns: 1fr 1fr;
row-gap: 1rem;
}
/* 프로젝트 명과 최근 로그는 공간을 넓게 쓰도록 설정 */
.accordion-header > div:nth-child(1),
.accordion-header > div:nth-child(6) {
grid-column: span 2;
}
.continent-header {
font-size: 15px;
}
}
@media screen and (max-width: 480px) {
/* 아주 작은 화면에서는 1열로 배치 */
.accordion-header {
grid-template-columns: 1fr;
}
.accordion-header > div {
grid-column: span 1 !important;
}
.topbar-header h2 {
font-size: 13px;
margin-right: 1rem;
}
.nav-item {
padding: 0 0.5rem;
font-size: 12px;
}
}

136
templates/analysis.html Normal file
View File

@@ -0,0 +1,136 @@
<!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" 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/analysis.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" 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-refresh" onclick="location.reload()">데이터 갱신</button>
</div>
</header>
<!-- 핵심 세이버메트릭스 지표 요약 -->
<section class="metrics-grid">
<div class="metric-card sra">
<div class="metric-info">
<span class="label" data-tooltip="Avg. P-WAR Score: 시스템 내 모든 프로젝트의 평균 기여도입니다. 양수(+)가 높을수록 시스템이 활발하게 운영되고 있음을 의미합니다.">평균 P-WAR (기여도) <i class="info-icon">?</i></span>
<h3 class="value">0.00</h3>
<span class="trend up">대체 수준(0.0) 대비</span>
</div>
<div class="metric-chart-mini" id="sraChart"></div>
</div>
<div class="metric-card iwar">
<div class="metric-info">
<span class="label" data-tooltip="Total Pending Risks: 현재 해결되지 않고 방치된 문의사항의 총합입니다. P-WAR 감점 요인입니다.">미결 리스크 총합 <i class="info-icon">?</i></span>
<h3 class="value">0</h3>
<span class="trend steady">실시간 집계</span>
</div>
<div class="metric-chart-mini" id="iwarChart"></div>
</div>
<div class="metric-card piso">
<div class="metric-info">
<span class="label" data-tooltip="Active Resource Scale: P-WAR가 양수(+)인 활성 프로젝트들이 관리 중인 총 파일 규모입니다.">활성 자원 규모 <i class="info-icon">?</i></span>
<h3 class="value">0</h3>
<span class="trend up">시스템 기여 자원</span>
</div>
<div class="metric-chart-mini" id="pisoChart"></div>
</div>
<div class="metric-card stability">
<div class="metric-info">
<span class="label" data-tooltip="Zombie Project Rate: P-WAR 점수가 -1.0 이하인 '대체 수준 미달' 프로젝트의 비중입니다.">좀비 프로젝트 비율 <i class="info-icon">?</i></span>
<h3 class="value">0%</h3>
<span class="trend steady">집중 관리 대상</span>
</div>
<div class="metric-chart-mini" id="stabilityChart"></div>
</div>
</section>
<!-- 메인 분석 영역 -->
<div class="analysis-main-grid">
<!-- P-WAR 분석 테이블 -->
<div class="analysis-card timeline-analysis">
<div class="card-header">
<div style="display: flex; flex-direction: column; gap: 4px;">
<h4>Project Performance Above Replacement (P-WAR Ranking)</h4>
<p style="font-size: 11px; color: #888; margin: 0;">대체 수준(Replacement Level) 프로젝트 대비 기여도를 측정합니다.</p>
</div>
<div class="card-tools">
<span id="avg-system-info" style="font-size: 11px; color: #888;">* 0.0 = 시스템 평균 계산 중...</span>
</div>
</div>
<div class="card-body">
<!-- P-WAR 판정 가이드 범례 수정 -->
<div class="d-war-guide">
<div class="guide-item active-low"><span>양수(+)</span> 운영 중</div>
<div class="guide-item warning-mid"><span>음수(-)</span> 위험군</div>
<div class="guide-item danger-high"><span>-0.3 이하</span> 방치-삭제대상</div>
<div class="guide-item hazard-critical"><span>시스템삭제</span> 잠김예정 프로젝트</div>
</div>
<div class="chart-placeholder">
<p>R-Engine 시각화 대기 중...</p>
</div>
</div>
</div>
<!-- 위험 신호 및 예측 -->
<div class="analysis-card risk-prediction">
<div class="card-header">
<h4>Deep Learning 기반 장애 예보 (Risk Signal)</h4>
</div>
<div class="card-body">
<div class="risk-signal-list">
<div class="risk-item high">
<div class="risk-project">프로젝트 A (해외/중동)</div>
<div class="risk-reason">파일 급증 대비 활동 정체 (P-ISO 급락)</div>
<div class="risk-status">위험</div>
</div>
<div class="risk-item warning">
<div class="risk-project">프로젝트 B (기술개발)</div>
<div class="risk-reason">특정 환경(IE/Edge) 문의 집중 발생</div>
<div class="risk-status">주의</div>
</div>
<div class="risk-item safe">
<div class="risk-project">프로젝트 C (국내/장헌)</div>
<div class="risk-reason">로그 활동성 및 해결률 안정적 유지</div>
<div class="risk-status">안전</div>
</div>
</div>
</div>
</div>
</div>
</main>
<script src="js/common.js"></script>
<script src="js/analysis.js"></script>
</body>
</html>

118
templates/dashboard.html Normal file
View File

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

46
templates/index.html Normal file
View File

@@ -0,0 +1,46 @@
<!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>
</html>

194
templates/inquiries.html Normal file
View File

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

144
templates/mailTest.html Normal file
View File

@@ -0,0 +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>
</html>

View File

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

View File

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

1229
tokens.json Normal file

File diff suppressed because it is too large Load Diff